@fantastic.dev/better-stripe 0.13.0 → 0.15.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 (111) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +63 -1
  3. package/dist/client/billing/checkoutOptions.d.ts +27 -0
  4. package/dist/client/billing/checkoutOptions.d.ts.map +1 -0
  5. package/dist/client/billing/checkoutOptions.js +2 -0
  6. package/dist/client/billing/checkoutOptions.js.map +1 -0
  7. package/dist/client/billing/intents.d.ts +32 -0
  8. package/dist/client/billing/intents.d.ts.map +1 -0
  9. package/dist/client/billing/intents.js +71 -0
  10. package/dist/client/billing/intents.js.map +1 -0
  11. package/dist/client/connect/disputes.d.ts +20 -0
  12. package/dist/client/connect/disputes.d.ts.map +1 -1
  13. package/dist/client/connect/disputes.js +55 -0
  14. package/dist/client/connect/disputes.js.map +1 -1
  15. package/dist/client/core/fees.d.ts +2 -0
  16. package/dist/client/core/fees.d.ts.map +1 -1
  17. package/dist/client/core/fees.js +7 -0
  18. package/dist/client/core/fees.js.map +1 -1
  19. package/dist/client/costRecovery-invoices.d.ts +10 -4
  20. package/dist/client/costRecovery-invoices.d.ts.map +1 -1
  21. package/dist/client/costRecovery-invoices.js +4 -0
  22. package/dist/client/costRecovery-invoices.js.map +1 -1
  23. package/dist/client/costRecovery.d.ts +18 -2
  24. package/dist/client/costRecovery.d.ts.map +1 -1
  25. package/dist/client/costRecovery.js +4 -0
  26. package/dist/client/costRecovery.js.map +1 -1
  27. package/dist/client/errors.d.ts +1 -1
  28. package/dist/client/errors.d.ts.map +1 -1
  29. package/dist/client/errors.js.map +1 -1
  30. package/dist/client/index.d.ts +20 -37
  31. package/dist/client/index.d.ts.map +1 -1
  32. package/dist/client/index.js +18 -20
  33. package/dist/client/index.js.map +1 -1
  34. package/dist/component/_generated/component.d.ts +59 -0
  35. package/dist/component/_generated/component.d.ts.map +1 -1
  36. package/dist/component/costRecovery/claims.d.ts +12 -0
  37. package/dist/component/costRecovery/claims.d.ts.map +1 -1
  38. package/dist/component/costRecovery/imports.d.ts +53 -0
  39. package/dist/component/costRecovery/imports.d.ts.map +1 -0
  40. package/dist/component/costRecovery/imports.js +67 -0
  41. package/dist/component/costRecovery/imports.js.map +1 -0
  42. package/dist/component/costRecovery/mutations.d.ts +47 -0
  43. package/dist/component/costRecovery/mutations.d.ts.map +1 -1
  44. package/dist/component/costRecovery/mutations.js +7 -1
  45. package/dist/component/costRecovery/mutations.js.map +1 -1
  46. package/dist/component/costRecovery/queries.d.ts +12 -0
  47. package/dist/component/costRecovery/queries.d.ts.map +1 -1
  48. package/dist/component/costRecovery/schema.d.ts +72 -2
  49. package/dist/component/costRecovery/schema.d.ts.map +1 -1
  50. package/dist/component/costRecovery/schema.js +13 -0
  51. package/dist/component/costRecovery/schema.js.map +1 -1
  52. package/dist/component/schema.d.ts +19 -1
  53. package/dist/react/hooks/useConfirmPayment.d.ts +1 -1
  54. package/dist/react/hooks/useConfirmPayment.d.ts.map +1 -1
  55. package/dist/react/hooks/useConfirmPayment.js +13 -2
  56. package/dist/react/hooks/useConfirmPayment.js.map +1 -1
  57. package/dist/react/index.d.ts +2 -0
  58. package/dist/react/index.d.ts.map +1 -1
  59. package/dist/react/index.js +1 -0
  60. package/dist/react/index.js.map +1 -1
  61. package/dist/react/workflows/EvidenceUpload.d.ts +18 -0
  62. package/dist/react/workflows/EvidenceUpload.d.ts.map +1 -0
  63. package/dist/react/workflows/EvidenceUpload.js +35 -0
  64. package/dist/react/workflows/EvidenceUpload.js.map +1 -0
  65. package/dist/react/workflows/Onboarding.d.ts +3 -3
  66. package/dist/react/workflows/Onboarding.d.ts.map +1 -1
  67. package/dist/react/workflows/Onboarding.js +8 -6
  68. package/dist/react/workflows/Onboarding.js.map +1 -1
  69. package/dist/react/workflows/SaveCard.d.ts +27 -0
  70. package/dist/react/workflows/SaveCard.d.ts.map +1 -0
  71. package/dist/react/workflows/SaveCard.js +56 -0
  72. package/dist/react/workflows/SaveCard.js.map +1 -0
  73. package/dist/react/workflows/index.d.ts +2 -0
  74. package/dist/react/workflows/index.d.ts.map +1 -1
  75. package/dist/react/workflows/index.js +1 -0
  76. package/dist/react/workflows/index.js.map +1 -1
  77. package/dist/react/workflows/useWorkflowAction.d.ts +1 -1
  78. package/dist/react/workflows/useWorkflowAction.d.ts.map +1 -1
  79. package/dist/react/workflows/useWorkflowAction.js +6 -2
  80. package/dist/react/workflows/useWorkflowAction.js.map +1 -1
  81. package/docs/react-workflows.md +83 -0
  82. package/package.json +1 -1
  83. package/src/client/billing/checkoutOptions.ts +27 -0
  84. package/src/client/billing/intents.test.ts +216 -0
  85. package/src/client/billing/intents.ts +120 -0
  86. package/src/client/billing/intentsTransport.test.ts +94 -0
  87. package/src/client/connect/dispute-upload.test.ts +208 -0
  88. package/src/client/connect/disputes.ts +103 -0
  89. package/src/client/core/fees.ts +8 -0
  90. package/src/client/costRecovery-import.test.ts +174 -0
  91. package/src/client/costRecovery-invoices.test.ts +69 -0
  92. package/src/client/costRecovery-invoices.ts +32 -0
  93. package/src/client/costRecovery.ts +16 -0
  94. package/src/client/errors.ts +1 -0
  95. package/src/client/index.ts +50 -52
  96. package/src/component/_generated/api.ts +2 -0
  97. package/src/component/_generated/component.ts +65 -0
  98. package/src/component/costRecovery/imports.ts +74 -0
  99. package/src/component/costRecovery/mutations.ts +8 -0
  100. package/src/component/costRecovery/schema.ts +13 -0
  101. package/src/react/hooks/useConfirmPayment.test.tsx +47 -0
  102. package/src/react/hooks/useConfirmPayment.ts +16 -2
  103. package/src/react/index.ts +3 -0
  104. package/src/react/workflows/EvidenceUpload.test.tsx +56 -0
  105. package/src/react/workflows/EvidenceUpload.tsx +71 -0
  106. package/src/react/workflows/Onboarding.test.tsx +73 -0
  107. package/src/react/workflows/Onboarding.tsx +8 -5
  108. package/src/react/workflows/SaveCard.test.tsx +127 -0
  109. package/src/react/workflows/SaveCard.tsx +117 -0
  110. package/src/react/workflows/index.ts +3 -0
  111. package/src/react/workflows/useWorkflowAction.ts +6 -1
