@fantastic.dev/better-stripe 0.26.0 → 0.28.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 (71) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/MIGRATION.md +6 -0
  3. package/README.md +7 -1
  4. package/dist/client/payoutPolicy.d.ts +1 -0
  5. package/dist/client/payoutPolicy.d.ts.map +1 -1
  6. package/dist/client/payoutPolicy.js +2 -1
  7. package/dist/client/payoutPolicy.js.map +1 -1
  8. package/dist/component/_generated/component.d.ts +8 -0
  9. package/dist/component/_generated/component.d.ts.map +1 -1
  10. package/dist/component/payoutPolicy/helpers.d.ts +1 -1
  11. package/dist/component/payoutPolicy/helpers.d.ts.map +1 -1
  12. package/dist/component/payoutPolicy/helpers.js +3 -1
  13. package/dist/component/payoutPolicy/helpers.js.map +1 -1
  14. package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
  15. package/dist/component/payoutPolicy/mutations.js +1 -1
  16. package/dist/component/payoutPolicy/mutations.js.map +1 -1
  17. package/dist/component/payoutRelease/allocationTotals.d.ts +8 -0
  18. package/dist/component/payoutRelease/allocationTotals.d.ts.map +1 -0
  19. package/dist/component/payoutRelease/allocationTotals.js +43 -0
  20. package/dist/component/payoutRelease/allocationTotals.js.map +1 -0
  21. package/dist/component/payoutRelease/planning.d.ts.map +1 -1
  22. package/dist/component/payoutRelease/planning.js +2 -0
  23. package/dist/component/payoutRelease/planning.js.map +1 -1
  24. package/dist/component/payoutRelease/preview.d.ts +35 -2
  25. package/dist/component/payoutRelease/preview.d.ts.map +1 -1
  26. package/dist/component/payoutRelease/preview.js +6 -11
  27. package/dist/component/payoutRelease/preview.js.map +1 -1
  28. package/dist/component/payoutRelease/progress.d.ts +29 -0
  29. package/dist/component/payoutRelease/progress.d.ts.map +1 -0
  30. package/dist/component/payoutRelease/progress.js +40 -0
  31. package/dist/component/payoutRelease/progress.js.map +1 -0
  32. package/dist/component/payoutRelease/queries.d.ts +8 -0
  33. package/dist/component/payoutRelease/queries.d.ts.map +1 -1
  34. package/dist/react/workflows/PayoutProgress.d.ts +5 -0
  35. package/dist/react/workflows/PayoutProgress.d.ts.map +1 -0
  36. package/dist/react/workflows/PayoutProgress.js +23 -0
  37. package/dist/react/workflows/PayoutProgress.js.map +1 -0
  38. package/dist/react/workflows/PayoutSettings.d.ts +44 -0
  39. package/dist/react/workflows/PayoutSettings.d.ts.map +1 -0
  40. package/dist/react/workflows/PayoutSettings.js +84 -0
  41. package/dist/react/workflows/PayoutSettings.js.map +1 -0
  42. package/dist/react/workflows/index.d.ts +4 -0
  43. package/dist/react/workflows/index.d.ts.map +1 -1
  44. package/dist/react/workflows/index.js +2 -0
  45. package/dist/react/workflows/index.js.map +1 -1
  46. package/dist/react/workflows/payout-progress-state.d.ts +50 -0
  47. package/dist/react/workflows/payout-progress-state.d.ts.map +1 -0
  48. package/dist/react/workflows/payout-progress-state.js +124 -0
  49. package/dist/react/workflows/payout-progress-state.js.map +1 -0
  50. package/docs/react-workflows-audit.md +1 -1
  51. package/docs/react-workflows.md +88 -0
  52. package/docs/settlement.md +23 -0
  53. package/package.json +1 -1
  54. package/src/client/connect/payoutPreview.test.ts +145 -0
  55. package/src/client/payoutPolicy.test.ts +13 -0
  56. package/src/client/payoutPolicy.ts +5 -1
  57. package/src/component/_generated/api.ts +4 -0
  58. package/src/component/_generated/component.ts +8 -0
  59. package/src/component/payoutPolicy/helpers.ts +3 -1
  60. package/src/component/payoutPolicy/mutations.ts +4 -1
  61. package/src/component/payoutRelease/allocationTotals.ts +56 -0
  62. package/src/component/payoutRelease/planning.ts +2 -0
  63. package/src/component/payoutRelease/preview.ts +10 -20
  64. package/src/component/payoutRelease/progress.test.ts +63 -0
  65. package/src/component/payoutRelease/progress.ts +56 -0
  66. package/src/react/workflows/PayoutProgress.test.tsx +208 -0
  67. package/src/react/workflows/PayoutProgress.tsx +108 -0
  68. package/src/react/workflows/PayoutSettings.test.tsx +139 -0
  69. package/src/react/workflows/PayoutSettings.tsx +209 -0
  70. package/src/react/workflows/index.ts +13 -0
  71. package/src/react/workflows/payout-progress-state.ts +199 -0
