// The billing page. Everything is painted from one call to /api/subscription,
// which carries the plan, the meters and the price list together - so the three
// cannot disagree about which tier this organisation is on.
//
// Nothing here decides a permission. The buttons are hidden when the fetch says
// billing is off or the caller is not an admin, and the routes behind them
// refuse on their own regardless (app/endpoints/billing.py). Hiding a control is
// how a page stays honest, not how it stays safe.
const RESOURCE_LABELS = { subcategory: "Pages", seat: "Members" };
function money(price) {
// Minor units, as Stripe reports them - so the amount is an integer all the
// way from plans.py to here and never a float being rounded twice.
if (!price || !price.amount) return "Free";
const amount = (price.amount / 100).toFixed(2);
return `${price.currency} ${amount} / ${price.interval}`;
}
function meterRow(resource, used, cap) {
const label = RESOURCE_LABELS[resource] || resource;
const of = cap === null || cap === undefined ? "unlimited" : `of ${cap}`;
const over = cap !== null && cap !== undefined && used >= cap;
return `<tr><td>${label}</td>` +
`<td class="${over ? "error" : "muted"}">${used} ${of}</td></tr>`;
}
function tierRow(key, tier, current, canBuy) {
const includes = tier.features.length ? tier.features.join(", ") : "—";
const here = key === current;
const buyable = canBuy && !here && key !== "free";
return `<tr>` +
`<td>${tier.label}${here ? " <span class='muted'>(current)</span>" : ""}</td>` +
`<td>${money(tier.price)}</td>` +
`<td class="muted">${includes}</td>` +
`<td>${buyable ? `<button data-tier="${key}">Choose</button>` : ""}</td>` +
`</tr>`;
}
function show(id, on) { document.getElementById(id).hidden = !on; }
function fail(e) {
const box = document.getElementById("plan-error");
box.textContent = e.message;
box.hidden = false;
}
async function refresh() {
const data = await api.get("/api/subscription");
const sub = data.subscription;
document.getElementById("plan-line").textContent =
`${data.label}${sub.status === "active" ? "" : ` — ${sub.status}`}`;
if (sub.current_period_end) {
const when = new Date(sub.current_period_end).toLocaleDateString();
const period = document.getElementById("plan-period");
// The same date means two different things depending on the flag, which is
// exactly why cancel_at_period_end is a column of its own: a winding-down
// subscription stays `active` at Stripe until the day it lapses.
period.textContent = sub.cancel_at_period_end
? `Access until ${when}.` : `Renews on ${when}.`;
period.hidden = false;
}
show("plan-exempt", data.exempt);
show("billing-off", !data.billing_enabled);
// `has_stripe` rather than the tier: an organisation put on a paid plan by
// hand has nothing at Stripe to manage or cancel.
const paid = data.billing_enabled && sub.has_stripe;
show("plan-actions", paid);
show("cancel", paid && !sub.cancel_at_period_end);
show("resume", paid && sub.cancel_at_period_end);
document.getElementById("meters").innerHTML =
Object.keys(data.usage)
.map(r => meterRow(r, data.usage[r], data.limits[r]))
.join("");
document.getElementById("tiers").innerHTML =
Object.entries(data.tiers)
.map(([key, tier]) =>
tierRow(key, tier, data.effective_tier, data.billing_enabled))
.join("");
document.querySelectorAll("#tiers button[data-tier]").forEach(b =>
b.addEventListener("click", async () => {
b.disabled = true;
try {
const out = await api.post("/api/billing/checkout", { tier: b.dataset.tier });
// Stripe's hosted page. We never see a card.
window.location = out.url;
} catch (e) {
b.disabled = false;
fail(e);
}
}));
}
document.getElementById("portal").addEventListener("click", async () => {
try {
const out = await api.post("/api/billing/portal");
window.location = out.url;
} catch (e) { fail(e); }
});
for (const [id, path] of [["cancel", "/api/billing/cancel"],
["resume", "/api/billing/resume"]]) {
document.getElementById(id).addEventListener("click", async () => {
try {
await api.post(path);
await refresh();
} catch (e) { fail(e); }
});
}
// The customer coming back from Stripe. `success` does not mean the webhook has
// landed yet - the two races - so the note says "shortly" rather than asserting
// a plan the next fetch might not show.
const outcome = new URLSearchParams(window.location.search).get("checkout");
if (outcome) {
const note = document.getElementById("checkout-note");
note.textContent = outcome === "success"
? "Thank you. Your plan will update here within a few seconds."
: "Checkout cancelled. Nothing has changed.";
note.hidden = false;
}
refresh().catch(fail);