Skip to content

Commit f8533a8

Browse files
committed
feat(payments): collect 0.9% platform fee on every stripe money movement
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.
1 parent 7e6492c commit f8533a8

13 files changed

Lines changed: 982 additions & 137 deletions

File tree

apps/backend/.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ STACK_OPENAI_API_KEY=# enter your openai api key
113113
STACK_FEATUREBASE_API_KEY=# enter your featurebase api key
114114
STACK_STRIPE_SECRET_KEY=# enter your stripe api key
115115
STACK_STRIPE_WEBHOOK_SECRET=# enter your stripe webhook secret
116+
STACK_STRIPE_PLATFORM_ACCOUNT_ID=# enter your platform stripe account id (acct_...), destination for Connect inverse transfers
116117
STACK_TELEGRAM_BOT_TOKEN= # enter you telegram bot token
117118
STACK_TELEGRAM_CHAT_ID=# enter your telegram chat id
118119

apps/backend/.env.development

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ STACK_VERCEL_SANDBOX_TOKEN=vercel_sandbox_disabled_for_local_development
7676
STACK_OPENAI_API_KEY=mock_openai_api_key
7777
STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey
7878
STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret
79+
STACK_STRIPE_PLATFORM_ACCOUNT_ID=acct_mock_platform
7980
STACK_OPENROUTER_API_KEY=FORWARD_TO_PRODUCTION
8081
STACK_FEEDBACK_MODE=FORWARD_TO_PRODUCTION
8182
# STACK_DOCS_INTERNAL_BASE_URL=http://localhost:8104
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
-- CreateTable
2+
CREATE TABLE "PlatformFeeEvent" (
3+
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
4+
"tenancyId" UUID NOT NULL,
5+
"projectId" TEXT NOT NULL,
6+
"sourceType" TEXT NOT NULL CHECK ("sourceType" IN ('REFUND')),
7+
"sourceId" TEXT NOT NULL,
8+
"amount" INTEGER NOT NULL CHECK ("amount" >= 0),
9+
"currency" TEXT NOT NULL,
10+
"status" TEXT NOT NULL CHECK ("status" IN ('PENDING', 'COLLECTED', 'FAILED')),
11+
"stripeTransferId" TEXT,
12+
"error" TEXT,
13+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
14+
"updatedAt" TIMESTAMP(3) NOT NULL,
15+
"collectedAt" TIMESTAMP(3),
16+
17+
CONSTRAINT "PlatformFeeEvent_pkey" PRIMARY KEY ("id")
18+
);
19+
20+
-- CreateIndex
21+
CREATE UNIQUE INDEX "PlatformFeeEvent_sourceType_sourceId_key" ON "PlatformFeeEvent"("sourceType", "sourceId");
22+
23+
-- CreateIndex
24+
CREATE INDEX "PlatformFeeEvent_tenancyId_idx" ON "PlatformFeeEvent"("tenancyId");
25+
26+
-- CreateIndex
27+
CREATE INDEX "PlatformFeeEvent_projectId_idx" ON "PlatformFeeEvent"("projectId");
28+
29+
-- CreateIndex
30+
CREATE INDEX "PlatformFeeEvent_status_idx" ON "PlatformFeeEvent"("status");

apps/backend/prisma/schema.prisma

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1312,6 +1312,40 @@ model SubscriptionInvoice {
13121312
@@unique([tenancyId, stripeInvoiceId])
13131313
}
13141314

