@fantastic.dev/better-stripe 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +8 -5
- package/dist/client/connect/payoutActivationProof.d.ts +16 -0
- package/dist/client/connect/payoutActivationProof.d.ts.map +1 -0
- package/dist/client/connect/payoutActivationProof.js +72 -0
- package/dist/client/connect/payoutActivationProof.js.map +1 -0
- package/dist/client/connect/payoutAdoption.d.ts +35 -0
- package/dist/client/connect/payoutAdoption.d.ts.map +1 -0
- package/dist/client/connect/payoutAdoption.js +230 -0
- package/dist/client/connect/payoutAdoption.js.map +1 -0
- package/dist/client/connect/payoutAdoptionAge.d.ts +19 -0
- package/dist/client/connect/payoutAdoptionAge.d.ts.map +1 -0
- package/dist/client/connect/payoutAdoptionAge.js +56 -0
- package/dist/client/connect/payoutAdoptionAge.js.map +1 -0
- package/dist/client/connect/payoutAdoptionAllocation.d.ts +24 -0
- package/dist/client/connect/payoutAdoptionAllocation.d.ts.map +1 -0
- package/dist/client/connect/payoutAdoptionAllocation.js +78 -0
- package/dist/client/connect/payoutAdoptionAllocation.js.map +1 -0
- 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/payoutReconciliation.d.ts.map +1 -1
- package/dist/client/connect/payoutReconciliation.js +6 -2
- package/dist/client/connect/payoutReconciliation.js.map +1 -1
- package/dist/client/payoutPolicy.d.ts +10 -1
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +82 -7
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/component/_generated/component.d.ts +269 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/earnings/triggers.d.ts +40 -4
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/payoutPolicy/adoption.d.ts +9 -0
- package/dist/component/payoutPolicy/adoption.d.ts.map +1 -0
- package/dist/component/payoutPolicy/adoption.js +45 -0
- package/dist/component/payoutPolicy/adoption.js.map +1 -0
- package/dist/component/payoutPolicy/mutations.d.ts +96 -0
- package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
- package/dist/component/payoutPolicy/mutations.js +38 -5
- package/dist/component/payoutPolicy/mutations.js.map +1 -1
- package/dist/component/payoutPolicy/queries.d.ts +16 -0
- package/dist/component/payoutPolicy/queries.d.ts.map +1 -1
- package/dist/component/payoutPolicy/schema.d.ts +299 -2
- package/dist/component/payoutPolicy/schema.d.ts.map +1 -1
- package/dist/component/payoutPolicy/schema.js +7 -0
- package/dist/component/payoutPolicy/schema.js.map +1 -1
- package/dist/component/payoutRelease/adoption.d.ts +269 -0
- package/dist/component/payoutRelease/adoption.d.ts.map +1 -0
- package/dist/component/payoutRelease/adoption.js +197 -0
- package/dist/component/payoutRelease/adoption.js.map +1 -0
- package/dist/component/payoutRelease/helpers.d.ts +4 -0
- package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
- package/dist/component/payoutRelease/helpers.js +20 -0
- package/dist/component/payoutRelease/helpers.js.map +1 -1
- package/dist/component/payoutRelease/mutations.d.ts +87 -0
- package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.js +13 -1
- package/dist/component/payoutRelease/mutations.js.map +1 -1
- package/dist/component/payoutRelease/observation.d.ts +18 -0
- package/dist/component/payoutRelease/observation.d.ts.map +1 -0
- package/dist/component/payoutRelease/observation.js +11 -0
- package/dist/component/payoutRelease/observation.js.map +1 -0
- package/dist/component/payoutRelease/queries.d.ts +74 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -1
- package/dist/component/payoutRelease/queries.js +29 -0
- package/dist/component/payoutRelease/queries.js.map +1 -1
- package/dist/component/payoutRelease/schema.d.ts +13 -19
- package/dist/component/payoutRelease/schema.d.ts.map +1 -1
- package/dist/component/payoutRelease/schema.js +4 -9
- package/dist/component/payoutRelease/schema.js.map +1 -1
- package/dist/component/schema.d.ts +75 -2
- package/docs/settlement.md +181 -40
- package/package.json +1 -1
- package/src/client/connect/payoutActivationProof.ts +101 -0
- package/src/client/connect/payoutAdoption.test.ts +421 -0
- package/src/client/connect/payoutAdoption.ts +330 -0
- package/src/client/connect/payoutAdoptionAge.test.ts +89 -0
- package/src/client/connect/payoutAdoptionAge.ts +74 -0
- package/src/client/connect/payoutAdoptionAllocation.test.ts +132 -0
- package/src/client/connect/payoutAdoptionAllocation.ts +106 -0
- 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/payoutReconciliation.test.ts +29 -0
- package/src/client/connect/payoutReconciliation.ts +10 -2
- package/src/client/payoutPolicy.test.ts +16 -0
- package/src/client/payoutPolicy.ts +121 -5
- package/src/component/_generated/api.ts +6 -0
- package/src/component/_generated/component.ts +272 -1
- package/src/component/payout-adoption.test.ts +272 -0
- package/src/component/payout-policy-adoption.test.ts +131 -0
- package/src/component/payout-release-snapshot.test.ts +57 -0
- package/src/component/payoutPolicy/adoption.ts +76 -0
- package/src/component/payoutPolicy/mutations.ts +46 -3
- package/src/component/payoutPolicy/schema.ts +9 -0
- package/src/component/payoutRelease/adoption.ts +262 -0
- package/src/component/payoutRelease/helpers.ts +21 -0
- package/src/component/payoutRelease/mutations.ts +19 -1
- package/src/component/payoutRelease/observation.ts +10 -0
- package/src/component/payoutRelease/queries.ts +35 -0
- package/src/component/payoutRelease/schema.ts +4 -9
|
@@ -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,32 @@ 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
|
+
});
|
|
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,10 +132,14 @@ 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 };
|
|
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 (
|
|
@@ -147,7 +151,11 @@ export async function reconcileRecipientPayout(
|
|
|
147
151
|
);
|
|
148
152
|
// Current claims use the default card source balance. Other rails need a
|
|
149
153
|
// persisted allocation contract before a provider receipt can be adopted.
|
|
150
|
-
if (
|
|
154
|
+
if (
|
|
155
|
+
receipt.sourceType !== "card" ||
|
|
156
|
+
(claim.historical &&
|
|
157
|
+
(receipt.method !== "standard" || receipt.type !== "bank_account"))
|
|
158
|
+
)
|
|
151
159
|
return { status: "held", reason: "source_allocation_required" };
|
|
152
160
|
const fee = addSummaryAmounts(receipt.debit.fee, receipt.credit?.fee ?? 0);
|
|
153
161
|
if (fee < 0) return { status: "held", reason: "fee_allocation_required" };
|
|
@@ -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
|
+
});
|