@fantastic.dev/better-stripe 0.25.1 → 0.27.0

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 (94) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/MIGRATION.md +11 -0
  3. package/README.md +10 -1
  4. package/dist/client/connect/payoutExecution.d.ts.map +1 -1
  5. package/dist/client/connect/payoutExecution.js +8 -83
  6. package/dist/client/connect/payoutExecution.js.map +1 -1
  7. package/dist/client/connect/payoutExecutionTestHelpers.d.ts +2 -10
  8. package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -1
  9. package/dist/client/connect/payoutExecutionTestHelpers.js.map +1 -1
  10. package/dist/client/connect/payoutPreview.d.ts +20 -0
  11. package/dist/client/connect/payoutPreview.d.ts.map +1 -0
  12. package/dist/client/connect/payoutPreview.js +44 -0
  13. package/dist/client/connect/payoutPreview.js.map +1 -0
  14. package/dist/client/connect/payoutQualification.d.ts +30 -0
  15. package/dist/client/connect/payoutQualification.d.ts.map +1 -0
  16. package/dist/client/connect/payoutQualification.js +98 -0
  17. package/dist/client/connect/payoutQualification.js.map +1 -0
  18. package/dist/client/payoutPolicy.d.ts +4 -0
  19. package/dist/client/payoutPolicy.d.ts.map +1 -1
  20. package/dist/client/payoutPolicy.js +6 -1
  21. package/dist/client/payoutPolicy.js.map +1 -1
  22. package/dist/component/_generated/component.d.ts +39 -0
  23. package/dist/component/_generated/component.d.ts.map +1 -1
  24. package/dist/component/affiliateRecovery/accounting.d.ts +2 -2
  25. package/dist/component/affiliateRecovery/accounting.d.ts.map +1 -1
  26. package/dist/component/affiliateRecovery/accounting.js.map +1 -1
  27. package/dist/component/payoutPolicy/helpers.d.ts +1 -1
  28. package/dist/component/payoutPolicy/helpers.d.ts.map +1 -1
  29. package/dist/component/payoutPolicy/helpers.js +3 -1
  30. package/dist/component/payoutPolicy/helpers.js.map +1 -1
  31. package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
  32. package/dist/component/payoutPolicy/mutations.js +1 -1
  33. package/dist/component/payoutPolicy/mutations.js.map +1 -1
  34. package/dist/component/payoutRelease/helpers.d.ts +4 -4
  35. package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
  36. package/dist/component/payoutRelease/helpers.js.map +1 -1
  37. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  38. package/dist/component/payoutRelease/mutations.js +2 -29
  39. package/dist/component/payoutRelease/mutations.js.map +1 -1
  40. package/dist/component/payoutRelease/planning.d.ts +27 -0
  41. package/dist/component/payoutRelease/planning.d.ts.map +1 -0
  42. package/dist/component/payoutRelease/planning.js +35 -0
  43. package/dist/component/payoutRelease/planning.js.map +1 -0
  44. package/dist/component/payoutRelease/preview.d.ts +117 -0
  45. package/dist/component/payoutRelease/preview.d.ts.map +1 -0
  46. package/dist/component/payoutRelease/preview.js +82 -0
  47. package/dist/component/payoutRelease/preview.js.map +1 -0
  48. package/dist/component/payoutRelease/queries.d.ts +39 -0
  49. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  50. package/dist/component/payoutRelease/queries.js +6 -0
  51. package/dist/component/payoutRelease/queries.js.map +1 -1
  52. package/dist/react/workflows/PayoutProgress.d.ts +5 -0
  53. package/dist/react/workflows/PayoutProgress.d.ts.map +1 -0
  54. package/dist/react/workflows/PayoutProgress.js +23 -0
  55. package/dist/react/workflows/PayoutProgress.js.map +1 -0
  56. package/dist/react/workflows/PayoutSettings.d.ts +44 -0
  57. package/dist/react/workflows/PayoutSettings.d.ts.map +1 -0
  58. package/dist/react/workflows/PayoutSettings.js +84 -0
  59. package/dist/react/workflows/PayoutSettings.js.map +1 -0
  60. package/dist/react/workflows/index.d.ts +4 -0
  61. package/dist/react/workflows/index.d.ts.map +1 -1
  62. package/dist/react/workflows/index.js +2 -0
  63. package/dist/react/workflows/index.js.map +1 -1
  64. package/dist/react/workflows/payout-progress-state.d.ts +43 -0
  65. package/dist/react/workflows/payout-progress-state.d.ts.map +1 -0
  66. package/dist/react/workflows/payout-progress-state.js +93 -0
  67. package/dist/react/workflows/payout-progress-state.js.map +1 -0
  68. package/docs/react-workflows-audit.md +1 -1
  69. package/docs/react-workflows.md +81 -0
  70. package/docs/settlement.md +57 -0
  71. package/package.json +1 -1
  72. package/src/client/connect/payoutExecution.ts +12 -99
  73. package/src/client/connect/payoutExecutionTestHelpers.ts +15 -13
  74. package/src/client/connect/payoutPreview.test.ts +269 -0
  75. package/src/client/connect/payoutPreview.ts +77 -0
  76. package/src/client/connect/payoutQualification.ts +145 -0
  77. package/src/client/payoutPolicy.test.ts +13 -0
  78. package/src/client/payoutPolicy.ts +19 -1
  79. package/src/component/_generated/api.ts +4 -0
  80. package/src/component/_generated/component.ts +42 -0
  81. package/src/component/affiliateRecovery/accounting.ts +2 -2
  82. package/src/component/payoutPolicy/helpers.ts +3 -1
  83. package/src/component/payoutPolicy/mutations.ts +4 -1
  84. package/src/component/payoutRelease/helpers.ts +7 -4
  85. package/src/component/payoutRelease/mutations.ts +2 -37
  86. package/src/component/payoutRelease/planning.ts +55 -0
  87. package/src/component/payoutRelease/preview.ts +118 -0
  88. package/src/component/payoutRelease/queries.ts +6 -0
  89. package/src/react/workflows/PayoutProgress.test.tsx +169 -0
  90. package/src/react/workflows/PayoutProgress.tsx +94 -0
  91. package/src/react/workflows/PayoutSettings.test.tsx +139 -0
  92. package/src/react/workflows/PayoutSettings.tsx +209 -0
  93. package/src/react/workflows/index.ts +13 -0
  94. package/src/react/workflows/payout-progress-state.ts +152 -0
