@fantastic.dev/better-stripe 0.21.0 → 0.22.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 (50) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +6 -5
  3. package/dist/client/connect/payoutExecution.d.ts +34 -0
  4. package/dist/client/connect/payoutExecution.d.ts.map +1 -0
  5. package/dist/client/connect/payoutExecution.js +189 -0
  6. package/dist/client/connect/payoutExecution.js.map +1 -0
  7. package/dist/client/connect/payoutHistory.d.ts +22 -0
  8. package/dist/client/connect/payoutHistory.d.ts.map +1 -0
  9. package/dist/client/connect/payoutHistory.js +122 -0
  10. package/dist/client/connect/payoutHistory.js.map +1 -0
  11. package/dist/client/connect/payoutReconciliation.d.ts.map +1 -1
  12. package/dist/client/connect/payoutReconciliation.js +2 -0
  13. package/dist/client/connect/payoutReconciliation.js.map +1 -1
  14. package/dist/client/payoutPolicy.d.ts +3 -0
  15. package/dist/client/payoutPolicy.d.ts.map +1 -1
  16. package/dist/client/payoutPolicy.js +4 -0
  17. package/dist/client/payoutPolicy.js.map +1 -1
  18. package/dist/component/_generated/component.d.ts +73 -0
  19. package/dist/component/_generated/component.d.ts.map +1 -1
  20. package/dist/component/earnings/triggers.d.ts +4 -2
  21. package/dist/component/earnings/triggers.d.ts.map +1 -1
  22. package/dist/component/payoutRelease/helpers.d.ts +1 -0
  23. package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
  24. package/dist/component/payoutRelease/mutations.d.ts +6 -0
  25. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  26. package/dist/component/payoutRelease/mutations.js +7 -1
  27. package/dist/component/payoutRelease/mutations.js.map +1 -1
  28. package/dist/component/payoutRelease/queries.d.ts +71 -0
  29. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  30. package/dist/component/payoutRelease/queries.js +29 -0
  31. package/dist/component/payoutRelease/queries.js.map +1 -1
  32. package/dist/component/payoutRelease/schema.d.ts +7 -2
  33. package/dist/component/payoutRelease/schema.d.ts.map +1 -1
  34. package/dist/component/payoutRelease/schema.js +1 -0
  35. package/dist/component/payoutRelease/schema.js.map +1 -1
  36. package/dist/component/schema.d.ts +3 -1
  37. package/docs/settlement.md +64 -4
  38. package/package.json +1 -1
  39. package/src/client/connect/payoutExecution.test.ts +504 -0
  40. package/src/client/connect/payoutExecution.ts +265 -0
  41. package/src/client/connect/payoutHistory.test.ts +145 -0
  42. package/src/client/connect/payoutHistory.ts +149 -0
  43. package/src/client/connect/payoutReconciliation.test.ts +13 -0
  44. package/src/client/connect/payoutReconciliation.ts +4 -0
  45. package/src/client/payoutPolicy.ts +14 -0
  46. package/src/component/_generated/component.ts +80 -0
  47. package/src/component/payout-release-snapshot.test.ts +57 -0
  48. package/src/component/payoutRelease/mutations.ts +9 -1
  49. package/src/component/payoutRelease/queries.ts +35 -0
  50. package/src/component/payoutRelease/schema.ts +1 -0
