@fantastic.dev/better-stripe 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +19 -3
  3. package/dist/client/connect/disputes.d.ts +1 -1
  4. package/dist/client/connect/disputes.d.ts.map +1 -1
  5. package/dist/client/connect/disputes.js +12 -6
  6. package/dist/client/connect/disputes.js.map +1 -1
  7. package/dist/component/_generated/component.d.ts +35 -0
  8. package/dist/component/_generated/component.d.ts.map +1 -1
  9. package/dist/component/connect/queries.d.ts +30 -1
  10. package/dist/component/connect/queries.d.ts.map +1 -1
  11. package/dist/component/connect/queries.js +19 -2
  12. package/dist/component/connect/queries.js.map +1 -1
  13. package/dist/react/components/DisputeDetail.d.ts +4 -1
  14. package/dist/react/components/DisputeDetail.d.ts.map +1 -1
  15. package/dist/react/components/DisputeDetail.js +12 -6
  16. package/dist/react/components/DisputeDetail.js.map +1 -1
  17. package/dist/react/components/DisputesList.d.ts.map +1 -1
  18. package/dist/react/components/DisputesList.js +2 -1
  19. package/dist/react/components/DisputesList.js.map +1 -1
  20. package/dist/react/index.d.ts +1 -1
  21. package/dist/react/index.d.ts.map +1 -1
  22. package/dist/react/index.js +1 -1
  23. package/dist/react/index.js.map +1 -1
  24. package/dist/react/workflows/AffiliatePayoutEditor.d.ts +41 -0
  25. package/dist/react/workflows/AffiliatePayoutEditor.d.ts.map +1 -0
  26. package/dist/react/workflows/AffiliatePayoutEditor.js +80 -0
  27. package/dist/react/workflows/AffiliatePayoutEditor.js.map +1 -0
  28. package/dist/react/workflows/AffiliateRateEditor.d.ts +26 -0
  29. package/dist/react/workflows/AffiliateRateEditor.d.ts.map +1 -0
  30. package/dist/react/workflows/AffiliateRateEditor.js +49 -0
  31. package/dist/react/workflows/AffiliateRateEditor.js.map +1 -0
  32. package/dist/react/workflows/CheckoutForms.d.ts +2 -0
  33. package/dist/react/workflows/CheckoutForms.d.ts.map +1 -1
  34. package/dist/react/workflows/CheckoutForms.js +22 -16
  35. package/dist/react/workflows/CheckoutForms.js.map +1 -1
  36. package/dist/react/workflows/Disputes.d.ts.map +1 -1
  37. package/dist/react/workflows/Disputes.js +9 -9
  38. package/dist/react/workflows/Disputes.js.map +1 -1
  39. package/dist/react/workflows/IntegrationSwitch.js +2 -2
  40. package/dist/react/workflows/IntegrationSwitch.js.map +1 -1
  41. package/dist/react/workflows/Pagination.d.ts +32 -0
  42. package/dist/react/workflows/Pagination.d.ts.map +1 -0
  43. package/dist/react/workflows/Pagination.js +49 -0
  44. package/dist/react/workflows/Pagination.js.map +1 -0
  45. package/dist/react/workflows/Payouts.d.ts +1 -0
  46. package/dist/react/workflows/Payouts.d.ts.map +1 -1
  47. package/dist/react/workflows/Payouts.js +3 -2
  48. package/dist/react/workflows/Payouts.js.map +1 -1
  49. package/dist/react/workflows/PlanPricingEditor.d.ts +35 -0
  50. package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -0
  51. package/dist/react/workflows/PlanPricingEditor.js +64 -0
  52. package/dist/react/workflows/PlanPricingEditor.js.map +1 -0
  53. package/dist/react/workflows/index.d.ts +8 -0
  54. package/dist/react/workflows/index.d.ts.map +1 -1
  55. package/dist/react/workflows/index.js +4 -0
  56. package/dist/react/workflows/index.js.map +1 -1
  57. package/docs/react-workflows-audit.md +41 -0
  58. package/docs/react-workflows.md +92 -1
  59. package/package.json +5 -4
  60. package/src/client/connect/disputes.test.ts +15 -0
  61. package/src/client/connect/disputes.ts +13 -7
  62. package/src/component/_generated/component.ts +41 -0
  63. package/src/component/connect/queries.ts +26 -2
  64. package/src/component/earnings-totals.test.ts +13 -0
  65. package/src/component/transfers.test.ts +48 -0
  66. package/src/react/components/DisputeDetail.tsx +14 -5
  67. package/src/react/components/DisputesList.render.test.tsx +21 -0
  68. package/src/react/components/DisputesList.tsx +2 -1
  69. package/src/react/components/EvidenceForm.render.test.tsx +1 -2
  70. package/src/react/index.ts +4 -1
  71. package/src/react/workflows/AffiliatePayoutEditor.test.tsx +90 -0
  72. package/src/react/workflows/AffiliatePayoutEditor.tsx +201 -0
  73. package/src/react/workflows/AffiliateRateEditor.test.tsx +36 -0
  74. package/src/react/workflows/AffiliateRateEditor.tsx +92 -0
  75. package/src/react/workflows/CheckoutForms.test.tsx +40 -0
  76. package/src/react/workflows/CheckoutForms.tsx +37 -16
  77. package/src/react/workflows/Disputes.test.tsx +41 -2
  78. package/src/react/workflows/Disputes.tsx +49 -40
  79. package/src/react/workflows/IntegrationSwitch.test.tsx +25 -0
  80. package/src/react/workflows/IntegrationSwitch.tsx +2 -2
  81. package/src/react/workflows/Pagination.test.tsx +53 -0
  82. package/src/react/workflows/Pagination.tsx +93 -0
  83. package/src/react/workflows/Payouts.test.tsx +30 -0
  84. package/src/react/workflows/Payouts.tsx +4 -1
  85. package/src/react/workflows/PlanPricingEditor.test.tsx +79 -0
  86. package/src/react/workflows/PlanPricingEditor.tsx +156 -0
  87. package/src/react/workflows/index.ts +22 -0
