@fantastic.dev/better-stripe 0.20.1 → 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.
- package/CHANGELOG.md +12 -0
- package/README.md +16 -5
- package/dist/client/connect/payoutExecution.d.ts +34 -0
- package/dist/client/connect/payoutExecution.d.ts.map +1 -0
- package/dist/client/connect/payoutExecution.js +189 -0
- package/dist/client/connect/payoutExecution.js.map +1 -0
- package/dist/client/connect/payoutHistory.d.ts +22 -0
- package/dist/client/connect/payoutHistory.d.ts.map +1 -0
- package/dist/client/connect/payoutHistory.js +122 -0
- package/dist/client/connect/payoutHistory.js.map +1 -0
- package/dist/client/connect/payoutReceipt.d.ts +53 -0
- package/dist/client/connect/payoutReceipt.d.ts.map +1 -0
- package/dist/client/connect/payoutReceipt.js +185 -0
- package/dist/client/connect/payoutReceipt.js.map +1 -0
- package/dist/client/connect/payoutReconciliation.d.ts +38 -0
- package/dist/client/connect/payoutReconciliation.d.ts.map +1 -0
- package/dist/client/connect/payoutReconciliation.js +112 -0
- package/dist/client/connect/payoutReconciliation.js.map +1 -0
- package/dist/client/payoutPolicy.d.ts +9 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +12 -0
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/component/_generated/component.d.ts +112 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/earnings/triggers.d.ts +52 -2
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/payoutRelease/allocation.d.ts +1 -0
- package/dist/component/payoutRelease/allocation.d.ts.map +1 -1
- package/dist/component/payoutRelease/allocation.js +3 -0
- package/dist/component/payoutRelease/allocation.js.map +1 -1
- package/dist/component/payoutRelease/fees.d.ts +22 -0
- package/dist/component/payoutRelease/fees.d.ts.map +1 -0
- package/dist/component/payoutRelease/fees.js +73 -0
- package/dist/component/payoutRelease/fees.js.map +1 -0
- package/dist/component/payoutRelease/helpers.d.ts +5 -0
- package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.d.ts +29 -0
- package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.js +24 -1
- package/dist/component/payoutRelease/mutations.js.map +1 -1
- package/dist/component/payoutRelease/queries.d.ts +87 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -1
- package/dist/component/payoutRelease/queries.js +39 -0
- package/dist/component/payoutRelease/queries.js.map +1 -1
- package/dist/component/payoutRelease/schema.d.ts +43 -2
- package/dist/component/payoutRelease/schema.d.ts.map +1 -1
- package/dist/component/payoutRelease/schema.js +12 -0
- package/dist/component/payoutRelease/schema.js.map +1 -1
- package/dist/component/schema.d.ts +27 -1
- package/dist/component/schema.js +2 -1
- package/dist/component/schema.js.map +1 -1
- package/docs/settlement.md +134 -3
- package/package.json +1 -1
- package/src/client/connect/payoutAllocation.test.ts +36 -3
- package/src/client/connect/payoutExecution.test.ts +504 -0
- package/src/client/connect/payoutExecution.ts +265 -0
- package/src/client/connect/payoutHistory.test.ts +145 -0
- package/src/client/connect/payoutHistory.ts +149 -0
- package/src/client/connect/payoutReceipt.test.ts +312 -0
- package/src/client/connect/payoutReceipt.ts +287 -0
- package/src/client/connect/payoutReconciliation.test.ts +398 -0
- package/src/client/connect/payoutReconciliation.ts +178 -0
- package/src/client/payoutPolicy.ts +30 -0
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +118 -0
- package/src/component/payout-fees.test.ts +224 -0
- package/src/component/payout-release-snapshot.test.ts +57 -0
- package/src/component/payoutRelease/allocation.ts +6 -0
- package/src/component/payoutRelease/fees.ts +93 -0
- package/src/component/payoutRelease/mutations.ts +26 -1
- package/src/component/payoutRelease/queries.ts +46 -0
- package/src/component/payoutRelease/schema.ts +13 -0
- package/src/component/schema.ts +2 -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
|
+
}
|