Skip to content

feat(payments): collect 0.9% platform fee on every stripe money movement#1378

Open
mantrakp04 wants to merge 10 commits intodevfrom
fix/platform-fee-reconciliation-double-debit
Open

feat(payments): collect 0.9% platform fee on every stripe money movement#1378
mantrakp04 wants to merge 10 commits intodevfrom
fix/platform-fee-reconciliation-double-debit

Conversation

@mantrakp04
Copy link
Copy Markdown
Collaborator

@mantrakp04 mantrakp04 commented Apr 23, 2026

Summary

Charges the platform 0.9% on both legs of each transaction on non-internal projects.

  • Charge leg — rides along via Stripe's native `application_fee_amount` / `application_fee_percent` params on the PaymentIntent / Subscription.
  • Refund leg — Stripe's default reverses our charge-leg fee on refund, netting us zero. We disable that with `refund_application_fee: false`

Test plan

  • `pnpm typecheck` passes
  • `platform-fees.test.ts` — 4 passed, 1 skipped (subscription refund, documented TODO)
  • `transactions-refund.test.ts` — 14 passed
  • Manual real-Stripe QA: charge leg + refund-leg failure path end-to-end (see ledger row captured the Stripe error exactly)
  • Manual real-Stripe QA: inverse transfer happy path via `stripe` CLI against an application-controlled test Connect account, +90¢ round-trip confirmed (2× 0.9% on $50)

Refs

Summary by CodeRabbit

  • New Features

    • Introduced platform fees (0.9%) applied to payment transactions for external projects
  • Improvements

    • Standardized refund handling across payment flows
    • Enhanced fee configuration for subscription switches and one-time purchases to ensure consistent platform fee application

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 23, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
stack-auth-hosted-components Ready Ready Preview, Comment May 5, 2026 1:42am
stack-backend Ready Ready Preview, Comment May 5, 2026 1:42am
stack-dashboard Ready Ready Preview, Comment May 5, 2026 1:42am
stack-demo Ready Ready Preview, Comment May 5, 2026 1:42am
stack-docs Ready Ready Preview, Comment May 5, 2026 1:42am
stack-preview-backend Ready Ready Preview, Comment May 5, 2026 1:42am
stack-preview-dashboard Ready Ready Preview, Comment May 5, 2026 1:42am

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

📝 Walkthrough

Walkthrough

The PR introduces platform fee configuration for Stripe operations (90 bps for non-internal projects), centralizes refund parameter construction with an invariant-enforcing helper, and extracts shared E2E payment test utilities into a reusable module. Three independent feature additions across backend payment flows and test infrastructure.

Changes

Refund Flow Improvements

Layer / File(s) Summary
Refund Helper
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx
New buildStripeRefundParams() helper centralizes stripe.refunds.create() parameter construction, enforcing refund_application_fee: false while passing through payment_intent, amount, and optional metadata.
Refund Integration
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx
Both subscription and one-time purchase refund flows refactored to call buildStripeRefundParams(); one-time path includes refund metadata.
Refund Tests
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx
Vitest coverage validates the fee invariant, parameter propagation, and metadata presence/absence.

Platform Fee Configuration and Integration

Layer / File(s) Summary
Fee Constants & Utilities
apps/backend/src/lib/payments/platform-fees.ts
New module defines APPLICATION_FEE_BPS (90 bps), getApplicationFeeBps(), computeApplicationFeeAmount(), and getApplicationFeePercentOrUndefined(). Internal projects return 0 bps; others return 90 bps. Amount calculation uses integer math with rounding.
Fee Module Tests
apps/backend/src/lib/payments/platform-fees.ts
Vitest suite covers internal vs. external project behavior, rounding on representative amounts, clip-to-zero threshold near half-cent, and negative input validation.
Subscription Switching
apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts
Imports and applies getApplicationFeePercentOrUndefined() to conditionally include application_fee_percent in both existing subscription updates and deprecated include-by-default subscription creation.
Purchase Session
apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx
Computes and conditionally applies application_fee_percent (subscription/upgrade paths) and application_fee_amount (one-time payment path) to Stripe requests.

