@fantastic.dev/better-stripe 0.26.0 → 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 (42) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +7 -1
  3. package/dist/client/payoutPolicy.d.ts +1 -0
  4. package/dist/client/payoutPolicy.d.ts.map +1 -1
  5. package/dist/client/payoutPolicy.js +2 -1
  6. package/dist/client/payoutPolicy.js.map +1 -1
  7. package/dist/component/payoutPolicy/helpers.d.ts +1 -1
  8. package/dist/component/payoutPolicy/helpers.d.ts.map +1 -1
  9. package/dist/component/payoutPolicy/helpers.js +3 -1
  10. package/dist/component/payoutPolicy/helpers.js.map +1 -1
  11. package/dist/component/payoutPolicy/mutations.d.ts.map +1 -1
  12. package/dist/component/payoutPolicy/mutations.js +1 -1
  13. package/dist/component/payoutPolicy/mutations.js.map +1 -1
  14. package/dist/react/workflows/PayoutProgress.d.ts +5 -0
  15. package/dist/react/workflows/PayoutProgress.d.ts.map +1 -0
  16. package/dist/react/workflows/PayoutProgress.js +23 -0
  17. package/dist/react/workflows/PayoutProgress.js.map +1 -0
  18. package/dist/react/workflows/PayoutSettings.d.ts +44 -0
  19. package/dist/react/workflows/PayoutSettings.d.ts.map +1 -0
  20. package/dist/react/workflows/PayoutSettings.js +84 -0
  21. package/dist/react/workflows/PayoutSettings.js.map +1 -0
  22. package/dist/react/workflows/index.d.ts +4 -0
  23. package/dist/react/workflows/index.d.ts.map +1 -1
  24. package/dist/react/workflows/index.js +2 -0
  25. package/dist/react/workflows/index.js.map +1 -1
  26. package/dist/react/workflows/payout-progress-state.d.ts +43 -0
  27. package/dist/react/workflows/payout-progress-state.d.ts.map +1 -0
  28. package/dist/react/workflows/payout-progress-state.js +93 -0
  29. package/dist/react/workflows/payout-progress-state.js.map +1 -0
  30. package/docs/react-workflows-audit.md +1 -1
  31. package/docs/react-workflows.md +81 -0
  32. package/package.json +1 -1
  33. package/src/client/payoutPolicy.test.ts +13 -0
  34. package/src/client/payoutPolicy.ts +5 -1
  35. package/src/component/payoutPolicy/helpers.ts +3 -1
  36. package/src/component/payoutPolicy/mutations.ts +4 -1
  37. package/src/react/workflows/PayoutProgress.test.tsx +169 -0
  38. package/src/react/workflows/PayoutProgress.tsx +94 -0
  39. package/src/react/workflows/PayoutSettings.test.tsx +139 -0
  40. package/src/react/workflows/PayoutSettings.tsx +209 -0
  41. package/src/react/workflows/index.ts +13 -0
  42. package/src/react/workflows/payout-progress-state.ts +152 -0
