@fantastic.dev/better-stripe 0.28.3 → 0.28.5
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 +5 -1
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +102 -4
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts +51 -2
- 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 +41 -2
- package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutWorker.d.ts +4 -1
- package/dist/client/connect/payoutWorker.d.ts.map +1 -1
- package/dist/client/connect/payoutWorker.js +35 -1
- package/dist/client/connect/payoutWorker.js.map +1 -1
- package/dist/client/payoutPolicy.d.ts +20 -2
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +5 -25
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/component/_generated/component.d.ts +120 -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 +26 -4
- 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/helpers.d.ts +1 -0
- package/dist/component/payoutWorker/helpers.d.ts.map +1 -1
- package/dist/component/payoutWorker/mutations.d.ts +2 -0
- package/dist/component/payoutWorker/mutations.d.ts.map +1 -1
- package/dist/component/payoutWorker/mutations.js +3 -0
- package/dist/component/payoutWorker/mutations.js.map +1 -1
- package/dist/component/payoutWorker/queries.d.ts +22 -0
- package/dist/component/payoutWorker/queries.d.ts.map +1 -1
- package/dist/component/payoutWorker/schema.d.ts +18 -2
- package/dist/component/payoutWorker/schema.d.ts.map +1 -1
- package/dist/component/payoutWorker/schema.js +7 -0
- package/dist/component/payoutWorker/schema.js.map +1 -1
- package/dist/component/schema.d.ts +41 -2
- package/dist/react/workflows/PayoutAttemptStatus.d.ts +14 -0
- package/dist/react/workflows/PayoutAttemptStatus.d.ts.map +1 -0
- package/dist/react/workflows/PayoutAttemptStatus.js +13 -0
- package/dist/react/workflows/PayoutAttemptStatus.js.map +1 -0
- package/dist/react/workflows/PayoutProgress.d.ts.map +1 -1
- package/dist/react/workflows/PayoutProgress.js +3 -2
- package/dist/react/workflows/PayoutProgress.js.map +1 -1
- package/dist/react/workflows/index.d.ts +2 -0
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +1 -0
- package/dist/react/workflows/index.js.map +1 -1
- package/dist/react/workflows/payout-attempt-state.d.ts +9 -0
- package/dist/react/workflows/payout-attempt-state.d.ts.map +1 -0
- package/dist/react/workflows/payout-attempt-state.js +47 -0
- package/dist/react/workflows/payout-attempt-state.js.map +1 -0
- package/dist/react/workflows/payout-progress-state.d.ts +16 -2
- package/dist/react/workflows/payout-progress-state.d.ts.map +1 -1
- package/dist/react/workflows/payout-progress-state.js +37 -2
- package/dist/react/workflows/payout-progress-state.js.map +1 -1
- package/docs/react-workflows.md +30 -0
- package/docs/settlement.md +47 -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/connect/payoutWorker.test.ts +57 -1
- package/src/client/connect/payoutWorker.ts +46 -2
- package/src/client/payoutPolicy.test.ts +196 -0
- package/src/client/payoutPolicy.ts +11 -23
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +147 -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/component/payoutWorker/mutations.ts +3 -0
- package/src/component/payoutWorker/schema.ts +15 -0
- package/src/react/workflows/PayoutAttemptStatus.tsx +44 -0
- package/src/react/workflows/PayoutProgress.test.tsx +90 -0
- package/src/react/workflows/PayoutProgress.tsx +31 -1
- package/src/react/workflows/index.ts +6 -0
- package/src/react/workflows/payout-attempt-state.ts +70 -0
- package/src/react/workflows/payout-progress-state.ts +62 -2
|
@@ -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
|
+
}
|
|
@@ -3,7 +3,7 @@ import { afterEach, expect, it, vi } from "vitest";
|
|
|
3
3
|
import type Stripe from "stripe";
|
|
4
4
|
import { setup, scope, component } from "./payoutExecutionTestHelpers.js";
|
|
5
5
|
import { reconcileRecipientPayout } from "./payoutReconciliation.js";
|
|
6
|
-
import { PayoutWorkerClient } from "./payoutWorker.js";
|
|
6
|
+
import { PayoutWorkerClient, payoutAttemptStatus } from "./payoutWorker.js";
|
|
7
7
|
import { changeBalance } from "../../component/affiliateRecovery/accounting.js";
|
|
8
8
|
afterEach(() => vi.useRealTimers());
|
|
9
9
|
async function worker(amounts?: number[]) {
|
|
@@ -143,3 +143,59 @@ it("reports affiliate recovery holds before a fresh attempt without creating a p
|
|
|
143
143
|
});
|
|
144
144
|
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
145
145
|
});
|
|
146
|
+
|
|
147
|
+
it.each(["balance_insufficient", "amount_too_small"])(
|
|
148
|
+
"persists actionable %s rejection and its result time",
|
|
149
|
+
async (code) => {
|
|
150
|
+
const s = await worker();
|
|
151
|
+
s.provider.payouts.create.mockRejectedValueOnce(
|
|
152
|
+
Object.assign(new Error(code), {
|
|
153
|
+
type: "StripeInvalidRequestError",
|
|
154
|
+
code,
|
|
155
|
+
requestId: "req_rejected",
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
expect(await s.run()).toMatchObject({
|
|
159
|
+
job: { lastReason: code, lastResultAt: expect.any(Number) },
|
|
160
|
+
payout: { claim: { status: "rejected", rejection: { code } } },
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
it("does not reuse a prior paid result or timestamp for a newer running generation", async () => {
|
|
166
|
+
const s = await worker();
|
|
167
|
+
const result = await s.run();
|
|
168
|
+
if (result.status !== "completed")
|
|
169
|
+
throw new Error("Expected completed fixture");
|
|
170
|
+
const active = {
|
|
171
|
+
...result.job,
|
|
172
|
+
generation: result.job.generation + 1,
|
|
173
|
+
completedGeneration: result.job.generation,
|
|
174
|
+
hasOperation: true,
|
|
175
|
+
operationKey: "new-operation",
|
|
176
|
+
lastReason: "paid",
|
|
177
|
+
lastResultAt: 1000,
|
|
178
|
+
lastAttemptAt: 2000,
|
|
179
|
+
leaseUntil: 3000,
|
|
180
|
+
};
|
|
181
|
+
expect(payoutAttemptStatus(active, 2100)).toEqual({
|
|
182
|
+
currency: "usd",
|
|
183
|
+
status: "running",
|
|
184
|
+
reason: "pending_claim",
|
|
185
|
+
at: 2000,
|
|
186
|
+
});
|
|
187
|
+
expect(payoutAttemptStatus(active, 4000)).toEqual({
|
|
188
|
+
currency: "usd",
|
|
189
|
+
status: "reconciling",
|
|
190
|
+
reason: "pending_claim",
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
it("keeps legacy result times unknown rather than inventing a completion time", async () => {
|
|
194
|
+
const s = await worker();
|
|
195
|
+
const result = await s.run();
|
|
196
|
+
if (result.status !== "completed")
|
|
197
|
+
throw new Error("Expected completed fixture");
|
|
198
|
+
expect(
|
|
199
|
+
payoutAttemptStatus({ ...result.job, lastResultAt: undefined }),
|
|
200
|
+
).toEqual({ currency: "usd", status: "paid", reason: "paid" });
|
|
201
|
+
});
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Infer } from "convex/values";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
jobDoc,
|
|
4
|
+
attemptStatus,
|
|
5
|
+
} from "../../component/payoutWorker/schema.js";
|
|
3
6
|
import type { policyDoc } from "../../component/payoutPolicy/schema.js";
|
|
4
7
|
import type { Component, RunCtx } from "../helpers.js";
|
|
5
8
|
import { runMutationOrThrow } from "../helpers.js";
|
|
@@ -138,7 +141,48 @@ export class PayoutWorkerClient {
|
|
|
138
141
|
throw error;
|
|
139
142
|
}
|
|
140
143
|
const reason =
|
|
141
|
-
payout.status === "held"
|
|
144
|
+
payout.status === "held"
|
|
145
|
+
? payout.reason
|
|
146
|
+
: (payout.claim.rejection?.code ?? payout.claim.status);
|
|
142
147
|
return { status: "completed", job: await finish(reason), payout };
|
|
143
148
|
}
|
|
144
149
|
}
|
|
150
|
+
|
|
151
|
+
export type PayoutAttempt = Infer<typeof attemptStatus>;
|
|
152
|
+
/** Current lease and generation take precedence over a prior result. Legacy timestamps remain unknown. */
|
|
153
|
+
export function payoutAttemptStatus(
|
|
154
|
+
job: PayoutWorkerJob | null,
|
|
155
|
+
now = Date.now(),
|
|
156
|
+
): PayoutAttempt | null {
|
|
157
|
+
if (!job || (!job.hasOperation && !job.lastReason)) return null;
|
|
158
|
+
const completed = job.completedGeneration === job.generation;
|
|
159
|
+
const running = job.hasOperation && (job.leaseUntil ?? 0) > now;
|
|
160
|
+
const reason =
|
|
161
|
+
job.hasOperation && !completed
|
|
162
|
+
? "pending_claim"
|
|
163
|
+
: (job.lastReason ?? "pending_claim");
|
|
164
|
+
const status = running
|
|
165
|
+
? "running"
|
|
166
|
+
: job.hasOperation
|
|
167
|
+
? "reconciling"
|
|
168
|
+
: ["balance_insufficient", "amount_too_small", "rejected"].includes(
|
|
169
|
+
reason,
|
|
170
|
+
)
|
|
171
|
+
? "rejected"
|
|
172
|
+
: reason === "paid"
|
|
173
|
+
? "paid"
|
|
174
|
+
: ["worker_error", "returned"].includes(reason)
|
|
175
|
+
? "failed"
|
|
176
|
+
: "held";
|
|
177
|
+
const at = running
|
|
178
|
+
? job.lastAttemptAt
|
|
179
|
+
: job.hasOperation && !completed
|
|
180
|
+
? undefined
|
|
181
|
+
: job.lastResultAt;
|
|
182
|
+
return {
|
|
183
|
+
currency: job.currency,
|
|
184
|
+
status,
|
|
185
|
+
reason,
|
|
186
|
+
...(at === undefined ? {} : { at }),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -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,7 @@
|
|
|
1
|
+
export { payoutAttemptStatus } from "./connect/payoutWorker.js";
|
|
2
|
+
export { attemptStatus as payoutAttemptValidator } from "../component/payoutWorker/schema.js";
|
|
3
|
+
export { holdingExpiry as payoutHoldingExpiryValidator } from "../component/payoutPolicy/schema.js";
|
|
4
|
+
import { expirePayoutPolicy } from "./connect/payoutExpiry.js";
|
|
1
5
|
import {
|
|
2
6
|
previewRecipientPayout,
|
|
3
7
|
type PayoutPreviewOptions,
|
|
@@ -570,28 +574,12 @@ export class PayoutPolicyClient {
|
|
|
570
574
|
}
|
|
571
575
|
/** A host cron must page due() and call expire(); payout execution is a separate boundary. */
|
|
572
576
|
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 });
|
|
577
|
+
return expirePayoutPolicy(
|
|
578
|
+
this.provider(),
|
|
579
|
+
this.component,
|
|
580
|
+
ctx,
|
|
581
|
+
scope,
|
|
582
|
+
this,
|
|
583
|
+
);
|
|
596
584
|
}
|
|
597
585
|
}
|
|
@@ -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;
|