@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +3 -1
  3. package/dist/client/connect/payoutExecutionTestHelpers.js +1 -1
  4. package/dist/client/connect/payoutExecutionTestHelpers.js.map +1 -1
  5. package/dist/component/_generated/component.d.ts +2 -0
  6. package/dist/component/_generated/component.d.ts.map +1 -1
  7. package/dist/component/payoutRelease/deadline.d.ts +10 -0
  8. package/dist/component/payoutRelease/deadline.d.ts.map +1 -0
  9. package/dist/component/payoutRelease/deadline.js +20 -0
  10. package/dist/component/payoutRelease/deadline.js.map +1 -0
  11. package/dist/component/payoutRelease/planning.d.ts +1 -0
  12. package/dist/component/payoutRelease/planning.d.ts.map +1 -1
  13. package/dist/component/payoutRelease/planning.js +5 -2
  14. package/dist/component/payoutRelease/planning.js.map +1 -1
  15. package/dist/component/payoutRelease/preview.d.ts +7 -1
  16. package/dist/component/payoutRelease/preview.d.ts.map +1 -1
  17. package/dist/component/payoutRelease/preview.js +5 -1
  18. package/dist/component/payoutRelease/preview.js.map +1 -1
  19. package/dist/component/payoutRelease/queries.d.ts +2 -0
  20. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  21. package/dist/component/payoutWorker/mutations.d.ts.map +1 -1
  22. package/dist/component/payoutWorker/mutations.js +12 -4
  23. package/dist/component/payoutWorker/mutations.js.map +1 -1
  24. package/dist/react/workflows/PayoutSettings.d.ts.map +1 -1
  25. package/dist/react/workflows/PayoutSettings.js +1 -1
  26. package/dist/react/workflows/PayoutSettings.js.map +1 -1
  27. package/dist/react/workflows/payout-progress-state.d.ts.map +1 -1
  28. package/dist/react/workflows/payout-progress-state.js +23 -13
  29. package/dist/react/workflows/payout-progress-state.js.map +1 -1
  30. package/docs/react-workflows.md +9 -1
  31. package/docs/settlement.md +22 -0
  32. package/package.json +1 -1
  33. package/src/client/connect/payoutDeadline.test.ts +168 -0
  34. package/src/client/connect/payoutExecutionTestHelpers.ts +1 -1
  35. package/src/client/payoutPolicy.test.ts +34 -0
  36. package/src/component/_generated/api.ts +2 -0
  37. package/src/component/_generated/component.ts +2 -0
  38. package/src/component/payoutRelease/deadline.ts +24 -0
  39. package/src/component/payoutRelease/planning.ts +5 -2
  40. package/src/component/payoutRelease/preview.ts +5 -0
  41. package/src/component/payoutWorker/mutations.ts +20 -4
  42. package/src/react/workflows/PayoutProgress.test.tsx +63 -5
  43. package/src/react/workflows/PayoutSettings.tsx +4 -1
  44. 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
- threshold: minimum.amount,
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
- if (job && job.nextAttemptAt > now)
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 + args.cadenceMs,
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 + args.cadenceMs;
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: Date.now() + job.cadenceMs,
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("does not reuse a prior assessment while the schedule change is pending", () => {
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.getByRole("status").textContent).toContain("being verified");
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. Other currency minimums are preserved.
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 (!policy || policy.mode === "stripe_managed") {
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
- preview.status === "qualified"
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
- value: Math.max(0, Math.min(preview.threshold, preview.matureUnpaid)),
140
- max: preview.threshold,
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],