@@ -0,0 +1,208 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import type { Component, RunCtx } from "../helpers.js";
4
+ import { updateDispute, uploadDisputeEvidence } from "./disputes.js";
5
+
6
+ function makeStripe() {
7
+ return {
8
+ disputes: {
9
+ update: vi.fn().mockResolvedValue({}),
10
+ close: vi.fn().mockResolvedValue({}),
11
+ },
12
+ };
13
+ }
14
+ const asStripe = (s: ReturnType<typeof makeStripe>) =>
15
+ s as unknown as Parameters<typeof updateDispute>[0];
16
+
17
+ const TO_REF = Symbol.for("toReferencePath");
18
+
19
+ /**
20
+ * Component proxy exposing the two connect query refs the dispute-action
21
+ * authorization resolves: the dispute (by its Stripe id) and the payment that
22
+ * dispute is linked to (by PaymentIntent id).
23
+ */
24
+ function makeAuthComponent(): Component {
25
+ const ref = (path: string) => ({ [TO_REF]: `betterStripe/${path}` });
26
+ return {
27
+ connect: {
28
+ queries: {
29
+ getDisputeByStripeId: ref("connect/queries/getDisputeByStripeId"),
30
+ getPaymentByStripeId: ref("connect/queries/getPaymentByStripeId"),
31
+ },
32
+ },
33
+ } as unknown as Component;
34
+ }
35
+
36
+ /**
37
+ * A ctx whose runQuery dispatches by the query args: a `stripeDisputeId` lookup
38
+ * resolves the dispute row; a `stripePaymentIntentId` lookup resolves the
39
+ * payment row. Either may be null to exercise the fail-closed paths.
40
+ */
41
+ function makeAuthCtx(disputeRow?: unknown, paymentRow?: unknown) {
42
+ const runQuery = vi.fn().mockImplementation((_ref, args) => {
43
+ if (args && "stripeDisputeId" in args)
44
+ return Promise.resolve(disputeRow ?? null);
45
+ if (args && "stripePaymentIntentId" in args)
46
+ return Promise.resolve(paymentRow ?? null);
47
+ return Promise.resolve(null);
48
+ });
49
+ return { runQuery } as unknown as RunCtx & {
50
+ runQuery: ReturnType<typeof vi.fn>;
51
+ };
52
+ }
53
+
54
+ describe("uploadDisputeEvidence", () => {
55
+ const file = {
56
+ data: new TextEncoder().encode("%PDF-1.7\nfixture"),
57
+ name: "evidence.pdf",
58
+ type: "application/pdf" as const,
59
+ };
60
+ const opts = {
61
+ stripeDisputeId: "dp_1",
62
+ actor: { type: "seller" as const, accountId: "acct_owner" },
63
+ field: "customer_communication" as const,
64
+ file,
65
+ };
66
+ function setup(owner = "acct_owner") {
67
+ const stripe = {
68
+ ...makeStripe(),
69
+ files: { create: vi.fn().mockResolvedValue({ id: "file_uploaded" }) },
70
+ };
71
+ const ctx = makeAuthCtx(
72
+ { stripePaymentIntentId: "pi_1" },
73
+ { destinationAccountId: owner },
74
+ );
75
+ return { stripe, ctx };
76
+ }
77
+ it("uploads bytes and binds only the returned file ID as draft", async () => {
78
+ const { stripe, ctx } = setup();
79
+ const result = await uploadDisputeEvidence(
80
+ asStripe(stripe),
81
+ makeAuthComponent(),
82
+ ctx,
83
+ opts,
84
+ );
85
+ expect(stripe.files.create).toHaveBeenCalledWith(
86
+ { purpose: "dispute_evidence", file },
87
+ undefined,
88
+ );
89
+ expect(stripe.disputes.update).toHaveBeenCalledWith(
90
+ "dp_1",
91
+ { evidence: { customer_communication: "file_uploaded" }, submit: false },
92
+ undefined,
93
+ );
94
+ expect(result).toEqual({
95
+ stripeFileId: "file_uploaded",
96
+ stripeDisputeId: "dp_1",
97
+ });
98
+ });
99
+ it("denies another seller before uploading", async () => {
100
+ const { stripe, ctx } = setup("acct_other");
101
+ await expect(
102
+ uploadDisputeEvidence(asStripe(stripe), makeAuthComponent(), ctx, opts),
103
+ ).rejects.toThrow(/not authorized/);
104
+ expect(stripe.files.create).not.toHaveBeenCalled();
105
+ expect(stripe.disputes.update).not.toHaveBeenCalled();
106
+ });
107
+ it.each([
108
+ { ...file, type: "text/plain" },
109
+ { ...file, data: new Uint8Array(0) },
110
+ { ...file, data: new Uint8Array(5_000_001) },
111
+ { ...file, data: new TextEncoder().encode("not a PDF") },
112
+ ])("rejects invalid files before provider calls", async (file) => {
113
+ const { stripe, ctx } = setup();
114
+ await expect(
115
+ uploadDisputeEvidence(asStripe(stripe), makeAuthComponent(), ctx, {
116
+ ...opts,
117
+ file: file as typeof opts.file,
118
+ }),
119
+ ).rejects.toThrow(/Invalid dispute evidence/);
120
+ expect(stripe.files.create).not.toHaveBeenCalled();
121
+ });
122
+ it("does not attach after an upload failure and preserves provider errors", async () => {
123
+ const { stripe, ctx } = setup();
124
+ stripe.files.create.mockRejectedValue({
125
+ type: "StripeInvalidRequestError",
126
+ code: "file_invalid",
127
+ message: "Corrupt PDF",
128
+ });
129
+ await expect(
130
+ uploadDisputeEvidence(asStripe(stripe), makeAuthComponent(), ctx, opts),
131
+ ).rejects.toMatchObject({
132
+ data: {
133
+ code: "DISPUTE_UPLOAD_FAILED",
134
+ stripeError: { code: "file_invalid" },
135
+ },
136
+ });
137
+ expect(stripe.disputes.update).not.toHaveBeenCalled();
138
+ });
139
+ it("surfaces draft attachment failure without submitting", async () => {
140
+ const { stripe, ctx } = setup();
141
+ stripe.disputes.update.mockRejectedValue(new Error("unavailable"));
142
+ await expect(
143
+ uploadDisputeEvidence(asStripe(stripe), makeAuthComponent(), ctx, opts),
144
+ ).rejects.toMatchObject({ data: { code: "DISPUTE_UPDATE_FAILED" } });
145
+ expect(stripe.disputes.update.mock.calls[0][1].submit).toBe(false);
146
+ });
147
+ });
148
+
149
+ it.each([
150
+ {
151
+ type: "image/png" as const,
152
+ data: new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
153
+ },
154
+ { type: "image/jpeg" as const, data: new Uint8Array([255, 216, 255]) },
155
+ ])(
156
+ "uploads supported image signatures to the owning request account",
157
+ async (file) => {
158
+ const stripe = {
159
+ ...makeStripe(),
160
+ files: { create: vi.fn().mockResolvedValue({ id: "file_image" }) },
161
+ };
162
+ await uploadDisputeEvidence(
163
+ asStripe(stripe),
164
+ makeAuthComponent(),
165
+ {} as RunCtx,
166
+ {
167
+ actor: { type: "admin" },
168
+ stripeDisputeId: "dp_1",
169
+ stripeAccountId: "acct_direct",
170
+ field: "receipt",
171
+ file: { ...file, name: "proof" },
172
+ },
173
+ );
174
+ expect(stripe.files.create.mock.calls[0][1]).toEqual({
175
+ stripeAccount: "acct_direct",
176
+ });
177
+ expect(stripe.disputes.update.mock.calls[0][2]).toEqual({
178
+ stripeAccount: "acct_direct",
179
+ });
180
+ },
181
+ );
182
+
183
+ it("rejects a malformed seller actor before any provider call", async () => {
184
+ const stripe = {
185
+ ...makeStripe(),
186
+ files: { create: vi.fn().mockResolvedValue({ id: "file_1" }) },
187
+ };
188
+ await expect(
189
+ uploadDisputeEvidence(
190
+ asStripe(stripe),
191
+ makeAuthComponent(),
192
+ makeAuthCtx({ stripePaymentIntentId: "pi_1" }, {}),
193
+ {
194
+ actor: { type: "seller" } as Parameters<
195
+ typeof uploadDisputeEvidence
196
+ >[3]["actor"],
197
+ stripeDisputeId: "dp_1",
198
+ field: "receipt",
199
+ file: {
200
+ data: new TextEncoder().encode("%PDF-1.7"),
201
+ name: "a.pdf",
202
+ type: "application/pdf",
203
+ },
204
+ },
205
+ ),
206
+ ).rejects.toThrow(/authenticated actor/);
207
+ expect(stripe.files.create).not.toHaveBeenCalled();
208
+ });
@@ -148,6 +148,109 @@ export async function updateDispute(
148
148
  }
