@fantastic.dev/better-stripe 0.8.1 → 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 (54) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +18 -2
  3. package/dist/component/_generated/component.d.ts +35 -0
  4. package/dist/component/_generated/component.d.ts.map +1 -1
  5. package/dist/component/connect/queries.d.ts +30 -1
  6. package/dist/component/connect/queries.d.ts.map +1 -1
  7. package/dist/component/connect/queries.js +19 -2
  8. package/dist/component/connect/queries.js.map +1 -1
  9. package/dist/react/workflows/AffiliatePayoutEditor.d.ts +41 -0
  10. package/dist/react/workflows/AffiliatePayoutEditor.d.ts.map +1 -0
  11. package/dist/react/workflows/AffiliatePayoutEditor.js +80 -0
  12. package/dist/react/workflows/AffiliatePayoutEditor.js.map +1 -0
  13. package/dist/react/workflows/AffiliateRateEditor.d.ts +26 -0
  14. package/dist/react/workflows/AffiliateRateEditor.d.ts.map +1 -0
  15. package/dist/react/workflows/AffiliateRateEditor.js +49 -0
  16. package/dist/react/workflows/AffiliateRateEditor.js.map +1 -0
  17. package/dist/react/workflows/CheckoutForms.d.ts +2 -0
  18. package/dist/react/workflows/CheckoutForms.d.ts.map +1 -1
  19. package/dist/react/workflows/CheckoutForms.js +22 -16
  20. package/dist/react/workflows/CheckoutForms.js.map +1 -1
  21. package/dist/react/workflows/IntegrationSwitch.js +2 -2
  22. package/dist/react/workflows/IntegrationSwitch.js.map +1 -1
  23. package/dist/react/workflows/Pagination.d.ts +32 -0
  24. package/dist/react/workflows/Pagination.d.ts.map +1 -0
  25. package/dist/react/workflows/Pagination.js +49 -0
  26. package/dist/react/workflows/Pagination.js.map +1 -0
  27. package/dist/react/workflows/PlanPricingEditor.d.ts +35 -0
  28. package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -0
  29. package/dist/react/workflows/PlanPricingEditor.js +64 -0
  30. package/dist/react/workflows/PlanPricingEditor.js.map +1 -0
  31. package/dist/react/workflows/index.d.ts +8 -0
  32. package/dist/react/workflows/index.d.ts.map +1 -1
  33. package/dist/react/workflows/index.js +4 -0
  34. package/dist/react/workflows/index.js.map +1 -1
  35. package/docs/react-workflows-audit.md +19 -0
  36. package/docs/react-workflows.md +89 -1
  37. package/package.json +5 -4
  38. package/src/component/_generated/component.ts +41 -0
  39. package/src/component/connect/queries.ts +26 -2
  40. package/src/component/earnings-totals.test.ts +13 -0
  41. package/src/component/transfers.test.ts +48 -0
  42. package/src/react/workflows/AffiliatePayoutEditor.test.tsx +90 -0
  43. package/src/react/workflows/AffiliatePayoutEditor.tsx +201 -0
  44. package/src/react/workflows/AffiliateRateEditor.test.tsx +36 -0
  45. package/src/react/workflows/AffiliateRateEditor.tsx +92 -0
  46. package/src/react/workflows/CheckoutForms.test.tsx +40 -0
  47. package/src/react/workflows/CheckoutForms.tsx +37 -16
  48. package/src/react/workflows/IntegrationSwitch.test.tsx +25 -0
  49. package/src/react/workflows/IntegrationSwitch.tsx +2 -2
  50. package/src/react/workflows/Pagination.test.tsx +53 -0
  51. package/src/react/workflows/Pagination.tsx +93 -0
  52. package/src/react/workflows/PlanPricingEditor.test.tsx +79 -0
  53. package/src/react/workflows/PlanPricingEditor.tsx +156 -0
  54. package/src/react/workflows/index.ts +22 -0
@@ -72,6 +72,10 @@ Use `renderForm` on the custom and PaymentIntent modes to render your own email
72
72
  input, `paymentElement`, error, and submit button. The preview `form` mode renders
73
73
  Stripe's form and does not use that renderer. `children` replaces the outer
74
74
  checkout frame and receives `content`, `isLoading`, `error`, and `retry`.
