@fantastic.dev/better-stripe 0.28.4 → 0.28.6

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 (93) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +5 -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/payoutPreview.d.ts.map +1 -1
  8. package/dist/client/connect/payoutPreview.js +1 -1
  9. package/dist/client/connect/payoutPreview.js.map +1 -1
  10. package/dist/client/connect/payoutQualification.d.ts.map +1 -1
  11. package/dist/client/connect/payoutQualification.js +5 -1
  12. package/dist/client/connect/payoutQualification.js.map +1 -1
  13. package/dist/client/connect/payoutSyncTestHelpers.d.ts +3 -1
  14. package/dist/client/connect/payoutSyncTestHelpers.d.ts.map +1 -1
  15. package/dist/client/connect/payoutWorker.d.ts +4 -1
  16. package/dist/client/connect/payoutWorker.d.ts.map +1 -1
  17. package/dist/client/connect/payoutWorker.js +35 -1
  18. package/dist/client/connect/payoutWorker.js.map +1 -1
  19. package/dist/client/payoutPolicy.d.ts +2 -0
  20. package/dist/client/payoutPolicy.d.ts.map +1 -1
  21. package/dist/client/payoutPolicy.js +2 -0
  22. package/dist/client/payoutPolicy.js.map +1 -1
  23. package/dist/component/_generated/component.d.ts +4 -0
  24. package/dist/component/_generated/component.d.ts.map +1 -1
  25. package/dist/component/earnings/triggers.d.ts +4 -2
  26. package/dist/component/earnings/triggers.d.ts.map +1 -1
  27. package/dist/component/payoutRelease/helpers.d.ts +1 -1
  28. package/dist/component/payoutRelease/helpers.d.ts.map +1 -1
  29. package/dist/component/payoutRelease/helpers.js +2 -1
  30. package/dist/component/payoutRelease/helpers.js.map +1 -1
  31. package/dist/component/payoutRelease/mutations.d.ts.map +1 -1
  32. package/dist/component/payoutRelease/mutations.js +1 -1
  33. package/dist/component/payoutRelease/mutations.js.map +1 -1
  34. package/dist/component/payoutRelease/planning.d.ts.map +1 -1
  35. package/dist/component/payoutRelease/planning.js +1 -1
  36. package/dist/component/payoutRelease/planning.js.map +1 -1
  37. package/dist/component/payoutRelease/preview.d.ts.map +1 -1
  38. package/dist/component/payoutRelease/preview.js +3 -1
  39. package/dist/component/payoutRelease/preview.js.map +1 -1
  40. package/dist/component/payoutWorker/helpers.d.ts +1 -0
  41. package/dist/component/payoutWorker/helpers.d.ts.map +1 -1
  42. package/dist/component/payoutWorker/mutations.d.ts +2 -0
  43. package/dist/component/payoutWorker/mutations.d.ts.map +1 -1
  44. package/dist/component/payoutWorker/mutations.js +3 -0
  45. package/dist/component/payoutWorker/mutations.js.map +1 -1
  46. package/dist/component/payoutWorker/queries.d.ts +2 -0
  47. package/dist/component/payoutWorker/queries.d.ts.map +1 -1
  48. package/dist/component/payoutWorker/schema.d.ts +18 -2
  49. package/dist/component/payoutWorker/schema.d.ts.map +1 -1
  50. package/dist/component/payoutWorker/schema.js +7 -0
  51. package/dist/component/payoutWorker/schema.js.map +1 -1
  52. package/dist/component/schema.d.ts +3 -1
  53. package/dist/react/workflows/PayoutAttemptStatus.d.ts +14 -0
  54. package/dist/react/workflows/PayoutAttemptStatus.d.ts.map +1 -0
  55. package/dist/react/workflows/PayoutAttemptStatus.js +13 -0
  56. package/dist/react/workflows/PayoutAttemptStatus.js.map +1 -0
  57. package/dist/react/workflows/PayoutProgress.d.ts.map +1 -1
  58. package/dist/react/workflows/PayoutProgress.js +2 -1
  59. package/dist/react/workflows/PayoutProgress.js.map +1 -1
  60. package/dist/react/workflows/index.d.ts +2 -0
  61. package/dist/react/workflows/index.d.ts.map +1 -1
  62. package/dist/react/workflows/index.js +1 -0
  63. package/dist/react/workflows/index.js.map +1 -1
  64. package/dist/react/workflows/payout-attempt-state.d.ts +9 -0
  65. package/dist/react/workflows/payout-attempt-state.d.ts.map +1 -0
  66. package/dist/react/workflows/payout-attempt-state.js +47 -0
  67. package/dist/react/workflows/payout-attempt-state.js.map +1 -0
  68. package/dist/react/workflows/payout-progress-state.d.ts +5 -1
  69. package/dist/react/workflows/payout-progress-state.d.ts.map +1 -1
  70. package/dist/react/workflows/payout-progress-state.js +11 -2
  71. package/dist/react/workflows/payout-progress-state.js.map +1 -1
  72. package/docs/react-workflows.md +19 -0
  73. package/docs/settlement.md +25 -1
  74. package/package.json +1 -1
  75. package/src/client/connect/payoutPreview.ts +3 -1
  76. package/src/client/connect/payoutQualification.ts +7 -1
  77. package/src/client/connect/payoutQualificationFreshness.test.ts +192 -0
  78. package/src/client/connect/payoutWorker.test.ts +57 -1
  79. package/src/client/connect/payoutWorker.ts +46 -2
  80. package/src/client/payoutPolicy.ts +2 -0
  81. package/src/component/_generated/component.ts +4 -0
  82. package/src/component/payoutRelease/helpers.ts +2 -0
  83. package/src/component/payoutRelease/mutations.ts +7 -1
  84. package/src/component/payoutRelease/planning.ts +1 -0
  85. package/src/component/payoutRelease/preview.ts +3 -1
  86. package/src/component/payoutWorker/mutations.ts +3 -0
  87. package/src/component/payoutWorker/schema.ts +15 -0
  88. package/src/react/workflows/PayoutAttemptStatus.tsx +44 -0
  89. package/src/react/workflows/PayoutProgress.test.tsx +48 -0
  90. package/src/react/workflows/PayoutProgress.tsx +6 -0
  91. package/src/react/workflows/index.ts +6 -0
  92. package/src/react/workflows/payout-attempt-state.ts +70 -0
  93. package/src/react/workflows/payout-progress-state.ts +21 -1
