@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.
- package/CHANGELOG.md +6 -0
- package/README.md +18 -2
- package/dist/component/_generated/component.d.ts +35 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/connect/queries.d.ts +30 -1
- package/dist/component/connect/queries.d.ts.map +1 -1
- package/dist/component/connect/queries.js +19 -2
- package/dist/component/connect/queries.js.map +1 -1
- package/dist/react/workflows/AffiliatePayoutEditor.d.ts +41 -0
- package/dist/react/workflows/AffiliatePayoutEditor.d.ts.map +1 -0
- package/dist/react/workflows/AffiliatePayoutEditor.js +80 -0
- package/dist/react/workflows/AffiliatePayoutEditor.js.map +1 -0
- package/dist/react/workflows/AffiliateRateEditor.d.ts +26 -0
- package/dist/react/workflows/AffiliateRateEditor.d.ts.map +1 -0
- package/dist/react/workflows/AffiliateRateEditor.js +49 -0
- package/dist/react/workflows/AffiliateRateEditor.js.map +1 -0
- package/dist/react/workflows/CheckoutForms.d.ts +2 -0
- package/dist/react/workflows/CheckoutForms.d.ts.map +1 -1
- package/dist/react/workflows/CheckoutForms.js +22 -16
- package/dist/react/workflows/CheckoutForms.js.map +1 -1
- package/dist/react/workflows/IntegrationSwitch.js +2 -2
- package/dist/react/workflows/IntegrationSwitch.js.map +1 -1
- package/dist/react/workflows/Pagination.d.ts +32 -0
- package/dist/react/workflows/Pagination.d.ts.map +1 -0
- package/dist/react/workflows/Pagination.js +49 -0
- package/dist/react/workflows/Pagination.js.map +1 -0
- package/dist/react/workflows/PlanPricingEditor.d.ts +35 -0
- package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -0
- package/dist/react/workflows/PlanPricingEditor.js +64 -0
- package/dist/react/workflows/PlanPricingEditor.js.map +1 -0
- package/dist/react/workflows/index.d.ts +8 -0
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +4 -0
- package/dist/react/workflows/index.js.map +1 -1
- package/docs/react-workflows-audit.md +19 -0
- package/docs/react-workflows.md +89 -1
- package/package.json +5 -4
- package/src/component/_generated/component.ts +41 -0
- package/src/component/connect/queries.ts +26 -2
- package/src/component/earnings-totals.test.ts +13 -0
- package/src/component/transfers.test.ts +48 -0
- package/src/react/workflows/AffiliatePayoutEditor.test.tsx +90 -0
- package/src/react/workflows/AffiliatePayoutEditor.tsx +201 -0
- package/src/react/workflows/AffiliateRateEditor.test.tsx +36 -0
- package/src/react/workflows/AffiliateRateEditor.tsx +92 -0
- package/src/react/workflows/CheckoutForms.test.tsx +40 -0
- package/src/react/workflows/CheckoutForms.tsx +37 -16
- package/src/react/workflows/IntegrationSwitch.test.tsx +25 -0
- package/src/react/workflows/IntegrationSwitch.tsx +2 -2
- package/src/react/workflows/Pagination.test.tsx +53 -0
- package/src/react/workflows/Pagination.tsx +93 -0
- package/src/react/workflows/PlanPricingEditor.test.tsx +79 -0
- package/src/react/workflows/PlanPricingEditor.tsx +156 -0
- package/src/react/workflows/index.ts +22 -0
package/docs/react-workflows.md
CHANGED
|
@@ -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
|
-
##
|
|
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.
|
|
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
|
+
});
|