agilentics / boiler
// 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);