@@ -611,6 +611,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
611
611
  destinationAccountId?: string;
612
612
  feeCollectedAmount?: number;
613
613
  feeRefundedAmount?: number;
614
+ isCollectionPaused?: boolean;
614
615
  isTrialing: boolean;
615
616
  metadata?: any;
616
617
  orgId?: string;
@@ -1339,6 +1340,46 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1339
1340
  }>,
1340
1341
  Name
1341
1342
  >;
1343
+ paginateTransfersByAccount: FunctionReference<
1344
+ "query",
1345
+ "internal",
1346
+ {
1347
+ destinationAccountId: string;
1348
+ paginationOpts: {
1349
+ cursor: string | null;
1350
+ endCursor?: string | null;
1351
+ id?: number;
1352
+ maximumBytesRead?: number;
1353
+ maximumRowsRead?: number;
1354
+ numItems: number;
1355
+ };
1356
+ },
1357
+ {
1358
+ continueCursor: string;
1359
+ isDone: boolean;
1360
+ page: Array<{
1361
+ _creationTime: number;
1362
+ _id: string;
1363
+ amount: number;
1364
+ currency: string;
1365
+ destinationAccountId: string;
1366
+ metadata?: any;
1367
+ paymentId?: string;
1368
+ reinstatement?: boolean;
1369
+ reversalClaimedAmount?: number;
1370
+ reversalStatus?: "partially_reversed" | "fully_reversed";
1371
+ reversedAmount?: number;
1372
+ role?: "store" | "affiliate" | "other";
1373
+ sourceChargeId?: string;
1374
+ sourceInvoiceId?: string;
1375
+ status: "pending" | "paid" | "failed" | "reversed";
1376
+ stripeTransferId: string;
1377
+ }>;
1378
+ pageStatus?: "SplitRecommended" | "SplitRequired" | null;
1379
+ splitCursor?: string | null;
1380
+ },
1381
+ Name
1382
+ >;
1342
1383
  };
1343
1384
  };
1344
1385
  core: {
@@ -1,4 +1,10 @@
1
1
  import { v } from "convex/values";
2
+ import { paginator } from "convex-helpers/server/pagination";
3
+ import schema from "../schema.js";
4
+ import {
5
+ paginationOptsValidator,
6
+ paginationResultValidator,
7
+ } from "convex/server";
2
8
 
3
9
  import type { Doc } from "../_generated/dataModel";
4
10
  import { query } from "../_generated/server";
@@ -343,6 +349,23 @@ export const listTransfersByAccount = query({
343
349
  },
344
350
  });