1315+
// Ledger of platform fees collected via inverse Connect transfers (e.g. our
1316+
// 0.9% cut of refund outflows). Each row is keyed by the originating Stripe
1317+
// event so collection is idempotent under webhook / handler retry.
1318+
model PlatformFeeEvent {
1319+
id String @id @default(uuid()) @db.Uuid
1320+
1321+
tenancyId String @db.Uuid
1322+
projectId String
1323+
1324+
// Discriminator for the source event. Currently only REFUND; additional
1325+
// sources (e.g. manual adjustments) may be added later.
1326+
sourceType String
1327+
// Stripe ID of the originating event — refund id for REFUND. Unique with
1328+
// sourceType to make fee collection idempotent.
1329+
sourceId String
1330+
1331+
amount Int
1332+
currency String
1333+
1334+
// PENDING | COLLECTED | FAILED
1335+
status String
1336+
stripeTransferId String?
1337+
error String?
1338+
1339+
createdAt DateTime @default(now())
1340+
updatedAt DateTime @updatedAt
1341+
collectedAt DateTime?
1342+
1343+
@@unique([sourceType, sourceId])
1344+
@@index([tenancyId])
1345+
@@index([projectId])
1346+
@@index([status])
1347+
}
1348+
13151349
model OutgoingRequest {
13161350
id String @id @default(uuid()) @db.Uuid
13171351
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { PlatformFeeStatus } from "@/lib/payments/platform-fees";
2+
import { globalPrismaClient } from "@/prisma-client";
3+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
4+
import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
5+
6+
export const GET = createSmartRouteHandler({
7+
metadata: {
8+
hidden: true,
9+
},
10+
request: yupObject({
11+
auth: yupObject({
12+
type: adminAuthTypeSchema.defined(),
13+
project: adaptSchema.defined(),
14+
tenancy: adaptSchema.defined(),
15+
}).defined(),
16+
}),
17+
response: yupObject({
18+
statusCode: yupNumber().oneOf([200]).defined(),
19+
bodyType: yupString().oneOf(["json"]).defined(),
20+
body: yupObject({
21+
// Sum of every platform fee event not yet marked COLLECTED, in
22+
// USD stripe-units (cents). Expands to a keyed map when multi-currency
23+
// support is introduced.
24+
total_due_usd: yupNumber().defined(),
25+
events: yupArray(
26+
yupObject({
27+
id: yupString().defined(),
28+
source_type: yupString().defined(),
29+
source_id: yupString().defined(),
30+
amount: yupNumber().defined(),
31+
currency: yupString().defined(),
32+
status: yupString().defined(),
33+
stripe_transfer_id: yupString().nullable().defined(),
34+
error: yupString().nullable().defined(),
35+
created_at: yupString().defined(),
36+
collected_at: yupString().nullable().defined(),
37+
}).defined(),
38+
).defined(),
39+
}).defined(),
40+
}),
41+
handler: async ({ auth }) => {
42+
// TODO: pagination. Low-priority today (volume per tenancy is small), but
43+
// merchants with high refund throughput will eventually accumulate
44+
// thousands of rows here. Add cursor-based pagination before shipping a
45+
// merchant-visible UI.
46+
const events = await globalPrismaClient.platformFeeEvent.findMany({
47+
where: { tenancyId: auth.tenancy.id },
48+
orderBy: { createdAt: "desc" },
49+
});
50+
51+
const totalDueUsdUnits = events
52+
.filter((e) => e.status !== PlatformFeeStatus.COLLECTED && e.currency === "usd")
53+
.reduce((sum, e) => sum + e.amount, 0);
54+
55+
return {
56+
statusCode: 200,
57+
bodyType: "json",
58+
body: {
59+
total_due_usd: totalDueUsdUnits,
60+
events: events.map((e) => ({
61+
id: e.id,
62+
source_type: e.sourceType,
63+
source_id: e.sourceId,
64+
amount: e.amount,
65+
currency: e.currency,
66+
status: e.status,
67+
stripe_transfer_id: e.stripeTransferId,
68+
error: e.error,
69+
created_at: e.createdAt.toISOString(),
70+
collected_at: e.collectedAt?.toISOString() ?? null,
71+
})),
72+
},
73+
};
74+
},
75+
});

apps/backend/src/app/api/latest/internal/payments/setup/route.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,22 @@ export const POST = createSmartRouteHandler({
5050
transfers: { requested: true },
5151
},
5252
country: "US",
53+
// `debit_negative_balances` lets Stripe ACH-debit the merchant's
54+
// linked bank when their Stripe balance goes negative due to payouts,
55+
// chargebacks, or other settlement events. It does NOT let our
56+
// `stripe.transfers.create` push a connected account into a negative
57+
// balance on its own — transfers hard-fail on insufficient balance
58+
// and land in the PlatformFeeEvent ledger with status=FAILED for
59+
// manual reconciliation. We still enable this setting so that merchant
60+
// balances that *would* go negative for other reasons (e.g. their own
61+
// refunds running ahead of incoming payments) are covered automatically.
62+
// Refs: https://docs.stripe.com/connect/account-debits
63+
// https://docs.stripe.com/connect/account-balances#negative-balances
64+
settings: {
65+
payouts: {
66+
debit_negative_balances: true,
67+
},
68+
},
5369
metadata: {
5470
tenancyId: auth.tenancy.id,
5571
}

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { buildOneTimePurchaseTransaction, buildSubscriptionTransaction, resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder";
22
import { bulldozerWriteManualTransaction, bulldozerWriteOneTimePurchase, bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write";
3+
import { collectInverseFee, PlatformFeeSourceType } from "@/lib/payments/platform-fees";
34
import type { ManualTransactionRow } from "@/lib/payments/schema/types";
45
import { getStripeForAccount } from "@/lib/stripe";
56
import { getPrismaClientForTenancy } from "@/prisma-client";
67
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
8+
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
79
import type { TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions";
810
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
911
import { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
@@ -262,10 +264,24 @@ export const POST = createSmartRouteHandler({
262264
if (refundAmountStripeUnits > totalStripeUnits) {
263265
throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount.");
264266
}
265-
await stripe.refunds.create({
267+
const subscriptionRefund = await stripe.refunds.create({
266268
payment_intent: paymentIntentId,
267269
amount: refundAmountStripeUnits,
270+
// Keep the charge-leg application fee with the platform. Stripe's
271+
// default would reverse it proportionally, cancelling out our 0.9%
272+
// cut on the original charge.
273+
refund_application_fee: false,
268274
});
275+
// Fee collection is best-effort and the originating refund has already
276+
// succeeded — don't block the response on the inverse transfer. The
277+
// helper has its own durable ledger + Sentry capture for failures.
278+
runAsynchronously(collectInverseFee({
279+
tenancy: auth.tenancy,
280+
amountStripeUnits: refundAmountStripeUnits,
281+
currency: "usd",
282+
sourceType: PlatformFeeSourceType.REFUND,
283+
sourceId: subscriptionRefund.id,
284+
}));
269285
const refundedAt = new Date();
270286
if (refundedQuantity > 0) {
271287
if (!subscription.stripeSubscriptionId) {
@@ -363,14 +379,22 @@ export const POST = createSmartRouteHandler({
363379
if (refundAmountStripeUnits > totalStripeUnits) {
364380
throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount.");
365381
}
366-
await stripe.refunds.create({
382+
const purchaseRefund = await stripe.refunds.create({
367383
payment_intent: purchase.stripePaymentIntentId,
368384
amount: refundAmountStripeUnits,
369385
metadata: {
370386
tenancyId: auth.tenancy.id,
371387
purchaseId: purchase.id,
372388
},
389+
refund_application_fee: false,
373390
});
391+
runAsynchronously(collectInverseFee({
392+
tenancy: auth.tenancy,
393+
amountStripeUnits: refundAmountStripeUnits,
394+
currency: "usd",
395+
sourceType: PlatformFeeSourceType.REFUND,
396+
sourceId: purchaseRefund.id,
397+
}));
374398
const refundedAt = new Date();
375399
await prisma.oneTimePurchase.update({
376400
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },

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: 25 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,22 @@ 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+
// TODO (platform-fees): this is a plan-switch mid-cycle that returns
97+
// `latest_invoice.confirmation_secret`, so an upgrade/proration invoice
98+
// is created synchronously. `application_fee_percent` is applied to
99+
// invoices generated from the subscription's normal billing cycle, but
100+
// per Stripe's subscription/proration docs the immediately-generated
101+
// upgrade invoice may not inherit the newly-set fee percent. Our
102+
// charge-leg guarantee for this specific invoice is therefore
103+
// best-effort until we either (a) observe the behaviour against a real
104+
// onboarded Connect account, or (b) listen for the resulting
105+
// `invoice.created` webhook and stamp `application_fee_amount` on the
106+
// invoice before it finalises. Refund-leg collection (via
107+
// `collectInverseFee`) is unaffected and still works on the full
108+
// refund amount regardless.
109+
// Refs: https://docs.stripe.com/connect/subscriptions
110+
// https://docs.stripe.com/billing/subscriptions/prorations
111+
const applicationFeePercent = getApplicationFeePercentOrUndefined(tenancy.project.id);
95112
const updated = await stripe.subscriptions.update(conflicting.stripeSubscriptionId, {
96113
payment_behavior: 'default_incomplete',
97114
payment_settings: { save_default_payment_method: 'on_subscription' },
@@ -114,6 +131,7 @@ export const POST = createSmartRouteHandler({
114131
productVersionId,
115132
priceId: price_id,
116133
},
134+
...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}),
117135
});
118136
const clientSecretUpdated = getClientSecretFromStripeSubscription(updated);
119137
await purchaseUrlVerificationCodeHandler.revokeCode({ tenancy, id: codeId });
@@ -145,6 +163,10 @@ export const POST = createSmartRouteHandler({
145163
// One-time payment path after conflicts handled
146164
if (!selectedPrice.interval) {
147165
const amountCents = Number(selectedPrice.USD) * 100 * Math.max(1, quantity);
166+
const applicationFeeAmount = computeApplicationFeeAmount({
167+
amountStripeUnits: amountCents,
168+
projectId: tenancy.project.id,
169+
});
148170
const paymentIntent = await stripe.paymentIntents.create({
149171
amount: amountCents,
150172
currency: "usd",
@@ -160,6 +182,7 @@ export const POST = createSmartRouteHandler({
160182
tenancyId: data.tenancyId,
161183
priceId: price_id,
162184
},
185+
...(applicationFeeAmount > 0 ? { application_fee_amount: applicationFeeAmount } : {}),
163186
});
164187
const clientSecret = paymentIntent.client_secret;
165188
if (typeof clientSecret !== "string") {
@@ -172,6 +195,7 @@ export const POST = createSmartRouteHandler({
172195
const product = await stripe.products.create({
173196
name: data.product.displayName ?? "Subscription",
174197
});
198+
const applicationFeePercent = getApplicationFeePercentOrUndefined(tenancy.project.id);
175199
const created = await stripe.subscriptions.create({
176200
customer: data.stripeCustomerId,
177201
payment_behavior: 'default_incomplete',
@@ -194,6 +218,7 @@ export const POST = createSmartRouteHandler({
194218
productVersionId,
195219
priceId: price_id,
196220
},
221+
...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}),
197222
});
198223
const clientSecret = getClientSecretFromStripeSubscription(created);
199224
if (typeof clientSecret !== "string") {

0 commit comments

Comments
 (0)