@fantastic.dev/better-stripe 0.20.1 → 0.21.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 +6 -0
- package/README.md +11 -1
- 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 +110 -0
- package/dist/client/connect/payoutReconciliation.js.map +1 -0
- package/dist/client/payoutPolicy.d.ts +6 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +8 -0
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/component/_generated/component.d.ts +39 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/earnings/triggers.d.ts +50 -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 +4 -0
- package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.d.ts +23 -0
- package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
- package/dist/component/payoutRelease/mutations.js +17 -0
- package/dist/component/payoutRelease/mutations.js.map +1 -1
- package/dist/component/payoutRelease/queries.d.ts +16 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -1
- package/dist/component/payoutRelease/queries.js +10 -0
- package/dist/component/payoutRelease/queries.js.map +1 -1
- package/dist/component/payoutRelease/schema.d.ts +38 -2
- package/dist/component/payoutRelease/schema.d.ts.map +1 -1
- package/dist/component/payoutRelease/schema.js +11 -0
- package/dist/component/payoutRelease/schema.js.map +1 -1
- package/dist/component/schema.d.ts +25 -1
- package/dist/component/schema.js +2 -1
- package/dist/component/schema.js.map +1 -1
- package/docs/settlement.md +74 -3
- package/package.json +1 -1
- package/src/client/connect/payoutAllocation.test.ts +36 -3
- package/src/client/connect/payoutReceipt.test.ts +312 -0
- package/src/client/connect/payoutReceipt.ts +287 -0
- package/src/client/connect/payoutReconciliation.test.ts +385 -0
- package/src/client/connect/payoutReconciliation.ts +174 -0
- package/src/client/payoutPolicy.ts +16 -0
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +38 -0
- package/src/component/payout-fees.test.ts +224 -0
- package/src/component/payoutRelease/allocation.ts +6 -0
- package/src/component/payoutRelease/fees.ts +93 -0
- package/src/component/payoutRelease/mutations.ts +17 -0
- package/src/component/payoutRelease/queries.ts +11 -0
- package/src/component/payoutRelease/schema.ts +12 -0
- package/src/component/schema.ts +2 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import { convexTest } from "convex-test";
|
|
3
|
+
import { makeFunctionReference } from "convex/server";
|
|
4
|
+
import { expect, it, vi } from "vitest";
|
|
5
|
+
import type Stripe from "stripe";
|
|
6
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
7
|
+
import schema from "../../component/schema.js";
|
|
8
|
+
import { transferFingerprint } from "../../component/payoutRelease/helpers.js";
|
|
9
|
+
import { reconcileRecipientPayout } from "./payoutReconciliation.js";
|
|
10
|
+
const modules = import.meta.glob("../../component/**/*.*s");
|
|
11
|
+
const scope = {
|
|
12
|
+
accountId: "acct_recipient",
|
|
13
|
+
ownerKey: "user_1",
|
|
14
|
+
operationKey: "release-one",
|
|
15
|
+
};
|
|
16
|
+
const symbol = Symbol.for("toReferencePath");
|
|
17
|
+
const component = {
|
|
18
|
+
payoutPolicy: {
|
|
19
|
+
queries: { get: { [symbol]: "betterStripe/payoutPolicy/queries/get" } },
|
|
20
|
+
},
|
|
21
|
+
} as unknown as Component;
|
|
22
|
+
const m = (name: string) =>
|
|
23
|
+
makeFunctionReference<"mutation">(`payoutRelease/mutations:${name}`);
|
|
24
|
+
async function setup(attempt = true) {
|
|
25
|
+
const t = convexTest(schema, modules);
|
|
26
|
+
const identity = { accountId: scope.accountId, ownerKey: scope.ownerKey };
|
|
27
|
+
await t.mutation(
|
|
28
|
+
makeFunctionReference<"mutation">("payoutPolicy/mutations:prepare"),
|
|
29
|
+
{
|
|
30
|
+
...identity,
|
|
31
|
+
operationKey: "enable",
|
|
32
|
+
expectedRevision: -1,
|
|
33
|
+
mode: "custom",
|
|
34
|
+
thresholds: [{ currency: "usd", amount: 1000 }],
|
|
35
|
+
country: "US",
|
|
36
|
+
observedSchedule: { interval: "daily" },
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
await t.mutation(
|
|
40
|
+
makeFunctionReference<"mutation">("payoutPolicy/mutations:confirm"),
|
|
41
|
+
{
|
|
42
|
+
...identity,
|
|
43
|
+
operationKey: "enable",
|
|
44
|
+
observedSchedule: { interval: "manual" },
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
await t.mutation(
|
|
48
|
+
makeFunctionReference<"mutation">("connect/mutations:upsertTransfer"),
|
|
49
|
+
{
|
|
50
|
+
stripeTransferId: "tr_one",
|
|
51
|
+
destinationAccountId: scope.accountId,
|
|
52
|
+
currency: "usd",
|
|
53
|
+
amount: 1500,
|
|
54
|
+
role: "affiliate",
|
|
55
|
+
status: "paid",
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
const row = (await t.run((ctx) => ctx.db.query("transfers").take(1)))[0];
|
|
59
|
+
const args = {
|
|
60
|
+
...scope,
|
|
61
|
+
expectedRevision: 0,
|
|
62
|
+
currency: "usd",
|
|
63
|
+
providerAvailable: 1000,
|
|
64
|
+
independentLiabilities: 0,
|
|
65
|
+
observations: [
|
|
66
|
+
{
|
|
67
|
+
stripeTransferId: "tr_one",
|
|
68
|
+
currency: "usd",
|
|
69
|
+
matureNet: 1500,
|
|
70
|
+
pendingCredits: 0,
|
|
71
|
+
ledgerFingerprint: transferFingerprint(row),
|
|
72
|
+
observedAt: Date.now(),
|
|
73
|
+
transactionIds: ["txn_funding"],
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
await t.mutation(m("claim"), args);
|
|
78
|
+
if (attempt) await t.mutation(m("beginAttempt"), scope);
|
|
79
|
+
const path = (ref: Record<symbol, string>) =>
|
|
80
|
+
ref[symbol].replace("betterStripe/", "").replace(/\/(\w+)$/, ":$1");
|
|
81
|
+
const ctx = {
|
|
82
|
+
runQuery: (ref: Record<symbol, string>, args: never) =>
|
|
83
|
+
t.query(makeFunctionReference<"query">(path(ref)), args),
|
|
84
|
+
runMutation: (ref: Record<symbol, string>, args: never) =>
|
|
85
|
+
t.mutation(makeFunctionReference<"mutation">(path(ref)), args),
|
|
86
|
+
} as unknown as RunCtx;
|
|
87
|
+
const now = Math.floor(Date.now() / 1000);
|
|
88
|
+
let payout = {
|
|
89
|
+
id: "po_one",
|
|
90
|
+
amount: 1000,
|
|
91
|
+
currency: "usd",
|
|
92
|
+
status: "paid",
|
|
93
|
+
automatic: false,
|
|
94
|
+
method: "standard",
|
|
95
|
+
type: "bank_account",
|
|
96
|
+
source_type: "card",
|
|
97
|
+
destination: "ba_one",
|
|
98
|
+
created: now,
|
|
99
|
+
arrival_date: now + 86400,
|
|
100
|
+
balance_transaction: "txn_debit",
|
|
101
|
+
failure_balance_transaction: null as string | null,
|
|
102
|
+
reversed_by: null,
|
|
103
|
+
metadata: { bsPayoutClaim: scope.operationKey },
|
|
104
|
+
};
|
|
105
|
+
const provider = {
|
|
106
|
+
payouts: {
|
|
107
|
+
create: vi.fn(),
|
|
108
|
+
retrieve: vi.fn(async () => payout),
|
|
109
|
+
list: vi.fn(async () => ({ data: [payout], has_more: false })),
|
|
110
|
+
},
|
|
111
|
+
balanceTransactions: {
|
|
112
|
+
retrieve: vi.fn(async (id: string) => ({
|
|
113
|
+
id,
|
|
114
|
+
source: "po_one",
|
|
115
|
+
amount: id === "txn_debit" ? -1000 : 1000,
|
|
116
|
+
fee: id === "txn_debit" ? 30 : -10,
|
|
117
|
+
net: id === "txn_debit" ? -1030 : 1010,
|
|
118
|
+
currency: "usd",
|
|
119
|
+
status: "available",
|
|
120
|
+
created: now,
|
|
121
|
+
available_on: now,
|
|
122
|
+
type: id === "txn_debit" ? "payout" : "payout_failure",
|
|
123
|
+
})),
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
const reconcile = (options = {}) =>
|
|
127
|
+
reconcileRecipientPayout(provider as unknown as Stripe, component, ctx, {
|
|
128
|
+
...scope,
|
|
129
|
+
...options,
|
|
130
|
+
});
|
|
131
|
+
return {
|
|
132
|
+
t,
|
|
133
|
+
ctx,
|
|
134
|
+
args,
|
|
135
|
+
provider,
|
|
136
|
+
reconcile,
|
|
137
|
+
setPayout: (changed: Partial<typeof payout>) => {
|
|
138
|
+
payout = { ...payout, ...changed };
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
it("discovers an exact attempted payout after a timeout and records actual principal and fees once", async () => {
|
|
143
|
+
const { t, provider, reconcile } = await setup();
|
|
144
|
+
expect(await reconcile()).toMatchObject({
|
|
145
|
+
status: "updated",
|
|
146
|
+
claim: { status: "paid", stripePayoutId: "po_one", providerFee: 30 },
|
|
147
|
+
});
|
|
148
|
+
expect(await reconcile()).toMatchObject({
|
|
149
|
+
status: "updated",
|
|
150
|
+
claim: { status: "paid", providerFee: 30 },
|
|
151
|
+
});
|
|
152
|
+
expect(provider.payouts.create).not.toHaveBeenCalled();
|
|
153
|
+
expect(
|
|
154
|
+
await t.run((ctx) => ctx.db.query("payoutSourceAllocations").take(1)),
|
|
155
|
+
).toMatchObject([{ paidAmount: 1000, reservedAmount: 0 }]);
|
|
156
|
+
});
|
|
157
|
+
it("keeps unknown attempts held when no payout can be identified, including after idempotency expiry", async () => {
|
|
158
|
+
const { t, provider, reconcile } = await setup();
|
|
159
|
+
provider.payouts.list.mockResolvedValue({ data: [], has_more: false });
|
|
160
|
+
await t.run(async (ctx) => {
|
|
161
|
+
const row = (await ctx.db.query("payoutReleaseClaims").take(1))[0];
|
|
162
|
+
await ctx.db.patch("payoutReleaseClaims", row._id, {
|
|
163
|
+
attemptedAt: Date.now() - 2 * 86400000,
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
expect(await reconcile()).toMatchObject({
|
|
167
|
+
status: "held",
|
|
168
|
+
reason: "payout_unidentified",
|
|
169
|
+
});
|
|
170
|
+
expect(provider.payouts.create).not.toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
it("does not adopt a partial scan, duplicate matches, or a changing provider history", async () => {
|
|
173
|
+
const { provider, reconcile } = await setup();
|
|
174
|
+
const first = await provider.payouts.retrieve();
|
|
175
|
+
provider.payouts.list.mockResolvedValue({ data: [first], has_more: true });
|
|
176
|
+
expect(await reconcile({ maxPages: 1 })).toMatchObject({
|
|
177
|
+
status: "held",
|
|
178
|
+
reason: "history_incomplete",
|
|
179
|
+
});
|
|
180
|
+
provider.payouts.list.mockResolvedValue({
|
|
181
|
+
data: [first, { ...first, id: "po_other" }],
|
|
182
|
+
has_more: false,
|
|
183
|
+
});
|
|
184
|
+
expect(await reconcile()).toMatchObject({
|
|
185
|
+
status: "held",
|
|
186
|
+
reason: "multiple_matches",
|
|
187
|
+
});
|
|
188
|
+
provider.payouts.list
|
|
189
|
+
.mockResolvedValueOnce({ data: [first], has_more: false })
|
|
190
|
+
.mockResolvedValueOnce({
|
|
191
|
+
data: [{ ...first, id: "po_new" }],
|
|
192
|
+
has_more: false,
|
|
193
|
+
});
|
|
194
|
+
expect(await reconcile()).toMatchObject({
|
|
195
|
+
status: "held",
|
|
196
|
+
reason: "provider_changed",
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
it("follows provider cursors before adopting the only matching payout", async () => {
|
|
200
|
+
const { provider, reconcile } = await setup();
|
|
201
|
+
const first = await provider.payouts.retrieve();
|
|
202
|
+
const unrelated = {
|
|
203
|
+
...first,
|
|
204
|
+
id: "po_unrelated",
|
|
205
|
+
metadata: { bsPayoutClaim: "different" },
|
|
206
|
+
};
|
|
207
|
+
provider.payouts.list
|
|
208
|
+
.mockResolvedValueOnce({ data: [unrelated], has_more: true })
|
|
209
|
+
.mockResolvedValueOnce({ data: [first], has_more: false })
|
|
210
|
+
.mockResolvedValueOnce({ data: [unrelated], has_more: false });
|
|
211
|
+
expect(await reconcile()).toMatchObject({
|
|
212
|
+
status: "updated",
|
|
213
|
+
claim: { status: "paid" },
|
|
214
|
+
});
|
|
215
|
+
expect(provider.payouts.list.mock.calls[1]).toEqual([
|
|
216
|
+
{ limit: 100, created: expect.any(Object), starting_after: "po_unrelated" },
|
|
217
|
+
{ stripeAccount: scope.accountId },
|
|
218
|
+
]);
|
|
219
|
+
});
|
|
220
|
+
it("keeps failed payouts reserved without a mature return receipt", async () => {
|
|
221
|
+
const { reconcile, setPayout } = await setup();
|
|
222
|
+
setPayout({ status: "failed" });
|
|
223
|
+
expect(await reconcile({ stripePayoutId: "po_one" })).toMatchObject({
|
|
224
|
+
status: "held",
|
|
225
|
+
reason: "return_unavailable",
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
it("reconciles a paid payout that later fails and returns, preserving non-refundable fees", async () => {
|
|
229
|
+
const { t, args, reconcile, setPayout } = await setup();
|
|
230
|
+
await reconcile({ stripePayoutId: "po_one" });
|
|
231
|
+
setPayout({ status: "failed", failure_balance_transaction: "txn_return" });
|
|
232
|
+
expect(await reconcile()).toMatchObject({
|
|
233
|
+
status: "updated",
|
|
234
|
+
claim: { status: "returned", providerFee: 20 },
|
|
235
|
+
});
|
|
236
|
+
expect(await reconcile()).toMatchObject({
|
|
237
|
+
status: "updated",
|
|
238
|
+
claim: { status: "returned", providerFee: 20 },
|
|
239
|
+
});
|
|
240
|
+
expect(
|
|
241
|
+
await t.mutation(m("claim"), {
|
|
242
|
+
...args,
|
|
243
|
+
operationKey: "next",
|
|
244
|
+
providerAvailable: 1500,
|
|
245
|
+
}),
|
|
246
|
+
).toMatchObject({ status: "reserved", amount: 1480 });
|
|
247
|
+
});
|
|
248
|
+
it("rejects foreign owner, changed payout identity, money mismatch, missing markers, and old historical payouts", async () => {
|
|
249
|
+
for (const change of [
|
|
250
|
+
{ amount: 999 },
|
|
251
|
+
{ currency: "eur" },
|
|
252
|
+
{ metadata: { bsPayoutClaim: "wrong" } },
|
|
253
|
+
{ created: 1 },
|
|
254
|
+
]) {
|
|
255
|
+
const { provider, reconcile, setPayout } = await setup();
|
|
256
|
+
setPayout(change);
|
|
257
|
+
if ("currency" in change || "amount" in change) {
|
|
258
|
+
const original =
|
|
259
|
+
provider.balanceTransactions.retrieve.getMockImplementation()!;
|
|
260
|
+
provider.balanceTransactions.retrieve.mockImplementation(async (id) => {
|
|
261
|
+
const transaction = await original(id);
|
|
262
|
+
const amount =
|
|
263
|
+
"amount" in change ? -change.amount! : transaction.amount;
|
|
264
|
+
return {
|
|
265
|
+
...transaction,
|
|
266
|
+
amount,
|
|
267
|
+
net: amount - transaction.fee,
|
|
268
|
+
currency:
|
|
269
|
+
"currency" in change ? change.currency! : transaction.currency,
|
|
270
|
+
};
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
await expect(reconcile({ stripePayoutId: "po_one" })).rejects.toThrow(
|
|
274
|
+
/claim|receipt|attempt/,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const { reconcile } = await setup();
|
|
278
|
+
await reconcile({ stripePayoutId: "po_one" });
|
|
279
|
+
await expect(reconcile({ stripePayoutId: "po_other" })).rejects.toThrow(
|
|
280
|
+
/identity/,
|
|
281
|
+
);
|
|
282
|
+
await expect(reconcile({ ownerKey: "other" })).rejects.toThrow(
|
|
283
|
+
/owner|identity/,
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
it("keeps over-refunded fees explicitly unallocated", async () => {
|
|
287
|
+
const { provider, reconcile, setPayout } = await setup();
|
|
288
|
+
setPayout({ status: "failed", failure_balance_transaction: "txn_return" });
|
|
289
|
+
provider.balanceTransactions.retrieve.mockImplementation(async (id) => ({
|
|
290
|
+
id,
|
|
291
|
+
source: "po_one",
|
|
292
|
+
amount: id === "txn_debit" ? -1000 : 1000,
|
|
293
|
+
fee: id === "txn_debit" ? 30 : -31,
|
|
294
|
+
net: id === "txn_debit" ? -1030 : 1031,
|
|
295
|
+
currency: "usd",
|
|
296
|
+
status: "available",
|
|
297
|
+
created: 1,
|
|
298
|
+
available_on: 1,
|
|
299
|
+
type: id === "txn_debit" ? "payout" : "payout_failure",
|
|
300
|
+
}));
|
|
301
|
+
expect(await reconcile({ stripePayoutId: "po_one" })).toMatchObject({
|
|
302
|
+
status: "held",
|
|
303
|
+
reason: "fee_allocation_required",
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
it("never creates another payout after a persistence outage", async () => {
|
|
307
|
+
const { provider, reconcile, ctx } = await setup();
|
|
308
|
+
const real = ctx.runMutation!;
|
|
309
|
+
ctx.runMutation = vi
|
|
310
|
+
.fn()
|
|
311
|
+
.mockRejectedValueOnce(new Error("persistence unavailable")) as never;
|
|
312
|
+
await expect(reconcile()).rejects.toThrow("persistence unavailable");
|
|
313
|
+
ctx.runMutation = real;
|
|
314
|
+
expect(await reconcile()).toMatchObject({
|
|
315
|
+
status: "updated",
|
|
316
|
+
claim: { status: "paid" },
|
|
317
|
+
});
|
|
318
|
+
expect(provider.payouts.create).not.toHaveBeenCalled();
|
|
319
|
+
});
|
|
320
|
+
it("is available through the public payout policy client", async () => {
|
|
321
|
+
const { PayoutPolicyClient } = await import("../payoutPolicy.js");
|
|
322
|
+
const { ctx, provider } = await setup();
|
|
323
|
+
const client = new PayoutPolicyClient(
|
|
324
|
+
component,
|
|
325
|
+
() => provider as unknown as Stripe,
|
|
326
|
+
);
|
|
327
|
+
expect(client.reconcilePayout).toBeTypeOf("function");
|
|
328
|
+
expect(await client.reconcilePayout(ctx, scope)).toMatchObject({
|
|
329
|
+
status: "updated",
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
it.each([0, 0.5, 101, NaN])(
|
|
333
|
+
"rejects an invalid discovery budget %s before provider I/O",
|
|
334
|
+
async (maxPages) => {
|
|
335
|
+
const { provider, reconcile } = await setup();
|
|
336
|
+
await expect(reconcile({ maxPages })).rejects.toThrow(/budget/);
|
|
337
|
+
expect(provider.payouts.list).not.toHaveBeenCalled();
|
|
338
|
+
},
|
|
339
|
+
);
|
|
340
|
+
it("does not read Stripe for missing, unattempted, or terminal claims", async () => {
|
|
341
|
+
const { t, provider, reconcile } = await setup();
|
|
342
|
+
expect(await reconcile({ operationKey: "unknown" })).toMatchObject({
|
|
343
|
+
status: "held",
|
|
344
|
+
reason: "not_found",
|
|
345
|
+
});
|
|
346
|
+
await t.mutation(m("recordRejection"), {
|
|
347
|
+
...scope,
|
|
348
|
+
requestId: "req_rejected",
|
|
349
|
+
code: "balance_insufficient",
|
|
350
|
+
});
|
|
351
|
+
expect(await reconcile()).toMatchObject({
|
|
352
|
+
status: "terminal",
|
|
353
|
+
claim: { status: "rejected" },
|
|
354
|
+
});
|
|
355
|
+
expect(provider.payouts.list).not.toHaveBeenCalled();
|
|
356
|
+
const unattempted = await setup(false);
|
|
357
|
+
expect(await unattempted.reconcile()).toMatchObject({
|
|
358
|
+
status: "held",
|
|
359
|
+
reason: "not_attempted",
|
|
360
|
+
});
|
|
361
|
+
expect(unattempted.provider.payouts.list).not.toHaveBeenCalled();
|
|
362
|
+
});
|
|
363
|
+
it("does not silently adopt a different source balance rail", async () => {
|
|
364
|
+
const { reconcile, setPayout } = await setup();
|
|
365
|
+
setPayout({ source_type: "bank_account" });
|
|
366
|
+
expect(await reconcile({ stripePayoutId: "po_one" })).toMatchObject({
|
|
367
|
+
status: "held",
|
|
368
|
+
reason: "source_allocation_required",
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
it("serializes concurrent outcome reconciliation without duplicate fees or principal", async () => {
|
|
372
|
+
const { t, reconcile, provider } = await setup();
|
|
373
|
+
const results = await Promise.all([reconcile(), reconcile()]);
|
|
374
|
+
expect(results.map((result) => result.status)).toEqual([
|
|
375
|
+
"updated",
|
|
376
|
+
"updated",
|
|
377
|
+
]);
|
|
378
|
+
expect(
|
|
379
|
+
await t.run((ctx) => ctx.db.query("payoutSourceAllocations").take(1)),
|
|
380
|
+
).toMatchObject([{ paidAmount: 1000, reservedAmount: 0 }]);
|
|
381
|
+
expect(
|
|
382
|
+
await t.run((ctx) => ctx.db.query("payoutFeeBalances").take(1)),
|
|
383
|
+
).toMatchObject([{ amount: 30, revision: 1 }]);
|
|
384
|
+
expect(provider.payouts.create).not.toHaveBeenCalled();
|
|
385
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import type { Infer } from "convex/values";
|
|
3
|
+
import type { claimDoc } from "../../component/payoutRelease/schema.js";
|
|
4
|
+
import { validateIdentity } from "../../component/payoutRelease/helpers.js";
|
|
5
|
+
import { addSummaryAmounts } from "../../component/earnings/arithmetic.js";
|
|
6
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
7
|
+
import { runMutationOrThrow } from "../helpers.js";
|
|
8
|
+
import { componentRef } from "../webhooks/helpers.js";
|
|
9
|
+
import {
|
|
10
|
+
getRecipientPayoutReceipt,
|
|
11
|
+
type RecipientPayoutReceipt,
|
|
12
|
+
} from "./payoutReceipt.js";
|
|
13
|
+
export type PayoutReconciliationOptions = {
|
|
14
|
+
accountId: string;
|
|
15
|
+
ownerKey: string;
|
|
16
|
+
operationKey: string;
|
|
17
|
+
stripePayoutId?: string;
|
|
18
|
+
/** Complete discovery only. Exhausting this budget never adopts a partial match. */
|
|
19
|
+
maxPages?: number;
|
|
20
|
+
};
|
|
21
|
+
type Claim = Infer<typeof claimDoc>;
|
|
22
|
+
export type PayoutReconciliation =
|
|
23
|
+
| { status: "terminal"; claim: Claim }
|
|
24
|
+
| {
|
|
25
|
+
status: "updated";
|
|
26
|
+
claim: Claim;
|
|
27
|
+
receipt: Extract<RecipientPayoutReceipt, { status: "ready" }>;
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
status: "held";
|
|
31
|
+
reason:
|
|
32
|
+
| "not_found"
|
|
33
|
+
| "not_attempted"
|
|
34
|
+
| "payout_unidentified"
|
|
35
|
+
| "multiple_matches"
|
|
36
|
+
| "history_incomplete"
|
|
37
|
+
| "provider_changed"
|
|
38
|
+
| "fee_allocation_required"
|
|
39
|
+
| "source_allocation_required"
|
|
40
|
+
| Extract<RecipientPayoutReceipt, { status: "not_ready" }>["reason"];
|
|
41
|
+
};
|
|
42
|
+
async function discover(
|
|
43
|
+
stripe: Stripe,
|
|
44
|
+
opts: PayoutReconciliationOptions,
|
|
45
|
+
claim: Claim,
|
|
46
|
+
maxPages: number,
|
|
47
|
+
): Promise<
|
|
48
|
+
| { status: "found"; stripePayoutId: string }
|
|
49
|
+
| Extract<PayoutReconciliation, { status: "held" }>
|
|
50
|
+
> {
|
|
51
|
+
const request = { stripeAccount: opts.accountId };
|
|
52
|
+
const created = {
|
|
53
|
+
gte: Math.max(0, Math.floor(claim.attemptedAt! / 1000) - 60),
|
|
54
|
+
};
|
|
55
|
+
const matches = new Set<string>();
|
|
56
|
+
let cursor: string | undefined;
|
|
57
|
+
let firstId: string | undefined;
|
|
58
|
+
for (let page = 0; page < maxPages; page++) {
|
|
59
|
+
const result = await stripe.payouts.list(
|
|
60
|
+
{ limit: 100, created, ...(cursor ? { starting_after: cursor } : {}) },
|
|
61
|
+
request,
|
|
62
|
+
);
|
|
63
|
+
if (page === 0) firstId = result.data[0]?.id;
|
|
64
|
+
for (const payout of result.data)
|
|
65
|
+
if (payout.metadata?.bsPayoutClaim === opts.operationKey)
|
|
66
|
+
matches.add(payout.id);
|
|
67
|
+
if (!result.has_more) {
|
|
68
|
+
const head = await stripe.payouts.list({ limit: 1, created }, request);
|
|
69
|
+
if (head.data[0]?.id !== firstId)
|
|
70
|
+
return { status: "held", reason: "provider_changed" };
|
|
71
|
+
if (matches.size > 1)
|
|
72
|
+
return { status: "held", reason: "multiple_matches" };
|
|
73
|
+
if (!matches.size)
|
|
74
|
+
return { status: "held", reason: "payout_unidentified" };
|
|
75
|
+
return { status: "found", stripePayoutId: [...matches][0] };
|
|
76
|
+
}
|
|
77
|
+
const next = result.data[result.data.length - 1]?.id;
|
|
78
|
+
if (!next || next === cursor)
|
|
79
|
+
return { status: "held", reason: "history_incomplete" };
|
|
80
|
+
cursor = next;
|
|
81
|
+
}
|
|
82
|
+
return { status: "held", reason: "history_incomplete" };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Recover provider outcomes without creating or retrying a payout. Host must
|
|
86
|
+
* authorize account/owner/operation. A complete metadata match plus scoped
|
|
87
|
+
* receipts is required for adoption; no match never proves money did not move.
|
|
88
|
+
* Fees and principal are persisted together in the component transaction.
|
|
89
|
+
*/
|
|
90
|
+
export async function reconcileRecipientPayout(
|
|
91
|
+
stripe: Stripe,
|
|
92
|
+
component: Component,
|
|
93
|
+
ctx: RunCtx,
|
|
94
|
+
options: PayoutReconciliationOptions,
|
|
95
|
+
): Promise<PayoutReconciliation> {
|
|
96
|
+
validateIdentity(options);
|
|
97
|
+
const maxPages = options.maxPages ?? 10;
|
|
98
|
+
if (!Number.isInteger(maxPages) || maxPages < 1 || maxPages > 100)
|
|
99
|
+
throw new Error("Payout discovery page budget must be between 1 and 100.");
|
|
100
|
+
if (!ctx.runMutation)
|
|
101
|
+
throw new Error(
|
|
102
|
+
"Payout reconciliation requires a Convex ctx with runMutation.",
|
|
103
|
+
);
|
|
104
|
+
const identity = {
|
|
105
|
+
accountId: options.accountId,
|
|
106
|
+
ownerKey: options.ownerKey,
|
|
107
|
+
operationKey: options.operationKey,
|
|
108
|
+
};
|
|
109
|
+
const claim = (await ctx.runQuery(
|
|
110
|
+
componentRef(component, "payoutRelease/queries/get"),
|
|
111
|
+
identity,
|
|
112
|
+
)) as Claim | null;
|
|
113
|
+
if (!claim) return { status: "held", reason: "not_found" };
|
|
114
|
+
if (claim.status === "rejected" || claim.status === "canceled")
|
|
115
|
+
return { status: "terminal", claim };
|
|
116
|
+
if (claim.attemptedAt === undefined)
|
|
117
|
+
return { status: "held", reason: "not_attempted" };
|
|
118
|
+
if (
|
|
119
|
+
claim.stripePayoutId &&
|
|
120
|
+
options.stripePayoutId &&
|
|
121
|
+
claim.stripePayoutId !== options.stripePayoutId
|
|
122
|
+
)
|
|
123
|
+
throw new Error(
|
|
124
|
+
"Payout receipt identity conflicts with the durable claim.",
|
|
125
|
+
);
|
|
126
|
+
let payoutId = claim.stripePayoutId ?? options.stripePayoutId;
|
|
127
|
+
if (!payoutId) {
|
|
128
|
+
const result = await discover(stripe, options, claim, maxPages);
|
|
129
|
+
if (result.status === "held") return result;
|
|
130
|
+
payoutId = result.stripePayoutId;
|
|
131
|
+
}
|
|
132
|
+
const receipt = await getRecipientPayoutReceipt(stripe, {
|
|
133
|
+
accountId: options.accountId,
|
|
134
|
+
stripePayoutId: payoutId,
|
|
135
|
+
operationKey: options.operationKey,
|
|
136
|
+
});
|
|
137
|
+
if (receipt.status !== "ready")
|
|
138
|
+
return { status: "held", reason: receipt.reason };
|
|
139
|
+
if (receipt.amount !== claim.amount || receipt.currency !== claim.currency)
|
|
140
|
+
throw new Error("Payout receipt money does not match the durable claim.");
|
|
141
|
+
if (
|
|
142
|
+
receipt.created * 1000 < claim.attemptedAt - 60000 ||
|
|
143
|
+
receipt.created * 1000 > Date.now() + 60000
|
|
144
|
+
)
|
|
145
|
+
throw new Error(
|
|
146
|
+
"Payout receipt predates its durable attempt or has an invalid creation time.",
|
|
147
|
+
);
|
|
148
|
+
// Current claims use the default card source balance. Other rails need a
|
|
149
|
+
// persisted allocation contract before a provider receipt can be adopted.
|
|
150
|
+
if (receipt.sourceType !== "card")
|
|
151
|
+
return { status: "held", reason: "source_allocation_required" };
|
|
152
|
+
const fee = addSummaryAmounts(receipt.debit.fee, receipt.credit?.fee ?? 0);
|
|
153
|
+
if (fee < 0) return { status: "held", reason: "fee_allocation_required" };
|
|
154
|
+
const saved = (await runMutationOrThrow(
|
|
155
|
+
ctx,
|
|
156
|
+
componentRef(component, "payoutRelease/mutations/recordOutcome"),
|
|
157
|
+
{
|
|
158
|
+
...identity,
|
|
159
|
+
stripePayoutId: receipt.stripePayoutId,
|
|
160
|
+
amount: receipt.amount,
|
|
161
|
+
currency: receipt.currency,
|
|
162
|
+
providerStatus: receipt.providerStatus,
|
|
163
|
+
debitTransactionId: receipt.debit.id,
|
|
164
|
+
debitFee: receipt.debit.fee,
|
|
165
|
+
...(receipt.credit
|
|
166
|
+
? {
|
|
167
|
+
returnTransactionId: receipt.credit.id,
|
|
168
|
+
returnFee: receipt.credit.fee,
|
|
169
|
+
}
|
|
170
|
+
: {}),
|
|
171
|
+
},
|
|
172
|
+
)) as Claim;
|
|
173
|
+
return { status: "updated", claim: saved, receipt };
|
|
174
|
+
}
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
reconcileRecipientPayout,
|
|
3
|
+
type PayoutReconciliationOptions,
|
|
4
|
+
} from "./connect/payoutReconciliation.js";
|
|
5
|
+
export type * from "./connect/payoutReconciliation.js";
|
|
6
|
+
import {
|
|
7
|
+
getRecipientPayoutReceipt,
|
|
8
|
+
type RecipientPayoutReceiptOptions,
|
|
9
|
+
} from "./connect/payoutReceipt.js";
|
|
10
|
+
export type * from "./connect/payoutReceipt.js";
|
|
1
11
|
import {
|
|
2
12
|
getTransferFundingReceipt,
|
|
3
13
|
type TransferReceiptOptions,
|
|
@@ -75,6 +85,12 @@ export class PayoutPolicyClient {
|
|
|
75
85
|
private readonly component: Component,
|
|
76
86
|
private readonly provider: () => Stripe,
|
|
77
87
|
) {}
|
|
88
|
+
reconcilePayout(ctx: RunCtx, opts: PayoutReconciliationOptions) {
|
|
89
|
+
return reconcileRecipientPayout(this.provider(), this.component, ctx, opts);
|
|
90
|
+
}
|
|
91
|
+
payoutReceipt(opts: RecipientPayoutReceiptOptions) {
|
|
92
|
+
return getRecipientPayoutReceipt(this.provider(), opts);
|
|
93
|
+
}
|
|
78
94
|
transferReceipt(ctx: RunCtx, opts: TransferReceiptOptions) {
|
|
79
95
|
return getTransferFundingReceipt(
|
|
80
96
|
this.provider(),
|
|
@@ -38,6 +38,7 @@ import type * as payoutPolicy_helpers from "../payoutPolicy/helpers.js";
|
|
|
38
38
|
import type * as payoutPolicy_mutations from "../payoutPolicy/mutations.js";
|
|
39
39
|
import type * as payoutPolicy_queries from "../payoutPolicy/queries.js";
|
|
40
40
|
import type * as payoutRelease_allocation from "../payoutRelease/allocation.js";
|
|
41
|
+
import type * as payoutRelease_fees from "../payoutRelease/fees.js";
|
|
41
42
|
import type * as payoutRelease_helpers from "../payoutRelease/helpers.js";
|
|
42
43
|
import type * as payoutRelease_mutations from "../payoutRelease/mutations.js";
|
|
43
44
|
import type * as payoutRelease_queries from "../payoutRelease/queries.js";
|
|
@@ -89,6 +90,7 @@ const fullApi: ApiFromModules<{
|
|
|
89
90
|
"payoutPolicy/mutations": typeof payoutPolicy_mutations;
|
|
90
91
|
"payoutPolicy/queries": typeof payoutPolicy_queries;
|
|
91
92
|
"payoutRelease/allocation": typeof payoutRelease_allocation;
|
|
93
|
+
"payoutRelease/fees": typeof payoutRelease_fees;
|
|
92
94
|
"payoutRelease/helpers": typeof payoutRelease_helpers;
|
|
93
95
|
"payoutRelease/mutations": typeof payoutRelease_mutations;
|
|
94
96
|
"payoutRelease/queries": typeof payoutRelease_queries;
|