@@ -0,0 +1,93 @@
1
+ import { formatPrice } from "../lib/price-helpers.js";
2
+ const reasons = {
3
+ below_threshold: "Below-minimum funds remain owed and carry forward to a later automatic attempt.",
4
+ insufficient_available: "Qualifying earnings meet the minimum, but the supported Stripe payout balance is insufficient.",
5
+ affiliate_recovery_pending: "Commission recovery must settle before a new automatic payout can qualify.",
6
+ unallocated_history: "Previous Stripe activity needs reconciliation before unpaid entitlement can be assessed.",
7
+ pending_claim: "An existing payout is being reconciled before another payout can qualify.",
8
+ payout_unidentified: "A previous payout outcome needs reconciliation. No replacement payout will be created blindly.",
9
+ observations_stale: "Provider observations expired. Refresh the assessment.",
10
+ snapshot_limit: "This account needs continued history reconciliation before eligibility can be assessed.",
11
+ policy_changed: "Payout settings changed. Refresh the assessment.",
12
+ };
13
+ export function payoutProgressState({ policy, preview, currency, nextAttemptAt, locale = "en-US", }) {
14
+ const amount = (value) => formatPrice(value, currency, locale);
15
+ const state = {
16
+ status: "loading",
17
+ currency,
18
+ reason: null,
19
+ message: "Loading payout eligibility…",
20
+ metrics: [],
21
+ sources: [],
22
+ progress: null,
23
+ nextAttemptAt: null,
24
+ holdDeadline: policy?.mode === "custom" ? (policy.holdDeadline ?? null) : null,
25
+ observedAt: null,
26
+ };
27
+ if (policy !== undefined) {
28
+ if (!policy || policy.mode === "stripe_managed") {
29
+ state.status = "stripe_managed";
30
+ state.message =
31
+ "Stripe manages automatic payouts. No additional application minimum is set.";
32
+ }
33
+ else if (policy.status === "changing") {
34
+ state.status = "changing";
35
+ state.message =
36
+ "Payout settings are being verified. Eligibility is unavailable until the change completes.";
37
+ }
38
+ else if (preview !== undefined) {
39
+ state.nextAttemptAt = nextAttemptAt ?? null;
40
+ if (!preview || !("currency" in preview)) {
41
+ state.status = "unavailable";
42
+ state.reason = preview?.reason ?? "assessment_unavailable";
43
+ state.message =
44
+ reasons[state.reason] ??
45
+ "Automatic payout eligibility is unavailable. Refresh or contact support.";
46
+ }
47
+ else {
48
+ const configured = policy.thresholds.find((t) => t.currency === currency);
49
+ if (preview.currency !== currency ||
50
+ !configured ||
51
+ configured.amount !== preview.threshold)
52
+ throw new Error("Payout progress currency or policy does not match the assessment.");
53
+ state.status = "assessed";
54
+ state.reason = preview.reason ?? null;
55
+ state.message =
56
+ preview.status === "qualified"
57
+ ? "The combined balance qualified at the assessment time. The automatic worker will verify it again before any payout."
58
+ : (reasons[preview.reason ?? ""] ??
59
+ "Automatic payout is on hold. Refresh or contact support.");
60
+ state.observedAt = preview.observedAt;
61
+ state.progress = {
62
+ value: Math.max(0, Math.min(preview.threshold, preview.matureUnpaid)),
63
+ max: preview.threshold,
64
+ };
65
+ const definitions = [
66
+ ["qualifying", "Combined qualifying balance", preview.matureUnpaid],
67
+ ["pending", "Pending settlement", preview.pendingCredits],
68
+ ["minimum", "Combined payout minimum", preview.threshold],
69
+ ["eligible", "Eligible at assessment", preview.amount],
70
+ ["fees", "Confirmed payout costs", preview.confirmedFees],
71
+ [
72
+ "liabilities",
73
+ "Other outstanding obligations",
74
+ preview.independentLiabilities,
75
+ ],
76
+ ];
77
+ state.metrics = definitions.map(([key, label, value]) => ({
78
+ key,
79
+ label,
80
+ amount: value,
81
+ formattedValue: amount(value),
82
+ }));
83
+ state.sources = preview.sources.map((source) => ({
84
+ ...source,
85
+ matureLabel: amount(source.matureUnpaid),
86
+ pendingLabel: amount(source.pendingCredits),
87
+ }));
88
+ }
89
+ }
90
+ }
91
+ return state;
92
+ }
93
+ //# sourceMappingURL=payout-progress-state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"payout-progress-state.js","sourceRoot":"","sources":["../../../src/react/workflows/payout-progress-state.ts"],"names":[],"mappings":"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"}
@@ -16,7 +16,7 @@ uses render props or companion hooks to apply shadcn styling.
16
16
  | Customer invoices | `Invoices` |
17
17
  | Customer payment methods | `usePaymentMethodsManager`, `PaymentMethods`, `PaymentMethodsList`, `AddCardForm`, Stripe providers |
18
18
  | Seller overview | `PayoutOverview`, `AccountCreateCard`, `ConnectStatusBadge` |
19
- | Seller and affiliate payouts | `Payouts`, `PayoutHistory`, live balances, upcoming date, history and pagination |
19
+ | Seller and affiliate payouts | `Payouts`, `PayoutHistory`, `PayoutProgress`, `PayoutSettings`, live balances, upcoming date, history and pagination |
20
20
  | Seller earnings | `EarningsSummary`, `PayoutSchedule`, `PayoutHistory`, `earningsFromBalance`, `useWorkflowResource` |
21
21
  | Seller sales | `Sales` |
22
22
  | Seller subscriptions | `Subscriptions`, `SubscriptionCard` |
