@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.
Files changed (101) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +5 -1
  3. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +102 -4
  4. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
  5. package/dist/client/connect/payoutExecutionTestHelpers.d.ts +51 -2
  6. package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -1
  7. package/dist/client/connect/payoutExpiry.d.ts +42 -0
  8. package/dist/client/connect/payoutExpiry.d.ts.map +1 -0
  9. package/dist/client/connect/payoutExpiry.js +93 -0
  10. package/dist/client/connect/payoutExpiry.js.map +1 -0
  11. package/dist/client/connect/payoutSyncTestHelpers.d.ts +41 -2
  12. package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -1
  13. package/dist/client/connect/payoutWorker.d.ts +4 -1
  14. package/dist/client/connect/payoutWorker.d.ts.map +1 -1
  15. package/dist/client/connect/payoutWorker.js +35 -1
  16. package/dist/client/connect/payoutWorker.js.map +1 -1
  17. package/dist/client/payoutPolicy.d.ts +20 -2
  18. package/dist/client/payoutPolicy.d.ts.map +1 -1
  19. package/dist/client/payoutPolicy.js +5 -25
  20. package/dist/client/payoutPolicy.js.map +1 -1
  21. package/dist/component/_generated/component.d.ts +120 -0
  22. package/dist/component/_generated/component.d.ts.map +1 -1
  23. package/dist/component/core/resetSafety.d.ts.map +1 -1
  24. package/dist/component/core/resetSafety.js +7 -1
  25. package/dist/component/core/resetSafety.js.map +1 -1
  26. package/dist/component/earnings/triggers.d.ts +26 -4
  27. package/dist/component/earnings/triggers.d.ts.map +1 -1
  28. package/dist/component/payoutPolicy/expiry.d.ts +78 -0
  29. package/dist/component/payoutPolicy/expiry.d.ts.map +1 -0
  30. package/dist/component/payoutPolicy/expiry.js +57 -0
  31. package/dist/component/payoutPolicy/expiry.js.map +1 -0
  32. package/dist/component/payoutPolicy/helpers.d.ts +10 -0
  33. package/dist/component/payoutPolicy/helpers.d.ts.map +1 -1
  34. package/dist/component/payoutPolicy/mutations.d.ts +77 -0
  35. package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
  36. package/dist/component/payoutPolicy/mutations.js +36 -3
  37. package/dist/component/payoutPolicy/mutations.js.map +1 -1
  38. package/dist/component/payoutPolicy/queries.d.ts +20 -0
  39. package/dist/component/payoutPolicy/queries.d.ts.map +1 -1
  40. package/dist/component/payoutPolicy/schema.d.ts +139 -2
  41. package/dist/component/payoutPolicy/schema.d.ts.map +1 -1
  42. package/dist/component/payoutPolicy/schema.js +12 -0
  43. package/dist/component/payoutPolicy/schema.js.map +1 -1
  44. package/dist/component/payoutRelease/helpers.d.ts +10 -0
  45. package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
  46. package/dist/component/payoutWorker/helpers.d.ts +1 -0
  47. package/dist/component/payoutWorker/helpers.d.ts.map +1 -1
  48. package/dist/component/payoutWorker/mutations.d.ts +2 -0
  49. package/dist/component/payoutWorker/mutations.d.ts.map +1 -1
  50. package/dist/component/payoutWorker/mutations.js +3 -0
  51. package/dist/component/payoutWorker/mutations.js.map +1 -1
  52. package/dist/component/payoutWorker/queries.d.ts +22 -0
  53. package/dist/component/payoutWorker/queries.d.ts.map +1 -1
  54. package/dist/component/payoutWorker/schema.d.ts +18 -2
  55. package/dist/component/payoutWorker/schema.d.ts.map +1 -1
  56. package/dist/component/payoutWorker/schema.js +7 -0
  57. package/dist/component/payoutWorker/schema.js.map +1 -1
  58. package/dist/component/schema.d.ts +41 -2
  59. package/dist/react/workflows/PayoutAttemptStatus.d.ts +14 -0
  60. package/dist/react/workflows/PayoutAttemptStatus.d.ts.map +1 -0
  61. package/dist/react/workflows/PayoutAttemptStatus.js +13 -0
  62. package/dist/react/workflows/PayoutAttemptStatus.js.map +1 -0
  63. package/dist/react/workflows/PayoutProgress.d.ts.map +1 -1
  64. package/dist/react/workflows/PayoutProgress.js +3 -2
  65. package/dist/react/workflows/PayoutProgress.js.map +1 -1
  66. package/dist/react/workflows/index.d.ts +2 -0
  67. package/dist/react/workflows/index.d.ts.map +1 -1
  68. package/dist/react/workflows/index.js +1 -0
  69. package/dist/react/workflows/index.js.map +1 -1
  70. package/dist/react/workflows/payout-attempt-state.d.ts +9 -0
  71. package/dist/react/workflows/payout-attempt-state.d.ts.map +1 -0
  72. package/dist/react/workflows/payout-attempt-state.js +47 -0
  73. package/dist/react/workflows/payout-attempt-state.js.map +1 -0
  74. package/dist/react/workflows/payout-progress-state.d.ts +16 -2
  75. package/dist/react/workflows/payout-progress-state.d.ts.map +1 -1
  76. package/dist/react/workflows/payout-progress-state.js +37 -2
  77. package/dist/react/workflows/payout-progress-state.js.map +1 -1
  78. package/docs/react-workflows.md +30 -0
  79. package/docs/settlement.md +47 -3
  80. package/package.json +1 -1
  81. package/src/client/connect/payoutDeadline.test.ts +70 -0
  82. package/src/client/connect/payoutExpiry.ts +132 -0
  83. package/src/client/connect/payoutWorker.test.ts +57 -1
  84. package/src/client/connect/payoutWorker.ts +46 -2
  85. package/src/client/payoutPolicy.test.ts +196 -0
  86. package/src/client/payoutPolicy.ts +11 -23
  87. package/src/component/_generated/api.ts +2 -0
  88. package/src/component/_generated/component.ts +147 -0
  89. package/src/component/core/resetSafety.ts +49 -33
  90. package/src/component/payout-reset-safety.test.ts +28 -0
  91. package/src/component/payoutPolicy/expiry.ts +80 -0
  92. package/src/component/payoutPolicy/mutations.ts +47 -2
  93. package/src/component/payoutPolicy/schema.ts +17 -0
  94. package/src/component/payoutWorker/mutations.ts +3 -0
  95. package/src/component/payoutWorker/schema.ts +15 -0
  96. package/src/react/workflows/PayoutAttemptStatus.tsx +44 -0
  97. package/src/react/workflows/PayoutProgress.test.tsx +90 -0
  98. package/src/react/workflows/PayoutProgress.tsx +31 -1
  99. package/src/react/workflows/index.ts +6 -0
  100. package/src/react/workflows/payout-attempt-state.ts +70 -0
  101. package/src/react/workflows/payout-progress-state.ts +62 -2
