@fantastic.dev/better-stripe 0.11.0 → 0.13.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 +30 -0
- package/README.md +267 -2
- 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-invoices.d.ts +38 -0
- package/dist/client/costRecovery-invoices.d.ts.map +1 -0
- package/dist/client/costRecovery-invoices.js +105 -0
- package/dist/client/costRecovery-invoices.js.map +1 -0
- package/dist/client/costRecovery.d.ts +156 -0
- package/dist/client/costRecovery.d.ts.map +1 -0
- package/dist/client/costRecovery.js +117 -0
- package/dist/client/costRecovery.js.map +1 -0
- package/dist/client/index.d.ts +28 -66
- 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 +3 -2
- package/dist/client/products/prices.d.ts.map +1 -1
- package/dist/client/products/prices.js +0 -3
- package/dist/client/products/prices.js.map +1 -1
- package/dist/client/products/products.d.ts +3 -2
- package/dist/client/products/products.d.ts.map +1 -1
- package/dist/client/products/products.js +0 -3
- 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 +322 -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 +92 -0
- package/dist/component/costRecovery/claims.js.map +1 -0
- package/dist/component/costRecovery/completion.d.ts +39 -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 +68 -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 +210 -0
- package/dist/component/costRecovery/mutations.d.ts.map +1 -0
- package/dist/component/costRecovery/mutations.js +93 -0
- package/dist/component/costRecovery/mutations.js.map +1 -0
- package/dist/component/costRecovery/queries.d.ts +108 -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 +95 -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 +382 -0
- package/dist/component/costRecovery/schema.d.ts.map +1 -0
- package/dist/component/costRecovery/schema.js +115 -0
- package/dist/component/costRecovery/schema.js.map +1 -0
- package/dist/component/schema.d.ts +135 -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/Collections.d.ts.map +1 -1
- package/dist/react/workflows/Collections.js +2 -1
- package/dist/react/workflows/Collections.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/dist/react/workflows/Refunds.d.ts +14 -3
- package/dist/react/workflows/Refunds.d.ts.map +1 -1
- package/dist/react/workflows/Refunds.js +58 -16
- package/dist/react/workflows/Refunds.js.map +1 -1
- package/dist/react/workflows/index.d.ts +3 -1
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +2 -1
- package/dist/react/workflows/index.js.map +1 -1
- package/dist/react/workflows/refund-preview.d.ts +16 -0
- package/dist/react/workflows/refund-preview.d.ts.map +1 -0
- package/dist/react/workflows/refund-preview.js +38 -0
- package/dist/react/workflows/refund-preview.js.map +1 -0
- package/docs/react-workflows.md +167 -1
- 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-invoices.test.ts +401 -0
- package/src/client/costRecovery-invoices.transport.test.ts +107 -0
- package/src/client/costRecovery-invoices.ts +144 -0
- package/src/client/costRecovery.test.ts +76 -0
- package/src/client/costRecovery.ts +254 -0
- package/src/client/index.ts +76 -75
- package/src/client/products/prices.ts +13 -11
- package/src/client/products/products.ts +10 -10
- 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 +407 -0
- package/src/component/cost-recovery.test.ts +344 -0
- package/src/component/costRecovery/claims.ts +119 -0
- package/src/component/costRecovery/completion.ts +92 -0
- package/src/component/costRecovery/helpers.ts +83 -0
- package/src/component/costRecovery/mutations.ts +115 -0
- package/src/component/costRecovery/queries.ts +88 -0
- package/src/component/costRecovery/reservations.ts +164 -0
- package/src/component/costRecovery/schema.ts +135 -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/Collections.test.tsx +36 -0
- package/src/react/workflows/Collections.tsx +3 -1
- package/src/react/workflows/PlanPricingEditor.currency.test.tsx +164 -0
- package/src/react/workflows/PlanPricingEditor.tsx +26 -10
- package/src/react/workflows/Refunds.currency.test.tsx +290 -0
- package/src/react/workflows/Refunds.tsx +115 -25
- package/src/react/workflows/index.ts +8 -1
- package/src/react/workflows/refund-preview.test.ts +65 -0
- package/src/react/workflows/refund-preview.ts +56 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { resolveOwnerAccount } from "../utils/owner.js";
|
|
3
|
+
|
|
4
|
+
export type UpcomingInvoicePreviewOptions = {
|
|
5
|
+
/** Host must authorize this customer account before calling. */
|
|
6
|
+
customerAccount: string;
|
|
7
|
+
stripeSubscriptionId: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Provider amounts are integer minor units; all dates are milliseconds. */
|
|
11
|
+
export type UpcomingInvoicePreview = {
|
|
12
|
+
stripeInvoiceId: string;
|
|
13
|
+
currency: string;
|
|
14
|
+
total: number;
|
|
15
|
+
subtotal: number;
|
|
16
|
+
amountDue: number;
|
|
17
|
+
nextBillingAt: number;
|
|
18
|
+
periodStart: number;
|
|
19
|
+
periodEnd: number;
|
|
20
|
+
linesComplete: true;
|
|
21
|
+
lines: {
|
|
22
|
+
id: string;
|
|
23
|
+
description: string;
|
|
24
|
+
amount: number;
|
|
25
|
+
currency: string;
|
|
26
|
+
periodStart: number;
|
|
27
|
+
periodEnd: number;
|
|
28
|
+
}[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function integer(value: number): number {
|
|
32
|
+
if (!Number.isSafeInteger(value))
|
|
33
|
+
throw new Error("Invoice preview amounts and dates must be safe integers.");
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function getUpcomingInvoicePreview(
|
|
38
|
+
stripe: Stripe,
|
|
39
|
+
opts: UpcomingInvoicePreviewOptions,
|
|
40
|
+
): Promise<UpcomingInvoicePreview> {
|
|
41
|
+
if (!opts.customerAccount.startsWith("acct_") || !opts.stripeSubscriptionId)
|
|
42
|
+
throw new Error("A customer account and subscription are required.");
|
|
43
|
+
const subscription = await stripe.subscriptions.retrieve(
|
|
44
|
+
opts.stripeSubscriptionId,
|
|
45
|
+
);
|
|
46
|
+
if (resolveOwnerAccount(subscription) !== opts.customerAccount)
|
|
47
|
+
throw new Error("Subscription does not belong to the customer account.");
|
|
48
|
+
const preview = await stripe.invoices.createPreview({
|
|
49
|
+
customer_account: opts.customerAccount,
|
|
50
|
+
subscription: opts.stripeSubscriptionId,
|
|
51
|
+
});
|
|
52
|
+
const lines: UpcomingInvoicePreview["lines"] = [];
|
|
53
|
+
const append = (line: Stripe.InvoiceLineItem) => {
|
|
54
|
+
if (line.currency !== preview.currency)
|
|
55
|
+
throw new Error(
|
|
56
|
+
"Invoice preview line currency does not match the invoice.",
|
|
57
|
+
);
|
|
58
|
+
lines.push({
|
|
59
|
+
id: line.id,
|
|
60
|
+
description: line.description ?? "Subscription",
|
|
61
|
+
amount: integer(line.amount),
|
|
62
|
+
currency: line.currency,
|
|
63
|
+
periodStart: integer(line.period.start * 1000),
|
|
64
|
+
periodEnd: integer(line.period.end * 1000),
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
if (preview.lines.has_more) {
|
|
68
|
+
for await (const line of stripe.invoices.listLineItems(preview.id, {
|
|
69
|
+
limit: 100,
|
|
70
|
+
}))
|
|
71
|
+
append(line);
|
|
72
|
+
} else {
|
|
73
|
+
preview.lines.data.forEach(append);
|
|
74
|
+
}
|
|
75
|
+
const renewalDates = subscription.items.data.map(
|
|
76
|
+
(item) => item.current_period_end,
|
|
77
|
+
);
|
|
78
|
+
const nextBillingAt =
|
|
79
|
+
preview.next_payment_attempt ??
|
|
80
|
+
(subscription.status === "trialing" ? subscription.trial_end : null) ??
|
|
81
|
+
Math.min(...renewalDates);
|
|
82
|
+
return {
|
|
83
|
+
stripeInvoiceId: preview.id,
|
|
84
|
+
currency: preview.currency,
|
|
85
|
+
total: integer(preview.total),
|
|
86
|
+
subtotal: integer(preview.subtotal),
|
|
87
|
+
amountDue: integer(preview.amount_due),
|
|
88
|
+
nextBillingAt: integer(nextBillingAt * 1000),
|
|
89
|
+
periodStart: integer(preview.period_start * 1000),
|
|
90
|
+
periodEnd: integer(preview.period_end * 1000),
|
|
91
|
+
linesComplete: true,
|
|
92
|
+
lines,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type Stripe from "stripe";
|
|
2
|
+
import { componentsGeneric } from "convex/server";
|
|
3
|
+
import { expect, it, vi } from "vitest";
|
|
4
|
+
import { BetterStripe } from "../index.js";
|
|
5
|
+
import { getStripeClient, type Component, type RunCtx } from "../helpers.js";
|
|
6
|
+
|
|
7
|
+
it.each([undefined, "2026-09-30.preview"] as const)(
|
|
8
|
+
"uses the configured API version for subscription and preview reads: %s",
|
|
9
|
+
async (version) => {
|
|
10
|
+
const key = `sk_test_invoice_transport_${crypto.randomUUID()}`;
|
|
11
|
+
const bs = new BetterStripe(
|
|
12
|
+
componentsGeneric().betterStripe as unknown as Component,
|
|
13
|
+
{
|
|
14
|
+
STRIPE_SECRET_KEY: key,
|
|
15
|
+
stripeApiVersion: version,
|
|
16
|
+
},
|
|
17
|
+
);
|
|
18
|
+
const versions: string[] = [];
|
|
19
|
+
const sdk = getStripeClient(key, version);
|
|
20
|
+
(
|
|
21
|
+
sdk as unknown as { _setApiField: (name: string, value: unknown) => void }
|
|
22
|
+
)._setApiField("httpClient", {
|
|
23
|
+
getClientName: () => "invoice-contract-test",
|
|
24
|
+
makeRequest: async (
|
|
25
|
+
...[, , path, , headers]: Parameters<
|
|
26
|
+
ReturnType<typeof Stripe.createNodeHttpClient>["makeRequest"]
|
|
27
|
+
>
|
|
28
|
+
) => {
|
|
29
|
+
versions.push(String(headers["Stripe-Version"]));
|
|
30
|
+
const data = path.includes("/subscriptions/")
|
|
31
|
+
? {
|
|
32
|
+
id: "sub_test",
|
|
33
|
+
customer_account: "acct_buyer",
|
|
34
|
+
items: { data: [{ current_period_end: 1800000000 }] },
|
|
35
|
+
}
|
|
36
|
+
: {
|
|
37
|
+
id: "in_preview",
|
|
38
|
+
currency: "usd",
|
|
39
|
+
total: 100,
|
|
40
|
+
subtotal: 100,
|
|
41
|
+
amount_due: 100,
|
|
42
|
+
period_start: 1800000000,
|
|
43
|
+
period_end: 1802592000,
|
|
44
|
+
lines: { data: [], has_more: false },
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
getStatusCode: () => 200,
|
|
48
|
+
getHeaders: () => ({}),
|
|
49
|
+
getRawResponse: () => ({}),
|
|
50
|
+
toJSON: async () => data,
|
|
51
|
+
toStream: () => {
|
|
52
|
+
throw new Error("Unexpected stream");
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
const ctx = {
|
|
58
|
+
runQuery: vi.fn(),
|
|
59
|
+
runMutation: vi.fn(),
|
|
60
|
+
} as unknown as RunCtx;
|
|
61
|
+
expect(
|
|
62
|
+
await bs.getUpcomingInvoicePreview(ctx, {
|
|
63
|
+
customerAccount: "acct_buyer",
|
|
64
|
+
stripeSubscriptionId: "sub_test",
|
|
65
|
+
}),
|
|
66
|
+
).toMatchObject({ total: 100, linesComplete: true });
|
|
67
|
+
expect(versions).toEqual(Array(2).fill(version ?? "2026-09-30.endive"));
|
|
68
|
+
expect(ctx.runQuery).not.toHaveBeenCalled();
|
|
69
|
+
},
|
|
70
|
+
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Internal exact renderer. Callers establish units and amount eligibility. */
|
|
2
|
+
export function renderCurrencyUnits(
|
|
3
|
+
amount: number,
|
|
4
|
+
currency: string,
|
|
5
|
+
apiDecimals: 0 | 2,
|
|
6
|
+
displayDecimals: 0 | 2,
|
|
7
|
+
locale: string,
|
|
8
|
+
): string {
|
|
9
|
+
const scale = 10n ** BigInt(apiDecimals);
|
|
10
|
+
const units = BigInt(Math.abs(amount));
|
|
11
|
+
const whole = units / scale;
|
|
12
|
+
const signedWhole = amount < 0 ? (whole === 0n ? -0 : -whole) : whole;
|
|
13
|
+
const formatter = new Intl.NumberFormat(locale, {
|
|
14
|
+
style: "currency",
|
|
15
|
+
currency: currency.toUpperCase(),
|
|
16
|
+
minimumFractionDigits: displayDecimals,
|
|
17
|
+
maximumFractionDigits: displayDecimals,
|
|
18
|
+
});
|
|
19
|
+
const digits = new Intl.NumberFormat(locale, { useGrouping: false });
|
|
20
|
+
const fraction = (units % scale).toString().padStart(apiDecimals, "0");
|
|
21
|
+
return formatter
|
|
22
|
+
.formatToParts(signedWhole)
|
|
23
|
+
.map((part) =>
|
|
24
|
+
part.type === "fraction"
|
|
25
|
+
? [...fraction].map((digit) => digits.format(Number(digit))).join("")
|
|
26
|
+
: part.value,
|
|
27
|
+
)
|
|
28
|
+
.join("");
|
|
29
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
chargeAmountToDecimal,
|
|
5
|
+
formatChargeAmount,
|
|
6
|
+
getChargeCurrencyMetadata,
|
|
7
|
+
parseChargeAmount,
|
|
8
|
+
} from "./charge-currency.js";
|
|
9
|
+
|
|
10
|
+
describe("charge currency units", () => {
|
|
11
|
+
it("produces exact editable decimals in charge units", () => {
|
|
12
|
+
expect(chargeAmountToDecimal(Number.MAX_SAFE_INTEGER, "usd")).toBe(
|
|
13
|
+
"90071992547409.91",
|
|
14
|
+
);
|
|
15
|
+
expect(chargeAmountToDecimal(500, "jpy")).toBe("500");
|
|
16
|
+
expect(chargeAmountToDecimal(50000, "isk")).toBe("500");
|
|
17
|
+
expect(chargeAmountToDecimal(1045, "huf")).toBe("10.45");
|
|
18
|
+
expect(chargeAmountToDecimal(0, "usd")).toBe("0.00");
|
|
19
|
+
});
|
|
20
|
+
it.each([
|
|
21
|
+
[101, "isk"],
|
|
22
|
+
[-1, "usd"],
|
|
23
|
+
[1.5, "usd"],
|
|
24
|
+
[100, "ZZZ"],
|
|
25
|
+
[100, "bhd"],
|
|
26
|
+
[Number.MAX_SAFE_INTEGER + 1, "usd"],
|
|
27
|
+
])("refuses invalid initial charge units %s %s", (amount, currency) => {
|
|
28
|
+
expect(chargeAmountToDecimal(amount, currency)).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
it.each([
|
|
31
|
+
["USD", "10.05", 1005, 2, 2],
|
|
32
|
+
["jpy", "500", 500, 0, 0],
|
|
33
|
+
["mga", "500", 500, 0, 0],
|
|
34
|
+
["isk", "500", 50000, 2, 0],
|
|
35
|
+
["ugx", "500", 50000, 2, 0],
|
|
36
|
+
["huf", "10.45", 1045, 2, 2],
|
|
37
|
+
["twd", "800.45", 80045, 2, 2],
|
|
38
|
+
["afn", "1.25", 125, 2, 2],
|
|
39
|
+
])(
|
|
40
|
+
"parses %s using charge rules",
|
|
41
|
+
(currency, decimal, units, apiDecimals, displayDecimals) => {
|
|
42
|
+
expect(getChargeCurrencyMetadata(currency)).toEqual({
|
|
43
|
+
currency: currency.toLowerCase(),
|
|
44
|
+
apiDecimals,
|
|
45
|
+
displayDecimals,
|
|
46
|
+
});
|
|
47
|
+
expect(parseChargeAmount(decimal, currency)).toBe(units);
|
|
48
|
+
const parts = new Intl.NumberFormat("en-US", {
|
|
49
|
+
style: "currency",
|
|
50
|
+
currency: currency.toUpperCase(),
|
|
51
|
+
minimumFractionDigits: displayDecimals,
|
|
52
|
+
maximumFractionDigits: displayDecimals,
|
|
53
|
+
}).format(Number(decimal));
|
|
54
|
+
expect(formatChargeAmount(units, currency)).toBe(parts);
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
it.each(["ZZZ", "", "usd ", "BHD", "CLF"])(
|
|
59
|
+
"rejects unknown or unsupported precision %s",
|
|
60
|
+
(currency) => {
|
|
61
|
+
expect(getChargeCurrencyMetadata(currency)).toBeNull();
|
|
62
|
+
expect(parseChargeAmount("1", currency)).toBeNull();
|
|
63
|
+
expect(() => formatChargeAmount(100, currency)).toThrow(RangeError);
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
it.each([
|
|
68
|
+
["", "usd"],
|
|
69
|
+
["1e2", "usd"],
|
|
70
|
+
["1,000", "usd"],
|
|
71
|
+
["-1", "usd"],
|
|
72
|
+
["1.005", "usd"],
|
|
73
|
+
["0.5", "jpy"],
|
|
74
|
+
["0.5", "mga"],
|
|
75
|
+
["1.01", "isk"],
|
|
76
|
+
["1.01", "ugx"],
|
|
77
|
+
["90071992547409.92", "usd"],
|
|
78
|
+
])("rejects invalid decimal %s %s without rounding", (decimal, currency) => {
|
|
79
|
+
expect(parseChargeAmount(decimal, currency)).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it.each([NaN, Infinity, 1.5, Number.MAX_SAFE_INTEGER + 1])(
|
|
83
|
+
"rejects invalid provider units %s",
|
|
84
|
+
(amount) => {
|
|
85
|
+
expect(() => formatChargeAmount(amount, "usd")).toThrow(RangeError);
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
it("rejects fractional compatibility units", () => {
|
|
90
|
+
expect(() => formatChargeAmount(101, "isk")).toThrow(RangeError);
|
|
91
|
+
expect(() => formatChargeAmount(101, "ugx")).toThrow(RangeError);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("keeps the final minor unit at the safe integer boundary", () => {
|
|
95
|
+
expect(formatChargeAmount(-1, "usd")).toBe("-$0.01");
|
|
96
|
+
expect(formatChargeAmount(-Number.MAX_SAFE_INTEGER, "usd")).toBe(
|
|
97
|
+
"-$90,071,992,547,409.91",
|
|
98
|
+
);
|
|
99
|
+
expect(parseChargeAmount("90071992547409.91", "usd")).toBe(
|
|
100
|
+
Number.MAX_SAFE_INTEGER,
|
|
101
|
+
);
|
|
102
|
+
expect(formatChargeAmount(Number.MAX_SAFE_INTEGER, "usd")).toBe(
|
|
103
|
+
"$90,071,992,547,409.91",
|
|
104
|
+
);
|
|
105
|
+
expect(formatChargeAmount(0, "usd")).toBe("$0.00");
|
|
106
|
+
expect(parseChargeAmount("0", "usd")).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("localizes grouping and digits without losing provider precision", () => {
|
|
110
|
+
expect(formatChargeAmount(123456, "eur", "de-DE")).toBe("1.234,56 €");
|
|
111
|
+
expect(formatChargeAmount(125, "usd", "ar-EG")).toContain("١٫٢٥");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/** Stripe charge/Price units. These are not payout or Global Payouts rules. */
|
|
2
|
+
import { renderCurrencyUnits } from "./charge-currency-format.js";
|
|
3
|
+
const zeroDecimal = new Set([
|
|
4
|
+
"bif",
|
|
5
|
+
"clp",
|
|
6
|
+
"djf",
|
|
7
|
+
"gnf",
|
|
8
|
+
"jpy",
|
|
9
|
+
"kmf",
|
|
10
|
+
"krw",
|
|
11
|
+
"mga",
|
|
12
|
+
"pyg",
|
|
13
|
+
"rwf",
|
|
14
|
+
"vnd",
|
|
15
|
+
"vuv",
|
|
16
|
+
"xaf",
|
|
17
|
+
"xof",
|
|
18
|
+
"xpf",
|
|
19
|
+
]);
|
|
20
|
+
const unsupportedPrecision = new Set([
|
|
21
|
+
"bhd",
|
|
22
|
+
"clf",
|
|
23
|
+
"iqd",
|
|
24
|
+
"jod",
|
|
25
|
+
"kwd",
|
|
26
|
+
"lyd",
|
|
27
|
+
"omr",
|
|
28
|
+
"tnd",
|
|
29
|
+
"uyw",
|
|
30
|
+
]);
|
|
31
|
+
const currencyIntl: typeof Intl & {
|
|
32
|
+
supportedValuesOf?: (key: "currency") => string[];
|
|
33
|
+
} = Intl;
|
|
34
|
+
const knownCurrencies = new Set(
|
|
35
|
+
(currencyIntl.supportedValuesOf?.("currency") ?? []).map((code) =>
|
|
36
|
+
code.toLowerCase(),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export type ChargeCurrencyMetadata = {
|
|
41
|
+
currency: string;
|
|
42
|
+
apiDecimals: 0 | 2;
|
|
43
|
+
displayDecimals: 0 | 2;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Metadata does not establish account/country/payment-method support. */
|
|
47
|
+
export function getChargeCurrencyMetadata(
|
|
48
|
+
currency: string,
|
|
49
|
+
): ChargeCurrencyMetadata | null {
|
|
50
|
+
const code = currency.toLowerCase();
|
|
51
|
+
if (!knownCurrencies.has(code) || unsupportedPrecision.has(code)) return null;
|
|
52
|
+
if (zeroDecimal.has(code))
|
|
53
|
+
return { currency: code, apiDecimals: 0, displayDecimals: 0 };
|
|
54
|
+
return {
|
|
55
|
+
currency: code,
|
|
56
|
+
apiDecimals: 2,
|
|
57
|
+
displayDecimals: code === "isk" || code === "ugx" ? 0 : 2,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Parse a nonnegative ASCII decimal exactly. Invalid input is never rounded. */
|
|
62
|
+
export function parseChargeAmount(
|
|
63
|
+
value: string,
|
|
64
|
+
currency: string,
|
|
65
|
+
): number | null {
|
|
66
|
+
const metadata = getChargeCurrencyMetadata(currency);
|
|
67
|
+
if (!metadata || !/^\d+(?:\.\d+)?$/.test(value)) return null;
|
|
68
|
+
const [whole, fraction = ""] = value.split(".");
|
|
69
|
+
if (fraction.length > metadata.displayDecimals) return null;
|
|
70
|
+
const units =
|
|
71
|
+
BigInt(whole) * 10n ** BigInt(metadata.apiDecimals) +
|
|
72
|
+
BigInt(fraction.padEnd(metadata.apiDecimals, "0") || "0");
|
|
73
|
+
return units <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(units) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Exact ASCII decimal for charge inputs; invalid source units stay unavailable. */
|
|
77
|
+
export function chargeAmountToDecimal(
|
|
78
|
+
amount: number,
|
|
79
|
+
currency: string,
|
|
80
|
+
): string | null {
|
|
81
|
+
const metadata = getChargeCurrencyMetadata(currency);
|
|
82
|
+
if (
|
|
83
|
+
!metadata ||
|
|
84
|
+
!Number.isSafeInteger(amount) ||
|
|
85
|
+
amount < 0 ||
|
|
86
|
+
(metadata.displayDecimals === 0 &&
|
|
87
|
+
amount % 10 ** metadata.apiDecimals !== 0)
|
|
88
|
+
)
|
|
89
|
+
return null;
|
|
90
|
+
const units = BigInt(amount);
|
|
91
|
+
const scale = 10n ** BigInt(metadata.apiDecimals);
|
|
92
|
+
const whole = (units / scale).toString();
|
|
93
|
+
if (metadata.displayDecimals === 0) return whole;
|
|
94
|
+
return `${whole}.${(units % scale).toString().padStart(metadata.apiDecimals, "0")}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Format signed integer charge units without floating-point division. */
|
|
98
|
+
export function formatChargeAmount(
|
|
99
|
+
amount: number,
|
|
100
|
+
currency: string,
|
|
101
|
+
locale = "en-US",
|
|
102
|
+
): string {
|
|
103
|
+
const metadata = getChargeCurrencyMetadata(currency);
|
|
104
|
+
if (
|
|
105
|
+
!metadata ||
|
|
106
|
+
!Number.isSafeInteger(amount) ||
|
|
107
|
+
(metadata.displayDecimals === 0 &&
|
|
108
|
+
amount % 10 ** metadata.apiDecimals !== 0)
|
|
109
|
+
) {
|
|
110
|
+
throw new RangeError("Invalid charge amount or unsupported currency");
|
|
111
|
+
}
|
|
112
|
+
return renderCurrencyUnits(
|
|
113
|
+
amount,
|
|
114
|
+
metadata.currency,
|
|
115
|
+
metadata.apiDecimals,
|
|
116
|
+
metadata.displayDecimals,
|
|
117
|
+
locale,
|
|
118
|
+
);
|
|
119
|
+
}
|