149
149
  }
150
150
 
151
+ /** File fields supported by Stripe dispute evidence. */
152
+ export type DisputeEvidenceFileField =
153
+ | "cancellation_policy"
154
+ | "customer_communication"
155
+ | "customer_signature"
156
+ | "duplicate_charge_documentation"
157
+ | "receipt"
158
+ | "refund_policy"
159
+ | "service_documentation"
160
+ | "shipping_documentation"
161
+ | "uncategorized_file";
162
+ export type DisputeEvidenceUploadOptions = {
163
+ stripeDisputeId: string;
164
+ /** Derive this on the server from the authenticated session. */
165
+ actor: RefundActor;
166
+ field: DisputeEvidenceFileField;
167
+ file: {
168
+ data: Uint8Array;
169
+ name: string;
170
+ type: "application/pdf" | "image/jpeg" | "image/png";
171
+ };
172
+ /** Request account owning the charge, distinct from the seller actor. */
173
+ stripeAccountId?: string;
174
+ };
175
+
176
+ /** Upload and attach to one authorized dispute as a draft. Never submits. */
177
+ export async function uploadDisputeEvidence(
178
+ stripe: Stripe,
179
+ component: Component,
180
+ ctx: RunCtx,
181
+ opts: DisputeEvidenceUploadOptions,
182
+ ): Promise<{ stripeFileId: string; stripeDisputeId: string }> {
183
+ if (
184
+ !opts.actor ||
185
+ !["admin", "seller"].includes(opts.actor.type) ||
186
+ (opts.actor.type === "seller" &&
187
+ (typeof opts.actor.accountId !== "string" ||
188
+ !opts.actor.accountId.trim()))
189
+ )
190
+ throwStripeError(
191
+ "DISPUTE_UNAUTHORIZED",
192
+ "Dispute upload requires an authenticated actor",
193
+ );
194
+ await assertDisputeAuthorized(component, ctx, opts);
195
+ const fields: DisputeEvidenceFileField[] = [
196
+ "cancellation_policy",
197
+ "customer_communication",
198
+ "customer_signature",
199
+ "duplicate_charge_documentation",
200
+ "receipt",
201
+ "refund_policy",
202
+ "service_documentation",
203
+ "shipping_documentation",
204
+ "uncategorized_file",
205
+ ];
206
+ const { data, name, type } = opts.file;
207
+ const signature =
208
+ type === "application/pdf"
209
+ ? [37, 80, 68, 70, 45]
210
+ : type === "image/jpeg"
211
+ ? [255, 216, 255]
212
+ : type === "image/png"
213
+ ? [137, 80, 78, 71, 13, 10, 26, 10]
214
+ : [];
215
+ if (
216
+ !fields.includes(opts.field) ||
217
+ !(data instanceof Uint8Array) ||
218
+ !data.byteLength ||
219
+ data.byteLength > 5_000_000 ||
220
+ !name.trim() ||
221
+ !signature.length ||
222
+ !signature.every((byte, i) => data[i] === byte)
223
+ )
224
+ throwStripeError(
225
+ "INVALID_CONFIGURATION",
226
+ "Invalid dispute evidence: use a nonempty PDF, JPEG or PNG up to 5 MB with a matching MIME type and filename",
227
+ );
228
+ let stripeFileId: string;
229
+ try {
230
+ const file = await stripe.files.create(
231
+ { purpose: "dispute_evidence", file: { data, name, type } },
232
+ opts.stripeAccountId
233
+ ? { stripeAccount: opts.stripeAccountId }
234
+ : undefined,
235
+ );
236
+ stripeFileId = file.id;
237
+ } catch (err) {
238
+ throwStripeError(
239
+ "DISPUTE_UPLOAD_FAILED",
240
+ "Failed to upload dispute evidence",
241
+ err,
242
+ );
243
+ }
244
+ await updateDispute(stripe, component, ctx, {
245
+ stripeDisputeId: opts.stripeDisputeId,
246
+ actor: opts.actor,
247
+ stripeAccountId: opts.stripeAccountId,
248
+ evidence: { [opts.field]: stripeFileId },
249
+ submit: false,
250
+ });
251
+ return { stripeFileId, stripeDisputeId: opts.stripeDisputeId };
252
+ }
253
+
151
254
  /**
152
255
  * Accept a dispute (concede the chargeback). Irreversible.
153
256
  *
@@ -273,3 +273,11 @@ export function validateSplit(recipients: SplitRecipient[]): void {
273
273
  throw new Error(`split percents sum to ${percentSum}, which exceeds 100`);
274
274
  }
275
275
  }
276
+
277
+ /** Deep-copy a platform fee config so stored/returned values can't be mutated. */
278
+ export function clonePlatformFee(config: PlatformFeeConfig): PlatformFeeConfig {
279
+ return {
280
+ ...config,
281
+ tiers: config.tiers?.map((tier) => ({ ...tier })),
282
+ };
283
+ }
@@ -0,0 +1,174 @@
1
+ import { componentsGeneric, type FunctionReference } from "convex/server";
2
+ import { expect, it } from "vitest";
3
+ import schema from "../component/schema.js";
4
+ import { initConvexTest } from "./setup.test.js";
5
+ import { BetterStripe } from "./index.js";
6
+ import type { Component, RunCtx } from "./helpers.js";
7
+ const modules = import.meta.glob("../component/**/*.{ts,js,tsx,jsx}");
8
+ const scope = { accountId: "acct_owner", scopeKey: "store", currency: "usd" };
9
+ const observation = {
10
+ ...scope,
11
+ claimKey: "cost:ch_original",
12
+ sourceChargeId: "ch_original",
13
+ sourceGross: 10000,
14
+ sourceProcessingFee: 320,
15
+ amountDue: 320,
16
+ revision: 0,
17
+ };
18
+ async function setup() {
19
+ const t = initConvexTest();
20
+ t.registerComponent("betterStripe", schema, modules);
21
+ const ctx = {
22
+ runQuery: (
23
+ ref: FunctionReference<"query">,
24
+ args: Record<string, unknown>,
25
+ ) => t.query(ref, args),
26
+ runMutation: (
27
+ ref: FunctionReference<"mutation">,
28
+ args: Record<string, unknown>,
29
+ ) => t.mutation(ref, args),
30
+ } as unknown as RunCtx;
31
+ return {
32
+ ctx,
33
+ recovery: new BetterStripe(
34
+ componentsGeneric().betterStripe as unknown as Component,
35
+ {},
36
+ ).costRecovery,
37
+ };
38
+ }
39
+ it("imports verified opening collections once and preserves the snapshot across newer observations", async () => {
40
+ const { ctx, recovery } = await setup();
41
+ const input = {
42
+ ...observation,
43
+ operationKey: "cutover:legacy-row",
44
+ collectedAmount: 200,
45
+ };
46
+ const first = await recovery.importClaim(ctx, input);
47
+ expect(first).toMatchObject({
48
+ amountDue: 320,
49
+ collectedAmount: 200,
50
+ outstanding: 120,
51
+ reservedAmount: 0,
52
+ });
53
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
54
+ amountDue: 320,
55
+ collectedAmount: 200,
56
+ outstanding: 120,
57
+ });
58
+ await recovery.recordClaim(ctx, {
59
+ ...observation,
60
+ amountDue: 100,
61
+ revision: 1,
62
+ });
63
+ const replay = await recovery.importClaim(ctx, input);
64
+ expect(replay).toMatchObject({
65
+ _id: first._id,
66
+ amountDue: 100,
67
+ revision: 1,
68
+ collectedAmount: 200,
69
+ outstanding: -100,
70
+ });
71
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
72
+ amountDue: 100,
73
+ collectedAmount: 200,
74
+ outstanding: -100,
75
+ });
76
+ await expect(
77
+ recovery.importClaim(ctx, { ...input, collectedAmount: 201 }),
78
+ ).rejects.toThrow(/snapshot/i);
79
+ await expect(
80
+ recovery.importClaim(ctx, { ...input, accountId: "acct_other" }),
81
+ ).rejects.toThrow(/identity/i);
82
+ });
83
+ it("preserves signed credit collections and currency isolation without Stripe credentials", async () => {
84
+ const { ctx, recovery } = await setup();
85
+ await recovery.importClaim(ctx, {
86
+ ...observation,
87
+ amountDue: -500,
88
+ collectedAmount: -200,
89
+ operationKey: "credit-import",
90
+ });
91
+ await recovery.importClaim(ctx, {
92
+ ...observation,
93
+ claimKey: "cost:eur",
94
+ currency: "eur",
95
+ collectedAmount: 300,
96
+ operationKey: "eur-import",
97
+ });
98
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
99
+ amountDue: -500,
100
+ collectedAmount: -200,
101
+ outstanding: -300,
102
+ });
103
+ expect(
104
+ await recovery.balance(ctx, { ...scope, currency: "eur" }),
105
+ ).toMatchObject({ amountDue: 320, collectedAmount: 300, outstanding: 20 });
106
+ });
107
+ it("refuses to overwrite live claims or import into a pending external recovery boundary", async () => {
108
+ const { ctx, recovery } = await setup();
109
+ await recovery.recordClaim(ctx, observation);
110
+ await expect(
111
+ recovery.importClaim(ctx, {
112
+ ...observation,
113
+ operationKey: "late-import",
114
+ collectedAmount: 200,
115
+ }),
116
+ ).rejects.toThrow(/existing/i);
117
+ await recovery.reserve(ctx, {
118
+ ...scope,
119
+ groupKey: "pending",
120
+ operationKey: "pending:1",
121
+ sourceKind: "invoice",
122
+ sourceId: "in_1",
123
+ maxDebit: 10000,
124
+ maxCredit: 10000,
125
+ });
126
+ await expect(
127
+ recovery.importClaim(ctx, {
128
+ ...observation,
129
+ claimKey: "other",
130
+ operationKey: "new-import",
131
+ collectedAmount: 10,
132
+ }),
133
+ ).rejects.toThrow(/pending/i);
134
+ await expect(
135
+ recovery.importClaim(ctx, {
136
+ ...observation,
137
+ claimKey: "invalid",
138
+ operationKey: "",
139
+ collectedAmount: 0.5,
140
+ }),
141
+ ).rejects.toThrow();
142
+ });
143
+ it("reserves only the uncollected remainder and keeps the opening import idempotent after collection", async () => {
144
+ const { ctx, recovery } = await setup();
145
+ const input = {
146
+ ...observation,
147
+ operationKey: "opening",
148
+ collectedAmount: 200,
149
+ };
150
+ await recovery.importClaim(ctx, input);
151
+ const group = {
152
+ ...scope,
153
+ groupKey: "funding",
154
+ operationKey: "funding:1",
155
+ sourceKind: "settlement" as const,
156
+ sourceId: "ch_funding",
157
+ sourceGross: 10000,
158
+ sourceProcessingFee: 320,
159
+ maxDebit: 10000,
160
+ maxCredit: 10000,
161
+ };
162
+ expect((await recovery.reserve(ctx, group)).group.amount).toBe(120);
163
+ await recovery.seal(ctx, { groupKey: "funding" });
164
+ await recovery.finish(ctx, { groupKey: "funding", outcome: "collect" });
165
+ await recovery.importClaim(ctx, input);
166
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
167
+ amountDue: 320,
168
+ collectedAmount: 320,
169
+ outstanding: 0,
170
+ });
171
+ await expect(
172
+ recovery.importClaim(ctx, { ...input, claimKey: "other" }),
173
+ ).rejects.toThrow(/identity/i);
174
+ });
@@ -399,3 +399,72 @@ it("never creates a new provider item for an already completed bookkeeping group
399
399
  await expect(recovery.attachInvoice(ctx, options)).rejects.toThrow(/sealed/i);
400
400
  expect(provider.invoiceItems.create).not.toHaveBeenCalled();
401
401
  });
