@fantastic.dev/better-stripe 0.28.4 → 0.28.5
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 +3 -1
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +6 -2
- package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts +3 -1
- package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutSyncTestHelpers.d.ts +3 -1
- package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -1
- package/dist/client/connect/payoutWorker.d.ts +4 -1
- package/dist/client/connect/payoutWorker.d.ts.map +1 -1
- package/dist/client/connect/payoutWorker.js +35 -1
- package/dist/client/connect/payoutWorker.js.map +1 -1
- package/dist/client/payoutPolicy.d.ts +2 -0
- package/dist/client/payoutPolicy.d.ts.map +1 -1
- package/dist/client/payoutPolicy.js +2 -0
- package/dist/client/payoutPolicy.js.map +1 -1
- package/dist/component/_generated/component.d.ts +4 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/earnings/triggers.d.ts +4 -2
- package/dist/component/earnings/triggers.d.ts.map +1 -1
- package/dist/component/payoutWorker/helpers.d.ts +1 -0
- package/dist/component/payoutWorker/helpers.d.ts.map +1 -1
- package/dist/component/payoutWorker/mutations.d.ts +2 -0
- package/dist/component/payoutWorker/mutations.d.ts.map +1 -1
- package/dist/component/payoutWorker/mutations.js +3 -0
- package/dist/component/payoutWorker/mutations.js.map +1 -1
- package/dist/component/payoutWorker/queries.d.ts +2 -0
- package/dist/component/payoutWorker/queries.d.ts.map +1 -1
- package/dist/component/payoutWorker/schema.d.ts +18 -2
- package/dist/component/payoutWorker/schema.d.ts.map +1 -1
- package/dist/component/payoutWorker/schema.js +7 -0
- package/dist/component/payoutWorker/schema.js.map +1 -1
- package/dist/component/schema.d.ts +3 -1
- package/dist/react/workflows/PayoutAttemptStatus.d.ts +14 -0
- package/dist/react/workflows/PayoutAttemptStatus.d.ts.map +1 -0
- package/dist/react/workflows/PayoutAttemptStatus.js +13 -0
- package/dist/react/workflows/PayoutAttemptStatus.js.map +1 -0
- package/dist/react/workflows/PayoutProgress.d.ts.map +1 -1
- package/dist/react/workflows/PayoutProgress.js +2 -1
- package/dist/react/workflows/PayoutProgress.js.map +1 -1
- package/dist/react/workflows/index.d.ts +2 -0
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +1 -0
- package/dist/react/workflows/index.js.map +1 -1
- package/dist/react/workflows/payout-attempt-state.d.ts +9 -0
- package/dist/react/workflows/payout-attempt-state.d.ts.map +1 -0
- package/dist/react/workflows/payout-attempt-state.js +47 -0
- package/dist/react/workflows/payout-attempt-state.js.map +1 -0
- package/dist/react/workflows/payout-progress-state.d.ts +5 -1
- package/dist/react/workflows/payout-progress-state.d.ts.map +1 -1
- package/dist/react/workflows/payout-progress-state.js +11 -2
- package/dist/react/workflows/payout-progress-state.js.map +1 -1
- package/docs/react-workflows.md +19 -0
- package/docs/settlement.md +16 -0
- package/package.json +1 -1
- package/src/client/connect/payoutWorker.test.ts +57 -1
- package/src/client/connect/payoutWorker.ts +46 -2
- package/src/client/payoutPolicy.ts +2 -0
- package/src/component/_generated/component.ts +4 -0
- package/src/component/payoutWorker/mutations.ts +3 -0
- package/src/component/payoutWorker/schema.ts +15 -0
- package/src/react/workflows/PayoutAttemptStatus.tsx +44 -0
- package/src/react/workflows/PayoutProgress.test.tsx +48 -0
- package/src/react/workflows/PayoutProgress.tsx +6 -0
- package/src/react/workflows/index.ts +6 -0
- package/src/react/workflows/payout-attempt-state.ts +70 -0
- package/src/react/workflows/payout-progress-state.ts +21 -1
package/docs/react-workflows.md
CHANGED
|
@@ -752,3 +752,22 @@ described in the [settlement guide](settlement.md#development-reset-safeguards).
|
|
|
752
752
|
See the [read contract guide](read-contracts.md) for reactive product, price and payout
|
|
753
753
|
cursors, exact currency summaries, legacy backfill, payout failure details, and
|
|
754
754
|
published-version adoption requirements.
|
|
755
|
+
|
|
756
|
+
### Latest automatic payout attempt
|
|
757
|
+
|
|
758
|
+
`payoutAttemptStatus(job)` projects a package worker job into the public
|
|
759
|
+
`PayoutAttempt` contract. Use `payoutAttemptValidator` in host response validators
|
|
760
|
+
and forward the result as `lastAttempt` to `PayoutProgress`, or render the
|
|
761
|
+
unstyled `PayoutAttemptStatus` workflow directly. Its render prop supplies a
|
|
762
|
+
safe message, tone and localized result time for host cards.
|
|
763
|
+
|
|
764
|
+
Stripe rejections retain `balance_insufficient` or `amount_too_small` instead of
|
|
765
|
+
a generic rejection. This history is separate from an informational eligibility
|
|
766
|
+
preview; qualification does not promise provider acceptance. A running lease
|
|
767
|
+
or unresolved operation suppresses an older paid result. The workflow hides a
|
|
768
|
+
stale retry date while a check runs. Legacy jobs without a recorded result time
|
|
769
|
+
show no invented timestamp. Result/check times are not bank arrival estimates.
|
|
770
|
+
|
|
771
|
+
The example uses the same package workflow in API and BetterStripe-component
|
|
772
|
+
views, including seller and affiliate payout panels. No status control is a
|
|
773
|
+
manual withdrawal request. Unknown outcomes reconcile the original operation.
|
package/docs/settlement.md
CHANGED
|
@@ -801,3 +801,19 @@ Pending or unknown direct affiliate payouts and recovery operations also block
|
|
|
801
801
|
reset, preserving the receipts and obligations needed for reconciliation.
|
|
802
802
|
The guard preserves the restoration and payout records until that work is done.
|
|
803
803
|
Read-only cache collections can be cleared with the other development data.
|
|
804
|
+
|
|
805
|
+
### Observing automatic attempt outcomes
|
|
806
|
+
|
|
807
|
+
Worker jobs record `lastResultAt` with each completed check and retain a verified
|
|
808
|
+
Stripe rejection code in `lastReason`. `payoutAttemptStatus(job)` distinguishes
|
|
809
|
+
running, reconciling, rejected, failed, held and paid results. Active generations
|
|
810
|
+
take precedence over old successes; missing legacy result times remain unknown.
|
|
811
|
+
Use the exported `payoutAttemptValidator` for a host DTO and keep the operation's
|
|
812
|
+
immutable rejection receipt for audits. The example warns operators on rejected
|
|
813
|
+
payouts before the holding deadline, without logging raw provider errors.
|
|
814
|
+
|
|
815
|
+
A rejected request releases proven reservations; it does not forfeit unpaid
|
|
816
|
+
entitlement. Refunds/reversals still reduce qualifying funds. Provider floors
|
|
817
|
+
remain provider-enforced and numerically unknown when not exposed. Reassess fresh
|
|
818
|
+
receipts and balances on the next scheduled attempt rather than manually
|
|
819
|
+
recreating uncertain requests.
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ import { afterEach, expect, it, vi } from "vitest";
|
|
|
3
3
|
import type Stripe from "stripe";
|
|
4
4
|
import { setup, scope, component } from "./payoutExecutionTestHelpers.js";
|
|
5
5
|
import { reconcileRecipientPayout } from "./payoutReconciliation.js";
|
|
6
|
-
import { PayoutWorkerClient } from "./payoutWorker.js";
|
|
6
|
+
import { PayoutWorkerClient, payoutAttemptStatus } from "./payoutWorker.js";
|
|
7
7
|
import { changeBalance } from "../../component/affiliateRecovery/accounting.js";
|
|
8
8
|
afterEach(() => vi.useRealTimers());
|
|
9
9
|
async function worker(amounts?: number[]) {
|
|
@@ -143,3 +143,59 @@ it("reports affiliate recovery holds before a fresh attempt without creating a p
|
|
|
143
143
|
});
|
|
144
144
|
expect(s.provider.payouts.create).not.toHaveBeenCalled();
|
|
145
145
|
});
|
|
146
|
+
|
|
147
|
+
it.each(["balance_insufficient", "amount_too_small"])(
|
|
148
|
+
"persists actionable %s rejection and its result time",
|
|
149
|
+
async (code) => {
|
|
150
|
+
const s = await worker();
|
|
151
|
+
s.provider.payouts.create.mockRejectedValueOnce(
|
|
152
|
+
Object.assign(new Error(code), {
|
|
153
|
+
type: "StripeInvalidRequestError",
|
|
154
|
+
code,
|
|
155
|
+
requestId: "req_rejected",
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
expect(await s.run()).toMatchObject({
|
|
159
|
+
job: { lastReason: code, lastResultAt: expect.any(Number) },
|
|
160
|
+
payout: { claim: { status: "rejected", rejection: { code } } },
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
it("does not reuse a prior paid result or timestamp for a newer running generation", async () => {
|
|
166
|
+
const s = await worker();
|
|
167
|
+
const result = await s.run();
|
|
168
|
+
if (result.status !== "completed")
|
|
169
|
+
throw new Error("Expected completed fixture");
|
|
170
|
+
const active = {
|
|
171
|
+
...result.job,
|
|
172
|
+
generation: result.job.generation + 1,
|
|
173
|
+
completedGeneration: result.job.generation,
|
|
174
|
+
hasOperation: true,
|
|
175
|
+
operationKey: "new-operation",
|
|
176
|
+
lastReason: "paid",
|
|
177
|
+
lastResultAt: 1000,
|
|
178
|
+
lastAttemptAt: 2000,
|
|
179
|
+
leaseUntil: 3000,
|
|
180
|
+
};
|
|
181
|
+
expect(payoutAttemptStatus(active, 2100)).toEqual({
|
|
182
|
+
currency: "usd",
|
|
183
|
+
status: "running",
|
|
184
|
+
reason: "pending_claim",
|
|
185
|
+
at: 2000,
|
|
186
|
+
});
|
|
187
|
+
expect(payoutAttemptStatus(active, 4000)).toEqual({
|
|
188
|
+
currency: "usd",
|
|
189
|
+
status: "reconciling",
|
|
190
|
+
reason: "pending_claim",
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
it("keeps legacy result times unknown rather than inventing a completion time", async () => {
|
|
194
|
+
const s = await worker();
|
|
195
|
+
const result = await s.run();
|
|
196
|
+
if (result.status !== "completed")
|
|
197
|
+
throw new Error("Expected completed fixture");
|
|
198
|
+
expect(
|
|
199
|
+
payoutAttemptStatus({ ...result.job, lastResultAt: undefined }),
|
|
200
|
+
).toEqual({ currency: "usd", status: "paid", reason: "paid" });
|
|
201
|
+
});
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Infer } from "convex/values";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
jobDoc,
|
|
4
|
+
attemptStatus,
|
|
5
|
+
} from "../../component/payoutWorker/schema.js";
|
|
3
6
|
import type { policyDoc } from "../../component/payoutPolicy/schema.js";
|
|
4
7
|
import type { Component, RunCtx } from "../helpers.js";
|
|
5
8
|
import { runMutationOrThrow } from "../helpers.js";
|
|
@@ -138,7 +141,48 @@ export class PayoutWorkerClient {
|
|
|
138
141
|
throw error;
|
|
139
142
|
}
|
|
140
143
|
const reason =
|
|
141
|
-
payout.status === "held"
|
|
144
|
+
payout.status === "held"
|
|
145
|
+
? payout.reason
|
|
146
|
+
: (payout.claim.rejection?.code ?? payout.claim.status);
|
|
142
147
|
return { status: "completed", job: await finish(reason), payout };
|
|
143
148
|
}
|
|
144
149
|
}
|
|
150
|
+
|
|
151
|
+
export type PayoutAttempt = Infer<typeof attemptStatus>;
|
|
152
|
+
/** Current lease and generation take precedence over a prior result. Legacy timestamps remain unknown. */
|
|
153
|
+
export function payoutAttemptStatus(
|
|
154
|
+
job: PayoutWorkerJob | null,
|
|
155
|
+
now = Date.now(),
|
|
156
|
+
): PayoutAttempt | null {
|
|
157
|
+
if (!job || (!job.hasOperation && !job.lastReason)) return null;
|
|
158
|
+
const completed = job.completedGeneration === job.generation;
|
|
159
|
+
const running = job.hasOperation && (job.leaseUntil ?? 0) > now;
|
|
160
|
+
const reason =
|
|
161
|
+
job.hasOperation && !completed
|
|
162
|
+
? "pending_claim"
|
|
163
|
+
: (job.lastReason ?? "pending_claim");
|
|
164
|
+
const status = running
|
|
165
|
+
? "running"
|
|
166
|
+
: job.hasOperation
|
|
167
|
+
? "reconciling"
|
|
168
|
+
: ["balance_insufficient", "amount_too_small", "rejected"].includes(
|
|
169
|
+
reason,
|
|
170
|
+
)
|
|
171
|
+
? "rejected"
|
|
172
|
+
: reason === "paid"
|
|
173
|
+
? "paid"
|
|
174
|
+
: ["worker_error", "returned"].includes(reason)
|
|
175
|
+
? "failed"
|
|
176
|
+
: "held";
|
|
177
|
+
const at = running
|
|
178
|
+
? job.lastAttemptAt
|
|
179
|
+
: job.hasOperation && !completed
|
|
180
|
+
? undefined
|
|
181
|
+
: job.lastResultAt;
|
|
182
|
+
return {
|
|
183
|
+
currency: job.currency,
|
|
184
|
+
status,
|
|
185
|
+
reason,
|
|
186
|
+
...(at === undefined ? {} : { at }),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export { payoutAttemptStatus } from "./connect/payoutWorker.js";
|
|
2
|
+
export { attemptStatus as payoutAttemptValidator } from "../component/payoutWorker/schema.js";
|
|
1
3
|
export { holdingExpiry as payoutHoldingExpiryValidator } from "../component/payoutPolicy/schema.js";
|
|
2
4
|
import { expirePayoutPolicy } from "./connect/payoutExpiry.js";
|
|
3
5
|
import {
|
|
@@ -4112,6 +4112,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
4112
4112
|
hasOperation: boolean;
|
|
4113
4113
|
lastAttemptAt?: number;
|
|
4114
4114
|
lastReason?: string;
|
|
4115
|
+
lastResultAt?: number;
|
|
4115
4116
|
leaseUntil?: number;
|
|
4116
4117
|
nextAttemptAt: number;
|
|
4117
4118
|
operationKey?: string;
|
|
@@ -4146,6 +4147,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
4146
4147
|
hasOperation: boolean;
|
|
4147
4148
|
lastAttemptAt?: number;
|
|
4148
4149
|
lastReason?: string;
|
|
4150
|
+
lastResultAt?: number;
|
|
4149
4151
|
leaseUntil?: number;
|
|
4150
4152
|
nextAttemptAt: number;
|
|
4151
4153
|
operationKey?: string;
|
|
@@ -4223,6 +4225,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
4223
4225
|
hasOperation: boolean;
|
|
4224
4226
|
lastAttemptAt?: number;
|
|
4225
4227
|
lastReason?: string;
|
|
4228
|
+
lastResultAt?: number;
|
|
4226
4229
|
leaseUntil?: number;
|
|
4227
4230
|
nextAttemptAt: number;
|
|
4228
4231
|
operationKey?: string;
|
|
@@ -4310,6 +4313,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
4310
4313
|
hasOperation: boolean;
|
|
4311
4314
|
lastAttemptAt?: number;
|
|
4312
4315
|
lastReason?: string;
|
|
4316
|
+
lastResultAt?: number;
|
|
4313
4317
|
leaseUntil?: number;
|
|
4314
4318
|
nextAttemptAt: number;
|
|
4315
4319
|
operationKey?: string;
|
|
@@ -77,6 +77,7 @@ export const begin = mutation({
|
|
|
77
77
|
hasOperation: false,
|
|
78
78
|
leaseUntil: undefined,
|
|
79
79
|
lastReason: "policy_inactive",
|
|
80
|
+
lastResultAt: now,
|
|
80
81
|
nextAttemptAt: nextPayoutAttempt(policy, now, args.cadenceMs),
|
|
81
82
|
});
|
|
82
83
|
return { status: "held" as const, reason: "policy_inactive" };
|
|
@@ -113,6 +114,7 @@ export const begin = mutation({
|
|
|
113
114
|
hasOperation: false,
|
|
114
115
|
leaseUntil: undefined,
|
|
115
116
|
lastReason: "affiliate_recovery_pending",
|
|
117
|
+
lastResultAt: now,
|
|
116
118
|
nextAttemptAt,
|
|
117
119
|
});
|
|
118
120
|
return {
|
|
@@ -189,6 +191,7 @@ export const finish = mutation({
|
|
|
189
191
|
job.cadenceMs,
|
|
190
192
|
),
|
|
191
193
|
lastReason: args.reason,
|
|
194
|
+
lastResultAt: Date.now(),
|
|
192
195
|
completedGeneration: args.generation,
|
|
193
196
|
completedOperationKey: args.operationKey,
|
|
194
197
|
});
|
|
@@ -13,6 +13,7 @@ export const fields = {
|
|
|
13
13
|
hasOperation: v.boolean(),
|
|
14
14
|
lastReason: v.optional(v.string()),
|
|
15
15
|
lastAttemptAt: v.optional(v.number()),
|
|
16
|
+
lastResultAt: v.optional(v.number()),
|
|
16
17
|
completedGeneration: v.optional(v.number()),
|
|
17
18
|
completedOperationKey: v.optional(v.string()),
|
|
18
19
|
};
|
|
@@ -24,3 +25,17 @@ export const jobDoc = v.object({
|
|
|
24
25
|
export const jobsTable = defineTable(fields)
|
|
25
26
|
.index("by_account_currency", ["accountId", "currency"])
|
|
26
27
|
.index("by_operation_pending", ["hasOperation", "nextAttemptAt"]);
|
|
28
|
+
|
|
29
|
+
export const attemptStatus = v.object({
|
|
30
|
+
currency: v.string(),
|
|
31
|
+
status: v.union(
|
|
32
|
+
v.literal("running"),
|
|
33
|
+
v.literal("reconciling"),
|
|
34
|
+
v.literal("rejected"),
|
|
35
|
+
v.literal("failed"),
|
|
36
|
+
v.literal("paid"),
|
|
37
|
+
v.literal("held"),
|
|
38
|
+
),
|
|
39
|
+
reason: v.string(),
|
|
40
|
+
at: v.optional(v.number()),
|
|
41
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { PayoutAttempt } from "../../client/connect/payoutWorker.js";
|
|
4
|
+
import {
|
|
5
|
+
payoutAttemptState,
|
|
6
|
+
type PayoutAttemptState,
|
|
7
|
+
} from "./payout-attempt-state.js";
|
|
8
|
+
export type { PayoutAttemptState } from "./payout-attempt-state.js";
|
|
9
|
+
export type PayoutAttemptStatusProps = {
|
|
10
|
+
attempt?: PayoutAttempt | null;
|
|
11
|
+
locale?: string;
|
|
12
|
+
timeZone?: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
children?: (state: PayoutAttemptState) => ReactNode;
|
|
15
|
+
};
|
|
16
|
+
/** Unstyled worker status. Result times are not bank arrival estimates. */
|
|
17
|
+
export function PayoutAttemptStatus({
|
|
18
|
+
attempt,
|
|
19
|
+
locale,
|
|
20
|
+
timeZone,
|
|
21
|
+
className,
|
|
22
|
+
children,
|
|
23
|
+
}: PayoutAttemptStatusProps) {
|
|
24
|
+
const state = payoutAttemptState(attempt, locale, timeZone);
|
|
25
|
+
if (!state) return null;
|
|
26
|
+
if (children) return <>{children(state)}</>;
|
|
27
|
+
return (
|
|
28
|
+
<aside
|
|
29
|
+
className={className}
|
|
30
|
+
data-better-stripe="payout-attempt"
|
|
31
|
+
data-tone={state.tone}
|
|
32
|
+
>
|
|
33
|
+
<h3>Latest automatic payout check</h3>
|
|
34
|
+
<p role="status" aria-label="Latest automatic payout attempt">
|
|
35
|
+
{state.message}
|
|
36
|
+
</p>
|
|
37
|
+
{state.formattedAt && (
|
|
38
|
+
<p>
|
|
39
|
+
{state.timeLabel}: {state.formattedAt}
|
|
40
|
+
</p>
|
|
41
|
+
)}
|
|
42
|
+
</aside>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -306,3 +306,51 @@ it("keeps unavailable expiry balances unverified instead of showing fabricated z
|
|
|
306
306
|
);
|
|
307
307
|
expect(screen.queryByText("$0.00")).toBeNull();
|
|
308
308
|
});
|
|
309
|
+
|
|
310
|
+
it("shows a previous Stripe rejection separately from informational eligibility", () => {
|
|
311
|
+
render(
|
|
312
|
+
<PayoutProgress
|
|
313
|
+
policy={policy}
|
|
314
|
+
preview={{
|
|
315
|
+
...preview,
|
|
316
|
+
status: "qualified",
|
|
317
|
+
reason: undefined,
|
|
318
|
+
amount: 999,
|
|
319
|
+
minimumWaived: true,
|
|
320
|
+
holdDeadline: policy.holdDeadline,
|
|
321
|
+
}}
|
|
322
|
+
currency="usd"
|
|
323
|
+
lastAttempt={{
|
|
324
|
+
currency: "usd",
|
|
325
|
+
status: "rejected",
|
|
326
|
+
reason: "balance_insufficient",
|
|
327
|
+
at: 1800000000000,
|
|
328
|
+
}}
|
|
329
|
+
nextAttemptAt={1800000300000}
|
|
330
|
+
timeZone="UTC"
|
|
331
|
+
/>,
|
|
332
|
+
);
|
|
333
|
+
expect(
|
|
334
|
+
screen.getByText(
|
|
335
|
+
/Stripe rejected the payout because the available balance changed/,
|
|
336
|
+
),
|
|
337
|
+
).toBeTruthy();
|
|
338
|
+
expect(screen.getByText(/Next automatic attempt/)).toBeTruthy();
|
|
339
|
+
});
|
|
340
|
+
it("does not display an old paid result or stale retry time while a new check is running", () => {
|
|
341
|
+
render(
|
|
342
|
+
<PayoutProgress
|
|
343
|
+
policy={policy}
|
|
344
|
+
preview={preview}
|
|
345
|
+
currency="usd"
|
|
346
|
+
lastAttempt={{
|
|
347
|
+
currency: "usd",
|
|
348
|
+
status: "running",
|
|
349
|
+
reason: "pending_claim",
|
|
350
|
+
}}
|
|
351
|
+
nextAttemptAt={1800000000000}
|
|
352
|
+
/>,
|
|
353
|
+
);
|
|
354
|
+
expect(screen.getByText(/automatic payout check is running/)).toBeTruthy();
|
|
355
|
+
expect(screen.queryByText(/Next automatic attempt/)).toBeNull();
|
|
356
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
+
import { PayoutAttemptStatus } from "./PayoutAttemptStatus.js";
|
|
2
3
|
import { useMemo } from "react";
|
|
3
4
|
import {
|
|
4
5
|
payoutProgressState,
|
|
@@ -28,6 +29,11 @@ export function PayoutProgress(props: PayoutProgressProps) {
|
|
|
28
29
|
<section className={className} data-better-stripe="payout-progress">
|
|
29
30
|
<h2>Automatic payout progress ({currency.toUpperCase()})</h2>
|
|
30
31
|
<p role="status">{state.message}</p>
|
|
32
|
+
<PayoutAttemptStatus
|
|
33
|
+
attempt={state.lastAttempt}
|
|
34
|
+
locale={locale}
|
|
35
|
+
timeZone={timeZone}
|
|
36
|
+
/>
|
|
31
37
|
<p>Stripe enforces its payout floor; a numeric amount is unavailable.</p>
|
|
32
38
|
{state.recovery?.balances && (
|
|
33
39
|
<div>
|
|
@@ -290,3 +290,9 @@ export type {
|
|
|
290
290
|
|
|
291
291
|
export { EvidenceUpload } from "./EvidenceUpload.js";
|
|
292
292
|
export type { EvidenceUploadProps } from "./EvidenceUpload.js";
|
|
293
|
+
|
|
294
|
+
export { PayoutAttemptStatus } from "./PayoutAttemptStatus.js";
|
|
295
|
+
export type {
|
|
296
|
+
PayoutAttemptStatusProps,
|
|
297
|
+
PayoutAttemptState,
|
|
298
|
+
} from "./PayoutAttemptStatus.js";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { PayoutAttempt } from "../../client/connect/payoutWorker.js";
|
|
2
|
+
const messages: Record<string, string> = {
|
|
3
|
+
balance_insufficient:
|
|
4
|
+
"Stripe rejected the payout because the available balance changed. Remaining unpaid entitlement is preserved; any retry requires fresh balance verification.",
|
|
5
|
+
amount_too_small:
|
|
6
|
+
"Stripe rejected the payout below its supported minimum. A numeric provider floor is unavailable. Review payout settings promptly; holding limits still apply.",
|
|
7
|
+
worker_error:
|
|
8
|
+
"The automatic payout check could not complete. An uncertain outcome must be reconciled before another payout can be created.",
|
|
9
|
+
returned:
|
|
10
|
+
"The bank returned the payout. Returned funds will be assessed again when available.",
|
|
11
|
+
bank_destination_unavailable:
|
|
12
|
+
"Add or verify a supported payout bank account before the worker can retry.",
|
|
13
|
+
affiliate_recovery_pending:
|
|
14
|
+
"Commission recovery must settle before a new automatic payout can qualify.",
|
|
15
|
+
below_threshold:
|
|
16
|
+
"No payout was requested because the combined balance is below the application minimum.",
|
|
17
|
+
insufficient_available:
|
|
18
|
+
"No payout was requested because the supported available Stripe balance is insufficient.",
|
|
19
|
+
schedule_drift:
|
|
20
|
+
"Payout scheduling changed outside the application. Contact platform support to verify controls.",
|
|
21
|
+
snapshot_limit:
|
|
22
|
+
"History reconciliation is incomplete. No payout was requested from a partial balance.",
|
|
23
|
+
policy_inactive:
|
|
24
|
+
"The application-managed payout policy is inactive. No new application payout was requested.",
|
|
25
|
+
};
|
|
26
|
+
export type PayoutAttemptState = PayoutAttempt & {
|
|
27
|
+
message: string;
|
|
28
|
+
tone: "warning" | "neutral" | "success";
|
|
29
|
+
formattedAt?: string;
|
|
30
|
+
timeLabel?: string;
|
|
31
|
+
};
|
|
32
|
+
export function payoutAttemptState(
|
|
33
|
+
attempt: PayoutAttempt | null | undefined,
|
|
34
|
+
locale = "en-US",
|
|
35
|
+
timeZone?: string,
|
|
36
|
+
): PayoutAttemptState | undefined {
|
|
37
|
+
if (!attempt) return undefined;
|
|
38
|
+
const message =
|
|
39
|
+
attempt.status === "running"
|
|
40
|
+
? "An automatic payout check is running. Its outcome is not verified yet."
|
|
41
|
+
: attempt.status === "reconciling"
|
|
42
|
+
? "The payout outcome needs verification. The same operation will be reconciled before any new payout."
|
|
43
|
+
: attempt.status === "paid"
|
|
44
|
+
? "The latest application payout was verified as paid by Stripe."
|
|
45
|
+
: (messages[attempt.reason] ??
|
|
46
|
+
"The latest automatic payout check did not complete. Review account settings or contact platform support.");
|
|
47
|
+
return {
|
|
48
|
+
...attempt,
|
|
49
|
+
message,
|
|
50
|
+
tone:
|
|
51
|
+
attempt.status === "paid"
|
|
52
|
+
? "success"
|
|
53
|
+
: ["rejected", "failed", "reconciling"].includes(attempt.status)
|
|
54
|
+
? "warning"
|
|
55
|
+
: "neutral",
|
|
56
|
+
...(attempt.at === undefined
|
|
57
|
+
? {}
|
|
58
|
+
: {
|
|
59
|
+
formattedAt: new Intl.DateTimeFormat(locale, {
|
|
60
|
+
dateStyle: "medium",
|
|
61
|
+
timeStyle: "short",
|
|
62
|
+
timeZone,
|
|
63
|
+
}).format(attempt.at),
|
|
64
|
+
timeLabel:
|
|
65
|
+
attempt.status === "running"
|
|
66
|
+
? "Attempt started"
|
|
67
|
+
: "Result recorded",
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
payoutAttemptState,
|
|
3
|
+
type PayoutAttemptState,
|
|
4
|
+
} from "./payout-attempt-state.js";
|
|
5
|
+
import type { PayoutAttempt } from "../../client/connect/payoutWorker.js";
|
|
1
6
|
import type { ReactNode } from "react";
|
|
2
7
|
import type { PayoutPolicy, PayoutPreview } from "../../client/payoutPolicy.js";
|
|
3
8
|
import { formatPrice } from "../lib/price-helpers.js";
|
|
@@ -42,6 +47,7 @@ export type PayoutProgressState = {
|
|
|
42
47
|
nextAttemptAt: number | null;
|
|
43
48
|
holdDeadline: number | null;
|
|
44
49
|
observedAt: number | null;
|
|
50
|
+
lastAttempt?: PayoutAttemptState;
|
|
45
51
|
recovery?: {
|
|
46
52
|
status: NonNullable<PayoutPolicy["holdingExpiry"]>["status"];
|
|
47
53
|
balances?: {
|
|
@@ -62,6 +68,7 @@ export type PayoutProgressProps = {
|
|
|
62
68
|
| null
|
|
63
69
|
| undefined;
|
|
64
70
|
preview?: PayoutPreview | null;
|
|
71
|
+
lastAttempt?: PayoutAttempt | null;
|
|
65
72
|
currency: string;
|
|
66
73
|
/** Application worker attempt time, never a bank arrival estimate. */
|
|
67
74
|
nextAttemptAt?: number | null;
|
|
@@ -96,8 +103,14 @@ export function payoutProgressState({
|
|
|
96
103
|
preview,
|
|
97
104
|
currency,
|
|
98
105
|
nextAttemptAt,
|
|
106
|
+
lastAttempt,
|
|
107
|
+
timeZone,
|
|
99
108
|
locale = "en-US",
|
|
100
109
|
}: PayoutProgressProps): PayoutProgressState {
|
|
110
|
+
if (lastAttempt && lastAttempt.currency !== currency)
|
|
111
|
+
throw new Error(
|
|
112
|
+
"Payout attempt currency does not match the selected currency.",
|
|
113
|
+
);
|
|
101
114
|
const amount = (value: number) => formatPrice(value, currency, locale);
|
|
102
115
|
const state: PayoutProgressState = {
|
|
103
116
|
status: "loading",
|
|
@@ -111,6 +124,10 @@ export function payoutProgressState({
|
|
|
111
124
|
holdDeadline:
|
|
112
125
|
policy?.mode === "custom" ? (policy.holdDeadline ?? null) : null,
|
|
113
126
|
observedAt: null,
|
|
127
|
+
lastAttempt:
|
|
128
|
+
policy === undefined
|
|
129
|
+
? undefined
|
|
130
|
+
: payoutAttemptState(lastAttempt, locale, timeZone),
|
|
114
131
|
};
|
|
115
132
|
if (policy !== undefined) {
|
|
116
133
|
if (policy?.status === "changing") {
|
|
@@ -149,7 +166,10 @@ export function payoutProgressState({
|
|
|
149
166
|
state.message =
|
|
150
167
|
"Stripe manages automatic payouts. No additional application minimum is set.";
|
|
151
168
|
} else if (preview !== undefined) {
|
|
152
|
-
state.nextAttemptAt =
|
|
169
|
+
state.nextAttemptAt =
|
|
170
|
+
state.lastAttempt?.status === "running"
|
|
171
|
+
? null
|
|
172
|
+
: (nextAttemptAt ?? null);
|
|
153
173
|
if (!preview || !("currency" in preview)) {
|
|
154
174
|
state.status = "unavailable";
|
|
155
175
|
state.reason = preview?.reason ?? "assessment_unavailable";
|