@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +3 -1
  3. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts +6 -2
  4. package/dist/client/connect/payoutAdoptionTestHelpers.d.ts.map +1 -1
  5. package/dist/client/connect/payoutExecutionTestHelpers.d.ts +3 -1
  6. package/dist/client/connect/payoutExecutionTestHelpers.d.ts.map +1 -1
  7. package/dist/client/connect/payoutSyncTestHelpers.d.ts +3 -1
  8. package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -1
  9. package/dist/client/connect/payoutWorker.d.ts +4 -1
  10. package/dist/client/connect/payoutWorker.d.ts.map +1 -1
  11. package/dist/client/connect/payoutWorker.js +35 -1
  12. package/dist/client/connect/payoutWorker.js.map +1 -1
  13. package/dist/client/payoutPolicy.d.ts +2 -0
  14. package/dist/client/payoutPolicy.d.ts.map +1 -1
  15. package/dist/client/payoutPolicy.js +2 -0
  16. package/dist/client/payoutPolicy.js.map +1 -1
  17. package/dist/component/_generated/component.d.ts +4 -0
  18. package/dist/component/_generated/component.d.ts.map +1 -1
  19. package/dist/component/earnings/triggers.d.ts +4 -2
  20. package/dist/component/earnings/triggers.d.ts.map +1 -1
  21. package/dist/component/payoutWorker/helpers.d.ts +1 -0
  22. package/dist/component/payoutWorker/helpers.d.ts.map +1 -1
  23. package/dist/component/payoutWorker/mutations.d.ts +2 -0
  24. package/dist/component/payoutWorker/mutations.d.ts.map +1 -1
  25. package/dist/component/payoutWorker/mutations.js +3 -0
  26. package/dist/component/payoutWorker/mutations.js.map +1 -1
  27. package/dist/component/payoutWorker/queries.d.ts +2 -0
  28. package/dist/component/payoutWorker/queries.d.ts.map +1 -1
  29. package/dist/component/payoutWorker/schema.d.ts +18 -2
  30. package/dist/component/payoutWorker/schema.d.ts.map +1 -1
  31. package/dist/component/payoutWorker/schema.js +7 -0
  32. package/dist/component/payoutWorker/schema.js.map +1 -1
  33. package/dist/component/schema.d.ts +3 -1
  34. package/dist/react/workflows/PayoutAttemptStatus.d.ts +14 -0
  35. package/dist/react/workflows/PayoutAttemptStatus.d.ts.map +1 -0
  36. package/dist/react/workflows/PayoutAttemptStatus.js +13 -0
  37. package/dist/react/workflows/PayoutAttemptStatus.js.map +1 -0
  38. package/dist/react/workflows/PayoutProgress.d.ts.map +1 -1
  39. package/dist/react/workflows/PayoutProgress.js +2 -1
  40. package/dist/react/workflows/PayoutProgress.js.map +1 -1
  41. package/dist/react/workflows/index.d.ts +2 -0
  42. package/dist/react/workflows/index.d.ts.map +1 -1
  43. package/dist/react/workflows/index.js +1 -0
  44. package/dist/react/workflows/index.js.map +1 -1
  45. package/dist/react/workflows/payout-attempt-state.d.ts +9 -0
  46. package/dist/react/workflows/payout-attempt-state.d.ts.map +1 -0
  47. package/dist/react/workflows/payout-attempt-state.js +47 -0
  48. package/dist/react/workflows/payout-attempt-state.js.map +1 -0
  49. package/dist/react/workflows/payout-progress-state.d.ts +5 -1
  50. package/dist/react/workflows/payout-progress-state.d.ts.map +1 -1
  51. package/dist/react/workflows/payout-progress-state.js +11 -2
  52. package/dist/react/workflows/payout-progress-state.js.map +1 -1
  53. package/docs/react-workflows.md +19 -0
  54. package/docs/settlement.md +16 -0
  55. package/package.json +1 -1
  56. package/src/client/connect/payoutWorker.test.ts +57 -1
  57. package/src/client/connect/payoutWorker.ts +46 -2
  58. package/src/client/payoutPolicy.ts +2 -0
  59. package/src/component/_generated/component.ts +4 -0
  60. package/src/component/payoutWorker/mutations.ts +3 -0
  61. package/src/component/payoutWorker/schema.ts +15 -0
  62. package/src/react/workflows/PayoutAttemptStatus.tsx +44 -0
  63. package/src/react/workflows/PayoutProgress.test.tsx +48 -0
  64. package/src/react/workflows/PayoutProgress.tsx +6 -0
  65. package/src/react/workflows/index.ts +6 -0
  66. package/src/react/workflows/payout-attempt-state.ts +70 -0
  67. package/src/react/workflows/payout-progress-state.ts +21 -1
@@ -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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fantastic.dev/better-stripe",
3
- "version": "0.28.4",
3
+ "version": "0.28.5",
4
4
  "description": "A Convex component for Stripe V2 Accounts API integration.",
5
5
  "keywords": [
6
6
  "stripe",
@@ -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 { jobDoc } from "../../component/payoutWorker/schema.js";
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" ? payout.reason : payout.claim.status;
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 = nextAttemptAt ?? null;
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";