402
+ it("releases an unbound void invoice after a complete item scan without creating an adjustment", async () => {
403
+ const { ctx, recovery, invoice, items, provider } = await setup();
404
+ items.push(
405
+ ...Array.from(
406
+ { length: 105 },
407
+ (_, i) => ({ id: `ii_other_${i}`, metadata: {} }) as Stripe.InvoiceItem,
408
+ ),
409
+ );
410
+ invoice.status = "void";
411
+ const result = await recovery.reconcileInvoice(ctx, options);
412
+ expect(result).toMatchObject({
413
+ isDone: true,
414
+ invoiceItemId: null,
415
+ invoiceStatus: "void",
416
+ });
417
+ await recovery.reconcileInvoice(ctx, options);
418
+ expect(provider.invoiceItems.create).not.toHaveBeenCalled();
419
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
420
+ reservedAmount: 0,
421
+ collectedAmount: 0,
422
+ outstanding: 320,
423
+ });
424
+ });
425
+ it("keeps missing adjustments reserved for unpaid, paid, uncollectible or previously bound invoices", async () => {
426
+ for (const status of ["draft", "open", "paid", "uncollectible"]) {
427
+ const { ctx, recovery, invoice } = await setup();
428
+ invoice.status = status;
429
+ await expect(
430
+ recovery.reconcileInvoice(ctx, {
431
+ ...options,
432
+ releaseUncollectible: true,
433
+ }),
434
+ ).rejects.toThrow(/missing/i);
435
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
436
+ reservedAmount: 320,
437
+ collectedAmount: 0,
438
+ });
439
+ }
440
+ const { ctx, recovery, invoice, items } = await setup();
441
+ await recovery.attachInvoice(ctx, options);
442
+ items.length = 0;
443
+ invoice.status = "void";
444
+ await expect(recovery.reconcileInvoice(ctx, options)).rejects.toThrow(
445
+ /bound.*missing/i,
446
+ );
447
+ });
448
+ it("does not release an unbound void invoice on an incomplete scan or ownership mismatch", async () => {
449
+ const { ctx, recovery, invoice, provider } = await setup();
450
+ invoice.status = "void";
451
+ const originalList = provider.invoiceItems.list.getMockImplementation()!;
452
+ provider.invoiceItems.list.mockImplementation(() =>
453
+ (async function* () {
454
+ yield { id: "ii_unrelated", metadata: {} } as Stripe.InvoiceItem;
455
+ throw new Error("pagination interrupted");
456
+ })(),
457
+ );
458
+ await expect(recovery.reconcileInvoice(ctx, options)).rejects.toThrow(
459
+ "pagination interrupted",
460
+ );
461
+ provider.invoiceItems.list.mockImplementation(originalList);
462
+ invoice.customer_account = "acct_other";
463
+ await expect(recovery.reconcileInvoice(ctx, options)).rejects.toThrow(
464
+ /ownership/i,
465
+ );
466
+ expect(await recovery.balance(ctx, scope)).toMatchObject({
467
+ reservedAmount: 320,
468
+ collectedAmount: 0,
469
+ });
470
+ });
@@ -6,6 +6,32 @@ const marker = "bsRecoveryGroup";
6
6
  const id = (value: string | { id: string } | null) =>
