@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,292 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { planRecipientPayout } from "../../component/payoutRelease/allocation.js";
3
+
4
+ const source = (id: string, matureNet: number, extra = {}) => ({
5
+ stripeTransferId: id,
6
+ currency: "usd",
7
+ matureNet,
8
+ paidAmount: 0,
9
+ reservedAmount: 0,
10
+ pendingCredits: 0,
11
+ role: "store" as const,
12
+ ...extra,
13
+ });
14
+ const options = (
15
+ sources = [
16
+ source("tr_creator", 600),
17
+ source("tr_affiliate", 400, { role: "affiliate" }),
18
+ ],
19
+ ) => ({
20
+ currency: "usd",
21
+ threshold: 1000,
22
+ providerAvailable: 1000,
23
+ independentLiabilities: 0,
24
+ sources,
25
+ });
26
+
27
+ describe("combined recipient payout allocation", () => {
28
+ it("qualifies creator and affiliate funds together at the exact threshold", () => {
29
+ expect(planRecipientPayout(options())).toEqual({
30
+ status: "qualified",
31
+ currency: "usd",
32
+ amount: 1000,
33
+ matureUnpaid: 1000,
34
+ pendingCredits: 0,
35
+ providerMinimum: null,
36
+ allocations: [
37
+ { stripeTransferId: "tr_affiliate", amount: 400, role: "affiliate" },
38
+ { stripeTransferId: "tr_creator", amount: 600, role: "store" },
39
+ ],
40
+ });
41
+ });
42
+ it("keeps the entire combined balance owed below the threshold", () => {
43
+ expect(
44
+ planRecipientPayout(options([source("tr_creator", 999)])),
45
+ ).toMatchObject({
46
+ status: "held",
47
+ reason: "below_threshold",
48
+ matureUnpaid: 999,
49
+ amount: 0,
50
+ });
51
+ });
52
+ it("releases the full eligible balance above the threshold", () => {
53
+ expect(
54
+ planRecipientPayout({
55
+ ...options([source("tr_creator", 1200)]),
56
+ providerAvailable: 1500,
57
+ }),
58
+ ).toMatchObject({
59
+ status: "qualified",
60
+ amount: 1200,
61
+ });
62
+ });
63
+ it("cannot use unrelated available money to qualify a pending source", () => {
64
+ expect(
65
+ planRecipientPayout({
66
+ ...options([source("tr_pending", 0, { pendingCredits: 1500 })]),
67
+ providerAvailable: 10000,
68
+ }),
69
+ ).toMatchObject({
70
+ status: "held",
71
+ reason: "below_threshold",
72
+ matureUnpaid: 0,
73
+ pendingCredits: 1500,
74
+ });
75
+ });
76
+ it("deducts previously paid and still reserved allocations exactly once", () => {
77
+ expect(
78
+ planRecipientPayout(
79
+ options([
80
+ source("tr_creator", 2500, { paidAmount: 800, reservedAmount: 700 }),
81
+ ]),
82
+ ),
83
+ ).toMatchObject({
84
+ status: "qualified",
85
+ matureUnpaid: 1000,
86
+ amount: 1000,
87
+ allocations: [
88
+ { stripeTransferId: "tr_creator", amount: 1000, role: "store" },
89
+ ],
90
+ });
91
+ });
92
+ it("carries a refunded paid source as debt against future funds", () => {
93
+ expect(
94
+ planRecipientPayout(
95
+ options([
96
+ source("tr_refunded", 0, { paidAmount: 600 }),
97
+ source("tr_new", 1500, { role: "affiliate" }),
98
+ ]),
99
+ ),
100
+ ).toMatchObject({
101
+ status: "held",
102
+ reason: "below_threshold",
103
+ matureUnpaid: 900,
104
+ });
105
+ expect(
106
+ planRecipientPayout(
107
+ options([
108
+ source("tr_refunded", 0, { paidAmount: 600 }),
109
+ source("tr_new", 1600, { role: "affiliate" }),
110
+ ]),
111
+ ),
112
+ ).toMatchObject({
113
+ status: "qualified",
114
+ amount: 1000,
115
+ allocations: [
116
+ { stripeTransferId: "tr_new", amount: 1000, role: "affiliate" },
117
+ ],
118
+ });
119
+ });
120
+ it("retains negative fee adjustments without forfeiting unpaid funds", () => {
121
+ expect(
122
+ planRecipientPayout(
123
+ options([source("tr_fee", -30), source("tr_new", 1030)]),
124
+ ),
125
+ ).toMatchObject({
126
+ status: "qualified",
127
+ amount: 1000,
128
+ allocations: [
129
+ { stripeTransferId: "tr_new", amount: 1000, role: "store" },
130
+ ],
131
+ });
132
+ });
133
+ it("caps release by fresh provider availability after independent liabilities", () => {
134
+ const result = planRecipientPayout({
135
+ ...options([source("tr_creator", 2000)]),
136
+ providerAvailable: 1500,
137
+ independentLiabilities: 200,
138
+ });
139
+ expect(result).toMatchObject({
140
+ status: "qualified",
141
+ amount: 1300,
142
+ matureUnpaid: 2000,
143
+ });
144
+ expect(
145
+ planRecipientPayout({
146
+ ...options(),
147
+ providerAvailable: 1000,
148
+ independentLiabilities: 1,
149
+ }),
150
+ ).toMatchObject({
151
+ status: "held",
152
+ reason: "insufficient_available",
153
+ matureUnpaid: 1000,
154
+ });
155
+ });
156
+ it("does not subtract reservations again from an already debited provider balance", () => {
157
+ expect(
158
+ planRecipientPayout({
159
+ ...options([source("tr_creator", 2000, { reservedAmount: 1000 })]),
160
+ providerAvailable: 1000,
161
+ }),
162
+ ).toMatchObject({ status: "qualified", amount: 1000 });
163
+ });
164
+ it("isolates currencies, including debts and pending credits", () => {
165
+ expect(
166
+ planRecipientPayout(
167
+ options([
168
+ source("tr_usd", 1000),
169
+ source("tr_eur", -5000, { currency: "eur", pendingCredits: 300 }),
170
+ ]),
171
+ ),
172
+ ).toMatchObject({ status: "qualified", amount: 1000, pendingCredits: 0 });
173
+ });
174
+ it("uses stable source ordering regardless of observation order", () => {
175
+ const rows = [source("tr_z", 700), source("tr_a", 800)];
176
+ const args = { ...options(rows), providerAvailable: 1100 };
177
+ const first = planRecipientPayout(args);
178
+ expect(first).toEqual(
179
+ planRecipientPayout({ ...args, sources: [...rows].reverse() }),
180
+ );
181
+ expect(first).toMatchObject({
182
+ allocations: [
183
+ { stripeTransferId: "tr_a", amount: 800, role: "store" },
184
+ { stripeTransferId: "tr_z", amount: 300, role: "store" },
185
+ ],
186
+ });
187
+ });
188
+ it("preserves signed debt even if there are no current credits", () => {
189
+ expect(
190
+ planRecipientPayout(
191
+ options([source("tr_debt", -50, { paidAmount: 200 })]),
192
+ ),
193
+ ).toMatchObject({
194
+ status: "held",
195
+ matureUnpaid: -250,
196
+ amount: 0,
197
+ });
198
+ });
199
+ it("does not invent a numeric provider minimum", () => {
200
+ expect(
201
+ planRecipientPayout({
202
+ ...options([source("tr_small", 1)]),
203
+ threshold: 1,
204
+ }),
205
+ ).toMatchObject({
206
+ status: "qualified",
207
+ amount: 1,
208
+ providerMinimum: null,
209
+ });
210
+ });
211
+ it("rejects duplicate sources rather than counting them twice", () => {
212
+ awaitInvalid(() =>
213
+ planRecipientPayout(
214
+ options([source("tr_same", 600), source("tr_same", 600)]),
215
+ ),
216
+ );
217
+ });
218
+ it.each([0, -1, 0.5, Number.MAX_SAFE_INTEGER + 1, NaN, Infinity])(
219
+ "rejects an unsafe threshold %s",
220
+ (threshold) => {
221
+ awaitInvalid(() => planRecipientPayout({ ...options(), threshold }));
222
+ },
223
+ );
224
+ it.each(["USD", "us", "", "usd\n"])(
225
+ "rejects malformed currency %s",
226
+ (currency) => {
227
+ awaitInvalid(() => planRecipientPayout({ ...options(), currency }));
228
+ },
229
+ );
230
+ it.each(["paidAmount", "reservedAmount", "pendingCredits"] as const)(
231
+ "rejects negative source %s",
232
+ (field) => {
233
+ awaitInvalid(() =>
234
+ planRecipientPayout(options([source("tr_bad", 1000, { [field]: -1 })])),
235
+ );
236
+ },
237
+ );
238
+ it("checks every source, even an otherwise ignored currency", () => {
239
+ awaitInvalid(() =>
240
+ planRecipientPayout(
241
+ options([
242
+ source("tr_usd", 1000),
243
+ source("tr_bad", 1.2, { currency: "eur" }),
244
+ ]),
245
+ ),
246
+ );
247
+ });
248
+ it.each([NaN, Infinity, 0.1, Number.MAX_SAFE_INTEGER + 1])(
249
+ "rejects unsafe source money %s",
250
+ (matureNet) => {
251
+ awaitInvalid(() =>
252
+ planRecipientPayout(options([source("tr_bad", matureNet)])),
253
+ );
254
+ },
255
+ );
256
+ it("rejects arithmetic overflow rather than producing approximate money", () => {
257
+ awaitInvalid(() =>
258
+ planRecipientPayout(
259
+ options([source("tr_a", Number.MAX_SAFE_INTEGER), source("tr_b", 1)]),
260
+ ),
261
+ );
262
+ awaitInvalid(() =>
263
+ planRecipientPayout(
264
+ options([source("tr_a", -Number.MAX_SAFE_INTEGER, { paidAmount: 1 })]),
265
+ ),
266
+ );
267
+ awaitInvalid(() =>
268
+ planRecipientPayout({
269
+ ...options(),
270
+ providerAvailable: -Number.MAX_SAFE_INTEGER,
271
+ independentLiabilities: 1,
272
+ }),
273
+ );
274
+ });
275
+ it("holds on a negative provider balance without erasing entitlement", () => {
276
+ expect(
277
+ planRecipientPayout({ ...options(), providerAvailable: -100 }),
278
+ ).toMatchObject({
279
+ status: "held",
280
+ reason: "insufficient_available",
281
+ matureUnpaid: 1000,
282
+ });
283
+ });
284
+ it("rejects negative independent liabilities", () => {
285
+ awaitInvalid(() =>
286
+ planRecipientPayout({ ...options(), independentLiabilities: -1 }),
287
+ );
288
+ });
289
+ });
290
+ function awaitInvalid(action: () => unknown) {
291
+ expect(action).toThrow(/Invalid|Unsafe|Duplicate/);
292
+ }
@@ -0,0 +1,209 @@
1
+ import type Stripe from "stripe";
2
+ import { expect, it, vi } from "vitest";
3
+ import type { Component, RunCtx } from "../helpers.js";
4
+ import { getTransferFundingReceipt } from "./transferReceipt.js";
5
+ const symbol = Symbol.for("toReferencePath");
6
+ const component = {
7
+ connect: {
8
+ queries: {
9
+ getTransferByStripeId: {
10
+ [symbol]: "betterStripe/connect/queries/getTransferByStripeId",
11
+ },
12
+ },
13
+ },
14
+ } as unknown as Component;
15
+ const ledger = {
16
+ stripeTransferId: "tr_1",
17
+ destinationAccountId: "acct_owner",
18
+ amount: 1000,
19
+ currency: "usd",
20
+ status: "paid",
21
+ };
22
+ const tx = (id: string, amount: number, source = "py_1") => ({
23
+ id,
24
+ source,
25
+ amount,
26
+ net: amount,
27
+ fee: 0,
28
+ currency: "usd",
29
+ created: 1,
30
+ available_on: 2,
31
+ status: "available",
32
+ type: amount > 0 ? "payment" : "adjustment",
33
+ exchange_rate: null,
34
+ });
35
+ function setup() {
36
+ const ctx = {
37
+ runQuery: vi.fn().mockResolvedValue(ledger),
38
+ } as unknown as RunCtx;
39
+ const provider = {
40
+ transfers: {
41
+ retrieve: vi.fn().mockResolvedValue({
42
+ id: "tr_1",
43
+ destination: "acct_owner",
44
+ destination_payment: "py_1",
45
+ source_transaction: null,
46
+ amount: 1000,
47
+ amount_reversed: 0,
48
+ currency: "usd",
49
+ }),
50
+ listReversals: vi.fn().mockResolvedValue({ data: [], has_more: false }),
51
+ },
52
+ charges: {
53
+ retrieve: vi.fn().mockResolvedValue({
54
+ id: "py_1",
55
+ source_transfer: "tr_1",
56
+ amount: 1000,
57
+ currency: "usd",
58
+ paid: true,
59
+ status: "succeeded",
60
+ balance_transaction: "txn_credit",
61
+ }),
62
+ },
63
+ refunds: { retrieve: vi.fn() },
64
+ balanceTransactions: {
65
+ list: vi
66
+ .fn()
67
+ .mockResolvedValue({ data: [tx("txn_credit", 1000)], has_more: false }),
68
+ },
69
+ };
70
+ return {
71
+ ctx,
72
+ provider,
73
+ run: (extra: object = {}) =>
74
+ getTransferFundingReceipt(provider as unknown as Stripe, component, ctx, {
75
+ accountId: "acct_owner",
76
+ stripeTransferId: "tr_1",
77
+ ...extra,
78
+ }),
79
+ };
80
+ }
81
+ it("reads all 205 source receipts instead of truncating at the first provider page", async () => {
82
+ const { provider, run } = setup();
83
+ const rows = [
84
+ tx("txn_credit", 1000),
85
+ ...Array.from({ length: 204 }, (_, i) => tx(`txn_fee_${i}`, -1)),
86
+ ];
87
+ provider.balanceTransactions.list.mockImplementation(
88
+ async (args: { starting_after?: string }) => {
89
+ const start = args.starting_after
90
+ ? rows.findIndex((t) => t.id === args.starting_after) + 1
91
+ : 0;
92
+ return {
93
+ data: rows.slice(start, start + 100),
94
+ has_more: start + 100 < rows.length,
95
+ };
96
+ },
97
+ );
98
+ const result = await run();
99
+ expect(result).toMatchObject({
100
+ status: "ready",
101
+ totalNet: 796,
102
+ matureNet: 796,
103
+ pendingCredits: 0,
104
+ });
105
+ if (result.status === "ready") expect(result.transactions).toHaveLength(205);
106
+ expect(provider.balanceTransactions.list).toHaveBeenCalledTimes(3);
107
+ const limited = await run({ maxRequests: 6 });
108
+ expect(limited).toMatchObject({
109
+ status: "not_ready",
110
+ reason: "history_incomplete",
111
+ });
112
+ expect(limited).not.toHaveProperty("totalNet");
113
+ });
114
+ it("does not publish a source snapshot if its authoritative ledger changed during the read", async () => {
115
+ const { ctx, run } = setup();
116
+ (ctx.runQuery as ReturnType<typeof vi.fn>)
117
+ .mockReset()
118
+ .mockResolvedValueOnce(ledger)
119
+ .mockResolvedValueOnce({ ...ledger, reversedAmount: 1 });
120
+ expect(await run()).toMatchObject({
121
+ status: "not_ready",
122
+ reason: "ledger_changed",
123
+ });
124
+ });
125
+ it("propagates genuine provider failures and detects aggregate overflow", async () => {
126
+ const { provider, run } = setup();
127
+ provider.charges.retrieve.mockRejectedValueOnce(
128
+ new Error("provider unavailable"),
129
+ );
130
+ await expect(run()).rejects.toThrow("provider unavailable");
131
+ provider.balanceTransactions.list.mockResolvedValue({
132
+ data: [
133
+ tx("txn_credit", 1000),
134
+ { ...tx("txn_extra", Number.MAX_SAFE_INTEGER), type: "adjustment" },
135
+ ],
136
+ has_more: false,
137
+ });
138
+ await expect(run()).rejects.toThrow("safe integers");
139
+ });
140
+ it("never uses a pending fee credit to qualify a fully returned mature transfer", async () => {
141
+ const { ctx, provider, run } = setup();
142
+ (ctx.runQuery as ReturnType<typeof vi.fn>).mockResolvedValue({
143
+ ...ledger,
144
+ reversedAmount: 1000,
145
+ });
146
+ provider.transfers.retrieve.mockResolvedValue({
147
+ id: "tr_1",
148
+ destination: "acct_owner",
149
+ destination_payment: "py_1",
150
+ source_transaction: null,
151
+ amount: 1000,
152
+ amount_reversed: 1000,
153
+ currency: "usd",
154
+ });
155
+ provider.transfers.listReversals.mockResolvedValue({
156
+ data: [
157
+ {
158
+ id: "trr_1",
159
+ transfer: "tr_1",
160
+ amount: 1000,
161
+ currency: "usd",
162
+ destination_payment_refund: "pyr_1",
163
+ },
164
+ ],
165
+ has_more: false,
166
+ } as never);
167
+ provider.refunds.retrieve.mockResolvedValue({
168
+ id: "pyr_1",
169
+ charge: "py_1",
170
+ amount: 1000,
171
+ currency: "usd",
172
+ status: "succeeded",
173
+ balance_transaction: "txn_debit",
174
+ });
175
+ provider.balanceTransactions.list.mockImplementation(
176
+ async (args: { source: string }) => ({
177
+ data:
178
+ args.source === "py_1"
179
+ ? [
180
+ tx("txn_credit", 1000),
181
+ { ...tx("txn_fee_credit", 100), status: "pending" },
182
+ ]
183
+ : [{ ...tx("txn_debit", -1000, "pyr_1"), status: "pending" }],
184
+ has_more: false,
185
+ }),
186
+ );
187
+ expect(await run()).toMatchObject({
188
+ status: "ready",
189
+ totalNet: 100,
190
+ matureNet: 0,
191
+ pendingCredits: 100,
192
+ });
193
+ });
194
+ it("preserves an already available debit while its original credit is pending", async () => {
195
+ const { provider, run } = setup();
196
+ provider.balanceTransactions.list.mockResolvedValue({
197
+ data: [
198
+ { ...tx("txn_credit", 1000), status: "pending" },
199
+ tx("txn_fee", -200),
200
+ ],
201
+ has_more: false,
202
+ });
203
+ expect(await run()).toMatchObject({
204
+ status: "ready",
205
+ totalNet: 800,
206
+ matureNet: -200,
207
+ pendingCredits: 1000,
208
+ });
209
+ });