345
351
 
352
+ /** Newest transfers for a recipient, with bounded cursor pagination. */
353
+ export const paginateTransfersByAccount = query({
354
+ args: {
355
+ destinationAccountId: v.string(),
356
+ paginationOpts: paginationOptsValidator,
357
+ },
358
+ returns: paginationResultValidator(transferDocValidator),
359
+ handler: (ctx, args) =>
360
+ paginator(ctx.db, schema)
361
+ .query("transfers")
362
+ .withIndex("by_destination_account_id", (q) =>
363
+ q.eq("destinationAccountId", args.destinationAccountId),
364
+ )
365
+ .order("desc")
366
+ .paginate(args.paginationOpts),
367
+ });
368
+
346
369
  // =============================================================================
347
370
  // REVERSAL-CLAIM RECLAIM (BTS-74) — ops diagnostics for wedged legs
348
371
  // =============================================================================
@@ -442,7 +465,7 @@ export const listWedgedReversalClaims = query({
442
465
  * Exact earnings totals for one connected account (BTS-64): `gross` (sum of all
443
466
  * transfer amounts, incl. reinstatements — the earnings view) and `reversed`
444
467
  * (sum of all `reversedAmount`), iterated to completion so totals are never
445
- * capped. `transfers` is a bounded preview (`previewLimit ?? 50` rows) for
468
+ * capped. `transfers` is a newest-first bounded preview (`previewLimit ?? 50` rows) for
446
469
  * drill-down; use `gross`/`reversed`/`transferCount` for the money figures.
447
470
  */
448
471
  export const getAccountEarnings = query({
@@ -467,7 +490,8 @@ export const getAccountEarnings = query({
467
490
  .query("transfers")
468
491
  .withIndex("by_destination_account_id", (q) =>
469
492
  q.eq("destinationAccountId", args.destinationAccountId),
470
- )) {
493
+ )
494
+ .order("desc")) {
471
495
  gross += tr.amount;
472
496
  reversed += tr.reversedAmount ?? 0;
473
497
  transferCount += 1;
@@ -50,6 +50,18 @@ describe("getAccountEarnings — exact totals past the 50-row cap (BTS-64)", ()
50
50
  expect(result.transferCount).toBe(120);
51
51
  // The preview is still bounded (drill-down rows), but the totals are exact.
52
52
  expect(result.transfers.length).toBe(50);
53
+ expect(result.transfers[0].stripeTransferId).toBe("tr_big_119");
54
+ expect(
55
+ result.transfers.some((row) => row.stripeTransferId === "tr_big_0"),
56
+ ).toBe(false);
57
+ const newest = await t.query(api.connect.queries.getAccountEarnings, {
58
+ destinationAccountId: "acct_big",
59
+ previewLimit: 1,
60
+ });
61
+ expect(newest.transfers.map((row) => row.stripeTransferId)).toEqual([
62
+ "tr_big_119",
63
+ ]);
64
+ expect(newest.gross).toBe(12000);
53
65
  });
54
66
 
55
67
  it("returns exact totals and all rows for a small account (regression)", async () => {
@@ -208,6 +220,7 @@ describe("aggregate read-limit failures (BTS-116)", () => {
208
220
  "Too many documents read in a single function execution",
209
221
  );
210
222
  const rows = {
223
+ order: () => rows,
211
224
  async *[Symbol.asyncIterator]() {
212
225
  yield { amount: 100, reversedAmount: 10, status: "paid" };
213
226
  throw readLimit;
@@ -469,3 +469,51 @@ describe("claimReversalSlices (BTS-63)", () => {
469
469
  expect(rival.slices).toEqual([]);
470
470
  });
471
471
  });
472
+
473
+ it("paginates an account's newest transfers beyond 50 without duplicates or other accounts", async () => {
474
+ const t = convexTest(schema, modules);
475
+ for (let i = 0; i < 57; i++) {
476
+ await t.mutation(api.connect.mutations.upsertTransfer, {
477
+ stripeTransferId: `tr_page_${i}`,
478
+ destinationAccountId: "acct_pages",
479
+ amount: 100,
480
+ currency: "usd",
481
+ status: "paid",
482
+ });
483
+ }
484
+ await t.mutation(api.connect.mutations.upsertTransfer, {
485
+ stripeTransferId: "tr_foreign",
486
+ destinationAccountId: "acct_other",
487
+ amount: 200,
488
+ currency: "usd",
489
+ status: "paid",
490
+ });
491
+ let cursor: string | null = null;
492
+ const ids: string[] = [];
493
+ const sizes: number[] = [];
494
+ for (;;) {
495
+ const result: {
496
+ page: { stripeTransferId: string }[];
497
+ isDone: boolean;
498
+ continueCursor: string;
499
+ } = await t.query(api.connect.queries.paginateTransfersByAccount, {
500
+ destinationAccountId: "acct_pages",
501
+ paginationOpts: { numItems: 10, cursor },
502
+ });
503
+ sizes.push(result.page.length);
504
+ ids.push(...result.page.map((row) => row.stripeTransferId));
505
+ if (result.isDone) break;
506
+ cursor = result.continueCursor;
507
+ }
508
+ expect(sizes).toEqual([10, 10, 10, 10, 10, 7]);
509
+ expect(ids).toEqual(
510
+ Array.from({ length: 57 }, (_, i) => `tr_page_${56 - i}`),
511
+ );
512
+ expect(new Set(ids).size).toBe(57);
513
+ const empty = await t.query(api.connect.queries.paginateTransfersByAccount, {
514
+ destinationAccountId: "acct_empty",
515
+ paginationOpts: { numItems: 10, cursor: null },
516
+ });
517
+ expect(empty.page).toEqual([]);
518
+ expect(empty.isDone).toBe(true);
519
+ });
@@ -5,6 +5,11 @@ import type { ReactNode } from "react";
5
5
  import type { DisputeWithCountdown } from "../hooks/useDisputeWithCountdown.js";
6
6
  import { formatPrice } from "../lib/price-helpers.js";
7
7
 
8
+ /** Stripe only accepts responses while the dispute requires one. */
9
+ export function canRespondToDispute(status: string | undefined) {
10
+ return status === "needs_response" || status === "warning_needs_response";
11
+ }
12
+
8
13
  export type DisputeDetailRenderProps = {
9
14
  dispute: DisputeWithCountdown;
10
15
  /** Formatted dispute amount, e.g. "$120.50". */
@@ -26,6 +31,7 @@ export type DisputeDetailProps = {
26
31
  dueLabel?: string;
27
32
  overdueLabel?: string;
28
33
  noDeadlineLabel?: string;
34
+ responseClosedLabel?: string;
29
35
  /** Field captions in the default rendering. */
30
36
  labels?: {
31
37
  amount?: string;
@@ -53,6 +59,7 @@ export function DisputeDetail({
53
59
  dueLabel = "Evidence due in {days} days",
54
60
  overdueLabel = "Evidence overdue",
55
61
  noDeadlineLabel = "No evidence deadline",
62
+ responseClosedLabel = "Evidence submission closed",
56
63
  labels,
57
64
  locale,
58
65
  className,
@@ -70,11 +77,13 @@ export function DisputeDetail({
70
77
  }
71
78
 
72
79
  const { countdown } = dispute;
73
- const countdownMessage = countdown.isOverdue
74
- ? overdueLabel
75
- : countdown.daysRemaining !== undefined
76
- ? dueLabel.replace("{days}", String(countdown.daysRemaining))
77
- : noDeadlineLabel;
80
+ const countdownMessage = !canRespondToDispute(dispute.status)
81
+ ? responseClosedLabel
82
+ : countdown.isOverdue
83
+ ? overdueLabel
84
+ : countdown.daysRemaining !== undefined
85
+ ? dueLabel.replace("{days}", String(countdown.daysRemaining))
86
+ : noDeadlineLabel;
78
87
 
79
88
  const renderProps: DisputeDetailRenderProps = {
80
89
  dispute,
@@ -242,3 +242,24 @@ describe("DisputesList", () => {
242
242
  expect(html).not.toContain("No disputes");
243
243
  });
244
244
  });
245
+ it.each(["won", "lost", "under_review"] as const)(
246
+ "hides stale evidence deadlines for %s disputes",
247
+ (status) => {
248
+ const html = renderToStaticMarkup(
249
+ createElement(DisputesList, {
250
+ disputes: [
251
+ makeDispute({
252
+ status,
253
+ countdown: {
254
+ dueBy: "2030-01-01T00:00:00Z",
255
+ daysRemaining: 9,
256
+ isOverdue: false,
257
+ },
258
+ }),
259
+ ],
260
+ }),
261
+ );
262
+ expect(html).toContain(status);
263
+ expect(html).not.toContain("Evidence due in");
264
+ },
265
+ );
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import type { ReactNode } from "react";
4
+ import { canRespondToDispute } from "./DisputeDetail.js";
4
5
 
5
6
  import type { StripeComponentDispute } from "../types.js";
6
7
  import type { DisputeEvidenceCountdown } from "../hooks/useDisputeWithCountdown.js";
@@ -78,7 +79,7 @@ export function DisputesList({
78
79
 
79
80
  const badgeTextFor = (row: DisputesListRow): string | undefined => {
80
81
  const countdown = row.countdown;
81
- if (!countdown?.dueBy) return undefined;
82
+ if (!canRespondToDispute(row.status) || !countdown?.dueBy) return undefined;
82
83
  if (countdown.isOverdue) return overdueLabel;
83
84
  return dueLabel.replace("{days}", String(countdown.daysRemaining));
84
85
  };
@@ -79,8 +79,7 @@ describe("EvidenceForm", () => {
79
79
  expect(args.evidence).toEqual({
80
80
  product_description: allFields.productDescription,
81
81
  access_activity_log: allFields.accessActivity,
82
- uncategorized_text: allFields.additionalInfo,
83
- customer_communication: allFields.customerCommunication,
82
+ uncategorized_text: `${allFields.additionalInfo}\n\nCustomer communication:\n${allFields.customerCommunication}`,
84
83
  });
85
84
  // Stripe's dispute-update `submit` DEFAULTS TO TRUE when omitted, so a
86
85
  // draft must send an explicit false — omission would submit to the bank.
@@ -163,7 +163,10 @@ export type {
163
163
  DisputesListRow,
164
164
  DisputesListRowContext,
165
165
  } from "./components/DisputesList.js";
166
- export { DisputeDetail } from "./components/DisputeDetail.js";
166
+ export {
167
+ DisputeDetail,
168
+ canRespondToDispute,
169
+ } from "./components/DisputeDetail.js";
167
170
  export type {
168
171
  DisputeDetailProps,
169
172
  DisputeDetailRenderProps,
@@ -0,0 +1,90 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { afterEach, expect, it, vi } from "vitest";
5
+ import { AffiliatePayoutEditor } from "./AffiliatePayoutEditor.js";
6
+ afterEach(cleanup);
7
+ it("saves either a percentage or a currency amount and clears inheritance", async () => {
8
+ const save = vi.fn().mockResolvedValue(undefined);
9
+ render(
10
+ <AffiliatePayoutEditor
11
+ scopeKey="price"
12
+ affiliateBasisPoints={null}
13
+ effectiveBasisPoints={2000}
14
+ version={0}
15
+ currency="eur"
16
+ onSave={save}
17
+ />,
18
+ );
19
+ const input = screen.getByLabelText("Affiliate payout");
20
+ await userEvent.type(input, "12.5");
21
+ await userEvent.click(screen.getByRole("button", { name: "Save" }));
22
+ expect(save).toHaveBeenLastCalledWith({
23
+ affiliateBasisPoints: 1250,
24
+ affiliateFixedAmount: null,
25
+ affiliateCommissionTerm: null,
26
+ expectedVersion: 0,
27
+ });
28
+ await userEvent.click(
29
+ screen.getByRole("button", { name: "Fixed amount in EUR" }),
30
+ );
31
+ await userEvent.click(screen.getByRole("button", { name: "Save" }));
32
+ expect(save).toHaveBeenLastCalledWith({
33
+ affiliateBasisPoints: null,
34
+ affiliateFixedAmount: 12.5,
35
+ affiliateCommissionTerm: null,
36
+ expectedVersion: 0,
37
+ });
38
+ await userEvent.click(
39
+ screen.getByRole("button", { name: "Clear affiliate payout" }),
40
+ );
41
+ expect(save).toHaveBeenLastCalledWith({
42
+ affiliateBasisPoints: null,
43
+ affiliateFixedAmount: null,
44
+ affiliateCommissionTerm: null,
45
+ expectedVersion: 0,
46
+ });
47
+ expect((input as HTMLInputElement).value).toBe("");
48
+ });
49
+ it("rejects excessive percentages and invalid decimals", async () => {
50
+ const save = vi.fn();
51
+ render(
52
+ <AffiliatePayoutEditor
53
+ scopeKey="product"
54
+ affiliateBasisPoints={null}
55
+ effectiveBasisPoints={2000}
56
+ version={0}
57
+ onSave={save}
58
+ />,
59
+ );
60
+ await userEvent.type(screen.getByLabelText("Affiliate payout"), "101");
61
+ await userEvent.click(screen.getByRole("button", { name: "Save" }));
62
+ expect(screen.getByRole("alert").textContent).toMatch(/100/);
63
+ expect(save).not.toHaveBeenCalled();
64
+ });
65
+ it("saves a one-time subscription commission and clears its term to inheritance", async () => {
66
+ const save = vi.fn().mockResolvedValue(undefined);
67
+ render(
68
+ <AffiliatePayoutEditor
69
+ scopeKey="term"
70
+ affiliateBasisPoints={1000}
71
+ effectiveBasisPoints={1000}
72
+ version={0}
73
+ onSave={save}
74
+ />,
75
+ );
76
+ await userEvent.selectOptions(
77
+ screen.getByLabelText("Commission term"),
78
+ "one_time",
79
+ );
80
+ await userEvent.click(screen.getByRole("button", { name: "Save" }));
81
+ expect(save).toHaveBeenLastCalledWith(
82
+ expect.objectContaining({ affiliateCommissionTerm: "one_time" }),
83
+ );
84
+ await userEvent.click(
85
+ screen.getByRole("button", { name: "Clear affiliate payout" }),
86
+ );
87
+ expect(save).toHaveBeenLastCalledWith(
88
+ expect.objectContaining({ affiliateCommissionTerm: null }),
89
+ );
90
+ });
@@ -0,0 +1,201 @@
1
+ "use client";
2
+ import { useId, useMemo, useState, type ReactNode } from "react";
3
+ import { useWorkflowAction } from "./useWorkflowAction.js";
4
+ export type AffiliateCommissionTerm = "forever" | "one_time";
5
+ export type AffiliatePayoutUpdate = {
6
+ affiliateCommissionTerm?: AffiliateCommissionTerm | null;
7
+ affiliateBasisPoints: number | null;
8
+ /** Major units of the sale currency, for example 5 means EUR 5 on a EUR sale. */
9
+ affiliateFixedAmount: number | null;
10
+ expectedVersion: number;
11
+ };
12
+ export type AffiliatePayoutEditorState = {
13
+ term: AffiliateCommissionTerm | "inherit";
14
+ setTerm: (term: AffiliateCommissionTerm | "inherit") => void;
15
+ value: string;
16
+ mode: "percentage" | "fixed";
17
+ currencySymbol: string;
18
+ setValue: (value: string) => void;
19
+ setMode: (mode: "percentage" | "fixed") => void;
20
+ effectiveLabel: string;
21
+ save: () => Promise<unknown>;
22
+ clear: () => Promise<unknown>;
23
+ isPending: boolean;
24
+ error: string | null;
25
+ message: string | undefined;
26
+ };
27
+ export type AffiliatePayoutEditorProps = {
28
+ scopeKey: string;
29
+ affiliateCommissionTerm?: AffiliateCommissionTerm | null;
30
+ affiliateBasisPoints: number | null;
31
+ affiliateFixedAmount?: number | null;
32
+ effectiveCommissionTerm?: AffiliateCommissionTerm;
33
+ effectiveBasisPoints: number;
34
+ effectiveFixedAmount?: number | null;
35
+ source?: "plan" | "product" | "price" | "store" | "global" | "none";
36
+ currency?: string;
37
+ version: number;
38
+ onSave: (update: AffiliatePayoutUpdate) => Promise<unknown>;
39
+ children?: (state: AffiliatePayoutEditorState) => ReactNode;
40
+ };
41
+ function initialFields(props: AffiliatePayoutEditorProps) {
42
+ return {
43
+ scope: `${props.scopeKey}:${props.version}`,
44
+ term: props.affiliateCommissionTerm ?? ("inherit" as const),
45
+ mode:
46
+ props.affiliateFixedAmount != null
47
+ ? ("fixed" as const)
48
+ : ("percentage" as const),
49
+ value:
50
+ props.affiliateFixedAmount != null
51
+ ? String(props.affiliateFixedAmount)
52
+ : props.affiliateBasisPoints == null
53
+ ? ""
54
+ : String(props.affiliateBasisPoints / 100),
55
+ };
56
+ }
57
+ /** Unstyled inline payout editor. Clear saves inheritance; explicit zero disables payouts. */
58
+ export function AffiliatePayoutEditor(props: AffiliatePayoutEditorProps) {
59
+ const id = useId();
60
+ const currency = (props.currency ?? "usd").toUpperCase();
61
+ const formatter = useMemo(
62
+ () =>
63
+ new Intl.NumberFormat("en-US", {
64
+ style: "currency",
65
+ currency,
66
+ currencyDisplay: "narrowSymbol",
67
+ }),
68
+ [currency],
69
+ );
70
+ const currencySymbol =
71
+ formatter.formatToParts(0).find((part) => part.type === "currency")
72
+ ?.value ?? currency;
73
+ const scope = `${props.scopeKey}:${props.version}`;
74
+ const initial = initialFields(props);
75
+ const [fields, setFields] = useState(initial);
76
+ if (fields.scope !== scope) setFields(initial);
77
+ const action = useWorkflowAction(async (clear = false) => {
78
+ const value = clear ? "" : fields.value.trim();
79
+ const amount = value === "" ? null : Number(value);
80
+ if (
81
+ amount !== null &&
82
+ (!/^\d+(\.\d{1,2})?$/.test(value) ||
83
+ !Number.isSafeInteger(Math.round(amount * 100)) ||
84
+ amount < 0 ||
85
+ (fields.mode === "percentage" && amount > 100))
86
+ )
87
+ throw new Error(
88
+ "Enter a non-negative amount with at most two decimals, or a percentage from 0 to 100.",
89
+ );
90
+ await props.onSave({
91
+ affiliateCommissionTerm:
92
+ clear || fields.term === "inherit" ? null : fields.term,
93
+ affiliateBasisPoints:
94
+ fields.mode === "percentage" && amount !== null
95
+ ? Math.round(amount * 100)
96
+ : null,
97
+ affiliateFixedAmount: fields.mode === "fixed" ? amount : null,
98
+ expectedVersion: props.version,
99
+ });
100
+ if (clear)
101
+ setFields((previous) =>
102
+ previous.scope === scope
103
+ ? { ...previous, value: "", term: "inherit" }
104
+ : previous,
105
+ );
106
+ return clear
107
+ ? "Affiliate payout cleared. Using the inherited default."
108
+ : "Affiliate payout saved.";
109
+ }, props.scopeKey);
110
+ const state: AffiliatePayoutEditorState = {
111
+ ...fields,
112
+ currencySymbol,
113
+ setTerm: (term) => setFields((previous) => ({ ...previous, term })),
114
+ setValue: (value) => setFields((previous) => ({ ...previous, value })),
115
+ setMode: (mode) => setFields((previous) => ({ ...previous, mode })),
116
+ effectiveLabel: `Commission term: ${(props.effectiveCommissionTerm ?? "forever") === "forever" ? "Forever" : "One-time"}. Effective payout: ${props.effectiveFixedAmount != null ? formatter.format(props.effectiveFixedAmount) : `${props.effectiveBasisPoints / 100}%`}${props.source && props.source !== "none" ? ` from ${props.source === "plan" ? "store plan" : props.source}` : ""}.`,
117
+ save: () => action.run(false),
118
+ clear: () => action.run(true),
119
+ isPending: action.isPending,
120
+ error: action.error,
121
+ message: action.result,
122
+ };
123
+ if (props.children) return <>{props.children(state)}</>;
124
+ return (
125
+ <form
126
+ onSubmit={(event) => {
127
+ event.preventDefault();
128
+ void state.save();
129
+ }}
130
+ >
131
+ <label htmlFor={id}>Affiliate payout</label>
132
+ <input
133
+ id={id}
134
+ inputMode="decimal"
135
+ value={state.value}
136
+ disabled={state.isPending}
137
+ onChange={(event) => state.setValue(event.target.value)}
138
+ />
139
+ <button
140
+ type="button"
141
+ aria-label="Percentage"
142
+ aria-pressed={state.mode === "percentage"}
143
+ disabled={state.isPending}
144
+ onClick={() => state.setMode("percentage")}
145
+ >
146
+ %
147
+ </button>
148
+ <button
149
+ type="button"
150
+ aria-label={`Fixed amount in ${currency}`}
151
+ aria-pressed={state.mode === "fixed"}
152
+ disabled={state.isPending}
153
+ onClick={() => state.setMode("fixed")}
154
+ >
155
+ {currencySymbol}
156
+ </button>
157
+ <label htmlFor={`${id}-term`}>Commission term</label>
158
+ <select
159
+ id={`${id}-term`}
160
+ value={state.term}
161
+ disabled={state.isPending}
162
+ onChange={(event) =>
163
+ state.setTerm(
164
+ event.target.value as AffiliateCommissionTerm | "inherit",
165
+ )
166
+ }
167
+ >
168
+ <option value="inherit">Inherited term</option>
169
+ <option value="forever">Forever</option>
170
+ <option value="one_time">One-time</option>
171
+ </select>
172
+ <button type="submit" disabled={state.isPending}>
173
+ Save
174
+ </button>
175
+ <button
176
+ type="button"
177
+ aria-label="Clear affiliate payout"
178
+ disabled={state.isPending}
179
+ onClick={() => void state.clear()}
180
+ >
181
+ <svg
182
+ width="16"
183
+ height="16"
184
+ viewBox="0 0 24 24"
185
+ fill="none"
186
+ stroke="currentColor"
187
+ strokeWidth="2"
188
+ aria-hidden="true"
189
+ >
190
+ <path d="M18 6 6 18M6 6l12 12" />
191
+ </svg>
192
+ </button>
193
+ <p>
194
+ {state.effectiveLabel} Blank inherits the default. Fixed payouts are
195
+ capped at proceeds after fees.
196
+ </p>
197
+ {state.error && <p role="alert">{state.error}</p>}
198
+ {state.message && <p role="status">{state.message}</p>}
199
+ </form>
200
+ );
201
+ }
@@ -0,0 +1,36 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { afterEach, expect, it, vi } from "vitest";
5
+ import { AffiliateRateEditor } from "./AffiliateRateEditor.js";
6
+ afterEach(cleanup);
7
+ it("saves a percentage, clears to inherit, and rejects invalid decimals", async () => {
8
+ const save = vi.fn().mockResolvedValue(undefined);
9
+ render(
10
+ <AffiliateRateEditor
11
+ scopeKey="price"
12
+ affiliateBasisPoints={null}
13
+ effectiveBasisPoints={2000}
14
+ source="plan"
15
+ version={0}
16
+ onSave={save}
17
+ />,
18
+ );
19
+ const input = screen.getByLabelText("Affiliate payout (%)");
20
+ await userEvent.type(input, "12.5");
21
+ await userEvent.click(screen.getByRole("button", { name: "Save payout" }));
22
+ expect(save).toHaveBeenLastCalledWith({
23
+ affiliateBasisPoints: 1250,
24
+ expectedVersion: 0,
25
+ });
26
+ await userEvent.clear(input);
27
+ await userEvent.click(screen.getByRole("button", { name: "Save payout" }));
28
+ expect(save).toHaveBeenLastCalledWith({
29
+ affiliateBasisPoints: null,
30
+ expectedVersion: 0,
31
+ });
32
+ await userEvent.type(input, "100.001");
33
+ await userEvent.click(screen.getByRole("button", { name: "Save payout" }));
34
+ expect(save).toHaveBeenCalledTimes(2);
35
+ expect(screen.getByRole("alert").textContent).toMatch(/percentage/i);
36
+ });