@fantastic.dev/better-stripe 0.12.0 → 0.14.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.
- package/CHANGELOG.md +24 -0
- package/README.md +93 -8
- package/dist/client/connect/disputes.d.ts +20 -0
- package/dist/client/connect/disputes.d.ts.map +1 -1
- package/dist/client/connect/disputes.js +55 -0
- package/dist/client/connect/disputes.js.map +1 -1
- package/dist/client/costRecovery-invoices.d.ts +44 -0
- package/dist/client/costRecovery-invoices.d.ts.map +1 -0
- package/dist/client/costRecovery-invoices.js +109 -0
- package/dist/client/costRecovery-invoices.js.map +1 -0
- package/dist/client/costRecovery.d.ts +59 -2
- package/dist/client/costRecovery.d.ts.map +1 -1
- package/dist/client/costRecovery.js +73 -2
- package/dist/client/costRecovery.js.map +1 -1
- package/dist/client/errors.d.ts +1 -1
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js.map +1 -1
- package/dist/client/index.d.ts +5 -14
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +4 -8
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +137 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/costRecovery/claims.d.ts +12 -0
- package/dist/component/costRecovery/claims.d.ts.map +1 -1
- package/dist/component/costRecovery/claims.js +15 -8
- package/dist/component/costRecovery/claims.js.map +1 -1
- package/dist/component/costRecovery/completion.d.ts +6 -0
- package/dist/component/costRecovery/completion.d.ts.map +1 -1
- package/dist/component/costRecovery/helpers.d.ts +6 -0
- package/dist/component/costRecovery/helpers.d.ts.map +1 -1
- package/dist/component/costRecovery/imports.d.ts +53 -0
- package/dist/component/costRecovery/imports.d.ts.map +1 -0
- package/dist/component/costRecovery/imports.js +67 -0
- package/dist/component/costRecovery/imports.js.map +1 -0
- package/dist/component/costRecovery/mutations.d.ts +119 -0
- package/dist/component/costRecovery/mutations.d.ts.map +1 -1
- package/dist/component/costRecovery/mutations.js +64 -1
- package/dist/component/costRecovery/mutations.js.map +1 -1
- package/dist/component/costRecovery/queries.d.ts +18 -0
- package/dist/component/costRecovery/queries.d.ts.map +1 -1
- package/dist/component/costRecovery/reservations.d.ts +12 -0
- package/dist/component/costRecovery/reservations.d.ts.map +1 -1
- package/dist/component/costRecovery/schema.d.ts +116 -4
- package/dist/component/costRecovery/schema.d.ts.map +1 -1
- package/dist/component/costRecovery/schema.js +19 -0
- package/dist/component/costRecovery/schema.js.map +1 -1
- package/dist/component/schema.d.ts +36 -2
- package/dist/react/workflows/Collections.d.ts.map +1 -1
- package/dist/react/workflows/Collections.js +2 -1
- package/dist/react/workflows/Collections.js.map +1 -1
- package/dist/react/workflows/EvidenceUpload.d.ts +18 -0
- package/dist/react/workflows/EvidenceUpload.d.ts.map +1 -0
- package/dist/react/workflows/EvidenceUpload.js +35 -0
- package/dist/react/workflows/EvidenceUpload.js.map +1 -0
- package/dist/react/workflows/Onboarding.d.ts +3 -3
- package/dist/react/workflows/Onboarding.d.ts.map +1 -1
- package/dist/react/workflows/Onboarding.js +8 -6
- package/dist/react/workflows/Onboarding.js.map +1 -1
- package/dist/react/workflows/Refunds.d.ts +14 -3
- package/dist/react/workflows/Refunds.d.ts.map +1 -1
- package/dist/react/workflows/Refunds.js +58 -16
- package/dist/react/workflows/Refunds.js.map +1 -1
- package/dist/react/workflows/index.d.ts +5 -1
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +3 -1
- package/dist/react/workflows/index.js.map +1 -1
- package/dist/react/workflows/refund-preview.d.ts +16 -0
- package/dist/react/workflows/refund-preview.d.ts.map +1 -0
- package/dist/react/workflows/refund-preview.js +38 -0
- package/dist/react/workflows/refund-preview.js.map +1 -0
- package/dist/react/workflows/useWorkflowAction.d.ts +1 -1
- package/dist/react/workflows/useWorkflowAction.d.ts.map +1 -1
- package/dist/react/workflows/useWorkflowAction.js +6 -2
- package/dist/react/workflows/useWorkflowAction.js.map +1 -1
- package/docs/react-workflows.md +121 -4
- package/package.json +1 -1
- package/src/client/connect/dispute-upload.test.ts +208 -0
- package/src/client/connect/disputes.ts +103 -0
- package/src/client/costRecovery-import.test.ts +174 -0
- package/src/client/costRecovery-invoices.test.ts +470 -0
- package/src/client/costRecovery-invoices.transport.test.ts +107 -0
- package/src/client/costRecovery-invoices.ts +176 -0
- package/src/client/costRecovery.ts +123 -2
- package/src/client/errors.ts +1 -0
- package/src/client/index.ts +15 -14
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +164 -0
- package/src/component/costRecovery/claims.ts +18 -8
- package/src/component/costRecovery/imports.ts +74 -0
- package/src/component/costRecovery/mutations.ts +80 -0
- package/src/component/costRecovery/schema.ts +21 -0
- package/src/react/workflows/Collections.test.tsx +36 -0
- package/src/react/workflows/Collections.tsx +3 -1
- package/src/react/workflows/EvidenceUpload.test.tsx +56 -0
- package/src/react/workflows/EvidenceUpload.tsx +71 -0
- package/src/react/workflows/Onboarding.test.tsx +73 -0
- package/src/react/workflows/Onboarding.tsx +8 -5
- package/src/react/workflows/Refunds.currency.test.tsx +290 -0
- package/src/react/workflows/Refunds.tsx +115 -25
- package/src/react/workflows/index.ts +11 -1
- package/src/react/workflows/refund-preview.test.ts +65 -0
- package/src/react/workflows/refund-preview.ts +56 -0
- package/src/react/workflows/useWorkflowAction.ts +6 -1
|
@@ -0,0 +1,470 @@
|
|
|
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
|
+
});
|
|
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
|
+
});
|
|
@@ -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
|
+
);
|