@fantastic.dev/better-stripe 0.22.0 → 0.23.1

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 (97) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +5 -3
  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 +231 -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/payoutAdoptionTestHelpers.d.ts +2617 -0
  20. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -0
  21. package/dist/client/connect/payoutAdoptionTestHelpers.js +231 -0
  22. package/dist/client/connect/payoutAdoptionTestHelpers.js.map +1 -0
  23. package/dist/client/connect/payoutReconciliation.d.ts.map +1 -1
  24. package/dist/client/connect/payoutReconciliation.js +4 -2
  25. package/dist/client/connect/payoutReconciliation.js.map +1 -1
  26. package/dist/client/payoutPolicy.d.ts +7 -1
  27. package/dist/client/payoutPolicy.d.ts.map +1 -1
  28. package/dist/client/payoutPolicy.js +78 -7
  29. package/dist/client/payoutPolicy.js.map +1 -1
  30. package/dist/component/_generated/component.d.ts +196 -0
  31. package/dist/component/_generated/component.d.ts.map +1 -1
  32. package/dist/component/earnings/triggers.d.ts +38 -4
  33. package/dist/component/earnings/triggers.d.ts.map +1 -1
  34. package/dist/component/payoutPolicy/adoption.d.ts +9 -0
  35. package/dist/component/payoutPolicy/adoption.d.ts.map +1 -0
  36. package/dist/component/payoutPolicy/adoption.js +45 -0
  37. package/dist/component/payoutPolicy/adoption.js.map +1 -0
  38. package/dist/component/payoutPolicy/mutations.d.ts +96 -0
  39. package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
  40. package/dist/component/payoutPolicy/mutations.js +38 -5
  41. package/dist/component/payoutPolicy/mutations.js.map +1 -1
  42. package/dist/component/payoutPolicy/queries.d.ts +16 -0
  43. package/dist/component/payoutPolicy/queries.d.ts.map +1 -1
  44. package/dist/component/payoutPolicy/schema.d.ts +299 -2
  45. package/dist/component/payoutPolicy/schema.d.ts.map +1 -1
  46. package/dist/component/payoutPolicy/schema.js +7 -0
  47. package/dist/component/payoutPolicy/schema.js.map +1 -1
  48. package/dist/component/payoutRelease/adoption.d.ts +269 -0
  49. package/dist/component/payoutRelease/adoption.d.ts.map +1 -0
  50. package/dist/component/payoutRelease/adoption.js +197 -0
  51. package/dist/component/payoutRelease/adoption.js.map +1 -0
  52. package/dist/component/payoutRelease/helpers.d.ts +3 -0
  53. package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
  54. package/dist/component/payoutRelease/helpers.js +20 -0
  55. package/dist/component/payoutRelease/helpers.js.map +1 -1
  56. package/dist/component/payoutRelease/mutations.d.ts +81 -0
  57. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  58. package/dist/component/payoutRelease/mutations.js +6 -0
  59. package/dist/component/payoutRelease/mutations.js.map +1 -1
  60. package/dist/component/payoutRelease/observation.d.ts +18 -0
  61. package/dist/component/payoutRelease/observation.d.ts.map +1 -0
  62. package/dist/component/payoutRelease/observation.js +11 -0
  63. package/dist/component/payoutRelease/observation.js.map +1 -0
  64. package/dist/component/payoutRelease/queries.d.ts +3 -0
  65. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  66. package/dist/component/payoutRelease/schema.d.ts +8 -19
  67. package/dist/component/payoutRelease/schema.d.ts.map +1 -1
  68. package/dist/component/payoutRelease/schema.js +3 -9
  69. package/dist/component/payoutRelease/schema.js.map +1 -1
  70. package/dist/component/schema.d.ts +73 -2
  71. package/docs/settlement.md +127 -40
  72. package/package.json +1 -1
  73. package/src/client/connect/payoutActivationProof.ts +101 -0
  74. package/src/client/connect/payoutAdoption.test.ts +186 -0
  75. package/src/client/connect/payoutAdoption.ts +331 -0
  76. package/src/client/connect/payoutAdoptionAge.test.ts +89 -0
  77. package/src/client/connect/payoutAdoptionAge.ts +74 -0
  78. package/src/client/connect/payoutAdoptionAllocation.test.ts +132 -0
  79. package/src/client/connect/payoutAdoptionAllocation.ts +106 -0
  80. package/src/client/connect/payoutAdoptionTestHelpers.ts +253 -0
  81. package/src/client/connect/payoutReconciliation.test.ts +16 -0
  82. package/src/client/connect/payoutReconciliation.ts +6 -2
  83. package/src/client/connect/payoutReturnedAdoption.test.ts +157 -0
  84. package/src/client/payoutPolicy.test.ts +16 -0
  85. package/src/client/payoutPolicy.ts +107 -5
  86. package/src/component/_generated/api.ts +6 -0
  87. package/src/component/_generated/component.ts +192 -1
  88. package/src/component/payout-adoption.test.ts +272 -0
  89. package/src/component/payout-policy-adoption.test.ts +131 -0
  90. package/src/component/payoutPolicy/adoption.ts +76 -0
  91. package/src/component/payoutPolicy/mutations.ts +46 -3
  92. package/src/component/payoutPolicy/schema.ts +9 -0
  93. package/src/component/payoutRelease/adoption.ts +262 -0
  94. package/src/component/payoutRelease/helpers.ts +21 -0
  95. package/src/component/payoutRelease/mutations.ts +10 -0
  96. package/src/component/payoutRelease/observation.ts +10 -0
  97. package/src/component/payoutRelease/schema.ts +3 -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,253 @@
