This commit is contained in:
@@ -319,6 +319,9 @@ export const api = {
|
||||
checkoutOptions: () => req<CheckoutOptions>("/api/checkout/options"),
|
||||
createSelfHosted: (name: string) =>
|
||||
post<{ instance_id: string }>("/api/instances/self-hosted", { name }),
|
||||
// Paid cloud: provisions the real instance the paid webhook then licenses.
|
||||
createCloudCheckout: (name: string) =>
|
||||
post<{ instance_id: string }>("/api/instances/cloud", { name }),
|
||||
updateEntitlement: (
|
||||
id: string,
|
||||
body: { tier: Tier; term: Term; servers: number; features: string[] },
|
||||
|
||||
@@ -15,3 +15,62 @@ export function initPaddle(): Promise<Paddle | undefined> {
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user