@@ -2790,6 +2790,44 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2790
2790
  },
2791
2791
  Name
2792
2792
  >;
2793
+ beginHoldingExpiry: FunctionReference<
2794
+ "mutation",
2795
+ "internal",
2796
+ { accountId: string; ownerKey: string },
2797
+ {
2798
+ _creationTime: number;
2799
+ _id: string;
2800
+ accountId: string;
2801
+ country: string;
2802
+ holdDeadline?: number;
2803
+ holdingExpiry?: {
2804
+ balances?: Array<{
2805
+ available: number;
2806
+ currency: string;
2807
+ pending: number;
2808
+ }>;
2809
+ deadline: number;
2810
+ observedAt?: number;
2811
+ status:
2812
+ | "unverified"
2813
+ | "balance_remaining"
2814
+ | "claims_unresolved"
2815
+ | "no_held_balance";
2816
+ };
2817
+ mode: "stripe_managed" | "custom";
2818
+ operationKey: string;
2819
+ ownerKey: string;
2820
+ previousSchedule?: {
2821
+ interval: "daily" | "weekly" | "monthly" | "manual";
2822
+ monthlyDays?: Array<number>;
2823
+ weeklyDays?: Array<string>;
2824
+ };
2825
+ revision: number;
2826
+ status: "changing" | "ready";
2827
+ thresholds: Array<{ amount: number; currency: string }>;
2828
+ },
2829
+ Name
2830
+ >;
2793
2831
  cancelUnattempted: FunctionReference<
2794
2832
  "mutation",
2795
2833
  "internal",
@@ -2906,6 +2944,55 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2906
2944
  },
2907
2945
  Name
2908
2946
  >;
