carlos / platform Public

Add the billing page copy-review index, preview renderer and preview

The string index is the draft copy the console billing page carries; the
preview is every page state rendered through the real handlers against the
fake Stripe and fake Keymail fixtures (TestBillingPageRenderPreview, skipped
unless CARLOS_BILLING_PREVIEW_DIR is set), bound to the index by bind.py.
Nothing here is approved copy yet.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9acaf6f66528f52089f9703e65e96701c073944d parent f486534
4 files changed, +1301 −0
  • implementation/carloku-paid-launch/copy-review/billing-page/bind.py +64 −0
  • implementation/carloku-paid-launch/copy-review/billing-page/preview/index.html +1123 −0
  • implementation/carloku-paid-launch/copy-review/billing-page/strings.json +41 −0
  • internal/console/billingpage_preview_test.go +73 −0

CI passed — run details

diff --git a/implementation/carloku-paid-launch/copy-review/billing-page/bind.py b/implementation/carloku-paid-launch/copy-review/billing-page/bind.py
new file mode 100644
index 000000000..bfb92a5cd
--- /dev/null
+++ b/implementation/carloku-paid-launch/copy-review/billing-page/bind.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+# 🤖 Builds preview/index.html for the copy review from pages rendered by
+# TestBillingPageRenderPreview (CARLOS_BILLING_PREVIEW_DIR). Binds each
+# rendered string to its id in strings.json by exact text match. Strings
+# with template placeholders or inline markup stay list-only.
+import json, re, sys, html, os
+src = sys.argv[1]; here = os.path.dirname(os.path.abspath(__file__))
+strings = json.load(open(os.path.join(here, "strings.json")))
+def esc(t): return html.escape(t, quote=False)
+states = [("owner-free-ie", "Owner on Free, prices quoted for Ireland"),
+ ("owner-free-error-unavailable", "After a failed checkout attempt (error=unavailable)"),
+ ("owner-free-error-pending", "A second checkout while one is settling (error=pending)"),
+ ("owner-free-error-conflict", "The account changed under the form (error=conflict)"),
+ ("owner-pending", "Back from Stripe, webhook not yet arrived (pending=1)"),
+ ("owner-active", "Active Personal subscription, portal available"),
+ ("owner-past-due", "Payment failed on renewal (past_due)"),
+ ("owner-review-hold", "An operator review hold is open"),
+ ("member-active", "A member who is not an owner"),
+ ("closed", "Billing switched off on this deployment (production today)")]
+pages = {n: open(os.path.join(src, n + ".html")).read() for n, _ in states}
+base = pages["owner-free-ie"]
+head, rest = base.split("<body", 1)
+body_open, rest = rest.split(">", 1)
+body, tail = rest.rsplit("</body>", 1)
+head = re.sub(r"<script>.*?</script>", "", head, flags=re.S)
+def main_of(p):
+ m = re.search(r"<main>(.*?)</main>", p, re.S)
+ return m.group(1) if m else ""
+SKIP = ("billing.consent.label", "billing.consent.link", "billing.nav", "billing.title")
+ordered = sorted([x for x in strings if "{{" not in x["text"] and x["id"] not in SKIP], key=lambda x: -len(x["text"]))
+def bind(fragment, seen):
+ tokens = {}
+ for i, s in enumerate(ordered):
+ e = esc(s["text"])
+ if e not in fragment: continue
+ tok_opt = "\x00o%d\x00" % i; tok = "\x00s%d\x00" % i
+ opt = '<option value="">' + e + '</option>'
+ if opt in fragment:
+ fragment = fragment.replace(opt, '<option value="" data-copy="%s">%s</option>' % (s["id"], tok_opt))
+ tokens[tok_opt] = e; seen.add(s["id"])
+ pat = re.compile(r"(?<![\w-])" + re.escape(e) + r"(?![\w-])")
+ if pat.search(fragment):
+ fragment = pat.sub(tok, fragment); tokens[tok] = '<span data-copy="%s">%s</span>' % (s["id"], e); seen.add(s["id"])
+ for tok, rep in tokens.items():
+ fragment = fragment.replace(tok, rep)
+ return fragment
+seen = set()
+sections = []
+for n, label in states:
+ frag = main_of(pages[n])
+ frag = re.sub(r"<script>.*?</script>", "", frag, flags=re.S)
+ sections.append('<section class="preview-state"><p class="preview-label">Preview state: %s</p><main>%s</main></section>' % (html.escape(label), bind(frag, seen)))
+# Header/nav from the base page (nav link binding), scripts removed.
+header = body.split("<main>", 1)[0]
+header = re.sub(r"<script>.*?</script>", "", header, flags=re.S)
+header = header.replace(">Billing</a>", ' data-copy="billing.nav">Billing</a>', 1); seen.add("billing.nav")
+head = head.replace("<title>Billing · CARLOS console</title>", '<title>Billing · CARLOS console</title>')
+style = "<style>.preview-state{border-top:3px dashed var(--accent);margin:3rem 0 0;padding-top:1rem}.preview-label{font:600 .85rem/1.4 var(--sans);color:var(--accent);text-transform:uppercase;letter-spacing:.04em;max-width:64rem;margin:0 auto .5rem}</style></head>"
+head = head.replace("</head>", style)
+out = head + "<body" + body_open + ">" + header + "\n".join(sections) + "</body>" + tail
+out = re.sub(r"<script>.*?</script>", "", out, flags=re.S)
+open(os.path.join(here, "preview", "index.html"), "w").write(out)
+missing = [s["id"] for s in strings if s["id"] not in seen]
+print("bound", len(seen), "list-only", missing)
diff --git a/implementation/carloku-paid-launch/copy-review/billing-page/preview/index.html b/implementation/carloku-paid-launch/copy-review/billing-page/preview/index.html
new file mode 100644
index 000000000..ddab558c8
--- /dev/null
+++ b/implementation/carloku-paid-launch/copy-review/billing-page/preview/index.html
@@ -0,0 +1,1123 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Billing · CARLOS console</title>
+<style>
+ :root {
+ --bg: #ffffff; --ink: #201d29; --muted: #6f6480;
+ --accent: #5b3e96; --accent-strong: #4c3380;
+ --accent-soft: rgba(91, 62, 150, 0.09);
+ --rule: #eae7f0; --bad: #b3403a; --warn: #a6790f; --good: #2f7a4f;
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ --sans: ui-sans-serif, system-ui, -apple-system, sans-serif;
+ }
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #16141c; --ink: #f0eef6; --muted: #a99ebd;
+ --accent: #b394f2; --accent-strong: #c9b2f7;
+ --accent-soft: rgba(179, 148, 242, 0.12);
+ --rule: #2c2836; --bad: #e07b76; --warn: #d9a94a; --good: #5fae7f;
+ }
+ }
+ [hidden] { display: none !important; }
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ body {
+ background: var(--bg); color: var(--ink);
+ font: 400 1rem/1.6 var(--sans);
+ -webkit-font-smoothing: antialiased; padding: 0 1.25rem;
+ }
+ a { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 2px; }
+ code {
+ font-family: var(--mono);
+ font-size: 0.88em; background: var(--accent-soft);
+ padding: 0.1em 0.35em; border-radius: 0.25rem;
+ }
+ pre {
+ background: var(--accent-soft); border-radius: 0.5rem;
+ padding: 0.9rem 1.1rem; margin: 0 0 1rem; max-width: 40rem;
+ overflow-x: auto; font-family: var(--mono); font-size: 0.85rem; line-height: 1.6;
+ }
+ pre code { background: none; padding: 0; }
+
+ .changelog { white-space: pre-wrap; }
+ select, input[type=text], input[type=search] {
+ font: inherit; font-family: var(--sans); font-size: 0.9rem; color: var(--ink); background: var(--bg);
+ border: 1px solid var(--rule); border-radius: 0.4rem; padding: 0.3rem 0.5rem;
+ }
+ td form { display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; margin: 0; }
+
+ form.inline { display: inline; margin: 0; }
+ form.inline button { vertical-align: baseline; }
+
+ form.listctl { display: flex; gap: 0.6rem; flex-wrap: wrap; margin: 0 0 0.6rem; }
+
+ .icon { width: 1em; height: 1em; flex: none; vertical-align: -0.125em; }
+ header {
+ max-width: 64rem; margin: 0 auto; padding: 1.4rem 0;
+ display: flex; align-items: baseline; gap: 1rem;
+ border-bottom: 1px solid var(--rule);
+ }
+ header .brand a { font-weight: 700; color: var(--ink); text-decoration: none; }
+ header nav {
+ margin-left: auto; display: flex; align-items: baseline; gap: 1rem;
+ font-size: 0.9rem;
+ }
+ header .addr { color: var(--muted); }
+
+ header .switcher {
+ position: relative;
+ }
+ header .switcher > summary {
+ display: inline-flex; align-items: baseline; gap: 0.4rem;
+ list-style: none; cursor: pointer;
+ padding: 0.25rem 0.5rem; margin: -0.25rem -0.5rem; border-radius: 0.5rem;
+ }
+ header .switcher > summary::-webkit-details-marker { display: none; }
+ header .switcher > summary:hover { background: var(--accent-soft); }
+
+ header .switcher .current-name {
+ font-weight: 700; min-width: 0; max-width: 14rem;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ }
+
+ header .switcher > summary > .icon { color: var(--muted); align-self: center; width: 0.9em; }
+ header .switcher[open] > summary > .icon { transform: rotate(180deg); }
+ header .switcher-menu {
+ position: absolute; z-index: 20; top: calc(100% + 0.6rem); left: -0.5rem;
+ min-width: 13rem; max-width: 20rem;
+ background: var(--bg); border: 1px solid var(--rule); border-radius: 0.6rem;
+ padding: 0.35rem; font-size: 0.9rem;
+ box-shadow: 0 0.4rem 1.5rem rgba(0, 0, 0, 0.14);
+ }
+ header .switcher-menu ul { list-style: none; margin: 0; padding: 0; }
+ header .switcher-menu a, header .switcher-menu .current {
+ display: flex; align-items: baseline; gap: 0.5rem;
+ padding: 0.35rem 0.55rem; border-radius: 0.4rem;
+ color: var(--ink); text-decoration: none; overflow-wrap: anywhere;
+ }
+ header .switcher-menu a:hover, header .switcher-menu a:focus { background: var(--accent-soft); }
+ header .switcher-menu .current { font-weight: 600; background: var(--accent-soft); }
+
+ header .switcher-menu .mark {
+ color: var(--accent); width: 0.9rem; flex: none;
+ display: flex; align-self: center;
+ }
+ header .switcher-menu a .mark { visibility: hidden; }
+ header .switcher-menu .new a .mark { visibility: visible; }
+ header .switcher-menu hr { border: 0; border-top: 1px solid var(--rule); margin: 0.35rem 0.2rem; }
+ header .switcher-menu .new a { color: var(--accent); }
+ main { max-width: 64rem; margin: 0 auto; padding: 2.5rem 0 3rem; min-height: 55vh; }
+ h1 { font-size: 1.9rem; line-height: 1.15; margin: 0 0 1rem; }
+ h2 { font-size: 1.25rem; margin: 2rem 0 0.75rem; }
+ h3 { font-size: 1rem; margin: 1.5rem 0 0.5rem; }
+
+ .settings-row { display: flex; gap: 0.6rem; flex-wrap: wrap; align-items: center; margin: 0 0 0.75rem; }
+ .settings-layout {
+ display: grid; grid-template-columns: minmax(10rem, 13rem) minmax(0, 1fr);
+ gap: 2.5rem; align-items: start;
+ }
+ .settings-nav { display: flex; flex-direction: column; gap: 0.2rem; }
+ .settings-nav a {
+ display: block; padding: 0.45rem 0.65rem; border-radius: 0.35rem;
+ color: var(--muted); text-decoration: none;
+ }
+ .settings-nav a:hover { color: var(--ink); background: var(--accent-soft); }
+ .settings-nav a[aria-current="page"] {
+ color: var(--ink); background: var(--accent-soft); font-weight: 600;
+ }
+ .settings-content { min-width: 0; }
+ .settings-content > h2:first-child { margin-top: 0; }
+ .danger-head { color: var(--bad); }
+ p { max-width: 40rem; margin: 0 0 1rem; }
+ .lede { font-size: 1.15rem; color: var(--muted); }
+ .small { font-size: 0.9rem; color: var(--muted); }
+
+ .visually-hidden {
+ position: absolute; width: 1px; height: 1px; overflow: hidden;
+ clip-path: inset(50%); white-space: nowrap;
+ }
+ .error { color: var(--bad); }
+ .banner {
+ background: var(--accent-soft); border-radius: 0.5rem;
+ padding: 0.6rem 0.9rem; margin: 0 0 1.5rem; max-width: 40rem;
+ }
+ .tablewrap { overflow-x: auto; }
+ .instances .instance-address { white-space: nowrap; }
+ .instances td:nth-child(2) { white-space: nowrap; }
+ .instance-open { display: inline-flex; padding: .25rem; }
+ .instance-group th { text-align: left; padding-top: 1rem; background: var(--accent-soft); }
+ .instance-group .small { font-weight: normal; }
+ .small a[aria-current="true"] { color: var(--ink); font-weight: 700; text-decoration-thickness: 3px; }
+ .instance-facts { display: grid; grid-template-columns: minmax(8rem, auto) 1fr; gap: .5rem 1.5rem; margin: 1rem 0; }
+ .instance-facts dt { color: var(--muted); }
+ .instance-facts dd { margin: 0; overflow-wrap: anywhere; }
+ .instance-facts .cellspark { max-width: 24rem; }
+
+ #logs-filter { display: flex; flex-wrap: wrap; align-items: end; gap: 0.65rem; margin-bottom: 1rem; }
+ #logs-filter label { display: flex; flex-direction: column; gap: 0.25rem; max-width: 100%; }
+ #logs-filter select, #logs-filter input { max-width: 100%; margin: 0; }
+ #logs-filter select[name="host"] { max-width: 28rem; }
+ .term {
+ background: var(--accent-soft); border-radius: 0.5rem;
+ padding: 0.8rem 0 0.8rem 1rem; margin: 0 0 1rem;
+ min-height: 16rem; max-height: 70vh; overflow: auto;
+ font-family: var(--mono); font-size: 0.8rem; line-height: 1.55;
+ }
+
+ .tl { white-space: pre; width: max-content; min-width: 100%; padding-right: 1rem; }
+
+ .tl-t, .tl-h, .tl-f { color: var(--muted); }
+ .tl-s { color: var(--accent); }
+
+ .tl-warn .tl-m { color: var(--warn, #a6790f); }
+ .tl-error .tl-m { color: var(--bad); font-weight: 600; }
+ table { border-collapse: collapse; width: 100%; margin: 0 0 1rem; font-size: 0.95rem; }
+ .tiles { display: flex; flex-wrap: wrap; gap: 1rem; margin: 0 0 1.5rem; }
+ .tile { flex: 1 1 10rem; background: var(--accent-soft); border-radius: 0.6rem; padding: 1rem 1.1rem; }
+
+ a.tile { color: var(--ink); text-decoration: none; }
+ a.tile:hover { outline: 1px solid var(--accent); }
+
+ .stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); }
+ .tile h2 {
+ margin: 0 0 0.35rem; font-family: var(--mono); font-size: 0.78rem; text-transform: uppercase;
+ letter-spacing: 0.05em; color: var(--muted); font-weight: 600;
+ }
+ .tile-value { font-size: 1.6rem; font-weight: 600; margin: 0; }
+ .tile-detail { color: var(--muted); font-size: 0.8rem; margin: 0.25rem 0 0; }
+
+ .chip {
+ display: inline-block; font-family: var(--mono); font-size: 0.78rem; font-weight: 500;
+ padding: 0.15rem 0.55rem; border-radius: 999px; margin: 0.15rem 0.3rem 0.15rem 0;
+ }
+ .chip-stale { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
+
+ .chip-canary { border: 1px solid var(--accent); color: var(--accent); }
+ .chip-prod { background: var(--bad); color: var(--bg); }
+
+ .chip-expired { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
+
+ .chip-env { border: 1px solid var(--rule); color: var(--muted); }
+
+ .chip-health-ok { border: 1px solid var(--good); color: var(--good); }
+ .chip-health-attention { background: var(--warn, #a6790f); color: var(--bg); }
+ .chip-health-notdeployed { border: 1px solid var(--rule); color: var(--muted); }
+ .chip-health-unknown { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
+
+ .chip-freetier-inside { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
+ .chip-freetier-partial { border: 1px solid var(--accent); color: var(--accent); }
+ .chip-freetier-paying { border: 1px solid var(--good); color: var(--good); }
+ .chip-freetier-pooled { border: 1px solid var(--rule); color: var(--muted); }
+
+ .chip-tier-unassigned { border: 1px solid var(--rule); color: var(--muted); }
+ .chip-tier-try { border: 1px solid var(--rule); color: var(--muted); }
+ .chip-tier-pool { border: 1px solid var(--rule); color: var(--muted); }
+ .chip-tier-hosted { border: 1px solid var(--good); color: var(--good); }
+ .chip-tier-fleet { border: 1px solid var(--accent); color: var(--accent); }
+ .chip-tier-dedicated { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
+ .chip .icon { vertical-align: -0.1em; margin-right: 0.2em; }
+ .chip-up { background: var(--accent-soft); color: #2f7a4f; }
+ .chip-down { background: var(--accent-soft); color: var(--bad); }
+ .chips { display: flex; flex-wrap: wrap; margin: 0 0 1rem; }
+
+ .bar { display: inline-block; width: 6rem; height: 0.5rem; border-radius: 999px; background: var(--accent-soft); vertical-align: middle; overflow: hidden; }
+ .bar-fill { display: block; height: 100%; background: var(--accent); }
+
+ .knob-row { display: flex; gap: 0.6rem; align-items: center; margin: 0 0 0.5rem; flex-wrap: wrap; }
+ .knob-row label { min-width: 12rem; color: var(--muted); font-size: 0.9rem; }
+ .knob-row input[type=number], .knob-row input[type=text] { width: 8rem; }
+ .knob-row input[type=range] { width: 12rem; }
+
+ .flow { margin: 0 0 1.75rem; }
+ .flow-line {
+ --flow-y: 0.52rem;
+ display: flex; flex-wrap: wrap; gap: 1.1rem 2.6rem;
+ list-style: none; margin: 0; padding: 0;
+ }
+ .flow-stage { position: relative; flex: 1 1 10rem; min-width: 9rem; }
+ .flow-stage + .flow-stage::before, .flow-stage + .flow-stage::after {
+ content: ""; position: absolute; pointer-events: none;
+ }
+ .flow-stage + .flow-stage::before {
+ left: -2.05rem; top: var(--flow-y); width: 1.3rem; border-top: 1px dashed var(--rule);
+ }
+ .flow-stage + .flow-stage::after {
+ left: -0.82rem; top: var(--flow-y); transform: translateY(-50%);
+ border: 0.28rem solid transparent; border-right: 0; border-left-color: var(--rule);
+ }
+ .flow-stage.is-carried::before { border-top-style: solid; border-top-color: var(--accent); }
+ .flow-stage.is-carried::after { border-left-color: var(--accent); }
+ .flow-label {
+ margin: 0; font-family: var(--mono); font-size: 0.74rem; text-transform: uppercase;
+ letter-spacing: 0.05em; color: var(--muted); font-weight: 600;
+ }
+ .flow-version { margin: 0.3rem 0 0; font-size: 0.95rem; }
+
+ .flow-stage:not(.is-held) .flow-version { color: var(--muted); }
+ .flow-note { margin: 0.25rem 0 0; font-size: 0.78rem; color: var(--muted); }
+ .flow-addrs {
+ list-style: none; margin: 0.6rem 0 0; padding: 0 0 0 0.7rem;
+ border-left: 1px solid var(--rule); display: grid; gap: 0.35rem;
+ }
+ .flow-addr { font-size: 0.82rem; line-height: 1.35; }
+ .flow-addr a { display: block; overflow-wrap: anywhere; }
+ .flow-health { display: inline-flex; align-items: center; gap: 0.2rem; color: var(--muted); }
+ .flow-health .icon { width: 0.9em; height: 0.9em; flex: none; }
+ .flow-tone-ok .flow-health { color: var(--good); }
+ .flow-tone-wait .flow-health { color: var(--warn, #a6790f); }
+ .flow-tone-bad .flow-health { color: var(--bad); font-weight: 600; }
+ .flow-band { margin: 1.4rem 0 0.7rem; font-size: 0.9rem; color: var(--muted); }
+ .flow-caption { margin: 1.1rem 0 0; }
+
+ @media (max-width: 46rem) {
+ .settings-layout { display: block; }
+ .settings-nav {
+ flex-direction: row; overflow-x: auto; margin: 0 0 1.5rem;
+ padding-bottom: 0.25rem;
+ }
+ .settings-nav a { white-space: nowrap; }
+ .flow-line { display: block; }
+ .flow-stage + .flow-stage { margin-top: 1.6rem; }
+ .flow-stage + .flow-stage::before {
+ left: 0.55rem; top: -1.2rem; width: 0; height: 0.85rem;
+ border-top: 0; border-left: 1px dashed var(--rule);
+ }
+ .flow-stage + .flow-stage::after {
+ left: 0.55rem; top: -0.34rem; transform: translateX(-50%);
+ border: 0.28rem solid transparent; border-bottom: 0; border-top-color: var(--rule);
+ border-left-color: transparent;
+ }
+ .flow-stage.is-carried::before { border-left-color: var(--accent); }
+ .flow-stage.is-carried::after { border-top-color: var(--accent); border-left-color: transparent; }
+ }
+
+ .ladder { list-style: none; margin: 0 0 1rem; padding: 0; max-width: 40rem; }
+ .rung {
+ border: 1px solid var(--rule); border-radius: 0.6rem; padding: 0.8rem 1rem;
+ }
+ .rung + .rung { margin-top: 0.4rem; }
+ .rung-name {
+ font-family: var(--mono);
+ font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em;
+ font-weight: 600; color: var(--muted); margin: 0 0 0.3rem;
+ }
+ .rung p { margin: 0 0 0.2rem; }
+ .rung .gate { margin: 0.5rem 0 0; }
+ .rung .routes { list-style: none; margin: 0.3rem 0 0.6rem; padding: 0; font-size: 0.95rem; }
+ .rung .routes li { padding: 0.1rem 0; }
+
+ .step {
+ display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap;
+ margin: 0.7rem -1rem -0.8rem; padding: 0.5rem 1rem;
+ border-top: 1px solid var(--rule);
+ }
+ .step form { display: flex; align-items: baseline; gap: 0.5rem; margin: 0; }
+ .step > .icon { color: var(--muted); align-self: center; }
+ th {
+ font-family: var(--mono);
+ font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em;
+ color: var(--muted); text-align: left; font-weight: 600;
+ }
+ th, td { padding: 0.5rem 1rem 0.5rem 0; border-bottom: 1px solid var(--rule); }
+ form.signin { display: flex; gap: 0.6rem; flex-wrap: wrap; margin: 0 0 1rem; }
+ form.signin input[type=text] { padding: 0.55rem 0.8rem; min-width: 16rem; }
+ button, .btn {
+ font-size: 0.95rem; font-weight: 600; background: var(--accent);
+ color: var(--bg); border: 0; border-radius: 0.4rem;
+ padding: 0.6rem 1.2rem; cursor: pointer;
+ text-decoration: none; display: inline-block;
+ }
+ button.linkish {
+ background: none; color: var(--accent); font-weight: 400; padding: 0;
+ border-radius: 0; text-decoration: underline; font-size: 0.9rem; cursor: pointer;
+ }
+ button:not(.linkish):hover, .btn:hover { background: var(--accent-strong); }
+ button.linkish.danger { color: var(--bad); }
+
+ .btn-sm, button.btn-sm {
+ font-size: 0.85rem; padding: 0.35rem 0.8rem; border-radius: 0.4rem;
+ }
+ button.btn-danger, .btn-danger { background: var(--bad); color: var(--bg); }
+ button.btn-danger:hover, .btn-danger:hover { background: var(--bad); filter: brightness(0.9); }
+
+ td .cell-value { display: inline-block; min-width: 3.5rem; font-weight: 600; }
+ .cellspark { width: 6rem; height: 1.1rem; vertical-align: -0.2rem; color: var(--muted); }
+ .cellspark polyline { stroke-width: 2.5; }
+
+ .feed { list-style: none; margin: 0 0 0.5rem; padding: 0; max-width: 40rem; }
+ .feed li { padding: 0.25rem 0; border-bottom: 1px solid var(--rule); }
+ .feed li:last-child { border-bottom: 0; }
+
+ .confirm-card {
+ border: 1px solid var(--rule); border-radius: 0.6rem;
+ padding: 1.25rem 1.4rem; max-width: 40rem; margin: 0 0 1rem;
+ }
+ .confirm-card .actions { display: flex; gap: 0.9rem; align-items: center; margin-top: 1rem; }
+ .confirm-card .confirm-note { margin: 0.8rem 0 0; }
+
+
+ .archwrap { overflow-x: auto; margin: 0.2rem 0 1.6rem; }
+
+ h4.archband {
+ border-top: 1px solid var(--rule); color: var(--muted);
+ font: 600 0.8rem var(--mono); letter-spacing: 0.02em;
+ margin: 1.5rem 0 0; padding-top: 0.5rem;
+ }
+
+ .archsvg { display: block; }
+ .archsvg rect { fill: var(--accent-soft); stroke: var(--rule); }
+ .archsvg .archnode-box rect { stroke: var(--accent); }
+ .archsvg .archtitle { fill: var(--ink); font: 600 13px var(--sans); }
+ .archsvg .archsub { fill: var(--muted); font: 400 11px var(--sans); }
+
+ .archsvg .archedge { fill: none; stroke: var(--muted); stroke-width: 1.5; }
+
+ .archsvg .archalarm { fill: var(--good); }
+ .archsvg .archalarm-firing { fill: var(--bad); }
+
+ .archbox {
+ border: 1px solid var(--rule); border-radius: 0.6rem;
+ padding: 0.9rem 1.1rem; margin: 0 0 0.6rem; max-width: 64rem;
+ }
+ .archbox h3 { margin: 0 0 0.3rem; }
+ .archbox p { margin: 0 0 0.2rem; }
+
+ .archbox .fact + .fact::before { content: " · "; color: var(--muted); }
+ footer {
+ max-width: 64rem; margin: 0 auto; padding: 1.25rem 0 2rem;
+ border-top: 1px solid var(--rule);
+ font-size: 0.85rem; color: var(--muted);
+ }
+
+ .pagehead { margin: 0 0 1.75rem; }
+ .eyebrow {
+ font-family: var(--mono); font-size: 0.75rem; letter-spacing: 0.08em;
+ text-transform: uppercase; color: var(--muted); margin: 0 0 0.3rem;
+ }
+ .pagehead h1 { margin: 0 0 0.35rem; }
+ .pagehead .lede { margin: 0; font-size: 1.05rem; }
+
+ .reveal > summary { font-size: 1.1rem; font-weight: 600; cursor: pointer; }
+
+ .tabs {
+ display: flex; flex-wrap: wrap; gap: 1.25rem; margin: 0 0 2rem;
+ border-bottom: 1px solid var(--rule);
+ font-family: var(--mono); font-size: 0.85rem;
+ }
+ .tabs a {
+ color: var(--muted); text-decoration: none;
+ padding: 0 0 0.55rem; border-bottom: 2px solid transparent; margin-bottom: -1px;
+ }
+ .tabs a:hover { color: var(--ink); }
+ .tabs a[aria-current] { color: var(--ink); border-bottom-color: var(--accent); }
+ header nav a[aria-current] { color: var(--ink); font-weight: 600; text-decoration: none; }
+ header nav a.addr { color: var(--muted); text-decoration: none; }
+ header nav a.addr:hover { text-decoration: underline; }
+</style>
+<style>.preview-state{border-top:3px dashed var(--accent);margin:3rem 0 0;padding-top:1rem}.preview-label{font:600 .85rem/1.4 var(--sans);color:var(--accent);text-transform:uppercase;letter-spacing:.04em;max-width:64rem;margin:0 auto .5rem}</style></head>
+<body>
+<header>
+
+ <details class="switcher" id="switcher">
+ <summary>
+ <span class="small">Account</span>
+ <span class="current-name">ops</span>
+ <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
+ </summary>
+ <div class="switcher-menu">
+ <ul>
+ <li>
+ <span class="current" aria-current="true"><span class="mark"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg></span>ops</span>
+ </li>
+
+ </ul>
+ <hr>
+ <div class="new"><a href="/accounts/new"><span class="mark"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="M12 5v14"/></svg></span>New account</a></div>
+ </div>
+ </details>
+
+ <nav>
+
+ <a href="/a/ops/">Apps</a>
+ <a href="/a/ops/fleet">Fleet</a>
+ <a href="/a/ops/team">Team</a>
+ <a href="/a/ops/billing" aria-current="page" data-copy="billing.nav">Billing</a>
+ <a href="/a/ops/settings">Settings</a>
+
+ <a class="addr" href="/settings" title="Your settings">paul@keymail.dev</a>
+ <form method="post" action="/auth/signout">
+ <input type="hidden" name="csrf" value="ZMN9j5wRsWbKVAXM9H5YrfbyML9QQvNlVAtQB_JE8NQ">
+ <button class="linkish" type="submit">Sign out</button>
+ </form>
+ </nav>
+</header>
+<section class="preview-state"><p class="preview-label">Preview state: Owner on Free, prices quoted for Ireland</p><main>
+
+
+
+
+
+
+
+
+
+
+<div class="pagehead">
+ <p class="eyebrow">Account · ops</p>
+ <h1>Billing</h1>
+ <p class="lede"><span data-copy="billing.lede">Your plan, what it includes, and how to change it.</span></p>
+</div>
+
+<h2><span data-copy="billing.current.heading">Your plan</span></h2>
+
+<p><span data-copy="billing.current.free">Free. One member, 3 apps, 100 awake hours a month, 250 MB of storage and 1 GB of transfer. Apps sleep after 2 minutes idle.</span></p>
+
+
+
+
+
+
+
+<h2><span data-copy="billing.choose.heading">Choose a plan</span></h2>
+<p class="small"><span data-copy="billing.choose.lede">Prices are per year, before VAT, and depend on your billing country. The exact price is shown before you pay.</span></p>
+
+<form class="signin" method="get" action="/a/bab/billing">
+ <label><span data-copy="billing.country.label">Billing country</span>
+ <select name="country">
+ <option value="" data-copy="billing.country.placeholder">Choose a country</option>
+ <option value="IE" selected>Ireland</option>
+
+ </select>
+ </label>
+ <button class="linkish" type="submit"><span data-copy="billing.quote.button">Show prices for this country</span></button>
+</form>
+
+<p class="small">Prices for Ireland are 100% of the base price, and so are the monthly inclusions.</p>
+
+<form method="post" action="/a/bab/billing/checkout">
+ <input type="hidden" name="csrf" value="ZMN9j5wRsWbKVAXM9H5YrfbyML9QQvNlVAtQB_JE8NQ">
+ <div class="tablewrap">
+ <table>
+
+ <tr>
+ <td><label><input type="radio" name="plan" value="personal" required>
+ <span data-copy="billing.plan.personal.name">Personal</span></label></td>
+ <td><strong>€24</strong> <span data-copy="billing.price.peryear">a year</span></td>
+ <td class="small"><span data-copy="billing.plan.personal.blurb">One member. Each month: 85 awake hours, 2 GB storage, 2 GB transfer.</span></td>
+ </tr>
+
+ <tr>
+ <td><label><input type="radio" name="plan" value="community" required>
+ <span data-copy="billing.plan.community.name">Community</span></label></td>
+ <td><strong>€5</strong> <span data-copy="billing.price.permember">per member a year</span>
+ <br><span class="small">€50 <span data-copy="billing.price.peryear">a year</span></span></td>
+ <td class="small"><span data-copy="billing.plan.community.blurb">Per member, minimum 10. Each member adds, per month: 17 awake hours, 0.5 GB storage, 0.4 GB transfer.</span></td>
+ </tr>
+
+ <tr>
+ <td><label><input type="radio" name="plan" value="business" required>
+ <span data-copy="billing.plan.business.name">Business</span></label></td>
+ <td><strong>€50</strong> <span data-copy="billing.price.permember">per member a year</span>
+ <br><span class="small">€500 <span data-copy="billing.price.peryear">a year</span></span></td>
+ <td class="small"><span data-copy="billing.plan.business.blurb">Per member, minimum 10. Each member adds, per month: 170 awake hours, 5 GB storage, 3 GB transfer, 30,000 replication writes. Priority support.</span></td>
+ </tr>
+
+ </table>
+ </div>
+ <p class="small"><span data-copy="billing.plan.community.name">Community</span> and <span data-copy="billing.plan.business.name">Business</span> are priced per member. You have 1 members; the minimum is 10.</p>
+ <p><label><span data-copy="billing.country.label">Billing country</span>
+ <select name="country" required>
+ <option value="" data-copy="billing.country.placeholder">Choose a country</option>
+ <option value="IE" selected>Ireland</option>
+
+ </select>
+ </label></p>
+ <p><label><span data-copy="billing.postal.label">Postal code</span>
+ <input type="text" name="postal" autocomplete="postal-code">
+ </label></p>
+ <p class="small"><span data-copy="billing.postal.help">Needed in the United States, Canada and India, where tax depends on it. Leave it empty elsewhere. We do not keep it.</span></p>
+ <p><label><input type="checkbox" name="consent" value="2026-09-07-v1" required>
+ I have read <a href="https://carloku.com/pricing/">the pricing page</a> and agree to be charged yearly at the price shown at checkout, plus usage beyond the plan's inclusions billed monthly.</label></p>
+ <button type="submit"><span data-copy="billing.pay.button">Continue to payment</span></button>
+ <p class="small"><span data-copy="billing.pay.note">You pay by card at Stripe. Nothing is charged until you confirm there.</span></p>
+</form>
+
+
+
+
+
+
+</main></section>
+<section class="preview-state"><p class="preview-label">Preview state: After a failed checkout attempt (error=unavailable)</p><main>
+
+
+
+
+
+<p class="banner error"><span data-copy="billing.error.unavailable">Billing is not available right now. Nothing was charged. Try again in a few minutes.</span></p>
+
+
+
+
+<div class="pagehead">
+ <p class="eyebrow">Account · ops</p>
+ <h1>Billing</h1>
+ <p class="lede"><span data-copy="billing.lede">Your plan, what it includes, and how to change it.</span></p>
+</div>
+
+<h2><span data-copy="billing.current.heading">Your plan</span></h2>
+
+<p><span data-copy="billing.current.free">Free. One member, 3 apps, 100 awake hours a month, 250 MB of storage and 1 GB of transfer. Apps sleep after 2 minutes idle.</span></p>
+
+
+
+
+
+
+
+<h2><span data-copy="billing.choose.heading">Choose a plan</span></h2>
+<p class="small"><span data-copy="billing.choose.lede">Prices are per year, before VAT, and depend on your billing country. The exact price is shown before you pay.</span></p>
+
+<form class="signin" method="get" action="/a/bab/billing">
+ <label><span data-copy="billing.country.label">Billing country</span>
+ <select name="country">
+ <option value="" data-copy="billing.country.placeholder">Choose a country</option>
+ <option value="IE">Ireland</option>
+
+ </select>
+ </label>
+ <button class="linkish" type="submit"><span data-copy="billing.quote.button">Show prices for this country</span></button>
+</form>
+
+
+
+<form method="post" action="/a/bab/billing/checkout">
+ <input type="hidden" name="csrf" value="ZMN9j5wRsWbKVAXM9H5YrfbyML9QQvNlVAtQB_JE8NQ">
+ <div class="tablewrap">
+ <table>
+
+ <tr>
+ <td><label><input type="radio" name="plan" value="personal" required>
+ <span data-copy="billing.plan.personal.name">Personal</span></label></td>
+ <td><strong>€24</strong> <span data-copy="billing.price.peryear">a year</span></td>
+ <td class="small"><span data-copy="billing.plan.personal.blurb">One member. Each month: 85 awake hours, 2 GB storage, 2 GB transfer.</span></td>
+ </tr>
+
+ <tr>
+ <td><label><input type="radio" name="plan" value="community" required>
+ <span data-copy="billing.plan.community.name">Community</span></label></td>
+ <td><strong>€5</strong> <span data-copy="billing.price.permember">per member a year</span>
+ <br><span class="small">€50 <span data-copy="billing.price.peryear">a year</span></span></td>
+ <td class="small"><span data-copy="billing.plan.community.blurb">Per member, minimum 10. Each member adds, per month: 17 awake hours, 0.5 GB storage, 0.4 GB transfer.</span></td>
+ </tr>
+
+ <tr>
+ <td><label><input type="radio" name="plan" value="business" required>
+ <span data-copy="billing.plan.business.name">Business</span></label></td>
+ <td><strong>€50</strong> <span data-copy="billing.price.permember">per member a year</span>
+ <br><span class="small">€500 <span data-copy="billing.price.peryear">a year</span></span></td>
+ <td class="small"><span data-copy="billing.plan.business.blurb">Per member, minimum 10. Each member adds, per month: 170 awake hours, 5 GB storage, 3 GB transfer, 30,000 replication writes. Priority support.</span></td>
+ </tr>
+
+ </table>
+ </div>
+ <p class="small"><span data-copy="billing.plan.community.name">Community</span> and <span data-copy="billing.plan.business.name">Business</span> are priced per member. You have 1 members; the minimum is 10.</p>
+ <p><label><span data-copy="billing.country.label">Billing country</span>
+ <select name="country" required>
+ <option value="" data-copy="billing.country.placeholder">Choose a country</option>
+ <option value="IE">Ireland</option>
+
+ </select>
+ </label></p>
+ <p><label><span data-copy="billing.postal.label">Postal code</span>
+ <input type="text" name="postal" autocomplete="postal-code">
+ </label></p>
+ <p class="small"><span data-copy="billing.postal.help">Needed in the United States, Canada and India, where tax depends on it. Leave it empty elsewhere. We do not keep it.</span></p>
+ <p><label><input type="checkbox" name="consent" value="2026-09-07-v1" required>
+ I have read <a href="https://carloku.com/pricing/">the pricing page</a> and agree to be charged yearly at the price shown at checkout, plus usage beyond the plan's inclusions billed monthly.</label></p>
+ <button type="submit"><span data-copy="billing.pay.button">Continue to payment</span></button>
+ <p class="small"><span data-copy="billing.pay.note">You pay by card at Stripe. Nothing is charged until you confirm there.</span></p>
+</form>
+
+
+
+
+
+
+</main></section>
+<section class="preview-state"><p class="preview-label">Preview state: A second checkout while one is settling (error=pending)</p><main>
+
+
+
+
+
+
+<p class="banner error"><span data-copy="billing.error.pending">An earlier checkout for this account is still completing. Wait a minute and reload this page before trying again.</span></p>
+
+
+
+<div class="pagehead">
+ <p class="eyebrow">Account · ops</p>
+ <h1>Billing</h1>
+ <p class="lede"><span data-copy="billing.lede">Your plan, what it includes, and how to change it.</span></p>
+</div>
+
+<h2><span data-copy="billing.current.heading">Your plan</span></h2>
+
+<p><span data-copy="billing.current.free">Free. One member, 3 apps, 100 awake hours a month, 250 MB of storage and 1 GB of transfer. Apps sleep after 2 minutes idle.</span></p>
+
+
+
+
+
+
+
+<h2><span data-copy="billing.choose.heading">Choose a plan</span></h2>
+<p class="small"><span data-copy="billing.choose.lede">Prices are per year, before VAT, and depend on your billing country. The exact price is shown before you pay.</span></p>
+
+<form class="signin" method="get" action="/a/bab/billing">
+ <label><span data-copy="billing.country.label">Billing country</span>
+ <select name="country">
+ <option value="" data-copy="billing.country.placeholder">Choose a country</option>
+ <option value="IE">Ireland</option>
+
+ </select>
+ </label>
+ <button class="linkish" type="submit"><span data-copy="billing.quote.button">Show prices for this country</span></button>
+</form>
+
+
+
+<form method="post" action="/a/bab/billing/checkout">
+ <input type="hidden" name="csrf" value="ZMN9j5wRsWbKVAXM9H5YrfbyML9QQvNlVAtQB_JE8NQ">
+ <div class="tablewrap">
+ <table>
+
+ <tr>
+ <td><label><input type="radio" name="plan" value="personal" required>
+ <span data-copy="billing.plan.personal.name">Personal</span></label></td>
+ <td><strong>€24</strong> <span data-copy="billing.price.peryear">a year</span></td>
+ <td class="small"><span data-copy="billing.plan.personal.blurb">One member. Each month: 85 awake hours, 2 GB storage, 2 GB transfer.</span></td>
+ </tr>
+
+ <tr>
+ <td><label><input type="radio" name="plan" value="community" required>
+ <span data-copy="billing.plan.community.name">Community</span></label></td>
+ <td><strong>€5</strong> <span data-copy="billing.price.permember">per member a year</span>
+ <br><span class="small">€50 <span data-copy="billing.price.peryear">a year</span></span></td>
+ <td class="small"><span data-copy="billing.plan.community.blurb">Per member, minimum 10. Each member adds, per month: 17 awake hours, 0.5 GB storage, 0.4 GB transfer.</span></td>
+ </tr>
+
+ <tr>
+ <td><label><input type="radio" name="plan" value="business" required>
+ <span data-copy="billing.plan.business.name">Business</span></label></td>
+ <td><strong>€50</strong> <span data-copy="billing.price.permember">per member a year</span>
+ <br><span class="small">€500 <span data-copy="billing.price.peryear">a year</span></span></td>
+ <td class="small"><span data-copy="billing.plan.business.blurb">Per member, minimum 10. Each member adds, per month: 170 awake hours, 5 GB storage, 3 GB transfer, 30,000 replication writes. Priority support.</span></td>
+ </tr>
+
+ </table>
+ </div>
+ <p class="small"><span data-copy="billing.plan.community.name">Community</span> and <span data-copy="billing.plan.business.name">Business</span> are priced per member. You have 1 members; the minimum is 10.</p>
+ <p><label><span data-copy="billing.country.label">Billing country</span>
+ <select name="country" required>
+ <option value="" data-copy="billing.country.placeholder">Choose a country</option>
+ <option value="IE">Ireland</option>
+
+ </select>
+ </label></p>
+ <p><label><span data-copy="billing.postal.label">Postal code</span>
+ <input type="text" name="postal" autocomplete="postal-code">
+ </label></p>
+ <p class="small"><span data-copy="billing.postal.help">Needed in the United States, Canada and India, where tax depends on it. Leave it empty elsewhere. We do not keep it.</span></p>
+ <p><label><input type="checkbox" name="consent" value="2026-09-07-v1" required>
+ I have read <a href="https://carloku.com/pricing/">the pricing page</a> and agree to be charged yearly at the price shown at checkout, plus usage beyond the plan's inclusions billed monthly.</label></p>
+ <button type="submit"><span data-copy="billing.pay.button">Continue to payment</span></button>
+ <p class="small"><span data-copy="billing.pay.note">You pay by card at Stripe. Nothing is charged until you confirm there.</span></p>
+</form>
+
+
+
+
+
+
+</main></section>
+<section class="preview-state"><p class="preview-label">Preview state: The account changed under the form (error=conflict)</p><main>
+
+
+
+
+
+
+
+<p class="banner error"><span data-copy="billing.error.conflict">The account changed while you were on this page, for example a member was added. Check the plan and try again.</span></p>
+
+
+<div class="pagehead">
+ <p class="eyebrow">Account · ops</p>
+ <h1>Billing</h1>
+ <p class="lede"><span data-copy="billing.lede">Your plan, what it includes, and how to change it.</span></p>
+</div>
+
+<h2><span data-copy="billing.current.heading">Your plan</span></h2>
+
+<p><span data-copy="billing.current.free">Free. One member, 3 apps, 100 awake hours a month, 250 MB of storage and 1 GB of transfer. Apps sleep after 2 minutes idle.</span></p>
+
+
+
+
+
+
+
+<h2><span data-copy="billing.choose.heading">Choose a plan</span></h2>
+<p class="small"><span data-copy="billing.choose.lede">Prices are per year, before VAT, and depend on your billing country. The exact price is shown before you pay.</span></p>
+
+<form class="signin" method="get" action="/a/bab/billing">
+ <label><span data-copy="billing.country.label">Billing country</span>
+ <select name="country">
+ <option value="" data-copy="billing.country.placeholder">Choose a country</option>
+ <option value="IE">Ireland</option>
+
+ </select>
+ </label>
+ <button class="linkish" type="submit"><span data-copy="billing.quote.button">Show prices for this country</span></button>
+</form>
+
+
+
+<form method="post" action="/a/bab/billing/checkout">
+ <input type="hidden" name="csrf" value="ZMN9j5wRsWbKVAXM9H5YrfbyML9QQvNlVAtQB_JE8NQ">
+ <div class="tablewrap">
+ <table>
+
+ <tr>
+ <td><label><input type="radio" name="plan" value="personal" required>
+ <span data-copy="billing.plan.personal.name">Personal</span></label></td>
+ <td><strong>€24</strong> <span data-copy="billing.price.peryear">a year</span></td>
+ <td class="small"><span data-copy="billing.plan.personal.blurb">One member. Each month: 85 awake hours, 2 GB storage, 2 GB transfer.</span></td>
+ </tr>
+
+ <tr>
+ <td><label><input type="radio" name="plan" value="community" required>
+ <span data-copy="billing.plan.community.name">Community</span></label></td>
+ <td><strong>€5</strong> <span data-copy="billing.price.permember">per member a year</span>
+ <br><span class="small">€50 <span data-copy="billing.price.peryear">a year</span></span></td>
+ <td class="small"><span data-copy="billing.plan.community.blurb">Per member, minimum 10. Each member adds, per month: 17 awake hours, 0.5 GB storage, 0.4 GB transfer.</span></td>
+ </tr>
+
+ <tr>
+ <td><label><input type="radio" name="plan" value="business" required>
+ <span data-copy="billing.plan.business.name">Business</span></label></td>
+ <td><strong>€50</strong> <span data-copy="billing.price.permember">per member a year</span>
+ <br><span class="small">€500 <span data-copy="billing.price.peryear">a year</span></span></td>
+ <td class="small"><span data-copy="billing.plan.business.blurb">Per member, minimum 10. Each member adds, per month: 170 awake hours, 5 GB storage, 3 GB transfer, 30,000 replication writes. Priority support.</span></td>
+ </tr>
+
+ </table>
+ </div>
+ <p class="small"><span data-copy="billing.plan.community.name">Community</span> and <span data-copy="billing.plan.business.name">Business</span> are priced per member. You have 1 members; the minimum is 10.</p>
+ <p><label><span data-copy="billing.country.label">Billing country</span>
+ <select name="country" required>
+ <option value="" data-copy="billing.country.placeholder">Choose a country</option>
+ <option value="IE">Ireland</option>
+
+ </select>
+ </label></p>
+ <p><label><span data-copy="billing.postal.label">Postal code</span>
+ <input type="text" name="postal" autocomplete="postal-code">
+ </label></p>
+ <p class="small"><span data-copy="billing.postal.help">Needed in the United States, Canada and India, where tax depends on it. Leave it empty elsewhere. We do not keep it.</span></p>
+ <p><label><input type="checkbox" name="consent" value="2026-09-07-v1" required>
+ I have read <a href="https://carloku.com/pricing/">the pricing page</a> and agree to be charged yearly at the price shown at checkout, plus usage beyond the plan's inclusions billed monthly.</label></p>
+ <button type="submit"><span data-copy="billing.pay.button">Continue to payment</span></button>
+ <p class="small"><span data-copy="billing.pay.note">You pay by card at Stripe. Nothing is charged until you confirm there.</span></p>
+</form>
+
+
+
+
+
+
+</main></section>
+<section class="preview-state"><p class="preview-label">Preview state: Back from Stripe, webhook not yet arrived (pending=1)</p><main>
+
+
+
+
+
+
+
+
+<p class="banner"><span data-copy="billing.current.pending">Thanks. Stripe is confirming your payment. Your plan turns on within a few minutes, and this page will show it.</span></p>
+
+<div class="pagehead">
+ <p class="eyebrow">Account · ops</p>
+ <h1>Billing</h1>
+ <p class="lede"><span data-copy="billing.lede">Your plan, what it includes, and how to change it.</span></p>
+</div>
+
+<h2><span data-copy="billing.current.heading">Your plan</span></h2>
+
+<p><span data-copy="billing.current.free">Free. One member, 3 apps, 100 awake hours a month, 250 MB of storage and 1 GB of transfer. Apps sleep after 2 minutes idle.</span></p>
+
+
+
+
+
+
+
+<h2><span data-copy="billing.choose.heading">Choose a plan</span></h2>
+<p class="small"><span data-copy="billing.choose.lede">Prices are per year, before VAT, and depend on your billing country. The exact price is shown before you pay.</span></p>
+
+<form class="signin" method="get" action="/a/bab/billing">
+ <label><span data-copy="billing.country.label">Billing country</span>
+ <select name="country">
+ <option value="" data-copy="billing.country.placeholder">Choose a country</option>
+ <option value="IE">Ireland</option>
+
+ </select>
+ </label>
+ <button class="linkish" type="submit"><span data-copy="billing.quote.button">Show prices for this country</span></button>
+</form>
+
+
+
+<form method="post" action="/a/bab/billing/checkout">
+ <input type="hidden" name="csrf" value="ZMN9j5wRsWbKVAXM9H5YrfbyML9QQvNlVAtQB_JE8NQ">
+ <div class="tablewrap">
+ <table>
+
+ <tr>
+ <td><label><input type="radio" name="plan" value="personal" required>
+ <span data-copy="billing.plan.personal.name">Personal</span></label></td>
+ <td><strong>€24</strong> <span data-copy="billing.price.peryear">a year</span></td>
+ <td class="small"><span data-copy="billing.plan.personal.blurb">One member. Each month: 85 awake hours, 2 GB storage, 2 GB transfer.</span></td>
+ </tr>
+
+ <tr>
+ <td><label><input type="radio" name="plan" value="community" required>
+ <span data-copy="billing.plan.community.name">Community</span></label></td>
+ <td><strong>€5</strong> <span data-copy="billing.price.permember">per member a year</span>
+ <br><span class="small">€50 <span data-copy="billing.price.peryear">a year</span></span></td>
+ <td class="small"><span data-copy="billing.plan.community.blurb">Per member, minimum 10. Each member adds, per month: 17 awake hours, 0.5 GB storage, 0.4 GB transfer.</span></td>
+ </tr>
+
+ <tr>
+ <td><label><input type="radio" name="plan" value="business" required>
+ <span data-copy="billing.plan.business.name">Business</span></label></td>
+ <td><strong>€50</strong> <span data-copy="billing.price.permember">per member a year</span>
+ <br><span class="small">€500 <span data-copy="billing.price.peryear">a year</span></span></td>
+ <td class="small"><span data-copy="billing.plan.business.blurb">Per member, minimum 10. Each member adds, per month: 170 awake hours, 5 GB storage, 3 GB transfer, 30,000 replication writes. Priority support.</span></td>
+ </tr>
+
+ </table>
+ </div>
+ <p class="small"><span data-copy="billing.plan.community.name">Community</span> and <span data-copy="billing.plan.business.name">Business</span> are priced per member. You have 1 members; the minimum is 10.</p>
+ <p><label><span data-copy="billing.country.label">Billing country</span>
+ <select name="country" required>
+ <option value="" data-copy="billing.country.placeholder">Choose a country</option>
+ <option value="IE">Ireland</option>
+
+ </select>
+ </label></p>
+ <p><label><span data-copy="billing.postal.label">Postal code</span>
+ <input type="text" name="postal" autocomplete="postal-code">
+ </label></p>
+ <p class="small"><span data-copy="billing.postal.help">Needed in the United States, Canada and India, where tax depends on it. Leave it empty elsewhere. We do not keep it.</span></p>
+ <p><label><input type="checkbox" name="consent" value="2026-09-07-v1" required>
+ I have read <a href="https://carloku.com/pricing/">the pricing page</a> and agree to be charged yearly at the price shown at checkout, plus usage beyond the plan's inclusions billed monthly.</label></p>
+ <button type="submit"><span data-copy="billing.pay.button">Continue to payment</span></button>
+ <p class="small"><span data-copy="billing.pay.note">You pay by card at Stripe. Nothing is charged until you confirm there.</span></p>
+</form>
+
+
+
+
+
+
+</main></section>
+<section class="preview-state"><p class="preview-label">Preview state: Active Personal subscription, portal available</p><main>
+
+
+
+
+
+
+
+
+
+
+<div class="pagehead">
+ <p class="eyebrow">Account · ops</p>
+ <h1>Billing</h1>
+ <p class="lede"><span data-copy="billing.lede">Your plan, what it includes, and how to change it.</span></p>
+</div>
+
+<h2><span data-copy="billing.current.heading">Your plan</span></h2>
+
+<p><span data-copy="billing.plan.personal.name">Personal</span>, 1 members, renews on 2027-09-30.</p>
+
+
+
+
+
+
+
+
+
+<h2><span data-copy="billing.manage.heading">Cards, invoices and cancelling</span></h2>
+<p class="small"><span data-copy="billing.manage.body">Change your card, download invoices or cancel at Stripe. Cancelling keeps the plan until the end of the year you paid for.</span></p>
+<form method="post" action="/a/bab/billing/portal">
+ <input type="hidden" name="csrf" value="ZMN9j5wRsWbKVAXM9H5YrfbyML9QQvNlVAtQB_JE8NQ">
+ <button type="submit"><span data-copy="billing.manage.button">Open billing at Stripe</span></button>
+</form>
+
+
+
+
+</main></section>
+<section class="preview-state"><p class="preview-label">Preview state: Payment failed on renewal (past_due)</p><main>
+
+
+
+
+
+
+
+
+
+
+<div class="pagehead">
+ <p class="eyebrow">Account · ops</p>
+ <h1>Billing</h1>
+ <p class="lede"><span data-copy="billing.lede">Your plan, what it includes, and how to change it.</span></p>
+</div>
+
+<h2><span data-copy="billing.current.heading">Your plan</span></h2>
+
+<p><span data-copy="billing.plan.personal.name">Personal</span>, 1 members, renews on 2027-09-30.</p>
+
+
+<p class="banner error"><span data-copy="billing.current.pastdue">The last payment did not go through. Update your card below to keep the plan.</span></p>
+
+
+
+
+
+
+<h2><span data-copy="billing.manage.heading">Cards, invoices and cancelling</span></h2>
+<p class="small"><span data-copy="billing.manage.body">Change your card, download invoices or cancel at Stripe. Cancelling keeps the plan until the end of the year you paid for.</span></p>
+<form method="post" action="/a/bab/billing/portal">
+ <input type="hidden" name="csrf" value="ZMN9j5wRsWbKVAXM9H5YrfbyML9QQvNlVAtQB_JE8NQ">
+ <button type="submit"><span data-copy="billing.manage.button">Open billing at Stripe</span></button>
+</form>
+
+
+
+
+</main></section>
+<section class="preview-state"><p class="preview-label">Preview state: An operator review hold is open</p><main>
+
+
+
+
+
+
+
+
+
+
+<div class="pagehead">
+ <p class="eyebrow">Account · ops</p>
+ <h1>Billing</h1>
+ <p class="lede"><span data-copy="billing.lede">Your plan, what it includes, and how to change it.</span></p>
+</div>
+
+<h2><span data-copy="billing.current.heading">Your plan</span></h2>
+
+<p><span data-copy="billing.plan.personal.name">Personal</span>, 1 members, renews on 2027-09-30.</p>
+
+
+<p class="banner error"><span data-copy="billing.current.pastdue">The last payment did not go through. Update your card below to keep the plan.</span></p>
+<p class="banner"><span data-copy="billing.current.review">Something about this account's billing needs a person to look at it. Nothing is charged while that happens. We will be in touch.</span></p>
+
+
+
+
+
+<h2><span data-copy="billing.manage.heading">Cards, invoices and cancelling</span></h2>
+<p class="small"><span data-copy="billing.manage.body">Change your card, download invoices or cancel at Stripe. Cancelling keeps the plan until the end of the year you paid for.</span></p>
+<form method="post" action="/a/bab/billing/portal">
+ <input type="hidden" name="csrf" value="ZMN9j5wRsWbKVAXM9H5YrfbyML9QQvNlVAtQB_JE8NQ">
+ <button type="submit"><span data-copy="billing.manage.button">Open billing at Stripe</span></button>
+</form>
+
+
+
+
+</main></section>
+<section class="preview-state"><p class="preview-label">Preview state: A member who is not an owner</p><main>
+
+
+
+
+
+
+
+
+
+
+<div class="pagehead">
+ <p class="eyebrow">Account · ops</p>
+ <h1>Billing</h1>
+ <p class="lede"><span data-copy="billing.lede">Your plan, what it includes, and how to change it.</span></p>
+</div>
+
+<h2><span data-copy="billing.current.heading">Your plan</span></h2>
+
+<p><span data-copy="billing.plan.personal.name">Personal</span>, 1 members, renews on 2027-09-30.</p>
+
+
+<p class="banner error"><span data-copy="billing.current.pastdue">The last payment did not go through. Update your card below to keep the plan.</span></p>
+<p class="banner"><span data-copy="billing.current.review">Something about this account's billing needs a person to look at it. Nothing is charged while that happens. We will be in touch.</span></p>
+
+
+<p class="small"><span data-copy="billing.member.readonly">Only an account owner can change the plan.</span></p>
+
+
+
+</main></section>
+<section class="preview-state"><p class="preview-label">Preview state: Billing switched off on this deployment (production today)</p><main>
+
+
+
+
+
+
+
+
+
+
+<div class="pagehead">
+ <p class="eyebrow">Account · ops</p>
+ <h1>Billing</h1>
+ <p class="lede"><span data-copy="billing.lede">Your plan, what it includes, and how to change it.</span></p>
+</div>
+
+<h2><span data-copy="billing.current.heading">Your plan</span></h2>
+
+<p><span data-copy="billing.current.free">Free. One member, 3 apps, 100 awake hours a month, 250 MB of storage and 1 GB of transfer. Apps sleep after 2 minutes idle.</span></p>
+
+
+
+
+
+
+<p><span data-copy="billing.closed">Paid plans are not open on this console yet. Every account is on the Free plan.</span></p>
+
+
+
+</main></section></body>
+</html>
diff --git a/implementation/carloku-paid-launch/copy-review/billing-page/strings.json b/implementation/carloku-paid-launch/copy-review/billing-page/strings.json
new file mode 100644
index 000000000..27580d44b
--- /dev/null
+++ b/implementation/carloku-paid-launch/copy-review/billing-page/strings.json
@@ -0,0 +1,41 @@
+[
+ {"id":"billing.title","section":"Billing page","label":"Page title","text":"Billing","context":"The browser tab title and the page heading of the account's billing page in the console. The page sits beside Apps, Fleet, Team and Settings in the account navigation.","notes":"One word if possible"},
+ {"id":"billing.nav","section":"Billing page","label":"Navigation label","text":"Billing","context":"The link to this page in the account navigation bar, next to Apps, Fleet, Team and Settings."},
+ {"id":"billing.lede","section":"Billing page","label":"Lede","text":"Your plan, what it includes, and how to change it.","context":"Sits under the page heading. One sentence saying what the page is for."},
+ {"id":"billing.closed","section":"Billing page","label":"Paid plans not open","text":"Paid plans are not open on this console yet. Every account is on the Free plan.","context":"Shown instead of the plan chooser when billing is switched off on this deployment. This is what production shows until launch day."},
+ {"id":"billing.current.heading","section":"Current plan","label":"Section heading","text":"Your plan","context":"Heading over the block that names the account's current plan and its state."},
+ {"id":"billing.current.free","section":"Current plan","label":"On the Free plan","text":"Free. One member, 3 apps, 100 awake hours a month, 250 MB of storage and 1 GB of transfer. Apps sleep after 2 minutes idle.","context":"The line shown when the account has no paid subscription. It states the plan and its limits so the reader knows what they are on before they compare."},
+ {"id":"billing.current.active","section":"Current plan","label":"Active subscription","text":"{{.PlanName}}, {{.Quantity}} members, renews on {{.Renews}}.","context":"The line shown when a paid subscription is active. The placeholders are filled in: plan name, member count paid for, renewal date. Keep the three placeholders.","notes":"Keep {{.PlanName}}, {{.Quantity}} and {{.Renews}} exactly"},
+ {"id":"billing.current.pastdue","section":"Current plan","label":"Payment failed","text":"The last payment did not go through. Update your card below to keep the plan.","context":"Shown under the plan line when Stripe reports the subscription as past due or unpaid. It tells the reader what to do without alarm."},
+ {"id":"billing.current.ending","section":"Current plan","label":"Cancelled, still running","text":"This plan ends on {{.Renews}} and will not renew.","context":"Shown when the subscriber has cancelled and the plan is running out its paid year. The placeholder is the end date.","notes":"Keep {{.Renews}}"},
+ {"id":"billing.current.pending","section":"Current plan","label":"Checkout completing","text":"Thanks. Stripe is confirming your payment. Your plan turns on within a few minutes, and this page will show it.","context":"Shown at the top of the page when the reader has just come back from paying at Stripe, before the confirmation has arrived. Reassures them nothing else is needed."},
+ {"id":"billing.current.review","section":"Current plan","label":"Under review","text":"Something about this account's billing needs a person to look at it. Nothing is charged while that happens. We will be in touch.","context":"Shown when an operator review hold is open on the account, which stops any charge until a person clears it."},
+ {"id":"billing.members.note","section":"Current plan","label":"Member count","text":"Community and Business are priced per member. You have {{.Members}} members; the minimum is 10.","context":"Sits under the plan chooser for team plans. Explains why the price is for at least ten members even if the account has fewer people.","notes":"Keep {{.Members}}"},
+ {"id":"billing.choose.heading","section":"Choose a plan","label":"Section heading","text":"Choose a plan","context":"Heading over the form where an account owner picks a paid plan, sets the billing country and goes to pay."},
+ {"id":"billing.choose.lede","section":"Choose a plan","label":"Section lede","text":"Prices are per year, before VAT, and depend on your billing country. The exact price is shown before you pay.","context":"One or two sentences under the Choose a plan heading. Prepares the reader for the country field and for the price being confirmed at checkout."},
+ {"id":"billing.plan.personal.name","section":"Choose a plan","label":"Personal plan name","text":"Personal","context":"The name of the single-person paid plan, used on the radio button, on Stripe's checkout page and on invoices."},
+ {"id":"billing.plan.personal.blurb","section":"Choose a plan","label":"Personal plan line","text":"One member. Each month: 85 awake hours, 2 GB storage, 2 GB transfer.","context":"The one-line description under the Personal radio button."},
+ {"id":"billing.plan.community.name","section":"Choose a plan","label":"Community plan name","text":"Community","context":"The name of the low-cost team plan for clubs, classes and open-source groups. Used on the radio button, on Stripe's checkout page and on invoices."},
+ {"id":"billing.plan.community.blurb","section":"Choose a plan","label":"Community plan line","text":"Per member, minimum 10. Each member adds, per month: 17 awake hours, 0.5 GB storage, 0.4 GB transfer.","context":"The one-line description under the Community radio button."},
+ {"id":"billing.plan.business.name","section":"Choose a plan","label":"Business plan name","text":"Business","context":"The name of the team plan for companies. Used on the radio button, on Stripe's checkout page and on invoices."},
+ {"id":"billing.plan.business.blurb","section":"Choose a plan","label":"Business plan line","text":"Per member, minimum 10. Each member adds, per month: 170 awake hours, 5 GB storage, 3 GB transfer, 30,000 replication writes. Priority support.","context":"The one-line description under the Business radio button."},
+ {"id":"billing.price.peryear","section":"Choose a plan","label":"Price suffix, whole account","text":"a year","context":"Follows the euro amount next to the Personal plan, as in €24 a year."},
+ {"id":"billing.price.permember","section":"Choose a plan","label":"Price suffix, per member","text":"per member a year","context":"Follows the euro amount next to Community and Business, as in €5 per member a year."},
+ {"id":"billing.country.label","section":"Choose a plan","label":"Country field label","text":"Billing country","context":"Label of the drop-down where the owner picks the country they are paying from. It sets the price band and the VAT."},
+ {"id":"billing.country.placeholder","section":"Choose a plan","label":"Country drop-down first option","text":"Choose a country","context":"The unselected first option in the country drop-down."},
+ {"id":"billing.postal.label","section":"Choose a plan","label":"Postal code label","text":"Postal code","context":"Label of the text field for a postal or ZIP code. Only required for the United States, Canada and India, where tax depends on it."},
+ {"id":"billing.postal.help","section":"Choose a plan","label":"Postal code help","text":"Needed in the United States, Canada and India, where tax depends on it. Leave it empty elsewhere. We do not keep it.","context":"Small print under the postal code field."},
+ {"id":"billing.quote.button","section":"Choose a plan","label":"Show prices button","text":"Show prices for this country","context":"A secondary button beside the country drop-down. It reloads the page with the exact yearly prices for the chosen country, without going to Stripe."},
+ {"id":"billing.quote.band","section":"Choose a plan","label":"Band explanation","text":"Prices for {{.Country}} are {{.Percent}}% of the base price, and so are the monthly inclusions.","context":"Shown after the owner asks for prices for a country. Explains the affordability band applied. Placeholders are the country name and the percentage (100, 75, 50 or 35).","notes":"Keep {{.Country}} and {{.Percent}}"},
+ {"id":"billing.consent.label","section":"Choose a plan","label":"Terms checkbox","text":"I have read the pricing page and agree to be charged yearly at the price shown at checkout, plus usage beyond the plan's inclusions billed monthly.","context":"The checkbox the owner must tick before the Pay button works. It is the consent record for the terms version in force. It should name what they agree to in one sentence."},
+ {"id":"billing.consent.link","section":"Choose a plan","label":"Pricing page link text","text":"the pricing page","context":"The words inside the consent sentence that link to carloku.com/pricing/. If the consent sentence changes, this must remain a phrase inside it."},
+ {"id":"billing.pay.button","section":"Choose a plan","label":"Pay button","text":"Continue to payment","context":"The main button of the form. It takes the owner to Stripe's hosted checkout page, where the final price with VAT is shown before any card details are entered."},
+ {"id":"billing.pay.note","section":"Choose a plan","label":"Under the pay button","text":"You pay by card at Stripe. Nothing is charged until you confirm there.","context":"Small print directly under the Continue to payment button."},
+ {"id":"billing.manage.heading","section":"Manage","label":"Section heading","text":"Cards, invoices and cancelling","context":"Heading over the button that opens Stripe's customer portal for an account that has paid before."},
+ {"id":"billing.manage.body","section":"Manage","label":"Section text","text":"Change your card, download invoices or cancel at Stripe. Cancelling keeps the plan until the end of the year you paid for.","context":"One or two sentences over the portal button."},
+ {"id":"billing.manage.button","section":"Manage","label":"Portal button","text":"Open billing at Stripe","context":"The button that opens Stripe's customer portal in the same tab."},
+ {"id":"billing.member.readonly","section":"Members","label":"Non-owner note","text":"Only an account owner can change the plan.","context":"Shown to a member who is not an owner, in place of the plan chooser and the portal button."},
+ {"id":"billing.error.unavailable","section":"Errors","label":"Unavailable","text":"Billing is not available right now. Nothing was charged. Try again in a few minutes.","context":"Red banner at the top of the page after a checkout or portal attempt failed for a reason on our side or Stripe's."},
+ {"id":"billing.error.pending","section":"Errors","label":"Previous checkout pending","text":"An earlier checkout for this account is still completing. Wait a minute and reload this page before trying again.","context":"Red banner when the owner tried to start a second checkout while an earlier one has not settled. Prevents a double purchase."},
+ {"id":"billing.error.conflict","section":"Errors","label":"Account changed","text":"The account changed while you were on this page, for example a member was added. Check the plan and try again.","context":"Red banner when the member count or plan state moved between loading the page and pressing pay."}
+]
diff --git a/internal/console/billingpage_preview_test.go b/internal/console/billingpage_preview_test.go
new file mode 100644
index 000000000..c23c79e8b
--- /dev/null
+++ b/internal/console/billingpage_preview_test.go
@@ -0,0 +1,73 @@
+package console
+
+import (
+ "context"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+// TestBillingPageRenderPreview writes the billing page in its main states to
+// CARLOS_BILLING_PREVIEW_DIR, for the copy-review preview
+// (implementation/carloku-paid-launch/copy-review/billing-page). It is a
+// rendering aid, not a check: without the variable it is skipped. Every
+// page comes from the real handlers against the fake Stripe and fake
+// Keymail fixtures; nothing external is touched.
+func TestBillingPageRenderPreview(t *testing.T) {
+ dir := os.Getenv("CARLOS_BILLING_PREVIEW_DIR")
+ if dir == "" {
+ t.Skip("set CARLOS_BILLING_PREVIEW_DIR to render the billing page preview")
+ }
+ write := func(name string, c *http.Client, fx *consoleFixture, path string) {
+ t.Helper()
+ status, _, body := get(t, c, fx.u(path))
+ if status != http.StatusOK {
+ t.Fatalf("%s: GET %s = %d", name, path, status)
+ }
+ if err := os.WriteFile(filepath.Join(dir, name+".html"), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // Owner on Free, quoting Ireland; then the error banners.
+ fx, f, acct := newBillingFixture(t)
+ owner := signInPaul(t, fx)
+ write("owner-free-ie", owner, fx, "/billing?country=IE")
+ write("owner-free-error-pending", owner, fx, "/billing?error=pending")
+ write("owner-free-error-unavailable", owner, fx, "/billing?error=unavailable")
+ write("owner-free-error-conflict", owner, fx, "/billing?error=conflict")
+ // Back from Stripe before the webhook: the pending banner.
+ if _, err := fx.srv.startBillingCheckout(context.Background(), acct, "personal", "IE", "", f.now); err != nil {
+ t.Fatal(err)
+ }
+ write("owner-pending", owner, fx, "/billing?pending=1")
+ // The webhook lands: active, with the portal door.
+ f.mu.Lock()
+ f.sessionStatus = "complete"
+ f.mu.Unlock()
+ if code := f.webhookPost(t, fx, "evt_preview_active", "customer.subscription.updated", "sub_paid"); code != http.StatusNoContent {
+ t.Fatalf("webhook: %d", code)
+ }
+ write("owner-active", owner, fx, "/billing")
+ f.mu.Lock()
+ f.subscriptionStatus = "past_due"
+ f.mu.Unlock()
+ if code := f.webhookPost(t, fx, "evt_preview_past_due", "customer.subscription.updated", "sub_paid"); code != http.StatusNoContent {
+ t.Fatalf("webhook: %d", code)
+ }
+ write("owner-past-due", owner, fx, "/billing")
+ if _, err := fx.st.db.Exec(`INSERT INTO billing_reviews(account_id,subscription,reason,created_at) VALUES(?,?,?,?)`, acct.ID, "sub_paid", "preview_reason", time.Now().Unix()); err != nil {
+ t.Fatal(err)
+ }
+ write("owner-review-hold", owner, fx, "/billing")
+ // A member of the same account (added last: Personal is a one-member plan).
+ if err := fx.st.AddMember(acct.ID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ member, _ := signInAs(t, fx, "kim@keymail.dev", "code-billing-preview-member")
+ write("member-active", member, fx, "/billing")
+ // Billing switched off, as production is today.
+ closed := newConsoleFixture(t)
+ write("closed", signInPaul(t, closed), closed, "/billing")
+}