7
7
  typeof value === "string" ? value : value?.id;
8
8
 
9
+ type InvoiceReceipt = {
10
+ group: RecoveryGroup;
11
+ invoiceItemId: string;
12
+ invoiceStatus: Stripe.Invoice["status"];
13
+ };
14
+ type BindInvoiceItem = (invoiceItemId: string) => Promise<RecoveryGroup>;
15
+
16
+ /** Attachment always produces an item; reconciliation can release an unbound void invoice. */
17
+ export function attachRecoveryInvoice(
18
+ stripe: Stripe,
19
+ group: RecoveryGroup | null,
20
+ opts: RecoveryInvoiceOptions,
21
+ bind: BindInvoiceItem,
22
+ create?: true,
23
+ ): Promise<InvoiceReceipt>;
24
+ export function attachRecoveryInvoice(
25
+ stripe: Stripe,
26
+ group: RecoveryGroup | null,
27
+ opts: RecoveryInvoiceOptions,
28
+ bind: BindInvoiceItem,
29
+ create: false,
30
+ ): Promise<
31
+ | InvoiceReceipt
32
+ | (Omit<InvoiceReceipt, "invoiceItemId"> & { invoiceItemId: null })
33
+ >;
34
+
9
35
  /** One immutable item per sealed group. Host serializes provider operations per group. */
