@fantastic.dev/better-stripe 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +211 -2
  3. package/dist/client/billing/balanceFunding.d.ts +36 -0
  4. package/dist/client/billing/balanceFunding.d.ts.map +1 -0
  5. package/dist/client/billing/balanceFunding.js +107 -0
  6. package/dist/client/billing/balanceFunding.js.map +1 -0
  7. package/dist/client/billing/invoicePreview.d.ts +28 -0
  8. package/dist/client/billing/invoicePreview.d.ts.map +1 -0
  9. package/dist/client/billing/invoicePreview.js +56 -0
  10. package/dist/client/billing/invoicePreview.js.map +1 -0
  11. package/dist/client/charge-currency-format.d.ts +3 -0
  12. package/dist/client/charge-currency-format.d.ts.map +1 -0
  13. package/dist/client/charge-currency-format.js +22 -0
  14. package/dist/client/charge-currency-format.js.map +1 -0
  15. package/dist/client/charge-currency.d.ts +14 -0
  16. package/dist/client/charge-currency.d.ts.map +1 -0
  17. package/dist/client/charge-currency.js +85 -0
  18. package/dist/client/charge-currency.js.map +1 -0
  19. package/dist/client/costRecovery.d.ts +115 -0
  20. package/dist/client/costRecovery.d.ts.map +1 -0
  21. package/dist/client/costRecovery.js +50 -0
  22. package/dist/client/costRecovery.js.map +1 -0
  23. package/dist/client/index.d.ts +28 -66
  24. package/dist/client/index.d.ts.map +1 -1
  25. package/dist/client/index.js +25 -0
  26. package/dist/client/index.js.map +1 -1
  27. package/dist/client/products/prices.d.ts +3 -2
  28. package/dist/client/products/prices.d.ts.map +1 -1
  29. package/dist/client/products/prices.js +0 -3
  30. package/dist/client/products/prices.js.map +1 -1
  31. package/dist/client/products/products.d.ts +3 -2
  32. package/dist/client/products/products.d.ts.map +1 -1
  33. package/dist/client/products/products.js +0 -3
  34. package/dist/client/products/products.js.map +1 -1
  35. package/dist/client/settlement/processingCosts.d.ts +56 -0
  36. package/dist/client/settlement/processingCosts.d.ts.map +1 -0
  37. package/dist/client/settlement/processingCosts.js +173 -0
  38. package/dist/client/settlement/processingCosts.js.map +1 -0
  39. package/dist/client/utils/webhookEndpoints.d.ts +1 -1
  40. package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
  41. package/dist/client/utils/webhookEndpoints.js +1 -0
  42. package/dist/client/utils/webhookEndpoints.js.map +1 -1
  43. package/dist/client/webhooks/processors.d.ts.map +1 -1
  44. package/dist/client/webhooks/processors.js +1 -0
  45. package/dist/client/webhooks/processors.js.map +1 -1
  46. package/dist/component/_generated/component.d.ts +244 -0
  47. package/dist/component/_generated/component.d.ts.map +1 -1
  48. package/dist/component/costRecovery/claims.d.ts +68 -0
  49. package/dist/component/costRecovery/claims.d.ts.map +1 -0
  50. package/dist/component/costRecovery/claims.js +85 -0
  51. package/dist/component/costRecovery/claims.js.map +1 -0
  52. package/dist/component/costRecovery/completion.d.ts +33 -0
  53. package/dist/component/costRecovery/completion.d.ts.map +1 -0
  54. package/dist/component/costRecovery/completion.js +69 -0
  55. package/dist/component/costRecovery/completion.js.map +1 -0
  56. package/dist/component/costRecovery/helpers.d.ts +62 -0
  57. package/dist/component/costRecovery/helpers.d.ts.map +1 -0
  58. package/dist/component/costRecovery/helpers.js +71 -0
  59. package/dist/component/costRecovery/helpers.js.map +1 -0
  60. package/dist/component/costRecovery/mutations.d.ts +138 -0
  61. package/dist/component/costRecovery/mutations.d.ts.map +1 -0
  62. package/dist/component/costRecovery/mutations.js +36 -0
  63. package/dist/component/costRecovery/mutations.js.map +1 -0
  64. package/dist/component/costRecovery/queries.d.ts +102 -0
  65. package/dist/component/costRecovery/queries.d.ts.map +1 -0
  66. package/dist/component/costRecovery/queries.js +73 -0
  67. package/dist/component/costRecovery/queries.js.map +1 -0
  68. package/dist/component/costRecovery/reservations.d.ts +83 -0
  69. package/dist/component/costRecovery/reservations.d.ts.map +1 -0
  70. package/dist/component/costRecovery/reservations.js +134 -0
  71. package/dist/component/costRecovery/reservations.js.map +1 -0
  72. package/dist/component/costRecovery/schema.d.ts +340 -0
  73. package/dist/component/costRecovery/schema.d.ts.map +1 -0
  74. package/dist/component/costRecovery/schema.js +109 -0
  75. package/dist/component/costRecovery/schema.js.map +1 -0
  76. package/dist/component/schema.d.ts +119 -0
  77. package/dist/component/schema.js +5 -0
  78. package/dist/component/schema.js.map +1 -1
  79. package/dist/react/components/PriceCard.d.ts +3 -1
  80. package/dist/react/components/PriceCard.d.ts.map +1 -1
  81. package/dist/react/components/PriceCard.js +2 -2
  82. package/dist/react/components/PriceCard.js.map +1 -1
  83. package/dist/react/index.d.ts +1 -0
  84. package/dist/react/index.d.ts.map +1 -1
  85. package/dist/react/index.js +1 -0
  86. package/dist/react/index.js.map +1 -1
  87. package/dist/react/lib/price-helpers.d.ts +4 -6
  88. package/dist/react/lib/price-helpers.d.ts.map +1 -1
  89. package/dist/react/lib/price-helpers.js +16 -9
  90. package/dist/react/lib/price-helpers.js.map +1 -1
  91. package/dist/react/workflows/PlanPricingEditor.d.ts +4 -0
  92. package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -1
  93. package/dist/react/workflows/PlanPricingEditor.js +12 -9
  94. package/dist/react/workflows/PlanPricingEditor.js.map +1 -1
  95. package/docs/react-workflows.md +91 -0
  96. package/package.json +2 -2
  97. package/src/client/billing/balanceFunding.test.ts +257 -0
  98. package/src/client/billing/balanceFunding.ts +183 -0
  99. package/src/client/billing/balanceFundingTransport.test.ts +89 -0
  100. package/src/client/billing/invoicePreview.test.ts +123 -0
  101. package/src/client/billing/invoicePreview.ts +94 -0
  102. package/src/client/billing/invoicePreviewTransport.test.ts +70 -0
  103. package/src/client/charge-currency-format.ts +29 -0
  104. package/src/client/charge-currency.test.ts +113 -0
  105. package/src/client/charge-currency.ts +119 -0
  106. package/src/client/costRecovery.test.ts +76 -0
  107. package/src/client/costRecovery.ts +149 -0
  108. package/src/client/index.ts +76 -75
  109. package/src/client/products/prices.ts +13 -11
  110. package/src/client/products/products.ts +10 -10
  111. package/src/client/settlement/processingCosts.test.ts +349 -0
  112. package/src/client/settlement/processingCosts.ts +315 -0
  113. package/src/client/settlement/processingCostsTransport.test.ts +135 -0
  114. package/src/client/utils/webhookEndpoints.test.ts +5 -0
  115. package/src/client/utils/webhookEndpoints.ts +1 -0
  116. package/src/client/webhooks/hooks.test.ts +9 -0
  117. package/src/client/webhooks/payout-canceled.integration.test.ts +192 -0
  118. package/src/client/webhooks/processors.ts +1 -0
  119. package/src/component/_generated/api.ts +12 -0
  120. package/src/component/_generated/component.ts +308 -0
  121. package/src/component/cost-recovery.test.ts +344 -0
  122. package/src/component/costRecovery/claims.ts +109 -0
  123. package/src/component/costRecovery/completion.ts +92 -0
  124. package/src/component/costRecovery/helpers.ts +83 -0
  125. package/src/component/costRecovery/mutations.ts +43 -0
  126. package/src/component/costRecovery/queries.ts +88 -0
  127. package/src/component/costRecovery/reservations.ts +164 -0
  128. package/src/component/costRecovery/schema.ts +127 -0
  129. package/src/component/schema.ts +10 -0
  130. package/src/react/components/PayoutSchedule.render.test.tsx +15 -0
  131. package/src/react/components/PriceCard.render.test.tsx +37 -0
  132. package/src/react/components/PriceCard.tsx +9 -1
  133. package/src/react/index.test.ts +5 -0
  134. package/src/react/index.ts +7 -0
  135. package/src/react/lib/price-helpers.test.ts +15 -2
  136. package/src/react/lib/price-helpers.ts +25 -8
  137. package/src/react/workflows/Billing.test.tsx +1 -1
  138. package/src/react/workflows/PlanPricingEditor.currency.test.tsx +164 -0
  139. package/src/react/workflows/PlanPricingEditor.tsx +26 -10