@@ -0,0 +1,265 @@
1
+ import type Stripe from "stripe";
2
+ import type { Infer } from "convex/values";
3
+ import type { Component, RunCtx } from "../helpers.js";
4
+ import { runMutationOrThrow } from "../helpers.js";
5
+ import { componentRef } from "../webhooks/helpers.js";
6
+ import type { claimDoc } from "../../component/payoutRelease/schema.js";
7
+ import type { transferDocValidator } from "../../component/connect/validators.js";
8
+ import type { policyDoc } from "../../component/payoutPolicy/schema.js";
9
+ import {
10
+ transferFingerprint,
11
+ validateIdentity,
12
+ } from "../../component/payoutRelease/helpers.js";
13
+ import {
14
+ getTransferFundingReceipt,
15
+ type TransferReceiptTransaction,
16
+ } from "./transferReceipt.js";
17
+ import { verifyPayoutHistory } from "./payoutHistory.js";
18
+ import {
19
+ reconcileRecipientPayout,
20
+ type PayoutReconciliation,
21
+ } from "./payoutReconciliation.js";
22
+ type Claim = Infer<typeof claimDoc>;
23
+ type Policy = Infer<typeof policyDoc>;
24
+ export type PayoutReleaseOptions = {
25
+ accountId: string;
26
+ ownerKey: string;
27
+ operationKey: string;
28
+ currency: string;
29
+ /** Host supplies all authorized off-provider debts/reserves, without double counting provider receipt debits. */
30
+ independentLiabilities: number;
31
+ maxPages?: number;
32
+ };
33
+ type Held = { status: "held"; reason: string };
34
+ export type PayoutReleaseResult = PayoutReconciliation | Held;
35
+ type Snapshot =
36
+ | {
37
+ status: "ready";
38
+ transfers: Infer<typeof transferDocValidator>[];
39
+ claims: Claim[];
40
+ }
41
+ | Held;
42
+ /**
43
+ * Complete receipt qualification followed by an atomic claim and one immutable
44
+ * provider request. Host authorizes the recipient, policy, operation and known
45
+ * off-provider liabilities. Attempted operations only reconcile on subsequent
46
+ * calls; they are never blindly recreated after an unknown provider outcome.
47
+ */
48
+ export async function releaseRecipientPayout(
49
+ stripe: Stripe,
50
+ component: Component,
51
+ ctx: RunCtx,
52
+ opts: PayoutReleaseOptions,
53
+ verify: () => Promise<
54
+ { status: "verified"; policy: Policy } | { status: string }
55
+ >,
56
+ ): Promise<PayoutReleaseResult> {
57
+ validateIdentity(opts);
58
+ if (
59
+ !/^[a-z]{3}$/.test(opts.currency) ||
60
+ !Number.isSafeInteger(opts.independentLiabilities) ||
61
+ opts.independentLiabilities < 0
62
+ )
63
+ throw new Error("Invalid payout currency or independent liabilities.");
64
+ const maxPages = opts.maxPages ?? 10;
65
+ if (!Number.isInteger(maxPages) || maxPages < 1 || maxPages > 100)
66
+ throw new Error("Payout page budget must be between 1 and 100.");
67
+ if (!ctx.runMutation) throw new Error("Payout release requires runMutation.");
68
+ const identity = {
69
+ accountId: opts.accountId,
70
+ ownerKey: opts.ownerKey,
71
+ operationKey: opts.operationKey,
72
+ };
73
+ const existing = (await ctx.runQuery(
74
+ componentRef(component, "payoutRelease/queries/get"),
75
+ identity,
76
+ )) as Claim | null;
77
+ if (existing) {
78
+ if (existing.currency !== opts.currency)
79
+ throw new Error("Payout claim currency cannot be changed.");
80
+ if (existing.attemptedAt !== undefined || existing.status !== "reserved")
81
+ return reconcileRecipientPayout(stripe, component, ctx, {
82
+ ...identity,
83
+ maxPages,
84
+ });
85
+ // A crash before the attempt has no provider side effect. Release its exact
86
+ // reservations; the next scheduled operation must collect fresh receipts.
87
+ const claim = (await runMutationOrThrow(
88
+ ctx,
89
+ componentRef(component, "payoutRelease/mutations/cancelUnattempted"),
90
+ identity,
91
+ )) as Claim;
92
+ return { status: "terminal", claim };
93
+ }
94
+ const controls = await verify();
95
+ if (controls.status !== "verified" || !("policy" in controls))
96
+ return { status: "held", reason: controls.status };
97
+ const snapshot = (await ctx.runQuery(
98
+ componentRef(component, "payoutRelease/queries/snapshot"),
99
+ { accountId: opts.accountId, ownerKey: opts.ownerKey },
100
+ )) as Snapshot;
101
+ if (snapshot.status !== "ready") return snapshot;
102
+ const banks: string[] = [];
103
+ let bankCursor: string | undefined;
104
+ for (let page = 0; ; page++) {
105
+ if (page >= maxPages)
106
+ return { status: "held", reason: "bank_history_incomplete" };
107
+ const batch = await stripe.accounts.listExternalAccounts(opts.accountId, {
108
+ object: "bank_account",
109
+ limit: 100,
110
+ ...(bankCursor ? { starting_after: bankCursor } : {}),
111
+ });
112
+ for (const bank of batch.data)
113
+ if (
114
+ bank.object === "bank_account" &&
115
+ bank.currency === opts.currency &&
116
+ bank.default_for_currency &&
117
+ bank.status !== "errored"
118
+ )
119
+ banks.push(bank.id);
120
+ if (!batch.has_more) break;
121
+ const next = batch.data[batch.data.length - 1]?.id;
122
+ if (!next || next === bankCursor)
123
+ return { status: "held", reason: "bank_history_incomplete" };
124
+ bankCursor = next;
125
+ }
126
+ if (banks.length !== 1 || !/^ba_\w+$/.test(banks[0]))
127
+ return { status: "held", reason: "bank_destination_unavailable" };
128
+ const destinationId = banks[0];
129
+ const transactions: TransferReceiptTransaction[] = [];
130
+ for (const claim of snapshot.claims) {
131
+ if (
132
+ claim.currency !== opts.currency ||
133
+ claim.status === "canceled" ||
134
+ claim.status === "rejected"
135
+ )
136
+ continue;
137
+ if (claim.attemptedAt === undefined)
138
+ return { status: "held", reason: "pending_claim" };
139
+ const result = await reconcileRecipientPayout(stripe, component, ctx, {
140
+ accountId: opts.accountId,
141
+ ownerKey: opts.ownerKey,
142
+ operationKey: claim.operationKey,
143
+ maxPages,
144
+ });
145
+ if (result.status !== "updated") return result;
146
+ for (const t of [result.receipt.debit, result.receipt.credit])
147
+ if (t)
148
+ transactions.push({ ...t, sourceId: result.receipt.stripePayoutId });
149
+ }
150
+ const observations = [];
151
+ for (const row of snapshot.transfers) {
152
+ const receipt = await getTransferFundingReceipt(stripe, component, ctx, {
153
+ accountId: opts.accountId,
154
+ stripeTransferId: row.stripeTransferId,
155
+ });
156
+ if (receipt.status !== "ready")
157
+ return { status: "held", reason: receipt.reason };
158
+ observations.push({
159
+ stripeTransferId: row.stripeTransferId,
160
+ currency: receipt.settlementCurrency,
161
+ matureNet: receipt.matureNet,
162
+ pendingCredits: receipt.pendingCredits,
163
+ ledgerFingerprint: transferFingerprint(row),
164
+ observedAt: receipt.observedAt,
165
+ transactionIds: receipt.transactions.map((t) => t.id),
166
+ });
167
+ if (receipt.settlementCurrency === opts.currency)
168
+ transactions.push(...receipt.transactions);
169
+ }
170
+ const history = await verifyPayoutHistory(stripe, {
171
+ accountId: opts.accountId,
172
+ currency: opts.currency,
173
+ transactions,
174
+ maxPages,
175
+ });
176
+ if (history.status !== "verified") return history;
177
+ const claim = (await runMutationOrThrow(
178
+ ctx,
179
+ componentRef(component, "payoutRelease/mutations/claim"),
180
+ {
181
+ ...identity,
182
+ expectedRevision: controls.policy.revision,
183
+ currency: opts.currency,
184
+ destinationId,
185
+ providerAvailable: history.providerAvailable,
186
+ independentLiabilities: opts.independentLiabilities,
187
+ observations,
188
+ },
189
+ )) as Claim | Held;
190
+ if (claim.status === "held") return claim;
191
+ // Another caller can win this operation while receipts were being collected.
192
+ if (claim.attemptedAt !== undefined || claim.status !== "reserved")
193
+ return reconcileRecipientPayout(stripe, component, ctx, {
194
+ ...identity,
195
+ maxPages,
196
+ });
197
+ const latest = await verify();
198
+ if (
199
+ latest.status !== "verified" ||
200
+ !("policy" in latest) ||
201
+ latest.policy.revision !== controls.policy.revision
202
+ ) {
203
+ await runMutationOrThrow(
204
+ ctx,
205
+ componentRef(component, "payoutRelease/mutations/cancelUnattempted"),
206
+ identity,
207
+ );
208
+ return {
209
+ status: "held",
210
+ reason: latest.status === "verified" ? "policy_changed" : latest.status,
211
+ };
212
+ }
213
+ await runMutationOrThrow(
214
+ ctx,
215
+ componentRef(component, "payoutRelease/mutations/beginAttempt"),
216
+ identity,
217
+ );
218
+ let payout: Stripe.Payout;
219
+ try {
220
+ payout = await stripe.payouts.create(
221
+ {
222
+ amount: claim.amount,
223
+ currency: claim.currency,
224
+ destination: claim.destinationId,
225
+ method: "standard",
226
+ source_type: "card",
227
+ metadata: { bsPayoutClaim: claim.operationKey },
228
+ },
229
+ {
230
+ stripeAccount: claim.accountId,
231
+ idempotencyKey: `bs:payout-release:${claim.operationKey}`,
232
+ },
233
+ );
234
+ } catch (error) {
235
+ if (
236
+ error instanceof Error &&
237
+ "type" in error &&
238
+ error.type === "StripeInvalidRequestError" &&
239
+ "code" in error &&
240
+ ["balance_insufficient", "amount_too_small"].includes(
241
+ String(error.code),
242
+ ) &&
243
+ "requestId" in error &&
244
+ typeof error.requestId === "string" &&
245
+ /^req_\w+$/.test(error.requestId)
246
+ ) {
247
+ const saved = (await runMutationOrThrow(
248
+ ctx,
249
+ componentRef(component, "payoutRelease/mutations/recordRejection"),
250
+ {
251
+ ...identity,
252
+ requestId: error.requestId,
253
+ code: error.code,
254
+ },
255
+ )) as Claim;
256
+ return { status: "terminal", claim: saved };
257
+ }
258
+ throw error;
259
+ }
260
+ return reconcileRecipientPayout(stripe, component, ctx, {
261
+ ...identity,
262
+ stripePayoutId: payout.id,
263
+ maxPages,
264
+ });
265
+ }
@@ -0,0 +1,145 @@
1
+ import { expect, it, vi } from "vitest";
2
+ import type Stripe from "stripe";
3
+ import { verifyPayoutHistory } from "./payoutHistory.js";
4
+ import type { TransferReceiptTransaction } from "./transferReceipt.js";
5
+ const credit: TransferReceiptTransaction = {
6
+ id: "txn_credit",
7
+ sourceId: "py_credit",
8
+ amount: 1000,
9
+ fee: 0,
10
+ net: 1000,
11
+ currency: "usd",
12
+ created: 10,
13
+ availableOn: 20,
14
+ status: "available" as const,
15
+ type: "payment",
16
+ };
17
+ const raw = (t = credit) => ({
18
+ ...t,
19
+ source: t.sourceId,
20
+ available_on: t.availableOn,
21
+ });
22
+ function setup(rows: unknown[] = [raw()]) {
23
+ const list = vi.fn(async () => ({ data: rows, has_more: false }));
24
+ const balance = vi.fn(async () => ({
25
+ available: [
26
+ { currency: "usd", amount: 1000, source_types: { card: 1000 } },
27
+ ],
28
+ pending: [],
29
+ }));
30
+ const stripe = {
31
+ balanceTransactions: { list },
32
+ balance: { retrieve: balance },
33
+ } as unknown as Stripe;
34
+ const read = (transactions = [credit], maxPages = 10) =>
35
+ verifyPayoutHistory(stripe, {
36
+ accountId: "acct_one",
37
+ currency: "usd",
38
+ transactions,
39
+ maxPages,
40
+ });
41
+ return { list, balance, read };
42
+ }
43
+ it("verifies complete exact known history and card-specific available cash", async () => {
44
+ const s = setup();
45
+ expect(await s.read()).toMatchObject({
46
+ status: "verified",
47
+ providerAvailable: 1000,
48
+ });
49
+ expect(s.list).toHaveBeenCalledWith(
50
+ { currency: "usd", limit: 100 },
51
+ { stripeAccount: "acct_one" },
52
+ );
53
+ });
54
+ it.each(["payout", "stripe_fee", "payment_refund", "payment"])(
55
+ "holds an unallocated %s instead of treating received credits as unpaid",
56
+ async (type) => {
57
+ const s = setup([
58
+ raw(),
59
+ { ...raw(), id: "txn_unknown", type, amount: -100, net: -100 },
60
+ ]);
61
+ expect(await s.read()).toEqual({
62
+ status: "held",
63
+ reason: "unallocated_history",
64
+ });
65
+ },
66
+ );
67
+ it("rejects reused receipt identities", async () => {
68
+ await expect(setup().read([credit, credit])).rejects.toThrow(/duplicate/i);
69
+ });
70
+ it("holds missing expected history", async () => {
71
+ expect(await setup([]).read()).toEqual({
72
+ status: "held",
73
+ reason: "history_changed",
74
+ });
75
+ });
76
+ it.each(["amount", "fee", "net", "created", "available_on"])(
77
+ "holds changed receipt %s",
78
+ async (field) => {
79
+ expect(await setup([{ ...raw(), [field]: 99 }]).read()).toEqual({
80
+ status: "held",
81
+ reason: "history_changed",
82
+ });
83
+ },
84
+ );
85
+ it("holds partial pages", async () => {
86
+ const s = setup();
87
+ s.list.mockResolvedValue({ data: [raw()], has_more: true });
88
+ expect(await s.read([credit], 1)).toEqual({
89
+ status: "held",
90
+ reason: "history_incomplete",
91
+ });
92
+ });
93
+ it("holds nonadvancing or duplicated history", async () => {
94
+ const s = setup([raw(), raw()]);
95
+ expect(await s.read()).toEqual({
96
+ status: "held",
97
+ reason: "history_incomplete",
98
+ });
99
+ });
100
+ it("holds a changed provider head", async () => {
101
+ const s = setup();
102
+ s.list
103
+ .mockResolvedValueOnce({ data: [raw()], has_more: false })
104
+ .mockResolvedValue({ data: [], has_more: false });
105
+ expect(await s.read()).toEqual({ status: "held", reason: "history_changed" });
106
+ });
107
+ it("holds a balance that does not reconcile to complete history", async () => {
108
+ const s = setup();
109
+ s.balance.mockResolvedValue({
110
+ available: [{ currency: "usd", amount: 900, source_types: { card: 900 } }],
111
+ pending: [],
112
+ });
113
+ expect(await s.read()).toEqual({ status: "held", reason: "balance_changed" });
114
+ });
115
+ it("never counts pending source money as available even when available_on has passed", async () => {
116
+ const pending = { ...credit, status: "pending" as const };
117
+ const s = setup([raw(pending)]);
118
+ s.balance.mockResolvedValue({
119
+ available: [],
120
+ pending: [{ currency: "usd", amount: 1000 }],
121
+ } as never);
122
+ expect(await s.read([pending] as (typeof credit)[])).toMatchObject({
123
+ status: "verified",
124
+ providerAvailable: 0,
125
+ });
126
+ });
127
+ it("uses card source availability rather than unrelated rails", async () => {
128
+ const s = setup();
129
+ s.balance.mockResolvedValue({
130
+ available: [{ currency: "usd", amount: 1000, source_types: { card: 100 } }],
131
+ pending: [],
132
+ });
133
+ expect(await s.read()).toMatchObject({
134
+ status: "verified",
135
+ providerAvailable: 100,
136
+ });
137
+ });
138
+ it.each([0, 101, 1.5])(
139
+ "rejects invalid page budget %s before provider I/O",
140
+ async (max) => {
141
+ const s = setup();
142
+ await expect(s.read([credit], max)).rejects.toThrow(/budget/i);
143
+ expect(s.list).not.toHaveBeenCalled();
144
+ },
145
+ );
@@ -0,0 +1,149 @@
1
+ import type Stripe from "stripe";
2
+ import type { TransferReceiptTransaction } from "./transferReceipt.js";
3
+ import { addSummaryAmounts as add } from "../../component/earnings/arithmetic.js";
4
+ export type PayoutHistoryVerification =
5
+ | { status: "verified"; providerAvailable: number; observedAt: number }
6
+ | {
7
+ status: "held";
8
+ reason:
9
+ | "unallocated_history"
10
+ | "history_changed"
11
+ | "history_incomplete"
12
+ | "balance_changed";
13
+ };
14
+ function fingerprint(t: TransferReceiptTransaction) {
15
+ return JSON.stringify([
16
+ t.id,
17
+ t.sourceId,
18
+ t.amount,
19
+ t.fee,
20
+ t.net,
21
+ t.currency,
22
+ t.created,
23
+ t.availableOn,
24
+ t.status,
25
+ t.type,
26
+ ]);
27
+ }
28
+ function normalize(t: Stripe.BalanceTransaction): TransferReceiptTransaction {
29
+ if (t.status !== "available" && t.status !== "pending")
30
+ throw new Error("Invalid provider receipt status.");
31
+ return {
32
+ id: t.id,
33
+ sourceId: typeof t.source === "string" ? t.source : (t.source?.id ?? ""),
34
+ amount: t.amount,
35
+ fee: t.fee,
36
+ net: t.net,
37
+ currency: t.currency,
38
+ created: t.created,
39
+ availableOn: t.available_on,
40
+ status: t.status,
41
+ type: t.type,
42
+ };
43
+ }
44
+ /**
45
+ * Verify a complete currency history against independently verified transfer and
46
+ * payout receipts. Unknown debits AND credits require explicit allocation. A
47
+ * current balance alone never establishes still-unpaid source ownership.
48
+ */
49
+ export async function verifyPayoutHistory(
50
+ stripe: Stripe,
51
+ opts: {
52
+ accountId: string;
53
+ currency: string;
54
+ transactions: TransferReceiptTransaction[];
55
+ maxPages?: number;
56
+ },
57
+ ): Promise<PayoutHistoryVerification> {
58
+ const maxPages = opts.maxPages ?? 10;
59
+ if (!Number.isInteger(maxPages) || maxPages < 1 || maxPages > 100)
60
+ throw new Error("History page budget must be between 1 and 100.");
61
+ if (!/^acct_\w+$/.test(opts.accountId) || !/^[a-z]{3}$/.test(opts.currency))
62
+ throw new Error("Invalid payout history scope.");
63
+ const expected = new Map<string, string>();
64
+ for (const t of opts.transactions) {
65
+ if (expected.has(t.id))
66
+ throw new Error("Duplicate payout history receipt.");
67
+ if (
68
+ t.currency !== opts.currency ||
69
+ !/^txn_\w+$/.test(t.id) ||
70
+ !t.sourceId ||
71
+ [t.amount, t.fee, t.net, t.created, t.availableOn].some(
72
+ (n) => !Number.isSafeInteger(n),
73
+ ) ||
74
+ t.net !== add(t.amount, -t.fee)
75
+ )
76
+ throw new Error("Invalid payout history receipt.");
77
+ expected.set(t.id, fingerprint(t));
78
+ }
79
+ const scope = { stripeAccount: opts.accountId };
80
+ const seen = new Set<string>();
81
+ let cursor: string | undefined, head: string | undefined;
82
+ let available = 0,
83
+ pending = 0,
84
+ complete = false;
85
+ const held = (
86
+ reason: Extract<PayoutHistoryVerification, { status: "held" }>["reason"],
87
+ ): PayoutHistoryVerification => ({ status: "held", reason });
88
+ for (let page = 0; page < maxPages; page++) {
89
+ const batch = await stripe.balanceTransactions.list(
90
+ {
91
+ currency: opts.currency,
92
+ limit: 100,
93
+ ...(cursor ? { starting_after: cursor } : {}),
94
+ },
95
+ scope,
96
+ );
97
+ if (page === 0 && batch.data[0])
98
+ head = fingerprint(normalize(batch.data[0]));
99
+ for (const raw of batch.data) {
100
+ const t = normalize(raw);
101
+ if (seen.has(t.id)) return held("history_incomplete");
102
+ seen.add(t.id);
103
+ if (!expected.has(t.id)) return held("unallocated_history");
104
+ if (fingerprint(t) !== expected.get(t.id)) return held("history_changed");
105
+ if (t.status === "available") available = add(available, t.net);
106
+ else if (t.status === "pending") pending = add(pending, t.net);
107
+ else return held("history_changed");
108
+ }
109
+ if (!batch.has_more) {
110
+ complete = true;
111
+ break;
112
+ }
113
+ const next = batch.data[batch.data.length - 1]?.id;
114
+ if (!next || next === cursor) return held("history_incomplete");
115
+ cursor = next;
116
+ }
117
+ if (!complete) return held("history_incomplete");
118
+ if (seen.size !== expected.size) return held("history_changed");
119
+ const balance = await stripe.balance.retrieve(undefined, scope);
120
+ const latest = await stripe.balanceTransactions.list(
121
+ { currency: opts.currency, limit: 1 },
122
+ scope,
123
+ );
124
+ const latestHead = latest.data[0]
125
+ ? fingerprint(normalize(latest.data[0]))
126
+ : undefined;
127
+ if (latestHead !== head) return held("history_changed");
128
+ const entries = (rows: Stripe.Balance.Available[]) =>
129
+ rows.filter((t) => t.currency === opts.currency);
130
+ const a = entries(balance.available),
131
+ p = entries(balance.pending);
132
+ if (
133
+ a.length > 1 ||
134
+ p.length > 1 ||
135
+ (a[0]?.amount ?? 0) !== available ||
136
+ (p[0]?.amount ?? 0) !== pending
137
+ )
138
+ return held("balance_changed");
139
+ // The durable payout request explicitly draws from card balance. Other rails
140
+ // cannot increase this cap even if they contribute to aggregate availability.
141
+ const card = a[0]?.source_types?.card ?? 0;
142
+ if (!Number.isSafeInteger(card))
143
+ throw new Error("Invalid provider card balance.");
144
+ return {
145
+ status: "verified",
146
+ providerAvailable: Math.min(available, card),
147
+ observedAt: Date.now(),
148
+ };
149
+ }
@@ -383,3 +383,16 @@ it("serializes concurrent outcome reconciliation without duplicate fees or princ
383
383
  ).toMatchObject([{ amount: 30, revision: 1 }]);
