@fantastic.dev/better-stripe 0.28.2 → 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.
Files changed (96) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +5 -1
  3. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +96 -2
  4. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
  5. package/dist/client/connect/payoutExecutionTestHelpers.d.ts +48 -1
  6. package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -1
  7. package/dist/client/connect/payoutExecutionTestHelpers.js +1 -1
  8. package/dist/client/connect/payoutExecutionTestHelpers.js.map +1 -1
  9. package/dist/client/connect/payoutExpiry.d.ts +42 -0
  10. package/dist/client/connect/payoutExpiry.d.ts.map +1 -0
  11. package/dist/client/connect/payoutExpiry.js +93 -0
  12. package/dist/client/connect/payoutExpiry.js.map +1 -0
  13. package/dist/client/connect/payoutSyncTestHelpers.d.ts +38 -1
  14. package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -1
  15. package/dist/client/payoutPolicy.d.ts +18 -2
  16. package/dist/client/payoutPolicy.d.ts.map +1 -1
  17. package/dist/client/payoutPolicy.js +3 -25
  18. package/dist/client/payoutPolicy.js.map +1 -1
  19. package/dist/component/_generated/component.d.ts +118 -0
  20. package/dist/component/_generated/component.d.ts.map +1 -1
  21. package/dist/component/core/resetSafety.d.ts.map +1 -1
  22. package/dist/component/core/resetSafety.js +7 -1
  23. package/dist/component/core/resetSafety.js.map +1 -1
  24. package/dist/component/earnings/triggers.d.ts +22 -2
  25. package/dist/component/earnings/triggers.d.ts.map +1 -1
  26. package/dist/component/payoutPolicy/expiry.d.ts +78 -0
  27. package/dist/component/payoutPolicy/expiry.d.ts.map +1 -0
  28. package/dist/component/payoutPolicy/expiry.js +57 -0
  29. package/dist/component/payoutPolicy/expiry.js.map +1 -0
  30. package/dist/component/payoutPolicy/helpers.d.ts +10 -0
  31. package/dist/component/payoutPolicy/helpers.d.ts.map +1 -1
  32. package/dist/component/payoutPolicy/mutations.d.ts +77 -0
  33. package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
  34. package/dist/component/payoutPolicy/mutations.js +36 -3
  35. package/dist/component/payoutPolicy/mutations.js.map +1 -1
  36. package/dist/component/payoutPolicy/queries.d.ts +20 -0
  37. package/dist/component/payoutPolicy/queries.d.ts.map +1 -1
  38. package/dist/component/payoutPolicy/schema.d.ts +139 -2
  39. package/dist/component/payoutPolicy/schema.d.ts.map +1 -1
  40. package/dist/component/payoutPolicy/schema.js +12 -0
  41. package/dist/component/payoutPolicy/schema.js.map +1 -1
  42. package/dist/component/payoutRelease/deadline.d.ts +10 -0
  43. package/dist/component/payoutRelease/deadline.d.ts.map +1 -0
  44. package/dist/component/payoutRelease/deadline.js +20 -0
  45. package/dist/component/payoutRelease/deadline.js.map +1 -0
  46. package/dist/component/payoutRelease/helpers.d.ts +10 -0
  47. package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
  48. package/dist/component/payoutRelease/planning.d.ts +1 -0
  49. package/dist/component/payoutRelease/planning.d.ts.map +1 -1
  50. package/dist/component/payoutRelease/planning.js +5 -2
  51. package/dist/component/payoutRelease/planning.js.map +1 -1
  52. package/dist/component/payoutRelease/preview.d.ts +7 -1
  53. package/dist/component/payoutRelease/preview.d.ts.map +1 -1
  54. package/dist/component/payoutRelease/preview.js +5 -1
  55. package/dist/component/payoutRelease/preview.js.map +1 -1
  56. package/dist/component/payoutRelease/queries.d.ts +2 -0
  57. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  58. package/dist/component/payoutWorker/mutations.d.ts.map +1 -1
  59. package/dist/component/payoutWorker/mutations.js +12 -4
  60. package/dist/component/payoutWorker/mutations.js.map +1 -1
  61. package/dist/component/payoutWorker/queries.d.ts +20 -0
  62. package/dist/component/payoutWorker/queries.d.ts.map +1 -1
  63. package/dist/component/schema.d.ts +38 -1
  64. package/dist/react/workflows/PayoutProgress.d.ts.map +1 -1
  65. package/dist/react/workflows/PayoutProgress.js +2 -2
  66. package/dist/react/workflows/PayoutProgress.js.map +1 -1
  67. package/dist/react/workflows/PayoutSettings.d.ts.map +1 -1
  68. package/dist/react/workflows/PayoutSettings.js +1 -1
  69. package/dist/react/workflows/PayoutSettings.js.map +1 -1
  70. package/dist/react/workflows/payout-progress-state.d.ts +11 -1
  71. package/dist/react/workflows/payout-progress-state.d.ts.map +1 -1
  72. package/dist/react/workflows/payout-progress-state.js +42 -7
  73. package/dist/react/workflows/payout-progress-state.js.map +1 -1
  74. package/docs/react-workflows.md +14 -0
  75. package/docs/settlement.md +50 -0
  76. package/package.json +1 -1
  77. package/src/client/connect/payoutDeadline.test.ts +238 -0
  78. package/src/client/connect/payoutExecutionTestHelpers.ts +1 -1
  79. package/src/client/connect/payoutExpiry.ts +132 -0
  80. package/src/client/payoutPolicy.test.ts +196 -0
  81. package/src/client/payoutPolicy.ts +9 -23
  82. package/src/component/_generated/api.ts +4 -0
  83. package/src/component/_generated/component.ts +145 -0
  84. package/src/component/core/resetSafety.ts +49 -33
  85. package/src/component/payout-reset-safety.test.ts +28 -0
  86. package/src/component/payoutPolicy/expiry.ts +80 -0
  87. package/src/component/payoutPolicy/mutations.ts +47 -2
  88. package/src/component/payoutPolicy/schema.ts +17 -0
  89. package/src/component/payoutRelease/deadline.ts +24 -0
  90. package/src/component/payoutRelease/planning.ts +5 -2
  91. package/src/component/payoutRelease/preview.ts +5 -0
  92. package/src/component/payoutWorker/mutations.ts +20 -4
  93. package/src/react/workflows/PayoutProgress.test.tsx +65 -0
  94. package/src/react/workflows/PayoutProgress.tsx +25 -1
  95. package/src/react/workflows/PayoutSettings.tsx +4 -1
  96. package/src/react/workflows/payout-progress-state.ts +60 -8