@@ -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;
@@ -71,6 +71,7 @@ export async function checkSnapshot(
71
71
  accountId: string,
72
72
  observations: Infer<typeof observation>[],
73
73
  fresh: boolean,
74
+ freshCurrency?: string,
74
75
  ) {
75
76
  const rows = await ctx.db
76
77
  .query("transfers")
@@ -106,6 +107,7 @@ export async function checkSnapshot(
106
107
  throw new Error("Payout source snapshot changed or is not ready.");
107
108
  if (
108
109
  fresh &&
110
+ (freshCurrency === undefined || o.currency === freshCurrency) &&
109
111
  (!Number.isSafeInteger(o.observedAt) ||
110
112
  o.observedAt > Date.now() ||
111
113
  o.observedAt < Date.now() - 30000)
@@ -124,7 +124,13 @@ export const beginAttempt = mutation({
124
124
  const feeRevision = (await readFees(ctx, claim))?.revision ?? 0;
125
125
  if (feeRevision !== (claim.feeRevision ?? 0))
126
126
  throw new Error("Payout fee revision changed after reservation.");
127
- await checkSnapshot(ctx, claim.accountId, claim.observations, true);
127
+ await checkSnapshot(
128
+ ctx,
129
+ claim.accountId,
130
+ claim.observations,
131
+ true,
132
+ claim.currency,
133
+ );
128
134
  await reserveRecoveryGuard(ctx, claim, claim.amount);
129
135
  await ctx.db.patch("payoutReleaseClaims", claim._id, {
130
136
  attemptedAt: Date.now(),
@@ -26,6 +26,7 @@ export async function readPayoutPlan(
26
26
  args.accountId,
27
27
  args.observations,
28
28
  true,
29
+ args.currency,
29
30
  );
30
31
  const sources: PayoutFundingSource[] = [];
31
32
  for (const observation of args.observations) {
@@ -106,7 +106,9 @@ export async function previewPlan(
106
106
  providerAvailable: args.providerAvailable,
107
107
  observedAt: Math.min(
108
108
  Date.now(),
109
- ...args.observations.map((o) => o.observedAt),
109
+ ...args.observations
110
+ .filter((o) => o.currency === args.currency)
111
+ .map((o) => o.observedAt),
110
112
  ),
111
113
  ...progress,
112
114
  };
@@ -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";