1
+ // @vitest-environment edge-runtime
2
+ import { convexTest } from "convex-test";
3
+ import { makeFunctionReference } from "convex/server";
4
+ import { 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
+ export const component = {
13
+ payoutPolicy: {
14
+ queries: { get: { [symbol]: "betterStripe/payoutPolicy/queries/get" } },
15
+ },
16
+ } as unknown as Component;
17
+ export const scope = {
18
+ accountId: "acct_recipient",
19
+ ownerKey: "owner",
20
+ operationKey: "release-1",
21
+ currency: "usd",
22
+ independentLiabilities: 0,
23
+ };
24
+ export async function setup(amounts = [600, 400], currency = "usd") {
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,
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,
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 history: Record<string, unknown>[] = [];
72
+ const provider = {
73
+ accounts: {
74
+ listExternalAccounts: vi.fn(async () => ({
75
+ data: [
76
+ {
77
+ id: "ba_one",
78
+ object: "bank_account",
79
+ currency,
80
+ default_for_currency: true,
81
+ },
82
+ ],
83
+ has_more: false,
84
+ })),
85
+ },
86
+ transfers: {
87
+ retrieve: vi.fn(async (id: string) => {
88
+ const i = Number(id.slice(3));
89
+ return {
90
+ id,
91
+ destination: scope.accountId,
92
+ destination_payment: `py_${i}`,
93
+ source_transaction: `ch_${i}`,
94
+ amount: amounts[i],
95
+ currency,
96
+ amount_reversed: 0,
97
+ };
98
+ }),
99
+ listReversals: vi.fn(async () => ({ data: [], has_more: false })),
100
+ },
101
+ charges: {
102
+ retrieve: vi.fn(async (id: string) => {
103
+ const i = Number(id.slice(3));
104
+ return {
105
+ id,
106
+ source_transfer: `tr_${i}`,
107
+ amount: amounts[i],
108
+ currency,
109
+ paid: true,
110
+ status: "succeeded",
111
+ balance_transaction: `txn_${i}`,
112
+ };
113
+ }),
114
+ },
115
+ balanceTransactions: {
116
+ list: vi.fn(async (params: { source?: string; limit?: number }) => ({
117
+ data: params.source
118
+ ? transactions.filter((t) => t.source === params.source)
119
+ : transactions.slice(0, params.limit),
120
+ has_more: false,
121
+ })),
122
+ retrieve: vi.fn(async (id: string) =>
123
+ transactions.find((t) => t.id === id),
124
+ ),
125
+ },
126
+ balance: {
127
+ retrieve: vi.fn(async () => ({
128
+ available: [
129
+ {
130
+ currency,
131
+ amount: transactions
132
+ .filter((t) => t.status === "available")
133
+ .reduce((a, t) => a + t.net, 0),
134
+ source_types: {
135
+ card: transactions
136
+ .filter((t) => t.status === "available")
137
+ .reduce((a, t) => a + t.net, 0),
138
+ },
139
+ },
140
+ ],
141
+ pending: [],
142
+ })),
143
+ },
144
+ payouts: {
145
+ list: vi.fn(async () => ({
146
+ data: [...history].reverse(),
147
+ has_more: false,
148
+ })),
149
+ retrieve: vi.fn(async (id?: string) =>
150
+ id ? history.find((p) => p.id === id) : payout,
151
+ ),
152
+ create: vi.fn(
153
+ async (params: {
154
+ amount: number;
155
+ currency: string;
156
+ metadata: object;
157
+ }) => {
158
+ const id = history.length ? `po_${history.length + 1}` : "po_one";
159
+ const debitId = history.length
160
+ ? `txn_debit_${history.length + 1}`
161
+ : "txn_debit";
162
+ payout = {
163
+ id,
164
+ ...params,
165
+ status: "paid",
166
+ automatic: false,
167
+ method: "standard",
168
+ type: "bank_account",
169
+ source_type: "card",
170
+ destination: "ba_one",
171
+ created: Math.floor(Date.now() / 1000),
172
+ arrival_date: Math.floor(Date.now() / 1000),
173
+ balance_transaction: debitId,
174
+ failure_balance_transaction: null,
175
+ reversed_by: null,
176
+ };
177
+ transactions = [
178
+ {
179
+ id: debitId,
180
+ source: id,
181
+ amount: -params.amount,
182
+ fee: 0,
183
+ net: -params.amount,
184
+ currency,
185
+ created: Math.floor(Date.now() / 1000),
186
+ available_on: Math.floor(Date.now() / 1000),
187
+ status: "available",
188
+ type: "payout",
189
+ exchange_rate: null,
190
+ },
191
+ ...transactions,
192
+ ];
193
+ history.push(payout);
194
+ return payout;
195
+ },
196
+ ),
197
+ },
198
+ };
199
+ const verify = vi.fn(async () => ({
200
+ status: "verified" as const,
201
+ policy: (await t.run((ctx) => ctx.db.query("payoutPolicies").first()))!,
202
+ }));
203
+ const run = (extra = {}) =>
204
+ releaseRecipientPayout(
205
+ provider as unknown as Stripe,
206
+ component,
207
+ ctx,
208
+ { ...scope, currency, ...extra },
209
+ verify,
210
+ );
211
+ return {
212
+ t,
213
+ ctx,
214
+ provider,
215
+ verify,
216
+ run,
217
+ setTransactions: (rows: typeof transactions) => {
218
+ transactions = rows;
219
+ },
220
+ transactions: () => transactions,
221
+ };
222
+ }
223
+ export async function historical(currency = "usd") {
224
+ const amounts = [600, 400];
225
+ const s = await setup(amounts, currency);
226
+ await s.provider.payouts.create({
227
+ amount: 1000,
228
+ currency,
229
+ metadata: {},
230
+ });
231
+ s.provider.payouts.create.mockClear();
232
+ const opts = {
233
+ accountId: scope.accountId,
234
+ ownerKey: scope.ownerKey,
235
+ currency,
236
+ expectedRevision: 0,
237
+ payouts: [
238
+ {
239
+ stripePayoutId: "po_one",
240
+ allocations: [
241
+ { stripeTransferId: "tr_0", amount: 600 },
242
+ { stripeTransferId: "tr_1", amount: 400 },
243
+ ],
244
+ },
245
+ ],
246
+ };
247
+ const adopt = (extra = {}) =>
248
+ adoptRecipientPayouts(s.provider as unknown as Stripe, component, s.ctx, {
249
+ ...opts,
250
+ ...extra,
251
+ });
252
+ return { ...s, adopt, opts, amounts };
253
+ }
@@ -396,3 +396,19 @@ it("rejects a payout sent to a different bank than the immutable claim", async (
396
396
  await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
397
397
  ).toMatchObject([{ paidAmount: 0, reservedAmount: 1000 }]);
398
398
  });
