@fantastic.dev/better-stripe 0.18.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +8 -3
  3. package/dist/client/connect/transferReceipt.d.ts +48 -0
  4. package/dist/client/connect/transferReceipt.d.ts.map +1 -0
  5. package/dist/client/connect/transferReceipt.js +283 -0
  6. package/dist/client/connect/transferReceipt.js.map +1 -0
  7. package/dist/client/payoutPolicy.d.ts +3 -0
  8. package/dist/client/payoutPolicy.d.ts.map +1 -1
  9. package/dist/client/payoutPolicy.js +4 -0
  10. package/dist/client/payoutPolicy.js.map +1 -1
  11. package/dist/component/_generated/component.d.ts +297 -0
  12. package/dist/component/_generated/component.d.ts.map +1 -1
  13. package/dist/component/earnings/triggers.d.ts +134 -0
  14. package/dist/component/earnings/triggers.d.ts.map +1 -1
  15. package/dist/component/payoutRelease/allocation.d.ts +48 -0
  16. package/dist/component/payoutRelease/allocation.d.ts.map +1 -0
  17. package/dist/component/payoutRelease/allocation.js +98 -0
  18. package/dist/component/payoutRelease/allocation.js.map +1 -0
  19. package/dist/component/payoutRelease/helpers.d.ts +106 -0
  20. package/dist/component/payoutRelease/helpers.d.ts.map +1 -0
  21. package/dist/component/payoutRelease/helpers.js +119 -0
  22. package/dist/component/payoutRelease/helpers.js.map +1 -0
  23. package/dist/component/payoutRelease/mutations.d.ts +218 -0
  24. package/dist/component/payoutRelease/mutations.d.ts.map +1 -0
  25. package/dist/component/payoutRelease/mutations.js +254 -0
  26. package/dist/component/payoutRelease/mutations.js.map +1 -0
  27. package/dist/component/payoutRelease/queries.d.ts +84 -0
  28. package/dist/component/payoutRelease/queries.d.ts.map +1 -0
  29. package/dist/component/payoutRelease/queries.js +51 -0
  30. package/dist/component/payoutRelease/queries.js.map +1 -0
  31. package/dist/component/payoutRelease/schema.d.ts +289 -0
  32. package/dist/component/payoutRelease/schema.d.ts.map +1 -0
  33. package/dist/component/payoutRelease/schema.js +56 -0
  34. package/dist/component/payoutRelease/schema.js.map +1 -0
  35. package/dist/component/schema.d.ts +110 -0
  36. package/dist/component/schema.js +3 -0
  37. package/dist/component/schema.js.map +1 -1
  38. package/docs/settlement.md +81 -0
  39. package/package.json +1 -1
  40. package/src/client/connect/payoutAllocation.test.ts +292 -0
  41. package/src/client/connect/transferReceipt.pagination.test.ts +209 -0
  42. package/src/client/connect/transferReceipt.test.ts +520 -0
  43. package/src/client/connect/transferReceipt.ts +409 -0
  44. package/src/client/payoutPolicy.ts +13 -0
  45. package/src/component/_generated/api.ts +8 -0
  46. package/src/component/_generated/component.ts +372 -0
  47. package/src/component/payout-release.test.ts +588 -0
  48. package/src/component/payoutRelease/allocation.ts +147 -0
  49. package/src/component/payoutRelease/helpers.ts +168 -0
  50. package/src/component/payoutRelease/mutations.ts +309 -0
  51. package/src/component/payoutRelease/queries.ts +56 -0
  52. package/src/component/payoutRelease/schema.ts +69 -0
  53. package/src/component/schema.ts +6 -0
