Skip to content

Commit 3a5153f

Browse files
mantrakp04nams1570
andauthored
feat(payments): collect 0.9% platform fee on every stripe money movement (#1378)
## 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\` ## Refs - https://docs.stripe.com/api/subscriptions/create#create_subscription-application_fee_percent - https://docs.stripe.com/api/payment_intents/object#payment_intent_object-application_fee_amount --------- Co-authored-by: nams1570 <amanganapathy@gmail.com>
1 parent c01c052 commit 3a5153f

6 files changed

Lines changed: 304 additions & 143 deletions

File tree

apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,35 @@ import { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, yup
1010
import { moneyAmountToStripeUnits } from "@stackframe/stack-shared/dist/utils/currencies";
1111
import { SUPPORTED_CURRENCIES, type MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants";
1212
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
13+
import type Stripe from "stripe";
1314
import { InferType } from "yup";
1415

1516
const USD_CURRENCY = SUPPORTED_CURRENCIES.find((currency) => currency.code === "USD")
1617
?? throwErr("USD currency configuration missing in SUPPORTED_CURRENCIES");
1718

19+
/**
20+
* Builds the parameters object for `stripe.refunds.create`. Centralised so the
21+
* platform-fee invariant — that we never let Stripe reverse our charge-leg
22+
* 0.9% application fee on refund — has exactly one source of truth and one
23+
* place to test.
24+
*
25+
* Stripe's default for `refund_application_fee` on a Connect direct charge is
26+
* `true`, which proportionally reverses the application fee along with the
27+
* refund. We always set it to `false` so the platform retains its cut.
28+
*/
29+
export function buildStripeRefundParams(args: {
30+
paymentIntentId: string,
31+
amountStripeUnits: number,
32+
metadata?: Record<string, string>,
33+
}): Stripe.RefundCreateParams {
34+
return {
35+
payment_intent: args.paymentIntentId,
36+
amount: args.amountStripeUnits,
37+
...(args.metadata ? { metadata: args.metadata } : {}),
38+
refund_application_fee: false,
39+
};
40+
}
41+
1842
function getTotalUsdStripeUnits(options: { product: InferType<typeof productSchema>, priceId: string | null, quantity: number }) {
1943
const selectedPrice = resolveSelectedPriceFromProduct(options.product, options.priceId ?? null);
2044
const usdPrice = selectedPrice?.USD;
@@ -262,10 +286,10 @@ export const POST = createSmartRouteHandler({
262286
if (refundAmountStripeUnits > totalStripeUnits) {
263287
throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount.");
264288
}
265-
await stripe.refunds.create({
266-
payment_intent: paymentIntentId,
267-
amount: refundAmountStripeUnits,
268-
});
289+
await stripe.refunds.create(buildStripeRefundParams({
290+
paymentIntentId,
291+
amountStripeUnits: refundAmountStripeUnits,
292+
}));
269293
const refundedAt = new Date();
270294
if (refundedQuantity > 0) {
271295
if (!subscription.stripeSubscriptionId) {
@@ -363,14 +387,14 @@ export const POST = createSmartRouteHandler({
363387
if (refundAmountStripeUnits > totalStripeUnits) {
364388
throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount.");
365389
}
366-
await stripe.refunds.create({
367-
payment_intent: purchase.stripePaymentIntentId,
368-
amount: refundAmountStripeUnits,
390+
await stripe.refunds.create(buildStripeRefundParams({
391+
paymentIntentId: purchase.stripePaymentIntentId,
392+
amountStripeUnits: refundAmountStripeUnits,
369393
metadata: {
370394
tenancyId: auth.tenancy.id,
371395
purchaseId: purchase.id,
372396
},
373-
});
397+
}));
374398
const refundedAt = new Date();
375399
await prisma.oneTimePurchase.update({
376400
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
@@ -405,3 +429,31 @@ export const POST = createSmartRouteHandler({
405429
};
406430
},
407431
});
432+
433+
import.meta.vitest?.describe("buildStripeRefundParams", (test) => {
434+
test("always sets refund_application_fee: false to keep our 0.9% with the platform", ({ expect }) => {
435+
const params = buildStripeRefundParams({ paymentIntentId: "pi_test", amountStripeUnits: 5000 });
436+
expect(params.refund_application_fee).toBe(false);
437+
});
438+
test("propagates payment_intent and amount as-is", ({ expect }) => {
439+
const params = buildStripeRefundParams({ paymentIntentId: "pi_abc", amountStripeUnits: 1234 });
440+
expect(params.payment_intent).toBe("pi_abc");
441+
expect(params.amount).toBe(1234);
442+
});
443+
test("propagates metadata when provided and omits the key when not", ({ expect }) => {
444+
const withMeta = buildStripeRefundParams({
445+
paymentIntentId: "pi_x",
446+
amountStripeUnits: 1,
447+
metadata: { tenancyId: "t1", purchaseId: "p1" },
448+
});
449+
expect(withMeta.metadata).toEqual({ tenancyId: "t1", purchaseId: "p1" });
450+
// refund_application_fee invariant must hold even when metadata is set —
451+
// pin this explicitly so a future change to the metadata branch can't
452+
// accidentally strip the fee flag.
453+
expect(withMeta.refund_application_fee).toBe(false);
454+
455+
const withoutMeta = buildStripeRefundParams({ paymentIntentId: "pi_x", amountStripeUnits: 1 });
456+
expect("metadata" in withoutMeta).toBe(false);
457+
expect(withoutMeta.refund_application_fee).toBe(false);
458+
});
459+
});

apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { SubscriptionStatus } from "@/generated/prisma/client";
22
import { ensureClientCanAccessCustomer, ensureCustomerExists, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull, isActiveSubscription, isAddOnProduct } from "@/lib/payments";
33
import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write";
44
import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data";
5+
import { getApplicationFeePercentOrUndefined } from "@/lib/payments/platform-fees";
56
import { upsertProductVersion } from "@/lib/product-versions";
67
import { getStripeForAccount, sanitizeStripePeriodDates } from "@/lib/stripe";
78
import { getPrismaClientForTenancy } from "@/prisma-client";
@@ -204,6 +205,11 @@ export const POST = createSmartRouteHandler({
204205
throw new StackAssertionError("Stripe subscription has no items", { subscriptionId: existingSub.id });
205206
}
206207
const existingItem = existingStripeSub.items.data[0];
208+
// Intentional: switching an existing (possibly pre-platform-fee)
209+
// subscription to a new plan attaches the 0.9% application fee from
210+
// this point forward. Subscriptions that never switch plans stay
211+
// fee-less until a separate migration applies fees retroactively.
212+
const applicationFeePercent = getApplicationFeePercentOrUndefined(auth.tenancy.project.id);
207213
const updated = await stripe.subscriptions.update(existingSub.stripeSubscriptionId, {
208214
payment_behavior: "error_if_incomplete",
209215
payment_settings: { save_default_payment_method: "on_subscription" },
@@ -226,6 +232,7 @@ export const POST = createSmartRouteHandler({
226232
productVersionId,
227233
priceId: selectedPriceId,
228234
},
235+
...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}),
229236
});
230237
const updatedSubscription = updated as Stripe.Subscription;
231238
const sanitizedUpdateDates = sanitizeStripePeriodDates(
@@ -261,6 +268,7 @@ export const POST = createSmartRouteHandler({
261268
// DEPRECATED: this path handles switching from include-by-default (free) products
262269
// to paid subscriptions. Default products are being removed; this code is kept
263270
// for backward compatibility only.
271+
const applicationFeePercent = getApplicationFeePercentOrUndefined(auth.tenancy.project.id);
264272
const created = await stripe.subscriptions.create({
265273
customer: stripeCustomer.id,
266274
payment_behavior: "error_if_incomplete",
@@ -283,6 +291,7 @@ export const POST = createSmartRouteHandler({
283291
productVersionId,
284292
priceId: selectedPriceId,
285293
},
294+
...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}),
286295
});
287296
const createdSubscription = created as Stripe.Subscription;
288297
if (createdSubscription.items.data.length === 0) {

apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { SubscriptionStatus } from "@/generated/prisma/client";
22
import { getClientSecretFromStripeSubscription, validatePurchaseSession } from "@/lib/payments";
33
import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write";
4+
import { computeApplicationFeeAmount, getApplicationFeePercentOrUndefined } from "@/lib/payments/platform-fees";
45
import { upsertProductVersion } from "@/lib/product-versions";
56
import { getStripeForAccount } from "@/lib/stripe";
67
import { getTenancy } from "@/lib/tenancies";
@@ -92,6 +93,7 @@ export const POST = createSmartRouteHandler({
9293
const existingItem = existingStripeSub.items.data[0];
9394
const product = await stripe.products.create({ name: data.product.displayName ?? "Subscription" });
9495
if (selectedPrice.interval) {
96+
const applicationFeePercent = getApplicationFeePercentOrUndefined(tenancy.project.id);
9597
const updated = await stripe.subscriptions.update(conflicting.stripeSubscriptionId, {
9698
payment_behavior: 'default_incomplete',
9799
payment_settings: { save_default_payment_method: 'on_subscription' },
@@ -114,6 +116,7 @@ export const POST = createSmartRouteHandler({
114116
productVersionId,
115117
priceId: price_id,
116118
},
119+
...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}),
117120
});
118121
const clientSecretUpdated = getClientSecretFromStripeSubscription(updated);
119122
await purchaseUrlVerificationCodeHandler.revokeCode({ tenancy, id: codeId });
@@ -145,6 +148,10 @@ export const POST = createSmartRouteHandler({
145148
// One-time payment path after conflicts handled
146149
if (!selectedPrice.interval) {
147150
const amountCents = Number(selectedPrice.USD) * 100 * Math.max(1, quantity);
151+
const applicationFeeAmount = computeApplicationFeeAmount({
152+
amountStripeUnits: amountCents,
153+
projectId: tenancy.project.id,
154+
});
148155
const paymentIntent = await stripe.paymentIntents.create({
149156
amount: amountCents,
150157
currency: "usd",
@@ -160,6 +167,7 @@ export const POST = createSmartRouteHandler({
160167
tenancyId: data.tenancyId,
161168
priceId: price_id,
162169
},
170+
...(applicationFeeAmount > 0 ? { application_fee_amount: applicationFeeAmount } : {}),
163171
});
164172
const clientSecret = paymentIntent.client_secret;
165173
if (typeof clientSecret !== "string") {
@@ -172,6 +180,7 @@ export const POST = createSmartRouteHandler({
172180
const product = await stripe.products.create({
173181
name: data.product.displayName ?? "Subscription",
174182
});
183+
const applicationFeePercent = getApplicationFeePercentOrUndefined(tenancy.project.id);
175184
const created = await stripe.subscriptions.create({
176185
customer: data.stripeCustomerId,
177186
payment_behavior: 'default_incomplete',
@@ -194,6 +203,7 @@ export const POST = createSmartRouteHandler({
194203
productVersionId,
195204
priceId: price_id,
196205
},
206+
...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}),
197207
});
198208
const clientSecret = getClientSecretFromStripeSubscription(created);
199209
if (typeof clientSecret !== "string") {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
2+
3+
// 0.9% of every Stripe money movement on a non-internal project is collected
4+
// as a platform fee, ridden along via Stripe's native application_fee_*
5+
// params on the PaymentIntent / Subscription. Refunds keep our charge-leg
6+
// fee with the platform via `refund_application_fee: false` at the refund
7+
// site — there is no separate refund-leg collection.
8+
//
9+
// Stored as basis points (1 bps = 1/10000 = 0.01%) instead of a decimal
10+
// percentage so all fee math is integer arithmetic — `0.9 * 5000 / 100` is
11+
// `45.000000000000004` in IEEE-754, but `90 * 5000 / 10000` is exactly `45`.
12+
export const APPLICATION_FEE_BPS = 90;
13+
14+
export function getApplicationFeeBps(projectId: string): number {
15+
if (projectId === "internal") return 0;
16+
return APPLICATION_FEE_BPS;
17+
}
18+
19+
/**
20+
* Half-to-nearest rounding. Stripe's `application_fee_amount` is an integer
21+
* in stripe-units, so we can't represent 0.9% exactly when the charge isn't
22+
* a multiple of $10. Round-nearest is unbiased on average — over many
23+
* charges the over- and under-rounding cancel — at the cost of producing a
24+
* 0 fee on charges in Stripe's min-charge band ($0.50–$0.55) where 0.9%
25+
* falls below half a cent. That clip-to-zero band is small enough to be
26+
* acceptable lost revenue; the alternative (ceil) over-collects on every
27+
* non-multiple-of-$10 charge, and a fractional-cents ledger is more
28+
* complexity than the precision is worth here.
29+
*/
30+
export function computeApplicationFeeAmount(options: { amountStripeUnits: number, projectId: string }): number {
31+
if (options.amountStripeUnits < 0) {
32+
throwErr("computeApplicationFeeAmount received negative amount", { amountStripeUnits: options.amountStripeUnits });
33+
}
34+
const bps = getApplicationFeeBps(options.projectId);
35+
if (bps === 0) return 0;
36+
return Math.round(options.amountStripeUnits * bps / 10000);
37+
}
38+
39+
/**
40+
* Returns the fee as a decimal percent for Stripe's `application_fee_percent`
41+
* (subscription) parameter, or `undefined` for projects that aren't billed.
42+
*
43+
* `bps / 100` is intentional float division — the rest of the module uses
44+
* integer arithmetic to avoid IEEE-754 noise on charge-amount math, but the
45+
* subscription path requires a decimal because that's the shape Stripe's API
46+
* accepts. This is safe for the current 90 bps (→ 0.9, which serialises
47+
* cleanly), and any future bps value must produce a number with at most 4
48+
* decimal places after IEEE-754 rounding — that's the maximum precision
49+
* Stripe documents for `application_fee_percent`.
50+
*/
51+
export function getApplicationFeePercentOrUndefined(projectId: string): number | undefined {
52+
const bps = getApplicationFeeBps(projectId);
53+
if (bps === 0) return undefined;
54+
return bps / 100;
55+
}
56+
57+
import.meta.vitest?.describe("platform fee helpers", (test) => {
58+
test("getApplicationFeeBps returns 0 for internal project", ({ expect }) => {
59+
expect(getApplicationFeeBps("internal")).toBe(0);
60+
});
61+
test("getApplicationFeeBps returns APPLICATION_FEE_BPS for any other project", ({ expect }) => {
62+
expect(getApplicationFeeBps("proj_abc123")).toBe(APPLICATION_FEE_BPS);
63+
expect(getApplicationFeeBps("some-uuid")).toBe(APPLICATION_FEE_BPS);
64+
});
65+
test("computeApplicationFeeAmount is 0.9% of the charge, rounded half-to-nearest", ({ expect }) => {
66+
expect(computeApplicationFeeAmount({ amountStripeUnits: 10000, projectId: "p" })).toBe(90);
67+
expect(computeApplicationFeeAmount({ amountStripeUnits: 12345, projectId: "p" })).toBe(111);
68+
expect(computeApplicationFeeAmount({ amountStripeUnits: 500000, projectId: "p" })).toBe(4500);
69+
});
70+
test("computeApplicationFeeAmount clips to 0 below the half-cent threshold (~$0.56)", ({ expect }) => {
71+
// Documented tradeoff: charges in Stripe's min-charge band whose 0.9%
72+
// is under half a cent round to a 0 fee. Pinned here so a future reader
73+
// doesn't accidentally "fix" the clipping without weighing the
74+
// alternatives (see the JSDoc on computeApplicationFeeAmount).
75+
expect(computeApplicationFeeAmount({ amountStripeUnits: 50, projectId: "p" })).toBe(0);
76+
expect(computeApplicationFeeAmount({ amountStripeUnits: 55, projectId: "p" })).toBe(0);
77+
expect(computeApplicationFeeAmount({ amountStripeUnits: 56, projectId: "p" })).toBe(1);
78+
});
79+
test("computeApplicationFeeAmount is 0 for internal project even on large charges", ({ expect }) => {
80+
expect(computeApplicationFeeAmount({ amountStripeUnits: 10000, projectId: "internal" })).toBe(0);
81+
});
82+
test("computeApplicationFeeAmount throws on negative amounts", ({ expect }) => {
83+
expect(() => computeApplicationFeeAmount({ amountStripeUnits: -1, projectId: "p" })).toThrow(/negative amount/);
84+
});
85+
test("getApplicationFeePercentOrUndefined returns 0.9 for non-internal", ({ expect }) => {
86+
expect(getApplicationFeePercentOrUndefined("proj_abc")).toBe(0.9);
87+
});
88+
test("getApplicationFeePercentOrUndefined returns undefined for internal", ({ expect }) => {
89+
expect(getApplicationFeePercentOrUndefined("internal")).toBeUndefined();
90+
});
91+
});

0 commit comments

Comments
 (0)