Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,35 @@ import { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, yup
import { moneyAmountToStripeUnits } from "@stackframe/stack-shared/dist/utils/currencies";
import { SUPPORTED_CURRENCIES, type MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import type Stripe from "stripe";
import { InferType } from "yup";

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

/**
* Builds the parameters object for `stripe.refunds.create`. Centralised so the
* platform-fee invariant — that we never let Stripe reverse our charge-leg
* 0.9% application fee on refund — has exactly one source of truth and one
* place to test.
*
* Stripe's default for `refund_application_fee` on a Connect direct charge is
* `true`, which proportionally reverses the application fee along with the
* refund. We always set it to `false` so the platform retains its cut.
*/
export function buildStripeRefundParams(args: {
paymentIntentId: string,
amountStripeUnits: number,
metadata?: Record<string, string>,
}): Stripe.RefundCreateParams {
return {
payment_intent: args.paymentIntentId,
amount: args.amountStripeUnits,
...(args.metadata ? { metadata: args.metadata } : {}),
refund_application_fee: false,
};
}

function getTotalUsdStripeUnits(options: { product: InferType<typeof productSchema>, priceId: string | null, quantity: number }) {
const selectedPrice = resolveSelectedPriceFromProduct(options.product, options.priceId ?? null);
const usdPrice = selectedPrice?.USD;
Expand Down Expand Up @@ -262,10 +286,10 @@ export const POST = createSmartRouteHandler({
if (refundAmountStripeUnits > totalStripeUnits) {
throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount.");
}
await stripe.refunds.create({
payment_intent: paymentIntentId,
amount: refundAmountStripeUnits,
});
await stripe.refunds.create(buildStripeRefundParams({
paymentIntentId,
amountStripeUnits: refundAmountStripeUnits,
}));
const refundedAt = new Date();
if (refundedQuantity > 0) {
if (!subscription.stripeSubscriptionId) {
Expand Down Expand Up @@ -363,14 +387,14 @@ export const POST = createSmartRouteHandler({
if (refundAmountStripeUnits > totalStripeUnits) {
throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount.");
}
await stripe.refunds.create({
payment_intent: purchase.stripePaymentIntentId,
amount: refundAmountStripeUnits,
await stripe.refunds.create(buildStripeRefundParams({
paymentIntentId: purchase.stripePaymentIntentId,
amountStripeUnits: refundAmountStripeUnits,
metadata: {
tenancyId: auth.tenancy.id,
purchaseId: purchase.id,
},
});
}));
const refundedAt = new Date();
await prisma.oneTimePurchase.update({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
Expand Down Expand Up @@ -405,3 +429,31 @@ export const POST = createSmartRouteHandler({
};
},
});

import.meta.vitest?.describe("buildStripeRefundParams", (test) => {
test("always sets refund_application_fee: false to keep our 0.9% with the platform", ({ expect }) => {
const params = buildStripeRefundParams({ paymentIntentId: "pi_test", amountStripeUnits: 5000 });
expect(params.refund_application_fee).toBe(false);
});
test("propagates payment_intent and amount as-is", ({ expect }) => {
const params = buildStripeRefundParams({ paymentIntentId: "pi_abc", amountStripeUnits: 1234 });
expect(params.payment_intent).toBe("pi_abc");
expect(params.amount).toBe(1234);
});
test("propagates metadata when provided and omits the key when not", ({ expect }) => {
const withMeta = buildStripeRefundParams({
paymentIntentId: "pi_x",
amountStripeUnits: 1,
metadata: { tenancyId: "t1", purchaseId: "p1" },
});
expect(withMeta.metadata).toEqual({ tenancyId: "t1", purchaseId: "p1" });
// refund_application_fee invariant must hold even when metadata is set —
// pin this explicitly so a future change to the metadata branch can't
// accidentally strip the fee flag.
expect(withMeta.refund_application_fee).toBe(false);

const withoutMeta = buildStripeRefundParams({ paymentIntentId: "pi_x", amountStripeUnits: 1 });
expect("metadata" in withoutMeta).toBe(false);
expect(withoutMeta.refund_application_fee).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { SubscriptionStatus } from "@/generated/prisma/client";
import { ensureClientCanAccessCustomer, ensureCustomerExists, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull, isActiveSubscription, isAddOnProduct } from "@/lib/payments";
import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write";
import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data";
import { getApplicationFeePercentOrUndefined } from "@/lib/payments/platform-fees";
import { upsertProductVersion } from "@/lib/product-versions";
import { getStripeForAccount, sanitizeStripePeriodDates } from "@/lib/stripe";
import { getPrismaClientForTenancy } from "@/prisma-client";
Expand Down Expand Up @@ -204,6 +205,11 @@ export const POST = createSmartRouteHandler({
throw new StackAssertionError("Stripe subscription has no items", { subscriptionId: existingSub.id });
}
const existingItem = existingStripeSub.items.data[0];
// Intentional: switching an existing (possibly pre-platform-fee)
// subscription to a new plan attaches the 0.9% application fee from
// this point forward. Subscriptions that never switch plans stay
// fee-less until a separate migration applies fees retroactively.
const applicationFeePercent = getApplicationFeePercentOrUndefined(auth.tenancy.project.id);
const updated = await stripe.subscriptions.update(existingSub.stripeSubscriptionId, {
payment_behavior: "error_if_incomplete",
payment_settings: { save_default_payment_method: "on_subscription" },
Expand All @@ -226,6 +232,7 @@ export const POST = createSmartRouteHandler({
productVersionId,
priceId: selectedPriceId,
},
...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}),
});
const updatedSubscription = updated as Stripe.Subscription;
const sanitizedUpdateDates = sanitizeStripePeriodDates(
Expand Down Expand Up @@ -261,6 +268,7 @@ export const POST = createSmartRouteHandler({
// DEPRECATED: this path handles switching from include-by-default (free) products
// to paid subscriptions. Default products are being removed; this code is kept
// for backward compatibility only.
const applicationFeePercent = getApplicationFeePercentOrUndefined(auth.tenancy.project.id);
const created = await stripe.subscriptions.create({
customer: stripeCustomer.id,
payment_behavior: "error_if_incomplete",
Expand All @@ -283,6 +291,7 @@ export const POST = createSmartRouteHandler({
productVersionId,
priceId: selectedPriceId,
},
...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}),
});
const createdSubscription = created as Stripe.Subscription;
if (createdSubscription.items.data.length === 0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { SubscriptionStatus } from "@/generated/prisma/client";
import { getClientSecretFromStripeSubscription, validatePurchaseSession } from "@/lib/payments";
import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write";
import { computeApplicationFeeAmount, getApplicationFeePercentOrUndefined } from "@/lib/payments/platform-fees";
import { upsertProductVersion } from "@/lib/product-versions";
import { getStripeForAccount } from "@/lib/stripe";
import { getTenancy } from "@/lib/tenancies";
Expand Down Expand Up @@ -92,6 +93,7 @@ export const POST = createSmartRouteHandler({
const existingItem = existingStripeSub.items.data[0];
const product = await stripe.products.create({ name: data.product.displayName ?? "Subscription" });
if (selectedPrice.interval) {
const applicationFeePercent = getApplicationFeePercentOrUndefined(tenancy.project.id);
const updated = await stripe.subscriptions.update(conflicting.stripeSubscriptionId, {
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
Expand All @@ -114,6 +116,7 @@ export const POST = createSmartRouteHandler({
productVersionId,
priceId: price_id,
},
...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}),
});
const clientSecretUpdated = getClientSecretFromStripeSubscription(updated);
await purchaseUrlVerificationCodeHandler.revokeCode({ tenancy, id: codeId });
Expand Down Expand Up @@ -145,6 +148,10 @@ export const POST = createSmartRouteHandler({
// One-time payment path after conflicts handled
if (!selectedPrice.interval) {
const amountCents = Number(selectedPrice.USD) * 100 * Math.max(1, quantity);
const applicationFeeAmount = computeApplicationFeeAmount({
amountStripeUnits: amountCents,
projectId: tenancy.project.id,
});
const paymentIntent = await stripe.paymentIntents.create({
amount: amountCents,
currency: "usd",
Expand All @@ -160,6 +167,7 @@ export const POST = createSmartRouteHandler({
tenancyId: data.tenancyId,
priceId: price_id,
},
...(applicationFeeAmount > 0 ? { application_fee_amount: applicationFeeAmount } : {}),
});
const clientSecret = paymentIntent.client_secret;
if (typeof clientSecret !== "string") {
Expand All @@ -172,6 +180,7 @@ export const POST = createSmartRouteHandler({
const product = await stripe.products.create({
name: data.product.displayName ?? "Subscription",
});
const applicationFeePercent = getApplicationFeePercentOrUndefined(tenancy.project.id);
const created = await stripe.subscriptions.create({
customer: data.stripeCustomerId,
payment_behavior: 'default_incomplete',
Expand All @@ -194,6 +203,7 @@ export const POST = createSmartRouteHandler({
productVersionId,
priceId: price_id,
},
...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}),
});
const clientSecret = getClientSecretFromStripeSubscription(created);
if (typeof clientSecret !== "string") {
Expand Down
91 changes: 91 additions & 0 deletions apps/backend/src/lib/payments/platform-fees.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";

// 0.9% of every Stripe money movement on a non-internal project is collected
// as a platform fee, ridden along via Stripe's native application_fee_*
// params on the PaymentIntent / Subscription. Refunds keep our charge-leg
// fee with the platform via `refund_application_fee: false` at the refund
// site — there is no separate refund-leg collection.
Comment thread
nams1570 marked this conversation as resolved.
//
// Stored as basis points (1 bps = 1/10000 = 0.01%) instead of a decimal
// percentage so all fee math is integer arithmetic — `0.9 * 5000 / 100` is
// `45.000000000000004` in IEEE-754, but `90 * 5000 / 10000` is exactly `45`.
export const APPLICATION_FEE_BPS = 90;

export function getApplicationFeeBps(projectId: string): number {
if (projectId === "internal") return 0;
return APPLICATION_FEE_BPS;
}

/**
* Half-to-nearest rounding. Stripe's `application_fee_amount` is an integer
* in stripe-units, so we can't represent 0.9% exactly when the charge isn't
* a multiple of $10. Round-nearest is unbiased on average — over many
* charges the over- and under-rounding cancel — at the cost of producing a
* 0 fee on charges in Stripe's min-charge band ($0.50–$0.55) where 0.9%
* falls below half a cent. That clip-to-zero band is small enough to be
* acceptable lost revenue; the alternative (ceil) over-collects on every
* non-multiple-of-$10 charge, and a fractional-cents ledger is more
* complexity than the precision is worth here.
*/
export function computeApplicationFeeAmount(options: { amountStripeUnits: number, projectId: string }): number {
if (options.amountStripeUnits < 0) {
throwErr("computeApplicationFeeAmount received negative amount", { amountStripeUnits: options.amountStripeUnits });
}
const bps = getApplicationFeeBps(options.projectId);
if (bps === 0) return 0;
return Math.round(options.amountStripeUnits * bps / 10000);
}

/**
* Returns the fee as a decimal percent for Stripe's `application_fee_percent`
* (subscription) parameter, or `undefined` for projects that aren't billed.
*
* `bps / 100` is intentional float division — the rest of the module uses
* integer arithmetic to avoid IEEE-754 noise on charge-amount math, but the
* subscription path requires a decimal because that's the shape Stripe's API
* accepts. This is safe for the current 90 bps (→ 0.9, which serialises
* cleanly), and any future bps value must produce a number with at most 4
* decimal places after IEEE-754 rounding — that's the maximum precision
* Stripe documents for `application_fee_percent`.
*/
export function getApplicationFeePercentOrUndefined(projectId: string): number | undefined {
const bps = getApplicationFeeBps(projectId);
if (bps === 0) return undefined;
return bps / 100;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

import.meta.vitest?.describe("platform fee helpers", (test) => {
test("getApplicationFeeBps returns 0 for internal project", ({ expect }) => {
expect(getApplicationFeeBps("internal")).toBe(0);
});
test("getApplicationFeeBps returns APPLICATION_FEE_BPS for any other project", ({ expect }) => {
expect(getApplicationFeeBps("proj_abc123")).toBe(APPLICATION_FEE_BPS);
expect(getApplicationFeeBps("some-uuid")).toBe(APPLICATION_FEE_BPS);
});
test("computeApplicationFeeAmount is 0.9% of the charge, rounded half-to-nearest", ({ expect }) => {
expect(computeApplicationFeeAmount({ amountStripeUnits: 10000, projectId: "p" })).toBe(90);
expect(computeApplicationFeeAmount({ amountStripeUnits: 12345, projectId: "p" })).toBe(111);
expect(computeApplicationFeeAmount({ amountStripeUnits: 500000, projectId: "p" })).toBe(4500);
});
test("computeApplicationFeeAmount clips to 0 below the half-cent threshold (~$0.56)", ({ expect }) => {
// Documented tradeoff: charges in Stripe's min-charge band whose 0.9%
// is under half a cent round to a 0 fee. Pinned here so a future reader
// doesn't accidentally "fix" the clipping without weighing the
// alternatives (see the JSDoc on computeApplicationFeeAmount).
expect(computeApplicationFeeAmount({ amountStripeUnits: 50, projectId: "p" })).toBe(0);
expect(computeApplicationFeeAmount({ amountStripeUnits: 55, projectId: "p" })).toBe(0);
expect(computeApplicationFeeAmount({ amountStripeUnits: 56, projectId: "p" })).toBe(1);
});
test("computeApplicationFeeAmount is 0 for internal project even on large charges", ({ expect }) => {
expect(computeApplicationFeeAmount({ amountStripeUnits: 10000, projectId: "internal" })).toBe(0);
});
test("computeApplicationFeeAmount throws on negative amounts", ({ expect }) => {
expect(() => computeApplicationFeeAmount({ amountStripeUnits: -1, projectId: "p" })).toThrow(/negative amount/);
});
test("getApplicationFeePercentOrUndefined returns 0.9 for non-internal", ({ expect }) => {
expect(getApplicationFeePercentOrUndefined("proj_abc")).toBe(0.9);
});
test("getApplicationFeePercentOrUndefined returns undefined for internal", ({ expect }) => {
expect(getApplicationFeePercentOrUndefined("internal")).toBeUndefined();
});
});
Loading
Loading