@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,401 @@
1
+ import { componentsGeneric, type FunctionReference } from "convex/server";
2
+ import type Stripe from "stripe";
3
+ import { expect, it, vi } from "vitest";
4
+ import schema from "../component/schema.js";
5
+ import { initConvexTest } from "./setup.test.js";
6
+ import { CostRecoveryClient } from "./costRecovery.js";
7
+ import type { Component, RunCtx } from "./helpers.js";
8
+ const modules = import.meta.glob("../component/**/*.{ts,js,tsx,jsx}");
9
+ const scope = { accountId: "acct_owner", scopeKey: "store", currency: "usd" };
10
+ const options = { ...scope, groupKey: "invoice:in_1" };
11
+ async function setup(amount = 320) {
12
+ const t = initConvexTest();
13
+ t.registerComponent("betterStripe", schema, modules);
14
+ const ctx = {
15
+ runQuery: (
16
+ ref: FunctionReference<"query">,
17
+ args: Record<string, unknown>,
18
+ ) => t.query(ref, args),
19
+ runMutation: (
20
+ ref: FunctionReference<"mutation">,
21
+ args: Record<string, unknown>,
22
+ ) => t.mutation(ref, args),
23
+ } as unknown as RunCtx;
24
+ const invoice = {
25
+ id: "in_1",
26
+ customer_account: scope.accountId,
27
+ currency: "usd",
28
+ status: "draft",
29
+ };
30
+ const items: Stripe.InvoiceItem[] = [];
31
+ const provider = {
32
+ invoices: { retrieve: vi.fn(async () => invoice) },
33
+ invoiceItems: {
34
+ list: vi.fn(() =>
35
+ (async function* () {
36
+ for (const item of items) yield item;
37
+ })(),
38
+ ),
39
+ create: vi.fn(async (params: Record<string, unknown>) => {
40
+ const item = {
41
+ id: "ii_recovery",
42
+ ...params,
43
+ } as unknown as Stripe.InvoiceItem;
44
+ items.push(item);
45
+ return item;
46
+ }),
47
+ },
48
+ };
49
+ const recovery = new CostRecoveryClient(
50
+ componentsGeneric().betterStripe as unknown as Component,
51
+ () => provider as unknown as Stripe,
52
+ );
53
+ await recovery.recordClaim(ctx, {
54
+ ...scope,
55
+ claimKey: "fee:ch_original",
56
+ sourceChargeId: "ch_original",
57
+ sourceGross: 10000,
58
+ sourceProcessingFee: 320,
59
+ amountDue: amount,
60
+ revision: 1,
61
+ });
62
+ await recovery.reserve(ctx, {
63
+ ...options,
64
+ operationKey: "batch1",
65
+ sourceKind: "invoice",
66
+ sourceId: invoice.id,
67
+ maxDebit: 10000,
68
+ maxCredit: 10000,
69
+ });
70
+ await recovery.seal(ctx, { groupKey: options.groupKey });
71
+ return { ctx, recovery, invoice, items, provider };
72
+ }
73
+ it("attaches one signed adjustment to an authorized draft invoice and keeps claims reserved", async () => {
74
+ const { ctx, recovery, provider } = await setup(-320);
75
+ const result = await recovery.attachInvoice(ctx, options);
76
+ expect(result.invoiceItemId).toBe("ii_recovery");
77
+ expect(provider.invoiceItems.create).toHaveBeenCalledWith(
78
+ expect.objectContaining({
79
+ invoice: "in_1",
80
+ customer_account: "acct_owner",
81
+ currency: "usd",
82
+ amount: -320,
83
+ discountable: false,
84
+ }),
85
+ expect.objectContaining({ idempotencyKey: expect.any(String) }),
86
+ );
87
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
88
+ reservedAmount: -320,
89
+ collectedAmount: 0,
90
+ outstanding: 0,
91
+ });
92
+ await recovery.attachInvoice(ctx, options);
93
+ expect(provider.invoiceItems.create).toHaveBeenCalledTimes(1);
94
+ });
95
+ it("rejects invoice ownership, currency and host scope mismatches before creating items", async () => {
96
+ const { ctx, recovery, invoice, provider } = await setup();
97
+ await expect(
98
+ recovery.attachInvoice(ctx, { ...options, scopeKey: "other" }),
99
+ ).rejects.toThrow(/scope/i);
100
+ invoice.customer_account = "acct_other";
101
+ await expect(recovery.attachInvoice(ctx, options)).rejects.toThrow(/owner/i);
102
+ invoice.customer_account = scope.accountId;
103
+ invoice.currency = "eur";
104
+ await expect(recovery.attachInvoice(ctx, options)).rejects.toThrow(
105
+ /currency/i,
106
+ );
107
+ expect(provider.invoiceItems.create).not.toHaveBeenCalled();
108
+ });
109
+ it("recovers provider success after persistence failure by scanning the exact invoice completely", async () => {
110
+ const { ctx, recovery, items, provider } = await setup();
111
+ const mutation = ctx.runMutation! as (
112
+ ref: FunctionReference<"mutation">,
113
+ args: Record<string, unknown>,
114
+ ) => Promise<unknown>;
115
+ ctx.runMutation = vi.fn(
116
+ async (
117
+ ref: FunctionReference<"mutation">,
118
+ args: Record<string, unknown>,
119
+ ) => {
120
+ if (items.length) throw new Error("host persistence failed");
121
+ return mutation(ref, args);
122
+ },
123
+ ) as RunCtx["runMutation"];
124
+ await expect(recovery.attachInvoice(ctx, options)).rejects.toThrow(
125
+ "host persistence failed",
126
+ );
127
+ ctx.runMutation = mutation as RunCtx["runMutation"];
128
+ items.unshift(
129
+ ...Array.from(
130
+ { length: 105 },
131
+ (_, i) => ({ id: `ii_other_${i}`, metadata: {} }) as Stripe.InvoiceItem,
132
+ ),
133
+ );
134
+ expect((await recovery.attachInvoice(ctx, options)).invoiceItemId).toBe(
135
+ "ii_recovery",
136
+ );
137
+ expect(provider.invoiceItems.list).toHaveBeenCalledWith({
138
+ invoice: "in_1",
139
+ limit: 100,
140
+ });
141
+ expect(provider.invoiceItems.create).toHaveBeenCalledTimes(1);
142
+ });
143
+ it("does not create new adjustments after finalization or recreate a deleted bound item", async () => {
144
+ const { ctx, recovery, invoice, items, provider } = await setup();
145
+ invoice.status = "open";
146
+ await expect(recovery.attachInvoice(ctx, options)).rejects.toThrow(/draft/i);
147
+ invoice.status = "draft";
148
+ await recovery.attachInvoice(ctx, options);
149
+ items.length = 0;
150
+ await expect(recovery.attachInvoice(ctx, options)).rejects.toThrow(
151
+ /missing/i,
152
+ );
153
+ expect(provider.invoiceItems.create).toHaveBeenCalledTimes(1);
154
+ });
155
+ it("preserves provider errors and keeps unknown outcomes reserved", async () => {
156
+ const { ctx, recovery, provider } = await setup();
157
+ const failure = new Error("network interrupted");
158
+ provider.invoiceItems.create.mockRejectedValue(failure);
159
+ await expect(recovery.attachInvoice(ctx, options)).rejects.toBe(failure);
160
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
161
+ reservedAmount: 320,
162
+ collectedAmount: 0,
163
+ });
164
+ });
165
+ it("collects only after verified payment and replays completion without collecting twice", async () => {
166
+ const { ctx, recovery, invoice } = await setup();
167
+ await recovery.attachInvoice(ctx, options);
168
+ invoice.status = "open";
169
+ expect((await recovery.reconcileInvoice(ctx, options)).isDone).toBe(false);
170
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
171
+ reservedAmount: 320,
172
+ collectedAmount: 0,
173
+ });
174
+ invoice.status = "paid";
175
+ expect((await recovery.reconcileInvoice(ctx, options)).isDone).toBe(true);
176
+ await recovery.reconcileInvoice(ctx, options);
177
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
178
+ reservedAmount: 0,
179
+ collectedAmount: 320,
180
+ outstanding: 0,
181
+ });
182
+ });
183
+ it("releases verified void invoices, and uncollectible invoices only under explicit host policy", async () => {
184
+ const { ctx, recovery, invoice } = await setup();
185
+ await recovery.attachInvoice(ctx, options);
186
+ invoice.status = "uncollectible";
187
+ expect((await recovery.reconcileInvoice(ctx, options)).isDone).toBe(false);
188
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
189
+ reservedAmount: 320,
190
+ });
191
+ invoice.status = "void";
192
+ await recovery.reconcileInvoice(ctx, options);
193
+ await recovery.reconcileInvoice(ctx, options);
194
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
195
+ reservedAmount: 0,
196
+ collectedAmount: 0,
197
+ outstanding: 320,
198
+ });
199
+ const other = await setup();
200
+ await other.recovery.attachInvoice(other.ctx, options);
201
+ other.invoice.status = "uncollectible";
202
+ await other.recovery.reconcileInvoice(other.ctx, {
203
+ ...options,
204
+ releaseUncollectible: true,
205
+ });
206
+ expect(await other.recovery.balance(other.ctx, scope)).toMatchObject({
207
+ reservedAmount: 0,
208
+ outstanding: 320,
209
+ });
210
+ });
211
+ it("retains source changes while an invoice is unpaid and carries the signed difference forward", async () => {
212
+ const { ctx, recovery, invoice } = await setup();
213
+ await recovery.attachInvoice(ctx, options);
214
+ await recovery.recordClaim(ctx, {
215
+ ...scope,
216
+ claimKey: "fee:ch_original",
217
+ sourceChargeId: "ch_original",
218
+ sourceGross: 10000,
219
+ sourceProcessingFee: 320,
220
+ amountDue: 0,
221
+ revision: 2,
222
+ });
223
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
224
+ reservedAmount: 320,
225
+ outstanding: -320,
226
+ });
227
+ await expect(
228
+ recovery.reserve(ctx, {
229
+ ...scope,
230
+ groupKey: "settlement:pi_2",
231
+ operationKey: "other",
232
+ sourceKind: "settlement",
233
+ sourceId: "pi_2",
234
+ sourceGross: 1000,
235
+ sourceProcessingFee: 59,
236
+ maxDebit: 1000,
237
+ maxCredit: 1000,
238
+ }),
239
+ ).rejects.toThrow();
240
+ invoice.status = "paid";
241
+ await recovery.reconcileInvoice(ctx, options);
242
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
243
+ collectedAmount: 320,
244
+ reservedAmount: 0,
245
+ outstanding: -320,
246
+ });
247
+ });
248
+ it("refuses altered or duplicate adjustment items before marking any claim collected", async () => {
249
+ const { ctx, recovery, invoice, items } = await setup();
250
+ await recovery.attachInvoice(ctx, options);
251
+ invoice.status = "paid";
252
+ items[0].amount = 1;
253
+ await expect(recovery.reconcileInvoice(ctx, options)).rejects.toThrow(
254
+ /economics/i,
255
+ );
256
+ items[0].amount = 320;
257
+ items.push({ ...items[0], id: "ii_duplicate" });
258
+ await expect(recovery.reconcileInvoice(ctx, options)).rejects.toThrow(
259
+ /duplicate/i,
260
+ );
261
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
262
+ reservedAmount: 320,
263
+ collectedAmount: 0,
264
+ });
265
+ });
266
+ it("preserves funding-charge economics when a collected invoice payment is partially refunded", async () => {
267
+ const { ctx, recovery, invoice, provider } = await setup();
268
+ await recovery.attachInvoice(ctx, options);
269
+ invoice.status = "paid";
270
+ await recovery.reconcileInvoice(ctx, options);
271
+ Object.assign(invoice, { amount_paid: 10000 });
272
+ Object.assign(provider, {
273
+ invoicePayments: {
274
+ list: () =>
275
+ (async function* () {
276
+ yield {
277
+ id: "inpay_1",
278
+ invoice: "in_1",
279
+ status: "paid",
280
+ amount_paid: 10000,
281
+ currency: "usd",
282
+ payment: { type: "payment_intent", payment_intent: "pi_funding" },
283
+ };
284
+ })(),
285
+ },
286
+ paymentIntents: {
287
+ retrieve: async () => ({
288
+ id: "pi_funding",
289
+ status: "succeeded",
290
+ amount_received: 10000,
291
+ currency: "usd",
292
+ metadata: {},
293
+ latest_charge: {
294
+ id: "ch_funding",
295
+ amount: 10000,
296
+ amount_captured: 10000,
297
+ amount_refunded: 5000,
298
+ currency: "usd",
299
+ disputed: false,
300
+ payment_intent: "pi_funding",
301
+ balance_transaction: {
302
+ id: "txn_original",
303
+ amount: 10000,
304
+ fee: 320,
305
+ net: 9680,
306
+ currency: "usd",
307
+ created: 1800000000,
308
+ },
309
+ },
310
+ }),
311
+ },
312
+ refunds: {
313
+ list: () =>
314
+ (async function* () {
315
+ yield {
316
+ id: "re_half",
317
+ charge: "ch_funding",
318
+ payment_intent: "pi_funding",
319
+ amount: 5000,
320
+ currency: "usd",
321
+ status: "succeeded",
322
+ balance_transaction: {
323
+ id: "txn_refund",
324
+ amount: -5000,
325
+ fee: 0,
326
+ net: -5000,
327
+ currency: "usd",
328
+ created: 1800000001,
329
+ },
330
+ failure_balance_transaction: null,
331
+ };
332
+ })(),
333
+ },
334
+ disputes: { list: () => (async function* () {})() },
335
+ });
336
+ const result = await recovery.rollbackInvoice(ctx, {
337
+ ...options,
338
+ revision: 1,
339
+ });
340
+ expect(result).toMatchObject({
341
+ status: "ready",
342
+ claim: {
343
+ sourceChargeId: "ch_funding",
344
+ sourceGross: 10000,
345
+ sourceProcessingFee: 320,
346
+ amountDue: 160,
347
+ },
348
+ });
349
+ await recovery.rollbackInvoice(ctx, { ...options, revision: 1 });
350
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
351
+ amountDue: 480,
352
+ collectedAmount: 320,
353
+ outstanding: 160,
354
+ });
355
+ expect(
356
+ await recovery.getClaim(ctx, { claimKey: "fee:ch_original" }),
357
+ ).toMatchObject({ amountDue: 320, sourceChargeId: "ch_original" });
358
+ });
359
+ it("does not guess funding allocations for multi-payment invoices", async () => {
360
+ const { ctx, recovery, invoice, provider } = await setup();
361
+ await recovery.attachInvoice(ctx, options);
362
+ invoice.status = "paid";
363
+ await recovery.reconcileInvoice(ctx, options);
364
+ Object.assign(invoice, { amount_paid: 10000 });
365
+ Object.assign(provider, {
366
+ invoicePayments: {
367
+ list: () =>
368
+ (async function* () {
369
+ for (let i = 0; i < 2; i++)
370
+ yield {
371
+ id: `inpay_${i}`,
372
+ invoice: "in_1",
373
+ status: "paid",
374
+ amount_paid: 5000,
375
+ currency: "usd",
376
+ payment: { type: "payment_intent", payment_intent: `pi_${i}` },
377
+ };
378
+ })(),
379
+ },
380
+ });
381
+ expect(
382
+ await recovery.rollbackInvoice(ctx, { ...options, revision: 1 }),
383
+ ).toMatchObject({
384
+ status: "not_ready",
385
+ reason: "invoice_funding_allocation_required",
386
+ });
387
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
388
+ amountDue: 320,
389
+ collectedAmount: 320,
390
+ outstanding: 0,
391
+ });
392
+ });
393
+ it("never creates a new provider item for an already completed bookkeeping group", async () => {
394
+ const { ctx, recovery, provider } = await setup();
395
+ await recovery.finish(ctx, {
396
+ groupKey: options.groupKey,
397
+ outcome: "collect",
398
+ });
399
+ await expect(recovery.attachInvoice(ctx, options)).rejects.toThrow(/sealed/i);
400
+ expect(provider.invoiceItems.create).not.toHaveBeenCalled();
401
+ });
@@ -0,0 +1,107 @@
1
+ import Stripe from "stripe";
2
+ import { componentsGeneric } from "convex/server";
3
+ import { expect, it } from "vitest";
4
+ import { BetterStripe } from "./index.js";
5
+ import { getStripeClient } from "./helpers.js";
6
+ import type { Component, RunCtx } from "./helpers.js";
7
+ import type { RecoveryGroup } from "./costRecovery.js";
8
+ it.each(["2026-09-30.endive", "2026-09-30.preview"] as const)(
9
+ "preserves %s on every SDK page and finds existing items beyond the first page",
10
+ async (version) => {
11
+ const key = `sk_test_recovery_transport_${version}`;
12
+ const sdk = getStripeClient(key, version);
13
+ const group = {
14
+ _id: "group-durable",
15
+ _creationTime: 0,
16
+ accountId: "acct_owner",
17
+ scopeKey: "store",
18
+ currency: "usd",
19
+ groupKey: "in:1",
20
+ sourceKind: "invoice",
21
+ sourceId: "in_1",
22
+ status: "sealed",
23
+ amount: 320,
24
+ pendingOperations: 1,
25
+ maxDebit: 1000,
26
+ maxCredit: 1000,
27
+ } as RecoveryGroup;
28
+ const paths: string[] = [];
29
+ (
30
+ sdk as unknown as { _setApiField(field: string, value: unknown): void }
31
+ )._setApiField("httpClient", {
32
+ getClientName: () => "test",
33
+ makeRequest: async (
34
+ ...[, , path, method, headers]: Parameters<
35
+ ReturnType<typeof Stripe.createNodeHttpClient>["makeRequest"]
36
+ >
37
+ ) => {
38
+ expect(headers["Stripe-Version"]).toBe(version);
39
+ expect(method).toBe("GET");
40
+ paths.push(path);
41
+ const data = path.startsWith("/v1/invoices/")
42
+ ? {
43
+ id: "in_1",
44
+ customer_account: group.accountId,
45
+ currency: "usd",
46
+ status: "draft",
47
+ }
48
+ : path.includes("starting_after=")
49
+ ? {
50
+ object: "list",
51
+ has_more: false,
52
+ data: [
53
+ {
54
+ id: "ii_existing",
55
+ invoice: "in_1",
56
+ customer_account: group.accountId,
57
+ currency: "usd",
58
+ amount: 320,
59
+ discountable: false,
60
+ metadata: { bsRecoveryGroup: group._id },
61
+ },
62
+ ],
63
+ }
64
+ : {
65
+ object: "list",
66
+ has_more: true,
67
+ data: Array.from({ length: 100 }, (_, i) => ({
68
+ id: `ii_${i}`,
69
+ metadata: {},
70
+ })),
71
+ };
72
+ return {
73
+ getStatusCode: () => 200,
74
+ getHeaders: () => ({}),
75
+ getRawResponse: () => ({}),
76
+ toJSON: async () => data,
77
+ toStream: () => {
78
+ throw new Error("unexpected stream");
79
+ },
80
+ };
81
+ },
82
+ });
83
+ let bound: string | undefined;
84
+ const ctx = {
85
+ runQuery: async () => group,
86
+ runMutation: async (_ref: unknown, args: { invoiceItemId: string }) => {
87
+ bound = args.invoiceItemId;
88
+ return { ...group, invoiceItemId: bound };
89
+ },
90
+ } as unknown as RunCtx;
91
+ const better = new BetterStripe(
92
+ componentsGeneric().betterStripe as unknown as Component,
93
+ { STRIPE_SECRET_KEY: key, stripeApiVersion: version },
94
+ );
95
+ const receipt = await better.costRecovery.attachInvoice(ctx, {
96
+ accountId: group.accountId,
97
+ scopeKey: group.scopeKey,
98
+ currency: group.currency,
99
+ groupKey: group.groupKey,
100
+ });
101
+ expect(receipt.invoiceItemId).toBe("ii_existing");
102
+ expect(bound).toBe("ii_existing");
103
+ expect(paths).toHaveLength(3);
104
+ expect(paths[2]).toContain("starting_after=ii_99");
105
+ expect(paths[2]).toContain("invoice=in_1");
106
+ },
107
+ );
@@ -0,0 +1,144 @@
1
+ import { getProcessingCostContext } from "./settlement/processingCosts.js";
2
+ import type Stripe from "stripe";
3
+ import type { RecoveryGroup, RecoveryScope } from "./costRecovery.js";
4
+ export type RecoveryInvoiceOptions = RecoveryScope & { groupKey: string };
5
+ const marker = "bsRecoveryGroup";
6
+ const id = (value: string | { id: string } | null) =>
7
+ typeof value === "string" ? value : value?.id;
8
+
9
+ /** One immutable item per sealed group. Host serializes provider operations per group. */
10
+ export async function attachRecoveryInvoice(
11
+ stripe: Stripe,
12
+ group: RecoveryGroup | null,
13
+ opts: RecoveryInvoiceOptions,
14
+ bind: (invoiceItemId: string) => Promise<RecoveryGroup>,
15
+ create = true,
16
+ ) {
17
+ if (
18
+ !group ||
19
+ group.groupKey !== opts.groupKey ||
20
+ group.sourceKind !== "invoice" ||
21
+ group.accountId !== opts.accountId ||
22
+ group.scopeKey !== opts.scopeKey ||
23
+ group.currency !== opts.currency
24
+ )
25
+ throw new Error(
26
+ "Recovery invoice scope does not match the authorized group.",
27
+ );
28
+ if (
29
+ group.status === "open" ||
30
+ (create && ["released", "releasing"].includes(group.status))
31
+ )
32
+ throw new Error("Invoice recovery group must be sealed and active.");
33
+ const invoice = await stripe.invoices.retrieve(group.sourceId);
34
+ if (
35
+ invoice.id !== group.sourceId ||
36
+ invoice.customer_account !== group.accountId
37
+ )
38
+ throw new Error("Recovery invoice ownership does not match.");
39
+ if (invoice.currency !== group.currency)
40
+ throw new Error("Recovery invoice currency does not match.");
41
+ let found: Stripe.InvoiceItem | undefined;
42
+ for await (const item of stripe.invoiceItems.list({
43
+ invoice: invoice.id,
44
+ limit: 100,
45
+ })) {
46
+ if (item.metadata?.[marker] !== group._id) continue;
47
+ if (found)
48
+ throw new Error(
49
+ "Duplicate recovery invoice items require reconciliation.",
50
+ );
51
+ if (
52
+ id(item.invoice) !== invoice.id ||
53
+ item.customer_account !== group.accountId ||
54
+ item.currency !== group.currency ||
55
+ item.amount !== group.amount ||
56
+ item.discountable ||
57
+ (group.invoiceItemId && group.invoiceItemId !== item.id)
58
+ )
59
+ throw new Error(
60
+ "Recovery invoice item economics or identity do not match.",
61
+ );
62
+ found = item;
63
+ }
64
+ if (!found) {
65
+ if (group.invoiceItemId)
66
+ throw new Error(
67
+ "Bound recovery invoice item is missing; reconcile before retrying.",
68
+ );
69
+ if (!create)
70
+ throw new Error(
71
+ "Recovery invoice item is missing; no collection can be confirmed.",
72
+ );
73
+ if (group.status !== "sealed")
74
+ throw new Error(
75
+ "New recovery items require a sealed, uncompleted group.",
76
+ );
77
+ if (invoice.status !== "draft")
78
+ throw new Error("New recovery items require a draft invoice.");
79
+ found = await stripe.invoiceItems.create(
80
+ {
81
+ invoice: invoice.id,
82
+ customer_account: group.accountId,
83
+ currency: group.currency,
84
+ amount: group.amount,
85
+ discountable: false,
86
+ description: "Refund and dispute cost adjustment",
87
+ metadata: { [marker]: group._id },
88
+ },
89
+ { idempotencyKey: `better-stripe:recovery:${group._id}` },
90
+ );
91
+ }
92
+ const bound = group.invoiceItemId === found.id ? group : await bind(found.id);
93
+ return {
94
+ group: bound,
95
+ invoiceItemId: found.id,
96
+ invoiceStatus: invoice.status,
97
+ };
98
+ }
99
+
100
+ /** Refuse ambiguous cash/credit or multi-payment allocations instead of inventing economics. */
101
+ export async function readInvoiceFunding(stripe: Stripe, group: RecoveryGroup) {
102
+ const invoice = await stripe.invoices.retrieve(group.sourceId);
103
+ if (
104
+ invoice.customer_account !== group.accountId ||
105
+ invoice.currency !== group.currency ||
106
+ invoice.status !== "paid"
107
+ )
108
+ throw new Error(
109
+ "Invoice funding ownership, currency or paid state does not match.",
110
+ );
111
+ const payments: Stripe.InvoicePayment[] = [];
112
+ for await (const payment of stripe.invoicePayments.list({
113
+ invoice: group.sourceId,
114
+ limit: 100,
115
+ })) {
116
+ if (
117
+ id(payment.invoice) !== group.sourceId ||
118
+ payment.currency !== group.currency
119
+ )
120
+ throw new Error("Invoice payment identity or currency does not match.");
121
+ if (payment.status === "paid") payments.push(payment);
122
+ }
123
+ const payment = payments[0];
124
+ if (
125
+ payments.length !== 1 ||
126
+ payment?.payment.type !== "payment_intent" ||
127
+ !payment.payment.payment_intent ||
128
+ payment.amount_paid !== invoice.amount_paid
129
+ )
130
+ return {
131
+ status: "not_ready" as const,
132
+ reason: "invoice_funding_allocation_required" as const,
133
+ };
134
+ const cost = await getProcessingCostContext(stripe, {
135
+ paymentIntentId: id(payment.payment.payment_intent)!,
136
+ });
137
+ if (cost.status === "not_ready") return cost;
138
+ if (cost.currency !== group.currency || cost.gross !== invoice.amount_paid)
139
+ return {
140
+ status: "not_ready" as const,
141
+ reason: "invoice_funding_allocation_required" as const,
142
+ };
143
+ return cost;
144
+ }