2947
+ observeHoldingExpiry: FunctionReference<
2948
+ "mutation",
2949
+ "internal",
2950
+ {
2951
+ accountId: string;
2952
+ balances: Array<{
2953
+ available: number;
2954
+ currency: string;
2955
+ pending: number;
2956
+ }>;
2957
+ deadline: number;
2958
+ expectedRevision: number;
2959
+ observedAt: number;
2960
+ ownerKey: string;
2961
+ },
2962
+ {
2963
+ _creationTime: number;
2964
+ _id: string;
2965
+ accountId: string;
2966
+ country: string;
2967
+ holdDeadline?: number;
2968
+ holdingExpiry?: {
2969
+ balances?: Array<{
2970
+ available: number;
2971
+ currency: string;
2972
+ pending: number;
2973
+ }>;
2974
+ deadline: number;
2975
+ observedAt?: number;
2976
+ status:
2977
+ | "unverified"
2978
+ | "balance_remaining"
2979
+ | "claims_unresolved"
2980
+ | "no_held_balance";
2981
+ };
2982
+ mode: "stripe_managed" | "custom";
2983
+ operationKey: string;
2984
+ ownerKey: string;
2985
+ previousSchedule?: {
2986
+ interval: "daily" | "weekly" | "monthly" | "manual";
2987
+ monthlyDays?: Array<number>;
2988
+ weeklyDays?: Array<string>;
2989
+ };
2990
+ revision: number;
2991
+ status: "changing" | "ready";
2992
+ thresholds: Array<{ amount: number; currency: string }>;
2993
+ },
2994
+ Name
2995
+ >;
2909
2996
  prepare: FunctionReference<
2910
2997
  "mutation",
2911
2998
  "internal",
@@ -3007,6 +3094,20 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
3007
3094
  accountId: string;
3008
3095
  country: string;
3009
3096
  holdDeadline?: number;
3097
+ holdingExpiry?: {
3098
+ balances?: Array<{
3099
+ available: number;
3100
+ currency: string;
3101
+ pending: number;
3102
+ }>;
3103
+ deadline: number;
3104
+ observedAt?: number;
3105
+ status:
3106
+ | "unverified"
3107
+ | "balance_remaining"
3108
+ | "claims_unresolved"
3109
+ | "no_held_balance";
3110
+ };
3010
3111
  mode: "stripe_managed" | "custom";
3011
3112
  operationKey: string;
3012
3113
  ownerKey: string;
@@ -3032,6 +3133,20 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
3032
3133
  accountId: string;
3033
3134
  country: string;
3034
3135
  holdDeadline?: number;
3136
+ holdingExpiry?: {
3137
+ balances?: Array<{
3138
+ available: number;
3139
+ currency: string;
3140
+ pending: number;
3141
+ }>;
3142
+ deadline: number;
3143
+ observedAt?: number;
3144
+ status:
3145
+ | "unverified"
3146
+ | "balance_remaining"
3147
+ | "claims_unresolved"
3148
+ | "no_held_balance";
3149
+ };
3035
3150
  mode: "stripe_managed" | "custom";
3036
3151
  operationKey: string;
3037
3152
  ownerKey: string;
@@ -3997,6 +4112,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
3997
4112
  hasOperation: boolean;
3998
4113
  lastAttemptAt?: number;
3999
4114
  lastReason?: string;
4115
+ lastResultAt?: number;
4000
4116
  leaseUntil?: number;
4001
4117
  nextAttemptAt: number;
4002
4118
  operationKey?: string;
@@ -4031,6 +4147,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
4031
4147
  hasOperation: boolean;
4032
4148
  lastAttemptAt?: number;
4033
4149
  lastReason?: string;
4150
+ lastResultAt?: number;
4034
4151
  leaseUntil?: number;
4035
4152
  nextAttemptAt: number;
4036
4153
  operationKey?: string;
@@ -4063,6 +4180,20 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
4063
4180
  accountId: string;
4064
4181
  country: string;
4065
4182
  holdDeadline?: number;
4183
+ holdingExpiry?: {
4184
+ balances?: Array<{
4185
+ available: number;
4186
+ currency: string;
4187
+ pending: number;
4188
+ }>;
4189
+ deadline: number;
4190
+ observedAt?: number;
4191
+ status:
4192
+ | "unverified"
4193
+ | "balance_remaining"
4194
+ | "claims_unresolved"
4195
+ | "no_held_balance";
4196
+ };
4066
4197
  mode: "stripe_managed" | "custom";
4067
4198
  operationKey: string;
4068
4199
  ownerKey: string;
@@ -4094,6 +4225,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
4094
4225
  hasOperation: boolean;
4095
4226
  lastAttemptAt?: number;
4096
4227
  lastReason?: string;
4228
+ lastResultAt?: number;
4097
4229
  leaseUntil?: number;
4098
4230
  nextAttemptAt: number;
4099
4231
  operationKey?: string;
@@ -4124,6 +4256,20 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
4124
4256
  accountId: string;
4125
4257
  country: string;
4126
4258
  holdDeadline?: number;
