@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
package/package.json
CHANGED
|
@@ -139,7 +139,7 @@ describe("combined recipient payout allocation", () => {
|
|
|
139
139
|
expect(result).toMatchObject({
|
|
140
140
|
status: "qualified",
|
|
141
141
|
amount: 1300,
|
|
142
|
-
matureUnpaid:
|
|
142
|
+
matureUnpaid: 1800,
|
|
143
143
|
});
|
|
144
144
|
expect(
|
|
145
145
|
planRecipientPayout({
|
|
@@ -149,8 +149,8 @@ describe("combined recipient payout allocation", () => {
|
|
|
149
149
|
}),
|
|
150
150
|
).toMatchObject({
|
|
151
151
|
status: "held",
|
|
152
|
-
reason: "
|
|
153
|
-
matureUnpaid:
|
|
152
|
+
reason: "below_threshold",
|
|
153
|
+
matureUnpaid: 999,
|
|
154
154
|
});
|
|
155
155
|
});
|
|
156
156
|
it("does not subtract reservations again from an already debited provider balance", () => {
|
|
@@ -290,3 +290,36 @@ describe("combined recipient payout allocation", () => {
|
|
|
290
290
|
function awaitInvalid(action: () => unknown) {
|
|
291
291
|
expect(action).toThrow(/Invalid|Unsafe|Duplicate/);
|
|
292
292
|
}
|
|
293
|
+
it("deducts confirmed payout fees from entitlement without deducting them twice from provider availability", () => {
|
|
294
|
+
expect(
|
|
295
|
+
planRecipientPayout({
|
|
296
|
+
...options([source("tr_one", 1500)]),
|
|
297
|
+
providerAvailable: 1470,
|
|
298
|
+
confirmedFees: 30,
|
|
299
|
+
}),
|
|
300
|
+
).toMatchObject({ status: "qualified", amount: 1470, matureUnpaid: 1470 });
|
|
301
|
+
expect(
|
|
302
|
+
planRecipientPayout({
|
|
303
|
+
...options([source("tr_one", 1000)]),
|
|
304
|
+
providerAvailable: 5000,
|
|
305
|
+
confirmedFees: 30,
|
|
306
|
+
}),
|
|
307
|
+
).toMatchObject({
|
|
308
|
+
status: "held",
|
|
309
|
+
reason: "below_threshold",
|
|
310
|
+
matureUnpaid: 970,
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
it("does not qualify the gross threshold while known independent liabilities remain", () => {
|
|
314
|
+
expect(
|
|
315
|
+
planRecipientPayout({
|
|
316
|
+
...options(),
|
|
317
|
+
providerAvailable: 5000,
|
|
318
|
+
independentLiabilities: 1,
|
|
319
|
+
}),
|
|
320
|
+
).toMatchObject({
|
|
321
|
+
status: "held",
|
|
322
|
+
reason: "below_threshold",
|
|
323
|
+
matureUnpaid: 999,
|
|
324
|
+
});
|
|
325
|
+
});
|
|
@@ -0,0 +1,504 @@
|
|
|
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 schema from "../../component/schema.js";
|
|
7
|
+
import type { Component, RunCtx } from "../helpers.js";
|
|
8
|
+
import { releaseRecipientPayout } from "./payoutExecution.js";
|
|
9
|
+
const modules = import.meta.glob("../../component/**/*.*s");
|
|
10
|
+
const symbol = Symbol.for("toReferencePath");
|
|
11
|
+
const component = {
|
|
12
|
+
payoutPolicy: {
|
|
13
|
+
queries: { get: { [symbol]: "betterStripe/payoutPolicy/queries/get" } },
|
|
14
|
+
},
|
|
15
|
+
} as unknown as Component;
|
|
16
|
+
const scope = {
|
|
17
|
+
accountId: "acct_recipient",
|
|
18
|
+
ownerKey: "owner",
|
|
19
|
+
operationKey: "release-1",
|
|
20
|
+
currency: "usd",
|
|
21
|
+
independentLiabilities: 0,
|
|
22
|
+
};
|
|
23
|
+
async function setup(amounts = [600, 400]) {
|
|
24
|
+
const t = convexTest(schema, modules);
|
|
25
|
+
await t.run(async (ctx) => {
|
|
26
|
+
await ctx.db.insert("payoutPolicies", {
|
|
27
|
+
accountId: scope.accountId,
|
|
28
|
+
ownerKey: scope.ownerKey,
|
|
29
|
+
mode: "custom",
|
|
30
|
+
status: "ready",
|
|
31
|
+
revision: 0,
|
|
32
|
+
operationKey: "enable",
|
|
33
|
+
country: "US",
|
|
34
|
+
thresholds: [{ currency: "usd", amount: 1000 }],
|
|
35
|
+
holdDeadline: Date.now() + 86400000,
|
|
36
|
+
});
|
|
37
|
+
for (let i = 0; i < amounts.length; i++)
|
|
38
|
+
await ctx.db.insert("transfers", {
|
|
39
|
+
stripeTransferId: `tr_${i}`,
|
|
40
|
+
destinationAccountId: scope.accountId,
|
|
41
|
+
sourceChargeId: `ch_${i}`,
|
|
42
|
+
amount: amounts[i],
|
|
43
|
+
currency: "usd",
|
|
44
|
+
status: "paid",
|
|
45
|
+
role: i % 2 ? "affiliate" : "store",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
const path = (ref: Record<symbol, string>) =>
|
|
49
|
+
ref[symbol].replace("betterStripe/", "").replace(/\/(\w+)$/, ":$1");
|
|
50
|
+
const ctx = {
|
|
51
|
+
runQuery: (ref: Record<symbol, string>, args: never) =>
|
|
52
|
+
t.query(makeFunctionReference<"query">(path(ref)), args),
|
|
53
|
+
runMutation: (ref: Record<symbol, string>, args: never) =>
|
|
54
|
+
t.mutation(makeFunctionReference<"mutation">(path(ref)), args),
|
|
55
|
+
} as unknown as RunCtx;
|
|
56
|
+
let transactions = amounts.map((amount, i) => ({
|
|
57
|
+
id: `txn_${i}`,
|
|
58
|
+
source: `py_${i}`,
|
|
59
|
+
amount,
|
|
60
|
+
fee: 0,
|
|
61
|
+
net: amount,
|
|
62
|
+
currency: "usd",
|
|
63
|
+
created: 100,
|
|
64
|
+
available_on: 200,
|
|
65
|
+
status: "available",
|
|
66
|
+
type: "payment",
|
|
67
|
+
exchange_rate: null,
|
|
68
|
+
}));
|
|
69
|
+
let payout: Record<string, unknown> | undefined;
|
|
70
|
+
const provider = {
|
|
71
|
+
accounts: {
|
|
72
|
+
listExternalAccounts: vi.fn(async () => ({
|
|
73
|
+
data: [
|
|
74
|
+
{
|
|
75
|
+
id: "ba_one",
|
|
76
|
+
object: "bank_account",
|
|
77
|
+
currency: "usd",
|
|
78
|
+
default_for_currency: true,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
has_more: false,
|
|
82
|
+
})),
|
|
83
|
+
},
|
|
84
|
+
transfers: {
|
|
85
|
+
retrieve: vi.fn(async (id: string) => {
|
|
86
|
+
const i = Number(id.slice(3));
|
|
87
|
+
return {
|
|
88
|
+
id,
|
|
89
|
+
destination: scope.accountId,
|
|
90
|
+
destination_payment: `py_${i}`,
|
|
91
|
+
source_transaction: `ch_${i}`,
|
|
92
|
+
amount: amounts[i],
|
|
93
|
+
currency: "usd",
|
|
94
|
+
amount_reversed: 0,
|
|
95
|
+
};
|
|
96
|
+
}),
|
|
97
|
+
listReversals: vi.fn(async () => ({ data: [], has_more: false })),
|
|
98
|
+
},
|
|
99
|
+
charges: {
|
|
100
|
+
retrieve: vi.fn(async (id: string) => {
|
|
101
|
+
const i = Number(id.slice(3));
|
|
102
|
+
return {
|
|
103
|
+
id,
|
|
104
|
+
source_transfer: `tr_${i}`,
|
|
105
|
+
amount: amounts[i],
|
|
106
|
+
currency: "usd",
|
|
107
|
+
paid: true,
|
|
108
|
+
status: "succeeded",
|
|
109
|
+
balance_transaction: `txn_${i}`,
|
|
110
|
+
};
|
|
111
|
+
}),
|
|
112
|
+
},
|
|
113
|
+
balanceTransactions: {
|
|
114
|
+
list: vi.fn(async (params: { source?: string; limit?: number }) => ({
|
|
115
|
+
data: params.source
|
|
116
|
+
? transactions.filter((t) => t.source === params.source)
|
|
117
|
+
: transactions.slice(0, params.limit),
|
|
118
|
+
has_more: false,
|
|
119
|
+
})),
|
|
120
|
+
retrieve: vi.fn(async (id: string) =>
|
|
121
|
+
transactions.find((t) => t.id === id),
|
|
122
|
+
),
|
|
123
|
+
},
|
|
124
|
+
balance: {
|
|
125
|
+
retrieve: vi.fn(async () => ({
|
|
126
|
+
available: [
|
|
127
|
+
{
|
|
128
|
+
currency: "usd",
|
|
129
|
+
amount: transactions
|
|
130
|
+
.filter((t) => t.status === "available")
|
|
131
|
+
.reduce((a, t) => a + t.net, 0),
|
|
132
|
+
source_types: {
|
|
133
|
+
card: transactions
|
|
134
|
+
.filter((t) => t.status === "available")
|
|
135
|
+
.reduce((a, t) => a + t.net, 0),
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
pending: [],
|
|
140
|
+
})),
|
|
141
|
+
},
|
|
142
|
+
payouts: {
|
|
143
|
+
list: vi.fn(async () => ({
|
|
144
|
+
data: payout ? [payout] : [],
|
|
145
|
+
has_more: false,
|
|
146
|
+
})),
|
|
147
|
+
retrieve: vi.fn(async () => payout),
|
|
148
|
+
create: vi.fn(
|
|
149
|
+
async (params: {
|
|
150
|
+
amount: number;
|
|
151
|
+
currency: string;
|
|
152
|
+
metadata: object;
|
|
153
|
+
}) => {
|
|
154
|
+
payout = {
|
|
155
|
+
id: "po_one",
|
|
156
|
+
...params,
|
|
157
|
+
status: "paid",
|
|
158
|
+
automatic: false,
|
|
159
|
+
method: "standard",
|
|
160
|
+
type: "bank_account",
|
|
161
|
+
source_type: "card",
|
|
162
|
+
destination: "ba_one",
|
|
163
|
+
created: Math.floor(Date.now() / 1000),
|
|
164
|
+
arrival_date: Math.floor(Date.now() / 1000),
|
|
165
|
+
balance_transaction: "txn_debit",
|
|
166
|
+
failure_balance_transaction: null,
|
|
167
|
+
reversed_by: null,
|
|
168
|
+
};
|
|
169
|
+
transactions = [
|
|
170
|
+
{
|
|
171
|
+
id: "txn_debit",
|
|
172
|
+
source: "po_one",
|
|
173
|
+
amount: -params.amount,
|
|
174
|
+
fee: 0,
|
|
175
|
+
net: -params.amount,
|
|
176
|
+
currency: "usd",
|
|
177
|
+
created: Math.floor(Date.now() / 1000),
|
|
178
|
+
available_on: Math.floor(Date.now() / 1000),
|
|
179
|
+
status: "available",
|
|
180
|
+
type: "payout",
|
|
181
|
+
exchange_rate: null,
|
|
182
|
+
},
|
|
183
|
+
...transactions,
|
|
184
|
+
];
|
|
185
|
+
return payout;
|
|
186
|
+
},
|
|
187
|
+
),
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
const verify = vi.fn(async () => ({
|
|
191
|
+
status: "verified" as const,
|
|
192
|
+
policy: (await t.run((ctx) => ctx.db.query("payoutPolicies").first()))!,
|
|
193
|
+
}));
|
|
194
|
+
const run = (extra = {}) =>
|
|
195
|
+
releaseRecipientPayout(
|
|
196
|
+
provider as unknown as Stripe,
|
|
197
|
+
component,
|
|
198
|
+
ctx,
|
|
199
|
+
{ ...scope, ...extra },
|
|
200
|
+
verify,
|
|
201
|
+
);
|
|
202
|
+
return {
|
|
203
|
+
t,
|
|
204
|
+
ctx,
|
|
205
|
+
provider,
|
|
206
|
+
verify,
|
|
207
|
+
run,
|
|
208
|
+
setTransactions: (rows: typeof transactions) => {
|
|
209
|
+
transactions = rows;
|
|
210
|
+
},
|
|
211
|
+
transactions: () => transactions,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
it("automatically releases combined creator and affiliate funds exactly once", async () => {
|
|
215
|
+
const s = await setup();
|
|
216
|
+
expect(await s.run()).toMatchObject({
|
|
217
|
+
status: "updated",
|
|
218
|
+
claim: { status: "paid", amount: 1000 },
|
|
219
|
+
});
|
|
220
|
+
expect(s.provider.payouts.create).toHaveBeenCalledWith(
|
|
221
|
+
{
|
|
222
|
+
amount: 1000,
|
|
223
|
+
currency: "usd",
|
|
224
|
+
method: "standard",
|
|
225
|
+
source_type: "card",
|
|
226
|
+
destination: "ba_one",
|
|
227
|
+
metadata: { bsPayoutClaim: scope.operationKey },
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
stripeAccount: scope.accountId,
|
|
231
|
+
idempotencyKey: `bs:payout-release:${scope.operationKey}`,
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
await s.run();
|
|
235
|
+
expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
|
|
236
|
+
expect(
|
|
237
|
+
await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
238
|
+
).toMatchObject([
|
|
239
|
+
{ paidAmount: 600, reservedAmount: 0 },
|
|
240
|
+
{ paidAmount: 400, reservedAmount: 0 },
|
|
241
|
+
]);
|
|
242
|
+
});
|
|
243
|
+
it("carries below-threshold funds forward", async () => {
|
|
244
|
+
const s = await setup([599, 400]);
|
|
245
|
+
expect(await s.run()).toMatchObject({
|
|
246
|
+
status: "held",
|
|
247
|
+
reason: "below_threshold",
|
|
248
|
+
matureUnpaid: 999,
|
|
249
|
+
});
|
|
250
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
251
|
+
});
|
|
252
|
+
it("holds unknown historical payouts rather than double paying", async () => {
|
|
253
|
+
const s = await setup();
|
|
254
|
+
s.setTransactions([
|
|
255
|
+
...s.transactions(),
|
|
256
|
+
{
|
|
257
|
+
...s.transactions()[0],
|
|
258
|
+
id: "txn_old",
|
|
259
|
+
source: "po_old",
|
|
260
|
+
amount: -500,
|
|
261
|
+
net: -500,
|
|
262
|
+
type: "payout",
|
|
263
|
+
},
|
|
264
|
+
]);
|
|
265
|
+
expect(await s.run()).toMatchObject({
|
|
266
|
+
status: "held",
|
|
267
|
+
reason: "unallocated_history",
|
|
268
|
+
});
|
|
269
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
270
|
+
});
|
|
271
|
+
it("an unknown provider outcome keeps the reservation and retries reconciliation only", async () => {
|
|
272
|
+
const s = await setup();
|
|
273
|
+
s.provider.payouts.create.mockRejectedValue(new Error("timeout"));
|
|
274
|
+
await expect(s.run()).rejects.toThrow("timeout");
|
|
275
|
+
expect(await s.run()).toMatchObject({
|
|
276
|
+
status: "held",
|
|
277
|
+
reason: "payout_unidentified",
|
|
278
|
+
});
|
|
279
|
+
expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
|
|
280
|
+
expect(
|
|
281
|
+
await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
282
|
+
).toMatchObject([{ reservedAmount: 600 }, { reservedAmount: 400 }]);
|
|
283
|
+
});
|
|
284
|
+
it("rechecks live controls before the attempted marker and cancels only unattempted funds on drift", async () => {
|
|
285
|
+
const s = await setup();
|
|
286
|
+
s.verify
|
|
287
|
+
.mockResolvedValueOnce(await s.verify())
|
|
288
|
+
.mockResolvedValue({ status: "schedule_drift" } as never);
|
|
289
|
+
expect(await s.run()).toMatchObject({
|
|
290
|
+
status: "held",
|
|
291
|
+
reason: "schedule_drift",
|
|
292
|
+
});
|
|
293
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
294
|
+
expect(
|
|
295
|
+
await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
296
|
+
).toMatchObject([{ reservedAmount: 0 }, { reservedAmount: 0 }]);
|
|
297
|
+
});
|
|
298
|
+
it("deducts independent liabilities before qualification", async () => {
|
|
299
|
+
const s = await setup();
|
|
300
|
+
expect(await s.run({ independentLiabilities: 1 })).toMatchObject({
|
|
301
|
+
status: "held",
|
|
302
|
+
reason: "below_threshold",
|
|
303
|
+
});
|
|
304
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
305
|
+
});
|
|
306
|
+
it("pays above-threshold combined funds without per-category minimums", async () => {
|
|
307
|
+
const s = await setup([600, 601]);
|
|
308
|
+
expect(await s.run()).toMatchObject({
|
|
309
|
+
status: "updated",
|
|
310
|
+
claim: { amount: 1201, status: "paid" },
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
it("does not release already-paid sources under a new schedule identity", async () => {
|
|
314
|
+
const s = await setup();
|
|
315
|
+
await s.run();
|
|
316
|
+
expect(await s.run({ operationKey: "release-2" })).toMatchObject({
|
|
317
|
+
status: "held",
|
|
318
|
+
reason: "below_threshold",
|
|
319
|
+
matureUnpaid: 0,
|
|
320
|
+
});
|
|
321
|
+
expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
|
|
322
|
+
});
|
|
323
|
+
it("does not qualify pending credits with unrelated mature money", async () => {
|
|
324
|
+
const s = await setup([600, 400]);
|
|
325
|
+
const rows = s.transactions();
|
|
326
|
+
rows[1].status = "pending";
|
|
327
|
+
s.provider.balance.retrieve.mockResolvedValue({
|
|
328
|
+
available: [{ currency: "usd", amount: 600, source_types: { card: 600 } }],
|
|
329
|
+
pending: [{ currency: "usd", amount: 400 }],
|
|
330
|
+
} as never);
|
|
331
|
+
expect(await s.run()).toMatchObject({
|
|
332
|
+
status: "held",
|
|
333
|
+
reason: "below_threshold",
|
|
334
|
+
matureUnpaid: 600,
|
|
335
|
+
pendingCredits: 400,
|
|
336
|
+
});
|
|
337
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
338
|
+
});
|
|
339
|
+
it.each(["balance_insufficient", "amount_too_small"])(
|
|
340
|
+
"records a verified %s rejection and releases reservations",
|
|
341
|
+
async (code) => {
|
|
342
|
+
const s = await setup();
|
|
343
|
+
s.provider.payouts.create.mockRejectedValue(
|
|
344
|
+
Object.assign(new Error(code), {
|
|
345
|
+
type: "StripeInvalidRequestError",
|
|
346
|
+
code,
|
|
347
|
+
requestId: "req_declined",
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
expect(await s.run()).toMatchObject({
|
|
351
|
+
status: "terminal",
|
|
352
|
+
claim: {
|
|
353
|
+
status: "rejected",
|
|
354
|
+
rejection: { code, requestId: "req_declined" },
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
expect(
|
|
358
|
+
await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
359
|
+
).toMatchObject([{ reservedAmount: 0 }, { reservedAmount: 0 }]);
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
it("an insufficient-funds error without a request receipt remains held", async () => {
|
|
363
|
+
const s = await setup();
|
|
364
|
+
s.provider.payouts.create.mockRejectedValue(
|
|
365
|
+
Object.assign(new Error("insufficient"), {
|
|
366
|
+
type: "StripeInvalidRequestError",
|
|
367
|
+
code: "balance_insufficient",
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
await expect(s.run()).rejects.toThrow("insufficient");
|
|
371
|
+
expect(await s.run()).toMatchObject({
|
|
372
|
+
status: "held",
|
|
373
|
+
reason: "payout_unidentified",
|
|
374
|
+
});
|
|
375
|
+
expect(
|
|
376
|
+
await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
377
|
+
).toMatchObject([{ reservedAmount: 600 }, { reservedAmount: 400 }]);
|
|
378
|
+
});
|
|
379
|
+
it("source changes during live control verification prevent provider creation", async () => {
|
|
380
|
+
const s = await setup();
|
|
381
|
+
const first = await s.verify();
|
|
382
|
+
s.verify.mockResolvedValueOnce(first).mockImplementationOnce(async () => {
|
|
383
|
+
await s.t.run(async (ctx) => {
|
|
384
|
+
const row = await ctx.db.query("transfers").first();
|
|
385
|
+
await ctx.db.patch("transfers", row!._id, { reversalClaimedAmount: 10 });
|
|
386
|
+
});
|
|
387
|
+
return first;
|
|
388
|
+
});
|
|
389
|
+
await expect(s.run()).rejects.toThrow(/snapshot changed/i);
|
|
390
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
391
|
+
expect(await s.run()).toMatchObject({
|
|
392
|
+
status: "terminal",
|
|
393
|
+
claim: { status: "canceled" },
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
it("a second worker's operation cannot spend an unresolved reservation", async () => {
|
|
397
|
+
const s = await setup();
|
|
398
|
+
s.provider.payouts.create.mockRejectedValue(new Error("timeout"));
|
|
399
|
+
await expect(s.run()).rejects.toThrow("timeout");
|
|
400
|
+
expect(await s.run({ operationKey: "other-worker" })).toMatchObject({
|
|
401
|
+
status: "held",
|
|
402
|
+
reason: "payout_unidentified",
|
|
403
|
+
});
|
|
404
|
+
expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
|
|
405
|
+
});
|
|
406
|
+
it("verified source reversals reduce combined eligibility before reservation", async () => {
|
|
407
|
+
const s = await setup([600, 450]);
|
|
408
|
+
await s.t.run(async (ctx) => {
|
|
409
|
+
const row = await ctx.db
|
|
410
|
+
.query("transfers")
|
|
411
|
+
.withIndex("by_stripe_transfer_id", (q) =>
|
|
412
|
+
q.eq("stripeTransferId", "tr_1"),
|
|
413
|
+
)
|
|
414
|
+
.unique();
|
|
415
|
+
await ctx.db.patch("transfers", row!._id, { reversedAmount: 100 });
|
|
416
|
+
});
|
|
417
|
+
const original = s.provider.transfers.retrieve.getMockImplementation()!;
|
|
418
|
+
s.provider.transfers.retrieve.mockImplementation(async (id) => ({
|
|
419
|
+
...(await original(id)),
|
|
420
|
+
amount_reversed: id === "tr_1" ? 100 : 0,
|
|
421
|
+
}));
|
|
422
|
+
s.provider.transfers.listReversals.mockImplementation(
|
|
423
|
+
async (...args: unknown[]) =>
|
|
424
|
+
({
|
|
425
|
+
data:
|
|
426
|
+
args[0] === "tr_1"
|
|
427
|
+
? [
|
|
428
|
+
{
|
|
429
|
+
id: "trr_one",
|
|
430
|
+
transfer: "tr_1",
|
|
431
|
+
amount: 100,
|
|
432
|
+
currency: "usd",
|
|
433
|
+
destination_payment_refund: "pyr_one",
|
|
434
|
+
},
|
|
435
|
+
]
|
|
436
|
+
: [],
|
|
437
|
+
has_more: false,
|
|
438
|
+
}) as never,
|
|
439
|
+
);
|
|
440
|
+
Object.assign(s.provider, {
|
|
441
|
+
refunds: {
|
|
442
|
+
retrieve: vi.fn(async () => ({
|
|
443
|
+
id: "pyr_one",
|
|
444
|
+
charge: "py_1",
|
|
445
|
+
amount: 100,
|
|
446
|
+
currency: "usd",
|
|
447
|
+
status: "succeeded",
|
|
448
|
+
balance_transaction: "txn_refund",
|
|
449
|
+
})),
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
s.setTransactions([
|
|
453
|
+
...s.transactions(),
|
|
454
|
+
{
|
|
455
|
+
...s.transactions()[0],
|
|
456
|
+
id: "txn_refund",
|
|
457
|
+
source: "pyr_one",
|
|
458
|
+
amount: -100,
|
|
459
|
+
net: -100,
|
|
460
|
+
type: "payment_refund",
|
|
461
|
+
},
|
|
462
|
+
]);
|
|
463
|
+
expect(await s.run()).toMatchObject({
|
|
464
|
+
status: "held",
|
|
465
|
+
reason: "below_threshold",
|
|
466
|
+
matureUnpaid: 950,
|
|
467
|
+
});
|
|
468
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("requires a verified default bank destination before any reservation", async () => {
|
|
472
|
+
const s = await setup();
|
|
473
|
+
s.provider.accounts.listExternalAccounts.mockResolvedValue({
|
|
474
|
+
data: [],
|
|
475
|
+
has_more: false,
|
|
476
|
+
});
|
|
477
|
+
expect(await s.run()).toMatchObject({
|
|
478
|
+
status: "held",
|
|
479
|
+
reason: "bank_destination_unavailable",
|
|
480
|
+
});
|
|
481
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
482
|
+
expect(
|
|
483
|
+
await s.t.run((ctx) => ctx.db.query("payoutReleaseClaims").collect()),
|
|
484
|
+
).toEqual([]);
|
|
485
|
+
});
|
|
486
|
+
it("does not select a partial or ambiguous default-bank page", async () => {
|
|
487
|
+
const s = await setup();
|
|
488
|
+
s.provider.accounts.listExternalAccounts.mockResolvedValue({
|
|
489
|
+
data: [
|
|
490
|
+
{
|
|
491
|
+
id: "ba_one",
|
|
492
|
+
object: "bank_account",
|
|
493
|
+
currency: "usd",
|
|
494
|
+
default_for_currency: true,
|
|
495
|
+
},
|
|
496
|
+
],
|
|
497
|
+
has_more: true,
|
|
498
|
+
});
|
|
499
|
+
expect(await s.run({ maxPages: 1 })).toMatchObject({
|
|
500
|
+
status: "held",
|
|
501
|
+
reason: "bank_history_incomplete",
|
|
502
|
+
});
|
|
503
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
504
|
+
});
|