@@ -0,0 +1,139 @@
1
+ // @vitest-environment jsdom
2
+ import "@testing-library/jest-dom/vitest";
3
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import { afterEach, expect, it, vi } from "vitest";
6
+ import { PayoutSettings } from "./PayoutSettings.js";
7
+ afterEach(cleanup);
8
+ it("requires explicit consent and sends a precise combined minimum to the host callback", async () => {
9
+ const onSave = vi.fn().mockResolvedValue({ status: "ready" });
10
+ render(
11
+ <PayoutSettings
12
+ scopeKey="recipient"
13
+ currency="usd"
14
+ policy={null}
15
+ onSave={onSave}
16
+ />,
17
+ );
18
+ await userEvent.selectOptions(screen.getByRole("combobox"), "custom");
19
+ await userEvent.type(
20
+ screen.getByRole("textbox", { name: /Minimum/ }),
21
+ "10.25",
22
+ );
23
+ expect(
24
+ screen.getByRole("button", { name: "Save payout settings" }),
25
+ ).toBeDisabled();
26
+ await userEvent.click(screen.getByRole("checkbox"));
27
+ await userEvent.click(
28
+ screen.getByRole("button", { name: "Save payout settings" }),
29
+ );
30
+ await waitFor(() =>
31
+ expect(onSave).toHaveBeenCalledWith({
32
+ currency: "usd",
33
+ mode: "custom",
34
+ expectedRevision: -1,
35
+ minimum: 1025,
36
+ consent: true,
37
+ }),
38
+ );
39
+ expect(await screen.findByText("Payout settings verified.")).toBeTruthy();
40
+ });
41
+ it("opts out account-wide without requiring custom consent or a numeric minimum", async () => {
42
+ const onSave = vi.fn().mockResolvedValue({ status: "ready" });
43
+ render(
44
+ <PayoutSettings
45
+ scopeKey="recipient"
46
+ currency="usd"
47
+ policy={{
48
+ mode: "custom",
49
+ status: "ready",
50
+ revision: 2,
51
+ thresholds: [{ currency: "usd", amount: 1000 }],
52
+ }}
53
+ onSave={onSave}
54
+ />,
55
+ );
56
+ await userEvent.selectOptions(screen.getByRole("combobox"), "stripe_managed");
57
+ await userEvent.click(
58
+ screen.getByRole("button", { name: "Save payout settings" }),
59
+ );
60
+ await waitFor(() =>
61
+ expect(onSave).toHaveBeenCalledWith({
62
+ currency: "usd",
63
+ mode: "stripe_managed",
64
+ expectedRevision: 2,
65
+ consent: false,
66
+ }),
67
+ );
68
+ });
69
+ it("shows a migration limitation without pretending settings were saved", async () => {
70
+ render(
71
+ <PayoutSettings
72
+ scopeKey="recipient"
73
+ currency="usd"
74
+ policy={null}
75
+ onSave={async () => ({
76
+ status: "migration_required",
77
+ reason: "existing_balance",
78
+ })}
79
+ />,
80
+ );
81
+ await userEvent.selectOptions(screen.getByRole("combobox"), "custom");
82
+ await userEvent.type(screen.getByRole("textbox"), "10");
83
+ await userEvent.click(screen.getByRole("checkbox"));
84
+ await userEvent.click(
85
+ screen.getByRole("button", { name: "Save payout settings" }),
86
+ );
87
+ expect((await screen.findByRole("status")).textContent).toContain(
88
+ "reconciliation",
89
+ );
90
+ expect(screen.queryByText("Payout settings verified.")).toBeNull();
91
+ });
92
+ it("locks edits during a pending schedule operation and retries the host's persisted operation", async () => {
93
+ const onRetry = vi.fn().mockResolvedValue({ status: "ready" });
94
+ const onSave = vi.fn();
95
+ render(
96
+ <PayoutSettings
97
+ scopeKey="recipient"
98
+ currency="usd"
99
+ policy={{
100
+ mode: "custom",
101
+ status: "changing",
102
+ revision: 1,
103
+ thresholds: [{ currency: "usd", amount: 1000 }],
104
+ }}
105
+ onSave={onSave}
106
+ onRetry={onRetry}
107
+ />,
108
+ );
109
+ expect(screen.getByRole("combobox")).toBeDisabled();
110
+ expect(
111
+ screen.getByRole("button", { name: "Save payout settings" }),
112
+ ).toBeDisabled();
113
+ await userEvent.click(
114
+ screen.getByRole("button", { name: "Retry settings verification" }),
115
+ );
116
+ await waitFor(() => expect(onRetry).toHaveBeenCalledTimes(1));
117
+ expect(onSave).not.toHaveBeenCalled();
118
+ });
119
+ it.each(["0", "10.001", "-2", "1e3", "10,20"])(
120
+ "does not round or submit invalid USD minimum %s",
121
+ async (value) => {
122
+ const onSave = vi.fn();
123
+ render(
124
+ <PayoutSettings
125
+ scopeKey="recipient"
126
+ currency="usd"
127
+ policy={null}
128
+ onSave={onSave}
129
+ />,
130
+ );
131
+ await userEvent.selectOptions(screen.getByRole("combobox"), "custom");
132
+ await userEvent.type(screen.getByRole("textbox"), value);
133
+ await userEvent.click(screen.getByRole("checkbox"));
134
+ expect(
135
+ screen.getByRole("button", { name: "Save payout settings" }),
136
+ ).toBeDisabled();
137
+ expect(onSave).not.toHaveBeenCalled();
138
+ },
139
+ );
@@ -0,0 +1,209 @@
1
+ "use client";
2
+ import { useId, useState, type ReactNode } from "react";
3
+ import type { PayoutPolicy } from "../../client/payoutPolicy.js";
4
+ import {
5
+ chargeAmountToDecimal,
6
+ parseChargeAmount,
7
+ } from "../../client/charge-currency.js";
8
+ import { useWorkflowAction } from "./useWorkflowAction.js";
9
+ export type PayoutSettingsChange = {
10
+ currency: string;
11
+ mode: "custom" | "stripe_managed";
12
+ expectedRevision: number;
13
+ minimum?: number;
14
+ consent: boolean;
15
+ };
16
+ export type PayoutSettingsResult = {
17
+ status:
18
+ | "ready"
19
+ | "unsupported"
20
+ | "migration_required"
21
+ | "schedule_drift"
22
+ | "canceled";
23
+ reason?: string;
24
+ };
25
+ export type PayoutSettingsProps = {
26
+ scopeKey: string;
27
+ currency: string;
28
+ policy:
29
+ | Pick<PayoutPolicy, "mode" | "status" | "revision" | "thresholds">
30
+ | null
31
+ | undefined;
32
+ onSave: (change: PayoutSettingsChange) => Promise<PayoutSettingsResult>;
33
+ onRetry?: () => Promise<PayoutSettingsResult>;
34
+ children?: (state: PayoutSettingsState) => ReactNode;
35
+ };
36
+ export type PayoutSettingsState = {
37
+ inputId: string;
38
+ currency: string;
39
+ mode: "custom" | "stripe_managed";
40
+ setMode: (mode: "custom" | "stripe_managed") => void;
41
+ minimum: string;
42
+ setMinimum: (minimum: string) => void;
43
+ consent: boolean;
44
+ setConsent: (consent: boolean) => void;
45
+ isLoading: boolean;
46
+ isChanging: boolean;
47
+ isPending: boolean;
48
+ canSave: boolean;
49
+ error: string | null;
50
+ message: string | null;
51
+ save: () => Promise<unknown>;
52
+ retry: () => Promise<unknown>;
53
+ };
54
+ const messages: Record<PayoutSettingsResult["status"], string> = {
55
+ ready: "Payout settings verified.",
56
+ migration_required:
57
+ "Existing funds or payouts need reconciliation before this schedule can change.",
58
+ unsupported:
59
+ "Custom payout scheduling is unavailable for this account. Review the current policy and Stripe schedule.",
60
+ schedule_drift:
61
+ "The Stripe payout schedule changed. Reconciliation is required before this operation can complete.",
62
+ canceled:
63
+ "The settings change was canceled. Refresh to view the current payout policy.",
64
+ };
65
+ /** Host callbacks authorize ownership and persist immutable schedule operations. */
66
+ export function usePayoutSettings(
67
+ props: PayoutSettingsProps,
68
+ ): PayoutSettingsState {
69
+ const inputId = useId();
70
+ const scope = `${props.scopeKey}:${props.currency}:${props.policy?.revision ?? -1}:${props.policy?.status ?? "default"}`;
71
+ const configured = props.policy?.thresholds.find(
72
+ (t) => t.currency === props.currency,
73
+ );
74
+ const initial = {
75
+ scope,
76
+ mode: props.policy?.mode ?? ("stripe_managed" as const),
77
+ minimum: configured
78
+ ? (chargeAmountToDecimal(configured.amount, props.currency) ?? "")
79
+ : "",
80
+ consent: false,
81
+ };
82
+ const [fields, setFields] = useState(initial);
83
+ if (fields.scope !== scope) setFields(initial);
84
+ const isLoading = props.policy === undefined;
85
+ const isChanging = props.policy?.status === "changing";
86
+ // This only parses ordinary currency units; provider capability/floor checks stay on the server.
87
+ const minimum = parseChargeAmount(fields.minimum.trim(), props.currency);
88
+ const valid =
89
+ fields.mode === "stripe_managed" ||
90
+ (minimum !== null && minimum > 0 && fields.consent);
91
+ const action = useWorkflowAction(async (retry: boolean) => {
92
+ if (retry) {
93
+ if (!props.onRetry) throw new Error("Settings retry is unavailable.");
94
+ return props.onRetry();
95
+ }
96
+ if (isLoading || isChanging || !valid)
97
+ throw new Error(
98
+ "Enter a positive minimum and consent to custom scheduling before saving.",
99
+ );
100
+ return props.onSave({
101
+ currency: props.currency,
102
+ expectedRevision: props.policy?.revision ?? -1,
103
+ mode: fields.mode,
104
+ ...(fields.mode === "custom" ? { minimum: minimum! } : {}),
105
+ consent: fields.mode === "custom" && fields.consent,
106
+ });
107
+ }, scope);
108
+ const update = (values: Partial<typeof fields>) => {
109
+ action.reset();
110
+ setFields((old) => ({ ...old, ...values }));
111
+ };
112
+ return {
113
+ inputId,
114
+ currency: props.currency,
115
+ mode: fields.mode,
116
+ minimum: fields.minimum,
117
+ consent: fields.consent,
118
+ setMode: (mode) => update({ mode, consent: false }),
119
+ setMinimum: (minimum) => update({ minimum }),
120
+ setConsent: (consent) => update({ consent }),
121
+ isLoading,
122
+ isChanging,
123
+ isPending: action.isPending,
124
+ canSave: !isLoading && !isChanging && !action.isPending && valid,
125
+ error: action.error,
126
+ message: action.result ? messages[action.result.status] : null,
127
+ save: () => action.run(false),
128
+ retry: () => action.run(true),
129
+ };
130
+ }
131
+ /** Unstyled account-wide schedule editor. It offers no payout or withdrawal action. */
132
+ export function PayoutSettings(props: PayoutSettingsProps) {
133
+ const state = usePayoutSettings(props);
134
+ if (props.children) return <>{props.children(state)}</>;
135
+ return (
136
+ <form
137
+ data-better-stripe="payout-settings"
138
+ onSubmit={(event) => {
139
+ event.preventDefault();
140
+ void state.save();
141
+ }}
142
+ >
143
+ <h2>Payout settings</h2>
144
+ <label htmlFor={`${state.inputId}-mode`}>Automatic payout policy</label>
145
+ <select
146
+ id={`${state.inputId}-mode`}
147
+ value={state.mode}
148
+ disabled={state.isLoading || state.isPending || state.isChanging}
149
+ onChange={(event) =>
150
+ state.setMode(event.target.value as PayoutSettingsState["mode"])
151
+ }
152
+ >
153
+ <option value="stripe_managed">
154
+ Stripe-managed automatic payouts, all currencies
155
+ </option>
156
+ <option value="custom">Custom combined minimum</option>
157
+ </select>
158
+ {state.mode === "custom" && (
159
+ <>
160
+ <label htmlFor={`${state.inputId}-minimum`}>
161
+ Minimum ({props.currency.toUpperCase()})
162
+ </label>
163
+ <input
164
+ id={`${state.inputId}-minimum`}
165
+ inputMode="decimal"
166
+ value={state.minimum}
167
+ disabled={state.isPending || state.isChanging}
168
+ onChange={(event) => state.setMinimum(event.target.value)}
169
+ />
170
+ <label>
171
+ <input
172
+ type="checkbox"
173
+ checked={state.consent}
174
+ disabled={state.isPending || state.isChanging}
175
+ onChange={(event) => state.setConsent(event.target.checked)}
176
+ />
177
+ I agree to hold funds in my Stripe balance until the combined
178
+ minimum is met, subject to the holding deadline.
179
+ </label>
180
+ <p>
181
+ Store and affiliate earnings combine per currency. Below-minimum
182
+ funds remain owed. Other currency minimums are preserved.
183
+ </p>
184
+ </>
185
+ )}
186
+ {state.isLoading && <p role="status">Loading payout settings…</p>}
187
+ {state.isChanging && (
188
+ <p role="status">
189
+ A settings change is being verified. The automatic worker will resume
190
+ it.
191
+ </p>
192
+ )}
193
+ {state.error && <p role="alert">{state.error}</p>}
194
+ {state.message && <p role="status">{state.message}</p>}
195
+ <button type="submit" disabled={!state.canSave}>
196
+ {state.isPending ? "Saving…" : "Save payout settings"}
197
+ </button>
198
+ {state.isChanging && props.onRetry && (
199
+ <button
200
+ type="button"
201
+ disabled={state.isPending}
202
+ onClick={() => void state.retry()}
203
+ >
204
+ Retry settings verification
205
+ </button>
206
+ )}
207
+ </form>
208
+ );
209
+ }
@@ -27,6 +27,19 @@ export type {
27
27
  ConnectRenderProps,
28
28
  } from "./Connect.js";
