@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,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
|
*
|
|
@@ -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
|
+
});
|