@fantastic.dev/better-stripe 0.21.0 → 0.23.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 (107) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +8 -5
  3. package/dist/client/connect/payoutActivationProof.d.ts +16 -0
  4. package/dist/client/connect/payoutActivationProof.d.ts.map +1 -0
  5. package/dist/client/connect/payoutActivationProof.js +72 -0
  6. package/dist/client/connect/payoutActivationProof.js.map +1 -0
  7. package/dist/client/connect/payoutAdoption.d.ts +35 -0
  8. package/dist/client/connect/payoutAdoption.d.ts.map +1 -0
  9. package/dist/client/connect/payoutAdoption.js +230 -0
  10. package/dist/client/connect/payoutAdoption.js.map +1 -0
  11. package/dist/client/connect/payoutAdoptionAge.d.ts +19 -0
  12. package/dist/client/connect/payoutAdoptionAge.d.ts.map +1 -0
  13. package/dist/client/connect/payoutAdoptionAge.js +56 -0
  14. package/dist/client/connect/payoutAdoptionAge.js.map +1 -0
  15. package/dist/client/connect/payoutAdoptionAllocation.d.ts +24 -0
  16. package/dist/client/connect/payoutAdoptionAllocation.d.ts.map +1 -0
  17. package/dist/client/connect/payoutAdoptionAllocation.js +78 -0
  18. package/dist/client/connect/payoutAdoptionAllocation.js.map +1 -0
  19. package/dist/client/connect/payoutExecution.d.ts +34 -0
  20. package/dist/client/connect/payoutExecution.d.ts.map +1 -0
  21. package/dist/client/connect/payoutExecution.js +189 -0
  22. package/dist/client/connect/payoutExecution.js.map +1 -0
  23. package/dist/client/connect/payoutHistory.d.ts +22 -0
  24. package/dist/client/connect/payoutHistory.d.ts.map +1 -0
  25. package/dist/client/connect/payoutHistory.js +122 -0
  26. package/dist/client/connect/payoutHistory.js.map +1 -0
  27. package/dist/client/connect/payoutReconciliation.d.ts.map +1 -1
  28. package/dist/client/connect/payoutReconciliation.js +6 -2
  29. package/dist/client/connect/payoutReconciliation.js.map +1 -1
  30. package/dist/client/payoutPolicy.d.ts +10 -1
  31. package/dist/client/payoutPolicy.d.ts.map +1 -1
  32. package/dist/client/payoutPolicy.js +82 -7
  33. package/dist/client/payoutPolicy.js.map +1 -1
  34. package/dist/component/_generated/component.d.ts +269 -0
  35. package/dist/component/_generated/component.d.ts.map +1 -1
  36. package/dist/component/earnings/triggers.d.ts +40 -4
  37. package/dist/component/earnings/triggers.d.ts.map +1 -1
  38. package/dist/component/payoutPolicy/adoption.d.ts +9 -0
  39. package/dist/component/payoutPolicy/adoption.d.ts.map +1 -0
  40. package/dist/component/payoutPolicy/adoption.js +45 -0
  41. package/dist/component/payoutPolicy/adoption.js.map +1 -0
  42. package/dist/component/payoutPolicy/mutations.d.ts +96 -0
  43. package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
  44. package/dist/component/payoutPolicy/mutations.js +38 -5
  45. package/dist/component/payoutPolicy/mutations.js.map +1 -1
  46. package/dist/component/payoutPolicy/queries.d.ts +16 -0
  47. package/dist/component/payoutPolicy/queries.d.ts.map +1 -1
  48. package/dist/component/payoutPolicy/schema.d.ts +299 -2
  49. package/dist/component/payoutPolicy/schema.d.ts.map +1 -1
  50. package/dist/component/payoutPolicy/schema.js +7 -0
  51. package/dist/component/payoutPolicy/schema.js.map +1 -1
  52. package/dist/component/payoutRelease/adoption.d.ts +269 -0
  53. package/dist/component/payoutRelease/adoption.d.ts.map +1 -0
  54. package/dist/component/payoutRelease/adoption.js +197 -0
  55. package/dist/component/payoutRelease/adoption.js.map +1 -0
  56. package/dist/component/payoutRelease/helpers.d.ts +4 -0
  57. package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
  58. package/dist/component/payoutRelease/helpers.js +20 -0
  59. package/dist/component/payoutRelease/helpers.js.map +1 -1
  60. package/dist/component/payoutRelease/mutations.d.ts +87 -0
  61. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  62. package/dist/component/payoutRelease/mutations.js +13 -1
  63. package/dist/component/payoutRelease/mutations.js.map +1 -1
  64. package/dist/component/payoutRelease/observation.d.ts +18 -0
  65. package/dist/component/payoutRelease/observation.d.ts.map +1 -0
  66. package/dist/component/payoutRelease/observation.js +11 -0
  67. package/dist/component/payoutRelease/observation.js.map +1 -0
  68. package/dist/component/payoutRelease/queries.d.ts +74 -0
  69. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  70. package/dist/component/payoutRelease/queries.js +29 -0
  71. package/dist/component/payoutRelease/queries.js.map +1 -1
  72. package/dist/component/payoutRelease/schema.d.ts +13 -19
  73. package/dist/component/payoutRelease/schema.d.ts.map +1 -1
  74. package/dist/component/payoutRelease/schema.js +4 -9
  75. package/dist/component/payoutRelease/schema.js.map +1 -1
  76. package/dist/component/schema.d.ts +75 -2
  77. package/docs/settlement.md +181 -40
  78. package/package.json +1 -1
  79. package/src/client/connect/payoutActivationProof.ts +101 -0
  80. package/src/client/connect/payoutAdoption.test.ts +421 -0
  81. package/src/client/connect/payoutAdoption.ts +330 -0
  82. package/src/client/connect/payoutAdoptionAge.test.ts +89 -0
  83. package/src/client/connect/payoutAdoptionAge.ts +74 -0
  84. package/src/client/connect/payoutAdoptionAllocation.test.ts +132 -0
  85. package/src/client/connect/payoutAdoptionAllocation.ts +106 -0
  86. package/src/client/connect/payoutExecution.test.ts +504 -0
  87. package/src/client/connect/payoutExecution.ts +265 -0
  88. package/src/client/connect/payoutHistory.test.ts +145 -0
  89. package/src/client/connect/payoutHistory.ts +149 -0
  90. package/src/client/connect/payoutReconciliation.test.ts +29 -0
  91. package/src/client/connect/payoutReconciliation.ts +10 -2
  92. package/src/client/payoutPolicy.test.ts +16 -0
  93. package/src/client/payoutPolicy.ts +121 -5
  94. package/src/component/_generated/api.ts +6 -0
  95. package/src/component/_generated/component.ts +272 -1
  96. package/src/component/payout-adoption.test.ts +272 -0
  97. package/src/component/payout-policy-adoption.test.ts +131 -0
  98. package/src/component/payout-release-snapshot.test.ts +57 -0
  99. package/src/component/payoutPolicy/adoption.ts +76 -0
  100. package/src/component/payoutPolicy/mutations.ts +46 -3
  101. package/src/component/payoutPolicy/schema.ts +9 -0
  102. package/src/component/payoutRelease/adoption.ts +262 -0
  103. package/src/component/payoutRelease/helpers.ts +21 -0
  104. package/src/component/payoutRelease/mutations.ts +19 -1
  105. package/src/component/payoutRelease/observation.ts +10 -0
  106. package/src/component/payoutRelease/queries.ts +35 -0
  107. package/src/component/payoutRelease/schema.ts +4 -9
