@fantastic.dev/better-stripe 0.22.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 (91) hide show
  1. package/CHANGELOG.md +6 -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 +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/payoutReconciliation.d.ts.map +1 -1
  20. package/dist/client/connect/payoutReconciliation.js +4 -2
  21. package/dist/client/connect/payoutReconciliation.js.map +1 -1
  22. package/dist/client/payoutPolicy.d.ts +7 -1
  23. package/dist/client/payoutPolicy.d.ts.map +1 -1
  24. package/dist/client/payoutPolicy.js +78 -7
  25. package/dist/client/payoutPolicy.js.map +1 -1
  26. package/dist/component/_generated/component.d.ts +196 -0
  27. package/dist/component/_generated/component.d.ts.map +1 -1
  28. package/dist/component/earnings/triggers.d.ts +38 -4
  29. package/dist/component/earnings/triggers.d.ts.map +1 -1
  30. package/dist/component/payoutPolicy/adoption.d.ts +9 -0
  31. package/dist/component/payoutPolicy/adoption.d.ts.map +1 -0
  32. package/dist/component/payoutPolicy/adoption.js +45 -0
  33. package/dist/component/payoutPolicy/adoption.js.map +1 -0
  34. package/dist/component/payoutPolicy/mutations.d.ts +96 -0
  35. package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
  36. package/dist/component/payoutPolicy/mutations.js +38 -5
  37. package/dist/component/payoutPolicy/mutations.js.map +1 -1
  38. package/dist/component/payoutPolicy/queries.d.ts +16 -0
  39. package/dist/component/payoutPolicy/queries.d.ts.map +1 -1
  40. package/dist/component/payoutPolicy/schema.d.ts +299 -2
  41. package/dist/component/payoutPolicy/schema.d.ts.map +1 -1
  42. package/dist/component/payoutPolicy/schema.js +7 -0
  43. package/dist/component/payoutPolicy/schema.js.map +1 -1
  44. package/dist/component/payoutRelease/adoption.d.ts +269 -0
  45. package/dist/component/payoutRelease/adoption.d.ts.map +1 -0
  46. package/dist/component/payoutRelease/adoption.js +197 -0
  47. package/dist/component/payoutRelease/adoption.js.map +1 -0
  48. package/dist/component/payoutRelease/helpers.d.ts +3 -0
  49. package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
  50. package/dist/component/payoutRelease/helpers.js +20 -0
  51. package/dist/component/payoutRelease/helpers.js.map +1 -1
  52. package/dist/component/payoutRelease/mutations.d.ts +81 -0
  53. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  54. package/dist/component/payoutRelease/mutations.js +6 -0
  55. package/dist/component/payoutRelease/mutations.js.map +1 -1
  56. package/dist/component/payoutRelease/observation.d.ts +18 -0
  57. package/dist/component/payoutRelease/observation.d.ts.map +1 -0
  58. package/dist/component/payoutRelease/observation.js +11 -0
  59. package/dist/component/payoutRelease/observation.js.map +1 -0
  60. package/dist/component/payoutRelease/queries.d.ts +3 -0
  61. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  62. package/dist/component/payoutRelease/schema.d.ts +8 -19
  63. package/dist/component/payoutRelease/schema.d.ts.map +1 -1
  64. package/dist/component/payoutRelease/schema.js +3 -9
  65. package/dist/component/payoutRelease/schema.js.map +1 -1
  66. package/dist/component/schema.d.ts +73 -2
  67. package/docs/settlement.md +121 -40
  68. package/package.json +1 -1
  69. package/src/client/connect/payoutActivationProof.ts +101 -0
  70. package/src/client/connect/payoutAdoption.test.ts +421 -0
  71. package/src/client/connect/payoutAdoption.ts +330 -0
  72. package/src/client/connect/payoutAdoptionAge.test.ts +89 -0
  73. package/src/client/connect/payoutAdoptionAge.ts +74 -0
  74. package/src/client/connect/payoutAdoptionAllocation.test.ts +132 -0
  75. package/src/client/connect/payoutAdoptionAllocation.ts +106 -0
  76. package/src/client/connect/payoutReconciliation.test.ts +16 -0
  77. package/src/client/connect/payoutReconciliation.ts +6 -2
  78. package/src/client/payoutPolicy.test.ts +16 -0
  79. package/src/client/payoutPolicy.ts +107 -5
  80. package/src/component/_generated/api.ts +6 -0
  81. package/src/component/_generated/component.ts +192 -1
  82. package/src/component/payout-adoption.test.ts +272 -0
  83. package/src/component/payout-policy-adoption.test.ts +131 -0
  84. package/src/component/payoutPolicy/adoption.ts +76 -0
  85. package/src/component/payoutPolicy/mutations.ts +46 -3
  86. package/src/component/payoutPolicy/schema.ts +9 -0
  87. package/src/component/payoutRelease/adoption.ts +262 -0
  88. package/src/component/payoutRelease/helpers.ts +21 -0
  89. package/src/component/payoutRelease/mutations.ts +10 -0
  90. package/src/component/payoutRelease/observation.ts +10 -0
  91. package/src/component/payoutRelease/schema.ts +3 -9
