@fantastic.dev/better-stripe 0.10.2 → 0.12.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 +23 -0
- package/README.md +220 -4
- package/dist/client/billing/balanceFunding.d.ts +36 -0
- package/dist/client/billing/balanceFunding.d.ts.map +1 -0
- package/dist/client/billing/balanceFunding.js +107 -0
- package/dist/client/billing/balanceFunding.js.map +1 -0
- package/dist/client/billing/invoicePreview.d.ts +28 -0
- package/dist/client/billing/invoicePreview.d.ts.map +1 -0
- package/dist/client/billing/invoicePreview.js +56 -0
- package/dist/client/billing/invoicePreview.js.map +1 -0
- package/dist/client/charge-currency-format.d.ts +3 -0
- package/dist/client/charge-currency-format.d.ts.map +1 -0
- package/dist/client/charge-currency-format.js +22 -0
- package/dist/client/charge-currency-format.js.map +1 -0
- package/dist/client/charge-currency.d.ts +14 -0
- package/dist/client/charge-currency.d.ts.map +1 -0
- package/dist/client/charge-currency.js +85 -0
- package/dist/client/charge-currency.js.map +1 -0
- package/dist/client/costRecovery.d.ts +115 -0
- package/dist/client/costRecovery.d.ts.map +1 -0
- package/dist/client/costRecovery.js +50 -0
- package/dist/client/costRecovery.js.map +1 -0
- package/dist/client/index.d.ts +28 -64
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +25 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/products/prices.d.ts +4 -2
- package/dist/client/products/prices.d.ts.map +1 -1
- package/dist/client/products/prices.js +5 -4
- package/dist/client/products/prices.js.map +1 -1
- package/dist/client/products/products.d.ts +4 -2
- package/dist/client/products/products.d.ts.map +1 -1
- package/dist/client/products/products.js +7 -5
- package/dist/client/products/products.js.map +1 -1
- package/dist/client/settlement/processingCosts.d.ts +56 -0
- package/dist/client/settlement/processingCosts.d.ts.map +1 -0
- package/dist/client/settlement/processingCosts.js +173 -0
- package/dist/client/settlement/processingCosts.js.map +1 -0
- package/dist/client/utils/webhookEndpoints.d.ts +1 -1
- package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
- package/dist/client/utils/webhookEndpoints.js +1 -0
- package/dist/client/utils/webhookEndpoints.js.map +1 -1
- package/dist/client/webhooks/processors.d.ts.map +1 -1
- package/dist/client/webhooks/processors.js +1 -0
- package/dist/client/webhooks/processors.js.map +1 -1
- package/dist/component/_generated/component.d.ts +244 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/costRecovery/claims.d.ts +68 -0
- package/dist/component/costRecovery/claims.d.ts.map +1 -0
- package/dist/component/costRecovery/claims.js +85 -0
- package/dist/component/costRecovery/claims.js.map +1 -0
- package/dist/component/costRecovery/completion.d.ts +33 -0
- package/dist/component/costRecovery/completion.d.ts.map +1 -0
- package/dist/component/costRecovery/completion.js +69 -0
- package/dist/component/costRecovery/completion.js.map +1 -0
- package/dist/component/costRecovery/helpers.d.ts +62 -0
- package/dist/component/costRecovery/helpers.d.ts.map +1 -0
- package/dist/component/costRecovery/helpers.js +71 -0
- package/dist/component/costRecovery/helpers.js.map +1 -0
- package/dist/component/costRecovery/mutations.d.ts +138 -0
- package/dist/component/costRecovery/mutations.d.ts.map +1 -0
- package/dist/component/costRecovery/mutations.js +36 -0
- package/dist/component/costRecovery/mutations.js.map +1 -0
- package/dist/component/costRecovery/queries.d.ts +102 -0
- package/dist/component/costRecovery/queries.d.ts.map +1 -0
- package/dist/component/costRecovery/queries.js +73 -0
- package/dist/component/costRecovery/queries.js.map +1 -0
- package/dist/component/costRecovery/reservations.d.ts +83 -0
- package/dist/component/costRecovery/reservations.d.ts.map +1 -0
- package/dist/component/costRecovery/reservations.js +134 -0
- package/dist/component/costRecovery/reservations.js.map +1 -0
- package/dist/component/costRecovery/schema.d.ts +340 -0
- package/dist/component/costRecovery/schema.d.ts.map +1 -0
- package/dist/component/costRecovery/schema.js +109 -0
- package/dist/component/costRecovery/schema.js.map +1 -0
- package/dist/component/schema.d.ts +119 -0
- package/dist/component/schema.js +5 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/react/components/PriceCard.d.ts +3 -1
- package/dist/react/components/PriceCard.d.ts.map +1 -1
- package/dist/react/components/PriceCard.js +2 -2
- package/dist/react/components/PriceCard.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/price-helpers.d.ts +4 -6
- package/dist/react/lib/price-helpers.d.ts.map +1 -1
- package/dist/react/lib/price-helpers.js +16 -9
- package/dist/react/lib/price-helpers.js.map +1 -1
- package/dist/react/workflows/PlanPricingEditor.d.ts +4 -0
- package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -1
- package/dist/react/workflows/PlanPricingEditor.js +12 -9
- package/dist/react/workflows/PlanPricingEditor.js.map +1 -1
- package/docs/react-workflows.md +91 -0
- package/package.json +2 -2
- package/src/client/billing/balanceFunding.test.ts +257 -0
- package/src/client/billing/balanceFunding.ts +183 -0
- package/src/client/billing/balanceFundingTransport.test.ts +89 -0
- package/src/client/billing/invoicePreview.test.ts +123 -0
- package/src/client/billing/invoicePreview.ts +94 -0
- package/src/client/billing/invoicePreviewTransport.test.ts +70 -0
- package/src/client/charge-currency-format.ts +29 -0
- package/src/client/charge-currency.test.ts +113 -0
- package/src/client/charge-currency.ts +119 -0
- package/src/client/costRecovery.test.ts +76 -0
- package/src/client/costRecovery.ts +149 -0
- package/src/client/index.ts +76 -73
- package/src/client/products/catalog-idempotency.test.ts +116 -0
- package/src/client/products/prices.ts +18 -11
- package/src/client/products/products.ts +17 -11
- package/src/client/settlement/processingCosts.test.ts +349 -0
- package/src/client/settlement/processingCosts.ts +315 -0
- package/src/client/settlement/processingCostsTransport.test.ts +135 -0
- package/src/client/utils/webhookEndpoints.test.ts +5 -0
- package/src/client/utils/webhookEndpoints.ts +1 -0
- package/src/client/webhooks/hooks.test.ts +9 -0
- package/src/client/webhooks/payout-canceled.integration.test.ts +192 -0
- package/src/client/webhooks/processors.ts +1 -0
- package/src/component/_generated/api.ts +12 -0
- package/src/component/_generated/component.ts +308 -0
- package/src/component/cost-recovery.test.ts +344 -0
- package/src/component/costRecovery/claims.ts +109 -0
- package/src/component/costRecovery/completion.ts +92 -0
- package/src/component/costRecovery/helpers.ts +83 -0
- package/src/component/costRecovery/mutations.ts +43 -0
- package/src/component/costRecovery/queries.ts +88 -0
- package/src/component/costRecovery/reservations.ts +164 -0
- package/src/component/costRecovery/schema.ts +127 -0
- package/src/component/schema.ts +10 -0
- package/src/react/components/PayoutSchedule.render.test.tsx +15 -0
- package/src/react/components/PriceCard.render.test.tsx +37 -0
- package/src/react/components/PriceCard.tsx +9 -1
- package/src/react/index.test.ts +5 -0
- package/src/react/index.ts +7 -0
- package/src/react/lib/price-helpers.test.ts +15 -2
- package/src/react/lib/price-helpers.ts +25 -8
- package/src/react/workflows/Billing.test.tsx +1 -1
- package/src/react/workflows/PlanPricingEditor.currency.test.tsx +164 -0
- package/src/react/workflows/PlanPricingEditor.tsx +26 -10
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { defineTable } from "convex/server";
|
|
2
|
+
import { v } from "convex/values";
|
|
3
|
+
export const scopeFields = {
|
|
4
|
+
accountId: v.string(),
|
|
5
|
+
scopeKey: v.string(),
|
|
6
|
+
currency: v.string(),
|
|
7
|
+
};
|
|
8
|
+
export const totalsFields = {
|
|
9
|
+
amountDue: v.number(),
|
|
10
|
+
collectedAmount: v.number(),
|
|
11
|
+
reservedAmount: v.number(),
|
|
12
|
+
outstanding: v.number(),
|
|
13
|
+
};
|
|
14
|
+
export const signValidator = v.union(
|
|
15
|
+
v.literal("positive"),
|
|
16
|
+
v.literal("negative"),
|
|
17
|
+
v.literal("zero"),
|
|
18
|
+
);
|
|
19
|
+
export const observationFields = {
|
|
20
|
+
...scopeFields,
|
|
21
|
+
claimKey: v.string(),
|
|
22
|
+
sourceChargeId: v.string(),
|
|
23
|
+
sourceGross: v.number(),
|
|
24
|
+
sourceProcessingFee: v.number(),
|
|
25
|
+
amountDue: v.number(),
|
|
26
|
+
revision: v.number(),
|
|
27
|
+
};
|
|
28
|
+
export const claimFields = {
|
|
29
|
+
...observationFields,
|
|
30
|
+
collectedAmount: v.number(),
|
|
31
|
+
reservedAmount: v.number(),
|
|
32
|
+
outstanding: v.number(),
|
|
33
|
+
sign: signValidator,
|
|
34
|
+
};
|
|
35
|
+
export const claimDoc = v.object({
|
|
36
|
+
...claimFields,
|
|
37
|
+
_id: v.id("recoveryClaims"),
|
|
38
|
+
_creationTime: v.number(),
|
|
39
|
+
});
|
|
40
|
+
export const balanceFields = {
|
|
41
|
+
...scopeFields,
|
|
42
|
+
...totalsFields,
|
|
43
|
+
activeGroupKey: v.optional(v.string()),
|
|
44
|
+
};
|
|
45
|
+
export const groupInput = {
|
|
46
|
+
...scopeFields,
|
|
47
|
+
groupKey: v.string(),
|
|
48
|
+
sourceKind: v.union(v.literal("settlement"), v.literal("invoice")),
|
|
49
|
+
sourceId: v.string(),
|
|
50
|
+
sourceGross: v.optional(v.number()),
|
|
51
|
+
sourceProcessingFee: v.optional(v.number()),
|
|
52
|
+
maxDebit: v.number(),
|
|
53
|
+
maxCredit: v.number(),
|
|
54
|
+
};
|
|
55
|
+
export const groupFields = {
|
|
56
|
+
...groupInput,
|
|
57
|
+
amount: v.number(),
|
|
58
|
+
pendingOperations: v.number(),
|
|
59
|
+
status: v.union(
|
|
60
|
+
v.literal("open"),
|
|
61
|
+
v.literal("sealed"),
|
|
62
|
+
v.literal("collecting"),
|
|
63
|
+
v.literal("releasing"),
|
|
64
|
+
v.literal("collected"),
|
|
65
|
+
v.literal("released"),
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
export const groupDoc = v.object({
|
|
69
|
+
...groupFields,
|
|
70
|
+
_id: v.id("recoveryGroups"),
|
|
71
|
+
_creationTime: v.number(),
|
|
72
|
+
});
|
|
73
|
+
export const reservationFields = {
|
|
74
|
+
operationKey: v.string(),
|
|
75
|
+
groupKey: v.string(),
|
|
76
|
+
amount: v.number(),
|
|
77
|
+
limit: v.number(),
|
|
78
|
+
allocations: v.array(
|
|
79
|
+
v.object({
|
|
80
|
+
claimId: v.id("recoveryClaims"),
|
|
81
|
+
claimKey: v.string(),
|
|
82
|
+
amount: v.number(),
|
|
83
|
+
}),
|
|
84
|
+
),
|
|
85
|
+
status: v.union(
|
|
86
|
+
v.literal("reserved"),
|
|
87
|
+
v.literal("collected"),
|
|
88
|
+
v.literal("released"),
|
|
89
|
+
v.literal("empty"),
|
|
90
|
+
),
|
|
91
|
+
};
|
|
92
|
+
export const reservationDoc = v.object({
|
|
93
|
+
...reservationFields,
|
|
94
|
+
_id: v.id("recoveryReservations"),
|
|
95
|
+
_creationTime: v.number(),
|
|
96
|
+
});
|
|
97
|
+
export const claimsTable = defineTable(claimFields)
|
|
98
|
+
.index("by_key", ["claimKey"])
|
|
99
|
+
.index("by_scope", ["accountId", "scopeKey", "currency"])
|
|
100
|
+
.index("by_sign", ["accountId", "scopeKey", "currency", "sign"]);
|
|
101
|
+
export const balancesTable = defineTable(balanceFields).index("by_scope", [
|
|
102
|
+
"accountId",
|
|
103
|
+
"scopeKey",
|
|
104
|
+
"currency",
|
|
105
|
+
]);
|
|
106
|
+
export const groupsTable = defineTable(groupFields).index("by_key", [
|
|
107
|
+
"groupKey",
|
|
108
|
+
]);
|
|
109
|
+
export const reservationsTable = defineTable(reservationFields)
|
|
110
|
+
.index("by_key", ["operationKey"])
|
|
111
|
+
.index("by_group_status", ["groupKey", "status"]);
|
|
112
|
+
|
|
113
|
+
export const reserveArgs = {
|
|
114
|
+
...groupInput,
|
|
115
|
+
operationKey: v.string(),
|
|
116
|
+
limit: v.optional(v.number()),
|
|
117
|
+
};
|
|
118
|
+
export const finishArgs = {
|
|
119
|
+
groupKey: v.string(),
|
|
120
|
+
outcome: v.union(v.literal("collect"), v.literal("release")),
|
|
121
|
+
limit: v.optional(v.number()),
|
|
122
|
+
};
|
|
123
|
+
export const rollbackArgs = {
|
|
124
|
+
groupKey: v.string(),
|
|
125
|
+
returnedPrincipal: v.number(),
|
|
126
|
+
revision: v.number(),
|
|
127
|
+
};
|
package/src/component/schema.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
claimsTable,
|
|
3
|
+
balancesTable,
|
|
4
|
+
groupsTable,
|
|
5
|
+
reservationsTable,
|
|
6
|
+
} from "./costRecovery/schema";
|
|
1
7
|
import {
|
|
2
8
|
allocationsTable,
|
|
3
9
|
commissionTermsTable,
|
|
@@ -24,6 +30,10 @@ import { pricesTable, productsTable } from "./products/schema";
|
|
|
24
30
|
import { webhookEventsTable } from "./webhooks/schema";
|
|
25
31
|
|
|
26
32
|
export default defineSchema({
|
|
33
|
+
recoveryClaims: claimsTable,
|
|
34
|
+
recoveryBalances: balancesTable,
|
|
35
|
+
recoveryGroups: groupsTable,
|
|
36
|
+
recoveryReservations: reservationsTable,
|
|
27
37
|
settlementAllocations: allocationsTable,
|
|
28
38
|
commissionTerms: commissionTermsTable,
|
|
29
39
|
commissionDecisions: commissionDecisionsTable,
|
|
@@ -176,3 +176,18 @@ describe("PayoutSchedule", () => {
|
|
|
176
176
|
expect(BarrelExport).toBe(PayoutSchedule);
|
|
177
177
|
});
|
|
178
178
|
});
|
|
179
|
+
it.each(["ugx", "isk"])(
|
|
180
|
+
"preserves observed fractional %s balances",
|
|
181
|
+
(currency) => {
|
|
182
|
+
const html = renderToStaticMarkup(
|
|
183
|
+
createElement(PayoutSchedule, {
|
|
184
|
+
balance: { currency, available: 101, pending: -1 },
|
|
185
|
+
children: ({ availableLabel, pendingLabel }) =>
|
|
186
|
+
createElement("p", null, `${availableLabel}|${pendingLabel}`),
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
expect(html).toBe(
|
|
190
|
+
`<p>${currency.toUpperCase()} 1.01|-${currency.toUpperCase()} 0.01</p>`,
|
|
191
|
+
);
|
|
192
|
+
},
|
|
193
|
+
);
|
|
@@ -30,6 +30,43 @@ const basePrice: PriceCardPrice = {
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
describe("PriceCard", () => {
|
|
33
|
+
it("accepts a translated custom price label and locale", () => {
|
|
34
|
+
const html = renderToStaticMarkup(
|
|
35
|
+
createElement(PriceCard, {
|
|
36
|
+
price: { ...basePrice, unitAmount: undefined },
|
|
37
|
+
customPriceLabel: "Preis auf Anfrage",
|
|
38
|
+
locale: "de-DE",
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
expect(html).toContain("<p>Preis auf Anfrage</p>");
|
|
42
|
+
expect(
|
|
43
|
+
renderToStaticMarkup(
|
|
44
|
+
createElement(PriceCard, {
|
|
45
|
+
price: { ...basePrice, currency: "eur" },
|
|
46
|
+
locale: "de-DE",
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
).toContain("20,00");
|
|
50
|
+
});
|
|
51
|
+
it("shows a zero-decimal charge without dividing by 100", () => {
|
|
52
|
+
const html = renderToStaticMarkup(
|
|
53
|
+
createElement(PriceCard, {
|
|
54
|
+
price: { ...basePrice, currency: "jpy", unitAmount: 500 },
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
expect(html).toContain("<p>¥500/month</p>");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("does not present a custom price as free in render props", () => {
|
|
61
|
+
const html = renderToStaticMarkup(
|
|
62
|
+
createElement(PriceCard, {
|
|
63
|
+
price: { ...basePrice, unitAmount: null },
|
|
64
|
+
children: ({ formattedPrice }) =>
|
|
65
|
+
createElement("p", null, formattedPrice),
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
expect(html).toBe("<p>Custom price</p>");
|
|
69
|
+
});
|
|
33
70
|
it("renders the formatted price inside the default card with role=option", () => {
|
|
34
71
|
const html = renderToStaticMarkup(
|
|
35
72
|
createElement(PriceCard, { price: basePrice }),
|
|
@@ -23,6 +23,8 @@ export type PriceCardProps = {
|
|
|
23
23
|
/** i18n overrides */
|
|
24
24
|
currentPlanLabel?: string;
|
|
25
25
|
selectLabel?: string;
|
|
26
|
+
customPriceLabel?: string;
|
|
27
|
+
locale?: string;
|
|
26
28
|
/** Render prop for full customization */
|
|
27
29
|
children?: (props: {
|
|
28
30
|
price: PriceCardPrice;
|
|
@@ -45,10 +47,16 @@ export function PriceCard({
|
|
|
45
47
|
onSelect,
|
|
46
48
|
currentPlanLabel = "Current plan",
|
|
47
49
|
selectLabel = "Select",
|
|
50
|
+
customPriceLabel = "Custom price",
|
|
51
|
+
locale = "en-US",
|
|
48
52
|
children,
|
|
49
53
|
className,
|
|
50
54
|
}: PriceCardProps) {
|
|
51
|
-
const formattedPrice = formatPriceWithInterval(
|
|
55
|
+
const formattedPrice = formatPriceWithInterval(
|
|
56
|
+
price,
|
|
57
|
+
locale,
|
|
58
|
+
customPriceLabel,
|
|
59
|
+
);
|
|
52
60
|
|
|
53
61
|
if (children) {
|
|
54
62
|
return (
|
package/src/react/index.test.ts
CHANGED
|
@@ -7,6 +7,11 @@ import { describe, expect, it } from "vitest";
|
|
|
7
7
|
import * as ReactExports from "./index.js";
|
|
8
8
|
|
|
9
9
|
describe("better-stripe/react barrel export", () => {
|
|
10
|
+
it("exports shared charge helpers with actual zero-decimal behavior", () => {
|
|
11
|
+
expect(ReactExports.parseChargeAmount("500", "jpy")).toBe(500);
|
|
12
|
+
expect(ReactExports.formatChargeAmount(500, "jpy")).toBe("¥500");
|
|
13
|
+
expect(ReactExports.getChargeCurrencyMetadata("isk")?.apiDecimals).toBe(2);
|
|
14
|
+
});
|
|
10
15
|
it("exports all documented hook factories", () => {
|
|
11
16
|
const hookFactories = [
|
|
12
17
|
"createUseAccount",
|
package/src/react/index.ts
CHANGED
|
@@ -283,3 +283,10 @@ export {
|
|
|
283
283
|
|
|
284
284
|
// Complete unstyled workflows
|
|
285
285
|
export * from "./workflows/index.js";
|
|
286
|
+
export {
|
|
287
|
+
chargeAmountToDecimal,
|
|
288
|
+
getChargeCurrencyMetadata,
|
|
289
|
+
parseChargeAmount,
|
|
290
|
+
formatChargeAmount,
|
|
291
|
+
type ChargeCurrencyMetadata,
|
|
292
|
+
} from "../client/charge-currency.js";
|
|
@@ -8,6 +8,10 @@ import {
|
|
|
8
8
|
} from "./price-helpers.js";
|
|
9
9
|
|
|
10
10
|
describe("formatPrice", () => {
|
|
11
|
+
it("keeps fractional compatibility units from observed balances and costs", () => {
|
|
12
|
+
expect(formatPrice(101, "ugx")).toContain("1.01");
|
|
13
|
+
expect(formatPrice(-1, "isk")).toBe("-ISK 0.01");
|
|
14
|
+
});
|
|
11
15
|
it("formats USD cents to dollars", () => {
|
|
12
16
|
expect(formatPrice(2000, "usd")).toBe("$20.00");
|
|
13
17
|
});
|
|
@@ -32,6 +36,15 @@ describe("formatPrice", () => {
|
|
|
32
36
|
});
|
|
33
37
|
|
|
34
38
|
describe("formatPriceWithInterval", () => {
|
|
39
|
+
it("supports translated custom prices without fabricating an interval", () => {
|
|
40
|
+
expect(
|
|
41
|
+
formatPriceWithInterval(
|
|
42
|
+
{ currency: "usd", type: "recurring", interval: "month", active: true },
|
|
43
|
+
"en-US",
|
|
44
|
+
"Contact us",
|
|
45
|
+
),
|
|
46
|
+
).toBe("Contact us");
|
|
47
|
+
});
|
|
35
48
|
it("shows amount only for one_time prices", () => {
|
|
36
49
|
const result = formatPriceWithInterval({
|
|
37
50
|
unitAmount: 2900,
|
|
@@ -80,7 +93,7 @@ describe("formatPriceWithInterval", () => {
|
|
|
80
93
|
expect(result).toBe("$50.00 every 3 months");
|
|
81
94
|
});
|
|
82
95
|
|
|
83
|
-
it("
|
|
96
|
+
it("keeps a custom/null amount distinct from a zero price", () => {
|
|
84
97
|
const result = formatPriceWithInterval({
|
|
85
98
|
unitAmount: null,
|
|
86
99
|
currency: "usd",
|
|
@@ -89,7 +102,7 @@ describe("formatPriceWithInterval", () => {
|
|
|
89
102
|
intervalCount: null,
|
|
90
103
|
active: true,
|
|
91
104
|
});
|
|
92
|
-
expect(result).toBe("
|
|
105
|
+
expect(result).toBe("Custom price");
|
|
93
106
|
});
|
|
94
107
|
});
|
|
95
108
|
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Price formatting and filtering utilities.
|
|
3
3
|
*/
|
|
4
|
+
import {
|
|
5
|
+
formatChargeAmount,
|
|
6
|
+
getChargeCurrencyMetadata,
|
|
7
|
+
} from "../../client/charge-currency.js";
|
|
8
|
+
import { renderCurrencyUnits } from "../../client/charge-currency-format.js";
|
|
4
9
|
|
|
5
10
|
type PriceLike = {
|
|
6
11
|
unitAmount?: number | null;
|
|
@@ -12,19 +17,29 @@ type PriceLike = {
|
|
|
12
17
|
};
|
|
13
18
|
|
|
14
19
|
/**
|
|
15
|
-
*
|
|
20
|
+
* Legacy display helper for observed ordinary account/charge units.
|
|
21
|
+
* Preserves fractional ISK/UGX balances; does not validate charge eligibility.
|
|
16
22
|
* e.g. 2000, 'usd' → '$20.00'
|
|
17
23
|
*/
|
|
18
24
|
export function formatPrice(
|
|
19
|
-
|
|
25
|
+
amount: number,
|
|
20
26
|
currency: string,
|
|
21
27
|
locale = "en-US",
|
|
22
28
|
): string {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
const metadata = getChargeCurrencyMetadata(currency);
|
|
30
|
+
if (!metadata || !Number.isSafeInteger(amount))
|
|
31
|
+
throw new RangeError("Invalid amount or unsupported currency");
|
|
32
|
+
const displayDecimals =
|
|
33
|
+
amount % 10 ** metadata.apiDecimals === 0
|
|
34
|
+
? metadata.displayDecimals
|
|
35
|
+
: metadata.apiDecimals;
|
|
36
|
+
return renderCurrencyUnits(
|
|
37
|
+
amount,
|
|
38
|
+
metadata.currency,
|
|
39
|
+
metadata.apiDecimals,
|
|
40
|
+
displayDecimals,
|
|
41
|
+
locale,
|
|
42
|
+
);
|
|
28
43
|
}
|
|
29
44
|
|
|
30
45
|
/**
|
|
@@ -34,8 +49,10 @@ export function formatPrice(
|
|
|
34
49
|
export function formatPriceWithInterval(
|
|
35
50
|
price: PriceLike,
|
|
36
51
|
locale = "en-US",
|
|
52
|
+
customPriceLabel = "Custom price",
|
|
37
53
|
): string {
|
|
38
|
-
|
|
54
|
+
if (price.unitAmount == null) return customPriceLabel;
|
|
55
|
+
const amount = formatChargeAmount(price.unitAmount, price.currency, locale);
|
|
39
56
|
if (price.type === "one_time" || !price.interval) {
|
|
40
57
|
return amount;
|
|
41
58
|
}
|
|
@@ -34,7 +34,7 @@ it("loads an itemized estimate only when requested and displays the server total
|
|
|
34
34
|
nextBillingAt: 1800000000000,
|
|
35
35
|
lines: [
|
|
36
36
|
{ id: "plan", description: "Store plan", amount: 2900 },
|
|
37
|
-
{ id: "cost", description: "Refund costs", amount:
|
|
37
|
+
{ id: "cost", description: "Refund costs", amount: 700 },
|
|
38
38
|
],
|
|
39
39
|
});
|
|
40
40
|
render(<InvoiceEstimate scopeKey="seller" load={load} />);
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import "@testing-library/jest-dom/vitest";
|
|
3
|
+
import type { ComponentProps } from "react";
|
|
4
|
+
import { act, cleanup, render, screen } from "@testing-library/react";
|
|
5
|
+
import userEvent from "@testing-library/user-event";
|
|
6
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
7
|
+
import { PlanPricingEditor } from "./PlanPricingEditor.js";
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
const plan = {
|
|
10
|
+
id: "plan",
|
|
11
|
+
name: "Plan",
|
|
12
|
+
monthlyAmount: 500,
|
|
13
|
+
feeBasisPoints: 500,
|
|
14
|
+
affiliateBasisPoints: 4000,
|
|
15
|
+
version: 1,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
it.each([
|
|
19
|
+
["usd", 500, "5.00", "12.34", 1234, "decimal"],
|
|
20
|
+
["jpy", 500, "500", "600", 600, "numeric"],
|
|
21
|
+
["mga", 500, "500", "600", 600, "numeric"],
|
|
22
|
+
["isk", 50000, "500", "600", 60000, "numeric"],
|
|
23
|
+
["ugx", 50000, "500", "600", 60000, "numeric"],
|
|
24
|
+
["huf", 1045, "10.45", "12.34", 1234, "decimal"],
|
|
25
|
+
["twd", 1045, "10.45", "12.34", 1234, "decimal"],
|
|
26
|
+
[
|
|
27
|
+
"usd",
|
|
28
|
+
Number.MAX_SAFE_INTEGER,
|
|
29
|
+
"90071992547409.91",
|
|
30
|
+
"90071992547409.91",
|
|
31
|
+
Number.MAX_SAFE_INTEGER,
|
|
32
|
+
"decimal",
|
|
33
|
+
],
|
|
34
|
+
])(
|
|
35
|
+
"initializes and saves exact %s provider price units",
|
|
36
|
+
async (currency, monthlyAmount, initial, input, expected, inputMode) => {
|
|
37
|
+
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
38
|
+
render(
|
|
39
|
+
<PlanPricingEditor
|
|
40
|
+
plan={{ ...plan, currency, monthlyAmount }}
|
|
41
|
+
onSave={onSave}
|
|
42
|
+
/>,
|
|
43
|
+
);
|
|
44
|
+
const field = screen.getByLabelText(
|
|
45
|
+
`Monthly price (${currency.toUpperCase()})`,
|
|
46
|
+
);
|
|
47
|
+
expect(field).toHaveValue(initial);
|
|
48
|
+
expect(field).toHaveAttribute("inputmode", inputMode);
|
|
49
|
+
await userEvent.clear(field);
|
|
50
|
+
await userEvent.type(field, input);
|
|
51
|
+
await userEvent.click(screen.getByRole("button", { name: "Save pricing" }));
|
|
52
|
+
expect(onSave).toHaveBeenCalledWith({
|
|
53
|
+
monthlyAmount: expected,
|
|
54
|
+
feeBasisPoints: 500,
|
|
55
|
+
affiliateBasisPoints: 4000,
|
|
56
|
+
expectedVersion: 1,
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
it.each([
|
|
62
|
+
["jpy", "0.5"],
|
|
63
|
+
["mga", "1.5"],
|
|
64
|
+
["isk", "1.01"],
|
|
65
|
+
["ugx", "1.01"],
|
|
66
|
+
["usd", "1.005"],
|
|
67
|
+
["usd", "90071992547409.92"],
|
|
68
|
+
["usd", "0"],
|
|
69
|
+
["usd", "1e2"],
|
|
70
|
+
["ZZZ", "1"],
|
|
71
|
+
["bhd", "1"],
|
|
72
|
+
])(
|
|
73
|
+
"rejects invalid %s price %s before the callback",
|
|
74
|
+
async (currency, value) => {
|
|
75
|
+
const onSave = vi.fn();
|
|
76
|
+
render(<PlanPricingEditor plan={{ ...plan, currency }} onSave={onSave} />);
|
|
77
|
+
const field = screen.getByLabelText(
|
|
78
|
+
`Monthly price (${currency.toUpperCase()})`,
|
|
79
|
+
);
|
|
80
|
+
await userEvent.clear(field);
|
|
81
|
+
await userEvent.type(field, value);
|
|
82
|
+
await userEvent.click(screen.getByRole("button", { name: "Save pricing" }));
|
|
83
|
+
expect(screen.getByRole("alert")).toBeTruthy();
|
|
84
|
+
expect(onSave).not.toHaveBeenCalled();
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
it("resets currency-scoped headless fields and ignores a late previous save", async () => {
|
|
89
|
+
let resolveOld: (() => void) | undefined;
|
|
90
|
+
const onSave = vi
|
|
91
|
+
.fn()
|
|
92
|
+
.mockImplementationOnce(
|
|
93
|
+
() =>
|
|
94
|
+
new Promise<void>((resolve) => {
|
|
95
|
+
resolveOld = resolve;
|
|
96
|
+
}),
|
|
97
|
+
)
|
|
98
|
+
.mockResolvedValue(undefined);
|
|
99
|
+
const children = (
|
|
100
|
+
state: Parameters<
|
|
101
|
+
NonNullable<ComponentProps<typeof PlanPricingEditor>["children"]>
|
|
102
|
+
>[0],
|
|
103
|
+
) => (
|
|
104
|
+
<>
|
|
105
|
+
<output>
|
|
106
|
+
{state.monthlyPrice}|{state.isPending ? "pending" : "idle"}|
|
|
107
|
+
{state.message ?? ""}
|
|
108
|
+
</output>
|
|
109
|
+
<button onClick={() => void state.save()}>Save</button>
|
|
110
|
+
<button onClick={() => state.setMonthlyPrice("777")}>Edit</button>
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
const { rerender } = render(
|
|
114
|
+
<PlanPricingEditor plan={{ ...plan, currency: "usd" }} onSave={onSave}>
|
|
115
|
+
{children}
|
|
116
|
+
</PlanPricingEditor>,
|
|
117
|
+
);
|
|
118
|
+
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
119
|
+
expect(screen.getByRole("status")).toHaveTextContent("pending");
|
|
120
|
+
rerender(
|
|
121
|
+
<PlanPricingEditor plan={{ ...plan, currency: "jpy" }} onSave={onSave}>
|
|
122
|
+
{children}
|
|
123
|
+
</PlanPricingEditor>,
|
|
124
|
+
);
|
|
125
|
+
expect(screen.getByRole("status")).toHaveTextContent("500|idle|");
|
|
126
|
+
await act(async () => resolveOld?.());
|
|
127
|
+
expect(screen.getByRole("status")).toHaveTextContent("500|idle|");
|
|
128
|
+
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
129
|
+
expect(onSave).toHaveBeenLastCalledWith({
|
|
130
|
+
monthlyAmount: 500,
|
|
131
|
+
feeBasisPoints: 500,
|
|
132
|
+
affiliateBasisPoints: 4000,
|
|
133
|
+
expectedVersion: 1,
|
|
134
|
+
});
|
|
135
|
+
await userEvent.click(screen.getByRole("button", { name: "Edit" }));
|
|
136
|
+
rerender(
|
|
137
|
+
<PlanPricingEditor
|
|
138
|
+
plan={{ ...plan, currency: "jpy", version: 2, monthlyAmount: 600 }}
|
|
139
|
+
onSave={onSave}
|
|
140
|
+
>
|
|
141
|
+
{children}
|
|
142
|
+
</PlanPricingEditor>,
|
|
143
|
+
);
|
|
144
|
+
expect(screen.getByRole("status")).toHaveTextContent("600|idle|");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("keeps currency-aware retries on the same plan version", async () => {
|
|
148
|
+
const onSave = vi
|
|
149
|
+
.fn()
|
|
150
|
+
.mockRejectedValueOnce(new Error("Retry"))
|
|
151
|
+
.mockResolvedValue(undefined);
|
|
152
|
+
render(
|
|
153
|
+
<PlanPricingEditor plan={{ ...plan, currency: "jpy" }} onSave={onSave} />,
|
|
154
|
+
);
|
|
155
|
+
await userEvent.click(screen.getByRole("button", { name: "Save pricing" }));
|
|
156
|
+
expect(screen.getByRole("alert")).toHaveTextContent("Retry");
|
|
157
|
+
await userEvent.click(screen.getByRole("button", { name: "Save pricing" }));
|
|
158
|
+
expect(onSave).toHaveBeenNthCalledWith(2, {
|
|
159
|
+
monthlyAmount: 500,
|
|
160
|
+
feeBasisPoints: 500,
|
|
161
|
+
affiliateBasisPoints: 4000,
|
|
162
|
+
expectedVersion: 1,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useId, useState, type ReactNode } from "react";
|
|
3
|
+
import {
|
|
4
|
+
chargeAmountToDecimal,
|
|
5
|
+
getChargeCurrencyMetadata,
|
|
6
|
+
parseChargeAmount,
|
|
7
|
+
} from "../../client/charge-currency.js";
|
|
3
8
|
import { useWorkflowAction } from "./useWorkflowAction.js";
|
|
4
9
|
export type PlanPricing = {
|
|
5
10
|
id: string;
|
|
6
11
|
name: string;
|
|
7
12
|
monthlyAmount: number;
|
|
13
|
+
/** Defaults to USD for existing consumers. Charge/Price currency, not payout units. */
|
|
14
|
+
currency?: string;
|
|
8
15
|
feeBasisPoints: number | null;
|
|
9
16
|
affiliateBasisPoints?: number | null;
|
|
10
17
|
version: number;
|
|
@@ -16,6 +23,8 @@ export type PlanPricingUpdate = {
|
|
|
16
23
|
expectedVersion: number;
|
|
17
24
|
};
|
|
18
25
|
export type PlanPricingEditorState = {
|
|
26
|
+
currency: string;
|
|
27
|
+
inputDecimalPlaces: 0 | 2 | null;
|
|
19
28
|
monthlyPrice: string;
|
|
20
29
|
saleFee: string;
|
|
21
30
|
affiliateShare: string;
|
|
@@ -39,24 +48,27 @@ export function PlanPricingEditor({
|
|
|
39
48
|
children,
|
|
40
49
|
}: PlanPricingEditorProps) {
|
|
41
50
|
const id = useId();
|
|
42
|
-
const
|
|
51
|
+
const currency = (plan.currency ?? "usd").toLowerCase();
|
|
52
|
+
const metadata = getChargeCurrencyMetadata(currency);
|
|
53
|
+
const scope = `${plan.id}:${plan.version}:${currency}`;
|
|
43
54
|
const initial = {
|
|
44
55
|
scope,
|
|
45
56
|
affiliateShare: String((plan.affiliateBasisPoints ?? 0) / 100),
|
|
46
|
-
monthlyPrice: (plan.monthlyAmount
|
|
57
|
+
monthlyPrice: chargeAmountToDecimal(plan.monthlyAmount, currency) ?? "",
|
|
47
58
|
saleFee:
|
|
48
59
|
plan.feeBasisPoints === null ? "" : String(plan.feeBasisPoints / 100),
|
|
49
60
|
};
|
|
50
61
|
const [fields, setFields] = useState(initial);
|
|
51
62
|
if (fields.scope !== scope) setFields(initial);
|
|
52
63
|
const action = useWorkflowAction(async () => {
|
|
53
|
-
|
|
64
|
+
const monthlyAmount = parseChargeAmount(
|
|
65
|
+
fields.monthlyPrice.trim(),
|
|
66
|
+
currency,
|
|
67
|
+
);
|
|
68
|
+
if (monthlyAmount === null || monthlyAmount <= 0)
|
|
54
69
|
throw new Error(
|
|
55
|
-
"Enter a positive monthly price
|
|
70
|
+
"Enter a positive monthly price in supported currency units.",
|
|
56
71
|
);
|
|
57
|
-
const monthlyAmount = Math.round(Number(fields.monthlyPrice) * 100);
|
|
58
|
-
if (!Number.isSafeInteger(monthlyAmount) || monthlyAmount <= 0)
|
|
59
|
-
throw new Error("Enter a positive monthly price.");
|
|
60
72
|
const feeBasisPoints =
|
|
61
73
|
fields.saleFee.trim() === ""
|
|
62
74
|
? null
|
|
@@ -90,8 +102,10 @@ export function PlanPricingEditor({
|
|
|
90
102
|
expectedVersion: plan.version,
|
|
91
103
|
});
|
|
92
104
|
return "Pricing saved. Existing subscriptions keep their monthly price.";
|
|
93
|
-
},
|
|
105
|
+
}, scope);
|
|
94
106
|
const state: PlanPricingEditorState = {
|
|
107
|
+
currency,
|
|
108
|
+
inputDecimalPlaces: metadata?.displayDecimals ?? null,
|
|
95
109
|
...fields,
|
|
96
110
|
setMonthlyPrice: (monthlyPrice) =>
|
|
97
111
|
setFields((previous) => ({ ...previous, monthlyPrice })),
|
|
@@ -114,10 +128,12 @@ export function PlanPricingEditor({
|
|
|
114
128
|
aria-label={`${plan.name} pricing`}
|
|
115
129
|
>
|
|
116
130
|
<h3>{plan.name}</h3>
|
|
117
|
-
<label htmlFor={`${id}-monthly`}>
|
|
131
|
+
<label htmlFor={`${id}-monthly`}>
|
|
132
|
+
Monthly price ({currency.toUpperCase()})
|
|
133
|
+
</label>
|
|
118
134
|
<input
|
|
119
135
|
id={`${id}-monthly`}
|
|
120
|
-
inputMode="decimal"
|
|
136
|
+
inputMode={state.inputDecimalPlaces === 0 ? "numeric" : "decimal"}
|
|
121
137
|
value={state.monthlyPrice}
|
|
122
138
|
disabled={state.isPending}
|
|
123
139
|
onChange={(event) => state.setMonthlyPrice(event.target.value)}
|