@@ -126,6 +126,19 @@ it("persists original provider settings, verifies manual schedule and restores o
126
126
  });
127
127
  expect((await client.get(ctx, scope))!.mode).toBe("stripe_managed");
128
128
  });
129
+ it("replays equivalent threshold objects regardless of transport property order", async () => {
130
+ const { client, ctx, provider } = await setup();
131
+ expect((await client.prepare(ctx, options)).status).toBe("prepared");
132
+ expect(
133
+ (
134
+ await client.prepare(ctx, {
135
+ ...options,
136
+ thresholds: [{ amount: 1000, currency: "usd" }],
137
+ })
138
+ ).status,
139
+ ).toBe("prepared");
140
+ expect(provider.balanceSettings.update).not.toHaveBeenCalled();
141
+ });
129
142
  it("recovers provider success after a failed persistence write without changing the request", async () => {
130
143
  const { client, ctx, provider } = await setup();
131
144
  await client.prepare(ctx, options);
@@ -1,3 +1,9 @@
1
+ import {
2
+ previewRecipientPayout,
3
+ type PayoutPreviewOptions,
4
+ } from "./connect/payoutPreview.js";
5
+ export type * from "./connect/payoutPreview.js";
6
+ export { previewResult as payoutPreviewValidator } from "../component/payoutRelease/preview.js";
1
7
  import { PayoutWorkerClient } from "./connect/payoutWorker.js";
2
8
  export type * from "./connect/payoutWorker.js";
3
9
  import {
@@ -107,6 +113,15 @@ const toProvider = (
107
113
  /** Host authenticates owner/policy/consent. Provider I/O stays in this package. */
108
114
  export class PayoutPolicyClient {
109
115
  readonly worker: PayoutWorkerClient;
116
+ preview(ctx: RunCtx, opts: PayoutPreviewOptions) {
117
+ return previewRecipientPayout(
118
+ this.provider(),
119
+ this.component,
120
+ ctx,
121
+ opts,
122
+ () => this.verify(ctx, opts),
123
+ );
124
+ }
110
125
  constructor(
111
126
  private readonly component: Component,
112
127
  private readonly provider: () => Stripe,
@@ -298,7 +313,10 @@ export class PayoutPolicyClient {
298
313
  if (
299
314
  prior.mode !== opts.mode ||
300
315
  prior.expectedRevision !== opts.expectedRevision ||
301
- !equal(prior.thresholds, thresholds) ||
316
+ !equal(
317
+ canonicalThresholds(prior.thresholds, prior.mode === "custom"),
318
+ thresholds,
319
+ ) ||
302
320
  Boolean(prior.adoption) !== Boolean(opts.adoptExistingBalance)
303
321
  )
304
322
  throw new Error("Payout policy operation identity cannot be reused.");
@@ -56,6 +56,8 @@ import type * as payoutRelease_fees from "../payoutRelease/fees.js";
56
56
  import type * as payoutRelease_helpers from "../payoutRelease/helpers.js";
57
57
  import type * as payoutRelease_mutations from "../payoutRelease/mutations.js";
58
58
  import type * as payoutRelease_observation from "../payoutRelease/observation.js";
59
+ import type * as payoutRelease_planning from "../payoutRelease/planning.js";
60
+ import type * as payoutRelease_preview from "../payoutRelease/preview.js";
59
61
  import type * as payoutRelease_queries from "../payoutRelease/queries.js";
60
62
  import type * as payoutSnapshots_localState from "../payoutSnapshots/localState.js";
61
63
  import type * as payoutSnapshots_mutations from "../payoutSnapshots/mutations.js";
@@ -136,6 +138,8 @@ const fullApi: ApiFromModules<{
136
138
  "payoutRelease/helpers": typeof payoutRelease_helpers;
137
139
  "payoutRelease/mutations": typeof payoutRelease_mutations;
138
140
  "payoutRelease/observation": typeof payoutRelease_observation;
141
+ "payoutRelease/planning": typeof payoutRelease_planning;
142
+ "payoutRelease/preview": typeof payoutRelease_preview;
139
143
  "payoutRelease/queries": typeof payoutRelease_queries;
140
144
  "payoutSnapshots/localState": typeof payoutSnapshots_localState;
141
145
  "payoutSnapshots/mutations": typeof payoutSnapshots_mutations;
@@ -3608,6 +3608,48 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
3608
3608
  },
3609
3609
  Name
3610
3610
  >;
3611
+ preview: FunctionReference<
3612
+ "query",
3613
+ "internal",
3614
+ {
3615
+ accountId: string;
3616
+ currency: string;
3617
+ expectedRevision: number;
3618
+ independentLiabilities: number;
3619
+ observations: Array<{
3620
+ currency: string;
3621
+ ledgerFingerprint: string;
3622
+ matureNet: number;
3623
+ observedAt: number;
3624
+ pendingCredits: number;
3625
+ stripeTransferId: string;
3626
+ transactionIds: Array<string>;
3627
+ }>;
3628
+ ownerKey: string;
3629
+ providerAvailable: number;
3630
+ },
3631
+ | { amount: 0; reason: string; status: "held" }
3632
+ | {
3633
+ amount: number;
3634
+ confirmedFees: number;
3635
+ currency: string;
3636
+ independentLiabilities: number;
3637
+ matureUnpaid: number;
3638
+ observedAt: number;
3639
+ pendingCredits: number;
3640
+ providerAvailable: number;
3641
+ providerMinimum: null;
3642
+ reason?: string;
3643
+ sources: Array<{
3644
+ matureUnpaid: number;
3645
+ pendingCredits: number;
3646
+ role: "store" | "affiliate" | "other";
3647
+ }>;
3648
+ status: "held" | "qualified";
3649
+ threshold: number;
3650
+ },
3651
+ Name
3652
+ >;
3611
3653
  snapshot: FunctionReference<
3612
3654
  "query",
3613
3655
  "internal",
@@ -1,5 +1,5 @@
1
1
  import type { Doc } from "../_generated/dataModel.js";
2
- import type { MutationCtx } from "../_generated/server.js";
2
+ import type { MutationCtx, QueryCtx } from "../_generated/server.js";
3
3
  export type Obligation = Omit<
4
4
  Doc<"affiliateObligations">,
5
5
  "_id" | "_creationTime"
@@ -13,7 +13,7 @@ export const due = (row: Obligation) =>
13
13
  export const credit = (row: Obligation) =>
14
14
  row.needsReconciliation ? 0 : Math.max(0, netRecovery(row) - target(row));
15
15
  export async function balance(
16
- ctx: MutationCtx,
16
+ ctx: MutationCtx | QueryCtx,
17
17
  account: string,
18
18
  currency: string,
19
19
  ) {
@@ -55,7 +55,9 @@ export function canonicalThresholds(
55
55
  )
56
56
  )
57
57
  throw new Error("Invalid custom payout thresholds.");
58
- return [...values].sort((a, b) => a.currency.localeCompare(b.currency));
58
+ return values
59
+ .map(({ currency, amount }) => ({ currency, amount }))
60
+ .sort((a, b) => a.currency.localeCompare(b.currency));
59
61
  }
60
62
  export function validateScope(scope: { accountId: string; ownerKey: string }) {
61
63
  if (
@@ -52,7 +52,10 @@ export const prepare = mutation({
52
52
  old.country !== args.country ||
53
53
  old.observedAt !== args.observedAt ||
54
54
  Boolean(old.adoption) !== Boolean(args.adoption) ||
55
- !same(old.thresholds, args.thresholds) ||
55
+ !same(
56
+ canonicalThresholds(old.thresholds, old.mode === "custom"),
57
+ args.thresholds,
58
+ ) ||
56
59
  !same(old.observedSchedule, args.observedSchedule)
57
60
  )
58
61
  throw new Error("Payout policy operation identity cannot be reused.");
@@ -1,6 +1,6 @@
1
1
  import type { Infer } from "convex/values";
2
2
  import type { transferDocValidator } from "../connect/validators.js";
3
- import type { MutationCtx } from "../_generated/server.js";
3
+ import type { MutationCtx, QueryCtx } from "../_generated/server.js";
4
4
  import type { Doc } from "../_generated/dataModel.js";
5
5
  import { readPolicy, validateScope } from "../payoutPolicy/helpers.js";
6
6
  import type { observation } from "./schema.js";
@@ -50,7 +50,7 @@ export async function claimByKey(
50
50
  return claim;
51
51
  }
52
52
  export async function readyPolicy(
53
- ctx: MutationCtx,
53
+ ctx: MutationCtx | QueryCtx,
54
54
  args: { accountId: string; ownerKey: string; expectedRevision: number },
55
55
  ) {
56
56
  const policy = await readPolicy(ctx, args);
@@ -67,7 +67,7 @@ export async function readyPolicy(
67
67
  }
68
68
  /** Full local account membership is checked under OCC before any reservation. */
69
69
  export async function checkSnapshot(
70
- ctx: MutationCtx,
70
+ ctx: MutationCtx | QueryCtx,
71
71
  accountId: string,
72
72
  observations: Infer<typeof observation>[],
73
73
  fresh: boolean,
@@ -126,7 +126,10 @@ export async function checkSnapshot(
126
126
  }
127
127
  return byId;
128
128
  }
129
- export async function sourceById(ctx: MutationCtx, stripeTransferId: string) {
129
+ export async function sourceById(
130
+ ctx: MutationCtx | QueryCtx,
131
+ stripeTransferId: string,
132
+ ) {
130
133
  return ctx.db
131
134
  .query("payoutSourceAllocations")
132
135
  .withIndex("by_transfer", (q) => q.eq("stripeTransferId", stripeTransferId))
@@ -19,7 +19,7 @@ import {
19
19
  validateIdentity,
20
20
  add,
21
21
  } from "./helpers.js";
22
- import { planRecipientPayout, type PayoutFundingSource } from "./allocation.js";
22
+ import { readPayoutPlan } from "./planning.js";
23
23
  const held = v.object({
24
24
  status: v.literal("held"),
25
25
  reason: v.union(
@@ -63,10 +63,6 @@ export const claim = mutation({
63
63
  return existing;
64
64
  }
65
65
  const policy = await readyPolicy(ctx, args);
66
- const minimum = policy.thresholds.find(
67
- (value) => value.currency === args.currency,
68
- );
69
- if (!minimum) throw new Error("Payout policy currency is not configured.");
70
66
  const pending = await ctx.db
71
67
  .query("payoutReleaseClaims")
72
68
  .withIndex("by_account_status", (q) =>
@@ -81,38 +77,7 @@ export const claim = mutation({
81
77
  currency: args.currency,
82
78
  providerMinimum: null,
83
79
  };
84
- const rows = await checkSnapshot(
85
- ctx,
86
- args.accountId,
87
- args.observations,
88
- true,
89
- );
90
- const sources: PayoutFundingSource[] = [];
91
- for (const observation of args.observations) {
92
- const prior = await sourceById(ctx, observation.stripeTransferId);
93
- if (
94
- prior &&
95
- (prior.accountId !== args.accountId ||
96
- prior.ownerKey !== args.ownerKey ||
97
- prior.currency !== observation.currency)
98
- )
99
- throw new Error("Payout source allocation identity changed.");
100
- sources.push({
101
- ...observation,
102
- role: rows.get(observation.stripeTransferId)!.role,
103
- paidAmount: prior?.paidAmount ?? 0,
104
- reservedAmount: prior?.reservedAmount ?? 0,
105
- });
106
- }
107
- const fees = await readFees(ctx, args);
108
- const plan = planRecipientPayout({
109
- currency: args.currency,
110
- threshold: minimum.amount,
111
- providerAvailable: args.providerAvailable,
112
- independentLiabilities: args.independentLiabilities,
113
- confirmedFees: fees?.amount ?? 0,
114
- sources,
115
- });
80
+ const { plan, fees } = await readPayoutPlan(ctx, args, policy);
116
81
  if (plan.status === "held") return plan;
117
82
  await reserveRecoveryGuard(ctx, args, plan.amount);
118
83
  for (const allocation of plan.allocations) {
@@ -0,0 +1,55 @@
1
+ import type { Infer } from "convex/values";
2
+ import type { MutationCtx, QueryCtx } from "../_generated/server.js";
3
+ import type { policyDoc } from "../payoutPolicy/schema.js";
4
+ import type { observation } from "./observation.js";
5
+ import { checkSnapshot, sourceById } from "./helpers.js";
6
+ import { readFees } from "./fees.js";
7
+ import { planRecipientPayout, type PayoutFundingSource } from "./allocation.js";
8
+ export async function readPayoutPlan(
9
+ ctx: MutationCtx | QueryCtx,
10
+ args: {
11
+ accountId: string;
12
+ ownerKey: string;
13
+ currency: string;
14
+ observations: Infer<typeof observation>[];
15
+ providerAvailable: number;
16
+ independentLiabilities: number;
17
+ },
18
+ policy: Infer<typeof policyDoc>,
19
+ ) {
20
+ const minimum = policy.thresholds.find((t) => t.currency === args.currency);
21
+ if (!minimum) throw new Error("Payout policy currency is not configured.");
22
+ const rows = await checkSnapshot(
23
+ ctx,
24
+ args.accountId,
25
+ args.observations,
26
+ true,
27
+ );
28
+ const sources: PayoutFundingSource[] = [];
29
+ for (const observation of args.observations) {
30
+ const prior = await sourceById(ctx, observation.stripeTransferId);
31
+ if (
32
+ prior &&
33
+ (prior.accountId !== args.accountId ||
34
+ prior.ownerKey !== args.ownerKey ||
35
+ prior.currency !== observation.currency)
36
+ )
37
+ throw new Error("Payout source allocation identity changed.");
38
+ sources.push({
39
+ ...observation,
40
+ role: rows.get(observation.stripeTransferId)!.role,
41
+ paidAmount: prior?.paidAmount ?? 0,
42
+ reservedAmount: prior?.reservedAmount ?? 0,
43
+ });
44
+ }
45
+ const fees = await readFees(ctx, args);
46
+ const plan = planRecipientPayout({
47
+ currency: args.currency,
48
+ threshold: minimum.amount,
49
+ providerAvailable: args.providerAvailable,
50
+ independentLiabilities: args.independentLiabilities,
51
+ confirmedFees: fees?.amount ?? 0,
52
+ sources,
53
+ });
54
+ return { plan, sources, fees, threshold: minimum.amount };
55
+ }
@@ -0,0 +1,118 @@
1
+ import { v } from "convex/values";
2
+ import type { MutationCtx, QueryCtx } from "../_generated/server.js";
3
+ import type { Infer } from "convex/values";
4
+ import { scopeFields } from "../payoutPolicy/schema.js";
5
+ import { observation } from "./observation.js";
6
+ import { readyPolicy, add } from "./helpers.js";
7
+ import { readPayoutPlan } from "./planning.js";
8
+ import { balance } from "../affiliateRecovery/accounting.js";
9
+ export const previewFields = {
10
+ ...scopeFields,
11
+ expectedRevision: v.number(),
12
+ currency: v.string(),
13
+ providerAvailable: v.number(),
14
+ independentLiabilities: v.number(),
15
+ observations: v.array(observation),
16
+ };
17
+ const held = v.object({
18
+ status: v.literal("held"),
19
+ reason: v.string(),
20
+ amount: v.literal(0),
21
+ });
22
+ export const previewResult = v.union(
23
+ held,
24
+ v.object({
25
+ status: v.union(v.literal("held"), v.literal("qualified")),
26
+ reason: v.optional(v.string()),
27
+ currency: v.string(),
28
+ amount: v.number(),
29
+ threshold: v.number(),
30
+ providerMinimum: v.null(),
31
+ matureUnpaid: v.number(),
32
+ pendingCredits: v.number(),
33
+ confirmedFees: v.number(),
34
+ independentLiabilities: v.number(),
35
+ providerAvailable: v.number(),
36
+ observedAt: v.number(),
37
+ sources: v.array(
38
+ v.object({
39
+ role: v.union(
40
+ v.literal("store"),
41
+ v.literal("affiliate"),
42
+ v.literal("other"),
43
+ ),
44
+ matureUnpaid: v.number(),
45
+ pendingCredits: v.number(),
46
+ }),
47
+ ),
48
+ }),
49
+ );
50
+ type Args = {
51
+ [K in keyof typeof previewFields]: Infer<(typeof previewFields)[K]>;
52
+ };
53
+ /** Trusted provider observations, informational only. No reservation or monetary state change. */
54
+ export async function previewPlan(
55
+ ctx: MutationCtx | QueryCtx,
56
+ args: Args,
57
+ ): Promise<Infer<typeof previewResult>> {
58
+ const policy = await readyPolicy(ctx, args);
59
+ const recovery = await balance(ctx, args.accountId, args.currency);
60
+ if (
61
+ recovery &&
62
+ (recovery.outstandingDebt > 0 ||
63
+ recovery.reservedRecovery > 0 ||
64
+ recovery.reservedRestoration > 0 ||
65
+ recovery.activePayoutKey)
66
+ )
67
+ return { status: "held", reason: "affiliate_recovery_pending", amount: 0 };
68
+ const pending = await ctx.db
69
+ .query("payoutReleaseClaims")
70
+ .withIndex("by_account_status", (q) =>
71
+ q.eq("accountId", args.accountId).eq("status", "reserved"),
72
+ )
73
+ .first();
74
+ if (pending) return { status: "held", reason: "pending_claim", amount: 0 };
75
+ const {
76
+ plan,
77
+ sources: funding,
78
+ fees,
79
+ threshold,
80
+ } = await readPayoutPlan(ctx, args, policy);
81
+ const sources = (["store", "affiliate", "other"] as const).map((role) => {
82
+ let matureUnpaid = 0,
83
+ pendingCredits = 0;
84
+ for (const source of funding)
85
+ if (
86
+ source.currency === args.currency &&
87
+ (source.role ?? "other") === role
88
+ ) {
89
+ matureUnpaid = add(
90
+ matureUnpaid,
91
+ add(
92
+ add(source.matureNet, -source.paidAmount),
93
+ -source.reservedAmount,
94
+ ),
95
+ );
96
+ pendingCredits = add(pendingCredits, source.pendingCredits);
97
+ }
98
+ return { role, matureUnpaid, pendingCredits };
99
+ });
100
+ return {
101
+ status: plan.status,
102
+ ...(plan.status === "held" ? { reason: plan.reason } : {}),
103
+ currency: plan.currency,
104
+ amount: plan.amount,
105
+ threshold,
106
+ providerMinimum: null,
107
+ matureUnpaid: plan.matureUnpaid,
108
+ pendingCredits: plan.pendingCredits,
109
+ confirmedFees: fees?.amount ?? 0,
110
+ independentLiabilities: args.independentLiabilities,
111
+ providerAvailable: args.providerAvailable,
112
+ observedAt: Math.min(
113
+ Date.now(),
114
+ ...args.observations.map((o) => o.observedAt),
115
+ ),
116
+ sources,
117
+ };
118
+ }
@@ -1,3 +1,4 @@
1
+ import { previewFields, previewResult, previewPlan } from "./preview.js";
1
2
  import { paginator } from "convex-helpers/server/pagination";
2
3
  import schema from "../schema.js";
3
4
  import { transferDocValidator } from "../connect/validators.js";
@@ -8,6 +9,11 @@ import { query } from "../_generated/server.js";
8
9
  import { identity, claimDoc } from "./schema.js";
9
10
  import { validateIdentity } from "./helpers.js";
10
11
  import { readPolicy } from "../payoutPolicy/helpers.js";
12
+ export const preview = query({
13
+ args: previewFields,
14
+ returns: previewResult,
15
+ handler: previewPlan,
16
+ });
11
17
  export const get = query({
12
18
  args: identity,
13
19
  returns: v.union(claimDoc, v.null()),
@@ -0,0 +1,169 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, expect, it } from "vitest";
4
+ import type { PayoutPreview } from "../../client/payoutPolicy.js";
5
+ import { PayoutProgress } from "./PayoutProgress.js";
6
+ afterEach(cleanup);
7
+ const policy = {
8
+ mode: "custom" as const,
9
+ status: "ready" as const,
10
+ thresholds: [{ currency: "usd", amount: 1000 }],
11
+ holdDeadline: 1900000000000,
12
+ };
13
+ const preview: PayoutPreview = {
14
+ status: "held",
15
+ reason: "below_threshold",
16
+ amount: 0,
17
+ currency: "usd",
18
+ threshold: 1000,
19
+ providerMinimum: null,
20
+ matureUnpaid: 999,
21
+ pendingCredits: 200,
22
+ confirmedFees: 0,
23
+ independentLiabilities: 0,
24
+ providerAvailable: 999,
25
+ observedAt: 1800000000000,
26
+ sources: [
27
+ { role: "store", matureUnpaid: 599, pendingCredits: 200 },
28
+ { role: "affiliate", matureUnpaid: 400, pendingCredits: 0 },
29
+ { role: "other", matureUnpaid: 0, pendingCredits: 0 },
30
+ ],
31
+ };
32
+ it("shows one combined minimum, pending settlement and source contributions", () => {
33
+ render(<PayoutProgress policy={policy} preview={preview} currency="usd" />);
34
+ expect(screen.getByRole("progressbar").getAttribute("max")).toBe("1000");
35
+ expect(screen.getByRole("progressbar").getAttribute("value")).toBe("999");
36
+ expect(screen.getByText("$9.99")).toBeTruthy();
37
+ expect(screen.getByText("$5.99")).toBeTruthy();
38
+ expect(screen.getByText("$4.00")).toBeTruthy();
39
+ expect(screen.getByText(/Below-minimum funds remain owed/)).toBeTruthy();
40
+ expect(
41
+ screen.queryByRole("button", { name: /withdraw|pay out/i }),
42
+ ).toBeNull();
43
+ });
44
+ it("does not invent zero balances or progress for an unavailable assessment", () => {
45
+ render(
46
+ <PayoutProgress
47
+ policy={policy}
48
+ currency="usd"
49
+ preview={{
50
+ status: "held",
51
+ reason: "unallocated_history",
52
+ amount: 0,
53
+ }}
54
+ />,
55
+ );
56
+ expect(screen.getByRole("status").textContent).toContain("reconciliation");
57
+ expect(screen.queryByRole("progressbar")).toBeNull();
58
+ expect(screen.queryByText("$0.00")).toBeNull();
59
+ });
60
+ it("keeps Stripe-managed defaults distinct from a custom application minimum", () => {
61
+ render(<PayoutProgress policy={null} currency="usd" />);
62
+ expect(screen.getByRole("status").textContent).toContain("Stripe manages");
63
+ expect(screen.getByText(/numeric amount is unavailable/)).toBeTruthy();
64
+ expect(screen.queryByRole("progressbar")).toBeNull();
65
+ expect(screen.queryByText("$1.00")).toBeNull();
66
+ });
67
+ it("does not reuse a prior assessment while the schedule change is pending", () => {
68
+ render(
69
+ <PayoutProgress
70
+ policy={{ ...policy, status: "changing" }}
71
+ preview={preview}
72
+ currency="usd"
73
+ />,
74
+ );
75
+ expect(screen.getByRole("status").textContent).toContain("being verified");
76
+ expect(screen.queryByRole("progressbar")).toBeNull();
77
+ });
78
+ it("preserves negative entitlement while clamping only the visual progress", () => {
79
+ render(
80
+ <PayoutProgress
81
+ policy={policy}
82
+ preview={{ ...preview, matureUnpaid: -100 }}
83
+ currency="usd"
84
+ />,
85
+ );
86
+ expect(screen.getByText("-$1.00")).toBeTruthy();
87
+ expect(screen.getByRole("progressbar").getAttribute("value")).toBe("0");
88
+ });
89
+ it("distinguishes the next worker attempt from a bank arrival date", () => {
90
+ render(
91
+ <PayoutProgress
92
+ policy={policy}
93
+ preview={preview}
94
+ currency="usd"
95
+ nextAttemptAt={1800000000000}
96
+ timeZone="UTC"
97
+ />,
98
+ );
99
+ expect(screen.getByText(/Next automatic attempt:/).textContent).toContain(
100
+ "not a bank arrival date",
101
+ );
102
+ expect(screen.getByText(/Custom holding deadline:/)).toBeTruthy();
103
+ });
104
+ it("rejects another currency or changed threshold instead of displaying mixed progress", () => {
105
+ expect(() =>
106
+ render(<PayoutProgress policy={policy} preview={preview} currency="eur" />),
107
+ ).toThrow("does not match");
108
+ expect(() =>
109
+ render(
110
+ <PayoutProgress
111
+ policy={{ ...policy, thresholds: [{ currency: "usd", amount: 2000 }] }}
112
+ preview={preview}
113
+ currency="usd"
114
+ />,
115
+ ),
116
+ ).toThrow("does not match");
117
+ });
118
+ it("exposes the same financial state for translated host cards", () => {
119
+ render(
120
+ <PayoutProgress policy={policy} preview={preview} currency="usd">
121
+ {(state) => (
122
+ <article data-testid="custom">
123
+ {state.status}{" "}
124
+ {
125
+ state.metrics.find((metric) => metric.key === "qualifying")
126
+ ?.formattedValue
127
+ }
128
+ </article>
129
+ )}
130
+ </PayoutProgress>,
131
+ );
132
+ expect(screen.getByTestId("custom").textContent).toBe("assessed $9.99");
133
+ expect(screen.queryByText("Source contributions")).toBeNull();
134
+ });
135
+ it.each([1000, 1100])(
136
+ "shows a qualifying combined assessment at %i without separate category minimums",
137
+ (total) => {
138
+ render(
139
+ <PayoutProgress
140
+ policy={policy}
141
+ currency="usd"
142
+ preview={{
143
+ ...preview,
144
+ status: "qualified",
145
+ reason: undefined,
146
+ amount: total,
147
+ matureUnpaid: total,
148
+ sources: [
149
+ { role: "store", matureUnpaid: total - 400, pendingCredits: 0 },
150
+ { role: "affiliate", matureUnpaid: 400, pendingCredits: 0 },
151
+ ],
152
+ }}
153
+ />,
154
+ );
155
+ expect(screen.getByRole("status").textContent).toContain("qualif");
156
+ expect(screen.getAllByRole("progressbar")).toHaveLength(1);
157
+ expect(screen.getByRole("progressbar").getAttribute("value")).toBe("1000");
158
+ },
159
+ );
160
+ it("does not display a former custom holding deadline for Stripe-managed payouts", () => {
161
+ render(
162
+ <PayoutProgress
163
+ policy={{ ...policy, mode: "stripe_managed" }}
164
+ currency="usd"
165
+ preview={preview}
166
+ />,
167
+ );
168
+ expect(screen.queryByText(/Custom holding deadline:/)).toBeNull();
169
+ });