@@ -0,0 +1,330 @@
1
+ import { oldestUnpaidFunding } from "./payoutAdoptionAge.js";
2
+ import { addSummaryAmounts as add } from "../../component/earnings/arithmetic.js";
3
+ import type Stripe from "stripe";
4
+ import type { Infer } from "convex/values";
5
+ import type { Component, RunCtx } from "../helpers.js";
6
+ import { runMutationOrThrow } from "../helpers.js";
7
+ import { componentRef } from "../webhooks/helpers.js";
8
+ import { validateScope } from "../../component/payoutPolicy/helpers.js";
9
+ import {
10
+ transferFingerprint,
11
+ payoutClaimFingerprint,
12
+ } from "../../component/payoutRelease/helpers.js";
13
+ import type {
14
+ claimDoc,
15
+ observation,
16
+ } from "../../component/payoutRelease/schema.js";
17
+ import type { policyDoc } from "../../component/payoutPolicy/schema.js";
18
+ import type { transferDocValidator } from "../../component/connect/validators.js";
19
+ import {
20
+ getTransferFundingReceipt,
21
+ type TransferReceiptTransaction,
22
+ } from "./transferReceipt.js";
23
+ import { getRecipientPayoutReceipt } from "./payoutReceipt.js";
24
+ import { verifyPayoutHistory } from "./payoutHistory.js";
25
+ import {
26
+ validateHistoricalFunding,
27
+ type HistoricalFundingSource,
28
+ type HistoricalFundingPayout,
29
+ } from "./payoutAdoptionAllocation.js";
30
+ import { reconcileRecipientPayout } from "./payoutReconciliation.js";
31
+ type Claim = Infer<typeof claimDoc>;
32
+ export type PayoutAdoptionOptions = {
33
+ accountId: string;
34
+ ownerKey: string;
35
+ currency: string;
36
+ expectedRevision: number;
37
+ maxPages?: number;
38
+ /** Explicit authorized source accounting for historical payouts not already recorded by BetterStripe. */
39
+ payouts: {
40
+ stripePayoutId: string;
41
+ allocations: { stripeTransferId: string; amount: number }[];
42
+ }[];
43
+ };
44
+ export type PayoutAdoptionResult =
45
+ | {
46
+ status: "adopted";
47
+ claims: Claim[];
48
+ observedAt: number;
49
+ observations: Infer<typeof observation>[];
50
+ oldestHeldAt?: number;
51
+ historyCurrencies: string[];
52
+ }
53
+ | { status: "held"; reason: string };
54
+ /** Import verified terminal historical payouts. Does not change payout schedules or authorize activation. */
55
+ export async function adoptRecipientPayouts(
56
+ stripe: Stripe,
57
+ component: Component,
58
+ ctx: RunCtx,
59
+ opts: PayoutAdoptionOptions,
60
+ verifyOnly = false,
61
+ ): Promise<PayoutAdoptionResult> {
62
+ validateScope(opts);
63
+ const max = opts.maxPages ?? 10;
64
+ if (
65
+ !/^[a-z]{3}$/.test(opts.currency) ||
66
+ !Number.isSafeInteger(opts.expectedRevision) ||
67
+ opts.expectedRevision < 0 ||
68
+ !Number.isInteger(max) ||
69
+ max < 1 ||
70
+ max > 100
71
+ )
72
+ throw new Error("Invalid historical adoption scope or budget.");
73
+ if (!ctx.runMutation)
74
+ throw new Error("Historical adoption requires runMutation.");
75
+ const mappings = new Map<
76
+ string,
77
+ PayoutAdoptionOptions["payouts"][number]["allocations"]
78
+ >();
79
+ for (const p of opts.payouts) {
80
+ if (mappings.has(p.stripePayoutId))
81
+ throw new Error("Duplicate historical payout mapping.");
82
+ mappings.set(p.stripePayoutId, p.allocations);
83
+ }
84
+ const scope = { accountId: opts.accountId, ownerKey: opts.ownerKey };
85
+ const policy = (await ctx.runQuery(
86
+ componentRef(component, "payoutPolicy/queries/get"),
87
+ scope,
88
+ )) as Infer<typeof policyDoc> | null;
89
+ if (
90
+ !policy ||
91
+ policy.revision !== opts.expectedRevision ||
92
+ policy.mode !== "stripe_managed" ||
93
+ (policy.status !== "ready" && !(verifyOnly && policy.status === "changing"))
94
+ )
95
+ throw new Error(
96
+ "Historical adoption requires an owned automatic policy at this revision.",
97
+ );
98
+ const snapshot = (await ctx.runQuery(
99
+ componentRef(component, "payoutRelease/queries/snapshot"),
100
+ scope,
101
+ )) as
102
+ | {
103
+ status: "ready";
104
+ transfers: Infer<typeof transferDocValidator>[];
105
+ claims: Claim[];
106
+ }
107
+ | { status: "held"; reason: string };
108
+ if (snapshot.status !== "ready") return snapshot;
109
+ const known = new Map(
110
+ snapshot.claims
111
+ .filter((c) => c.stripePayoutId)
112
+ .map((c) => [c.stripePayoutId!, c]),
113
+ );
114
+ const historyCurrencies = new Set<string>();
115
+ const payouts: Stripe.Payout[] = [];
116
+ let cursor: string | undefined,
117
+ head: string | undefined,
118
+ complete = false;
119
+ for (let page = 0; page < max; page++) {
120
+ const batch = await stripe.payouts.list(
121
+ { limit: 100, ...(cursor ? { starting_after: cursor } : {}) },
122
+ { stripeAccount: opts.accountId },
123
+ );
124
+ if (page === 0) head = batch.data[0]?.id;
125
+ for (const p of batch.data) historyCurrencies.add(p.currency);
126
+ payouts.push(...batch.data.filter((p) => p.currency === opts.currency));
127
+ if (!batch.has_more) {
128
+ complete = true;
129
+ break;
130
+ }
131
+ const next = batch.data[batch.data.length - 1]?.id;
132
+ if (!next || next === cursor)
133
+ return { status: "held", reason: "history_incomplete" };
134
+ cursor = next;
135
+ }
136
+ if (!complete) return { status: "held", reason: "history_incomplete" };
137
+ if (new Set(payouts.map((p) => p.id)).size !== payouts.length)
138
+ return { status: "held", reason: "history_incomplete" };
139
+ if ([...mappings.keys()].some((id) => !payouts.some((p) => p.id === id)))
140
+ return { status: "held", reason: "historical_history_changed" };
141
+ const proofPayouts: HistoricalFundingPayout[] = [];
142
+ const transactions: TransferReceiptTransaction[] = [];
143
+ const imports = [];
144
+ for (const p of payouts) {
145
+ const old = known.get(p.id),
146
+ supplied = mappings.get(p.id);
147
+ const allocations = old?.allocations ?? supplied;
148
+ if (!allocations)
149
+ return { status: "held", reason: "historical_allocation_required" };
150
+ const canonical = (a: typeof allocations) =>
151
+ JSON.stringify(
152
+ a
153
+ .map((x) => [x.stripeTransferId, x.amount])
154
+ .sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
155
+ );
156
+ if (old && supplied && canonical(old.allocations) !== canonical(supplied))
157
+ throw new Error(
158
+ "Historical payout source allocations cannot be changed.",
159
+ );
160
+ const receipt = await getRecipientPayoutReceipt(stripe, {
161
+ accountId: opts.accountId,
162
+ stripePayoutId: p.id,
163
+ });
164
+ if (receipt.status !== "ready")
165
+ return { status: "held", reason: receipt.reason };
166
+ if (
167
+ receipt.sourceType !== "card" ||
168
+ receipt.method !== "standard" ||
169
+ receipt.type !== "bank_account"
170
+ )
171
+ return { status: "held", reason: "source_allocation_required" };
172
+ if (!["paid", "failed", "canceled"].includes(receipt.providerStatus))
173
+ return { status: "held", reason: "historical_payout_pending" };
174
+ const returned = receipt.providerStatus !== "paid";
175
+ if (returned && !receipt.credit)
176
+ return { status: "held", reason: "return_unavailable" };
177
+ if (receipt.debit.fee + (receipt.credit?.fee ?? 0) < 0)
178
+ return { status: "held", reason: "fee_allocation_required" };
179
+ proofPayouts.push({
180
+ stripePayoutId: p.id,
181
+ currency: receipt.currency,
182
+ amount: receipt.amount,
183
+ created: receipt.created,
184
+ allocations,
185
+ ...(returned
186
+ ? {
187
+ returnedAt: Math.max(
188
+ receipt.credit!.created,
189
+ receipt.credit!.availableOn,
190
+ ),
191
+ }
192
+ : {}),
193
+ });
194
+ transactions.push({ ...receipt.debit, sourceId: p.id });
195
+ if (receipt.credit)
196
+ transactions.push({ ...receipt.credit, sourceId: p.id });
197
+ imports.push({
198
+ old,
199
+ allocations,
200
+ payout: {
201
+ stripePayoutId: p.id,
202
+ amount: receipt.amount,
203
+ currency: receipt.currency,
204
+ created: receipt.created,
205
+ destinationId: receipt.destinationId,
206
+ status: returned ? ("returned" as const) : ("paid" as const),
207
+ debitTransactionId: receipt.debit.id,
208
+ debitFee: receipt.debit.fee,
209
+ ...(returned
210
+ ? {
211
+ returnTransactionId: receipt.credit!.id,
212
+ returnFee: receipt.credit!.fee,
213
+ }
214
+ : {}),
215
+ },
216
+ });
217
+ }
218
+ const observations: Infer<typeof observation>[] = [];
219
+ const sources: HistoricalFundingSource[] = [];
220
+ for (const row of snapshot.transfers) {
221
+ const receipt = await getTransferFundingReceipt(stripe, component, ctx, {
222
+ accountId: opts.accountId,
223
+ stripeTransferId: row.stripeTransferId,
224
+ });
225
+ if (receipt.status !== "ready")
226
+ return { status: "held", reason: receipt.reason };
227
+ historyCurrencies.add(receipt.settlementCurrency);
228
+ sources.push({
229
+ stripeTransferId: row.stripeTransferId,
230
+ currency: receipt.settlementCurrency,
231
+ transactions: receipt.transactions,
232
+ });
233
+ observations.push({
234
+ stripeTransferId: row.stripeTransferId,
235
+ currency: receipt.settlementCurrency,
236
+ matureNet: receipt.matureNet,
237
+ pendingCredits: receipt.pendingCredits,
238
+ ledgerFingerprint: transferFingerprint(row),
239
+ observedAt: receipt.observedAt,
240
+ transactionIds: receipt.transactions.map((t) => t.id),
241
+ });
242
+ if (receipt.settlementCurrency === opts.currency)
243
+ transactions.push(...receipt.transactions);
244
+ }
245
+ validateHistoricalFunding(sources, proofPayouts);
246
+ const history = await verifyPayoutHistory(stripe, {
247
+ accountId: opts.accountId,
248
+ currency: opts.currency,
249
+ transactions,
250
+ maxPages: max,
251
+ });
252
+ if (history.status !== "verified") return history;
253
+ const latest = await stripe.payouts.list(
254
+ { limit: 1 },
255
+ { stripeAccount: opts.accountId },
256
+ );
257
+ if (latest.data[0]?.id !== head)
258
+ return { status: "held", reason: "provider_changed" };
259
+ const claims: Claim[] = [];
260
+ for (const item of imports.filter((i) => i.old)) {
261
+ const result = await reconcileRecipientPayout(stripe, component, ctx, {
262
+ ...scope,
263
+ operationKey: item.old!.operationKey,
264
+ stripePayoutId: item.payout.stripePayoutId,
265
+ maxPages: max,
266
+ });
267
+ if (result.status !== "updated")
268
+ return {
269
+ status: "held",
270
+ reason:
271
+ result.status === "held"
272
+ ? result.reason
273
+ : "historical_claim_terminal",
274
+ };
275
+ claims.push(result.claim);
276
+ }
277
+ const unknown = imports.filter((i) => !i.old);
278
+ if (unknown.length && verifyOnly)
279
+ return { status: "held", reason: "historical_allocation_required" };
280
+ if (unknown.length) {
281
+ const fresh = (await ctx.runQuery(
282
+ componentRef(component, "payoutRelease/queries/snapshot"),
283
+ scope,
284
+ )) as
285
+ { status: "ready"; claims: Claim[] } | { status: "held"; reason: string };
286
+ if (fresh.status !== "ready") return fresh;
287
+ const result = (await runMutationOrThrow(
288
+ ctx,
289
+ componentRef(component, "payoutRelease/mutations/adoptHistoricalBatch"),
290
+ {
291
+ ...scope,
292
+ expectedRevision: opts.expectedRevision,
293
+ observations,
294
+ expectedClaims: fresh.claims.map((c) => ({
295
+ operationKey: c.operationKey,
296
+ fingerprint: payoutClaimFingerprint(c),
297
+ })),
298
+ payouts: unknown.map((i) => ({
299
+ payout: i.payout,
300
+ allocations: i.allocations,
301
+ })),
302
+ },
303
+ )) as { claims: Claim[] };
304
+ claims.push(...result.claims);
305
+ }
306
+ const paid = claims
307
+ .filter((c) => c.status === "paid")
308
+ .flatMap((c) => c.allocations);
309
+ const fees = claims.reduce((total, c) => add(total, c.providerFee ?? 0), 0);
310
+ const returned = proofPayouts
311
+ .filter((p) => p.returnedAt !== undefined)
312
+ .flatMap((p) =>
313
+ p.allocations.map((a) => ({ ...a, created: p.returnedAt! })),
314
+ );
315
+ const oldestHeldAt = oldestUnpaidFunding(
316
+ opts.currency,
317
+ sources,
318
+ paid,
319
+ fees,
320
+ returned,
321
+ );
322
+ return {
323
+ status: "adopted",
324
+ claims,
325
+ observations,
326
+ historyCurrencies: [...historyCurrencies],
327
+ ...(oldestHeldAt !== undefined ? { oldestHeldAt } : {}),
328
+ observedAt: Date.now(),
329
+ };
330
+ }
@@ -0,0 +1,89 @@
1
+ import { expect, it } from "vitest";
2
+ import { oldestUnpaidFunding } from "./payoutAdoptionAge.js";
3
+ const source = (stripeTransferId: string, net: number, created: number) => ({
4
+ stripeTransferId,
5
+ currency: "usd",
6
+ transactions: [{ net, created }],
7
+ });
8
+ it("ignores fully paid old funding and anchors a later unpaid credit", () => {
9
+ expect(
10
+ oldestUnpaidFunding(
11
+ "usd",
12
+ [source("tr_old", 1000, 10), source("tr_new", 300, 100)],
13
+ [{ stripeTransferId: "tr_old", amount: 1000 }],
14
+ 0,
15
+ ),
16
+ ).toBe(100000);
17
+ });
18
+ it("old refund debt consumes new funding without reviving the old holding age", () => {
19
+ expect(
20
+ oldestUnpaidFunding(
21
+ "usd",
22
+ [source("tr_old", 900, 10), source("tr_new", 300, 100)],
23
+ [{ stripeTransferId: "tr_old", amount: 1000 }],
24
+ 0,
25
+ ),
26
+ ).toBe(100000);
27
+ });
28
+ it("fees consume the oldest unpaid funds first", () => {
29
+ expect(
30
+ oldestUnpaidFunding(
31
+ "usd",
32
+ [source("tr_old", 30, 10), source("tr_new", 300, 100)],
33
+ [],
34
+ 30,
35
+ ),
36
+ ).toBe(100000);
37
+ });
38
+ it("anchors a fee credit after the original source was fully paid", () => {
39
+ expect(
40
+ oldestUnpaidFunding(
41
+ "usd",
42
+ [
43
+ {
44
+ ...source("tr_old", 1000, 10),
45
+ transactions: [
46
+ { net: 1000, created: 10 },
47
+ { net: 100, created: 100 },
48
+ ],
49
+ },
50
+ ],
51
+ [{ stripeTransferId: "tr_old", amount: 1000 }],
52
+ 0,
53
+ ),
54
+ ).toBe(100000);
55
+ });
56
+ it("returns no holding age when nothing is owed", () => {
57
+ expect(
58
+ oldestUnpaidFunding(
59
+ "usd",
60
+ [source("tr_old", 1000, 10)],
61
+ [{ stripeTransferId: "tr_old", amount: 1000 }],
62
+ 0,
63
+ ),
64
+ ).toBeUndefined();
65
+ });
66
+ it("does not offset another currency", () => {
67
+ expect(
68
+ oldestUnpaidFunding(
69
+ "usd",
70
+ [
71
+ { ...source("tr_eur", -1000, 10), currency: "eur" },
72
+ source("tr_usd", 100, 20),
73
+ ],
74
+ [],
75
+ 0,
76
+ ),
77
+ ).toBe(20000);
78
+ });
79
+ it("returned bank cash starts a new holding age after the original funds left Stripe", () => {
80
+ expect(
81
+ oldestUnpaidFunding(
82
+ "usd",
83
+ [source("tr_old", 1000, 10)],
84
+ [{ stripeTransferId: "tr_old", amount: 1000 }],
85
+ 0,
86
+ [{ stripeTransferId: "tr_old", amount: 1000, created: 100 }],
87
+ ),
88
+ ).toBe(100000);
89
+ });
@@ -0,0 +1,74 @@
1
+ import { addSummaryAmounts as add } from "../../component/earnings/arithmetic.js";
2
+ type Source = {
3
+ stripeTransferId: string;
4
+ currency: string;
5
+ transactions: { net: number; created: number }[];
6
+ };
7
+ /** FIFO ownership age after historical paid principal, source debt and verified payout fees. */
8
+ export function oldestUnpaidFunding(
9
+ currency: string,
10
+ sources: Source[],
11
+ paid: { stripeTransferId: string; amount: number }[],
12
+ fees: number,
13
+ returned: {
14
+ stripeTransferId: string;
15
+ amount: number;
16
+ created: number;
17
+ }[] = [],
18
+ ): number | undefined {
19
+ if (!Number.isSafeInteger(fees) || fees < 0)
20
+ throw new Error("Invalid payout fee amount.");
21
+ const bySource = new Map<string, number>();
22
+ for (const p of paid) {
23
+ if (!Number.isSafeInteger(p.amount) || p.amount < 0)
24
+ throw new Error("Invalid historical paid amount.");
25
+ bySource.set(
26
+ p.stripeTransferId,
27
+ add(bySource.get(p.stripeTransferId) ?? 0, p.amount),
28
+ );
29
+ }
30
+ sources = sources.map((s) => ({ ...s, transactions: [...s.transactions] }));
31
+ const returnedTotals = new Map<string, number>();
32
+ for (const r of returned) {
33
+ const source = sources.find(
34
+ (s) => s.stripeTransferId === r.stripeTransferId,
35
+ );
36
+ if (!source || !Number.isSafeInteger(r.amount) || r.amount <= 0)
37
+ throw new Error("Invalid returned funding allocation.");
38
+ const total = add(returnedTotals.get(r.stripeTransferId) ?? 0, r.amount);
39
+ if (total > (bySource.get(r.stripeTransferId) ?? 0))
40
+ throw new Error("Returned funding exceeds historical paid principal.");
41
+ returnedTotals.set(r.stripeTransferId, total);
42
+ source.transactions.push({ net: r.amount, created: r.created });
43
+ }
44
+ const chunks: { amount: number; created: number }[] = [];
45
+ let debt = fees;
46
+ for (const s of sources.filter((s) => s.currency === currency)) {
47
+ let consumed = bySource.get(s.stripeTransferId) ?? 0;
48
+ for (const t of s.transactions) {
49
+ if (
50
+ !Number.isSafeInteger(t.net) ||
51
+ !Number.isSafeInteger(t.created) ||
52
+ t.created < 0 ||
53
+ !Number.isSafeInteger(t.created * 1000)
54
+ )
55
+ throw new Error("Invalid source funding age.");
56
+ if (t.net < 0) consumed = add(consumed, -t.net);
57
+ }
58
+ for (const t of [...s.transactions]
59
+ .filter((t) => t.net > 0)
60
+ .sort((a, b) => a.created - b.created)) {
61
+ const used = Math.min(consumed, t.net);
62
+ consumed = add(consumed, -used);
63
+ const amount = add(t.net, -used);
64
+ if (amount) chunks.push({ amount, created: t.created });
65
+ }
66
+ debt = add(debt, consumed);
67
+ }
68
+ for (const c of chunks.sort((a, b) => a.created - b.created)) {
69
+ const used = Math.min(debt, c.amount);
70
+ debt = add(debt, -used);
71
+ if (c.amount > used) return c.created * 1000;
72
+ }
73
+ return undefined;
74
+ }
@@ -0,0 +1,132 @@
1
+ import { expect, it } from "vitest";
2
+ import { validateHistoricalFunding } from "./payoutAdoptionAllocation.js";
3
+ const source = {
4
+ stripeTransferId: "tr_one",
5
+ currency: "usd",
6
+ transactions: [
7
+ {
8
+ amount: 1000,
9
+ fee: 0,
10
+ net: 1000,
11
+ created: 10,
12
+ availableOn: 20,
13
+ status: "available",
14
+ },
15
+ ],
16
+ };
17
+ const payout = {
18
+ stripePayoutId: "po_one",
19
+ currency: "usd",
20
+ amount: 1000,
21
+ created: 30,
22
+ allocations: [{ stripeTransferId: "tr_one", amount: 1000 }],
23
+ };
24
+ it("validates exact historical funding without requiring it to remain unpaid now", () => {
25
+ expect(() =>
26
+ validateHistoricalFunding(
27
+ [
28
+ {
29
+ ...source,
30
+ transactions: [
31
+ ...source.transactions,
32
+ {
33
+ amount: -500,
34
+ fee: 0,
35
+ net: -500,
36
+ created: 40,
37
+ availableOn: 50,
38
+ status: "available",
39
+ },
40
+ ],
41
+ },
42
+ ],
43
+ [payout],
44
+ ),
45
+ ).not.toThrow();
46
+ });
47
+ it("rejects funding from a transfer not mature at the payout date", () => {
48
+ expect(() =>
49
+ validateHistoricalFunding(
50
+ [
51
+ {
52
+ ...source,
53
+ transactions: [{ ...source.transactions[0], availableOn: 31 }],
54
+ },
55
+ ],
56
+ [payout],
57
+ ),
58
+ ).toThrow(/funding/i);
59
+ });
60
+ it("rejects paying the same historical source twice", () => {
61
+ expect(() =>
62
+ validateHistoricalFunding(
63
+ [source],
64
+ [payout, { ...payout, stripePayoutId: "po_two", created: 40 }],
65
+ ),
66
+ ).toThrow(/funding/i);
67
+ });
68
+ it("a returned payout restores source capacity only at actual return maturity", () => {
69
+ const first = { ...payout, returnedAt: 50 };
70
+ expect(() =>
71
+ validateHistoricalFunding(
72
+ [source],
73
+ [first, { ...payout, stripePayoutId: "po_two", created: 45 }],
74
+ ),
75
+ ).toThrow(/funding/i);
76
+ expect(() =>
77
+ validateHistoricalFunding(
78
+ [source],
79
+ [first, { ...payout, stripePayoutId: "po_two", created: 50 }],
80
+ ),
81
+ ).not.toThrow();
82
+ });
83
+ it("cannot use a future positive fee adjustment to fund an older payout", () => {
84
+ expect(() =>
85
+ validateHistoricalFunding(
86
+ [
87
+ {
88
+ ...source,
89
+ transactions: [
90
+ { ...source.transactions[0], net: 900 },
91
+ {
92
+ net: 100,
93
+ created: 40,
94
+ availableOn: 40,
95
+ status: "available",
96
+ },
97
+ ],
98
+ },
99
+ ],
100
+ [payout],
101
+ ),
102
+ ).toThrow(/funding/i);
103
+ });
104
+ it("does not net currencies", () => {
105
+ expect(() =>
106
+ validateHistoricalFunding([{ ...source, currency: "eur" }], [payout]),
107
+ ).toThrow(/currency/i);
108
+ });
109
+ it("requires exact principal allocations", () => {
110
+ expect(() =>
111
+ validateHistoricalFunding([source], [{ ...payout, amount: 999 }]),
112
+ ).toThrow(/principal/i);
113
+ });
114
+ it("rejects duplicate payouts or duplicate source allocations", () => {
115
+ expect(() => validateHistoricalFunding([source], [payout, payout])).toThrow(
116
+ /duplicate/i,
117
+ );
118
+ expect(() =>
119
+ validateHistoricalFunding(
120
+ [source],
121
+ [
122
+ {
123
+ ...payout,
124
+ allocations: [
125
+ { stripeTransferId: "tr_one", amount: 500 },
126
+ { stripeTransferId: "tr_one", amount: 500 },
127
+ ],
128
+ },
129
+ ],
130
+ ),
131
+ ).toThrow(/duplicate/i);
132
+ });