@@ -0,0 +1,315 @@
1
+ import type Stripe from "stripe";
2
+
3
+ export type ProcessingCostOptions = {
4
+ /** Host authorizes the payment and optional connected-account context. */
5
+ paymentIntentId: string;
6
+ stripeAccountId?: string;
7
+ };
8
+ export type ProcessingCostTransaction = {
9
+ id: string;
10
+ sourceId: string;
11
+ sourceType: "charge" | "refund" | "refund_failure" | "dispute";
12
+ currency: string;
13
+ amount: number;
14
+ fee: number;
15
+ net: number;
16
+ created: number;
17
+ feeDetails:
18
+ | {
19
+ type: string;
20
+ amount: number;
21
+ currency: string;
22
+ application: string | null;
23
+ description: string | null;
24
+ }[]
25
+ | null;
26
+ };
27
+ export type ProcessingCostRecord = {
28
+ id: string;
29
+ status: string;
30
+ amount: number;
31
+ balanceTransactionIds: string[];
32
+ };
33
+ export type ProcessingCostNotReadyReason =
34
+ | "payment_not_succeeded"
35
+ | "charge_unavailable"
36
+ | "charge_transaction_unavailable"
37
+ | "refund_transaction_unavailable"
38
+ | "refund_failure_transaction_unavailable"
39
+ | "refund_pending"
40
+ | "dispute_transactions_unavailable"
41
+ | "dispute_reinstatement_unavailable"
42
+ | "currency_mismatch";
43
+ export type ProcessingCostContext =
44
+ | {
45
+ status: "not_ready";
46
+ paymentIntentId: string;
47
+ reason: ProcessingCostNotReadyReason;
48
+ }
49
+ | {
50
+ status: "ready";
51
+ paymentIntentId: string;
52
+ chargeId: string;
53
+ currency: string;
54
+ gross: number;
55
+ metadata: Record<string, string>;
56
+ processingFee: number;
57
+ refundFeeAdjustment: number;
58
+ disputeFeeAdjustment: number;
59
+ netFees: number;
60
+ refundedPrincipal: number;
61
+ disputedPrincipal: number;
62
+ /** Capped economic principal; raw provider debits remain in transactions. */
63
+ principalReturned: number;
64
+ refunds: ProcessingCostRecord[];
65
+ disputes: ProcessingCostRecord[];
66
+ transactions: ProcessingCostTransaction[];
67
+ };
68
+ class NotReady extends Error {
69
+ constructor(readonly reason: ProcessingCostNotReadyReason) {
70
+ super(reason);
71
+ }
72
+ }
73
+ const id = (value: string | { id: string } | null) =>
74
+ typeof value === "string" ? value : value?.id;
75
+ function integer(value: number) {
76
+ if (!Number.isSafeInteger(value))
77
+ throw new Error("Processing cost amounts and dates must be safe integers.");
78
+ return value;
79
+ }
80
+ const sum = (values: number[]) => values.reduce((a, b) => integer(a + b), 0);
81
+
82
+ /** Authoritative read only. Refunded/disputed charges are valid cost sources. */
83
+ export async function getProcessingCostContext(
84
+ stripe: Stripe,
85
+ opts: ProcessingCostOptions,
86
+ ): Promise<ProcessingCostContext> {
87
+ const requestOptions = opts.stripeAccountId
88
+ ? { stripeAccount: opts.stripeAccountId }
89
+ : undefined;
90
+ try {
91
+ const payment = await stripe.paymentIntents.retrieve(
92
+ opts.paymentIntentId,
93
+ { expand: ["latest_charge.balance_transaction"] },
94
+ requestOptions,
95
+ );
96
+ if (payment.status !== "succeeded")
97
+ throw new NotReady("payment_not_succeeded");
98
+ const charge = payment.latest_charge;
99
+ if (!charge || typeof charge === "string")
100
+ throw new NotReady("charge_unavailable");
101
+ if (id(charge.payment_intent) !== payment.id)
102
+ throw new Error("Charge does not belong to the payment.");
103
+ const currency = payment.currency;
104
+ if (charge.currency !== currency) throw new NotReady("currency_mismatch");
105
+ const transactions = new Map<string, ProcessingCostTransaction>();
106
+ async function add(
107
+ value: Stripe.BalanceTransaction | string | null,
108
+ sourceId: string,
109
+ sourceType: ProcessingCostTransaction["sourceType"],
110
+ missing: ProcessingCostNotReadyReason,
111
+ ) {
112
+ if (!value) throw new NotReady(missing);
113
+ const tx =
114
+ typeof value === "string"
115
+ ? await stripe.balanceTransactions.retrieve(value, {}, requestOptions)
116
+ : value;
117
+ if (tx.currency !== currency) throw new NotReady("currency_mismatch");
118
+ [tx.amount, tx.fee, tx.net].forEach(integer);
119
+ if (integer(tx.amount - tx.fee) !== tx.net)
120
+ throw new Error("Provider balance transaction does not reconcile.");
121
+ const existing = transactions.get(tx.id);
122
+ if (
123
+ existing &&
124
+ (existing.sourceId !== sourceId || existing.sourceType !== sourceType)
125
+ )
126
+ throw new Error(
127
+ "Balance transaction belongs to multiple cost sources.",
128
+ );
129
+ const feeDetails =
130
+ tx.fee_details?.map((detail) => {
131
+ if (detail.currency !== currency)
132
+ throw new NotReady("currency_mismatch");
133
+ return {
134
+ type: detail.type,
135
+ amount: integer(detail.amount),
136
+ currency: detail.currency,
137
+ application: detail.application,
138
+ description: detail.description,
139
+ };
140
+ }) ?? null;
141
+ transactions.set(tx.id, {
142
+ id: tx.id,
143
+ sourceId,
144
+ sourceType,
145
+ currency,
146
+ amount: tx.amount,
147
+ fee: tx.fee,
148
+ net: tx.net,
149
+ created: integer(tx.created * 1000),
150
+ feeDetails,
151
+ });
152
+ return tx.id;
153
+ }
154
+ const chargeTransactionId = await add(
155
+ charge.balance_transaction,
156
+ charge.id,
157
+ "charge",
158
+ "charge_transaction_unavailable",
159
+ );
160
+ const original = transactions.get(chargeTransactionId)!;
161
+ const gross = integer(payment.amount_received);
162
+ if (gross < 0 || original.amount !== gross)
163
+ throw new Error("Payment and processed charge amounts do not reconcile.");
164
+ const refunds = new Map<string, ProcessingCostRecord>();
165
+ for await (const refund of stripe.refunds.list(
166
+ {
167
+ payment_intent: payment.id,
168
+ limit: 100,
169
+ expand: [
170
+ "data.balance_transaction",
171
+ "data.failure_balance_transaction",
172
+ ],
173
+ },
174
+ requestOptions,
175
+ )) {
176
+ if (
177
+ id(refund.charge) !== charge.id ||
178
+ (refund.payment_intent && id(refund.payment_intent) !== payment.id)
179
+ )
180
+ throw new Error("Refund does not belong to the payment charge.");
181
+ if (refund.currency !== currency) throw new NotReady("currency_mismatch");
182
+ if (!["succeeded", "failed", "canceled"].includes(refund.status ?? ""))
183
+ throw new NotReady("refund_pending");
184
+ const amount = integer(refund.amount);
185
+ if (amount < 0) throw new Error("Refund amount must be nonnegative.");
186
+ const ids: string[] = [];
187
+ if (refund.balance_transaction || refund.status === "succeeded")
188
+ ids.push(
189
+ await add(
190
+ refund.balance_transaction,
191
+ refund.id,
192
+ "refund",
193
+ "refund_transaction_unavailable",
194
+ ),
195
+ );
196
+ if (
197
+ refund.failure_balance_transaction ||
198
+ (refund.status === "failed" && refund.balance_transaction)
199
+ )
200
+ ids.push(
201
+ await add(
202
+ refund.failure_balance_transaction ?? null,
203
+ refund.id,
204
+ "refund_failure",
205
+ "refund_failure_transaction_unavailable",
206
+ ),
207
+ );
208
+ refunds.set(refund.id, {
209
+ id: refund.id,
210
+ status: refund.status!,
211
+ amount,
212
+ balanceTransactionIds: [...new Set(ids)],
213
+ });
214
+ }
215
+ const disputes = new Map<string, ProcessingCostRecord>();
216
+ for await (const dispute of stripe.disputes.list(
217
+ { payment_intent: payment.id, limit: 100 },
218
+ requestOptions,
219
+ )) {
220
+ if (
221
+ id(dispute.charge) !== charge.id ||
222
+ (dispute.payment_intent && id(dispute.payment_intent) !== payment.id)
223
+ )
224
+ throw new Error("Dispute does not belong to the payment charge.");
225
+ if (dispute.currency !== currency)
226
+ throw new NotReady("currency_mismatch");
227
+ if (
228
+ !dispute.balance_transactions.length &&
229
+ !dispute.status.startsWith("warning_")
230
+ )
231
+ throw new NotReady("dispute_transactions_unavailable");
232
+ const amount = integer(dispute.amount);
233
+ if (amount < 0) throw new Error("Dispute amount must be nonnegative.");
234
+ const ids: string[] = [];
235
+ for (const tx of dispute.balance_transactions)
236
+ ids.push(
237
+ await add(
238
+ tx,
239
+ dispute.id,
240
+ "dispute",
241
+ "dispute_transactions_unavailable",
242
+ ),
243
+ );
244
+ if (
245
+ dispute.status === "won" &&
246
+ sum([...new Set(ids)].map((txId) => transactions.get(txId)!.amount)) < 0
247
+ )
248
+ throw new NotReady("dispute_reinstatement_unavailable");
249
+ disputes.set(dispute.id, {
250
+ id: dispute.id,
251
+ status: dispute.status,
252
+ amount,
253
+ balanceTransactionIds: [...new Set(ids)],
254
+ });
255
+ }
256
+ const all = [...transactions.values()];
257
+ const refundFeeAdjustment = sum(
258
+ all
259
+ .filter(
260
+ (tx) =>
261
+ tx.sourceType === "refund" || tx.sourceType === "refund_failure",
262
+ )
263
+ .map((tx) => tx.fee),
264
+ );
265
+ const disputeFeeAdjustment = sum(
266
+ all.filter((tx) => tx.sourceType === "dispute").map((tx) => tx.fee),
267
+ );
268
+ const refundedPrincipal = sum(
269
+ [...refunds.values()]
270
+ .filter((refund) => refund.status === "succeeded")
271
+ .map((refund) => refund.amount),
272
+ );
273
+ const disputedPrincipal = sum(
274
+ [...disputes.values()].map((dispute) =>
275
+ Math.max(
276
+ 0,
277
+ -sum(
278
+ dispute.balanceTransactionIds.map(
279
+ (txId) => transactions.get(txId)!.amount,
280
+ ),
281
+ ),
282
+ ),
283
+ ),
284
+ );
285
+ return {
286
+ status: "ready",
287
+ paymentIntentId: payment.id,
288
+ chargeId: charge.id,
289
+ currency,
290
+ gross,
291
+ metadata: payment.metadata,
292
+ processingFee: original.fee,
293
+ refundFeeAdjustment,
294
+ disputeFeeAdjustment,
295
+ netFees: sum([original.fee, refundFeeAdjustment, disputeFeeAdjustment]),
296
+ refundedPrincipal,
297
+ disputedPrincipal,
298
+ principalReturned: Math.min(
299
+ gross,
300
+ sum([refundedPrincipal, disputedPrincipal]),
301
+ ),
302
+ refunds: [...refunds.values()],
303
+ disputes: [...disputes.values()],
304
+ transactions: all,
305
+ };
306
+ } catch (error) {
307
+ if (error instanceof NotReady)
308
+ return {
309
+ status: "not_ready",
310
+ paymentIntentId: opts.paymentIntentId,
311
+ reason: error.reason,
312
+ };
313
+ throw error;
314
+ }
315
+ }
@@ -0,0 +1,135 @@
1
+ import type Stripe from "stripe";
2
+ import { componentsGeneric } from "convex/server";
3
+ import { expect, it, vi } from "vitest";
4
+ import { BetterStripe } from "../index.js";
5
+ import { getStripeClient, type Component, type RunCtx } from "../helpers.js";
6
+
7
+ it.each([undefined, "2026-09-30.preview"] as const)(
8
+ "paginates using the configured version and connected namespace: %s",
9
+ async (version) => {
10
+ const key = `sk_test_cost_transport_${crypto.randomUUID()}`;
11
+ const bs = new BetterStripe(
12
+ componentsGeneric().betterStripe as unknown as Component,
13
+ {
14
+ STRIPE_SECRET_KEY: key,
15
+ stripeApiVersion: version,
16
+ },
17
+ );
18
+ const calls: {
19
+ path: string;
20
+ version: string;
21
+ account: string;
22
+ method: string;
23
+ }[] = [];
24
+ const sdk = getStripeClient(key, version);
25
+ const tx = (id: string, amount: number, fee = 0) => ({
26
+ id,
27
+ amount,
28
+ fee,
29
+ net: amount - fee,
30
+ currency: "usd",
31
+ created: 1800000000,
32
+ });
33
+ const refund = (id: string) => ({
34
+ id,
35
+ amount: 100,
36
+ status: "succeeded",
37
+ currency: "usd",
38
+ charge: "ch_test",
39
+ payment_intent: "pi_test",
40
+ balance_transaction: tx(`txn_${id}`, -100),
41
+ });
42
+ (
43
+ sdk as unknown as { _setApiField: (key: string, value: unknown) => void }
44
+ )._setApiField("httpClient", {
45
+ getClientName: () => "cost-context-contract",
46
+ makeRequest: async (
47
+ ...[, , path, method, headers]: Parameters<
48
+ ReturnType<typeof Stripe.createNodeHttpClient>["makeRequest"]
49
+ >
50
+ ) => {
51
+ calls.push({
52
+ path,
53
+ method,
54
+ version: String(headers["Stripe-Version"]),
55
+ account: String(headers["Stripe-Account"]),
56
+ });
57
+ const next = path.includes("starting_after");
58
+ const data = path.includes("/payment_intents/")
59
+ ? {
60
+ id: "pi_test",
61
+ status: "succeeded",
62
+ currency: "usd",
63
+ amount_received: 10000,
64
+ metadata: {},
65
+ latest_charge: {
66
+ id: "ch_test",
67
+ payment_intent: "pi_test",
68
+ currency: "usd",
69
+ balance_transaction: tx("txn_charge", 10000, 320),
70
+ },
71
+ }
72
+ : path.includes("/refunds")
73
+ ? {
74
+ object: "list",
75
+ url: "/v1/refunds",
76
+ has_more: !next,
77
+ data: [refund(next ? "re_second" : "re_first")],
78
+ }
79
+ : {
80
+ object: "list",
81
+ url: "/v1/disputes",
82
+ has_more: !next,
83
+ data: [
84
+ {
85
+ id: next ? "dp_second" : "dp_first",
86
+ status: "warning_closed",
87
+ amount: 100,
88
+ currency: "usd",
89
+ charge: "ch_test",
90
+ payment_intent: "pi_test",
91
+ balance_transactions: [],
92
+ },
93
+ ],
94
+ };
95
+ return {
96
+ getStatusCode: () => 200,
97
+ getHeaders: () => ({}),
98
+ getRawResponse: () => ({}),
99
+ toJSON: async () => data,
100
+ toStream: () => {
101
+ throw new Error("Unexpected stream");
102
+ },
103
+ };
104
+ },
105
+ });
106
+ const ctx = {
107
+ runQuery: vi.fn(),
108
+ runMutation: vi.fn(),
109
+ } as unknown as RunCtx;
110
+ expect(
111
+ await bs.getProcessingCostContext(ctx, {
112
+ paymentIntentId: "pi_test",
113
+ stripeAccountId: "acct_connected",
114
+ }),
115
+ ).toMatchObject({
116
+ status: "ready",
117
+ refundedPrincipal: 200,
118
+ refunds: [{ id: "re_first" }, { id: "re_second" }],
119
+ disputes: [{ id: "dp_first" }, { id: "dp_second" }],
120
+ });
121
+ expect(calls).toHaveLength(5);
122
+ expect(
123
+ calls.every(
124
+ (call) =>
125
+ call.method === "GET" &&
126
+ call.version === (version ?? "2026-09-30.endive") &&
127
+ call.account === "acct_connected",
128
+ ),
129
+ ).toBe(true);
130
+ expect(
131
+ calls.filter((call) => call.path.includes("starting_after")),
132
+ ).toHaveLength(2);
133
+ expect(ctx.runMutation).not.toHaveBeenCalled();
134
+ },
135
+ );
@@ -51,6 +51,11 @@ const asStripe = (s: ReturnType<typeof makeStripe>) =>
51
51
  const ctx = {} as RunCtx;