@@ -0,0 +1,106 @@
1
+ import { addSummaryAmounts as add } from "../../component/earnings/arithmetic.js";
2
+ export type HistoricalFundingSource = {
3
+ stripeTransferId: string;
4
+ currency: string;
5
+ transactions: {
6
+ net: number;
7
+ created: number;
8
+ availableOn: number;
9
+ status: string;
10
+ }[];
11
+ };
12
+ export type HistoricalFundingPayout = {
13
+ stripePayoutId: string;
14
+ currency: string;
15
+ amount: number;
16
+ created: number;
17
+ returnedAt?: number;
18
+ allocations: { stripeTransferId: string; amount: number }[];
19
+ };
20
+ function integer(n: number) {
21
+ if (!Number.isSafeInteger(n))
22
+ throw new Error("Invalid historical funding arithmetic.");
23
+ return n;
24
+ }
25
+ function timestamp(n: number) {
26
+ if (integer(n) < 0) throw new Error("Invalid historical funding timestamp.");
27
+ }
28
+ /** Validate explicit historical cash provenance, not today's still-unpaid entitlement. */
29
+ export function validateHistoricalFunding(
30
+ sources: HistoricalFundingSource[],
31
+ payouts: HistoricalFundingPayout[],
32
+ ): void {
33
+ const byId = new Map<string, HistoricalFundingSource>();
34
+ for (const s of sources) {
35
+ if (byId.has(s.stripeTransferId))
36
+ throw new Error("Duplicate historical funding source.");
37
+ if (!/^tr_\w+$/.test(s.stripeTransferId) || !/^[a-z]{3}$/.test(s.currency))
38
+ throw new Error("Invalid historical funding source.");
39
+ for (const t of s.transactions) {
40
+ integer(t.net);
41
+ timestamp(t.created);
42
+ timestamp(t.availableOn);
43
+ if (!["available", "pending"].includes(t.status))
44
+ throw new Error("Invalid historical funding status.");
45
+ }
46
+ byId.set(s.stripeTransferId, s);
47
+ }
48
+ const ids = new Set<string>();
49
+ for (const p of payouts) {
50
+ if (ids.has(p.stripePayoutId))
51
+ throw new Error("Duplicate historical payout.");
52
+ ids.add(p.stripePayoutId);
53
+ if (
54
+ !/^po_\w+$/.test(p.stripePayoutId) ||
55
+ !/^[a-z]{3}$/.test(p.currency) ||
56
+ integer(p.amount) <= 0
57
+ )
58
+ throw new Error("Invalid historical payout principal.");
59
+ timestamp(p.created);
60
+ if (p.returnedAt !== undefined) {
61
+ timestamp(p.returnedAt);
62
+ if (p.returnedAt < p.created)
63
+ throw new Error("Historical payout return predates the payout.");
64
+ }
65
+ }
66
+ const prior: HistoricalFundingPayout[] = [];
67
+ for (const p of [...payouts].sort(
68
+ (a, b) =>
69
+ a.created - b.created || a.stripePayoutId.localeCompare(b.stripePayoutId),
70
+ )) {
71
+ const seen = new Set<string>();
72
+ let principal = 0;
73
+ for (const a of p.allocations) {
74
+ if (seen.has(a.stripeTransferId))
75
+ throw new Error("Duplicate historical source allocation.");
76
+ seen.add(a.stripeTransferId);
77
+ if (integer(a.amount) <= 0)
78
+ throw new Error("Invalid historical principal allocation.");
79
+ principal = add(principal, a.amount);
80
+ const s = byId.get(a.stripeTransferId);
81
+ if (!s)
82
+ throw new Error("Historical payout funding source is unavailable.");
83
+ if (s.currency !== p.currency)
84
+ throw new Error("Historical payout currency allocation mismatch.");
85
+ let capacity = 0;
86
+ for (const t of s.transactions)
87
+ if (
88
+ t.status === "available" &&
89
+ Math.max(t.created, t.availableOn) <= p.created
90
+ )
91
+ capacity = add(capacity, t.net);
92
+ for (const old of prior)
93
+ if (old.returnedAt === undefined || old.returnedAt > p.created)
94
+ for (const allocation of old.allocations)
95
+ if (allocation.stripeTransferId === s.stripeTransferId)
96
+ capacity = add(capacity, -allocation.amount);
97
+ if (a.amount > capacity)
98
+ throw new Error(
99
+ "Historical payout exceeds verified mature source funding.",
100
+ );
101
+ }
102
+ if (principal !== p.amount)
103
+ throw new Error("Historical allocations do not equal payout principal.");
104
+ prior.push(p);
105
+ }
106
+ }
@@ -0,0 +1,504 @@
1
+ // @vitest-environment edge-runtime
2
+ import { convexTest } from "convex-test";
3
+ import { makeFunctionReference } from "convex/server";
4
+ import { expect, it, vi } from "vitest";
5
+ import type Stripe from "stripe";
6
+ import schema from "../../component/schema.js";
7
+ import type { Component, RunCtx } from "../helpers.js";
8
+ import { releaseRecipientPayout } from "./payoutExecution.js";
9
+ const modules = import.meta.glob("../../component/**/*.*s");
10
+ const symbol = Symbol.for("toReferencePath");
11
+ const component = {
12
+ payoutPolicy: {
13
+ queries: { get: { [symbol]: "betterStripe/payoutPolicy/queries/get" } },
14
+ },
15
+ } as unknown as Component;
16
+ const scope = {
17
+ accountId: "acct_recipient",
18
+ ownerKey: "owner",
19
+ operationKey: "release-1",
20
+ currency: "usd",
21
+ independentLiabilities: 0,
22
+ };
23
+ async function setup(amounts = [600, 400]) {
24
+ const t = convexTest(schema, modules);
25
+ await t.run(async (ctx) => {
26
+ await ctx.db.insert("payoutPolicies", {
27
+ accountId: scope.accountId,
28
+ ownerKey: scope.ownerKey,
29
+ mode: "custom",
30
+ status: "ready",
31
+ revision: 0,
32
+ operationKey: "enable",
33
+ country: "US",
34
+ thresholds: [{ currency: "usd", amount: 1000 }],
35
+ holdDeadline: Date.now() + 86400000,
36
+ });
37
+ for (let i = 0; i < amounts.length; i++)
38
+ await ctx.db.insert("transfers", {
39
+ stripeTransferId: `tr_${i}`,
40
+ destinationAccountId: scope.accountId,
41
+ sourceChargeId: `ch_${i}`,
42
+ amount: amounts[i],
43
+ currency: "usd",
44
+ status: "paid",
45
+ role: i % 2 ? "affiliate" : "store",
46
+ });
47
+ });
48
+ const path = (ref: Record<symbol, string>) =>
49
+ ref[symbol].replace("betterStripe/", "").replace(/\/(\w+)$/, ":$1");
50
+ const ctx = {
51
+ runQuery: (ref: Record<symbol, string>, args: never) =>
52
+ t.query(makeFunctionReference<"query">(path(ref)), args),
53
+ runMutation: (ref: Record<symbol, string>, args: never) =>
54
+ t.mutation(makeFunctionReference<"mutation">(path(ref)), args),
55
+ } as unknown as RunCtx;
56
+ let transactions = amounts.map((amount, i) => ({
57
+ id: `txn_${i}`,
58
+ source: `py_${i}`,
59
+ amount,
60
+ fee: 0,
61
+ net: amount,
62
+ currency: "usd",
63
+ created: 100,
64
+ available_on: 200,
65
+ status: "available",
66
+ type: "payment",
67
+ exchange_rate: null,
68
+ }));
69
+ let payout: Record<string, unknown> | undefined;
70
+ const provider = {
71
+ accounts: {
72
+ listExternalAccounts: vi.fn(async () => ({
73
+ data: [
74
+ {
75
+ id: "ba_one",
76
+ object: "bank_account",
77
+ currency: "usd",
78
+ default_for_currency: true,
79
+ },
80
+ ],
81
+ has_more: false,
82
+ })),
83
+ },
84
+ transfers: {
85
+ retrieve: vi.fn(async (id: string) => {
86
+ const i = Number(id.slice(3));
87
+ return {
88
+ id,
89
+ destination: scope.accountId,
90
+ destination_payment: `py_${i}`,
91
+ source_transaction: `ch_${i}`,
92
+ amount: amounts[i],
93
+ currency: "usd",
94
+ amount_reversed: 0,
95
+ };
96
+ }),
97
+ listReversals: vi.fn(async () => ({ data: [], has_more: false })),
98
+ },
99
+ charges: {
100
+ retrieve: vi.fn(async (id: string) => {
101
+ const i = Number(id.slice(3));
102
+ return {
103
+ id,
104
+ source_transfer: `tr_${i}`,
105
+ amount: amounts[i],
106
+ currency: "usd",
107
+ paid: true,
108
+ status: "succeeded",
109
+ balance_transaction: `txn_${i}`,
110
+ };
111
+ }),
112
+ },
113
+ balanceTransactions: {
114
+ list: vi.fn(async (params: { source?: string; limit?: number }) => ({
115
+ data: params.source
116
+ ? transactions.filter((t) => t.source === params.source)
117
+ : transactions.slice(0, params.limit),
118
+ has_more: false,
119
+ })),
120
+ retrieve: vi.fn(async (id: string) =>
121
+ transactions.find((t) => t.id === id),
122
+ ),
123
+ },
124
+ balance: {
125
+ retrieve: vi.fn(async () => ({
126
+ available: [
127
+ {
128
+ currency: "usd",
129
+ amount: transactions
130
+ .filter((t) => t.status === "available")
131
+ .reduce((a, t) => a + t.net, 0),
132
+ source_types: {
133
+ card: transactions
134
+ .filter((t) => t.status === "available")
135
+ .reduce((a, t) => a + t.net, 0),
136
+ },
137
+ },
138
+ ],
139
+ pending: [],
140
+ })),
141
+ },
142
+ payouts: {
143
+ list: vi.fn(async () => ({
144
+ data: payout ? [payout] : [],
145
+ has_more: false,
146
+ })),
147
+ retrieve: vi.fn(async () => payout),
148
+ create: vi.fn(
149
+ async (params: {
150
+ amount: number;
151
+ currency: string;
152
+ metadata: object;
153
+ }) => {
154
+ payout = {
155
+ id: "po_one",
156
+ ...params,
157
+ status: "paid",
158
+ automatic: false,
159
+ method: "standard",
160
+ type: "bank_account",
161
+ source_type: "card",
162
+ destination: "ba_one",
163
+ created: Math.floor(Date.now() / 1000),
164
+ arrival_date: Math.floor(Date.now() / 1000),
165
+ balance_transaction: "txn_debit",
166
+ failure_balance_transaction: null,
167
+ reversed_by: null,
168
+ };
169
+ transactions = [
170
+ {
171
+ id: "txn_debit",
172
+ source: "po_one",
173
+ amount: -params.amount,
174
+ fee: 0,
175
+ net: -params.amount,
176
+ currency: "usd",
177
+ created: Math.floor(Date.now() / 1000),
178
+ available_on: Math.floor(Date.now() / 1000),
179
+ status: "available",
180
+ type: "payout",
181
+ exchange_rate: null,
182
+ },
183
+ ...transactions,
184
+ ];
185
+ return payout;
186
+ },
187
+ ),
188
+ },
189
+ };
190
+ const verify = vi.fn(async () => ({
191
+ status: "verified" as const,
192
+ policy: (await t.run((ctx) => ctx.db.query("payoutPolicies").first()))!,
193
+ }));
194
+ const run = (extra = {}) =>
195
+ releaseRecipientPayout(
196
+ provider as unknown as Stripe,
197
+ component,
198
+ ctx,
199
+ { ...scope, ...extra },
200
+ verify,
201
+ );
202
+ return {
203
+ t,
204
+ ctx,
205
+ provider,
206
+ verify,
207
+ run,
208
+ setTransactions: (rows: typeof transactions) => {
209
+ transactions = rows;
210
+ },
211
+ transactions: () => transactions,
212
+ };
213
+ }
214
+ it("automatically releases combined creator and affiliate funds exactly once", async () => {
215
+ const s = await setup();
216
+ expect(await s.run()).toMatchObject({
217
+ status: "updated",
218
+ claim: { status: "paid", amount: 1000 },
219
+ });
220
+ expect(s.provider.payouts.create).toHaveBeenCalledWith(
221
+ {
222
+ amount: 1000,
223
+ currency: "usd",
224
+ method: "standard",
225
+ source_type: "card",
226
+ destination: "ba_one",
227
+ metadata: { bsPayoutClaim: scope.operationKey },
228
+ },
229
+ {
230
+ stripeAccount: scope.accountId,
231
+ idempotencyKey: `bs:payout-release:${scope.operationKey}`,
232
+ },
233
+ );
234
+ await s.run();
235
+ expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
236
+ expect(
237
+ await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
238
+ ).toMatchObject([
239
+ { paidAmount: 600, reservedAmount: 0 },
240
+ { paidAmount: 400, reservedAmount: 0 },
241
+ ]);
242
+ });
243
+ it("carries below-threshold funds forward", async () => {
244
+ const s = await setup([599, 400]);
245
+ expect(await s.run()).toMatchObject({
246
+ status: "held",
247
+ reason: "below_threshold",
248
+ matureUnpaid: 999,
249
+ });
250
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
251
+ });
252
+ it("holds unknown historical payouts rather than double paying", async () => {
253
+ const s = await setup();
254
+ s.setTransactions([
255
+ ...s.transactions(),
256
+ {
257
+ ...s.transactions()[0],
258
+ id: "txn_old",
259
+ source: "po_old",
260
+ amount: -500,
261
+ net: -500,
262
+ type: "payout",
263
+ },
264
+ ]);
265
+ expect(await s.run()).toMatchObject({
266
+ status: "held",
267
+ reason: "unallocated_history",
268
+ });
269
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
270
+ });
271
+ it("an unknown provider outcome keeps the reservation and retries reconciliation only", async () => {
272
+ const s = await setup();
273
+ s.provider.payouts.create.mockRejectedValue(new Error("timeout"));
274
+ await expect(s.run()).rejects.toThrow("timeout");
275
+ expect(await s.run()).toMatchObject({
276
+ status: "held",
277
+ reason: "payout_unidentified",
278
+ });
279
+ expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
280
+ expect(
281
+ await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
282
+ ).toMatchObject([{ reservedAmount: 600 }, { reservedAmount: 400 }]);
283
+ });
284
+ it("rechecks live controls before the attempted marker and cancels only unattempted funds on drift", async () => {
285
+ const s = await setup();
286
+ s.verify
287
+ .mockResolvedValueOnce(await s.verify())
288
+ .mockResolvedValue({ status: "schedule_drift" } as never);
289
+ expect(await s.run()).toMatchObject({
290
+ status: "held",
291
+ reason: "schedule_drift",
292
+ });
293
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
294
+ expect(
295
+ await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
296
+ ).toMatchObject([{ reservedAmount: 0 }, { reservedAmount: 0 }]);
297
+ });
298
+ it("deducts independent liabilities before qualification", async () => {
299
+ const s = await setup();
300
+ expect(await s.run({ independentLiabilities: 1 })).toMatchObject({
301
+ status: "held",
302
+ reason: "below_threshold",
303
+ });
304
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
305
+ });
306
+ it("pays above-threshold combined funds without per-category minimums", async () => {
307
+ const s = await setup([600, 601]);
308
+ expect(await s.run()).toMatchObject({
309
+ status: "updated",
310
+ claim: { amount: 1201, status: "paid" },
311
+ });
312
+ });
313
+ it("does not release already-paid sources under a new schedule identity", async () => {
314
+ const s = await setup();
315
+ await s.run();
316
+ expect(await s.run({ operationKey: "release-2" })).toMatchObject({
317
+ status: "held",
318
+ reason: "below_threshold",
319
+ matureUnpaid: 0,
320
+ });
321
+ expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
322
+ });
323
+ it("does not qualify pending credits with unrelated mature money", async () => {
324
+ const s = await setup([600, 400]);
325
+ const rows = s.transactions();
326
+ rows[1].status = "pending";
327
+ s.provider.balance.retrieve.mockResolvedValue({
328
+ available: [{ currency: "usd", amount: 600, source_types: { card: 600 } }],
329
+ pending: [{ currency: "usd", amount: 400 }],
330
+ } as never);
331
+ expect(await s.run()).toMatchObject({
332
+ status: "held",
333
+ reason: "below_threshold",
334
+ matureUnpaid: 600,
335
+ pendingCredits: 400,
336
+ });
337
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
338
+ });
339
+ it.each(["balance_insufficient", "amount_too_small"])(
340
+ "records a verified %s rejection and releases reservations",
341
+ async (code) => {
342
+ const s = await setup();
343
+ s.provider.payouts.create.mockRejectedValue(
344
+ Object.assign(new Error(code), {
345
+ type: "StripeInvalidRequestError",
346
+ code,
347
+ requestId: "req_declined",
348
+ }),
349
+ );
350
+ expect(await s.run()).toMatchObject({
351
+ status: "terminal",
352
+ claim: {
353
+ status: "rejected",
354
+ rejection: { code, requestId: "req_declined" },
355
+ },
356
+ });
357
+ expect(
358
+ await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
359
+ ).toMatchObject([{ reservedAmount: 0 }, { reservedAmount: 0 }]);
360
+ },
361
+ );
362
+ it("an insufficient-funds error without a request receipt remains held", async () => {
363
+ const s = await setup();
364
+ s.provider.payouts.create.mockRejectedValue(
365
+ Object.assign(new Error("insufficient"), {
366
+ type: "StripeInvalidRequestError",
367
+ code: "balance_insufficient",
368
+ }),
369
+ );
370
+ await expect(s.run()).rejects.toThrow("insufficient");
371
+ expect(await s.run()).toMatchObject({
372
+ status: "held",
373
+ reason: "payout_unidentified",
374
+ });
375
+ expect(
376
+ await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
377
+ ).toMatchObject([{ reservedAmount: 600 }, { reservedAmount: 400 }]);
378
+ });
379
+ it("source changes during live control verification prevent provider creation", async () => {
380
+ const s = await setup();
381
+ const first = await s.verify();
382
+ s.verify.mockResolvedValueOnce(first).mockImplementationOnce(async () => {
383
+ await s.t.run(async (ctx) => {
384
+ const row = await ctx.db.query("transfers").first();
385
+ await ctx.db.patch("transfers", row!._id, { reversalClaimedAmount: 10 });
386
+ });
387
+ return first;
388
+ });
389
+ await expect(s.run()).rejects.toThrow(/snapshot changed/i);
390
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
391
+ expect(await s.run()).toMatchObject({
392
+ status: "terminal",
393
+ claim: { status: "canceled" },
394
+ });
395
+ });
396
+ it("a second worker's operation cannot spend an unresolved reservation", async () => {
397
+ const s = await setup();
398
+ s.provider.payouts.create.mockRejectedValue(new Error("timeout"));
399
+ await expect(s.run()).rejects.toThrow("timeout");
400
+ expect(await s.run({ operationKey: "other-worker" })).toMatchObject({
401
+ status: "held",
402
+ reason: "payout_unidentified",
403
+ });
404
+ expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
405
+ });
406
+ it("verified source reversals reduce combined eligibility before reservation", async () => {
407
+ const s = await setup([600, 450]);
408
+ await s.t.run(async (ctx) => {
409
+ const row = await ctx.db
410
+ .query("transfers")
411
+ .withIndex("by_stripe_transfer_id", (q) =>
412
+ q.eq("stripeTransferId", "tr_1"),
413
+ )
414
+ .unique();
415
+ await ctx.db.patch("transfers", row!._id, { reversedAmount: 100 });
416
+ });
417
+ const original = s.provider.transfers.retrieve.getMockImplementation()!;
418
+ s.provider.transfers.retrieve.mockImplementation(async (id) => ({
419
+ ...(await original(id)),
420
+ amount_reversed: id === "tr_1" ? 100 : 0,
421
+ }));
422
+ s.provider.transfers.listReversals.mockImplementation(
423
+ async (...args: unknown[]) =>
424
+ ({
425
+ data:
426
+ args[0] === "tr_1"
427
+ ? [
428
+ {
429
+ id: "trr_one",
430
+ transfer: "tr_1",
431
+ amount: 100,
432
+ currency: "usd",
433
+ destination_payment_refund: "pyr_one",
434
+ },
435
+ ]
436
+ : [],
437
+ has_more: false,
438
+ }) as never,
439
+ );
440
+ Object.assign(s.provider, {
441
+ refunds: {
442
+ retrieve: vi.fn(async () => ({
443
+ id: "pyr_one",
444
+ charge: "py_1",
445
+ amount: 100,
446
+ currency: "usd",
447
+ status: "succeeded",
448
+ balance_transaction: "txn_refund",
449
+ })),
450
+ },
451
+ });
452
+ s.setTransactions([
453
+ ...s.transactions(),
454
+ {
455
+ ...s.transactions()[0],
456
+ id: "txn_refund",
457
+ source: "pyr_one",
458
+ amount: -100,
459
+ net: -100,
460
+ type: "payment_refund",
461
+ },
462
+ ]);
463
+ expect(await s.run()).toMatchObject({
464
+ status: "held",
465
+ reason: "below_threshold",
466
+ matureUnpaid: 950,
467
+ });
468
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
469
+ });
470
+
471
+ it("requires a verified default bank destination before any reservation", async () => {
472
+ const s = await setup();
473
+ s.provider.accounts.listExternalAccounts.mockResolvedValue({
474
+ data: [],
475
+ has_more: false,
476
+ });
477
+ expect(await s.run()).toMatchObject({
478
+ status: "held",
479
+ reason: "bank_destination_unavailable",
480
+ });
481
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
482
+ expect(
483
+ await s.t.run((ctx) => ctx.db.query("payoutReleaseClaims").collect()),
484
+ ).toEqual([]);
485
+ });
486
+ it("does not select a partial or ambiguous default-bank page", async () => {
487
+ const s = await setup();
488
+ s.provider.accounts.listExternalAccounts.mockResolvedValue({
489
+ data: [
490
+ {
491
+ id: "ba_one",
492
+ object: "bank_account",
493
+ currency: "usd",
494
+ default_for_currency: true,
495
+ },
496
+ ],
497
+ has_more: true,
498
+ });
499
+ expect(await s.run({ maxPages: 1 })).toMatchObject({
500
+ status: "held",
501
+ reason: "bank_history_incomplete",
502
+ });
503
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
504
+ });