@@ -1,39 +1,51 @@
1
1
  import type { MutationCtx } from "../_generated/server.js";
2
2
  /** A database reset cannot erase the only restoration or unresolved-payout record. */
3
3
  export async function assertPayoutResetSafe(ctx: MutationCtx) {
4
- const [bound, custom, changing, reserved, submitted, pending, transit] =
5
- await Promise.all([
6
- ctx.db
7
- .query("payoutSnapshots")
8
- .withIndex("by_read_only", (q) => q.eq("readOnly", false))
9
- .first(),
10
- ctx.db
11
- .query("payoutPolicies")
12
- .withIndex("by_mode_status", (q) => q.eq("mode", "custom"))
13
- .first(),
14
- ctx.db
15
- .query("payoutPolicies")
16
- .withIndex("by_mode_status", (q) =>
17
- q.eq("mode", "stripe_managed").eq("status", "changing"),
18
- )
19
- .first(),
20
- ctx.db
21
- .query("payoutReleaseClaims")
22
- .withIndex("by_status", (q) => q.eq("status", "reserved"))
23
- .first(),
24
- ctx.db
25
- .query("payoutReleaseClaims")
26
- .withIndex("by_status", (q) => q.eq("status", "submitted"))
27
- .first(),
28
- ctx.db
29
- .query("payouts")
30
- .withIndex("by_status", (q) => q.eq("status", "pending"))
31
- .first(),
32
- ctx.db
33
- .query("payouts")
34
- .withIndex("by_status", (q) => q.eq("status", "in_transit"))
35
- .first(),
36
- ]);
4
+ const [
5
+ bound,
6
+ custom,
7
+ changing,
8
+ reserved,
9
+ submitted,
10
+ pending,
11
+ transit,
12
+ deadline,
13
+ ] = await Promise.all([
14
+ ctx.db
15
+ .query("payoutSnapshots")
16
+ .withIndex("by_read_only", (q) => q.eq("readOnly", false))
17
+ .first(),
18
+ ctx.db
19
+ .query("payoutPolicies")
20
+ .withIndex("by_mode_status", (q) => q.eq("mode", "custom"))
21
+ .first(),
22
+ ctx.db
23
+ .query("payoutPolicies")
24
+ .withIndex("by_mode_status", (q) =>
25
+ q.eq("mode", "stripe_managed").eq("status", "changing"),
26
+ )
27
+ .first(),
28
+ ctx.db
29
+ .query("payoutReleaseClaims")
30
+ .withIndex("by_status", (q) => q.eq("status", "reserved"))
31
+ .first(),
32
+ ctx.db
33
+ .query("payoutReleaseClaims")
34
+ .withIndex("by_status", (q) => q.eq("status", "submitted"))
35
+ .first(),
36
+ ctx.db
37
+ .query("payouts")
38
+ .withIndex("by_status", (q) => q.eq("status", "pending"))
39
+ .first(),
40
+ ctx.db
41
+ .query("payouts")
42
+ .withIndex("by_status", (q) => q.eq("status", "in_transit"))
43
+ .first(),
44
+ ctx.db
45
+ .query("payoutPolicies")
46
+ .withIndex("by_deadline", (q) => q.gt("holdDeadline", 0))
47
+ .first(),
48
+ ]);
37
49
  if (bound)
38
50
  throw new Error(
39
51
  "A financially bound payout collection must be resolved before reset.",
@@ -42,6 +54,10 @@ export async function assertPayoutResetSafe(ctx: MutationCtx) {
42
54
  throw new Error(
43
55
  "Restore and verify payout schedules before reset; restoration records cannot be erased.",
44
56
  );
57
+ if (deadline)
58
+ throw new Error(
59
+ "Resolve holding expiry recovery before reset; its deadline and balance record cannot be erased.",
60
+ );
45
61
  if (reserved || submitted)
46
62
  throw new Error(
47
63
  "Resolve payout claims before reset; unresolved reservations cannot be erased.",
@@ -146,3 +146,31 @@ it.each(["pending", "unknown"] as const)(
146
146
  ).toHaveLength(1);
147
147
  },
148
148
  );
149
+
150
+ it("retains an unresolved holding-expiry record after automatic scheduling was restored", async () => {
151
+ const t = convexTest(schema, modules);
152
+ await t.run((ctx) =>
153
+ ctx.db.insert("payoutPolicies", {
154
+ ...scope,
155
+ revision: 1,
156
+ mode: "stripe_managed",
157
+ status: "ready",
158
+ operationKey: "restore",
159
+ country: "US",
160
+ thresholds: [],
161
+ holdDeadline: Date.now() - 1,
162
+ holdingExpiry: {
163
+ deadline: Date.now() - 1,
164
+ status: "balance_remaining",
165
+ balances: [{ currency: "usd", available: 999, pending: 0 }],
166
+ observedAt: Date.now(),
167
+ },
168
+ }),
169
+ );
170
+ await expect(t.mutation(reset, {})).rejects.toThrow(
171
+ /holding expiry.*recovery/i,
172
+ );
173
+ expect(
174
+ await t.run((ctx) => ctx.db.query("payoutPolicies").first()),
175
+ ).not.toBeNull();
176
+ });
@@ -0,0 +1,80 @@
1
+ import type { MutationCtx } from "../_generated/server.js";
2
+ import { readPolicy } from "./helpers.js";
3
+ type Scope = { accountId: string; ownerKey: string };
4
+ export async function beginHoldingExpiry(ctx: MutationCtx, args: Scope) {
5
+ const p = await readPolicy(ctx, args);
6
+ if (
7
+ !p ||
8
+ p.mode !== "custom" ||
9
+ !p.holdDeadline ||
10
+ p.holdDeadline > Date.now()
11
+ )
12
+ throw new Error("A due custom holding deadline is required.");
13
+ if (p.holdingExpiry?.deadline === p.holdDeadline) return p;
14
+ if (p.holdingExpiry && p.holdingExpiry.status !== "no_held_balance")
15
+ throw new Error("Previous holding expiry recovery is unresolved.");
16
+ await ctx.db.patch("payoutPolicies", p._id, {
17
+ holdingExpiry: { deadline: p.holdDeadline, status: "unverified" },
18
+ });
19
+ return (await ctx.db.get("payoutPolicies", p._id))!;
20
+ }
21
+ export async function observeHoldingExpiry(
22
+ ctx: MutationCtx,
23
+ args: Scope & {
24
+ expectedRevision: number;
25
+ deadline: number;
26
+ observedAt: number;
27
+ balances: { currency: string; available: number; pending: number }[];
28
+ },
29
+ ) {
30
+ const p = await readPolicy(ctx, args);
31
+ if (
32
+ !p ||
33
+ p.revision !== args.expectedRevision ||
34
+ p.mode !== "stripe_managed" ||
35
+ p.status !== "ready" ||
36
+ p.holdingExpiry?.deadline !== args.deadline ||
37
+ p.holdDeadline !== args.deadline
38
+ )
39
+ throw new Error("Holding expiry recovery changed.");
40
+ if (
41
+ !Number.isSafeInteger(args.observedAt) ||
42
+ args.observedAt > Date.now() ||
43
+ args.observedAt < Date.now() - 30000 ||
44
+ new Set(args.balances.map((b) => b.currency)).size !==
45
+ args.balances.length ||
46
+ args.balances.some(
47
+ (b) =>
48
+ !/^[a-z]{3}$/.test(b.currency) ||
49
+ !Number.isSafeInteger(b.available) ||
50
+ !Number.isSafeInteger(b.pending),
51
+ )
52
+ )
53
+ throw new Error("Invalid or stale holding expiry balance observation.");
54
+ const unresolved = await Promise.all(
55
+ (["reserved", "submitted"] as const).map((status) =>
56
+ ctx.db
57
+ .query("payoutReleaseClaims")
58
+ .withIndex("by_account_status", (q) =>
59
+ q.eq("accountId", args.accountId).eq("status", status),
60
+ )
61
+ .first(),
62
+ ),
63
+ );
64
+ const status = unresolved.some(Boolean)
65
+ ? ("claims_unresolved" as const)
66
+ : args.balances.some((b) => b.available !== 0 || b.pending !== 0)
67
+ ? ("balance_remaining" as const)
68
+ : ("no_held_balance" as const);
69
+ await ctx.db.patch("payoutPolicies", p._id, {
70
+ holdingExpiry: {
71
+ deadline: args.deadline,
72
+ observedAt: args.observedAt,
73
+ balances: args.balances,
74
+ status,
75
+ },
76
+ // Retain due discovery until both provider balances and durable claims clear.
77
+ holdDeadline: status === "no_held_balance" ? undefined : args.deadline,
78
+ });
79
+ return (await ctx.db.get("payoutPolicies", p._id))!;
80
+ }
@@ -1,8 +1,14 @@
1
+ import {
2
+ beginHoldingExpiry as beginExpiry,
3
+ observeHoldingExpiry as observeExpiry,
4
+ } from "./expiry.js";
1
5
  import { validateAdoption } from "./adoption.js";
2
6
  import { v } from "convex/values";
3
7
  import { mutation } from "../_generated/server.js";
4
8
  import {
5
9
  adoptionProof,
10
+ expiryBalance,
11
+ policyDoc,
6
12
  prepareFields,
7
13
  operationDoc,
8
14
  scopeFields,
@@ -69,6 +75,14 @@ export const prepare = mutation({
69
75
  )
70
76
  throw new Error("Payout policy observation is stale or in the future.");
71
77
  const policy = await readPolicy(ctx, args);
78
+ if (
79
+ args.mode === "custom" &&
80
+ policy?.holdingExpiry &&
81
+ policy.holdingExpiry.status !== "no_held_balance"
82
+ )
83
+ throw new Error(
84
+ "Holding expiry requires recovery before custom activation.",
85
+ );
72
86
  if (policy?.status === "changing")
73
87
  throw new Error("A payout policy operation is pending.");
74
88
  if ((policy?.revision ?? -1) !== args.expectedRevision)
@@ -177,7 +191,14 @@ export const confirm = mutation({
177
191
  thresholds: op.thresholds,
178
192
  status: "ready",
179
193
  ...(op.mode === "stripe_managed"
180
- ? { previousSchedule: undefined, holdDeadline: undefined }
194
+ ? {
195
+ previousSchedule: undefined,
196
+ holdDeadline:
197
+ policy.holdingExpiry &&
198
+ policy.holdingExpiry.status !== "no_held_balance"
199
+ ? policy.holdDeadline
200
+ : undefined,
201
+ }
181
202
  : {}),
182
203
  });
183
204
  return (await ctx.db.get("payoutPolicyOperations", op._id))!;
@@ -263,9 +284,33 @@ export const cancelUnattempted = mutation({
263
284
  await ctx.db.patch("payoutPolicies", policy._id, {
264
285
  status: "ready",
265
286
  ...(policy.mode === "stripe_managed"
266
- ? { previousSchedule: undefined, holdDeadline: undefined }
287
+ ? {
288
+ previousSchedule: undefined,
289
+ holdDeadline:
290
+ policy.holdingExpiry &&
291
+ policy.holdingExpiry.status !== "no_held_balance"
292
+ ? policy.holdDeadline
293
+ : undefined,
294
+ }
267
295
  : {}),
268
296
  });
269
297
  return (await ctx.db.get("payoutPolicyOperations", op._id))!;
270
298
  },
271
299
  });
300
+
301
+ export const beginHoldingExpiry = mutation({
302
+ args: scopeFields,
303
+ returns: policyDoc,
304
+ handler: beginExpiry,
305
+ });
306
+ export const observeHoldingExpiry = mutation({
307
+ args: {
308
+ ...scopeFields,
309
+ expectedRevision: v.number(),
310
+ deadline: v.number(),
311
+ observedAt: v.number(),
312
+ balances: v.array(expiryBalance),
313
+ },
314
+ returns: policyDoc,
315
+ handler: observeExpiry,
316
+ });
@@ -34,6 +34,22 @@ export const prepareFields = {
34
34
  observedSchedule: schedule,
35
35
  observedAt: v.optional(v.number()),
36
36
  };
37
+ export const expiryBalance = v.object({
38
+ currency: v.string(),
39
+ available: v.number(),
40
+ pending: v.number(),
41
+ });
42
+ export const holdingExpiry = v.object({
43
+ deadline: v.number(),
44
+ status: v.union(
45
+ v.literal("unverified"),
46
+ v.literal("balance_remaining"),
47
+ v.literal("claims_unresolved"),
48
+ v.literal("no_held_balance"),
49
+ ),
50
+ observedAt: v.optional(v.number()),
51
+ balances: v.optional(v.array(expiryBalance)),
52
+ });
37
53
  export const policyFields = {
38
54
  ...scopeFields,
39
55
  revision: v.number(),
@@ -44,6 +60,7 @@ export const policyFields = {
44
60
  country: v.string(),
45
61
  previousSchedule: v.optional(schedule),
46
62
  holdDeadline: v.optional(v.number()),
63
+ holdingExpiry: v.optional(holdingExpiry),
47
64
  };
48
65
  export const operationFields = {
49
66
  ...prepareFields,
@@ -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,
@@ -241,3 +241,68 @@ it("does not display a former custom holding deadline for Stripe-managed payouts
241
241
  );
242
242
  expect(screen.queryByText(/Custom holding deadline:/)).toBeNull();
243
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
+ });
267
+
268
+ it("shows expiry recovery after automatic schedule restoration without claiming disbursement", () => {
269
+ render(
270
+ <PayoutProgress
271
+ policy={{
272
+ ...policy,
273
+ mode: "stripe_managed",
274
+ thresholds: [],
275
+ holdingExpiry: {
276
+ deadline: policy.holdDeadline,
277
+ status: "balance_remaining",
278
+ observedAt: 1800000000000,
279
+ balances: [{ currency: "usd", available: 999, pending: 25 }],
280
+ },
281
+ }}
282
+ currency="usd"
283
+ />,
284
+ );
285
+ expect(screen.getByRole("status").textContent).toContain(
286
+ "does not confirm disbursement",
287
+ );
288
+ expect(screen.queryByText(/Stripe manages automatic payouts/)).toBeNull();
289
+ expect(screen.queryByRole("progressbar")).toBeNull();
290
+ expect(screen.getByText("$9.99")).toBeTruthy();
291
+ expect(screen.getByText("$0.25")).toBeTruthy();
292
+ });
293
+ it("keeps unavailable expiry balances unverified instead of showing fabricated zero", () => {
294
+ render(
295
+ <PayoutProgress
296
+ policy={{
297
+ ...policy,
298
+ mode: "stripe_managed",
299
+ holdingExpiry: { deadline: policy.holdDeadline, status: "unverified" },
300
+ }}
301
+ currency="usd"
302
+ />,
303
+ );
304
+ expect(screen.getByRole("status").textContent).toContain(
305
+ "verification is unavailable",
306
+ );
307
+ expect(screen.queryByText("$0.00")).toBeNull();
308
+ });
@@ -29,6 +29,25 @@ export function PayoutProgress(props: PayoutProgressProps) {
29
29
  <h2>Automatic payout progress ({currency.toUpperCase()})</h2>
30
30
  <p role="status">{state.message}</p>
31
31
  <p>Stripe enforces its payout floor; a numeric amount is unavailable.</p>
32
+ {state.recovery?.balances && (
33
+ <div>
34
+ <h3>Stripe balances at last expiry check</h3>
35
+ <p>
36
+ Provider balances are not a payable earnings assessment. Currencies
37
+ remain separate.
38
+ </p>
39
+ <dl>
40
+ {state.recovery.balances.map((balance) => (
41
+ <div key={balance.currency}>
42
+ <dt>{balance.currency.toUpperCase()} available</dt>
43
+ <dd>{balance.availableLabel}</dd>
44
+ <dt>{balance.currency.toUpperCase()} pending</dt>
45
+ <dd>{balance.pendingLabel}</dd>
46
+ </div>
47
+ ))}
48
+ </dl>
49
+ </div>
50
+ )}
32
51
  {state.progress && (
33
52
  <progress
34
53
  aria-label="Combined payout minimum"
@@ -102,7 +121,12 @@ export function PayoutProgress(props: PayoutProgressProps) {
102
121
  {state.holdDeadline !== null && (
103
122
  <p>Custom holding deadline: {date(state.holdDeadline)}</p>
104
123
  )}
105
- {state.observedAt !== null && <p>Assessed: {date(state.observedAt)}</p>}
124
+ {state.observedAt !== null && (
125
+ <p>
126
+ {state.recovery ? "Last recovery check" : "Assessed"}:{" "}
127
+ {date(state.observedAt)}
128
+ </p>
129
+ )}
106
130
  </section>
107
131
  );
108
132
  }
@@ -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
  )}