Skip to content

Commit 0c9059c

Browse files
committed
refactor: nix charges on refund
Rather than maintaining complex refund cut logic, lets not charge on refunds. Let's keep the logic where we dont refund the app fee.
1 parent 645e060 commit 0c9059c

11 files changed

Lines changed: 68 additions & 815 deletions

File tree

apps/backend/.env

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@ 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
117116
STACK_TELEGRAM_BOT_TOKEN= # enter you telegram bot token
118117
STACK_TELEGRAM_CHAT_ID=# enter your telegram chat id
119118

apps/backend/.env.development

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,6 @@ 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
8079
STACK_OPENROUTER_API_KEY=FORWARD_TO_PRODUCTION
8180
STACK_FEEDBACK_MODE=FORWARD_TO_PRODUCTION
8281
STACK_MINTLIFY_MCP_URL=https://stackauth-e0affa27.mintlify.app/mcp

apps/backend/prisma/migrations/20260422000000_add_platform_fee_event/migration.sql

Lines changed: 0 additions & 30 deletions
This file was deleted.

apps/backend/prisma/schema.prisma

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1312,40 +1312,6 @@ 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(dbgenerated("gen_random_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-
13491315
model OutgoingRequest {
13501316
id String @id @default(uuid()) @db.Uuid
13511317

apps/backend/src/app/api/latest/internal/payments/platform-fees/route.ts

Lines changed: 0 additions & 75 deletions
This file was deleted.

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

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,22 +50,6 @@ 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-
},
6953
metadata: {
7054
tenancyId: auth.tenancy.id,
7155
}

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

Lines changed: 57 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,44 @@
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";
43
import type { ManualTransactionRow } from "@/lib/payments/schema/types";
54
import { getStripeForAccount } from "@/lib/stripe";
65
import { getPrismaClientForTenancy } from "@/prisma-client";
76
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
8-
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
97
import type { TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions";
108
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
119
import { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
1210
import { moneyAmountToStripeUnits } from "@stackframe/stack-shared/dist/utils/currencies";
1311
import { SUPPORTED_CURRENCIES, type MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants";
1412
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
13+
import type Stripe from "stripe";
1514
import { InferType } from "yup";
1615

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

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+
2042
function getTotalUsdStripeUnits(options: { product: InferType<typeof productSchema>, priceId: string | null, quantity: number }) {
2143
const selectedPrice = resolveSelectedPriceFromProduct(options.product, options.priceId ?? null);
2244
const usdPrice = selectedPrice?.USD;
@@ -264,23 +286,9 @@ export const POST = createSmartRouteHandler({
264286
if (refundAmountStripeUnits > totalStripeUnits) {
265287
throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount.");
266288
}
267-
const subscriptionRefund = await stripe.refunds.create({
268-
payment_intent: paymentIntentId,
269-
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,
274-
});
275-
// Fee collection is best-effort and the originating refund has already
276-
// succeeded, but we still need to keep the background ledger/transfer work
277-
// alive after the response on serverless runtimes.
278-
runAsynchronouslyAndWaitUntil(collectInverseFee({
279-
tenancy: auth.tenancy,
289+
await stripe.refunds.create(buildStripeRefundParams({
290+
paymentIntentId,
280291
amountStripeUnits: refundAmountStripeUnits,
281-
currency: "usd",
282-
sourceType: PlatformFeeSourceType.REFUND,
283-
sourceId: subscriptionRefund.id,
284292
}));
285293
const refundedAt = new Date();
286294
if (refundedQuantity > 0) {
@@ -379,21 +387,13 @@ export const POST = createSmartRouteHandler({
379387
if (refundAmountStripeUnits > totalStripeUnits) {
380388
throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount.");
381389
}
382-
const purchaseRefund = await stripe.refunds.create({
383-
payment_intent: purchase.stripePaymentIntentId,
384-
amount: refundAmountStripeUnits,
390+
await stripe.refunds.create(buildStripeRefundParams({
391+
paymentIntentId: purchase.stripePaymentIntentId,
392+
amountStripeUnits: refundAmountStripeUnits,
385393
metadata: {
386394
tenancyId: auth.tenancy.id,
387395
purchaseId: purchase.id,
388396
},
389-
refund_application_fee: false,
390-
});
391-
runAsynchronouslyAndWaitUntil(collectInverseFee({
392-
tenancy: auth.tenancy,
393-
amountStripeUnits: refundAmountStripeUnits,
394-
currency: "usd",
395-
sourceType: PlatformFeeSourceType.REFUND,
396-
sourceId: purchaseRefund.id,
397397
}));
398398
const refundedAt = new Date();
399399
await prisma.oneTimePurchase.update({
@@ -429,3 +429,31 @@ export const POST = createSmartRouteHandler({
429429
};
430430
},
431431
});
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/purchases/purchase-session/route.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,13 @@ export const POST = createSmartRouteHandler({
9898
// is created synchronously. `application_fee_percent` is applied to
9999
// invoices generated from the subscription's normal billing cycle, but
100100
// 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.
101+
// upgrade invoice may not inherit the newly-set fee percent — i.e. we
102+
// may miss collecting our 0.9% on the proration invoice itself even
103+
// though all later renewals of the new plan are covered. Best-effort
104+
// until we either (a) observe the behaviour against a real onboarded
105+
// Connect account, or (b) listen for the resulting `invoice.created`
106+
// webhook and stamp `application_fee_amount` on the invoice before it
107+
// finalises.
109108
// Refs: https://docs.stripe.com/connect/subscriptions
110109
// https://docs.stripe.com/billing/subscriptions/prorations
111110
const applicationFeePercent = getApplicationFeePercentOrUndefined(tenancy.project.id);

0 commit comments

Comments
 (0)