@fantastic.dev/better-stripe 0.21.0 → 0.22.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 (50) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +6 -5
  3. package/dist/client/connect/payoutExecution.d.ts +34 -0
  4. package/dist/client/connect/payoutExecution.d.ts.map +1 -0
  5. package/dist/client/connect/payoutExecution.js +189 -0
  6. package/dist/client/connect/payoutExecution.js.map +1 -0
  7. package/dist/client/connect/payoutHistory.d.ts +22 -0
  8. package/dist/client/connect/payoutHistory.d.ts.map +1 -0
  9. package/dist/client/connect/payoutHistory.js +122 -0
  10. package/dist/client/connect/payoutHistory.js.map +1 -0
  11. package/dist/client/connect/payoutReconciliation.d.ts.map +1 -1
  12. package/dist/client/connect/payoutReconciliation.js +2 -0
  13. package/dist/client/connect/payoutReconciliation.js.map +1 -1
  14. package/dist/client/payoutPolicy.d.ts +3 -0
  15. package/dist/client/payoutPolicy.d.ts.map +1 -1
  16. package/dist/client/payoutPolicy.js +4 -0
  17. package/dist/client/payoutPolicy.js.map +1 -1
  18. package/dist/component/_generated/component.d.ts +73 -0
  19. package/dist/component/_generated/component.d.ts.map +1 -1
  20. package/dist/component/earnings/triggers.d.ts +4 -2
  21. package/dist/component/earnings/triggers.d.ts.map +1 -1
  22. package/dist/component/payoutRelease/helpers.d.ts +1 -0
  23. package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
  24. package/dist/component/payoutRelease/mutations.d.ts +6 -0
  25. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  26. package/dist/component/payoutRelease/mutations.js +7 -1
  27. package/dist/component/payoutRelease/mutations.js.map +1 -1
  28. package/dist/component/payoutRelease/queries.d.ts +71 -0
  29. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  30. package/dist/component/payoutRelease/queries.js +29 -0
  31. package/dist/component/payoutRelease/queries.js.map +1 -1
  32. package/dist/component/payoutRelease/schema.d.ts +7 -2
  33. package/dist/component/payoutRelease/schema.d.ts.map +1 -1
  34. package/dist/component/payoutRelease/schema.js +1 -0
  35. package/dist/component/payoutRelease/schema.js.map +1 -1
  36. package/dist/component/schema.d.ts +3 -1
  37. package/docs/settlement.md +64 -4
  38. package/package.json +1 -1
  39. package/src/client/connect/payoutExecution.test.ts +504 -0
  40. package/src/client/connect/payoutExecution.ts +265 -0
  41. package/src/client/connect/payoutHistory.test.ts +145 -0
  42. package/src/client/connect/payoutHistory.ts +149 -0
  43. package/src/client/connect/payoutReconciliation.test.ts +13 -0
  44. package/src/client/connect/payoutReconciliation.ts +4 -0
  45. package/src/client/payoutPolicy.ts +14 -0
  46. package/src/component/_generated/component.ts +80 -0
  47. package/src/component/payout-release-snapshot.test.ts +57 -0
  48. package/src/component/payoutRelease/mutations.ts +9 -1
  49. package/src/component/payoutRelease/queries.ts +35 -0
  50. package/src/component/payoutRelease/schema.ts +1 -0
@@ -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
+ });