@fantastic.dev/better-stripe 0.28.1 → 0.28.3
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 +3 -1
- package/dist/client/connect/payoutExecutionTestHelpers.js +1 -1
- package/dist/client/connect/payoutExecutionTestHelpers.js.map +1 -1
- package/dist/component/_generated/component.d.ts +2 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/payoutRelease/deadline.d.ts +10 -0
- package/dist/component/payoutRelease/deadline.d.ts.map +1 -0
- package/dist/component/payoutRelease/deadline.js +20 -0
- package/dist/component/payoutRelease/deadline.js.map +1 -0
- package/dist/component/payoutRelease/planning.d.ts +1 -0
- package/dist/component/payoutRelease/planning.d.ts.map +1 -1
- package/dist/component/payoutRelease/planning.js +5 -2
- package/dist/component/payoutRelease/planning.js.map +1 -1
- package/dist/component/payoutRelease/preview.d.ts +7 -1
- package/dist/component/payoutRelease/preview.d.ts.map +1 -1
- package/dist/component/payoutRelease/preview.js +5 -1
- package/dist/component/payoutRelease/preview.js.map +1 -1
- package/dist/component/payoutRelease/queries.d.ts +2 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -1
- package/dist/component/payoutWorker/mutations.d.ts.map +1 -1
- package/dist/component/payoutWorker/mutations.js +12 -4
- package/dist/component/payoutWorker/mutations.js.map +1 -1
- package/dist/react/workflows/PayoutSettings.d.ts.map +1 -1
- package/dist/react/workflows/PayoutSettings.js +1 -1
- package/dist/react/workflows/PayoutSettings.js.map +1 -1
- package/dist/react/workflows/payout-progress-state.d.ts.map +1 -1
- package/dist/react/workflows/payout-progress-state.js +23 -13
- package/dist/react/workflows/payout-progress-state.js.map +1 -1
- package/docs/react-workflows.md +9 -1
- package/docs/settlement.md +22 -0
- package/package.json +1 -1
- package/src/client/connect/payoutDeadline.test.ts +168 -0
- package/src/client/connect/payoutExecutionTestHelpers.ts +1 -1
- package/src/client/payoutPolicy.test.ts +34 -0
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +2 -0
- package/src/component/payoutRelease/deadline.ts +24 -0
- package/src/component/payoutRelease/planning.ts +5 -2
- package/src/component/payoutRelease/preview.ts +5 -0
- package/src/component/payoutWorker/mutations.ts +20 -4
- package/src/react/workflows/PayoutProgress.test.tsx +63 -5
- package/src/react/workflows/PayoutSettings.tsx +4 -1
- package/src/react/workflows/payout-progress-state.ts +26 -12
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// @vitest-environment edge-runtime
|
|
2
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
3
|
+
import type Stripe from "stripe";
|
|
4
|
+
import { setup, scope, component } from "./payoutExecutionTestHelpers.js";
|
|
5
|
+
import { PayoutWorkerClient } from "./payoutWorker.js";
|
|
6
|
+
import { reconcileRecipientPayout } from "./payoutReconciliation.js";
|
|
7
|
+
import { previewRecipientPayout } from "./payoutPreview.js";
|
|
8
|
+
afterEach(() => vi.useRealTimers());
|
|
9
|
+
async function recipient(hours: number, amounts = [599, 400]) {
|
|
10
|
+
const s = await setup(amounts);
|
|
11
|
+
const deadline = Date.now() + hours * 3600000;
|
|
12
|
+
await s.t.run(async (ctx) => {
|
|
13
|
+
const policy = (await ctx.db.query("payoutPolicies").first())!;
|
|
14
|
+
await ctx.db.patch(policy._id, { holdDeadline: deadline });
|
|
15
|
+
});
|
|
16
|
+
const client = new PayoutWorkerClient(
|
|
17
|
+
component,
|
|
18
|
+
(_ctx, opts) => s.run(opts),
|
|
19
|
+
(_ctx, opts) =>
|
|
20
|
+
reconcileRecipientPayout(
|
|
21
|
+
s.provider as unknown as Stripe,
|
|
22
|
+
component,
|
|
23
|
+
s.ctx,
|
|
24
|
+
opts,
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
const run = (cadenceMs = 300000) =>
|
|
28
|
+
client.run(s.ctx, { ...scope, cadenceMs });
|
|
29
|
+
return { ...s, deadline, client, worker: run };
|
|
30
|
+
}
|
|
31
|
+
it("the scheduled worker releases below-custom-minimum funds in the final day", async () => {
|
|
32
|
+
const s = await recipient(12);
|
|
33
|
+
expect(await s.worker()).toMatchObject({
|
|
34
|
+
payout: { claim: { status: "paid", amount: 999 } },
|
|
35
|
+
});
|
|
36
|
+
expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
|
|
37
|
+
const policy = await s.t.run((ctx) => ctx.db.query("payoutPolicies").first());
|
|
38
|
+
expect(policy).toMatchObject({
|
|
39
|
+
thresholds: [{ currency: "usd", amount: 1000 }],
|
|
40
|
+
holdDeadline: s.deadline,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
it("retains the configured minimum before the final day", async () => {
|
|
44
|
+
const s = await recipient(48);
|
|
45
|
+
expect(await s.worker()).toMatchObject({
|
|
46
|
+
payout: { status: "held", reason: "below_threshold" },
|
|
47
|
+
});
|
|
48
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
it("the final attempt still subtracts known liabilities", async () => {
|
|
51
|
+
const s = await recipient(12);
|
|
52
|
+
expect(await s.run({ independentLiabilities: 100 })).toMatchObject({
|
|
53
|
+
claim: { status: "paid", amount: 899 },
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
it("previews the deadline waiver without changing the configured minimum or reserving money", async () => {
|
|
57
|
+
const s = await recipient(12);
|
|
58
|
+
expect(
|
|
59
|
+
await previewRecipientPayout(
|
|
60
|
+
s.provider as unknown as Stripe,
|
|
61
|
+
component,
|
|
62
|
+
s.ctx,
|
|
63
|
+
scope,
|
|
64
|
+
s.verify,
|
|
65
|
+
),
|
|
66
|
+
).toMatchObject({
|
|
67
|
+
status: "qualified",
|
|
68
|
+
amount: 999,
|
|
69
|
+
threshold: 1000,
|
|
70
|
+
minimumWaived: true,
|
|
71
|
+
holdDeadline: s.deadline,
|
|
72
|
+
providerMinimum: null,
|
|
73
|
+
});
|
|
74
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
75
|
+
expect(
|
|
76
|
+
await s.t.run((ctx) => ctx.db.query("payoutReleaseClaims").collect()),
|
|
77
|
+
).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
it("caps a daily cadence at the beginning of the final-attempt window", async () => {
|
|
80
|
+
vi.useFakeTimers();
|
|
81
|
+
const s = await recipient(25);
|
|
82
|
+
expect(await s.worker(86400000)).toMatchObject({
|
|
83
|
+
payout: { reason: "below_threshold" },
|
|
84
|
+
job: { nextAttemptAt: s.deadline - 86400000 },
|
|
85
|
+
});
|
|
86
|
+
vi.advanceTimersByTime(3600000);
|
|
87
|
+
expect(await s.worker(86400000)).toMatchObject({
|
|
88
|
+
payout: { claim: { amount: 999, status: "paid" } },
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
it("retains an unknown final-attempt claim and reconciles without another create", async () => {
|
|
92
|
+
vi.useFakeTimers();
|
|
93
|
+
const s = await recipient(12);
|
|
94
|
+
const create = s.provider.payouts.create.getMockImplementation()!;
|
|
95
|
+
s.provider.payouts.create.mockImplementationOnce(async (params) => {
|
|
96
|
+
await create(params);
|
|
97
|
+
throw new Error("response lost after provider accepted");
|
|
98
|
+
});
|
|
99
|
+
await expect(s.worker()).rejects.toThrow(/response lost/);
|
|
100
|
+
vi.advanceTimersByTime(300000);
|
|
101
|
+
expect(await s.worker()).toMatchObject({
|
|
102
|
+
payout: { claim: { amount: 999, status: "paid" } },
|
|
103
|
+
});
|
|
104
|
+
expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it.each(["balance_insufficient", "amount_too_small"])(
|
|
108
|
+
"the final attempt preserves provider rejection %s",
|
|
109
|
+
async (code) => {
|
|
110
|
+
const s = await recipient(12);
|
|
111
|
+
s.provider.payouts.create.mockRejectedValueOnce(
|
|
112
|
+
Object.assign(new Error(code), {
|
|
113
|
+
type: "StripeInvalidRequestError",
|
|
114
|
+
code,
|
|
115
|
+
requestId: "req_declined",
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
expect(await s.worker()).toMatchObject({
|
|
119
|
+
payout: { claim: { status: "rejected", rejection: { code } } },
|
|
120
|
+
});
|
|
121
|
+
expect(
|
|
122
|
+
await s.t.run((ctx) => ctx.db.query("payoutSourceAllocations").collect()),
|
|
123
|
+
).toMatchObject([{ reservedAmount: 0 }, { reservedAmount: 0 }]);
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
it("an exhausted balance cannot qualify a zero-amount final payout", async () => {
|
|
127
|
+
const s = await recipient(12);
|
|
128
|
+
expect(await s.run({ independentLiabilities: 999 })).toMatchObject({
|
|
129
|
+
status: "held",
|
|
130
|
+
amount: 0,
|
|
131
|
+
});
|
|
132
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
it("does not begin a new payout after the holding deadline", async () => {
|
|
135
|
+
const s = await recipient(-1);
|
|
136
|
+
expect(await s.worker()).toMatchObject({
|
|
137
|
+
status: "held",
|
|
138
|
+
reason: "policy_inactive",
|
|
139
|
+
});
|
|
140
|
+
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("retries an unknown final attempt within five minutes even with a daily cadence", async () => {
|
|
144
|
+
vi.useFakeTimers();
|
|
145
|
+
const s = await recipient(12);
|
|
146
|
+
s.provider.payouts.create.mockRejectedValueOnce(
|
|
147
|
+
new Error("network outcome unknown"),
|
|
148
|
+
);
|
|
149
|
+
await expect(s.worker(86400000)).rejects.toThrow(/network outcome unknown/);
|
|
150
|
+
expect((await s.client.get(s.ctx, scope))!.nextAttemptAt).toBe(
|
|
151
|
+
Date.now() + 300000,
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("rescues a pre-existing job whose stored cadence would skip the final window", async () => {
|
|
156
|
+
vi.useFakeTimers();
|
|
157
|
+
const s = await recipient(25);
|
|
158
|
+
await s.worker(86400000);
|
|
159
|
+
await s.t.run(async (ctx) => {
|
|
160
|
+
const job = (await ctx.db.query("payoutWorkerJobs").first())!;
|
|
161
|
+
await ctx.db.patch(job._id, { nextAttemptAt: Date.now() + 86400000 });
|
|
162
|
+
});
|
|
163
|
+
vi.advanceTimersByTime(3600000);
|
|
164
|
+
expect(await s.worker(86400000)).toMatchObject({
|
|
165
|
+
payout: { claim: { amount: 999, status: "paid" } },
|
|
166
|
+
});
|
|
167
|
+
expect(s.provider.payouts.create).toHaveBeenCalledTimes(1);
|
|
168
|
+
});
|
|
@@ -32,7 +32,7 @@ export async function setup(amounts = [600, 400]) {
|
|
|
32
32
|
operationKey: "enable",
|
|
33
33
|
country: "US",
|
|
34
34
|
thresholds: [{ currency: "usd", amount: 1000 }],
|
|
35
|
-
holdDeadline: Date.now() + 86400000,
|
|
35
|
+
holdDeadline: Date.now() + 7 * 86400000,
|
|
36
36
|
});
|
|
37
37
|
for (let i = 0; i < amounts.length; i++)
|
|
38
38
|
await ctx.db.insert("transfers", {
|
|
@@ -242,6 +242,40 @@ it("restores original settings at the holding deadline, without extending it", a
|
|
|
242
242
|
expect((await client.get(ctx, scope))!.mode).toBe("stripe_managed");
|
|
243
243
|
expect(provider.balanceSettings.update).toHaveBeenCalledTimes(2);
|
|
244
244
|
});
|
|
245
|
+
it("retains pending expiry restoration and retries the same provider operation", async () => {
|
|
246
|
+
const { client, ctx, t, provider } = await setup();
|
|
247
|
+
await client.prepare(ctx, options);
|
|
248
|
+
await client.apply(ctx, { ...scope, operationKey: "enable" });
|
|
249
|
+
await t.run(async (c) => {
|
|
250
|
+
const p = await c.db.query("payoutPolicies").first();
|
|
251
|
+
await c.db.patch(p!._id, { holdDeadline: Date.now() - 1 });
|
|
252
|
+
});
|
|
253
|
+
provider.balanceSettings.update.mockRejectedValueOnce(
|
|
254
|
+
new Error("restoration timeout"),
|
|
255
|
+
);
|
|
256
|
+
await expect(client.expire(ctx, scope)).rejects.toThrow(
|
|
257
|
+
"restoration timeout",
|
|
258
|
+
);
|
|
259
|
+
const pending = await client.get(ctx, scope);
|
|
260
|
+
expect(pending).toMatchObject({
|
|
261
|
+
mode: "custom",
|
|
262
|
+
status: "changing",
|
|
263
|
+
previousSchedule: { interval: "weekly", weeklyDays: ["monday"] },
|
|
264
|
+
});
|
|
265
|
+
expect(pending!.holdDeadline).toBeLessThan(Date.now());
|
|
266
|
+
await client.expire(ctx, scope);
|
|
267
|
+
expect(await client.get(ctx, scope)).toMatchObject({
|
|
268
|
+
mode: "stripe_managed",
|
|
269
|
+
status: "ready",
|
|
270
|
+
});
|
|
271
|
+
expect(provider.balanceSettings.update).toHaveBeenCalledTimes(3);
|
|
272
|
+
expect(provider.balanceSettings.update.mock.calls[1][1]).toEqual(
|
|
273
|
+
provider.balanceSettings.update.mock.calls[2][1],
|
|
274
|
+
);
|
|
275
|
+
expect(
|
|
276
|
+
(await provider.balanceSettings.retrieve()).payments.payouts.schedule,
|
|
277
|
+
).toMatchObject({ interval: "weekly", weekly_payout_days: ["monday"] });
|
|
278
|
+
});
|
|
245
279
|
it("re-checks live control on every payout-policy verification", async () => {
|
|
246
280
|
const { client, ctx, setSchedule } = await setup();
|
|
247
281
|
await client.prepare(ctx, options);
|
|
@@ -53,6 +53,7 @@ import type * as payoutPolicy_queries from "../payoutPolicy/queries.js";
|
|
|
53
53
|
import type * as payoutRelease_adoption from "../payoutRelease/adoption.js";
|
|
54
54
|
import type * as payoutRelease_allocation from "../payoutRelease/allocation.js";
|
|
55
55
|
import type * as payoutRelease_allocationTotals from "../payoutRelease/allocationTotals.js";
|
|
56
|
+
import type * as payoutRelease_deadline from "../payoutRelease/deadline.js";
|
|
56
57
|
import type * as payoutRelease_fees from "../payoutRelease/fees.js";
|
|
57
58
|
import type * as payoutRelease_helpers from "../payoutRelease/helpers.js";
|
|
58
59
|
import type * as payoutRelease_mutations from "../payoutRelease/mutations.js";
|
|
@@ -137,6 +138,7 @@ const fullApi: ApiFromModules<{
|
|
|
137
138
|
"payoutRelease/adoption": typeof payoutRelease_adoption;
|
|
138
139
|
"payoutRelease/allocation": typeof payoutRelease_allocation;
|
|
139
140
|
"payoutRelease/allocationTotals": typeof payoutRelease_allocationTotals;
|
|
141
|
+
"payoutRelease/deadline": typeof payoutRelease_deadline;
|
|
140
142
|
"payoutRelease/fees": typeof payoutRelease_fees;
|
|
141
143
|
"payoutRelease/helpers": typeof payoutRelease_helpers;
|
|
142
144
|
"payoutRelease/mutations": typeof payoutRelease_mutations;
|
|
@@ -3639,8 +3639,10 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
3639
3639
|
amount: number;
|
|
3640
3640
|
confirmedFees: number;
|
|
3641
3641
|
currency: string;
|
|
3642
|
+
holdDeadline?: number;
|
|
3642
3643
|
independentLiabilities: number;
|
|
3643
3644
|
matureUnpaid: number;
|
|
3645
|
+
minimumWaived?: boolean;
|
|
3644
3646
|
observedAt: number;
|
|
3645
3647
|
pendingCredits: number;
|
|
3646
3648
|
providerAvailable: number;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** One operational day to retry final payouts before the country holding deadline. */
|
|
2
|
+
export const finalAttemptWindowMs = 86400000;
|
|
3
|
+
type Policy = { holdDeadline?: number } | null;
|
|
4
|
+
export function finalAttemptStartsAt(policy: Policy): number | undefined {
|
|
5
|
+
return policy?.holdDeadline === undefined
|
|
6
|
+
? undefined
|
|
7
|
+
: policy.holdDeadline - finalAttemptWindowMs;
|
|
8
|
+
}
|
|
9
|
+
export function waiveApplicationMinimum(policy: Policy, now: number): boolean {
|
|
10
|
+
const start = finalAttemptStartsAt(policy);
|
|
11
|
+
return start !== undefined && now >= start && now < policy!.holdDeadline!;
|
|
12
|
+
}
|
|
13
|
+
export function nextPayoutAttempt(
|
|
14
|
+
policy: Policy,
|
|
15
|
+
now: number,
|
|
16
|
+
cadence: number,
|
|
17
|
+
) {
|
|
18
|
+
const start = finalAttemptStartsAt(policy);
|
|
19
|
+
if (waiveApplicationMinimum(policy, now))
|
|
20
|
+
return now + Math.min(cadence, 300000);
|
|
21
|
+
return start !== undefined && start > now
|
|
22
|
+
? Math.min(now + cadence, start)
|
|
23
|
+
: now + cadence;
|
|
24
|
+
}
|
|
@@ -5,6 +5,7 @@ import type { observation } from "./observation.js";
|
|
|
5
5
|
import { checkSnapshot, sourceById } from "./helpers.js";
|
|
6
6
|
import { readFees } from "./fees.js";
|
|
7
7
|
import { planRecipientPayout, type PayoutFundingSource } from "./allocation.js";
|
|
8
|
+
import { waiveApplicationMinimum } from "./deadline.js";
|
|
8
9
|
import { assertAllocationTotals } from "./allocationTotals.js";
|
|
9
10
|
export async function readPayoutPlan(
|
|
10
11
|
ctx: MutationCtx | QueryCtx,
|
|
@@ -45,13 +46,15 @@ export async function readPayoutPlan(
|
|
|
45
46
|
}
|
|
46
47
|
const fees = await readFees(ctx, args);
|
|
47
48
|
await assertAllocationTotals(ctx, args, sources);
|
|
49
|
+
const minimumWaived = waiveApplicationMinimum(policy, Date.now());
|
|
48
50
|
const plan = planRecipientPayout({
|
|
49
51
|
currency: args.currency,
|
|
50
|
-
|
|
52
|
+
// One minor unit is the positive-money guard, not an asserted provider floor.
|
|
53
|
+
threshold: minimumWaived ? 1 : minimum.amount,
|
|
51
54
|
providerAvailable: args.providerAvailable,
|
|
52
55
|
independentLiabilities: args.independentLiabilities,
|
|
53
56
|
confirmedFees: fees?.amount ?? 0,
|
|
54
57
|
sources,
|
|
55
58
|
});
|
|
56
|
-
return { plan, sources, fees, threshold: minimum.amount };
|
|
59
|
+
return { plan, sources, fees, threshold: minimum.amount, minimumWaived };
|
|
57
60
|
}
|
|
@@ -28,6 +28,8 @@ export const previewResult = v.union(
|
|
|
28
28
|
currency: v.string(),
|
|
29
29
|
amount: v.number(),
|
|
30
30
|
threshold: v.number(),
|
|
31
|
+
minimumWaived: v.optional(v.boolean()),
|
|
32
|
+
holdDeadline: v.optional(v.number()),
|
|
31
33
|
providerMinimum: v.null(),
|
|
32
34
|
matureUnpaid: v.number(),
|
|
33
35
|
pendingCredits: v.number(),
|
|
@@ -81,6 +83,7 @@ export async function previewPlan(
|
|
|
81
83
|
sources: funding,
|
|
82
84
|
fees,
|
|
83
85
|
threshold,
|
|
86
|
+
minimumWaived,
|
|
84
87
|
} = await readPayoutPlan(ctx, args, policy);
|
|
85
88
|
const progress = summarizePayoutProgress(
|
|
86
89
|
plan,
|
|
@@ -93,6 +96,8 @@ export async function previewPlan(
|
|
|
93
96
|
currency: plan.currency,
|
|
94
97
|
amount: plan.amount,
|
|
95
98
|
threshold,
|
|
99
|
+
minimumWaived,
|
|
100
|
+
holdDeadline: policy.holdDeadline,
|
|
96
101
|
providerMinimum: null,
|
|
97
102
|
matureUnpaid: plan.matureUnpaid,
|
|
98
103
|
pendingCredits: plan.pendingCredits,
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { v } from "convex/values";
|
|
2
2
|
import { mutation } from "../_generated/server.js";
|
|
3
|
+
import {
|
|
4
|
+
finalAttemptStartsAt,
|
|
5
|
+
nextPayoutAttempt,
|
|
6
|
+
} from "../payoutRelease/deadline.js";
|
|
3
7
|
import { readPolicy } from "../payoutPolicy/helpers.js";
|
|
4
8
|
import { claimByKey, add } from "../payoutRelease/helpers.js";
|
|
5
9
|
import { balance as recoveryBalance } from "../affiliateRecovery/accounting.js";
|
|
@@ -38,7 +42,15 @@ export const begin = mutation({
|
|
|
38
42
|
reason: "worker_busy",
|
|
39
43
|
nextAttemptAt: job.leaseUntil,
|
|
40
44
|
};
|
|
41
|
-
|
|
45
|
+
const finalStart = finalAttemptStartsAt(policy);
|
|
46
|
+
const enteringFinalWindow =
|
|
47
|
+
policy?.mode === "custom" &&
|
|
48
|
+
policy.status === "ready" &&
|
|
49
|
+
finalStart !== undefined &&
|
|
50
|
+
finalStart <= now &&
|
|
51
|
+
policy.holdDeadline! > now &&
|
|
52
|
+
(job?.lastAttemptAt ?? 0) < finalStart;
|
|
53
|
+
if (job && job.nextAttemptAt > now && !enteringFinalWindow)
|
|
42
54
|
return {
|
|
43
55
|
status: "held" as const,
|
|
44
56
|
reason: "not_due",
|
|
@@ -65,7 +77,7 @@ export const begin = mutation({
|
|
|
65
77
|
hasOperation: false,
|
|
66
78
|
leaseUntil: undefined,
|
|
67
79
|
lastReason: "policy_inactive",
|
|
68
|
-
nextAttemptAt: now
|
|
80
|
+
nextAttemptAt: nextPayoutAttempt(policy, now, args.cadenceMs),
|
|
69
81
|
});
|
|
70
82
|
return { status: "held" as const, reason: "policy_inactive" };
|
|
71
83
|
}
|
|
@@ -95,7 +107,7 @@ export const begin = mutation({
|
|
|
95
107
|
recovery.reservedRestoration > 0 ||
|
|
96
108
|
recovery.activePayoutKey)
|
|
97
109
|
) {
|
|
98
|
-
const nextAttemptAt = now
|
|
110
|
+
const nextAttemptAt = nextPayoutAttempt(policy, now, args.cadenceMs);
|
|
99
111
|
await ctx.db.patch("payoutWorkerJobs", job._id, {
|
|
100
112
|
operationKey: undefined,
|
|
101
113
|
hasOperation: false,
|
|
@@ -171,7 +183,11 @@ export const finish = mutation({
|
|
|
171
183
|
operationKey: retain ? job.operationKey : undefined,
|
|
172
184
|
hasOperation: retain,
|
|
173
185
|
leaseUntil: undefined,
|
|
174
|
-
nextAttemptAt:
|
|
186
|
+
nextAttemptAt: nextPayoutAttempt(
|
|
187
|
+
await readPolicy(ctx, args),
|
|
188
|
+
Date.now(),
|
|
189
|
+
job.cadenceMs,
|
|
190
|
+
),
|
|
175
191
|
lastReason: args.reason,
|
|
176
192
|
completedGeneration: args.generation,
|
|
177
193
|
completedOperationKey: args.operationKey,
|
|
@@ -90,6 +90,20 @@ it("shows verified carry forward, withholding, in-flight and paid totals separat
|
|
|
90
90
|
expect(screen.getByRole("columnheader", { name: "Paid out" })).toBeTruthy();
|
|
91
91
|
expect(screen.getByRole("columnheader", { name: "In flight" })).toBeTruthy();
|
|
92
92
|
});
|
|
93
|
+
it("explains an expired holding deadline without inventing a payable balance", () => {
|
|
94
|
+
render(
|
|
95
|
+
<PayoutProgress
|
|
96
|
+
policy={policy}
|
|
97
|
+
currency="usd"
|
|
98
|
+
preview={{ status: "held", reason: "expired", amount: 0 }}
|
|
99
|
+
/>,
|
|
100
|
+
);
|
|
101
|
+
expect(screen.getByRole("status").textContent).toContain(
|
|
102
|
+
"holding deadline has passed",
|
|
103
|
+
);
|
|
104
|
+
expect(screen.queryByRole("progressbar")).toBeNull();
|
|
105
|
+
expect(screen.queryByText("$0.00")).toBeNull();
|
|
106
|
+
});
|
|
93
107
|
it("does not invent new accounting totals for a legacy assessment", () => {
|
|
94
108
|
render(<PayoutProgress policy={policy} preview={preview} currency="usd" />);
|
|
95
109
|
expect(screen.queryByText("Carried forward")).toBeNull();
|
|
@@ -103,16 +117,37 @@ it("keeps Stripe-managed defaults distinct from a custom application minimum", (
|
|
|
103
117
|
expect(screen.queryByRole("progressbar")).toBeNull();
|
|
104
118
|
expect(screen.queryByText("$1.00")).toBeNull();
|
|
105
119
|
});
|
|
106
|
-
it("
|
|
120
|
+
it.each(["custom", "stripe_managed"] as const)(
|
|
121
|
+
"keeps %s mode unverified while the schedule change is pending",
|
|
122
|
+
(mode) => {
|
|
123
|
+
render(
|
|
124
|
+
<PayoutProgress
|
|
125
|
+
policy={{ ...policy, mode, status: "changing" }}
|
|
126
|
+
preview={preview}
|
|
127
|
+
currency="usd"
|
|
128
|
+
/>,
|
|
129
|
+
);
|
|
130
|
+
expect(screen.getByRole("status").textContent).toContain("being verified");
|
|
131
|
+
expect(screen.queryByRole("progressbar")).toBeNull();
|
|
132
|
+
expect(screen.queryByText(/Stripe manages automatic payouts/)).toBeNull();
|
|
133
|
+
expect(screen.queryByText("Combined qualifying balance")).toBeNull();
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
it("gives host cards an unverified state during activation from Stripe-managed defaults", () => {
|
|
107
137
|
render(
|
|
108
138
|
<PayoutProgress
|
|
109
|
-
policy={{ ...policy, status: "changing" }}
|
|
139
|
+
policy={{ ...policy, mode: "stripe_managed", status: "changing" }}
|
|
110
140
|
preview={preview}
|
|
111
141
|
currency="usd"
|
|
112
|
-
|
|
142
|
+
>
|
|
143
|
+
{(state) => (
|
|
144
|
+
<span data-testid="restoration">
|
|
145
|
+
{state.status}:{state.metrics.length}
|
|
146
|
+
</span>
|
|
147
|
+
)}
|
|
148
|
+
</PayoutProgress>,
|
|
113
149
|
);
|
|
114
|
-
expect(screen.
|
|
115
|
-
expect(screen.queryByRole("progressbar")).toBeNull();
|
|
150
|
+
expect(screen.getByTestId("restoration").textContent).toBe("changing:0");
|
|
116
151
|
});
|
|
117
152
|
it("preserves negative entitlement while clamping only the visual progress", () => {
|
|
118
153
|
render(
|
|
@@ -206,3 +241,26 @@ it("does not display a former custom holding deadline for Stripe-managed payouts
|
|
|
206
241
|
);
|
|
207
242
|
expect(screen.queryByText(/Custom holding deadline:/)).toBeNull();
|
|
208
243
|
});
|
|
244
|
+
|
|
245
|
+
it("explains the deadline waiver without a minimum progress bar", () => {
|
|
246
|
+
render(
|
|
247
|
+
<PayoutProgress
|
|
248
|
+
policy={policy}
|
|
249
|
+
currency="usd"
|
|
250
|
+
preview={{
|
|
251
|
+
...preview,
|
|
252
|
+
status: "qualified",
|
|
253
|
+
reason: undefined,
|
|
254
|
+
amount: 999,
|
|
255
|
+
minimumWaived: true,
|
|
256
|
+
holdDeadline: policy.holdDeadline,
|
|
257
|
+
}}
|
|
258
|
+
/>,
|
|
259
|
+
);
|
|
260
|
+
expect(screen.getByRole("status").textContent).toContain("holding deadline");
|
|
261
|
+
expect(screen.getByRole("status").textContent).toContain(
|
|
262
|
+
"application minimum is waived",
|
|
263
|
+
);
|
|
264
|
+
expect(screen.queryByRole("progressbar")).toBeNull();
|
|
265
|
+
expect(screen.getByText("Combined payout minimum")).toBeTruthy();
|
|
266
|
+
});
|
|
@@ -179,7 +179,10 @@ export function PayoutSettings(props: PayoutSettingsProps) {
|
|
|
179
179
|
</label>
|
|
180
180
|
<p>
|
|
181
181
|
Store and affiliate earnings combine per currency. Below-minimum
|
|
182
|
-
funds remain owed.
|
|
182
|
+
funds remain owed. During the final day before the holding deadline,
|
|
183
|
+
the application minimum is waived for automatic payout attempts.
|
|
184
|
+
Stripe payout limits and balance checks still apply. Other currency
|
|
185
|
+
minimums are preserved.
|
|
183
186
|
</p>
|
|
184
187
|
</>
|
|
185
188
|
)}
|
|
@@ -72,6 +72,8 @@ const reasons: Record<string, string> = {
|
|
|
72
72
|
payout_unidentified:
|
|
73
73
|
"A previous payout outcome needs reconciliation. No replacement payout will be created blindly.",
|
|
74
74
|
observations_stale: "Provider observations expired. Refresh the assessment.",
|
|
75
|
+
expired:
|
|
76
|
+
"The custom holding deadline has passed. Eligibility is unavailable until the payout schedule is restored and verified.",
|
|
75
77
|
snapshot_limit:
|
|
76
78
|
"This account needs continued history reconciliation before eligibility can be assessed.",
|
|
77
79
|
policy_changed: "Payout settings changed. Refresh the assessment.",
|
|
@@ -98,14 +100,14 @@ export function payoutProgressState({
|
|
|
98
100
|
observedAt: null,
|
|
99
101
|
};
|
|
100
102
|
if (policy !== undefined) {
|
|
101
|
-
if (
|
|
102
|
-
state.status = "stripe_managed";
|
|
103
|
-
state.message =
|
|
104
|
-
"Stripe manages automatic payouts. No additional application minimum is set.";
|
|
105
|
-
} else if (policy.status === "changing") {
|
|
103
|
+
if (policy?.status === "changing") {
|
|
106
104
|
state.status = "changing";
|
|
107
105
|
state.message =
|
|
108
106
|
"Payout settings are being verified. Eligibility is unavailable until the change completes.";
|
|
107
|
+
} else if (!policy || policy.mode === "stripe_managed") {
|
|
108
|
+
state.status = "stripe_managed";
|
|
109
|
+
state.message =
|
|
110
|
+
"Stripe manages automatic payouts. No additional application minimum is set.";
|
|
109
111
|
} else if (preview !== undefined) {
|
|
110
112
|
state.nextAttemptAt = nextAttemptAt ?? null;
|
|
111
113
|
if (!preview || !("currency" in preview)) {
|
|
@@ -121,24 +123,36 @@ export function payoutProgressState({
|
|
|
121
123
|
if (
|
|
122
124
|
preview.currency !== currency ||
|
|
123
125
|
!configured ||
|
|
124
|
-
configured.amount !== preview.threshold
|
|
126
|
+
configured.amount !== preview.threshold ||
|
|
127
|
+
(preview.minimumWaived &&
|
|
128
|
+
preview.holdDeadline !== policy.holdDeadline)
|
|
125
129
|
)
|
|
126
130
|
throw new Error(
|
|
127
131
|
"Payout progress currency or policy does not match the assessment.",
|
|
128
132
|
);
|
|
129
133
|
state.status = "assessed";
|
|
130
134
|
state.reason = preview.reason ?? null;
|
|
131
|
-
state.message =
|
|
132
|
-
|
|
135
|
+
state.message = preview.minimumWaived
|
|
136
|
+
? "The holding deadline is approaching. The application minimum is waived for final automatic attempts; provider limits and all balance checks still apply. " +
|
|
137
|
+
(preview.status === "qualified"
|
|
138
|
+
? "The worker will verify eligibility again before any payout."
|
|
139
|
+
: (reasons[preview.reason ?? ""] ??
|
|
140
|
+
"Automatic payout remains on hold. Contact support before the deadline."))
|
|
141
|
+
: preview.status === "qualified"
|
|
133
142
|
? "The combined balance qualified at the assessment time. The automatic worker will verify it again before any payout."
|
|
134
143
|
: (reasons[preview.reason ?? ""] ??
|
|
135
144
|
"Automatic payout is on hold. Refresh or contact support.");
|
|
136
145
|
state.observedAt = preview.observedAt;
|
|
137
146
|
state.accounting = preview.accounting;
|
|
138
|
-
state.progress =
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
147
|
+
state.progress = preview.minimumWaived
|
|
148
|
+
? null
|
|
149
|
+
: {
|
|
150
|
+
value: Math.max(
|
|
151
|
+
0,
|
|
152
|
+
Math.min(preview.threshold, preview.matureUnpaid),
|
|
153
|
+
),
|
|
154
|
+
max: preview.threshold,
|
|
155
|
+
};
|
|
142
156
|
const definitions = [
|
|
143
157
|
["qualifying", "Combined qualifying balance", preview.matureUnpaid],
|
|
144
158
|
["pending", "Pending settlement", preview.pendingCredits],
|