75
+ Respect `state.emailReadOnly` on your email input. Custom Checkout uses the
76
+ email Stripe supplied when the session loaded and skips `updateEmail` for that
77
+ session. Guest sessions without an email collect it before confirmation and
78
+ keep it editable if a payment attempt fails.
75
79
  The lower-level `CheckoutElementsForm`, `PaymentIntentForm`, and `CheckoutForm`
76
80
  are also exported for an existing client secret.
77
81
 
@@ -79,7 +83,14 @@ Only a server-confirmed payment or checkout session should grant access to the
79
83
  product. Use `PaymentReceipt` or `CheckoutStatus` with webhook-backed query data;
80
84
  a browser return URL alone does not prove payment.
81
85
 
82
- ## Headless or Stripe embedded
86
+ ## Via API, BetterStripe components, or Stripe embedded
87
+
88
+ The switch labels are **via API**, **BetterStripe components**, **Stripe embedded**,
89
+ and **Stripe hosted**. BetterStripe components are the unstyled exports from this
90
+ package; Stripe embedded views mount Stripe's Connect UI. The internal mode keys
91
+ remain `headless`, `components`, `embedded`, and `hosted` for compatibility, including
92
+ existing `?example=headless` links. The package's native components accept render
93
+ props so the example can match the API view's cards and layout.
83
94
 
84
95
  `Account`, `Onboarding`, `Disputes`, `Refunds`, `Earnings`, and `Payouts` accept
85
96
  `presentation="embedded"` with Connect session props. Their default headless
@@ -232,3 +243,80 @@ available and pending Stripe balances shown by `Payouts`.
232
243
  shared by its account pages and native `Onboarding`. Supply `countries` to limit
233
244
  or expand the UI for your platform. This default is not a complete list of
234
245
  Stripe-supported countries; your server must validate account eligibility.