@@ -0,0 +1,520 @@
1
+ import type Stripe from "stripe";
2
+ import { expect, it, vi } from "vitest";
3
+ import type { Component, RunCtx } from "../helpers.js";
4
+ const symbol = Symbol.for("toReferencePath");
5
+ const component = {
6
+ connect: {
7
+ queries: {
8
+ getTransferByStripeId: {
9
+ [symbol]: "betterStripe/connect/queries/getTransferByStripeId",
10
+ },
11
+ },
12
+ },
13
+ } as unknown as Component;
14
+ const ledger = {
15
+ stripeTransferId: "tr_1",
16
+ sourceChargeId: "ch_sale",
17
+ destinationAccountId: "acct_owner",
18
+ amount: 1000,
19
+ currency: "usd",
20
+ role: "affiliate",
21
+ status: "paid",
22
+ reversedAmount: 0,
23
+ };
24
+ const transfer = {
25
+ id: "tr_1",
26
+ destination: "acct_owner",
27
+ destination_payment: "py_1",
28
+ source_transaction: "ch_sale",
29
+ amount: 1000,
30
+ amount_reversed: 0,
31
+ currency: "usd",
32
+ reinstatement: false,
33
+ };
34
+ const payment = {
35
+ id: "py_1",
36
+ source_transfer: "tr_1",
37
+ amount: 1000,
38
+ currency: "usd",
39
+ paid: true,
40
+ status: "succeeded",
41
+ balance_transaction: "txn_credit",
42
+ };
43
+ const transaction = (id = "txn_credit", source = "py_1", amount = 1000) => ({
44
+ id,
45
+ source,
46
+ amount,
47
+ fee: 0,
48
+ net: amount,
49
+ currency: "usd",
50
+ created: 100,
51
+ available_on: 200,
52
+ status: "available",
53
+ type: amount > 0 ? "payment" : "payment_refund",
54
+ exchange_rate: null,
55
+ });
56
+ async function feature() {
57
+ const module = (await import("./transferReceipt.js").catch(
58
+ () => ({}),
59
+ )) as Partial<typeof import("./transferReceipt.js")>;
60
+ expect(
61
+ module.getTransferFundingReceipt,
62
+ "recipient funding reader is missing",
63
+ ).toBeTypeOf("function");
64
+ return module.getTransferFundingReceipt!;
65
+ }
66
+ function setup() {
67
+ const ctx = {
68
+ runQuery: vi.fn().mockResolvedValue(ledger),
69
+ } as unknown as RunCtx;
70
+ const provider = {
71
+ transfers: {
72
+ retrieve: vi.fn().mockResolvedValue(transfer),
73
+ listReversals: vi.fn().mockResolvedValue({ data: [], has_more: false }),
74
+ },
75
+ charges: { retrieve: vi.fn().mockResolvedValue(payment) },
76
+ refunds: { retrieve: vi.fn() },
77
+ balanceTransactions: {
78
+ list: vi
79
+ .fn()
80
+ .mockResolvedValue({ data: [transaction()], has_more: false }),
81
+ },
82
+ };
83
+ const run = async (extra: object = {}) =>
84
+ (await feature())(provider as unknown as Stripe, component, ctx, {
85
+ accountId: "acct_owner",
86
+ stripeTransferId: "tr_1",
87
+ ...extra,
88
+ });
89
+ return { ctx, provider, run };
90
+ }
91
+ it("binds actual recipient receipts and returns mature received credit, not unpaid entitlement", async () => {
92
+ const { provider, run } = setup();
93
+ const result = await run();
94
+ expect(result).toMatchObject({
95
+ status: "ready",
96
+ stripeTransferId: "tr_1",
97
+ accountId: "acct_owner",
98
+ sourceCurrency: "usd",
99
+ settlementCurrency: "usd",
100
+ sourceAmount: 1000,
101
+ totalNet: 1000,
102
+ matureNet: 1000,
103
+ pendingCredits: 0,
104
+ role: "affiliate",
105
+ });
106
+ expect(provider.charges.retrieve).toHaveBeenCalledWith(
107
+ "py_1",
108
+ {},
109
+ { stripeAccount: "acct_owner" },
110
+ );
111
+ expect(provider.balanceTransactions.list).toHaveBeenCalledWith(
112
+ { source: "py_1", limit: 100 },
113
+ { stripeAccount: "acct_owner" },
114
+ );
115
+ expect(result).not.toHaveProperty("unpaidAmount");
116
+ });
117
+ it.each([
118
+ { status: "pending", available_on: 1 },
119
+ { status: "available", available_on: Math.floor(Date.now() / 1000) + 86400 },
120
+ ])("excludes pending or future positive receipts", async (state) => {
121
+ const { provider, run } = setup();
122
+ provider.balanceTransactions.list.mockResolvedValue({
123
+ data: [{ ...transaction(), ...state }],
124
+ has_more: false,
125
+ });
126
+ expect(await run()).toMatchObject({
127
+ status: "ready",
128
+ matureNet: 0,
129
+ pendingCredits: 1000,
130
+ totalNet: 1000,
131
+ });
132
+ });
133
+ it("nets pending reversals against the matching pending original credit", async () => {
134
+ const { ctx, provider, run } = setup();
135
+ (ctx.runQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
136
+ ...ledger,
137
+ reversedAmount: 250,
138
+ });
139
+ provider.transfers.retrieve.mockResolvedValue({
140
+ ...transfer,
141
+ amount_reversed: 250,
142
+ });
143
+ provider.transfers.listReversals.mockResolvedValue({
144
+ data: [
145
+ {
146
+ id: "trr_1",
147
+ transfer: "tr_1",
148
+ amount: 250,
149
+ currency: "usd",
150
+ destination_payment_refund: "pyr_1",
151
+ },
152
+ ],
153
+ has_more: false,
154
+ } as never);
155
+ provider.refunds.retrieve.mockResolvedValue({
156
+ id: "pyr_1",
157
+ charge: "py_1",
158
+ amount: 250,
159
+ currency: "usd",
160
+ status: "succeeded",
161
+ balance_transaction: "txn_debit",
162
+ });
163
+ provider.balanceTransactions.list
164
+ .mockResolvedValueOnce({
165
+ data: [{ ...transaction(), status: "pending" }],
166
+ has_more: false,
167
+ })
168
+ .mockResolvedValueOnce({
169
+ data: [{ ...transaction("txn_debit", "pyr_1", -250), status: "pending" }],
170
+ has_more: false,
171
+ });
172
+ expect(await run()).toMatchObject({
173
+ status: "ready",
174
+ totalNet: 750,
175
+ matureNet: 0,
176
+ pendingCredits: 750,
177
+ });
178
+ });
179
+ it("does not guess settlement currency amounts from exchange rates", async () => {
180
+ const { provider, run } = setup();
181
+ provider.balanceTransactions.list.mockResolvedValue({
182
+ data: [
183
+ {
184
+ ...transaction(),
185
+ currency: "eur",
186
+ amount: 920,
187
+ fee: 20,
188
+ net: 900,
189
+ exchange_rate: 0.92,
190
+ },
191
+ ],
192
+ has_more: false,
193
+ } as never);
194
+ expect(await run()).toMatchObject({
195
+ status: "ready",
196
+ sourceCurrency: "usd",
197
+ sourceAmount: 1000,
198
+ settlementCurrency: "eur",
199
+ totalNet: 900,
200
+ matureNet: 900,
201
+ });
202
+ });
203
+ it("fails closed for reserved reversal claims and missing or incomplete provider history", async () => {
204
+ const { ctx, provider, run } = setup();
205
+ (ctx.runQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
206
+ ...ledger,
207
+ reversalClaimedAmount: 500,
208
+ });
209
+ expect(await run()).toMatchObject({
210
+ status: "not_ready",
211
+ reason: "reversal_pending",
212
+ });
213
+ (ctx.runQuery as ReturnType<typeof vi.fn>).mockResolvedValue(ledger);
214
+ provider.balanceTransactions.list.mockResolvedValue({
215
+ data: [],
216
+ has_more: false,
217
+ });
218
+ expect(await run()).toMatchObject({
219
+ status: "not_ready",
220
+ reason: "receipt_unavailable",
221
+ });
222
+ provider.balanceTransactions.list.mockResolvedValue({
223
+ data: [transaction()],
224
+ has_more: true,
225
+ });
226
+ const result = await run({ maxRequests: 4 });
227
+ expect(result).toMatchObject({
228
+ status: "not_ready",
229
+ reason: "history_incomplete",
230
+ });
231
+ expect(result).not.toHaveProperty("totalNet");
232
+ });
233
+ it("deduplicates repeated receipts but rejects conflicting receipt identities", async () => {
234
+ const { provider, run } = setup();
235
+ provider.balanceTransactions.list.mockResolvedValue({
236
+ data: [transaction(), transaction()],
237
+ has_more: false,
238
+ });
239
+ expect(await run()).toMatchObject({ status: "ready", totalNet: 1000 });
240
+ provider.balanceTransactions.list.mockResolvedValue({
241
+ data: [transaction(), { ...transaction(), net: 999, fee: 1 }],
242
+ has_more: false,
243
+ });
244
+ await expect(run()).rejects.toThrow(/receipt/);
245
+ });
246
+ it.each([
247
+ { destination: "acct_other" },
248
+ { amount: 999 },
249
+ { currency: "eur" },
250
+ { source_transaction: "ch_other" },
251
+ { destination_payment: null },
252
+ ])("rejects mismatched transfer provenance", async (change) => {
253
+ const { provider, run } = setup();
254
+ provider.transfers.retrieve.mockResolvedValue({
255
+ ...transfer,
256
+ ...change,
257
+ } as never);
258
+ await expect(run()).rejects.toThrow(/identity|provenance/);
259
+ });
260
+ it("rejects a foreign recipient payment or refund receipt", async () => {
261
+ const { provider, run } = setup();
262
+ provider.charges.retrieve.mockResolvedValue({
263
+ ...payment,
264
+ source_transfer: "tr_other",
265
+ });
266
+ await expect(run()).rejects.toThrow(/identity/);
267
+ provider.charges.retrieve.mockResolvedValue(payment);
268
+ provider.balanceTransactions.list.mockResolvedValue({
269
+ data: [transaction("txn_credit", "py_foreign")],
270
+ has_more: false,
271
+ });
272
+ await expect(run()).rejects.toThrow(/identity/);
273
+ });
274
+ it("requires complete provider reversal totals and consistent snapshots", async () => {
275
+ const { provider, run } = setup();
276
+ provider.transfers.retrieve.mockResolvedValue({
277
+ ...transfer,
278
+ amount_reversed: 100,
279
+ });
280
+ expect(await run()).toMatchObject({
281
+ status: "not_ready",
282
+ reason: "provider_changed",
283
+ });
284
+ provider.transfers.retrieve
285
+ .mockReset()
286
+ .mockResolvedValueOnce(transfer)
287
+ .mockResolvedValueOnce({ ...transfer, amount_reversed: 100 });
288
+ expect(await run()).toMatchObject({
289
+ status: "not_ready",
290
+ reason: "provider_changed",
291
+ });
292
+ });
293
+ it("rejects unsafe financial arithmetic and incompatible settlement currencies", async () => {
294
+ const { provider, run } = setup();
295
+ provider.balanceTransactions.list.mockResolvedValue({
296
+ data: [
297
+ {
298
+ ...transaction(),
299
+ amount: Number.MAX_SAFE_INTEGER,
300
+ net: Number.MAX_SAFE_INTEGER,
301
+ },
302
+ transaction("txn_second"),
303
+ ],
304
+ has_more: false,
305
+ });
306
+ await expect(run()).rejects.toThrow(/integer|amount/);
307
+ provider.balanceTransactions.list.mockResolvedValue({
308
+ data: [
309
+ transaction(),
310
+ { ...transaction("txn_second"), currency: "eur", exchange_rate: 0.92 },
311
+ ],
312
+ has_more: false,
313
+ } as never);
314
+ expect(await run()).toMatchObject({
315
+ status: "not_ready",
316
+ reason: "currency_allocation_required",
317
+ });
318
+ });
319
+ it("returns a missing-ledger limitation before provider reads and rejects invalid request budgets", async () => {
320
+ const { ctx, provider, run } = setup();
321
+ (ctx.runQuery as ReturnType<typeof vi.fn>).mockResolvedValue(null);
322
+ expect(await run()).toMatchObject({
323
+ status: "not_ready",
324
+ reason: "not_in_ledger",
325
+ });
326
+ expect(provider.transfers.retrieve).not.toHaveBeenCalled();
327
+ await expect(run({ maxRequests: 0 })).rejects.toThrow(/budget/);
328
+ });
329
+ it("verifies the refund's primary debit rather than trusting an unrelated source total", async () => {
330
+ const { ctx, provider, run } = setup();
331
+ (ctx.runQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
332
+ ...ledger,
333
+ reversedAmount: 250,
334
+ });
335
+ provider.transfers.retrieve.mockResolvedValue({
336
+ ...transfer,
337
+ amount_reversed: 250,
338
+ });
339
+ provider.transfers.listReversals.mockResolvedValue({
340
+ data: [
341
+ {
342
+ id: "trr_1",
343
+ transfer: "tr_1",
344
+ amount: 250,
345
+ currency: "usd",
346
+ destination_payment_refund: "pyr_1",
347
+ },
348
+ ],
349
+ has_more: false,
350
+ } as never);
351
+ provider.refunds.retrieve.mockResolvedValue({
352
+ id: "pyr_1",
353
+ charge: "py_1",
354
+ amount: 250,
355
+ currency: "usd",
356
+ status: "succeeded",
357
+ balance_transaction: null,
358
+ });
359
+ provider.balanceTransactions.list.mockImplementation(
360
+ async (args: { source: string }) => ({
361
+ data: [
362
+ args.source === "py_1"
363
+ ? transaction()
364
+ : transaction("txn_debit", "pyr_1", -250),
365
+ ],
366
+ has_more: false,
367
+ }),
368
+ );
369
+ expect(await run()).toMatchObject({
370
+ status: "not_ready",
371
+ reason: "receipt_unavailable",
372
+ });
373
+ provider.refunds.retrieve.mockResolvedValue({
374
+ id: "pyr_1",
375
+ charge: "py_1",
376
+ amount: 250,
377
+ currency: "usd",
378
+ status: "succeeded",
379
+ balance_transaction: "txn_debit",
380
+ });
381
+ provider.balanceTransactions.list.mockImplementation(
382
+ async (args: { source: string }) => ({
383
+ data: [
384
+ args.source === "py_1"
385
+ ? transaction()
386
+ : transaction("txn_debit", "pyr_1", -200),
387
+ ],
388
+ has_more: false,
389
+ }),
390
+ );
391
+ await expect(run()).rejects.toThrow(/receipt amount/);
392
+ });
393
+ it("rejects corrupted ledger reversal counters before qualification", async () => {
394
+ const { ctx, run } = setup();
395
+ (ctx.runQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
396
+ ...ledger,
397
+ reversalClaimedAmount: -1,
398
+ });
399
+ await expect(run()).rejects.toThrow(/amount/);
400
+ });
401
+ it("nets a fully reversed pending transfer to zero without withholding other earnings", async () => {
402
+ const { ctx, provider, run } = setup();
403
+ (ctx.runQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
404
+ ...ledger,
405
+ reversedAmount: 1000,
406
+ });
407
+ provider.transfers.retrieve.mockResolvedValue({
408
+ ...transfer,
409
+ amount_reversed: 1000,
410
+ });
411
+ provider.transfers.listReversals.mockResolvedValue({
412
+ data: [
413
+ {
414
+ id: "trr_full",
415
+ transfer: "tr_1",
416
+ amount: 1000,
417
+ currency: "usd",
418
+ destination_payment_refund: "pyr_full",
419
+ },
420
+ ],
421
+ has_more: false,
422
+ } as never);
423
+ provider.refunds.retrieve.mockResolvedValue({
424
+ id: "pyr_full",
425
+ charge: "py_1",
426
+ amount: 1000,
427
+ currency: "usd",
428
+ status: "succeeded",
429
+ balance_transaction: "txn_full",
430
+ });
431
+ provider.balanceTransactions.list.mockImplementation(
432
+ async (args: { source: string }) => ({
433
+ data: [
434
+ {
435
+ ...(args.source === "py_1"
436
+ ? transaction()
437
+ : transaction("txn_full", "pyr_full", -1000)),
438
+ status: "pending",
439
+ },
440
+ ],
441
+ has_more: false,
442
+ }),
443
+ );
444
+ expect(await run()).toMatchObject({
445
+ status: "ready",
446
+ totalNet: 0,
447
+ matureNet: 0,
448
+ pendingCredits: 0,
449
+ });
450
+ });
451
+ it("reduces mature original credit for a known pending reversal", async () => {
452
+ const { ctx, provider, run } = setup();
453
+ (ctx.runQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
454
+ ...ledger,
455
+ reversedAmount: 250,
456
+ });
457
+ provider.transfers.retrieve.mockResolvedValue({
458
+ ...transfer,
459
+ amount_reversed: 250,
460
+ });
461
+ provider.transfers.listReversals.mockResolvedValue({
462
+ data: [
463
+ {
464
+ id: "trr_1",
465
+ transfer: "tr_1",
466
+ amount: 250,
467
+ currency: "usd",
468
+ destination_payment_refund: "pyr_1",
469
+ },
470
+ ],
471
+ has_more: false,
472
+ } as never);
473
+ provider.refunds.retrieve.mockResolvedValue({
474
+ id: "pyr_1",
475
+ charge: "py_1",
476
+ amount: 250,
477
+ currency: "usd",
478
+ status: "succeeded",
479
+ balance_transaction: "txn_debit",
480
+ });
481
+ provider.balanceTransactions.list.mockImplementation(
482
+ async (args: { source: string }) => ({
483
+ data: [
484
+ args.source === "py_1"
485
+ ? transaction()
486
+ : { ...transaction("txn_debit", "pyr_1", -250), status: "pending" },
487
+ ],
488
+ has_more: false,
489
+ }),
490
+ );
491
+ expect(await run()).toMatchObject({
492
+ status: "ready",
493
+ totalNet: 750,
494
+ matureNet: 750,
495
+ pendingCredits: 0,
496
+ });
497
+ });
498
+ it("rechecks reinstatement provenance if its provider metadata changes during receipt reads", async () => {
499
+ const { ctx, provider, run } = setup();
500
+ (ctx.runQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
501
+ ...ledger,
502
+ reinstatement: true,
503
+ });
504
+ provider.transfers.retrieve
505
+ .mockReset()
506
+ .mockResolvedValueOnce({
507
+ ...transfer,
508
+ source_transaction: null,
509
+ metadata: { bsReinstateOf: "ch_sale" },
510
+ })
511
+ .mockResolvedValueOnce({
512
+ ...transfer,
513
+ source_transaction: null,
514
+ metadata: { bsReinstateOf: "ch_other" },
515
+ });
516
+ expect(await run()).toMatchObject({
517
+ status: "not_ready",
518
+ reason: "provider_changed",
519
+ });
520
+ });