77 lines
2.8 KiB
TypeScript
77 lines
2.8 KiB
TypeScript
import { initializePaddle, type Paddle } from "@paddle/paddle-js";
|
|
|
|
let cached: Promise<Paddle | undefined> | null = null;
|
|
|
|
/* One Paddle instance for the app. The token and environment are baked into the
|
|
* build (NEXT_PUBLIC_*), never fetched, so a production build can never load a
|
|
* sandbox token by accident. */
|
|
export function initPaddle(): Promise<Paddle | undefined> {
|
|
if (!cached) {
|
|
cached = initializePaddle({
|
|
environment:
|
|
(process.env.NEXT_PUBLIC_PADDLE_ENV as "sandbox" | "production") ?? "sandbox",
|
|
token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN ?? "",
|
|
});
|
|
}
|
|
return cached;
|
|
}
|
|
|
|
export interface PricedLine {
|
|
priceId: string;
|
|
/* Already localised and currency-formatted by Paddle, e.g. "£39.00". The line
|
|
* total for the quantity, not the unit price. */
|
|
total: string;
|
|
unit: string;
|
|
}
|
|
|
|
export interface PricePreview {
|
|
currency: string;
|
|
/* Grand total, formatted. */
|
|
total: string;
|
|
lines: Record<string, PricedLine>;
|
|
}
|
|
|
|
/*
|
|
* previewPrices asks Paddle for the real localised prices of a set of line items,
|
|
* so the order summary shows what the customer will actually pay rather than a
|
|
* hardcoded number that would drift from the dashboard.
|
|
*
|
|
* It returns null when Paddle is unavailable or a price cannot be previewed (an
|
|
* unconfigured sandbox price, an ad blocker). The caller falls back to showing
|
|
* the line items without amounts rather than a wrong total — the real figure
|
|
* still appears in the checkout overlay, which is the authority.
|
|
*/
|
|
export async function previewPrices(
|
|
items: { priceId: string; quantity: number }[],
|
|
): Promise<PricePreview | null> {
|
|
if (items.length === 0) return { currency: "", total: "", lines: {} };
|
|
const paddle = await initPaddle();
|
|
if (!paddle) return null;
|
|
try {
|
|
const res = await paddle.PricePreview({
|
|
items: items.map((i) => ({ priceId: i.priceId, quantity: i.quantity })),
|
|
});
|
|
const currency = res.data.currencyCode;
|
|
const lines: Record<string, PricedLine> = {};
|
|
// Paddle gives per-line totals but no grand total, so sum the raw minor
|
|
// units and format once. The checkout overlay is the authority; this is
|
|
// the honest preview beside it.
|
|
let subtotalMinor = 0;
|
|
for (const li of res.data.details.lineItems) {
|
|
lines[li.price.id] = {
|
|
priceId: li.price.id,
|
|
total: li.formattedTotals.subtotal,
|
|
unit: li.formattedUnitTotals.subtotal,
|
|
};
|
|
subtotalMinor += Number.parseInt(li.totals.subtotal, 10) || 0;
|
|
}
|
|
const total = new Intl.NumberFormat(undefined, {
|
|
style: "currency",
|
|
currency,
|
|
}).format(subtotalMinor / 100);
|
|
return { currency, total, lines };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|