52
52
 
53
53
  describe("event constant exports", () => {
54
+ it("selects dedicated payout cancellations by default", () => {
55
+ expect(BETTER_STRIPE_WEBHOOK_EVENTS).toContain("payout.canceled");
56
+ expect(ALL_BETTER_STRIPE_EVENTS).toContain("payout.canceled");
57
+ });
58
+
54
59
  it("ALL_BETTER_STRIPE_EVENTS is the concatenation of V1 then V2 events", () => {
55
60
  expect(ALL_BETTER_STRIPE_EVENTS).toEqual([
56
61
  ...BETTER_STRIPE_WEBHOOK_EVENTS,
@@ -39,6 +39,7 @@ export const BETTER_STRIPE_WEBHOOK_EVENTS = [
39
39
  "payout.updated",
40
40
  "payout.paid",
41
41
  "payout.failed",
42
+ "payout.canceled",
42
43
  // Refunds
43
44
  "refund.created",
44
45
  "refund.updated",
@@ -108,3 +108,12 @@ describe("scheduleAsyncHook — invoice.payment_failed (BTS-33)", () => {
108
108
  );
109
109
  });
110
110
  });
111
+
112
+ describe("scheduleAsyncHook — payout cancellation", () => {
113
+ it("does not schedule the paid convenience hook for cancellation", async () => {
114
+ const whCtx = makeWhCtx({ doc: { status: "canceled" } });
115
+ await scheduleAsyncHook(whCtx, "payout.canceled", "po_canceled");
116
+ expect(whCtx.ctx.runQuery).not.toHaveBeenCalled();
117
+ expect(whCtx.ctx.scheduler?.runAfter).not.toHaveBeenCalled();
118
+ });
119
+ });
@@ -0,0 +1,192 @@
1
+ // @vitest-environment edge-runtime
2
+ import Stripe from "stripe";
3
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
4
+
5
+ import schema from "../../component/schema.js";
6
+ import { getStripeClient } from "../helpers.js";
7
+ import { components, initConvexTest } from "../setup.test.js";
8
+ import type { RegisterRoutesConfig, StripeWebhookEvent } from "../types.js";
9
+ import { handleWebhookRequest } from "./handler.js";
10
+
11
+ const modules = import.meta.glob("../../component/**/*.{ts,js,tsx,jsx}");
12
+ const key = "sk_test_payout_cancellation_regression";
13
+ const signingSecret = "whsec_payout_cancellation_regression";
14
+ const payoutRef = components.betterStripe.connect.queries.getPayoutByStripeId;
15
+ const ledgerRef = components.betterStripe.webhooks.queries.getWebhookEvent;
16
+ const transport = vi.fn(async () => {
17
+ throw new Error("Unexpected provider request in payout regression");
18
+ });
19
+ beforeEach(() => {
20
+ transport.mockClear();
21
+ vi.spyOn(
22
+ getStripeClient(key).getApiField("httpClient"),
23
+ "makeRequest",
24
+ ).mockImplementation(transport);
25
+ });
26
+ afterEach(() => vi.restoreAllMocks());
27
+
28
+ type PayoutEvent = Extract<
29
+ StripeWebhookEvent,
30
+ {
31
+ type:
32
+ | "payout.created"
33
+ | "payout.updated"
34
+ | "payout.paid"
35
+ | "payout.failed"
36
+ | "payout.canceled";
37
+ }
38
+ >;
39
+
40
+ function fixture(
41
+ type: PayoutEvent["type"] = "payout.created",
42
+ status: PayoutEvent["data"]["object"]["status"] = "pending",
43
+ ) {
44
+ const t = initConvexTest();
45
+ t.registerComponent("betterStripe", schema, modules);
46
+ const event: PayoutEvent = {
47
+ id: `evt_qa_${type.replace(".", "_")}`,
48
+ object: "event" as const,
49
+ api_version: null,
50
+ created: 1792010000,
51
+ pending_webhooks: 1,
52
+ request: null,
53
+ type,
54
+ account: "acct_qa_recipient",
55
+ livemode: false,
56
+ data: {
57
+ object: {
58
+ id: "po_qa_audit",
59
+ object: "payout" as const,
60
+ amount: 1200,
61
+ application_fee: null,
62
+ application_fee_amount: null,
63
+ automatic: true,
64
+ balance_transaction: null,
65
+ created: 1792010000,
66
+ description: null,
67
+ failure_balance_transaction: null,
68
+ failure_code: null,
69
+ failure_message: null,
70
+ livemode: false,
71
+ original_payout: null,
72
+ payout_method: null,
73
+ reconciliation_status: "not_applicable",
74
+ reversed_by: null,
75
+ source_type: "card",
76
+ statement_descriptor: null,
77
+ trace_id: null,
78
+ type: "bank_account",
79
+ currency: "usd",
80
+ status,
81
+ arrival_date: 1792108800,
82
+ method: "standard",
83
+ destination: "ba_qa_destination",
84
+ metadata: { accountId: "acct_untrusted_metadata" },
85
+ },
86
+ },
87
+ };
88
+ const payout = () => t.query(payoutRef, { stripePayoutId: "po_qa_audit" });
89
+ const deliver = async (config: RegisterRoutesConfig = {}, tamper = false) => {
90
+ const body = JSON.stringify(event);
91
+ const signature = await Stripe.webhooks.generateTestHeaderStringAsync({
92
+ payload: body,
93
+ secret: signingSecret,
94
+ });
95
+ return t.action(async (ctx) => {
96
+ const response = await handleWebhookRequest(
97
+ ctx,
98
+ new Request("https://example.test/stripe/webhook", {
99
+ method: "POST",
100
+ body: tamper ? body.replace("po_qa_audit", "po_tampered") : body,
101
+ headers: { "stripe-signature": signature },
102
+ }),
103
+ components.betterStripe,
104
+ { stripeSecretKey: key, webhookSecret: signingSecret, ...config },
105
+ );
106
+ return { status: response.status, body: await response.json() };
107
+ });
108
+ };
109
+ return { t, event, payout, deliver };
110
+ }
111
+
112
+ it("reconciles signed cancellation before callbacks and deduplicates replay", async () => {
113
+ const f = fixture("payout.created", "pending");
114
+ expect((await f.deliver()).status).toBe(200);
115
+ const created = await f.payout();
116
+ expect(created?.status).toBe("pending");
117
+ f.event.id = "evt_canceled_regression";
118
+ f.event.type = "payout.canceled";
119
+ f.event.data.object.status = "canceled";
120
+ const observed: string[] = [];
121
+ const paid = vi.fn();
122
+ const config: RegisterRoutesConfig = {
123
+ onEvent: async (ctx) => {
124
+ const row = await ctx.runQuery(payoutRef, {
125
+ stripePayoutId: "po_qa_audit",
126
+ });
127
+ observed.push(`all:${row?.status}`);
128
+ },
129
+ events: {
130
+ "payout.canceled": async (ctx) => {
131
+ const row = await ctx.runQuery(payoutRef, {
132
+ stripePayoutId: "po_qa_audit",
133
+ });
134
+ observed.push(`canceled:${row?.status}`);
135
+ },
136
+ "payout.paid": paid,
137
+ },
138
+ };
139
+ expect((await f.deliver(config)).status).toBe(200);
140
+ const canceled = await f.payout();
141
+ expect(canceled).toMatchObject({
142
+ _id: created?._id,
143
+ stripePayoutId: "po_qa_audit",
144
+ accountId: "acct_qa_recipient",
145
+ amount: 1200,
146
+ currency: "usd",
147
+ status: "canceled",
148
+ });
149
+ expect(observed).toEqual(["all:canceled", "canceled:canceled"]);
150
+ expect(await f.deliver(config)).toMatchObject({
151
+ status: 200,
152
+ body: { deduplicated: true },
153
+ });
154
+ expect(await f.payout()).toEqual(canceled);
155
+ expect(observed).toEqual(["all:canceled", "canceled:canceled"]);
156
+ expect(paid).not.toHaveBeenCalled();
157
+ expect(
158
+ await f.t.query(ledgerRef, { stripeEventId: f.event.id }),
159
+ ).toMatchObject({ status: "processed" });
160
+ expect(transport).not.toHaveBeenCalled();
161
+ });
162
+
163
+ it("rejects a tampered cancellation before storage or callbacks", async () => {
164
+ const f = fixture("payout.canceled", "canceled");
165
+ const onEvent = vi.fn();
166
+ expect((await f.deliver({ onEvent }, true)).status).toBe(400);
167
+ expect(await f.payout()).toBeNull();
168
+ expect(await f.t.query(ledgerRef, { stripeEventId: f.event.id })).toBeNull();
169
+ expect(onEvent).not.toHaveBeenCalled();
170
+ expect(transport).not.toHaveBeenCalled();
171
+ });
172
+
173
+ it.each([
174
+ ["payout.created", "pending"],
175
+ ["payout.updated", "in_transit"],
176
+ ["payout.paid", "paid"],
177
+ ["payout.failed", "failed"],
178
+ ] as const)(
179
+ "preserves signed %s processing and replay",
180
+ async (type, status) => {
181
+ const f = fixture(type, status);
182
+ expect((await f.deliver()).status).toBe(200);
183
+ const first = await f.payout();
184
+ expect(first).toMatchObject({ accountId: "acct_qa_recipient", status });
185
+ expect(await f.deliver()).toMatchObject({
186
+ status: 200,
187
+ body: { deduplicated: true },
188
+ });
189
+ expect(await f.payout()).toEqual(first);
190
+ expect(transport).not.toHaveBeenCalled();
191
+ },
192
+ );
@@ -103,6 +103,7 @@ export async function processEvent(
103
103
  case "payout.updated":
104
104
  case "payout.paid":
105
105
  case "payout.failed":
106
+ case "payout.canceled":
106
107
  await handlePayoutEvent(whCtx, obj as Stripe.Payout, event.account);
107
108
  break;
108
109
  case "refund.created":