|
1 | 1 | import { buildOneTimePurchaseTransaction, buildSubscriptionTransaction, resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder"; |
2 | 2 | import { bulldozerWriteManualTransaction, bulldozerWriteOneTimePurchase, bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; |
3 | | -import { collectInverseFee, PlatformFeeSourceType } from "@/lib/payments/platform-fees"; |
4 | 3 | import type { ManualTransactionRow } from "@/lib/payments/schema/types"; |
5 | 4 | import { getStripeForAccount } from "@/lib/stripe"; |
6 | 5 | import { getPrismaClientForTenancy } from "@/prisma-client"; |
7 | 6 | import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; |
8 | | -import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks"; |
9 | 7 | import type { TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions"; |
10 | 8 | import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; |
11 | 9 | import { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; |
12 | 10 | import { moneyAmountToStripeUnits } from "@stackframe/stack-shared/dist/utils/currencies"; |
13 | 11 | import { SUPPORTED_CURRENCIES, type MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants"; |
14 | 12 | import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; |
| 13 | +import type Stripe from "stripe"; |
15 | 14 | import { InferType } from "yup"; |
16 | 15 |
|
17 | 16 | const USD_CURRENCY = SUPPORTED_CURRENCIES.find((currency) => currency.code === "USD") |
18 | 17 | ?? throwErr("USD currency configuration missing in SUPPORTED_CURRENCIES"); |
19 | 18 |
|
| 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 | + |
20 | 42 | function getTotalUsdStripeUnits(options: { product: InferType<typeof productSchema>, priceId: string | null, quantity: number }) { |
21 | 43 | const selectedPrice = resolveSelectedPriceFromProduct(options.product, options.priceId ?? null); |
22 | 44 | const usdPrice = selectedPrice?.USD; |
@@ -264,23 +286,9 @@ export const POST = createSmartRouteHandler({ |
264 | 286 | if (refundAmountStripeUnits > totalStripeUnits) { |
265 | 287 | throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount."); |
266 | 288 | } |
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, |
280 | 291 | amountStripeUnits: refundAmountStripeUnits, |
281 | | - currency: "usd", |
282 | | - sourceType: PlatformFeeSourceType.REFUND, |
283 | | - sourceId: subscriptionRefund.id, |
284 | 292 | })); |
285 | 293 | const refundedAt = new Date(); |
286 | 294 | if (refundedQuantity > 0) { |
@@ -379,21 +387,13 @@ export const POST = createSmartRouteHandler({ |
379 | 387 | if (refundAmountStripeUnits > totalStripeUnits) { |
380 | 388 | throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount."); |
381 | 389 | } |
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, |
385 | 393 | metadata: { |
386 | 394 | tenancyId: auth.tenancy.id, |
387 | 395 | purchaseId: purchase.id, |
388 | 396 | }, |
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, |
397 | 397 | })); |
398 | 398 | const refundedAt = new Date(); |
399 | 399 | await prisma.oneTimePurchase.update({ |
@@ -429,3 +429,31 @@ export const POST = createSmartRouteHandler({ |
429 | 429 | }; |
430 | 430 | }, |
431 | 431 | }); |
| 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 | +}); |
0 commit comments