@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,421 @@
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
+ import { adoptRecipientPayouts } from "./payoutAdoption.js";
10
+ const modules = import.meta.glob("../../component/**/*.*s");
11
+ const symbol = Symbol.for("toReferencePath");
12
+ const component = {
13
+ payoutPolicy: {
14
+ queries: { get: { [symbol]: "betterStripe/payoutPolicy/queries/get" } },
15
+ },
16
+ } as unknown as Component;
17
+ const scope = {
18
+ accountId: "acct_recipient",
19
+ ownerKey: "owner",
20
+ operationKey: "release-1",
21
+ currency: "usd",
22
+ independentLiabilities: 0,
23
+ };
24
+ async function setup(amounts = [600, 400]) {
25
+ const t = convexTest(schema, modules);
26
+ await t.run(async (ctx) => {
27
+ await ctx.db.insert("payoutPolicies", {
28
+ accountId: scope.accountId,
29
+ ownerKey: scope.ownerKey,
30
+ mode: "stripe_managed",
31
+ status: "ready",
32
+ revision: 0,
33
+ operationKey: "enable",
34
+ country: "US",
35
+ thresholds: [],
36
+ holdDeadline: Date.now() + 86400000,
37
+ });
38
+ for (let i = 0; i < amounts.length; i++)
39
+ await ctx.db.insert("transfers", {
40
+ stripeTransferId: `tr_${i}`,
41
+ destinationAccountId: scope.accountId,
42
+ sourceChargeId: `ch_${i}`,
43
+ amount: amounts[i],
44
+ currency: "usd",
45
+ status: "paid",
46
+ role: i % 2 ? "affiliate" : "store",
47
+ });
48
+ });
49
+ const path = (ref: Record<symbol, string>) =>
50
+ ref[symbol].replace("betterStripe/", "").replace(/\/(\w+)$/, ":$1");
51
+ const ctx = {
52
+ runQuery: (ref: Record<symbol, string>, args: never) =>
53
+ t.query(makeFunctionReference<"query">(path(ref)), args),
54
+ runMutation: (ref: Record<symbol, string>, args: never) =>
55
+ t.mutation(makeFunctionReference<"mutation">(path(ref)), args),
56
+ } as unknown as RunCtx;
57
+ let transactions = amounts.map((amount, i) => ({
58
+ id: `txn_${i}`,
59
+ source: `py_${i}`,
60
+ amount,
61
+ fee: 0,
62
+ net: amount,
63
+ currency: "usd",
64
+ created: 100,
65
+ available_on: 200,
66
+ status: "available",
67
+ type: "payment",
68
+ exchange_rate: null,
69
+ }));
70
+ let payout: Record<string, unknown> | undefined;
71
+ const provider = {
72
+ accounts: {
73
+ listExternalAccounts: vi.fn(async () => ({
74
+ data: [
75
+ {
76
+ id: "ba_one",
77
+ object: "bank_account",
78
+ currency: "usd",
79
+ default_for_currency: true,
80
+ },
81
+ ],
82
+ has_more: false,
83
+ })),
84
+ },
85
+ transfers: {
86
+ retrieve: vi.fn(async (id: string) => {
87
+ const i = Number(id.slice(3));
88
+ return {
89
+ id,
90
+ destination: scope.accountId,
91
+ destination_payment: `py_${i}`,
92
+ source_transaction: `ch_${i}`,
93
+ amount: amounts[i],
94
+ currency: "usd",
95
+ amount_reversed: 0,
96
+ };
97
+ }),
98
+ listReversals: vi.fn(async () => ({ data: [], has_more: false })),
99
+ },
100
+ charges: {
101
+ retrieve: vi.fn(async (id: string) => {
102
+ const i = Number(id.slice(3));
103
+ return {
104
+ id,
105
+ source_transfer: `tr_${i}`,
106
+ amount: amounts[i],
107
+ currency: "usd",
108
+ paid: true,
109
+ status: "succeeded",
110
+ balance_transaction: `txn_${i}`,
111
+ };
112
+ }),
113
+ },
114
+ balanceTransactions: {
115
+ list: vi.fn(async (params: { source?: string; limit?: number }) => ({
116
+ data: params.source
117
+ ? transactions.filter((t) => t.source === params.source)
118
+ : transactions.slice(0, params.limit),
119
+ has_more: false,
120
+ })),
121
+ retrieve: vi.fn(async (id: string) =>
122
+ transactions.find((t) => t.id === id),
123
+ ),
124
+ },
125
+ balance: {
126
+ retrieve: vi.fn(async () => ({
127
+ available: [
128
+ {
129
+ currency: "usd",
130
+ amount: transactions
131
+ .filter((t) => t.status === "available")
132
+ .reduce((a, t) => a + t.net, 0),
133
+ source_types: {
134
+ card: transactions
135
+ .filter((t) => t.status === "available")
136
+ .reduce((a, t) => a + t.net, 0),
137
+ },
138
+ },
139
+ ],
140
+ pending: [],
141
+ })),
142
+ },
143
+ payouts: {
144
+ list: vi.fn(async () => ({
145
+ data: payout ? [payout] : [],
146
+ has_more: false,
147
+ })),
148
+ retrieve: vi.fn(async () => payout),
149
+ create: vi.fn(
150
+ async (params: {
151
+ amount: number;
152
+ currency: string;
153
+ metadata: object;
154
+ }) => {
155
+ payout = {
156
+ id: "po_one",
157
+ ...params,
158
+ status: "paid",
159
+ automatic: false,
160
+ method: "standard",
161
+ type: "bank_account",
162
+ source_type: "card",
163
+ destination: "ba_one",
164
+ created: Math.floor(Date.now() / 1000),
165
+ arrival_date: Math.floor(Date.now() / 1000),
166
+ balance_transaction: "txn_debit",
167
+ failure_balance_transaction: null,
168
+ reversed_by: null,
169
+ };
170
+ transactions = [
171
+ {
172
+ id: "txn_debit",
173
+ source: "po_one",
174
+ amount: -params.amount,
175
+ fee: 0,
176
+ net: -params.amount,
177
+ currency: "usd",
178
+ created: Math.floor(Date.now() / 1000),
179
+ available_on: Math.floor(Date.now() / 1000),
180
+ status: "available",
181
+ type: "payout",
182
+ exchange_rate: null,
183
+ },
184
+ ...transactions,
185
+ ];
186
+ return payout;
187
+ },
188
+ ),
189
+ },
190
+ };
191
+ const verify = vi.fn(async () => ({
192
+ status: "verified" as const,
193
+ policy: (await t.run((ctx) => ctx.db.query("payoutPolicies").first()))!,
194
+ }));
195
+ const run = (extra = {}) =>
196
+ releaseRecipientPayout(
197
+ provider as unknown as Stripe,
198
+ component,
199
+ ctx,
200
+ { ...scope, ...extra },
201
+ verify,
202
+ );
203
+ return {
204
+ t,
205
+ ctx,
206
+ provider,
207
+ verify,
208
+ run,
209
+ setTransactions: (rows: typeof transactions) => {
210
+ transactions = rows;
211
+ },
212
+ transactions: () => transactions,
213
+ };
214
+ }
215
+ async function historical() {
216
+ const s = await setup();
217
+ await s.provider.payouts.create({
218
+ amount: 1000,
219
+ currency: "usd",
220
+ metadata: {},
221
+ });
222
+ s.provider.payouts.create.mockClear();
223
+ const opts = {
224
+ accountId: scope.accountId,
225
+ ownerKey: scope.ownerKey,
226
+ currency: "usd",
227
+ expectedRevision: 0,
228
+ payouts: [
229
+ {
230
+ stripePayoutId: "po_one",
231
+ allocations: [
232
+ { stripeTransferId: "tr_0", amount: 600 },
233
+ { stripeTransferId: "tr_1", amount: 400 },
234
+ ],
235
+ },
236
+ ],
237
+ };
238
+ const adopt = (extra = {}) =>
239
+ adoptRecipientPayouts(s.provider as unknown as Stripe, component, s.ctx, {
240
+ ...opts,
241
+ ...extra,
242
+ });
243
+ return { ...s, adopt, opts };
244
+ }
245
+ it("imports a verified historical manual payout with exact source allocations", async () => {
246
+ const s = await historical();
247
+ expect(await s.adopt()).toMatchObject({
248
+ status: "adopted",
249
+ claims: [{ historical: true, status: "paid", amount: 1000 }],
250
+ });
251
+ await s.adopt();
252
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
253
+ expect(
254
+ await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
255
+ ).toMatchObject([
256
+ { paidAmount: 600, reservedAmount: 0 },
257
+ { paidAmount: 400, reservedAmount: 0 },
258
+ ]);
259
+ });
260
+ it("requires explicit historical allocations instead of inferring ownership from balance", async () => {
261
+ const s = await historical();
262
+ expect(await s.adopt({ payouts: [] })).toMatchObject({
263
+ status: "held",
264
+ reason: "historical_allocation_required",
265
+ });
266
+ expect(
267
+ await s.t.run((ctx) => ctx.db.query("payoutReleaseClaims").collect()),
268
+ ).toEqual([]);
269
+ });
270
+ it("rejects unavailable funding without writing any partial import", async () => {
271
+ const s = await historical();
272
+ s.setTransactions(
273
+ s
274
+ .transactions()
275
+ .map((t) =>
276
+ t.source === "py_1"
277
+ ? { ...t, available_on: Math.floor(Date.now() / 1000) + 100 }
278
+ : t,
279
+ ),
280
+ );
281
+ await expect(s.adopt()).rejects.toThrow(/funding/i);
282
+ expect(
283
+ await s.t.run((ctx) => ctx.db.query("payoutReleaseClaims").collect()),
284
+ ).toEqual([]);
285
+ });
286
+ it("holds unrelated history before writing source allocations", async () => {
287
+ const s = await historical();
288
+ s.setTransactions([
289
+ ...s.transactions(),
290
+ { ...s.transactions()[0], id: "txn_unowned", source: "po_unknown" },
291
+ ]);
292
+ expect(await s.adopt()).toMatchObject({
293
+ status: "held",
294
+ reason: "unallocated_history",
295
+ });
296
+ expect(
297
+ await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
298
+ ).toEqual([]);
299
+ });
300
+ async function activation() {
301
+ const s = await setup();
302
+ const now = Math.floor(Date.now() / 1000);
303
+ s.setTransactions(
304
+ s
305
+ .transactions()
306
+ .map((t) => ({ ...t, created: now - 100, available_on: now - 50 })),
307
+ );
308
+ let schedule: Record<string, unknown> = {
309
+ interval: "weekly",
310
+ weekly_payout_days: ["monday"],
311
+ };
312
+ const settings = {
313
+ retrieve: vi.fn(async () => ({
314
+ payments: { payouts: { status: "enabled", schedule } },
315
+ })),
316
+ update: vi.fn(
317
+ async (args: {
318
+ payments: { payouts: { schedule: Record<string, unknown> } };
319
+ }) => {
320
+ schedule = args.payments.payouts.schedule;
321
+ return { payments: { payouts: { status: "enabled", schedule } } };
322
+ },
323
+ ),
324
+ };
325
+ Object.assign(s.provider, {
326
+ v2: {
327
+ core: {
328
+ accounts: {
329
+ retrieve: vi.fn(async () => ({
330
+ id: scope.accountId,
331
+ dashboard: "none",
332
+ identity: { country: "US" },
333
+ })),
334
+ },
335
+ },
336
+ },
337
+ balanceSettings: settings,
338
+ });
339
+ const { PayoutPolicyClient } = await import("../payoutPolicy.js");
340
+ const client = new PayoutPolicyClient(
341
+ component,
342
+ () => s.provider as unknown as Stripe,
343
+ );
344
+ const opts = {
345
+ accountId: scope.accountId,
346
+ ownerKey: scope.ownerKey,
347
+ operationKey: "activate",
348
+ expectedRevision: 0,
349
+ mode: "custom" as const,
350
+ thresholds: [{ currency: "usd", amount: 1000 }],
351
+ consent: true as const,
352
+ adoptExistingBalance: true as const,
353
+ };
354
+ return { ...s, client, settings, opts };
355
+ }
356
+ it("explicit funded activation verifies complete history, then releases through the public package", async () => {
357
+ const s = await activation();
358
+ expect(await s.client.prepare(s.ctx, s.opts)).toMatchObject({
359
+ status: "prepared",
360
+ operation: { adoption: { oldestHeldAt: expect.any(Number) } },
361
+ });
362
+ expect(
363
+ await s.client.apply(s.ctx, { ...scope, operationKey: "activate" }),
364
+ ).toMatchObject({ status: "ready" });
365
+ expect(await s.client.release(s.ctx, scope)).toMatchObject({
366
+ status: "updated",
367
+ claim: { status: "paid", amount: 1000 },
368
+ });
369
+ expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
370
+ });
371
+ it("new unallocated provider history before a delayed attempt cancels activation safely", async () => {
372
+ const s = await activation();
373
+ await s.client.prepare(s.ctx, s.opts);
374
+ s.setTransactions([
375
+ ...s.transactions(),
376
+ {
377
+ ...s.transactions()[0],
378
+ id: "txn_unknown",
379
+ source: "py_unknown",
380
+ amount: 10,
381
+ net: 10,
382
+ },
383
+ ]);
384
+ expect(
385
+ await s.client.apply(s.ctx, { ...scope, operationKey: "activate" }),
386
+ ).toMatchObject({
387
+ status: "migration_required",
388
+ adoptionReason: "unallocated_history",
389
+ });
390
+ expect(s.settings.update).not.toHaveBeenCalled();
391
+ expect(await s.client.get(s.ctx, scope)).toMatchObject({
392
+ mode: "stripe_managed",
393
+ status: "ready",
394
+ });
395
+ });
396
+ it("funded activation remains explicit", async () => {
397
+ const s = await activation();
398
+ expect(
399
+ await s.client.prepare(s.ctx, {
400
+ ...s.opts,
401
+ adoptExistingBalance: undefined,
402
+ }),
403
+ ).toMatchObject({ status: "migration_required", reason: "existing_balance" });
404
+ expect(s.settings.update).not.toHaveBeenCalled();
405
+ });
406
+ it("cannot restart the holding period for already-expired funds", async () => {
407
+ const s = await activation();
408
+ s.setTransactions(
409
+ s.transactions().map((t) => ({ ...t, created: 100, available_on: 200 })),
410
+ );
411
+ expect(await s.client.prepare(s.ctx, s.opts)).toMatchObject({
412
+ status: "migration_required",
413
+ adoptionReason: "holding_deadline_expired",
414
+ });
415
+ expect(await s.client.get(s.ctx, scope)).toMatchObject({
416
+ mode: "stripe_managed",
417
+ status: "ready",
418
+ revision: 0,
419
+ });
420
+ expect(s.settings.update).not.toHaveBeenCalled();
421
+ });