E2E Payment Test Helpers

Layer / File(s) Summary
Shared Helper Module
apps/e2e/tests/backend/helpers/payments.ts
New module exports createDefaultPaymentsConfig(), setupProjectWithPaymentsConfig(), createPurchaseCode(), createTestModeTransaction(), and createLiveModeOneTimePurchaseTransaction() for reuse across E2E tests.
Test Consolidation
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts
Refactored to import shared payment helpers instead of defining local payment-config and transaction-helper functions; existing test logic unchanged.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

The PR spans three independent feature areas (refund helpers, platform fee infrastructure, and E2E test extraction) with consistent patterns across routes. New module (platform-fees.ts) has clear logic with comprehensive tests. Refund parameter helper and integration are straightforward. E2E helper extraction is pattern-driven but requires verifying test compatibility. Scope is moderate with good test coverage throughout.

🐰 Refunds now dance with fees contained,
Platform rates are now maintained,
Test helpers shared, no code ordained,
Three features joined, all three champained! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: implementing collection of a 0.9% platform fee on Stripe transactions.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description provides a clear summary of changes, test plan with checkmarks, and relevant references.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/platform-fee-reconciliation-double-debit

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 23, 2026

Greptile Summary

This PR adds a 0.9% platform fee to all non-internal Stripe money movements by attaching application_fee_amount to one-time PaymentIntents and application_fee_percent to subscriptions across three charge paths (purchase-session, switch, refund). Refunds retain the charge-leg fee by always passing refund_application_fee: false. A platform-fees.ts helper module is introduced with integer BPS math, well-tested rounding, and tests co-located via import.meta.vitest.

Note: The PR description still references PlatformFeeEvent ledger rows, inverse Connect transfers, transfer_group reconciliation, retry logic, debit_negative_balances, and an admin /platform-fees endpoint — all of which were removed by the refactor: nix charges on refund commit (02a18c07). Readers relying on the description to understand the final scope will be misled; consider updating it to reflect the simpler current state.

Confidence Score: 5/5

Safe to merge — no P0 or P1 findings; the fee application logic is correct, well-tested, and consistently applied across all charge paths.

All three charge paths (OTP, subscription create, subscription switch) correctly attach fee params; refund path unconditionally sets refund_application_fee: false which is safe even for pre-fee or internal-project payments. Integer BPS math avoids IEEE-754 accumulation. In-file unit tests pin all edge cases. Only finding is P2: the PR description is stale relative to the scope reduction in 02a18c0.

No files require special attention.

Important Files Changed

Filename Overview
apps/backend/src/lib/payments/platform-fees.ts New helper module: BPS constants, integer-safe computeApplicationFeeAmount, and getApplicationFeePercentOrUndefined. Well-documented, all edge cases pinned with in-file tests.
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx Centralises refund creation behind buildStripeRefundParams which always sets refund_application_fee: false; both subscription and OTP paths updated; in-file unit tests added.
apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx Adds application_fee_amount to OTP PaymentIntent creation and application_fee_percent to subscription create/update paths; correct guards prevent attaching zero-fee params.
apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts Adds application_fee_percent to both the active update path and the deprecated create path; correctly documented that plan-switching attaches fees going forward.
apps/e2e/tests/backend/helpers/payments.ts New shared test helper extracted from transactions-refund.test.ts; event/PI IDs renamed from otp_refund to otp_purchase for accuracy; uses getEnvVariable instead of raw process.env.
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts Refactored to import helpers from the new shared module; no logic changes, just cleanup.

Reviews (3): Last reviewed commit: "chore: document float division" | Re-trigger Greptile

Comment thread apps/backend/src/lib/payments/platform-fees.ts Outdated
@mantrakp04 mantrakp04 force-pushed the payment-subscription-handling-rework branch from f8533a8 to 7e6492c Compare April 23, 2026 20:28
@mantrakp04 mantrakp04 changed the title fix(payments): don't fall through to transfers.create when reconciliation finds existing transfer feat(payments): collect 0.9% platform fee on every stripe money movement Apr 23, 2026
@mantrakp04
Copy link
Copy Markdown
Collaborator Author

