@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.
- package/CHANGELOG.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/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/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 +43 -0
- package/dist/react/workflows/payout-progress-state.d.ts.map +1 -0
- package/dist/react/workflows/payout-progress-state.js +93 -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 +81 -0
- package/package.json +1 -1
- package/src/client/payoutPolicy.test.ts +13 -0
- package/src/client/payoutPolicy.ts +5 -1
- package/src/component/payoutPolicy/helpers.ts +3 -1
- package/src/component/payoutPolicy/mutations.ts +4 -1
- package/src/react/workflows/PayoutProgress.test.tsx +169 -0
- package/src/react/workflows/PayoutProgress.tsx +94 -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 +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` |
|
package/docs/react-workflows.md
CHANGED
|
@@ -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
|
@@ -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(
|
|
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
|
|
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(
|
|
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
|
+
}
|