@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.
- package/CHANGELOG.md +12 -0
- package/README.md +19 -3
- package/dist/client/connect/disputes.d.ts +1 -1
- package/dist/client/connect/disputes.d.ts.map +1 -1
- package/dist/client/connect/disputes.js +12 -6
- package/dist/client/connect/disputes.js.map +1 -1
- 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/components/DisputeDetail.d.ts +4 -1
- package/dist/react/components/DisputeDetail.d.ts.map +1 -1
- package/dist/react/components/DisputeDetail.js +12 -6
- package/dist/react/components/DisputeDetail.js.map +1 -1
- package/dist/react/components/DisputesList.d.ts.map +1 -1
- package/dist/react/components/DisputesList.js +2 -1
- package/dist/react/components/DisputesList.js.map +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -1
- package/dist/react/index.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/Disputes.d.ts.map +1 -1
- package/dist/react/workflows/Disputes.js +9 -9
- package/dist/react/workflows/Disputes.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/Payouts.d.ts +1 -0
- package/dist/react/workflows/Payouts.d.ts.map +1 -1
- package/dist/react/workflows/Payouts.js +3 -2
- package/dist/react/workflows/Payouts.js.map +1 -1
- 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 +41 -0
- package/docs/react-workflows.md +92 -1
- package/package.json +5 -4
- package/src/client/connect/disputes.test.ts +15 -0
- package/src/client/connect/disputes.ts +13 -7
- 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/components/DisputeDetail.tsx +14 -5
- package/src/react/components/DisputesList.render.test.tsx +21 -0
- package/src/react/components/DisputesList.tsx +2 -1
- package/src/react/components/EvidenceForm.render.test.tsx +1 -2
- package/src/react/index.ts +4 -1
- 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/Disputes.test.tsx +41 -2
- package/src/react/workflows/Disputes.tsx +49 -40
- 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/Payouts.test.tsx +30 -0
- package/src/react/workflows/Payouts.tsx +4 -1
- 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
|
@@ -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 =
|
|
74
|
-
?
|
|
75
|
-
: countdown.
|
|
76
|
-
?
|
|
77
|
-
:
|
|
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.
|
package/src/react/index.ts
CHANGED
|
@@ -163,7 +163,10 @@ export type {
|
|
|
163
163
|
DisputesListRow,
|
|
164
164
|
DisputesListRowContext,
|
|
165
165
|
} from "./components/DisputesList.js";
|
|
166
|
-
export {
|
|
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
|
+
});
|