10
36
  export async function attachRecoveryInvoice(
11
37
  stripe: Stripe,
@@ -66,6 +92,12 @@ export async function attachRecoveryInvoice(
66
92
  throw new Error(
67
93
  "Bound recovery invoice item is missing; reconcile before retrying.",
68
94
  );
95
+ if (
96
+ !create &&
97
+ invoice.status === "void" &&
98
+ ["sealed", "releasing", "released"].includes(group.status)
99
+ )
100
+ return { group, invoiceItemId: null, invoiceStatus: invoice.status };
69
101
  if (!create)
70
102
  throw new Error(
71
103
  "Recovery invoice item is missing; no collection can be confirmed.",
@@ -26,6 +26,12 @@ export type RecoveryClaim = RecoveryObservation & {
26
26
  reservedAmount: number;
27
27
  outstanding: number;
28
28
  sign: "positive" | "negative" | "zero";
29
+ openingCollection?: {
30
+ operationKey: string;
31
+ amountDue: number;
32
+ revision: number;
33
+ collectedAmount: number;
34
+ };
29
35
  };
30
36
  export type RecoveryBalance = RecoveryScope & {
31
37
  amountDue: number;
@@ -194,6 +200,16 @@ export class CostRecoveryClient {
194
200
  }),
195
201
  };
196
202
  }
203
+ /** Import a host-verified opening collection. Never overwrites a live claim. */
204
+ importClaim(
205
+ ctx: RunCtx,
206
+ opts: RecoveryObservation & {
207
+ operationKey: string;
208
+ collectedAmount: number;
209
+ },
210
+ ) {
211
+ return this.mutate<RecoveryClaim>(ctx, "mutations/importClaim", opts);
212
+ }
197
213
  recordClaim(ctx: RunCtx, opts: RecoveryObservation) {
198
214
  return this.mutate<RecoveryClaim>(ctx, "mutations/record", opts);
199
215
  }
@@ -9,6 +9,7 @@ export type BetterStripeErrorCode =
9
9
  | "SUBSCRIPTION_UPDATE_FAILED"
10
10
  | "REFUND_CREATE_FAILED"
11
11
  | "REFUND_UNAUTHORIZED"
12
+ | "DISPUTE_UPLOAD_FAILED"
12
13
  | "DISPUTE_UPDATE_FAILED"
13
14
  | "DISPUTE_UNAUTHORIZED"
14
15
  | "CHECKOUT_CREATE_FAILED"