@greptile-ai review

@mantrakp04
Copy link
Copy Markdown
Collaborator Author

@greptile-ai review

@nams1570
Copy link
Copy Markdown
Collaborator

nams1570 commented May 4, 2026

@greptileai refresh and do a deep re review

@nams1570
Copy link
Copy Markdown
Collaborator

nams1570 commented May 4, 2026

@greptile-ai review

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx (1)

96-110: TODO is well-documented but may need tracking.

The comment correctly identifies uncertainty about whether Stripe's immediately-generated proration invoice inherits application_fee_percent. This is a potential revenue gap on mid-cycle plan switches until verified or webhook-based stamping is implemented.

Consider opening an issue to track this follow-up verification against a real Connect account.

Would you like me to open an issue to track verifying the proration invoice fee behavior?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx`
around lines 96 - 110, Create a tracking issue for the TODO about Stripe
proration invoice behavior: document the uncertainty, include the relevant code
reference (the TODO comment around getApplicationFeePercentOrUndefined and the
applicationFeePercent variable in route.tsx), add steps to verify against a real
Connect account and to implement webhook-based stamping of
invoice.application_fee_amount if needed, and link the Stripe docs mentioned
(connect/subscriptions and billing/subscriptions/prorations) plus any test/QA
steps and acceptance criteria for closing the issue.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx`:
- Around line 96-110: Create a tracking issue for the TODO about Stripe
proration invoice behavior: document the uncertainty, include the relevant code
reference (the TODO comment around getApplicationFeePercentOrUndefined and the
applicationFeePercent variable in route.tsx), add steps to verify against a real
Connect account and to implement webhook-based stamping of
invoice.application_fee_amount if needed, and link the Stripe docs mentioned
(connect/subscriptions and billing/subscriptions/prorations) plus any test/QA
steps and acceptance criteria for closing the issue.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a2a08623-9fd7-48d9-a518-01a29ff2d5b9

📥 Commits

Reviewing files that changed from the base of the PR and between 6b2c70a and a2ea0aa.

📒 Files selected for processing (6)
  • apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx
  • apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts
  • apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx
  • apps/backend/src/lib/payments/platform-fees.ts
  • apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts
  • apps/e2e/tests/backend/helpers/payments.ts

@mantrakp04
Copy link
Copy Markdown
Collaborator Author

@greptile-ai review

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
apps/e2e/tests/backend/helpers/payments.ts (2)

124-124: ⚡ Quick win

Add a comment explaining the any on the tx callback parameter.

Using any without justification violates the project guideline. The transactions body is an untyped API response here, which is a valid reason — just make it explicit.

♻️ Proposed refactor
-  const purchaseTransaction = transactionsRes.body.transactions.find((tx: any) => tx.type === "purchase");
+  // `any` here because `transactionsRes.body` is an untyped raw HTTP response shape
+  // eslint-disable-next-line `@typescript-eslint/no-explicit-any`
+  const purchaseTransaction = transactionsRes.body.transactions.find((tx: any) => tx.type === "purchase");

