@fantastic.dev/better-stripe 0.11.0 → 0.13.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 (168) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +267 -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-invoices.d.ts +38 -0
  20. package/dist/client/costRecovery-invoices.d.ts.map +1 -0
  21. package/dist/client/costRecovery-invoices.js +105 -0
  22. package/dist/client/costRecovery-invoices.js.map +1 -0
  23. package/dist/client/costRecovery.d.ts +156 -0
  24. package/dist/client/costRecovery.d.ts.map +1 -0
  25. package/dist/client/costRecovery.js +117 -0
  26. package/dist/client/costRecovery.js.map +1 -0
  27. package/dist/client/index.d.ts +28 -66
  28. package/dist/client/index.d.ts.map +1 -1
  29. package/dist/client/index.js +25 -0
  30. package/dist/client/index.js.map +1 -1
  31. package/dist/client/products/prices.d.ts +3 -2
  32. package/dist/client/products/prices.d.ts.map +1 -1
  33. package/dist/client/products/prices.js +0 -3
  34. package/dist/client/products/prices.js.map +1 -1
  35. package/dist/client/products/products.d.ts +3 -2
  36. package/dist/client/products/products.d.ts.map +1 -1
  37. package/dist/client/products/products.js +0 -3
  38. package/dist/client/products/products.js.map +1 -1
  39. package/dist/client/settlement/processingCosts.d.ts +56 -0
  40. package/dist/client/settlement/processingCosts.d.ts.map +1 -0
  41. package/dist/client/settlement/processingCosts.js +173 -0
  42. package/dist/client/settlement/processingCosts.js.map +1 -0
  43. package/dist/client/utils/webhookEndpoints.d.ts +1 -1
  44. package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
  45. package/dist/client/utils/webhookEndpoints.js +1 -0
  46. package/dist/client/utils/webhookEndpoints.js.map +1 -1
  47. package/dist/client/webhooks/processors.d.ts.map +1 -1
  48. package/dist/client/webhooks/processors.js +1 -0
  49. package/dist/client/webhooks/processors.js.map +1 -1
  50. package/dist/component/_generated/component.d.ts +322 -0
  51. package/dist/component/_generated/component.d.ts.map +1 -1
  52. package/dist/component/costRecovery/claims.d.ts +68 -0
  53. package/dist/component/costRecovery/claims.d.ts.map +1 -0
  54. package/dist/component/costRecovery/claims.js +92 -0
  55. package/dist/component/costRecovery/claims.js.map +1 -0
  56. package/dist/component/costRecovery/completion.d.ts +39 -0
  57. package/dist/component/costRecovery/completion.d.ts.map +1 -0
  58. package/dist/component/costRecovery/completion.js +69 -0
  59. package/dist/component/costRecovery/completion.js.map +1 -0
  60. package/dist/component/costRecovery/helpers.d.ts +68 -0
  61. package/dist/component/costRecovery/helpers.d.ts.map +1 -0
  62. package/dist/component/costRecovery/helpers.js +71 -0
  63. package/dist/component/costRecovery/helpers.js.map +1 -0
  64. package/dist/component/costRecovery/mutations.d.ts +210 -0
  65. package/dist/component/costRecovery/mutations.d.ts.map +1 -0
  66. package/dist/component/costRecovery/mutations.js +93 -0
  67. package/dist/component/costRecovery/mutations.js.map +1 -0
  68. package/dist/component/costRecovery/queries.d.ts +108 -0
  69. package/dist/component/costRecovery/queries.d.ts.map +1 -0
  70. package/dist/component/costRecovery/queries.js +73 -0
  71. package/dist/component/costRecovery/queries.js.map +1 -0
  72. package/dist/component/costRecovery/reservations.d.ts +95 -0
  73. package/dist/component/costRecovery/reservations.d.ts.map +1 -0
  74. package/dist/component/costRecovery/reservations.js +134 -0
  75. package/dist/component/costRecovery/reservations.js.map +1 -0
  76. package/dist/component/costRecovery/schema.d.ts +382 -0
  77. package/dist/component/costRecovery/schema.d.ts.map +1 -0
  78. package/dist/component/costRecovery/schema.js +115 -0
  79. package/dist/component/costRecovery/schema.js.map +1 -0
  80. package/dist/component/schema.d.ts +135 -0
  81. package/dist/component/schema.js +5 -0
  82. package/dist/component/schema.js.map +1 -1
  83. package/dist/react/components/PriceCard.d.ts +3 -1
  84. package/dist/react/components/PriceCard.d.ts.map +1 -1
  85. package/dist/react/components/PriceCard.js +2 -2
  86. package/dist/react/components/PriceCard.js.map +1 -1
  87. package/dist/react/index.d.ts +1 -0
  88. package/dist/react/index.d.ts.map +1 -1
  89. package/dist/react/index.js +1 -0
  90. package/dist/react/index.js.map +1 -1
  91. package/dist/react/lib/price-helpers.d.ts +4 -6
  92. package/dist/react/lib/price-helpers.d.ts.map +1 -1
  93. package/dist/react/lib/price-helpers.js +16 -9
  94. package/dist/react/lib/price-helpers.js.map +1 -1
  95. package/dist/react/workflows/Collections.d.ts.map +1 -1
  96. package/dist/react/workflows/Collections.js +2 -1
  97. package/dist/react/workflows/Collections.js.map +1 -1
  98. package/dist/react/workflows/PlanPricingEditor.d.ts +4 -0
  99. package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -1
  100. package/dist/react/workflows/PlanPricingEditor.js +12 -9
  101. package/dist/react/workflows/PlanPricingEditor.js.map +1 -1
  102. package/dist/react/workflows/Refunds.d.ts +14 -3
  103. package/dist/react/workflows/Refunds.d.ts.map +1 -1
  104. package/dist/react/workflows/Refunds.js +58 -16
  105. package/dist/react/workflows/Refunds.js.map +1 -1
  106. package/dist/react/workflows/index.d.ts +3 -1
  107. package/dist/react/workflows/index.d.ts.map +1 -1
  108. package/dist/react/workflows/index.js +2 -1
  109. package/dist/react/workflows/index.js.map +1 -1
  110. package/dist/react/workflows/refund-preview.d.ts +16 -0
  111. package/dist/react/workflows/refund-preview.d.ts.map +1 -0
  112. package/dist/react/workflows/refund-preview.js +38 -0
  113. package/dist/react/workflows/refund-preview.js.map +1 -0
  114. package/docs/react-workflows.md +167 -1
  115. package/package.json +2 -2
  116. package/src/client/billing/balanceFunding.test.ts +257 -0
  117. package/src/client/billing/balanceFunding.ts +183 -0
  118. package/src/client/billing/balanceFundingTransport.test.ts +89 -0
  119. package/src/client/billing/invoicePreview.test.ts +123 -0
  120. package/src/client/billing/invoicePreview.ts +94 -0
  121. package/src/client/billing/invoicePreviewTransport.test.ts +70 -0
  122. package/src/client/charge-currency-format.ts +29 -0
  123. package/src/client/charge-currency.test.ts +113 -0
  124. package/src/client/charge-currency.ts +119 -0
  125. package/src/client/costRecovery-invoices.test.ts +401 -0
  126. package/src/client/costRecovery-invoices.transport.test.ts +107 -0
  127. package/src/client/costRecovery-invoices.ts +144 -0
  128. package/src/client/costRecovery.test.ts +76 -0
  129. package/src/client/costRecovery.ts +254 -0
  130. package/src/client/index.ts +76 -75
  131. package/src/client/products/prices.ts +13 -11
  132. package/src/client/products/products.ts +10 -10
  133. package/src/client/settlement/processingCosts.test.ts +349 -0
  134. package/src/client/settlement/processingCosts.ts +315 -0
  135. package/src/client/settlement/processingCostsTransport.test.ts +135 -0
  136. package/src/client/utils/webhookEndpoints.test.ts +5 -0
  137. package/src/client/utils/webhookEndpoints.ts +1 -0
  138. package/src/client/webhooks/hooks.test.ts +9 -0
  139. package/src/client/webhooks/payout-canceled.integration.test.ts +192 -0
  140. package/src/client/webhooks/processors.ts +1 -0
  141. package/src/component/_generated/api.ts +12 -0
  142. package/src/component/_generated/component.ts +407 -0
  143. package/src/component/cost-recovery.test.ts +344 -0
  144. package/src/component/costRecovery/claims.ts +119 -0
  145. package/src/component/costRecovery/completion.ts +92 -0
  146. package/src/component/costRecovery/helpers.ts +83 -0
  147. package/src/component/costRecovery/mutations.ts +115 -0
  148. package/src/component/costRecovery/queries.ts +88 -0
  149. package/src/component/costRecovery/reservations.ts +164 -0
  150. package/src/component/costRecovery/schema.ts +135 -0
  151. package/src/component/schema.ts +10 -0
  152. package/src/react/components/PayoutSchedule.render.test.tsx +15 -0
  153. package/src/react/components/PriceCard.render.test.tsx +37 -0
  154. package/src/react/components/PriceCard.tsx +9 -1
  155. package/src/react/index.test.ts +5 -0
  156. package/src/react/index.ts +7 -0
  157. package/src/react/lib/price-helpers.test.ts +15 -2
  158. package/src/react/lib/price-helpers.ts +25 -8
  159. package/src/react/workflows/Billing.test.tsx +1 -1
  160. package/src/react/workflows/Collections.test.tsx +36 -0
  161. package/src/react/workflows/Collections.tsx +3 -1
  162. package/src/react/workflows/PlanPricingEditor.currency.test.tsx +164 -0
  163. package/src/react/workflows/PlanPricingEditor.tsx +26 -10
  164. package/src/react/workflows/Refunds.currency.test.tsx +290 -0
  165. package/src/react/workflows/Refunds.tsx +115 -25
  166. package/src/react/workflows/index.ts +8 -1
  167. package/src/react/workflows/refund-preview.test.ts +65 -0
  168. package/src/react/workflows/refund-preview.ts +56 -0