29
29
  export { Payouts } from "./Payouts.js";
30
+ export { PayoutProgress } from "./PayoutProgress.js";
31
+ export { PayoutSettings, usePayoutSettings } from "./PayoutSettings.js";
32
+ export type {
33
+ PayoutSettingsProps,
34
+ PayoutSettingsState,
35
+ PayoutSettingsChange,
36
+ PayoutSettingsResult,
37
+ } from "./PayoutSettings.js";
38
+ export type {
39
+ PayoutProgressProps,
40
+ PayoutProgressState,
41
+ PayoutProgressMetric,
42
+ } from "./PayoutProgress.js";
30
43
  export type {
31
44
  PayoutsProps,
32
45
  HeadlessPayoutsProps,
@@ -0,0 +1,199 @@
1
+ import type { ReactNode } from "react";
2
+ import type { PayoutPolicy, PayoutPreview } from "../../client/payoutPolicy.js";
3
+ import { formatPrice } from "../lib/price-helpers.js";
4
+ export type PayoutProgressMetric = {
5
+ key:
6
+ | "qualifying"
7
+ | "pending"
8
+ | "minimum"
9
+ | "eligible"
10
+ | "fees"
11
+ | "liabilities"
12
+ | "carried"
13
+ | "withheld"
14
+ | "reserved"
15
+ | "released";
16
+ label: string;
17
+ amount: number;
18
+ formattedValue: string;
19
+ };
20
+ export type PayoutProgressState = {
21
+ status:
22
+ "loading" | "stripe_managed" | "changing" | "unavailable" | "assessed";
23
+ currency: string;
24
+ reason: string | null;
25
+ message: string;
26
+ metrics: PayoutProgressMetric[];
27
+ accounting?: NonNullable<
28
+ Extract<PayoutPreview, { currency: string }>["accounting"]
29
+ >;
30
+ sources: {
31
+ role: "store" | "affiliate" | "other";
32
+ matureUnpaid: number;
33
+ pendingCredits: number;
34
+ matureLabel: string;
35
+ pendingLabel: string;
36
+ releasedAmount?: number;
37
+ reservedAmount?: number;
38
+ releasedLabel?: string;
39
+ reservedLabel?: string;
40
+ }[];
41
+ progress: { value: number; max: number } | null;
42
+ nextAttemptAt: number | null;
43
+ holdDeadline: number | null;
44
+ observedAt: number | null;
45
+ };
46
+ export type PayoutProgressProps = {
47
+ policy:
48
+ | Pick<PayoutPolicy, "mode" | "status" | "thresholds" | "holdDeadline">
49
+ | null
50
+ | undefined;
51
+ preview?: PayoutPreview | null;
52
+ currency: string;
53
+ /** Application worker attempt time, never a bank arrival estimate. */
54
+ nextAttemptAt?: number | null;
55
+ locale?: string;
56
+ timeZone?: string;
57
+ className?: string;
58
+ /** Compose translated host cards with the same financial state. */
59
+ children?: (state: PayoutProgressState) => ReactNode;
60
+ };
61
+ const reasons: Record<string, string> = {
62
+ below_threshold:
63
+ "Below-minimum funds remain owed and carry forward to a later automatic attempt.",
64
+ insufficient_available:
65
+ "Qualifying earnings meet the minimum, but the supported Stripe payout balance is insufficient.",
66
+ affiliate_recovery_pending:
67
+ "Commission recovery must settle before a new automatic payout can qualify.",
68
+ unallocated_history:
69
+ "Previous Stripe activity needs reconciliation before unpaid entitlement can be assessed.",
70
+ pending_claim:
71
+ "An existing payout is being reconciled before another payout can qualify.",
72
+ payout_unidentified:
73
+ "A previous payout outcome needs reconciliation. No replacement payout will be created blindly.",
74
+ observations_stale: "Provider observations expired. Refresh the assessment.",
75
+ snapshot_limit:
76
+ "This account needs continued history reconciliation before eligibility can be assessed.",
77
+ policy_changed: "Payout settings changed. Refresh the assessment.",
78
+ };
79
+ export function payoutProgressState({
80
+ policy,
81
+ preview,
82
+ currency,
83
+ nextAttemptAt,
84
+ locale = "en-US",
85
+ }: PayoutProgressProps): PayoutProgressState {
86
+ const amount = (value: number) => formatPrice(value, currency, locale);
87
+ const state: PayoutProgressState = {
88
+ status: "loading",
89
+ currency,
90
+ reason: null,
91
+ message: "Loading payout eligibility…",
92
+ metrics: [],
93
+ sources: [],
94
+ progress: null,
95
+ nextAttemptAt: null,
96
+ holdDeadline:
97
+ policy?.mode === "custom" ? (policy.holdDeadline ?? null) : null,
98
+ observedAt: null,
99
+ };
100
+ 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") {
106
+ state.status = "changing";
107
+ state.message =
108
+ "Payout settings are being verified. Eligibility is unavailable until the change completes.";
109
+ } else if (preview !== undefined) {
110
+ state.nextAttemptAt = nextAttemptAt ?? null;
111
+ if (!preview || !("currency" in preview)) {
112
+ state.status = "unavailable";
113
+ state.reason = preview?.reason ?? "assessment_unavailable";
114
+ state.message =
115
+ reasons[state.reason] ??
116
+ "Automatic payout eligibility is unavailable. Refresh or contact support.";
117
+ } else {
118
+ const configured = policy.thresholds.find(
119
+ (t) => t.currency === currency,
120
+ );
121
+ if (
122
+ preview.currency !== currency ||
123
+ !configured ||
124
+ configured.amount !== preview.threshold
125
+ )
126
+ throw new Error(
127
+ "Payout progress currency or policy does not match the assessment.",
128
+ );
129
+ state.status = "assessed";
130
+ state.reason = preview.reason ?? null;
131
+ state.message =
132
+ preview.status === "qualified"
133
+ ? "The combined balance qualified at the assessment time. The automatic worker will verify it again before any payout."
134
+ : (reasons[preview.reason ?? ""] ??
135
+ "Automatic payout is on hold. Refresh or contact support.");
136
+ state.observedAt = preview.observedAt;
137
+ state.accounting = preview.accounting;
138
+ state.progress = {
139
+ value: Math.max(0, Math.min(preview.threshold, preview.matureUnpaid)),
140
+ max: preview.threshold,
141
+ };
142
+ const definitions = [
143
+ ["qualifying", "Combined qualifying balance", preview.matureUnpaid],
144
+ ["pending", "Pending settlement", preview.pendingCredits],
145
+ ["minimum", "Combined payout minimum", preview.threshold],
146
+ ["eligible", "Eligible at assessment", preview.amount],
147
+ ...(preview.accounting
148
+ ? ([
149
+ [
150
+ "carried",
151
+ "Carried forward",
152
+ preview.accounting.carriedForwardAmount,
153
+ ],
154
+ [
155
+ "withheld",
156
+ "Withheld for costs",
157
+ preview.accounting.withheldAmount,
158
+ ],
159
+ [
160
+ "reserved",
161
+ "Payouts in progress",
162
+ preview.accounting.reservedAmount,
163
+ ],
164
+ [
165
+ "released",
166
+ "Paid out, net of returns",
167
+ preview.accounting.releasedAmount,
168
+ ],
169
+ ] as const)
170
+ : []),
171
+ ["fees", "Confirmed payout costs", preview.confirmedFees],
172
+ [
173
+ "liabilities",
174
+ "Other outstanding obligations",
175
+ preview.independentLiabilities,
176
+ ],
177
+ ] as const;
178
+ state.metrics = definitions.map(([key, label, value]) => ({
179
+ key,
180
+ label,
181
+ amount: value,
182
+ formattedValue: amount(value),
183
+ }));
184
+ state.sources = preview.sources.map((source) => ({
185
+ ...source,
186
+ matureLabel: amount(source.matureUnpaid),
187
+ pendingLabel: amount(source.pendingCredits),
188
+ ...(source.releasedAmount === undefined
189
+ ? {}
190
+ : { releasedLabel: amount(source.releasedAmount) }),
191
+ ...(source.reservedAmount === undefined
192
+ ? {}
193
+ : { reservedLabel: amount(source.reservedAmount) }),
194
+ }));
195
+ }
196
+ }
197
+ }
198
+ return state;
199
+ }