@fantastic.dev/better-stripe 0.19.0 → 0.20.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 +6 -0
- package/README.md +5 -3
- package/dist/client/connect/transferReceipt.d.ts.map +1 -1
- package/dist/client/connect/transferReceipt.js +1 -14
- package/dist/client/connect/transferReceipt.js.map +1 -1
- package/dist/component/_generated/component.d.ts +297 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/earnings/triggers.d.ts +134 -0
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/payoutRelease/allocation.d.ts +48 -0
- package/dist/component/payoutRelease/allocation.d.ts.map +1 -0
- package/dist/component/payoutRelease/allocation.js +98 -0
- package/dist/component/payoutRelease/allocation.js.map +1 -0
- package/dist/component/payoutRelease/helpers.d.ts +106 -0
- package/dist/component/payoutRelease/helpers.d.ts.map +1 -0
- package/dist/component/payoutRelease/helpers.js +119 -0
- package/dist/component/payoutRelease/helpers.js.map +1 -0
- package/dist/component/payoutRelease/mutations.d.ts +218 -0
- package/dist/component/payoutRelease/mutations.d.ts.map +1 -0
- package/dist/component/payoutRelease/mutations.js +254 -0
- package/dist/component/payoutRelease/mutations.js.map +1 -0
- package/dist/component/payoutRelease/queries.d.ts +84 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -0
- package/dist/component/payoutRelease/queries.js +51 -0
- package/dist/component/payoutRelease/queries.js.map +1 -0
- package/dist/component/payoutRelease/schema.d.ts +289 -0
- package/dist/component/payoutRelease/schema.d.ts.map +1 -0
- package/dist/component/payoutRelease/schema.js +56 -0
- package/dist/component/payoutRelease/schema.js.map +1 -0
- package/dist/component/schema.d.ts +110 -0
- package/dist/component/schema.js +3 -0
- package/dist/component/schema.js.map +1 -1
- package/docs/settlement.md +31 -0
- package/package.json +1 -1
- package/src/client/connect/payoutAllocation.test.ts +292 -0
- package/src/client/connect/transferReceipt.ts +1 -14
- package/src/component/_generated/api.ts +8 -0
- package/src/component/_generated/component.ts +372 -0
- package/src/component/payout-release.test.ts +588 -0
- package/src/component/payoutRelease/allocation.ts +147 -0
- package/src/component/payoutRelease/helpers.ts +168 -0
- package/src/component/payoutRelease/mutations.ts +309 -0
- package/src/component/payoutRelease/queries.ts +56 -0
- package/src/component/payoutRelease/schema.ts +69 -0
- package/src/component/schema.ts +6 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import { convexTest } from "convex-test";
|
|
3
|
+
import { makeFunctionReference } from "convex/server";
|
|
4
|
+
import { expect, it } from "vitest";
|
|
5
|
+
import schema from "./schema.js";
|
|
6
|
+
import { transferFingerprint } from "./payoutRelease/helpers.js";
|
|
7
|
+
const modules = import.meta.glob("./**/*.*s");
|
|
8
|
+
const m = (name: string) =>
|
|
9
|
+
makeFunctionReference<"mutation">(`payoutRelease/mutations:${name}`);
|
|
10
|
+
const pm = (name: string) =>
|
|
11
|
+
makeFunctionReference<"mutation">(`payoutPolicy/mutations:${name}`);
|
|
12
|
+
const scope = { accountId: "acct_recipient", ownerKey: "user_1" };
|
|
13
|
+
const transfer = (
|
|
14
|
+
stripeTransferId: string,
|
|
15
|
+
amount: number,
|
|
16
|
+
role = "store",
|
|
17
|
+
) => ({
|
|
18
|
+
stripeTransferId,
|
|
19
|
+
destinationAccountId: scope.accountId,
|
|
20
|
+
amount,
|
|
21
|
+
currency: "usd",
|
|
22
|
+
status: "paid",
|
|
23
|
+
role,
|
|
24
|
+
});
|
|
25
|
+
async function setup() {
|
|
26
|
+
const t = convexTest(schema, modules);
|
|
27
|
+
await t.mutation(pm("prepare"), {
|
|
28
|
+
...scope,
|
|
29
|
+
operationKey: "enable",
|
|
30
|
+
expectedRevision: -1,
|
|
31
|
+
mode: "custom",
|
|
32
|
+
thresholds: [{ currency: "usd", amount: 1000 }],
|
|
33
|
+
country: "US",
|
|
34
|
+
observedSchedule: { interval: "daily" },
|
|
35
|
+
});
|
|
36
|
+
await t.mutation(pm("confirm"), {
|
|
37
|
+
...scope,
|
|
38
|
+
operationKey: "enable",
|
|
39
|
+
observedSchedule: { interval: "manual" },
|
|
40
|
+
});
|
|
41
|
+
for (const row of [
|
|
42
|
+
transfer("tr_creator", 600),
|
|
43
|
+
transfer("tr_affiliate", 400, "affiliate"),
|
|
44
|
+
])
|
|
45
|
+
await t.mutation(
|
|
46
|
+
makeFunctionReference<"mutation">("connect/mutations:upsertTransfer"),
|
|
47
|
+
row,
|
|
48
|
+
);
|
|
49
|
+
const rows = await t.run(async (ctx) => ctx.db.query("transfers").take(10));
|
|
50
|
+
const args = {
|
|
51
|
+
...scope,
|
|
52
|
+
operationKey: "release-1",
|
|
53
|
+
expectedRevision: 0,
|
|
54
|
+
currency: "usd",
|
|
55
|
+
providerAvailable: 1000,
|
|
56
|
+
independentLiabilities: 0,
|
|
57
|
+
observations: rows.map((row) => ({
|
|
58
|
+
stripeTransferId: row.stripeTransferId,
|
|
59
|
+
matureNet: row.amount,
|
|
60
|
+
pendingCredits: 0,
|
|
61
|
+
currency: row.currency,
|
|
62
|
+
ledgerFingerprint: transferFingerprint(row),
|
|
63
|
+
observedAt: Date.now(),
|
|
64
|
+
transactionIds: [`txn_${row.stripeTransferId}`],
|
|
65
|
+
})),
|
|
66
|
+
};
|
|
67
|
+
return { t, args };
|
|
68
|
+
}
|
|
69
|
+
it("reserves exact combined allocations atomically and replays an immutable claim", async () => {
|
|
70
|
+
const { t, args } = await setup();
|
|
71
|
+
const result = await t.mutation(m("claim"), args);
|
|
72
|
+
expect(result).toMatchObject({
|
|
73
|
+
status: "reserved",
|
|
74
|
+
amount: 1000,
|
|
75
|
+
currency: "usd",
|
|
76
|
+
});
|
|
77
|
+
expect(result.attemptedAt).toBeUndefined();
|
|
78
|
+
expect(result.allocations).toEqual([
|
|
79
|
+
{ stripeTransferId: "tr_affiliate", amount: 400, role: "affiliate" },
|
|
80
|
+
{ stripeTransferId: "tr_creator", amount: 600, role: "store" },
|
|
81
|
+
]);
|
|
82
|
+
expect(
|
|
83
|
+
await t.mutation(m("claim"), { ...args, providerAvailable: 0 }),
|
|
84
|
+
).toEqual(result);
|
|
85
|
+
expect(
|
|
86
|
+
await t.run((ctx) => ctx.db.query("payoutSourceAllocations").take(10)),
|
|
87
|
+
).toHaveLength(2);
|
|
88
|
+
});
|
|
89
|
+
it("serializes competing workers with one unresolved account claim", async () => {
|
|
90
|
+
const { t, args } = await setup();
|
|
91
|
+
const results = await Promise.all([
|
|
92
|
+
t.mutation(m("claim"), args),
|
|
93
|
+
t.mutation(m("claim"), { ...args, operationKey: "release-2" }),
|
|
94
|
+
]);
|
|
95
|
+
expect(results.filter((result) => result.status === "reserved")).toHaveLength(
|
|
96
|
+
1,
|
|
97
|
+
);
|
|
98
|
+
expect(results.filter((result) => result.status === "held")).toMatchObject([
|
|
99
|
+
{ reason: "pending_claim" },
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
it("holds below the threshold without reserving or erasing money", async () => {
|
|
103
|
+
const { t, args } = await setup();
|
|
104
|
+
args.observations[0].matureNet -= 1;
|
|
105
|
+
expect(await t.mutation(m("claim"), args)).toMatchObject({
|
|
106
|
+
status: "held",
|
|
107
|
+
reason: "below_threshold",
|
|
108
|
+
matureUnpaid: 999,
|
|
109
|
+
});
|
|
110
|
+
expect(
|
|
111
|
+
await t.run((ctx) => ctx.db.query("payoutReleaseClaims").take(10)),
|
|
112
|
+
).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
it("rejects stale, incomplete, duplicate, or foreign ledger snapshots", async () => {
|
|
115
|
+
for (const change of [
|
|
116
|
+
(args: Awaited<ReturnType<typeof setup>>["args"]) => {
|
|
117
|
+
args.observations.pop();
|
|
118
|
+
},
|
|
119
|
+
(args: Awaited<ReturnType<typeof setup>>["args"]) => {
|
|
120
|
+
args.observations.push(args.observations[0]);
|
|
121
|
+
},
|
|
122
|
+
(args: Awaited<ReturnType<typeof setup>>["args"]) => {
|
|
123
|
+
args.observations[0].ledgerFingerprint = "stale";
|
|
124
|
+
},
|
|
125
|
+
(args: Awaited<ReturnType<typeof setup>>["args"]) => {
|
|
126
|
+
args.observations[0].stripeTransferId = "tr_foreign";
|
|
127
|
+
},
|
|
128
|
+
]) {
|
|
129
|
+
const { t, args } = await setup();
|
|
130
|
+
change(args);
|
|
131
|
+
await expect(t.mutation(m("claim"), args)).rejects.toThrow(
|
|
132
|
+
/snapshot|source/,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
it("detects known reversal changes before reservation and before an attempt", async () => {
|
|
137
|
+
for (const beforeClaim of [true, false]) {
|
|
138
|
+
const { t, args } = await setup();
|
|
139
|
+
if (!beforeClaim) await t.mutation(m("claim"), args);
|
|
140
|
+
await t.mutation(
|
|
141
|
+
makeFunctionReference<"mutation">(
|
|
142
|
+
"connect/mutations:recordTransferReversal",
|
|
143
|
+
),
|
|
144
|
+
{ stripeTransferId: "tr_creator", reversedAmount: 100 },
|
|
145
|
+
);
|
|
146
|
+
await expect(
|
|
147
|
+
t.mutation(
|
|
148
|
+
m(beforeClaim ? "claim" : "beginAttempt"),
|
|
149
|
+
beforeClaim ? args : { ...scope, operationKey: args.operationKey },
|
|
150
|
+
),
|
|
151
|
+
).rejects.toThrow(/snapshot|source/);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
it("binds a claim to the recipient owner, account, currency and policy revision", async () => {
|
|
155
|
+
const { t, args } = await setup();
|
|
156
|
+
await t.mutation(m("claim"), args);
|
|
157
|
+
for (const change of [
|
|
158
|
+
{ ownerKey: "other" },
|
|
159
|
+
{ accountId: "acct_other" },
|
|
160
|
+
{ currency: "eur" },
|
|
161
|
+
{ expectedRevision: 1 },
|
|
162
|
+
])
|
|
163
|
+
await expect(
|
|
164
|
+
t.mutation(m("claim"), { ...args, ...change }),
|
|
165
|
+
).rejects.toThrow(/identity/);
|
|
166
|
+
});
|
|
167
|
+
it("keeps uncertain provider outcomes reserved and forbids canceling after an attempt", async () => {
|
|
168
|
+
const { t, args } = await setup();
|
|
169
|
+
await t.mutation(m("claim"), args);
|
|
170
|
+
const identity = { ...scope, operationKey: args.operationKey };
|
|
171
|
+
const attempt = await t.mutation(m("beginAttempt"), identity);
|
|
172
|
+
expect(attempt.attemptedAt).toBeGreaterThan(0);
|
|
173
|
+
expect((await t.mutation(m("beginAttempt"), identity)).attemptedAt).toBe(
|
|
174
|
+
attempt.attemptedAt,
|
|
175
|
+
);
|
|
176
|
+
await expect(t.mutation(m("cancelUnattempted"), identity)).rejects.toThrow(
|
|
177
|
+
/attempt/,
|
|
178
|
+
);
|
|
179
|
+
expect(
|
|
180
|
+
await t.mutation(m("claim"), { ...args, operationKey: "next" }),
|
|
181
|
+
).toMatchObject({ status: "held", reason: "pending_claim" });
|
|
182
|
+
});
|
|
183
|
+
it("cancels an unattempted claim and releases only its own reservations", async () => {
|
|
184
|
+
const { t, args } = await setup();
|
|
185
|
+
await t.mutation(m("claim"), args);
|
|
186
|
+
const canceled = await t.mutation(m("cancelUnattempted"), {
|
|
187
|
+
...scope,
|
|
188
|
+
operationKey: args.operationKey,
|
|
189
|
+
});
|
|
190
|
+
expect(canceled.status).toBe("canceled");
|
|
191
|
+
expect(
|
|
192
|
+
(await t.mutation(m("claim"), { ...args, operationKey: "retry" })).amount,
|
|
193
|
+
).toBe(1000);
|
|
194
|
+
expect((await t.mutation(m("claim"), args)).status).toBe("canceled");
|
|
195
|
+
});
|
|
196
|
+
it("records accepted payouts without losing reservations, then moves them to paid exactly once", async () => {
|
|
197
|
+
const { t, args } = await setup();
|
|
198
|
+
await t.mutation(m("claim"), args);
|
|
199
|
+
const identity = { ...scope, operationKey: args.operationKey };
|
|
200
|
+
await t.mutation(m("beginAttempt"), identity);
|
|
201
|
+
const receipt = {
|
|
202
|
+
...identity,
|
|
203
|
+
stripePayoutId: "po_one",
|
|
204
|
+
amount: 1000,
|
|
205
|
+
currency: "usd",
|
|
206
|
+
providerStatus: "pending",
|
|
207
|
+
};
|
|
208
|
+
expect((await t.mutation(m("recordOutcome"), receipt)).status).toBe(
|
|
209
|
+
"submitted",
|
|
210
|
+
);
|
|
211
|
+
expect(
|
|
212
|
+
await t.mutation(m("claim"), { ...args, operationKey: "next" }),
|
|
213
|
+
).toMatchObject({
|
|
214
|
+
status: "held",
|
|
215
|
+
reason: "below_threshold",
|
|
216
|
+
matureUnpaid: 0,
|
|
217
|
+
});
|
|
218
|
+
for (let i = 0; i < 2; i++)
|
|
219
|
+
expect(
|
|
220
|
+
(
|
|
221
|
+
await t.mutation(m("recordOutcome"), {
|
|
222
|
+
...receipt,
|
|
223
|
+
providerStatus: "paid",
|
|
224
|
+
})
|
|
225
|
+
).status,
|
|
226
|
+
).toBe("paid");
|
|
227
|
+
expect(
|
|
228
|
+
await t.run((ctx) => ctx.db.query("payoutSourceAllocations").take(10)),
|
|
229
|
+
).toMatchObject([
|
|
230
|
+
{ paidAmount: 400, reservedAmount: 0 },
|
|
231
|
+
{ paidAmount: 600, reservedAmount: 0 },
|
|
232
|
+
]);
|
|
233
|
+
expect((await t.mutation(m("recordOutcome"), receipt)).status).toBe("paid");
|
|
234
|
+
});
|
|
235
|
+
it("keeps failed payout money held until the provider return receipt is verified", async () => {
|
|
236
|
+
const { t, args } = await setup();
|
|
237
|
+
await t.mutation(m("claim"), args);
|
|
238
|
+
const identity = { ...scope, operationKey: args.operationKey };
|
|
239
|
+
await t.mutation(m("beginAttempt"), identity);
|
|
240
|
+
const receipt = {
|
|
241
|
+
...identity,
|
|
242
|
+
stripePayoutId: "po_one",
|
|
243
|
+
amount: 1000,
|
|
244
|
+
currency: "usd",
|
|
245
|
+
providerStatus: "failed",
|
|
246
|
+
};
|
|
247
|
+
expect((await t.mutation(m("recordOutcome"), receipt)).status).toBe(
|
|
248
|
+
"submitted",
|
|
249
|
+
);
|
|
250
|
+
expect(
|
|
251
|
+
await t.mutation(m("claim"), { ...args, operationKey: "retry" }),
|
|
252
|
+
).toMatchObject({ status: "held", matureUnpaid: 0 });
|
|
253
|
+
const returned = { ...receipt, returnTransactionId: "txn_return" };
|
|
254
|
+
expect((await t.mutation(m("recordOutcome"), returned)).status).toBe(
|
|
255
|
+
"returned",
|
|
256
|
+
);
|
|
257
|
+
expect((await t.mutation(m("recordOutcome"), returned)).status).toBe(
|
|
258
|
+
"returned",
|
|
259
|
+
);
|
|
260
|
+
expect(
|
|
261
|
+
(await t.mutation(m("claim"), { ...args, operationKey: "retry" })).amount,
|
|
262
|
+
).toBe(1000);
|
|
263
|
+
});
|
|
264
|
+
it("adopts a verified late payout failure after it was marked paid without double credit", async () => {
|
|
265
|
+
const { t, args } = await setup();
|
|
266
|
+
await t.mutation(m("claim"), args);
|
|
267
|
+
const identity = { ...scope, operationKey: args.operationKey };
|
|
268
|
+
await t.mutation(m("beginAttempt"), identity);
|
|
269
|
+
const receipt = {
|
|
270
|
+
...identity,
|
|
271
|
+
stripePayoutId: "po_one",
|
|
272
|
+
amount: 1000,
|
|
273
|
+
currency: "usd",
|
|
274
|
+
providerStatus: "paid",
|
|
275
|
+
};
|
|
276
|
+
await t.mutation(m("recordOutcome"), receipt);
|
|
277
|
+
const returned = {
|
|
278
|
+
...receipt,
|
|
279
|
+
providerStatus: "failed",
|
|
280
|
+
returnTransactionId: "txn_return",
|
|
281
|
+
};
|
|
282
|
+
await t.mutation(m("recordOutcome"), returned);
|
|
283
|
+
await t.mutation(m("recordOutcome"), returned);
|
|
284
|
+
expect(
|
|
285
|
+
(await t.mutation(m("claim"), { ...args, operationKey: "retry" })).amount,
|
|
286
|
+
).toBe(1000);
|
|
287
|
+
});
|
|
288
|
+
it("rejects provider identity and money mismatches before changing allocations", async () => {
|
|
289
|
+
const { t, args } = await setup();
|
|
290
|
+
await t.mutation(m("claim"), args);
|
|
291
|
+
const identity = { ...scope, operationKey: args.operationKey };
|
|
292
|
+
await t.mutation(m("beginAttempt"), identity);
|
|
293
|
+
const receipt = {
|
|
294
|
+
...identity,
|
|
295
|
+
stripePayoutId: "po_one",
|
|
296
|
+
amount: 1000,
|
|
297
|
+
currency: "usd",
|
|
298
|
+
providerStatus: "pending",
|
|
299
|
+
};
|
|
300
|
+
for (const change of [
|
|
301
|
+
{ amount: 999 },
|
|
302
|
+
{ currency: "eur" },
|
|
303
|
+
{ stripePayoutId: "" },
|
|
304
|
+
])
|
|
305
|
+
await expect(
|
|
306
|
+
t.mutation(m("recordOutcome"), { ...receipt, ...change }),
|
|
307
|
+
).rejects.toThrow(/receipt|identity/);
|
|
308
|
+
await t.mutation(m("recordOutcome"), receipt);
|
|
309
|
+
await expect(
|
|
310
|
+
t.mutation(m("recordOutcome"), { ...receipt, stripePayoutId: "po_other" }),
|
|
311
|
+
).rejects.toThrow(/identity/);
|
|
312
|
+
});
|
|
313
|
+
it("rejects expired observations and stale policy revisions", async () => {
|
|
314
|
+
const { t, args } = await setup();
|
|
315
|
+
await expect(
|
|
316
|
+
t.mutation(m("claim"), { ...args, expectedRevision: 1 }),
|
|
317
|
+
).rejects.toThrow(/policy/);
|
|
318
|
+
args.observations[0].observedAt = Date.now() - 60000;
|
|
319
|
+
await expect(t.mutation(m("claim"), args)).rejects.toThrow(/observation/);
|
|
320
|
+
});
|
|
321
|
+
it("reads durable claims with ownership checks and bounded pending pages", async () => {
|
|
322
|
+
const { t, args } = await setup();
|
|
323
|
+
const get = makeFunctionReference<"query">("payoutRelease/queries:get");
|
|
324
|
+
const pending = makeFunctionReference<"query">(
|
|
325
|
+
"payoutRelease/queries:pending",
|
|
326
|
+
);
|
|
327
|
+
expect(
|
|
328
|
+
await t.query(get, { ...scope, operationKey: args.operationKey }),
|
|
329
|
+
).toBeNull();
|
|
330
|
+
const claim = await t.mutation(m("claim"), args);
|
|
331
|
+
expect(
|
|
332
|
+
await t.query(get, { ...scope, operationKey: args.operationKey }),
|
|
333
|
+
).toEqual(claim);
|
|
334
|
+
expect(
|
|
335
|
+
(
|
|
336
|
+
await t.query(pending, {
|
|
337
|
+
...scope,
|
|
338
|
+
paginationOpts: { numItems: 1, cursor: null },
|
|
339
|
+
})
|
|
340
|
+
).page,
|
|
341
|
+
).toEqual([claim]);
|
|
342
|
+
await expect(
|
|
343
|
+
t.query(get, {
|
|
344
|
+
...scope,
|
|
345
|
+
ownerKey: "other",
|
|
346
|
+
operationKey: args.operationKey,
|
|
347
|
+
}),
|
|
348
|
+
).rejects.toThrow(/owner/);
|
|
349
|
+
await expect(
|
|
350
|
+
t.query(pending, {
|
|
351
|
+
...scope,
|
|
352
|
+
paginationOpts: { numItems: 101, cursor: null },
|
|
353
|
+
}),
|
|
354
|
+
).rejects.toThrow(/page/);
|
|
355
|
+
});
|
|
356
|
+
it("rejects newly arrived sources before attempting an already reserved payout", async () => {
|
|
357
|
+
const { t, args } = await setup();
|
|
358
|
+
await t.mutation(m("claim"), args);
|
|
359
|
+
await t.mutation(
|
|
360
|
+
makeFunctionReference<"mutation">("connect/mutations:upsertTransfer"),
|
|
361
|
+
transfer("tr_new", 100),
|
|
362
|
+
);
|
|
363
|
+
await expect(
|
|
364
|
+
t.mutation(m("beginAttempt"), {
|
|
365
|
+
...scope,
|
|
366
|
+
operationKey: args.operationKey,
|
|
367
|
+
}),
|
|
368
|
+
).rejects.toThrow(/snapshot/);
|
|
369
|
+
expect(
|
|
370
|
+
(
|
|
371
|
+
await t.mutation(m("cancelUnattempted"), {
|
|
372
|
+
...scope,
|
|
373
|
+
operationKey: args.operationKey,
|
|
374
|
+
})
|
|
375
|
+
).status,
|
|
376
|
+
).toBe("canceled");
|
|
377
|
+
});
|
|
378
|
+
it("refuses incomplete large account histories without reserving partial totals", async () => {
|
|
379
|
+
const { t, args } = await setup();
|
|
380
|
+
await t.run(async (ctx) => {
|
|
381
|
+
for (let i = 0; i < 100; i++)
|
|
382
|
+
await ctx.db.insert("transfers", {
|
|
383
|
+
...transfer(`tr_more${i}`, 100),
|
|
384
|
+
role: "store" as const,
|
|
385
|
+
status: "paid" as const,
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
await expect(t.mutation(m("claim"), args)).rejects.toThrow(/incomplete/);
|
|
389
|
+
expect(
|
|
390
|
+
await t.run((ctx) => ctx.db.query("payoutSourceAllocations").take(10)),
|
|
391
|
+
).toEqual([]);
|
|
392
|
+
});
|
|
393
|
+
it("cannot reserve against a policy change or an expired hold", async () => {
|
|
394
|
+
const { t, args } = await setup();
|
|
395
|
+
await t.mutation(m("claim"), args);
|
|
396
|
+
await t.mutation(pm("prepare"), {
|
|
397
|
+
...scope,
|
|
398
|
+
operationKey: "off",
|
|
399
|
+
expectedRevision: 0,
|
|
400
|
+
mode: "stripe_managed",
|
|
401
|
+
thresholds: [],
|
|
402
|
+
country: "US",
|
|
403
|
+
observedSchedule: { interval: "manual" },
|
|
404
|
+
});
|
|
405
|
+
await expect(
|
|
406
|
+
t.mutation(m("beginAttempt"), {
|
|
407
|
+
...scope,
|
|
408
|
+
operationKey: args.operationKey,
|
|
409
|
+
}),
|
|
410
|
+
).rejects.toThrow(/policy/);
|
|
411
|
+
await expect(
|
|
412
|
+
t.mutation(m("claim"), {
|
|
413
|
+
...args,
|
|
414
|
+
operationKey: "new",
|
|
415
|
+
expectedRevision: 1,
|
|
416
|
+
}),
|
|
417
|
+
).rejects.toThrow(/policy/);
|
|
418
|
+
});
|
|
419
|
+
it("rejects a recipient receipt counted against two source transfers", async () => {
|
|
420
|
+
const { t, args } = await setup();
|
|
421
|
+
args.observations[1].transactionIds = args.observations[0].transactionIds;
|
|
422
|
+
await expect(t.mutation(m("claim"), args)).rejects.toThrow(/receipt/);
|
|
423
|
+
});
|
|
424
|
+
it("cannot reuse one payout return receipt to credit a different payout", async () => {
|
|
425
|
+
const { t, args } = await setup();
|
|
426
|
+
const first = { ...scope, operationKey: args.operationKey };
|
|
427
|
+
await t.mutation(m("claim"), args);
|
|
428
|
+
await t.mutation(m("beginAttempt"), first);
|
|
429
|
+
const receipt = {
|
|
430
|
+
...first,
|
|
431
|
+
stripePayoutId: "po_one",
|
|
432
|
+
amount: 1000,
|
|
433
|
+
currency: "usd",
|
|
434
|
+
providerStatus: "failed",
|
|
435
|
+
returnTransactionId: "txn_return",
|
|
436
|
+
};
|
|
437
|
+
await t.mutation(m("recordOutcome"), receipt);
|
|
438
|
+
const second = { ...scope, operationKey: "release-2" };
|
|
439
|
+
await t.mutation(m("claim"), { ...args, operationKey: second.operationKey });
|
|
440
|
+
await t.mutation(m("beginAttempt"), second);
|
|
441
|
+
await expect(
|
|
442
|
+
t.mutation(m("recordOutcome"), {
|
|
443
|
+
...receipt,
|
|
444
|
+
...second,
|
|
445
|
+
stripePayoutId: "po_two",
|
|
446
|
+
}),
|
|
447
|
+
).rejects.toThrow(/receipt/);
|
|
448
|
+
});
|
|
449
|
+
it("carries a refund after a paid payout into the next combined release", async () => {
|
|
450
|
+
const { t, args } = await setup();
|
|
451
|
+
const identity = { ...scope, operationKey: args.operationKey };
|
|
452
|
+
await t.mutation(m("claim"), args);
|
|
453
|
+
await t.mutation(m("beginAttempt"), identity);
|
|
454
|
+
await t.mutation(m("recordOutcome"), {
|
|
455
|
+
...identity,
|
|
456
|
+
stripePayoutId: "po_one",
|
|
457
|
+
amount: 1000,
|
|
458
|
+
currency: "usd",
|
|
459
|
+
providerStatus: "paid",
|
|
460
|
+
});
|
|
461
|
+
await t.mutation(
|
|
462
|
+
makeFunctionReference<"mutation">(
|
|
463
|
+
"connect/mutations:recordTransferReversal",
|
|
464
|
+
),
|
|
465
|
+
{ stripeTransferId: "tr_creator", reversedAmount: 600 },
|
|
466
|
+
);
|
|
467
|
+
await t.mutation(
|
|
468
|
+
makeFunctionReference<"mutation">("connect/mutations:upsertTransfer"),
|
|
469
|
+
transfer("tr_new", 1600, "affiliate"),
|
|
470
|
+
);
|
|
471
|
+
const rows = await t.run((ctx) => ctx.db.query("transfers").take(10));
|
|
472
|
+
const next = await t.mutation(m("claim"), {
|
|
473
|
+
...args,
|
|
474
|
+
operationKey: "release-2",
|
|
475
|
+
observations: rows.map((row) => ({
|
|
476
|
+
stripeTransferId: row.stripeTransferId,
|
|
477
|
+
matureNet: row.amount - (row.reversedAmount ?? 0),
|
|
478
|
+
pendingCredits: 0,
|
|
479
|
+
currency: row.currency,
|
|
480
|
+
ledgerFingerprint: transferFingerprint(row),
|
|
481
|
+
observedAt: Date.now(),
|
|
482
|
+
transactionIds: [
|
|
483
|
+
`txn_${row.stripeTransferId}`,
|
|
484
|
+
...(row.reversedAmount ? ["txn_refund"] : []),
|
|
485
|
+
],
|
|
486
|
+
})),
|
|
487
|
+
});
|
|
488
|
+
expect(next).toMatchObject({
|
|
489
|
+
status: "reserved",
|
|
490
|
+
amount: 1000,
|
|
491
|
+
allocations: [
|
|
492
|
+
{ stripeTransferId: "tr_new", amount: 1000, role: "affiliate" },
|
|
493
|
+
],
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
it("does not forget a previously paid source when its transfer row disappears", async () => {
|
|
497
|
+
const { t, args } = await setup();
|
|
498
|
+
const identity = { ...scope, operationKey: args.operationKey };
|
|
499
|
+
await t.mutation(m("claim"), args);
|
|
500
|
+
await t.mutation(m("beginAttempt"), identity);
|
|
501
|
+
await t.mutation(m("recordOutcome"), {
|
|
502
|
+
...identity,
|
|
503
|
+
stripePayoutId: "po_one",
|
|
504
|
+
amount: 1000,
|
|
505
|
+
currency: "usd",
|
|
506
|
+
providerStatus: "paid",
|
|
507
|
+
});
|
|
508
|
+
await t.run(async (ctx) => {
|
|
509
|
+
const row = await ctx.db
|
|
510
|
+
.query("transfers")
|
|
511
|
+
.withIndex("by_stripe_transfer_id", (q) =>
|
|
512
|
+
q.eq("stripeTransferId", "tr_creator"),
|
|
513
|
+
)
|
|
514
|
+
.unique();
|
|
515
|
+
await ctx.db.delete("transfers", row!._id);
|
|
516
|
+
});
|
|
517
|
+
const observations = args.observations.filter(
|
|
518
|
+
(row) => row.stripeTransferId !== "tr_creator",
|
|
519
|
+
);
|
|
520
|
+
await expect(
|
|
521
|
+
t.mutation(m("claim"), { ...args, operationKey: "next", observations }),
|
|
522
|
+
).rejects.toThrow(/snapshot|allocation/);
|
|
523
|
+
});
|
|
524
|
+
it("releases a recent proven provider rejection but keeps unknown or old attempts held", async () => {
|
|
525
|
+
const { t, args } = await setup();
|
|
526
|
+
const identity = { ...scope, operationKey: args.operationKey };
|
|
527
|
+
await t.mutation(m("claim"), args);
|
|
528
|
+
await t.mutation(m("beginAttempt"), identity);
|
|
529
|
+
const proof = {
|
|
530
|
+
...identity,
|
|
531
|
+
requestId: "req_rejected",
|
|
532
|
+
code: "balance_insufficient",
|
|
533
|
+
};
|
|
534
|
+
expect((await t.mutation(m("recordRejection"), proof)).status).toBe(
|
|
535
|
+
"rejected",
|
|
536
|
+
);
|
|
537
|
+
expect((await t.mutation(m("recordRejection"), proof)).status).toBe(
|
|
538
|
+
"rejected",
|
|
539
|
+
);
|
|
540
|
+
const next = { ...scope, operationKey: "next" };
|
|
541
|
+
expect(
|
|
542
|
+
(await t.mutation(m("claim"), { ...args, operationKey: next.operationKey }))
|
|
543
|
+
.amount,
|
|
544
|
+
).toBe(1000);
|
|
545
|
+
await t.mutation(m("beginAttempt"), next);
|
|
546
|
+
await expect(
|
|
547
|
+
t.mutation(m("recordRejection"), { ...proof, ...next }),
|
|
548
|
+
).rejects.toThrow(/receipt/);
|
|
549
|
+
await t.run(async (ctx) => {
|
|
550
|
+
const row = await ctx.db
|
|
551
|
+
.query("payoutReleaseClaims")
|
|
552
|
+
.withIndex("by_operation", (q) => q.eq("operationKey", next.operationKey))
|
|
553
|
+
.unique();
|
|
554
|
+
await ctx.db.patch("payoutReleaseClaims", row!._id, {
|
|
555
|
+
attemptedAt: Date.now() - 24 * 60 * 60 * 1000,
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
await expect(
|
|
559
|
+
t.mutation(m("recordRejection"), {
|
|
560
|
+
...proof,
|
|
561
|
+
...next,
|
|
562
|
+
requestId: "req_late",
|
|
563
|
+
}),
|
|
564
|
+
).rejects.toThrow(/uncertain|expired/);
|
|
565
|
+
expect(
|
|
566
|
+
await t.mutation(m("claim"), { ...args, operationKey: "third" }),
|
|
567
|
+
).toMatchObject({ status: "held", reason: "pending_claim" });
|
|
568
|
+
});
|
|
569
|
+
it("does not treat an accepted payout as a rejected create request", async () => {
|
|
570
|
+
const { t, args } = await setup();
|
|
571
|
+
const identity = { ...scope, operationKey: args.operationKey };
|
|
572
|
+
await t.mutation(m("claim"), args);
|
|
573
|
+
await t.mutation(m("beginAttempt"), identity);
|
|
574
|
+
await t.mutation(m("recordOutcome"), {
|
|
575
|
+
...identity,
|
|
576
|
+
stripePayoutId: "po_one",
|
|
577
|
+
amount: 1000,
|
|
578
|
+
currency: "usd",
|
|
579
|
+
providerStatus: "pending",
|
|
580
|
+
});
|
|
581
|
+
await expect(
|
|
582
|
+
t.mutation(m("recordRejection"), {
|
|
583
|
+
...identity,
|
|
584
|
+
requestId: "req_rejected",
|
|
585
|
+
code: "balance_insufficient",
|
|
586
|
+
}),
|
|
587
|
+
).rejects.toThrow(/uncertain|accepted/);
|
|
588
|
+
});
|