As per coding guidelines, "Try to avoid the any type. Whenever you need to use any, leave a comment explaining why you're using it."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/e2e/tests/backend/helpers/payments.ts` at line 124, The callback in
transactionsRes.body.transactions.find currently uses an untyped parameter (tx:
any); add a concise inline comment explaining why any is used (e.g.,
"transactionsRes.body is an untyped API response from the test backend, so we
use any here to avoid coupling tests to response types") so it complies with the
guideline; locate the find call where purchaseTransaction is defined and add
that explanatory comment next to the (tx: any) parameter or immediately above
the line.

46-49: ⚡ Quick win

Replace as string type cast with a defensive guard.

as string bypasses the type system; after expect(code).toBeDefined() vitest narrows the runtime value but TypeScript still sees string | undefined. Use the project's throwErr pattern instead.

♻️ Proposed refactor
-  const codeMatch = (res.body.url as string).match(/\/purchase\/([a-z0-9-_]+)/);
-  const code = codeMatch ? codeMatch[1] : undefined;
-  expect(code).toBeDefined();
-  return code as string;
+  const codeMatch = (res.body.url as string).match(/\/purchase\/([a-z0-9_-]+)/);
+  const code = codeMatch?.[1] ?? throwErr("No purchase code found in URL: " + res.body.url);
+  return code;

As per coding guidelines, "Do NOT use as/any/type casts or anything else like that to bypass the type system" and "Prefer ?? throwErr(...) over non-null assertions, with good error messages explicitly stating the assumption that must've been violated."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/e2e/tests/backend/helpers/payments.ts` around lines 46 - 49, The code
currently casts the extracted purchase code with "as string"; instead replace
the cast with the project's defensive throwErr pattern: locate the extraction
using codeMatch and the code variable in the helper (the lines that compute code
from codeMatch and return it) and change the return to use the non-null
coalescing guard—e.g. derive code via codeMatch?.[1] or keep code variable, then
return code ?? throwErr("Expected purchase code in redirect URL from payment
helper")—removing the "as string" cast and keeping the expect assertion as-is.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/backend/src/lib/payments/platform-fees.ts`:
- Around line 3-7: Update the top block comment that currently claims "there is
no separate refund-leg collection" to accurately describe the implemented flow:
state that refunds keep the charge-leg fee via refund_application_fee: false and
that the refund-leg fee is collected separately by performing an inverse Connect
transfer in the refund route (the code that constructs/dispatches the inverse
transfer). Replace the incorrect sentence with a concise note referencing the
refund route's inverse Connect transfer behavior so future readers understand
both the charge-leg retention and the separate refund-leg collection.

In `@apps/e2e/tests/backend/helpers/payments.ts`:
- Around line 86-87: The eventId and paymentIntentId prefixes are incorrect for
a purchase flow: update the constants eventId and paymentIntentId (currently
`evt_otp_refund_${idSuffix}` and `pi_otp_refund_${idSuffix}`) to use
purchase/charge semantics (e.g., `evt_otp_purchase_${idSuffix}` and
`pi_otp_purchase_${idSuffix}` or `evt_otp_charge_${idSuffix}` /
`pi_otp_charge_${idSuffix}`) so the generated `payment_intent.succeeded` test
event and logs accurately reflect a one-time purchase.

---

Nitpick comments:
In `@apps/e2e/tests/backend/helpers/payments.ts`:
- Line 124: The callback in transactionsRes.body.transactions.find currently
uses an untyped parameter (tx: any); add a concise inline comment explaining why
any is used (e.g., "transactionsRes.body is an untyped API response from the
test backend, so we use any here to avoid coupling tests to response types") so
it complies with the guideline; locate the find call where purchaseTransaction
is defined and add that explanatory comment next to the (tx: any) parameter or
immediately above the line.
- Around line 46-49: The code currently casts the extracted purchase code with
"as string"; instead replace the cast with the project's defensive throwErr
pattern: locate the extraction using codeMatch and the code variable in the
helper (the lines that compute code from codeMatch and return it) and change the
return to use the non-null coalescing guard—e.g. derive code via codeMatch?.[1]
or keep code variable, then return code ?? throwErr("Expected purchase code in
redirect URL from payment helper")—removing the "as string" cast and keeping the
expect assertion as-is.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 66e567a4-b7de-492d-8aa0-60f18e25b3c4

📥 Commits

Reviewing files that changed from the base of the PR and between a2ea0aa and 83661d1.

📒 Files selected for processing (3)
  • apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx
  • apps/backend/src/lib/payments/platform-fees.ts
  • apps/e2e/tests/backend/helpers/payments.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx

Comment thread apps/backend/src/lib/payments/platform-fees.ts
Comment thread apps/e2e/tests/backend/helpers/payments.ts Outdated
@nams1570
Copy link
Copy Markdown
Collaborator

nams1570 commented May 4, 2026

@greptileai refresh summary and rereview please

Copy link
Copy Markdown
Collaborator

@nams1570 nams1570 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be good now, worth noting that we charge on otps and subscription changes but not on refunds. We just dont refund the app fees on refunds.

@nams1570 nams1570 requested a review from N2D4 May 4, 2026 22:09
@nams1570
Copy link
Copy Markdown
Collaborator

nams1570 commented May 4, 2026

@greptileai please rereview and update summary

Comment thread apps/e2e/tests/backend/helpers/payments.ts
Comment thread apps/backend/src/lib/payments/platform-fees.ts
@nams1570
Copy link
Copy Markdown
Collaborator

nams1570 commented May 4, 2026

@greptileai rereview and refresh summary. Removal of sub product was intentional under YAGNI, don't see how that is an issue

mantrakp04 and others added 10 commits May 4, 2026 18:35
Charges the platform 0.9% on both legs of each transaction on non-internal
projects. The charge leg rides along via Stripe's native
`application_fee_amount` / `application_fee_percent` params on the
PaymentIntent / Subscription. The refund leg cannot use Stripe's built-in
fee handling (Stripe's default is to reverse our charge-leg fee on refund,
netting us zero) so we disable that with `refund_application_fee: false`
and collect the refund-leg fee via an inverse Connect transfer.

The inverse-transfer path is fire-and-forget from the refund route so a
fee-collection failure never blocks the end-customer's refund. Every
attempt writes a durable `PlatformFeeEvent` ledger row first, upserting
on `(sourceType, sourceId)` so replayed refunds cannot double-record. On
retry beyond Stripe's 24h idempotency-key window we reconcile via a
content-addressed `transfer_group` lookup so we don't double-debit the
merchant. FAILED rows keep a Sentry-captured error string so ops can see
what's stuck via the admin `/platform-fees` endpoint.

Merchant Connect accounts now onboard with `debit_negative_balances: true`
so Stripe can ACH-debit the merchant's linked bank when their balance
goes negative from settlement events (payouts, chargebacks). Inverse
transfers themselves still hard-fail on insufficient balance and land in
the ledger as FAILED for manual reconciliation.
…tion finds existing transfer

The retry-reconciliation block in `collectInverseFeeInner` wraps two
distinct Stripe/DB calls in one try/catch. When Stripe's `transfers.list`
finds a pre-existing transfer for this `transfer_group` but the
subsequent `platformFeeEvent.update` throws (transient DB error, etc.),
the shared catch swallowed the error and fell through to
`transfers.create`. That's fine in-window (the idempotency key dedupes),
but after the 24h idempotency-key window expires the next retry would
create a second transfer on the merchant's account — double-debit.

Split into two try blocks with distinct error semantics:

  (a) `transfers.list` fails — safe to fall through. No transfer
      is known to exist yet, and the idempotency key still dedupes
      the near-term retry path.

  (b) `transfers.list` succeeds and returns a pre-existing transfer,
      but the ledger update then fails — bail with FAILED status and
      return. Creating another transfer here would double-debit once
      the idempotency key expires. The Sentry report and ledger row
      both capture the pre-existing transfer id so ops can reconcile
      manually.

Refs:
  https://docs.stripe.com/api/idempotent_requests (24h key pruning)
  https://docs.stripe.com/api/transfers/list (transfer_group filter)
…ingify dbErr in Sentry

- schema.prisma: use dbgenerated("gen_random_uuid()") to match migration's DEFAULT and fix prisma migrate diff drift in CI
- platform-fees.ts: serialize dbErr to string in the case-(b) reconciliation captureError so the primary double-debit signal is readable in Sentry
Rather than maintaining complex refund cut logic, lets not charge on refunds.
Let's keep the logic where we dont refund the app fee.
OTPs require application_fee_amounts so we calc them using a helper.
However this means that for really small purchases, we charge nothing. We explicitly document this
@nams1570
Copy link
Copy Markdown
Collaborator

nams1570 commented May 5, 2026

@greptileai re review and refresh summary

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants