@fantastic.dev/better-stripe 0.28.3 → 0.28.4
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 +3 -1
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +96 -2
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts +48 -1
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutExpiry.d.ts +42 -0
- package/dist/client/connect/payoutExpiry.d.ts.map +1 -0
- package/dist/client/connect/payoutExpiry.js +93 -0
- package/dist/client/connect/payoutExpiry.js.map +1 -0
- package/dist/client/connect/payoutSyncTestHelpers.d.ts +38 -1
- package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -1
- package/dist/client/payoutPolicy.d.ts +18 -2
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +3 -25
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/component/_generated/component.d.ts +116 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/core/resetSafety.d.ts.map +1 -1
- package/dist/component/core/resetSafety.js +7 -1
- package/dist/component/core/resetSafety.js.map +1 -1
- package/dist/component/earnings/triggers.d.ts +22 -2
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/payoutPolicy/expiry.d.ts +78 -0
- package/dist/component/payoutPolicy/expiry.d.ts.map +1 -0
- package/dist/component/payoutPolicy/expiry.js +57 -0
- package/dist/component/payoutPolicy/expiry.js.map +1 -0
- package/dist/component/payoutPolicy/helpers.d.ts +10 -0
- package/dist/component/payoutPolicy/helpers.d.ts.map +1 -1
- package/dist/component/payoutPolicy/mutations.d.ts +77 -0
- package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
- package/dist/component/payoutPolicy/mutations.js +36 -3
- package/dist/component/payoutPolicy/mutations.js.map +1 -1
- package/dist/component/payoutPolicy/queries.d.ts +20 -0
- package/dist/component/payoutPolicy/queries.d.ts.map +1 -1
- package/dist/component/payoutPolicy/schema.d.ts +139 -2
- package/dist/component/payoutPolicy/schema.d.ts.map +1 -1
- package/dist/component/payoutPolicy/schema.js +12 -0
- package/dist/component/payoutPolicy/schema.js.map +1 -1
- package/dist/component/payoutRelease/helpers.d.ts +10 -0
- package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
- package/dist/component/payoutWorker/queries.d.ts +20 -0
- package/dist/component/payoutWorker/queries.d.ts.map +1 -1
- package/dist/component/schema.d.ts +38 -1
- package/dist/react/workflows/PayoutProgress.d.ts.map +1 -1
- package/dist/react/workflows/PayoutProgress.js +2 -2
- package/dist/react/workflows/PayoutProgress.js.map +1 -1
- package/dist/react/workflows/payout-progress-state.d.ts +11 -1
- package/dist/react/workflows/payout-progress-state.d.ts.map +1 -1
- package/dist/react/workflows/payout-progress-state.js +26 -0
- package/dist/react/workflows/payout-progress-state.js.map +1 -1
- package/docs/react-workflows.md +11 -0
- package/docs/settlement.md +31 -3
- package/package.json +1 -1
- package/src/client/connect/payoutDeadline.test.ts +70 -0
- package/src/client/connect/payoutExpiry.ts +132 -0
- package/src/client/payoutPolicy.test.ts +196 -0
- package/src/client/payoutPolicy.ts +9 -23
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +143 -0
- package/src/component/core/resetSafety.ts +49 -33
- package/src/component/payout-reset-safety.test.ts +28 -0
- package/src/component/payoutPolicy/expiry.ts +80 -0
- package/src/component/payoutPolicy/mutations.ts +47 -2
- package/src/component/payoutPolicy/schema.ts +17 -0
- package/src/react/workflows/PayoutProgress.test.tsx +42 -0
- package/src/react/workflows/PayoutProgress.tsx +25 -1
- package/src/react/workflows/payout-progress-state.ts +41 -1
|
@@ -166,3 +166,73 @@ it("rescues a pre-existing job whose stored cadence would skip the final window"
|
|
|
166
166
|
});
|
|
167
167
|
expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
|
|
168
168
|
});
|
|
169
|
+
|
|
170
|
+
it("final-window release subtracts verified refund and reversal receipts before paying", async () => {
|
|
171
|
+
const s = await recipient(12, [600, 450]);
|
|
172
|
+
await s.t.run(async (ctx) => {
|
|
173
|
+
const row = await ctx.db
|
|
174
|
+
.query("transfers")
|
|
175
|
+
.withIndex("by_stripe_transfer_id", (q) =>
|
|
176
|
+
q.eq("stripeTransferId", "tr_1"),
|
|
177
|
+
)
|
|
178
|
+
.unique();
|
|
179
|
+
await ctx.db.patch("transfers", row!._id, { reversedAmount: 100 });
|
|
180
|
+
});
|
|
181
|
+
const original = s.provider.transfers.retrieve.getMockImplementation()!;
|
|
182
|
+
s.provider.transfers.retrieve.mockImplementation(async (id) => ({
|
|
183
|
+
...(await original(id)),
|
|
184
|
+
amount_reversed: id === "tr_1" ? 100 : 0,
|
|
185
|
+
}));
|
|
186
|
+
s.provider.transfers.listReversals.mockImplementation(
|
|
187
|
+
async (...args: unknown[]) =>
|
|
188
|
+
({
|
|
189
|
+
data:
|
|
190
|
+
args[0] === "tr_1"
|
|
191
|
+
? [
|
|
192
|
+
{
|
|
193
|
+
id: "trr_one",
|
|
194
|
+
transfer: "tr_1",
|
|
195
|
+
amount: 100,
|
|
196
|
+
currency: "usd",
|
|
197
|
+
destination_payment_refund: "pyr_one",
|
|
198
|
+
},
|
|
199
|
+
]
|
|
200
|
+
: [],
|
|
201
|
+
has_more: false,
|
|
202
|
+
}) as never,
|
|
203
|
+
);
|
|
204
|
+
Object.assign(s.provider, {
|
|
205
|
+
refunds: {
|
|
206
|
+
retrieve: vi.fn(async () => ({
|
|
207
|
+
id: "pyr_one",
|
|
208
|
+
charge: "py_1",
|
|
209
|
+
amount: 100,
|
|
210
|
+
currency: "usd",
|
|
211
|
+
status: "succeeded",
|
|
212
|
+
balance_transaction: "txn_refund",
|
|
213
|
+
})),
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
s.setTransactions([
|
|
217
|
+
...s.transactions(),
|
|
218
|
+
{
|
|
219
|
+
...s.transactions()[0],
|
|
220
|
+
id: "txn_refund",
|
|
221
|
+
source: "pyr_one",
|
|
222
|
+
amount: -100,
|
|
223
|
+
net: -100,
|
|
224
|
+
type: "payment_refund",
|
|
225
|
+
},
|
|
226
|
+
]);
|
|
227
|
+
expect(await s.run()).toMatchObject({
|
|
228
|
+
claim: {
|
|
229
|
+
status: "paid",
|
|
230
|
+
amount: 950,
|
|
231
|
+
allocations: [
|
|
232
|
+
{ stripeTransferId: "tr_0", amount: 600 },
|
|
233
|
+
{ stripeTransferId: "tr_1", amount: 350 },
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
|
|
238
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
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 { policyDoc } from "../../component/payoutPolicy/schema.js";
|
|
7
|
+
type Scope = { accountId: string; ownerKey: string };
|
|
8
|
+
type Policy = Infer<typeof policyDoc>;
|
|
9
|
+
function expiryBalances(balance: Stripe.Balance) {
|
|
10
|
+
const values = new Map<
|
|
11
|
+
string,
|
|
12
|
+
{ currency: string; available: number; pending: number }
|
|
13
|
+
>();
|
|
14
|
+
for (const type of ["available", "pending"] as const) {
|
|
15
|
+
const seen = new Set<string>();
|
|
16
|
+
for (const row of balance[type]) {
|
|
17
|
+
if (
|
|
18
|
+
!/^[a-z]{3}$/.test(row.currency) ||
|
|
19
|
+
!Number.isSafeInteger(row.amount) ||
|
|
20
|
+
seen.has(row.currency)
|
|
21
|
+
)
|
|
22
|
+
throw new Error("Invalid expiry balance.");
|
|
23
|
+
seen.add(row.currency);
|
|
24
|
+
const value = values.get(row.currency) ?? {
|
|
25
|
+
currency: row.currency,
|
|
26
|
+
available: 0,
|
|
27
|
+
pending: 0,
|
|
28
|
+
};
|
|
29
|
+
value[type] = row.amount;
|
|
30
|
+
values.set(row.currency, value);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return [...values.values()].sort((a, b) =>
|
|
34
|
+
a.currency.localeCompare(b.currency),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
/** Restore controls, then separately verify remaining balances and durable claims. Never creates a payout. */
|
|
38
|
+
export async function expirePayoutPolicy<
|
|
39
|
+
R extends { status: string },
|
|
40
|
+
P extends { status: string },
|
|
41
|
+
>(
|
|
42
|
+
stripe: Stripe,
|
|
43
|
+
component: Component,
|
|
44
|
+
ctx: RunCtx,
|
|
45
|
+
scope: Scope,
|
|
46
|
+
client: {
|
|
47
|
+
get(ctx: RunCtx, scope: Scope): Promise<Policy | null>;
|
|
48
|
+
apply(ctx: RunCtx, args: Scope & { operationKey: string }): Promise<R>;
|
|
49
|
+
prepare(
|
|
50
|
+
ctx: RunCtx,
|
|
51
|
+
args: Scope & {
|
|
52
|
+
operationKey: string;
|
|
53
|
+
expectedRevision: number;
|
|
54
|
+
mode: "stripe_managed";
|
|
55
|
+
thresholds: [];
|
|
56
|
+
},
|
|
57
|
+
): Promise<P>;
|
|
58
|
+
},
|
|
59
|
+
) {
|
|
60
|
+
scope = { accountId: scope.accountId, ownerKey: scope.ownerKey };
|
|
61
|
+
let policy = await client.get(ctx, scope);
|
|
62
|
+
if (!policy?.holdDeadline || policy.holdDeadline > Date.now())
|
|
63
|
+
return { status: "not_due" as const };
|
|
64
|
+
if (policy.mode === "custom")
|
|
65
|
+
policy = (await runMutationOrThrow(
|
|
66
|
+
ctx,
|
|
67
|
+
componentRef(component, "payoutPolicy/mutations/beginHoldingExpiry"),
|
|
68
|
+
scope,
|
|
69
|
+
)) as Policy;
|
|
70
|
+
if (policy.status === "changing") {
|
|
71
|
+
const result = await client.apply(ctx, {
|
|
72
|
+
...scope,
|
|
73
|
+
operationKey: policy.operationKey,
|
|
74
|
+
});
|
|
75
|
+
if (result.status !== "ready") return result;
|
|
76
|
+
policy = await client.get(ctx, scope);
|
|
77
|
+
}
|
|
78
|
+
if (!policy) return { status: "not_due" as const };
|
|
79
|
+
if (policy.mode === "custom") {
|
|
80
|
+
const operationKey = `bs:payout-expiry:${scope.accountId}:${policy.revision}`;
|
|
81
|
+
const prepared = await client.prepare(ctx, {
|
|
82
|
+
...scope,
|
|
83
|
+
operationKey,
|
|
84
|
+
expectedRevision: policy.revision,
|
|
85
|
+
mode: "stripe_managed",
|
|
86
|
+
thresholds: [],
|
|
87
|
+
});
|
|
88
|
+
if (prepared.status !== "prepared") return prepared;
|
|
89
|
+
const applied = await client.apply(ctx, { ...scope, operationKey });
|
|
90
|
+
if (applied.status !== "ready") return applied;
|
|
91
|
+
policy = await client.get(ctx, scope);
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
!policy?.holdingExpiry ||
|
|
95
|
+
policy.mode !== "stripe_managed" ||
|
|
96
|
+
policy.status !== "ready"
|
|
97
|
+
)
|
|
98
|
+
return { status: "not_due" as const };
|
|
99
|
+
const unresolved = {
|
|
100
|
+
status: "expiry_unresolved" as const,
|
|
101
|
+
holdingExpiry: policy.holdingExpiry,
|
|
102
|
+
};
|
|
103
|
+
let balances;
|
|
104
|
+
const observedAt = Date.now();
|
|
105
|
+
try {
|
|
106
|
+
balances = expiryBalances(
|
|
107
|
+
await stripe.balance.retrieve(undefined, {
|
|
108
|
+
stripeAccount: scope.accountId,
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
} catch {
|
|
112
|
+
return unresolved;
|
|
113
|
+
}
|
|
114
|
+
if (Date.now() - observedAt > 30000) return unresolved;
|
|
115
|
+
const observed = (await runMutationOrThrow(
|
|
116
|
+
ctx,
|
|
117
|
+
componentRef(component, "payoutPolicy/mutations/observeHoldingExpiry"),
|
|
118
|
+
{
|
|
119
|
+
...scope,
|
|
120
|
+
expectedRevision: policy.revision,
|
|
121
|
+
deadline: policy.holdingExpiry.deadline,
|
|
122
|
+
observedAt,
|
|
123
|
+
balances,
|
|
124
|
+
},
|
|
125
|
+
)) as Policy;
|
|
126
|
+
if (observed.holdingExpiry!.status !== "no_held_balance")
|
|
127
|
+
return {
|
|
128
|
+
status: "expiry_unresolved" as const,
|
|
129
|
+
holdingExpiry: observed.holdingExpiry!,
|
|
130
|
+
};
|
|
131
|
+
return client.apply(ctx, { ...scope, operationKey: policy.operationKey });
|
|
132
|
+
}
|
|
@@ -381,3 +381,199 @@ it("requires adoption for historical payouts even when today's balance is zero",
|
|
|
381
381
|
expect(provider.balanceSettings.update).not.toHaveBeenCalled();
|
|
382
382
|
expect(await client.get(ctx, scope)).toBeNull();
|
|
383
383
|
});
|
|
384
|
+
|
|
385
|
+
async function expiredRecipient() {
|
|
386
|
+
const s = await setup();
|
|
387
|
+
await s.client.prepare(s.ctx, options);
|
|
388
|
+
await s.client.apply(s.ctx, { ...scope, operationKey: "enable" });
|
|
389
|
+
const deadline = Date.now() - 1;
|
|
390
|
+
await s.t.run(async (ctx) => {
|
|
391
|
+
const p = (await ctx.db.query("payoutPolicies").first())!;
|
|
392
|
+
await ctx.db.patch(p._id, { holdDeadline: deadline });
|
|
393
|
+
});
|
|
394
|
+
return { ...s, deadline };
|
|
395
|
+
}
|
|
396
|
+
it("keeps funded expiry discoverable after restoring weekly controls until fresh balances clear", async () => {
|
|
397
|
+
const s = await expiredRecipient();
|
|
398
|
+
s.provider.balance.retrieve.mockResolvedValue({
|
|
399
|
+
available: [{ currency: "usd", amount: 999 }],
|
|
400
|
+
pending: [],
|
|
401
|
+
});
|
|
402
|
+
expect(await s.client.expire(s.ctx, scope)).toMatchObject({
|
|
403
|
+
status: "expiry_unresolved",
|
|
404
|
+
});
|
|
405
|
+
expect(await s.client.get(s.ctx, scope)).toMatchObject({
|
|
406
|
+
mode: "stripe_managed",
|
|
407
|
+
status: "ready",
|
|
408
|
+
holdDeadline: s.deadline,
|
|
409
|
+
holdingExpiry: {
|
|
410
|
+
deadline: s.deadline,
|
|
411
|
+
status: "balance_remaining",
|
|
412
|
+
balances: [{ currency: "usd", available: 999, pending: 0 }],
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
expect(
|
|
416
|
+
(await s.client.due(s.ctx, { cursor: null, numItems: 10 })).page,
|
|
417
|
+
).toHaveLength(1);
|
|
418
|
+
await s.client.expire(s.ctx, scope);
|
|
419
|
+
expect(s.provider.balanceSettings.update).toHaveBeenCalledTimes(2);
|
|
420
|
+
s.provider.balance.retrieve.mockResolvedValue({
|
|
421
|
+
available: [{ currency: "usd", amount: 0 }],
|
|
422
|
+
pending: [],
|
|
423
|
+
});
|
|
424
|
+
expect(await s.client.expire(s.ctx, scope)).toMatchObject({
|
|
425
|
+
status: "ready",
|
|
426
|
+
});
|
|
427
|
+
expect((await s.client.get(s.ctx, scope))!.holdDeadline).toBeUndefined();
|
|
428
|
+
expect((await s.client.get(s.ctx, scope))!.holdingExpiry).toMatchObject({
|
|
429
|
+
status: "no_held_balance",
|
|
430
|
+
deadline: s.deadline,
|
|
431
|
+
});
|
|
432
|
+
expect(
|
|
433
|
+
(await s.client.due(s.ctx, { cursor: null, numItems: 10 })).page,
|
|
434
|
+
).toHaveLength(0);
|
|
435
|
+
});
|
|
436
|
+
it("restores controls even if expiry balance verification is unavailable, without fabricating zero", async () => {
|
|
437
|
+
const s = await expiredRecipient();
|
|
438
|
+
s.provider.balance.retrieve.mockRejectedValue(
|
|
439
|
+
new Error("provider unavailable"),
|
|
440
|
+
);
|
|
441
|
+
expect(await s.client.expire(s.ctx, scope)).toMatchObject({
|
|
442
|
+
status: "expiry_unresolved",
|
|
443
|
+
});
|
|
444
|
+
const p = await s.client.get(s.ctx, scope);
|
|
445
|
+
expect(p).toMatchObject({
|
|
446
|
+
mode: "stripe_managed",
|
|
447
|
+
holdDeadline: s.deadline,
|
|
448
|
+
holdingExpiry: { status: "unverified" },
|
|
449
|
+
});
|
|
450
|
+
expect(p!.holdingExpiry!.balances).toBeUndefined();
|
|
451
|
+
expect(
|
|
452
|
+
(await s.provider.balanceSettings.retrieve()).payments.payouts.schedule
|
|
453
|
+
.interval,
|
|
454
|
+
).toBe("weekly");
|
|
455
|
+
});
|
|
456
|
+
it("does not clear an unknown payout claim at expiry even when Stripe balance is zero", async () => {
|
|
457
|
+
const s = await expiredRecipient();
|
|
458
|
+
const id = await s.t.run((ctx) =>
|
|
459
|
+
ctx.db.insert("payoutReleaseClaims", {
|
|
460
|
+
...scope,
|
|
461
|
+
operationKey: "unknown-payout",
|
|
462
|
+
expectedRevision: 0,
|
|
463
|
+
amount: 999,
|
|
464
|
+
currency: "usd",
|
|
465
|
+
allocations: [],
|
|
466
|
+
observations: [],
|
|
467
|
+
status: "reserved",
|
|
468
|
+
attemptedAt: Date.now(),
|
|
469
|
+
}),
|
|
470
|
+
);
|
|
471
|
+
expect(await s.client.expire(s.ctx, scope)).toMatchObject({
|
|
472
|
+
status: "expiry_unresolved",
|
|
473
|
+
});
|
|
474
|
+
expect((await s.client.get(s.ctx, scope))!.holdingExpiry).toMatchObject({
|
|
475
|
+
status: "claims_unresolved",
|
|
476
|
+
});
|
|
477
|
+
expect(await s.t.run((ctx) => ctx.db.get(id))).toMatchObject({
|
|
478
|
+
status: "reserved",
|
|
479
|
+
amount: 999,
|
|
480
|
+
});
|
|
481
|
+
await expect(
|
|
482
|
+
s.client.prepare(s.ctx, {
|
|
483
|
+
...options,
|
|
484
|
+
expectedRevision: 1,
|
|
485
|
+
operationKey: "reactivate",
|
|
486
|
+
}),
|
|
487
|
+
).rejects.toThrow(/expiry.*recovery/i);
|
|
488
|
+
});
|
|
489
|
+
it("does not net currencies or pending funds when checking expiry recovery", async () => {
|
|
490
|
+
const s = await expiredRecipient();
|
|
491
|
+
s.provider.balance.retrieve.mockResolvedValue({
|
|
492
|
+
available: [
|
|
493
|
+
{ currency: "usd", amount: 100 },
|
|
494
|
+
{ currency: "eur", amount: -100 },
|
|
495
|
+
],
|
|
496
|
+
pending: [{ currency: "usd", amount: 25 }],
|
|
497
|
+
});
|
|
498
|
+
expect(await s.client.expire(s.ctx, scope)).toMatchObject({
|
|
499
|
+
status: "expiry_unresolved",
|
|
500
|
+
});
|
|
501
|
+
expect((await s.client.get(s.ctx, scope))!.holdingExpiry!.balances).toEqual([
|
|
502
|
+
{ currency: "eur", available: -100, pending: 0 },
|
|
503
|
+
{ currency: "usd", available: 100, pending: 25 },
|
|
504
|
+
]);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("does not label a delayed expiry balance response as a fresh observation", async () => {
|
|
508
|
+
vi.useFakeTimers();
|
|
509
|
+
try {
|
|
510
|
+
const s = await expiredRecipient();
|
|
511
|
+
s.provider.balance.retrieve.mockImplementation(async () => {
|
|
512
|
+
vi.advanceTimersByTime(31000);
|
|
513
|
+
return { available: [{ currency: "usd", amount: 0 }], pending: [] };
|
|
514
|
+
});
|
|
515
|
+
expect(await s.client.expire(s.ctx, scope)).toMatchObject({
|
|
516
|
+
status: "expiry_unresolved",
|
|
517
|
+
});
|
|
518
|
+
expect((await s.client.get(s.ctx, scope))!.holdingExpiry).toMatchObject({
|
|
519
|
+
status: "unverified",
|
|
520
|
+
});
|
|
521
|
+
} finally {
|
|
522
|
+
vi.useRealTimers();
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("retries a lost recovery write without losing its original deadline or restoring twice", async () => {
|
|
527
|
+
const s = await expiredRecipient();
|
|
528
|
+
const write = s.ctx.runMutation!;
|
|
529
|
+
s.ctx.runMutation = (async (ref: Record<symbol, string>, args: never) => {
|
|
530
|
+
if (ref[symbol].endsWith("/observeHoldingExpiry"))
|
|
531
|
+
throw new Error("recovery write failed");
|
|
532
|
+
return write(ref as never, args);
|
|
533
|
+
}) as never;
|
|
534
|
+
await expect(s.client.expire(s.ctx, scope)).rejects.toThrow(
|
|
535
|
+
/recovery write failed/,
|
|
536
|
+
);
|
|
537
|
+
expect(await s.client.get(s.ctx, scope)).toMatchObject({
|
|
538
|
+
mode: "stripe_managed",
|
|
539
|
+
holdDeadline: s.deadline,
|
|
540
|
+
holdingExpiry: { status: "unverified" },
|
|
541
|
+
});
|
|
542
|
+
s.ctx.runMutation = write;
|
|
543
|
+
expect(await s.client.expire(s.ctx, scope)).toMatchObject({
|
|
544
|
+
status: "ready",
|
|
545
|
+
});
|
|
546
|
+
expect(s.provider.balanceSettings.update).toHaveBeenCalledTimes(2);
|
|
547
|
+
});
|
|
548
|
+
it("concurrent expiry retries preserve one restoration identity and unresolved funds", async () => {
|
|
549
|
+
const s = await expiredRecipient();
|
|
550
|
+
s.provider.balance.retrieve.mockResolvedValue({
|
|
551
|
+
available: [{ currency: "usd", amount: 999 }],
|
|
552
|
+
pending: [],
|
|
553
|
+
});
|
|
554
|
+
const results = await Promise.allSettled([
|
|
555
|
+
s.client.expire(s.ctx, scope),
|
|
556
|
+
s.client.expire(s.ctx, scope),
|
|
557
|
+
]);
|
|
558
|
+
expect(results.some((r) => r.status === "fulfilled")).toBe(true);
|
|
559
|
+
expect(await s.client.expire(s.ctx, scope)).toMatchObject({
|
|
560
|
+
status: "expiry_unresolved",
|
|
561
|
+
});
|
|
562
|
+
expect(await s.client.get(s.ctx, scope)).toMatchObject({
|
|
563
|
+
mode: "stripe_managed",
|
|
564
|
+
holdDeadline: s.deadline,
|
|
565
|
+
holdingExpiry: { status: "balance_remaining" },
|
|
566
|
+
});
|
|
567
|
+
const restoreKeys = s.provider.balanceSettings.update.mock.calls
|
|
568
|
+
.slice(1)
|
|
569
|
+
.map((c) => (c[1] as { idempotencyKey: string }).idempotencyKey);
|
|
570
|
+
expect(new Set(restoreKeys).size).toBe(1);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("accepts a due policy document as scope without forwarding its extra fields to mutations", async () => {
|
|
574
|
+
const s = await expiredRecipient();
|
|
575
|
+
const policy = (await s.client.get(s.ctx, scope))!;
|
|
576
|
+
expect(await s.client.expire(s.ctx, policy)).toMatchObject({
|
|
577
|
+
status: "ready",
|
|
578
|
+
});
|
|
579
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { holdingExpiry as payoutHoldingExpiryValidator } from "../component/payoutPolicy/schema.js";
|
|
2
|
+
import { expirePayoutPolicy } from "./connect/payoutExpiry.js";
|
|
1
3
|
import {
|
|
2
4
|
previewRecipientPayout,
|
|
3
5
|
type PayoutPreviewOptions,
|
|
@@ -570,28 +572,12 @@ export class PayoutPolicyClient {
|
|
|
570
572
|
}
|
|
571
573
|
/** A host cron must page due() and call expire(); payout execution is a separate boundary. */
|
|
572
574
|
async expire(ctx: RunCtx, scope: PayoutPolicyScope) {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
});
|
|
581
|
-
if (result.status !== "ready") return result;
|
|
582
|
-
policy = await this.get(ctx, scope);
|
|
583
|
-
}
|
|
584
|
-
if (!policy || policy.mode !== "custom")
|
|
585
|
-
return { status: "not_due" as const };
|
|
586
|
-
const operationKey = `bs:payout-expiry:${scope.accountId}:${policy.revision}`;
|
|
587
|
-
const prepared = await this.prepare(ctx, {
|
|
588
|
-
...scope,
|
|
589
|
-
operationKey,
|
|
590
|
-
expectedRevision: policy.revision,
|
|
591
|
-
mode: "stripe_managed",
|
|
592
|
-
thresholds: [],
|
|
593
|
-
});
|
|
594
|
-
if (prepared.status !== "prepared") return prepared;
|
|
595
|
-
return this.apply(ctx, { ...scope, operationKey });
|
|
575
|
+
return expirePayoutPolicy(
|
|
576
|
+
this.provider(),
|
|
577
|
+
this.component,
|
|
578
|
+
ctx,
|
|
579
|
+
scope,
|
|
580
|
+
this,
|
|
581
|
+
);
|
|
596
582
|
}
|
|
597
583
|
}
|
|
@@ -47,6 +47,7 @@ import type * as lib_paymentSeller from "../lib/paymentSeller.js";
|
|
|
47
47
|
import type * as lib_reversals from "../lib/reversals.js";
|
|
48
48
|
import type * as lib_stripeFactory from "../lib/stripeFactory.js";
|
|
49
49
|
import type * as payoutPolicy_adoption from "../payoutPolicy/adoption.js";
|
|
50
|
+
import type * as payoutPolicy_expiry from "../payoutPolicy/expiry.js";
|
|
50
51
|
import type * as payoutPolicy_helpers from "../payoutPolicy/helpers.js";
|
|
51
52
|
import type * as payoutPolicy_mutations from "../payoutPolicy/mutations.js";
|
|
52
53
|
import type * as payoutPolicy_queries from "../payoutPolicy/queries.js";
|
|
@@ -132,6 +133,7 @@ const fullApi: ApiFromModules<{
|
|
|
132
133
|
"lib/reversals": typeof lib_reversals;
|
|
133
134
|
"lib/stripeFactory": typeof lib_stripeFactory;
|
|
134
135
|
"payoutPolicy/adoption": typeof payoutPolicy_adoption;
|
|
136
|
+
"payoutPolicy/expiry": typeof payoutPolicy_expiry;
|
|
135
137
|
"payoutPolicy/helpers": typeof payoutPolicy_helpers;
|
|
136
138
|
"payoutPolicy/mutations": typeof payoutPolicy_mutations;
|
|
137
139
|
"payoutPolicy/queries": typeof payoutPolicy_queries;
|
|
@@ -2790,6 +2790,44 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2790
2790
|
},
|
|
2791
2791
|
Name
|
|
2792
2792
|
>;
|
|
2793
|
+
beginHoldingExpiry: FunctionReference<
|
|
2794
|
+
"mutation",
|
|
2795
|
+
"internal",
|
|
2796
|
+
{ accountId: string; ownerKey: string },
|
|
2797
|
+
{
|
|
2798
|
+
_creationTime: number;
|
|
2799
|
+
_id: string;
|
|
2800
|
+
accountId: string;
|
|
2801
|
+
country: string;
|
|
2802
|
+
holdDeadline?: number;
|
|
2803
|
+
holdingExpiry?: {
|
|
2804
|
+
balances?: Array<{
|
|
2805
|
+
available: number;
|
|
2806
|
+
currency: string;
|
|
2807
|
+
pending: number;
|
|
2808
|
+
}>;
|
|
2809
|
+
deadline: number;
|
|
2810
|
+
observedAt?: number;
|
|
2811
|
+
status:
|
|
2812
|
+
| "unverified"
|
|
2813
|
+
| "balance_remaining"
|
|
2814
|
+
| "claims_unresolved"
|
|
2815
|
+
| "no_held_balance";
|
|
2816
|
+
};
|
|
2817
|
+
mode: "stripe_managed" | "custom";
|
|
2818
|
+
operationKey: string;
|
|
2819
|
+
ownerKey: string;
|
|
2820
|
+
previousSchedule?: {
|
|
2821
|
+
interval: "daily" | "weekly" | "monthly" | "manual";
|
|
2822
|
+
monthlyDays?: Array<number>;
|
|
2823
|
+
weeklyDays?: Array<string>;
|
|
2824
|
+
};
|
|
2825
|
+
revision: number;
|
|
2826
|
+
status: "changing" | "ready";
|
|
2827
|
+
thresholds: Array<{ amount: number; currency: string }>;
|
|
2828
|
+
},
|
|
2829
|
+
Name
|
|
2830
|
+
>;
|
|
2793
2831
|
cancelUnattempted: FunctionReference<
|
|
2794
2832
|
"mutation",
|
|
2795
2833
|
"internal",
|
|
@@ -2906,6 +2944,55 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
2906
2944
|
},
|
|
2907
2945
|
Name
|
|
2908
2946
|
>;
|
|
2947
|
+
observeHoldingExpiry: FunctionReference<
|
|
2948
|
+
"mutation",
|
|
2949
|
+
"internal",
|
|
2950
|
+
{
|
|
2951
|
+
accountId: string;
|
|
2952
|
+
balances: Array<{
|
|
2953
|
+
available: number;
|
|
2954
|
+
currency: string;
|
|
2955
|
+
pending: number;
|
|
2956
|
+
}>;
|
|
2957
|
+
deadline: number;
|
|
2958
|
+
expectedRevision: number;
|
|
2959
|
+
observedAt: number;
|
|
2960
|
+
ownerKey: string;
|
|
2961
|
+
},
|
|
2962
|
+
{
|
|
2963
|
+
_creationTime: number;
|
|
2964
|
+
_id: string;
|
|
2965
|
+
accountId: string;
|
|
2966
|
+
country: string;
|
|
2967
|
+
holdDeadline?: number;
|
|
2968
|
+
holdingExpiry?: {
|
|
2969
|
+
balances?: Array<{
|
|
2970
|
+
available: number;
|
|
2971
|
+
currency: string;
|
|
2972
|
+
pending: number;
|
|
2973
|
+
}>;
|
|
2974
|
+
deadline: number;
|
|
2975
|
+
observedAt?: number;
|
|
2976
|
+
status:
|
|
2977
|
+
| "unverified"
|
|
2978
|
+
| "balance_remaining"
|
|
2979
|
+
| "claims_unresolved"
|
|
2980
|
+
| "no_held_balance";
|
|
2981
|
+
};
|
|
2982
|
+
mode: "stripe_managed" | "custom";
|
|
2983
|
+
operationKey: string;
|
|
2984
|
+
ownerKey: string;
|
|
2985
|
+
previousSchedule?: {
|
|
2986
|
+
interval: "daily" | "weekly" | "monthly" | "manual";
|
|
2987
|
+
monthlyDays?: Array<number>;
|
|
2988
|
+
weeklyDays?: Array<string>;
|
|
2989
|
+
};
|
|
2990
|
+
revision: number;
|
|
2991
|
+
status: "changing" | "ready";
|
|
2992
|
+
thresholds: Array<{ amount: number; currency: string }>;
|
|
2993
|
+
},
|
|
2994
|
+
Name
|
|
2995
|
+
>;
|
|
2909
2996
|
prepare: FunctionReference<
|
|
2910
2997
|
"mutation",
|
|
2911
2998
|
"internal",
|
|
@@ -3007,6 +3094,20 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
3007
3094
|
accountId: string;
|
|
3008
3095
|
country: string;
|
|
3009
3096
|
holdDeadline?: number;
|
|
3097
|
+
holdingExpiry?: {
|
|
3098
|
+
balances?: Array<{
|
|
3099
|
+
available: number;
|
|
3100
|
+
currency: string;
|
|
3101
|
+
pending: number;
|
|
3102
|
+
}>;
|
|
3103
|
+
deadline: number;
|
|
3104
|
+
observedAt?: number;
|
|
3105
|
+
status:
|
|
3106
|
+
| "unverified"
|
|
3107
|
+
| "balance_remaining"
|
|
3108
|
+
| "claims_unresolved"
|
|
3109
|
+
| "no_held_balance";
|
|
3110
|
+
};
|
|
3010
3111
|
mode: "stripe_managed" | "custom";
|
|
3011
3112
|
operationKey: string;
|
|
3012
3113
|
ownerKey: string;
|
|
@@ -3032,6 +3133,20 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
3032
3133
|
accountId: string;
|
|
3033
3134
|
country: string;
|
|
3034
3135
|
holdDeadline?: number;
|
|
3136
|
+
holdingExpiry?: {
|
|
3137
|
+
balances?: Array<{
|
|
3138
|
+
available: number;
|
|
3139
|
+
currency: string;
|
|
3140
|
+
pending: number;
|
|
3141
|
+
}>;
|
|
3142
|
+
deadline: number;
|
|
3143
|
+
observedAt?: number;
|
|
3144
|
+
status:
|
|
3145
|
+
| "unverified"
|
|
3146
|
+
| "balance_remaining"
|
|
3147
|
+
| "claims_unresolved"
|
|
3148
|
+
| "no_held_balance";
|
|
3149
|
+
};
|
|
3035
3150
|
mode: "stripe_managed" | "custom";
|
|
3036
3151
|
operationKey: string;
|
|
3037
3152
|
ownerKey: string;
|
|
@@ -4063,6 +4178,20 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
4063
4178
|
accountId: string;
|
|
4064
4179
|
country: string;
|
|
4065
4180
|
holdDeadline?: number;
|
|
4181
|
+
holdingExpiry?: {
|
|
4182
|
+
balances?: Array<{
|
|
4183
|
+
available: number;
|
|
4184
|
+
currency: string;
|
|
4185
|
+
pending: number;
|
|
4186
|
+
}>;
|
|
4187
|
+
deadline: number;
|
|
4188
|
+
observedAt?: number;
|
|
4189
|
+
status:
|
|
4190
|
+
| "unverified"
|
|
4191
|
+
| "balance_remaining"
|
|
4192
|
+
| "claims_unresolved"
|
|
4193
|
+
| "no_held_balance";
|
|
4194
|
+
};
|
|
4066
4195
|
mode: "stripe_managed" | "custom";
|
|
4067
4196
|
operationKey: string;
|
|
4068
4197
|
ownerKey: string;
|
|
@@ -4124,6 +4253,20 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
4124
4253
|
accountId: string;
|
|
4125
4254
|
country: string;
|
|
4126
4255
|
holdDeadline?: number;
|
|
4256
|
+
holdingExpiry?: {
|
|
4257
|
+
balances?: Array<{
|
|
4258
|
+
available: number;
|
|
4259
|
+
currency: string;
|
|
4260
|
+
pending: number;
|
|
4261
|
+
}>;
|
|
4262
|
+
deadline: number;
|
|
4263
|
+
observedAt?: number;
|
|
4264
|
+
status:
|
|
4265
|
+
| "unverified"
|
|
4266
|
+
| "balance_remaining"
|
|
4267
|
+
| "claims_unresolved"
|
|
4268
|
+
| "no_held_balance";
|
|
4269
|
+
};
|
|
4127
4270
|
mode: "stripe_managed" | "custom";
|
|
4128
4271
|
operationKey: string;
|
|
4129
4272
|
ownerKey: string;
|