@@ -640,6 +640,87 @@ Consumer applications still need to verify their own authenticated adapters and
640
640
  See [sandbox setup findings](accounts-v2-preview.md#connect-sandbox-setup-findings-2026-10-15)
641
641
  for the account eligibility requirement.
642
642
 
643
+ ## Combined automatic payout progress
644
+
645
+ `PayoutProgress` is an unstyled, read-only view of a custom recipient's combined
646
+ eligibility assessment. Load the policy and
647
+ [preview](settlement.md#payout-eligibility-preview) through an authorized server
648
+ adapter. Derive independent liabilities on the server. Read the worker's next
649
+ attempt separately from the provider's actual bank arrival dates.
650
+
651
+ ```tsx
652
+ import { PayoutProgress } from "@fantastic.dev/better-stripe/react";
653
+
654
+ <PayoutProgress
655
+ policy={policy}
656
+ preview={preview}
657
+ currency="usd"
658
+ nextAttemptAt={job?.nextAttemptAt}
659
+ locale="en-US"
660
+ timeZone="UTC"
661
+ />;
662
+ ```
663
+
664
+ The default markup uses a single accessible progress indicator, financial
665
+ metrics, source contributions and assessment/attempt/deadline dates. Store and
666
+ affiliate contributions share one minimum. Pending credits are separate, shared
667
+ costs reduce the combined entitlement once, and negative entitlement remains
668
+ visible even though the visual progress indicator stops at zero. A qualified
669
+ assessment is informational; the automatic worker verifies it again before a
670
+ payout. The component has no withdrawal action.
671
+
672
+ `policy={undefined}` means loading. A null policy or `stripe_managed` mode shows
673
+ Stripe-managed defaults without inventing a numeric provider floor. Changing
674
+ policies suppress prior financial progress. For a ready custom policy, an
675
+ undefined preview means loading; a null or minimal held preview shows an
676
+ unavailable assessment rather than fabricated zero balances. A mismatched
677
+ currency or configured minimum rejects the display.
678
+
679
+ Use the `children` render prop for translated labels and application cards. It
680
+ receives `status`, `reason`, `metrics`, `sources`, `progress`, `observedAt`,
681
+ `nextAttemptAt` and `holdDeadline`. Metrics include stable keys and raw minor-unit
682
+ amounts as well as formatted values. Render props use the same state calculation
683
+ as the native markup; replace the English message with your own translated copy
684
+ selected by `status` and `reason`.
685
+
686
+ Scope and refresh the host data when the recipient or settlement currency
687
+ changes. Keep currencies separate. This display does not configure policies,
688
+ install a scheduler or bypass the release API's capability/history limits.
689
+
690
+ ## Payout settings
691
+
692
+ `PayoutSettings` and `usePayoutSettings` own minimum entry, explicit custom-hold
693
+ consent, loading/error states and callback locking. They never create a payout.
694
+ `onSave` receives `{ currency, mode, expectedRevision, minimum?, consent }`.
695
+ The minimum uses ordinary Stripe currency units. No provider floor is guessed.
696
+ The host must derive the authenticated account/owner, preserve the policy's
697
+ other currency minimums, and call `payoutPolicy.prepare` and `apply` with a
698
+ stable immutable operation identity. Return `{ status, reason? }`; supported
699
+ statuses are `ready`, `unsupported`, `migration_required`, `schedule_drift` and
700
+ `canceled`.
701
+
702
+ When a provider outcome is unknown, refresh the policy instead of inventing
703
+ success or creating a new operation. `onRetry` resumes the server's stored
704
+ pending operation; the installed recurring job also resumes it. The editor
705
+ locks changes while `policy.status` is `changing`. Render props expose the same
706
+ state for translated forms and design-system controls.
707
+
708
+ The example's `recipientPayoutPolicy` actions bind the demo user to its account
709
+ server-side and derive uncollected seller costs through the same query as the
710
+ worker. They preserve other currencies, replay stored changes without rolling
711
+ back newer settings, and restore the original schedule on opt-out. The public
712
+ `payoutPreviewValidator` lets the host validate the package assessment without
713
+ copying its return schema. Production apps must replace demo persona selection
714
+ with authenticated ownership; the demo is not evidence of production auth.
715
+
716
+ Custom schedules affect the entire recipient account. Configure each settlement
717
+ currency. Choosing Stripe-managed mode restores the original schedule for all
718
+ currencies and removes application minimums. Funded accounts needing historical
719
+ adoption show `migration_required`; this form does not guess their source
720
+ allocations or automatically adopt unallocated money. Install worker recovery
721
+ and deadline-expiry jobs before exposing activation, and use the reset guard
722
+ described in the [settlement guide](settlement.md#development-reset-safeguards).
723
+
643
724
  ## Component history and currency summaries
644
725
 
645
726
  See the [read contract guide](read-contracts.md) for reactive product, price and payout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fantastic.dev/better-stripe",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "A Convex component for Stripe V2 Accounts API integration.",
5
5
  "keywords": [
6
6
  "stripe",
@@ -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);
@@ -3,6 +3,7 @@ import {
3
3
  type PayoutPreviewOptions,
4
4
  } from "./connect/payoutPreview.js";
5
5
  export type * from "./connect/payoutPreview.js";
6
+ export { previewResult as payoutPreviewValidator } from "../component/payoutRelease/preview.js";
6
7
  import { PayoutWorkerClient } from "./connect/payoutWorker.js";
7
8
  export type * from "./connect/payoutWorker.js";
8
9
  import {
@@ -312,7 +313,10 @@ export class PayoutPolicyClient {
312
313
  if (
313
314
  prior.mode !== opts.mode ||
314
315
  prior.expectedRevision !== opts.expectedRevision ||
315
- !equal(prior.thresholds, thresholds) ||
316
+ !equal(
317
+ canonicalThresholds(prior.thresholds, prior.mode === "custom"),
318
+ thresholds,
319
+ ) ||
316
320
  Boolean(prior.adoption) !== Boolean(opts.adoptExistingBalance)
317
321
  )
318
322
  throw new Error("Payout policy operation identity cannot be reused.");
@@ -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.");
@@ -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
+ });
@@ -0,0 +1,94 @@
1
+ "use client";
2
+ import { useMemo } from "react";
3
+ import {
4
+ payoutProgressState,
5
+ type PayoutProgressProps,
6
+ } from "./payout-progress-state.js";
7
+ export type {
8
+ PayoutProgressMetric,
9
+ PayoutProgressProps,
10
+ PayoutProgressState,
11
+ } from "./payout-progress-state.js";
12
+ /** Unstyled, read-only progress. A preview never authorizes or requests a payout. */
13
+ export function PayoutProgress(props: PayoutProgressProps) {
14
+ const { currency, locale = "en-US", timeZone, className, children } = props;
15
+ const state = payoutProgressState(props);
16
+ const formatter = useMemo(
17
+ () =>
18
+ new Intl.DateTimeFormat(locale, {
19
+ dateStyle: "medium",
20
+ timeStyle: "short",
21
+ timeZone,
22
+ }),
23
+ [locale, timeZone],
24
+ );
25
+ if (children) return <>{children(state)}</>;
26
+ const date = (value: number) => formatter.format(value);
27
+ return (
28
+ <section className={className} data-better-stripe="payout-progress">
29
+ <h2>Automatic payout progress ({currency.toUpperCase()})</h2>
30
+ <p role="status">{state.message}</p>
31
+ <p>Stripe enforces its payout floor; a numeric amount is unavailable.</p>
32
+ {state.progress && (
33
+ <progress
34
+ aria-label="Combined payout minimum"
35
+ value={state.progress.value}
36
+ max={state.progress.max}
37
+ />
38
+ )}
39
+ {state.metrics.length > 0 && (
40
+ <dl>
41
+ {state.metrics.map((metric) => (
42
+ <div key={metric.key}>
43
+ <dt>{metric.label}</dt>
44
+ <dd>{metric.formattedValue}</dd>
45
+ </div>
46
+ ))}
47
+ </dl>
48
+ )}
49
+ {state.sources.length > 0 && (
50
+ <>
51
+ <p>
52
+ All categories contribute to one minimum. Source amounts are before
53
+ shared costs and obligations. Pending funds do not qualify.
54
+ </p>
55
+ <table>
56
+ <caption>Source contributions</caption>
57
+ <thead>
58
+ <tr>
59
+ <th scope="col">Source</th>
60
+ <th scope="col">Mature unpaid</th>
61
+ <th scope="col">Pending settlement</th>
62
+ </tr>
63
+ </thead>
64
+ <tbody>
65
+ {state.sources.map((source) => (
66
+ <tr key={source.role}>
67
+ <th scope="row">
68
+ {source.role === "store"
69
+ ? "Store"
70
+ : source.role === "affiliate"
71
+ ? "Affiliate"
72
+ : "Other"}
73
+ </th>
74
+ <td>{source.matureLabel}</td>
75
+ <td>{source.pendingLabel}</td>
76
+ </tr>
77
+ ))}
78
+ </tbody>
79
+ </table>
80
+ </>
81
+ )}
82
+ {state.nextAttemptAt !== null && (
83
+ <p>
84
+ Next automatic attempt: {date(state.nextAttemptAt)}. This is not a
85
+ bank arrival date.
86
+ </p>
87
+ )}
88
+ {state.holdDeadline !== null && (
89
+ <p>Custom holding deadline: {date(state.holdDeadline)}</p>
90
+ )}
91
+ {state.observedAt !== null && <p>Assessed: {date(state.observedAt)}</p>}
92
+ </section>
93
+ );
94
+ }