4259
+ holdingExpiry?: {
4260
+ balances?: Array<{
4261
+ available: number;
4262
+ currency: string;
4263
+ pending: number;
4264
+ }>;
4265
+ deadline: number;
4266
+ observedAt?: number;
4267
+ status:
4268
+ | "unverified"
4269
+ | "balance_remaining"
4270
+ | "claims_unresolved"
4271
+ | "no_held_balance";
4272
+ };
4127
4273
  mode: "stripe_managed" | "custom";
4128
4274
  operationKey: string;
4129
4275
  ownerKey: string;
@@ -4167,6 +4313,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
4167
4313
  hasOperation: boolean;
4168
4314
  lastAttemptAt?: number;
4169
4315
  lastReason?: string;
4316
+ lastResultAt?: number;
4170
4317
  leaseUntil?: number;
4171
4318
  nextAttemptAt: number;
4172
4319
  operationKey?: string;
@@ -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,
@@ -77,6 +77,7 @@ export const begin = mutation({
77
77
  hasOperation: false,
78
78
  leaseUntil: undefined,
79
79
  lastReason: "policy_inactive",
80
+ lastResultAt: now,
80
81
  nextAttemptAt: nextPayoutAttempt(policy, now, args.cadenceMs),
81
82
  });
82
83
  return { status: "held" as const, reason: "policy_inactive" };
@@ -113,6 +114,7 @@ export const begin = mutation({
113
114
  hasOperation: false,
114
115
  leaseUntil: undefined,
115
116
  lastReason: "affiliate_recovery_pending",
117
+ lastResultAt: now,
116
118
  nextAttemptAt,
117
119
  });
118
120
  return {
@@ -189,6 +191,7 @@ export const finish = mutation({
189
191
  job.cadenceMs,
190
192
  ),
191
193
  lastReason: args.reason,
194
+ lastResultAt: Date.now(),
192
195
  completedGeneration: args.generation,
193
196
  completedOperationKey: args.operationKey,
194
197
  });
@@ -13,6 +13,7 @@ export const fields = {
13
13
  hasOperation: v.boolean(),
14
14
  lastReason: v.optional(v.string()),
15
15
  lastAttemptAt: v.optional(v.number()),
16
+ lastResultAt: v.optional(v.number()),
16
17
  completedGeneration: v.optional(v.number()),
17
18
  completedOperationKey: v.optional(v.string()),
18
19
  };
@@ -24,3 +25,17 @@ export const jobDoc = v.object({
24
25
  export const jobsTable = defineTable(fields)
25
26
  .index("by_account_currency", ["accountId", "currency"])
26
27
  .index("by_operation_pending", ["hasOperation", "nextAttemptAt"]);
28
+
29
+ export const attemptStatus = v.object({
30
+ currency: v.string(),
31
+ status: v.union(
32
+ v.literal("running"),
33
+ v.literal("reconciling"),
34
+ v.literal("rejected"),
35
+ v.literal("failed"),
36
+ v.literal("paid"),
37
+ v.literal("held"),
38
+ ),
39
+ reason: v.string(),
40
+ at: v.optional(v.number()),
41
+ });
@@ -0,0 +1,44 @@
1
+ "use client";
2
+ import type { ReactNode } from "react";
3
+ import type { PayoutAttempt } from "../../client/connect/payoutWorker.js";
4
+ import {
5
+ payoutAttemptState,
6
+ type PayoutAttemptState,
7
+ } from "./payout-attempt-state.js";
8
+ export type { PayoutAttemptState } from "./payout-attempt-state.js";
9
+ export type PayoutAttemptStatusProps = {
10
+ attempt?: PayoutAttempt | null;
11
+ locale?: string;
12
+ timeZone?: string;
13
+ className?: string;
14
+ children?: (state: PayoutAttemptState) => ReactNode;
15
+ };
16
+ /** Unstyled worker status. Result times are not bank arrival estimates. */
17
+ export function PayoutAttemptStatus({
18
+ attempt,
19
+ locale,
20
+ timeZone,
21
+ className,
22
+ children,
23
+ }: PayoutAttemptStatusProps) {
24
+ const state = payoutAttemptState(attempt, locale, timeZone);
25
+ if (!state) return null;
26
+ if (children) return <>{children(state)}</>;
27
+ return (
28
+ <aside
29
+ className={className}
30
+ data-better-stripe="payout-attempt"
31
+ data-tone={state.tone}
32
+ >
33
+ <h3>Latest automatic payout check</h3>
34
+ <p role="status" aria-label="Latest automatic payout attempt">
35
+ {state.message}
36
+ </p>
37
+ {state.formattedAt && (
38
+ <p>
39
+ {state.timeLabel}: {state.formattedAt}
40
+ </p>
41
+ )}
42
+ </aside>
43
+ );
44
+ }