384
384
  expect(provider.payouts.create).not.toHaveBeenCalled();
385
385
  });
386
+ it("rejects a payout sent to a different bank than the immutable claim", async () => {
387
+ const s = await setup();
388
+ await s.t.run(async (ctx) => {
389
+ const claim = await ctx.db.query("payoutReleaseClaims").first();
390
+ await ctx.db.patch("payoutReleaseClaims", claim!._id, {
391
+ destinationId: "ba_expected",
392
+ });
393
+ });
394
+ await expect(s.reconcile()).rejects.toThrow(/bank destination/i);
395
+ expect(
396
+ await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
397
+ ).toMatchObject([{ paidAmount: 0, reservedAmount: 1000 }]);
398
+ });
@@ -136,6 +136,10 @@ export async function reconcileRecipientPayout(
136
136
  });
137
137
  if (receipt.status !== "ready")
138
138
  return { status: "held", reason: receipt.reason };
139
+ if (claim.destinationId && receipt.destinationId !== claim.destinationId)
140
+ throw new Error(
141
+ "Payout receipt bank destination does not match the durable claim.",
142
+ );
139
143
  if (receipt.amount !== claim.amount || receipt.currency !== claim.currency)
140
144
  throw new Error("Payout receipt money does not match the durable claim.");
141
145
  if (
@@ -1,3 +1,8 @@
1
+ import {
2
+ releaseRecipientPayout,
3
+ type PayoutReleaseOptions,
4
+ } from "./connect/payoutExecution.js";
5
+ export type * from "./connect/payoutExecution.js";
1
6
  import {
2
7
  reconcileRecipientPayout,
3
8
  type PayoutReconciliationOptions,
@@ -85,6 +90,15 @@ export class PayoutPolicyClient {
85
90
  private readonly component: Component,
86
91
  private readonly provider: () => Stripe,
87
92
  ) {}
93
+ release(ctx: RunCtx, opts: PayoutReleaseOptions) {
94
+ return releaseRecipientPayout(
95
+ this.provider(),
96
+ this.component,
97
+ ctx,
98
+ opts,
99
+ () => this.verify(ctx, opts),
100
+ );
101
+ }
88
102
  reconcilePayout(ctx: RunCtx, opts: PayoutReconciliationOptions) {
89
103
  return reconcileRecipientPayout(this.provider(), this.component, ctx, opts);
90
104
  }