@@ -0,0 +1,349 @@
1
+ import type Stripe from "stripe";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { getProcessingCostContext } from "./processingCosts.js";
4
+
5
+ const transaction = (
6
+ id: string,
7
+ amount: number,
8
+ fee: number,
9
+ currency = "usd",
10
+ ) => ({
11
+ id,
12
+ amount,
13
+ fee,
14
+ net: amount - fee,
15
+ currency,
16
+ created: 1800000000,
17
+ });
18
+ const charge = {
19
+ id: "ch_1",
20
+ payment_intent: "pi_1",
21
+ amount: 10000,
22
+ amount_captured: 10000,
23
+ currency: "usd",
24
+ disputed: false,
25
+ amount_refunded: 0,
26
+ balance_transaction: transaction("txn_charge", 10000, 320),
27
+ };
28
+ const payment = {
29
+ id: "pi_1",
30
+ status: "succeeded",
31
+ currency: "usd",
32
+ amount_received: 10000,
33
+ metadata: { sellerId: "store" },
34
+ latest_charge: charge,
35
+ };
36
+ const raw = {
37
+ paymentIntents: { retrieve: vi.fn() },
38
+ balanceTransactions: { retrieve: vi.fn() },
39
+ refunds: { list: vi.fn() },
40
+ disputes: { list: vi.fn() },
41
+ };
42
+ const stripe = raw as unknown as Stripe;
43
+ function page<T>(items: T[]) {
44
+ return (async function* () {
45
+ for (const item of items) yield item;
46
+ })();
47
+ }
48
+ const refund = (id: string, amount: number, fee = 0) => ({
49
+ id,
50
+ amount,
51
+ currency: "usd",
52
+ charge: "ch_1",
53
+ payment_intent: "pi_1",
54
+ status: "succeeded",
55
+ balance_transaction: transaction(`txn_${id}`, -amount, fee),
56
+ failure_balance_transaction: null,
57
+ });
58
+ const dispute = (
59
+ status: string,
60
+ transactions: ReturnType<typeof transaction>[],
61
+ ) => ({
62
+ id: "dp_1",
63
+ charge: "ch_1",
64
+ payment_intent: "pi_1",
65
+ amount: 10000,
66
+ currency: "usd",
67
+ status,
68
+ balance_transactions: transactions,
69
+ });
70
+ beforeEach(() => {
71
+ vi.resetAllMocks();
72
+ raw.paymentIntents.retrieve.mockResolvedValue(payment);
73
+ raw.refunds.list.mockImplementation(() => page([]));
74
+ raw.disputes.list.mockImplementation(() => page([]));
75
+ });
76
+ describe("authoritative processing-cost context", () => {
77
+ it("returns signed source-linked provider transactions and integer fees without initiating settlement", async () => {
78
+ const result = await getProcessingCostContext(stripe, {
79
+ paymentIntentId: "pi_1",
80
+ });
81
+ expect(result).toMatchObject({
82
+ status: "ready",
83
+ paymentIntentId: "pi_1",
84
+ chargeId: "ch_1",
85
+ currency: "usd",
86
+ gross: 10000,
87
+ processingFee: 320,
88
+ refundFeeAdjustment: 0,
89
+ disputeFeeAdjustment: 0,
90
+ netFees: 320,
91
+ refundedPrincipal: 0,
92
+ disputedPrincipal: 0,
93
+ principalReturned: 0,
94
+ transactions: [
95
+ {
96
+ id: "txn_charge",
97
+ sourceId: "ch_1",
98
+ sourceType: "charge",
99
+ amount: 10000,
100
+ fee: 320,
101
+ net: 9680,
102
+ created: 1800000000000,
103
+ },
104
+ ],
105
+ });
106
+ });
107
+ it("includes cumulative partial refunds and signed returned processing fees", async () => {
108
+ raw.refunds.list.mockImplementation(() =>
109
+ page([refund("re_first", 2000, -64), refund("re_second", 3000, -96)]),
110
+ );
111
+ raw.paymentIntents.retrieve.mockResolvedValue({
112
+ ...payment,
113
+ latest_charge: { ...charge, amount_refunded: 5000 },
114
+ });
115
+ expect(
116
+ await getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
117
+ ).toMatchObject({
118
+ status: "ready",
119
+ refundedPrincipal: 5000,
120
+ principalReturned: 5000,
121
+ processingFee: 320,
122
+ refundFeeAdjustment: -160,
123
+ netFees: 160,
124
+ });
125
+ });
126
+ it.each(["lost", "needs_response"])(
127
+ "accounts for %s dispute principal and fees once",
128
+ async (status) => {
129
+ raw.disputes.list.mockImplementation(() =>
130
+ page([dispute(status, [transaction("txn_dispute", -10000, 1500)])]),
131
+ );
132
+ raw.paymentIntents.retrieve.mockResolvedValue({
133
+ ...payment,
134
+ latest_charge: { ...charge, disputed: true },
135
+ });
136
+ expect(
137
+ await getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
138
+ ).toMatchObject({
139
+ status: "ready",
140
+ disputedPrincipal: 10000,
141
+ principalReturned: 10000,
142
+ disputeFeeAdjustment: 1500,
143
+ netFees: 1820,
144
+ });
145
+ },
146
+ );
147
+ it("handles won disputes and fee credits without retaining principal or assuming all fees return", async () => {
148
+ raw.disputes.list.mockImplementation(() =>
149
+ page([
150
+ dispute("won", [
151
+ transaction("txn_withdraw", -10000, 1500),
152
+ transaction("txn_reinstate", 10000, -1000),
153
+ ]),
154
+ ]),
155
+ );
156
+ expect(
157
+ await getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
158
+ ).toMatchObject({
159
+ status: "ready",
160
+ disputedPrincipal: 0,
161
+ principalReturned: 0,
162
+ disputeFeeAdjustment: 500,
163
+ netFees: 820,
164
+ });
165
+ });
166
+ it("does not double-count principal when refund and dispute observations overlap", async () => {
167
+ raw.refunds.list.mockImplementation(() =>
168
+ page([refund("re_partial", 2000)]),
169
+ );
170
+ raw.disputes.list.mockImplementation(() =>
171
+ page([dispute("lost", [transaction("txn_dispute", -10000, 1500)])]),
172
+ );
173
+ const result = await getProcessingCostContext(stripe, {
174
+ paymentIntentId: "pi_1",
175
+ });
176
+ expect(result).toMatchObject({
177
+ status: "ready",
178
+ refundedPrincipal: 2000,
179
+ disputedPrincipal: 10000,
180
+ principalReturned: 10000,
181
+ });
182
+ if (result.status === "ready")
183
+ expect(
184
+ result.transactions
185
+ .filter((t) => t.sourceType !== "charge")
186
+ .reduce((n, t) => n + t.amount, 0),
187
+ ).toBe(-12000);
188
+ });
189
+ it("walks every refund and dispute page rather than using a capped preview", async () => {
190
+ raw.refunds.list.mockImplementation(() =>
191
+ page(Array.from({ length: 101 }, (_, i) => refund(`re_${i}`, 10))),
192
+ );
193
+ raw.disputes.list.mockImplementation(() =>
194
+ page(
195
+ Array.from({ length: 101 }, (_, i) => ({
196
+ ...dispute("warning_closed", []),
197
+ id: `dp_${i}`,
198
+ })),
199
+ ),
200
+ );
201
+ const result = await getProcessingCostContext(stripe, {
202
+ paymentIntentId: "pi_1",
203
+ });
204
+ expect(result).toMatchObject({ status: "ready", refundedPrincipal: 1010 });
205
+ if (result.status === "ready") {
206
+ expect(result.refunds).toHaveLength(101);
207
+ expect(result.disputes).toHaveLength(101);
208
+ expect(result.transactions).toHaveLength(102);
209
+ }
210
+ });
211
+ it("returns explicit not-ready states for absent fees and pending refunds", async () => {
212
+ raw.paymentIntents.retrieve.mockResolvedValueOnce({
213
+ ...payment,
214
+ latest_charge: { ...charge, balance_transaction: null },
215
+ });
216
+ expect(
217
+ await getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
218
+ ).toMatchObject({
219
+ status: "not_ready",
220
+ reason: "charge_transaction_unavailable",
221
+ });
222
+ raw.refunds.list.mockImplementation(() =>
223
+ page([
224
+ {
225
+ ...refund("re_pending", 1000),
226
+ status: "pending",
227
+ balance_transaction: null,
228
+ },
229
+ ]),
230
+ );
231
+ expect(
232
+ await getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
233
+ ).toMatchObject({ status: "not_ready", reason: "refund_pending" });
234
+ });
235
+ it("includes a failed refund's reversal rather than counting a returned principal twice", async () => {
236
+ raw.refunds.list.mockImplementation(() =>
237
+ page([
238
+ {
239
+ ...refund("re_failed", 1000, 10),
240
+ status: "failed",
241
+ failure_balance_transaction: transaction("txn_failure", 1000, -10),
242
+ },
243
+ ]),
244
+ );
245
+ expect(
246
+ await getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
247
+ ).toMatchObject({
248
+ status: "ready",
249
+ refundedPrincipal: 0,
250
+ refundFeeAdjustment: 0,
251
+ principalReturned: 0,
252
+ netFees: 320,
253
+ });
254
+ });
255
+ it("rejects inconsistent source linkage and fractional provider amounts", async () => {
256
+ raw.refunds.list.mockImplementation(() =>
257
+ page([{ ...refund("re_other", 1000), charge: "ch_other" }]),
258
+ );
259
+ await expect(
260
+ getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
261
+ ).rejects.toThrow(/belong/);
262
+ raw.refunds.list.mockImplementation(() =>
263
+ page([refund("re_bad", 1000, 0.5)]),
264
+ );
265
+ await expect(
266
+ getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
267
+ ).rejects.toThrow(/integer/);
268
+ });
269
+ it("never combines amounts in different currencies", async () => {
270
+ raw.refunds.list.mockImplementation(() =>
271
+ page([
272
+ {
273
+ ...refund("re_fx", 1000),
274
+ balance_transaction: transaction("txn_fx", -1000, 0, "eur"),
275
+ },
276
+ ]),
277
+ );
278
+ expect(
279
+ await getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
280
+ ).toMatchObject({ status: "not_ready", reason: "currency_mismatch" });
281
+ });
282
+ it("preserves provider errors instead of returning incomplete cost data", async () => {
283
+ const error = Object.assign(new Error("Provider unavailable"), {
284
+ statusCode: 503,
285
+ });
286
+ raw.disputes.list.mockImplementation(() =>
287
+ (async function* () {
288
+ yield dispute("warning_closed", []);
289
+ throw error;
290
+ })(),
291
+ );
292
+ await expect(
293
+ getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
294
+ ).rejects.toBe(error);
295
+ });
296
+ });
297
+ it("waits for a won dispute's principal reinstatement instead of reporting the old withdrawal as owed", async () => {
298
+ raw.disputes.list.mockImplementation(() =>
299
+ page([dispute("won", [transaction("txn_out", -10000, 1500)])]),
300
+ );
301
+ expect(
302
+ await getProcessingCostContext(stripe, { paymentIntentId: "pi_1" }),
303
+ ).toMatchObject({
304
+ status: "not_ready",
305
+ reason: "dispute_reinstatement_unavailable",
306
+ });
307
+ });
308
+ it("exposes provider fee breakdowns so application fees are not mistaken for processor fees", async () => {
309
+ raw.paymentIntents.retrieve.mockResolvedValue({
310
+ ...payment,
311
+ latest_charge: {
312
+ ...charge,
313
+ balance_transaction: {
314
+ ...transaction("txn_charge", 10000, 820),
315
+ fee_details: [
316
+ {
317
+ type: "stripe_fee",
318
+ amount: 320,
319
+ currency: "usd",
320
+ application: null,
321
+ description: "Processing",
322
+ },
323
+ {
324
+ type: "application_fee",
325
+ amount: 500,
326
+ currency: "usd",
327
+ application: "ca_platform",
328
+ description: "Platform",
329
+ },
330
+ ],
331
+ },
332
+ },
333
+ });
334
+ const result = await getProcessingCostContext(stripe, {
335
+ paymentIntentId: "pi_1",
336
+ });
337
+ expect(result).toMatchObject({
338
+ status: "ready",
339
+ processingFee: 820,
340
+ transactions: [
341
+ {
342
+ feeDetails: [
343
+ { type: "stripe_fee", amount: 320 },
344
+ { type: "application_fee", amount: 500 },
345
+ ],
346
+ },
347
+ ],
348
+ });
349
+ });
@@ -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
+ }