@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.
- package/CHANGELOG.md +12 -0
- package/MIGRATION.md +6 -0
- package/README.md +7 -1
- package/dist/client/payoutPolicy.d.ts +1 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +2 -1
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/component/_generated/component.d.ts +8 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/payoutPolicy/helpers.d.ts +1 -1
- package/dist/component/payoutPolicy/helpers.d.ts.map +1 -1
- package/dist/component/payoutPolicy/helpers.js +3 -1
- package/dist/component/payoutPolicy/helpers.js.map +1 -1
- package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
- package/dist/component/payoutPolicy/mutations.js +1 -1
- package/dist/component/payoutPolicy/mutations.js.map +1 -1
- package/dist/component/payoutRelease/allocationTotals.d.ts +8 -0
- package/dist/component/payoutRelease/allocationTotals.d.ts.map +1 -0
- package/dist/component/payoutRelease/allocationTotals.js +43 -0
- package/dist/component/payoutRelease/allocationTotals.js.map +1 -0
- package/dist/component/payoutRelease/planning.d.ts.map +1 -1
- package/dist/component/payoutRelease/planning.js +2 -0
- package/dist/component/payoutRelease/planning.js.map +1 -1
- package/dist/component/payoutRelease/preview.d.ts +35 -2
- package/dist/component/payoutRelease/preview.d.ts.map +1 -1
- package/dist/component/payoutRelease/preview.js +6 -11
- package/dist/component/payoutRelease/preview.js.map +1 -1
- package/dist/component/payoutRelease/progress.d.ts +29 -0
- package/dist/component/payoutRelease/progress.d.ts.map +1 -0
- package/dist/component/payoutRelease/progress.js +40 -0
- package/dist/component/payoutRelease/progress.js.map +1 -0
- package/dist/component/payoutRelease/queries.d.ts +8 -0
- package/dist/component/payoutRelease/queries.d.ts.map +1 -1
- package/dist/react/workflows/PayoutProgress.d.ts +5 -0
- package/dist/react/workflows/PayoutProgress.d.ts.map +1 -0
- package/dist/react/workflows/PayoutProgress.js +23 -0
- package/dist/react/workflows/PayoutProgress.js.map +1 -0
- package/dist/react/workflows/PayoutSettings.d.ts +44 -0
- package/dist/react/workflows/PayoutSettings.d.ts.map +1 -0
- package/dist/react/workflows/PayoutSettings.js +84 -0
- package/dist/react/workflows/PayoutSettings.js.map +1 -0
- package/dist/react/workflows/index.d.ts +4 -0
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +2 -0
- package/dist/react/workflows/index.js.map +1 -1
- package/dist/react/workflows/payout-progress-state.d.ts +50 -0
- package/dist/react/workflows/payout-progress-state.d.ts.map +1 -0
- package/dist/react/workflows/payout-progress-state.js +124 -0
- package/dist/react/workflows/payout-progress-state.js.map +1 -0
- package/docs/react-workflows-audit.md +1 -1
- package/docs/react-workflows.md +88 -0
- package/docs/settlement.md +23 -0
- package/package.json +1 -1
- package/src/client/connect/payoutPreview.test.ts +145 -0
- package/src/client/payoutPolicy.test.ts +13 -0
- package/src/client/payoutPolicy.ts +5 -1
- package/src/component/_generated/api.ts +4 -0
- package/src/component/_generated/component.ts +8 -0
- package/src/component/payoutPolicy/helpers.ts +3 -1
- package/src/component/payoutPolicy/mutations.ts +4 -1
- package/src/component/payoutRelease/allocationTotals.ts +56 -0
- package/src/component/payoutRelease/planning.ts +2 -0
- package/src/component/payoutRelease/preview.ts +10 -20
- package/src/component/payoutRelease/progress.test.ts +63 -0
- package/src/component/payoutRelease/progress.ts +56 -0
- package/src/react/workflows/PayoutProgress.test.tsx +208 -0
- package/src/react/workflows/PayoutProgress.tsx +108 -0
- package/src/react/workflows/PayoutSettings.test.tsx +139 -0
- package/src/react/workflows/PayoutSettings.tsx +209 -0
- package/src/react/workflows/index.ts +13 -0
- 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
|
+
}
|