246
+
247
+ ## Cursor pagination
248
+
249
+ Use `useCursorPagination(accountId)` to keep a page number, current cursor, and
250
+ Previous/Next history. Pass `cursor` to your backend's paginated query and call
251
+ `next(result.continueCursor)` only when `result.isDone` is false. Changing the
252
+ scope key resets the history to page 1.
253
+
254
+ `Pagination` renders unstyled Previous/Next buttons and an announced page number,
255
+ or accepts a render function for your design system. Supply `canPrevious`,
256
+ `canNext`, `isLoading`, `onPrevious`, and `onNext`. Navigation is disabled while
257
+ loading. The example's affiliate sales page uses this with the component query
258
+ `connect.queries.paginateTransfersByAccount`, which returns newest-first cursor
259
+ pages scoped to the recipient account. Earnings totals still use aggregate
260
+ queries independently of pagination.
261
+
262
+ ## Store-plan pricing editor
263
+
264
+ `PlanPricingEditor` accepts a `plan` with `id`, `name`, `monthlyAmount` in minor
265
+ units, nullable `feeBasisPoints`, and `version`, plus `onSave(update)`. The update
266
+ contains `monthlyAmount`, `feeBasisPoints`, and `expectedVersion`. Blank fees
267
+ mean processing-only. Native fields are unstyled; use the render prop to supply
268
+ your design system. Server-side authorization, fee floors, Stripe Price creation,
269
+ and conflict checks belong in your backend. Existing subscription migration is
270
+ an application policy, not an automatic side effect of this editor.
271
+
272
+ `PlanPricingEditor` also supports optional `affiliateBasisPoints` on the plan and
273
+ save update, defaulting to 0. Its render state exposes `affiliateShare` and
274
+ `setAffiliateShare`. The native field is labeled Affiliate payout (%), accepts
275
+ 0 through 100 with at most two decimal places, and saves integer basis points.
276
+ Omitting the field in an older backend request preserves an existing configured
277
+ rate. The example applies this rate after sale fees and records it with each
278
+ settled sale.
279
+
280
+ ## Product and subscription affiliate-rate editor
281
+
282
+ `AffiliateRateEditor` accepts `scopeKey`, nullable `affiliateBasisPoints`,
283
+ `effectiveBasisPoints`, `source` of global/store/plan/product/price/none, `version`, and `onSave`.
284
+ Its save update contains nullable `affiliateBasisPoints` and `expectedVersion`.
285
+ Blank input means inherit, zero disables commission, and percentages support
286
+ 0 to 100 with two decimal places. Use its native unstyled form or render state
287
+ `percentage`, `setPercentage`, `effectiveLabel`, `save`, `isPending`, `error`,
288
+ and `message` to supply styled controls. The backend must verify ownership and
289
+ resolve price overrides before product overrides and plan defaults. The example
290
+ uses the current subscription price on renewals and preserves settled economics.
291
+
292
+ ## Percentage or fixed affiliate payouts
293
+
294
+ `AffiliatePayoutEditor` is the unstyled editor used inline by the example's seller
295
+ product and price rows and admin store-plan rows. Supply `scopeKey`, nullable
296
+ `affiliateBasisPoints`, optional nullable `affiliateFixedAmount`, effective values,
297
+ `version`, `currency`, and `onSave`. Fixed amounts are major currency units,
298
+ not cents. The save callback receives both nullable fields and `expectedVersion`;
299
+ only one payout field is non-null. Blank means inherit; explicit zero disables.
300
+
301
+ The native form includes a number input, `%`/currency-symbol toggle, Save, and X.
302
+ X saves null for both fields to clear the override. `currency` defaults to USD
303
+ and controls the display symbol. A render prop exposes `value`, `mode`, `setValue`,
304
+ `setMode`, `currencySymbol`, `effectiveLabel`, `save`, `clear`, `isPending`, `error`,
305
+ and `message` so applications can supply their own markup. Server code must
306
+ resolve defaults, validate ownership and versions, convert major amounts into
307
+ Stripe minor units, and cap fixed payouts at proceeds after fees. The example
308
+ uses a nominal fixed amount per sale currency; it does not perform FX conversion.
309
+
310
+ Affiliate payout sources can be `global`, `store`, `plan`, `product`, `price`, or
311
+ `none`. Unset app rules fall back to zero. The example resolves price → product →
312
+ store-wide → plan → platform-wide → 0, and distinguishes explicit zero from null
313
+ inheritance. Both global controls use the same package editor as the row controls.
314
+
315
+ `AffiliatePayoutEditor` also exposes an optional `affiliateCommissionTerm`
316
+ (`"forever"`, `"one_time"`, or `null` to inherit) and
317
+ `effectiveCommissionTerm`. Its render state includes `term` and `setTerm`, and
318
+ `onSave` receives the selected term together with the payout amount. Clear
319
+ removes both overrides. The example saves the resolved subscription term in
320
+ Stripe metadata at checkout and claims the first paid invoice atomically in its
321
+ Convex ledger; the UI component does not itself create transfers or process
322
+ renewals.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fantastic.dev/better-stripe",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "A Convex component for Stripe V2 Accounts API integration.",
5
5
  "keywords": [
6
6
  "stripe",
@@ -103,12 +103,15 @@
103
103
  "@stripe/react-connect-js": "^3.4.4",
104
104
  "@stripe/react-stripe-js": "^7.0.0",
105
105
  "@stripe/stripe-js": "^10.0.0",
106
+ "convex-helpers": "^0.1.128",
106
107
  "stripe": "~23.0.0"
107
108
  },
108
109
  "devDependencies": {
109
110
  "@convex-dev/eslint-plugin": "^5.0.0",
110
111
  "@edge-runtime/vm": "^5.0.0",
111
112
  "@fantastic.dev/repo-gates": "^0.4.0",
113
+ "@shadcn/lint": "0.1.5",
114
+ "@shadscan/cli": "0.7.0",
112
115
  "@testing-library/jest-dom": "^7.0.1",
113
116
  "@testing-library/react": "^16.3.3",
114
117
  "@testing-library/user-event": "^14.6.7",
@@ -127,9 +130,7 @@
127
130
  "pretty-quick": "^4.2.2",
128
131
  "typescript": "npm:@typescript/typescript6@^6.0.2",
129
132
  "typescript-eslint": "^8.71.1",
130
- "vitest": "^5.0.3",
131
- "@shadcn/lint": "0.1.5",
132
- "@shadscan/cli": "0.7.0"
133
+ "vitest": "^5.0.3"
133
134
  },
134
135
  "peerDependencies": {
135
136
  "convex": "^1.29.3",
@@ -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
+ });
@@ -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
+ });