399
+ it("reconciles an explicitly adopted automatic payout without a package claim marker", async () => {
400
+ const s = await setup();
401
+ await s.t.run(async (ctx) => {
402
+ const c = await ctx.db.query("payoutReleaseClaims").first();
403
+ await ctx.db.patch("payoutReleaseClaims", c!._id, {
404
+ historical: true,
405
+ stripePayoutId: "po_one",
406
+ destinationId: "ba_one",
407
+ });
408
+ });
409
+ s.setPayout({ automatic: true, metadata: {} as never });
410
+ expect(await s.reconcile()).toMatchObject({
411
+ status: "updated",
412
+ claim: { status: "paid", historical: true },
413
+ });
414
+ });
@@ -132,7 +132,7 @@ export async function reconcileRecipientPayout(
132
132
  const receipt = await getRecipientPayoutReceipt(stripe, {
133
133
  accountId: options.accountId,
134
134
  stripePayoutId: payoutId,
135
- operationKey: options.operationKey,
135
+ ...(claim.historical ? {} : { operationKey: options.operationKey }),
136
136
  });
137
137
  if (receipt.status !== "ready")
138
138
  return { status: "held", reason: receipt.reason };
@@ -151,7 +151,11 @@ export async function reconcileRecipientPayout(
151
151
  );
152
152
  // Current claims use the default card source balance. Other rails need a
153
153
  // persisted allocation contract before a provider receipt can be adopted.
154
- if (receipt.sourceType !== "card")
154
+ if (
155
+ receipt.sourceType !== "card" ||
156
+ (claim.historical &&
157
+ (receipt.method !== "standard" || receipt.type !== "bank_account"))
158
+ )
155
159
  return { status: "held", reason: "source_allocation_required" };
156
160
  const fee = addSummaryAmounts(receipt.debit.fee, receipt.credit?.fee ?? 0);
157
161
  if (fee < 0) return { status: "held", reason: "fee_allocation_required" };
@@ -0,0 +1,157 @@
1
+ // @vitest-environment edge-runtime
2
+ import { expect, it, vi } from "vitest";
3
+ import { historical, scope } from "./payoutAdoptionTestHelpers.js";
4
+
5
+ it.each([
6
+ ["usd", "failed", 0],
7
+ ["jpy", "canceled", 25],
8
+ ["kwd", "failed", 25],
9
+ ] as const)(
10
+ "adopts and replays returned %s principal with %s status and %i fee",
11
+ async (currency, status, fee) => {
12
+ const s = await historical(currency);
13
+ const payout = await s.provider.payouts.retrieve();
14
+ if (!payout) throw new Error("Missing fixture payout");
15
+ vi.useFakeTimers();
16
+ try {
17
+ vi.setSystemTime(Date.now() + 2000);
18
+ const returnedAt = Math.floor(Date.now() / 1000);
19
+ payout.status = status;
20
+ payout.failure_balance_transaction = "txn_return";
21
+ s.setTransactions([
22
+ {
23
+ id: "txn_return",
24
+ source: "po_one",
25
+ amount: 1000,
26
+ fee,
27
+ net: 1000 - fee,
28
+ currency,
29
+ created: returnedAt,
30
+ available_on: returnedAt,
31
+ status: "available",
32
+ type: status === "failed" ? "payout_failure" : "payout_cancel",
33
+ exchange_rate: null,
34
+ },
35
+ ...s.transactions(),
36
+ ]);
37
+ const expected = {
38
+ status: "adopted",
39
+ oldestHeldAt: returnedAt * 1000,
40
+ claims: [
41
+ { status: "returned", amount: 1000, currency, providerFee: fee },
42
+ ],
43
+ };
44
+ expect(await s.adopt()).toMatchObject(expected);
45
+ expect(await s.adopt()).toMatchObject(expected);
46
+ expect(
47
+ await s.t.run((ctx) => ctx.db.query("payoutReleaseClaims").collect()),
48
+ ).toHaveLength(1);
49
+ expect(
50
+ await s.t.run((ctx) =>
51
+ ctx.db.query("payoutSourceAllocations").collect(),
52
+ ),
53
+ ).toMatchObject([
54
+ { paidAmount: 0, reservedAmount: 0 },
55
+ { paidAmount: 0, reservedAmount: 0 },
56
+ ]);
57
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
58
+
59
+ // Add later source funding; the returned principal is payable once, net of its fee.
60
+ s.amounts.push(300);
61
+ vi.setSystemTime(Date.now() + 10000);
62
+ const created = Math.floor(Date.now() / 1000);
63
+ await s.t.run(async (ctx) => {
64
+ await ctx.db.insert("transfers", {
65
+ stripeTransferId: "tr_2",
66
+ destinationAccountId: scope.accountId,
67
+ sourceChargeId: "ch_2",
68
+ amount: 300,
69
+ currency,
70
+ status: "paid",
71
+ role: "store",
72
+ });
73
+ const policy = await ctx.db.query("payoutPolicies").first();
74
+ if (!policy) throw new Error("Missing fixture policy");
75
+ await ctx.db.patch("payoutPolicies", policy._id, {
76
+ mode: "custom",
77
+ thresholds: [{ currency, amount: 1 }],
78
+ });
79
+ });
80
+ s.setTransactions([
81
+ ...s.transactions(),
82
+ {
83
+ id: "txn_2",
84
+ source: "py_2",
85
+ amount: 300,
86
+ fee: 0,
87
+ net: 300,
88
+ currency,
89
+ created,
90
+ available_on: created,
91
+ status: "available",
92
+ type: "payment",
93
+ exchange_rate: null,
94
+ },
95
+ ]);
96
+ expect(await s.run()).toMatchObject({
97
+ status: "updated",
98
+ claim: { status: "paid", amount: 1300 - fee, currency },
99
+ });
100
+ expect(await s.run()).toMatchObject({
101
+ status: "updated",
102
+ claim: { status: "paid", amount: 1300 - fee, currency },
103
+ });
104
+ expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
105
+ } finally {
106
+ vi.useRealTimers();
107
+ }
108
+ },
109
+ );
110
+
111
+ it.each(["pending", "wrong_currency", "wrong_principal"] as const)(
112
+ "holds or rejects %s returns without importing partial history",
113
+ async (kind) => {
114
+ const s = await historical();
115
+ const payout = await s.provider.payouts.retrieve();
116
+ if (!payout) throw new Error("Missing fixture payout");
117
+ payout.status = "failed";
118
+ payout.failure_balance_transaction = "txn_return";
119
+ const now = Math.floor(Date.now() / 1000);
120
+ const amount = kind === "wrong_principal" ? 999 : 1000;
121
+ s.setTransactions([
122
+ {
123
+ id: "txn_return",
124
+ source: "po_one",
125
+ amount,
126
+ fee: 0,
127
+ net: amount,
128
+ currency: kind === "wrong_currency" ? "eur" : "usd",
129
+ created: now,
130
+ available_on: now,
131
+ status: kind === "pending" ? "pending" : "available",
132
+ type: "payout_failure",
133
+ exchange_rate: null,
134
+ },
135
+ ...s.transactions(),
136
+ ]);
137
+ if (kind === "wrong_principal")
138
+ await expect(s.adopt()).rejects.toThrow(
139
+ "Payout transaction receipt identity or principal does not match.",
140
+ );
141
+ else
142
+ expect(await s.adopt()).toMatchObject({
143
+ status: "held",
144
+ reason:
145
+ kind === "pending"
146
+ ? "return_pending"
147
+ : "currency_allocation_required",
148
+ });
149
+ expect(
150
+ await s.t.run((ctx) => ctx.db.query("payoutReleaseClaims").collect()),
151
+ ).toEqual([]);
152
+ expect(
153
+ await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
154
+ ).toEqual([]);
155
+ expect(s.provider.payouts.create).not.toHaveBeenCalled();
156
+ },
157
+ );
@@ -318,3 +318,19 @@ it("rechecks delayed activation and cancels safely when funds arrive after prepa
318
318
  status: "ready",
319
319
  });
320
320
  });
321
+ it("requires adoption for historical payouts even when today's balance is zero", async () => {
322
+ const { client, ctx, provider } = await setup();
323
+ provider.payouts.list.mockImplementation(
324
+ async (params?: { status?: string }) =>
325
+ ({
326
+ data: params?.status ? [] : [{ id: "po_old" }],
327
+ has_more: false,
328
+ }) as never,
329
+ );
330
+ expect(await client.prepare(ctx, options)).toMatchObject({
331
+ status: "migration_required",
332
+ reason: "existing_payout_history",
333
+ });
334
+ expect(provider.balanceSettings.update).not.toHaveBeenCalled();
335
+ expect(await client.get(ctx, scope)).toBeNull();
336
+ });