| 1 | <!-- 🤖 Public "books" page (issue #550 Parts 11/12, decision #19: income |
| 2 | publishes AGGREGATED, never per-payer — and expenses publish ITEMIZED, |
| 3 | the deliberate mirror image). Fully self-contained: inline CSS/JS, no |
| 4 | external requests except ./books.json (relative). Published by |
| 5 | deploy/status-publish.sh from Stripe aggregates (totals only) plus |
| 6 | per-provider infrastructure spend. --> |
| 7 | <!doctype html> |
| 8 | <html lang="en"> |
| 9 | <head> |
| 10 | <meta charset="utf-8" /> |
| 11 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 12 | <meta name="color-scheme" content="light dark" /> |
| 13 | <title>Eleven — the books</title> |
| 14 | <link rel="icon" type="image/png" href="./icon-192.png" /> |
| 15 | <link rel="stylesheet" href="./status.css" /> |
| 16 | <style> |
| 17 | /* Books-only: the per-tier bars label, the itemized expense ledger, and the |
| 18 | income-unavailable notice (shared #error covers the feed-load error). |
| 19 | Everything shared lives in status.css. */ |
| 20 | section h3 { font-size: 14px; font-weight: 650; margin: 18px 0 10px; } |
| 21 | |
| 22 | .bars .tier { font-size: 14px; font-weight: 600; text-transform: capitalize; overflow-wrap: anywhere; } |
| 23 | |
| 24 | /* Expenditure ledger: one line per provider, its own billing currency. */ |
| 25 | .ledger { list-style: none; padding: 0; display: grid; gap: 0; } |
| 26 | .ledger li { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 9px 2px; border-bottom: 1px solid var(--border); font-size: 15px; } |
| 27 | .ledger .amt { font-variant-numeric: tabular-nums; font-weight: 600; white-space: nowrap; } |
| 28 | .ledger-total { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 11px 2px 0; font-weight: 700; } |
| 29 | .ledger-total .amt { font-variant-numeric: tabular-nums; white-space: nowrap; } |
| 30 | .ledger-total .caveat { font-weight: 400; font-size: 13px; color: var(--ink-2); } |
| 31 | |
| 32 | #unavailable { display: none; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-bottom: 24px; } |
| 33 | #unavailable.show { display: block; } |
| 34 | </style> |
| 35 | </head> |
| 36 | <body> |
| 37 | <div class="wrap"> |
| 38 | <header data-title="The books"></header> |
| 39 | <nav class="status-nav" aria-label="Status"></nav> |
| 40 | <script src="./status-nav.js"></script> |
| 41 | <p class="lede">Eleven’s income and spending, published as they stand — part |
| 42 | of running the service in the open.</p> |
| 43 | <p class="asof">As of <time id="asof" datetime="">—</time><span id="staleness"></span></p> |
| 44 | |
| 45 | <p id="error" role="alert">Couldn’t load the books feed. It may be mid-publish — this page retries every minute.</p> |
| 46 | |
| 47 | <div id="books"> |
| 48 | <!-- Two clearly-headed halves, the deliberate mirror image of each other: |
| 49 | income aggregates, expenditure itemizes (decision #19). Each section |
| 50 | renders independently — a degraded income feed shows its notice here, |
| 51 | inside Income, and never hides Expenditure (or vice versa). --> |
| 52 | <section aria-labelledby="h-income" id="s-income"> |
| 53 | <h2 id="h-income">Income</h2> |
| 54 | <p class="note explain"><strong>Aggregated by design:</strong> our own |
| 55 | records hold no customer identities (they live in Stripe, where we can |
| 56 | see them), and what appears here is totals only — never a per-payer |
| 57 | line, never an individual payment.</p> |
| 58 | <p id="unavailable" role="status">Income is unavailable right now: <span id="reason"></span>.</p> |
| 59 | <div id="income-body"> |
| 60 | <div class="tiles"> |
| 61 | <div class="tile"><div class="label">Active subscriptions</div><div class="value" id="t-active">—</div></div> |
| 62 | <div class="tile"><div class="label">Monthly recurring revenue</div><div class="value" id="t-mrr">—</div></div> |
| 63 | <div class="tile"><div class="label" id="l-collected">Collected this month</div><div class="value" id="t-collected">—</div></div> |
| 64 | </div> |
| 65 | <h3 id="h-tiers">Subscriptions by tier</h3> |
| 66 | <ul class="bars" id="tiers"><li><span class="empty">Loading…</span></li></ul> |
| 67 | <!-- Spaces selling to their own members (Stripe Connect). The gross |
| 68 | volume is deliberately public — if we know it, you know it — but |
| 69 | it is the spaces' money, not Eleven's: our income from these |
| 70 | sales is the commission line. Hidden entirely when the feed |
| 71 | predates these fields; zeros render honestly once it carries |
| 72 | them. --> |
| 73 | <div id="s-connect" hidden> |
| 74 | <h3 id="h-connect">Spaces selling to their members</h3> |
| 75 | <p class="note explain">Spaces can sell their own subscriptions, |
| 76 | paid straight to the space’s owner — that money is theirs, not |
| 77 | ours. We publish the total sold because we can see it, and what we |
| 78 | can see should be public. Eleven’s own earning from these sales is |
| 79 | the commission.</p> |
| 80 | <div class="tiles"> |
| 81 | <div class="tile"><div class="label" id="l-gmv">Sold by spaces</div><div class="value" id="t-gmv">—</div></div> |
| 82 | <div class="tile"><div class="label">Eleven’s commission</div><div class="value" id="t-commission">—</div></div> |
| 83 | </div> |
| 84 | </div> |
| 85 | </div> |
| 86 | </section> |
| 87 | |
| 88 | <section aria-labelledby="h-expenditure" id="s-expenditure" hidden> |
| 89 | <h2 id="h-expenditure">Expenditure</h2> |
| 90 | <p class="note explain"><strong>Itemized by design:</strong> one line per |
| 91 | provider, in that provider’s own billing currency — never converted.</p> |
| 92 | <ul class="ledger" id="exp-lines"></ul> |
| 93 | <div class="ledger-total" id="exp-total"></div> |
| 94 | </section> |
| 95 | </div> |
| 96 | |
| 97 | <footer> |
| 98 | <p>Published every 5 minutes from Stripe aggregates and provider billing. |
| 99 | Tier names are the public price book; income amounts are EUR, expenses in |
| 100 | each provider’s own billing currency. Disbursements join when the public |
| 101 | ledger lands (see the roadmap in docs/planet-scale.md).</p> |
| 102 | </footer> |
| 103 | </div> |
| 104 | |
| 105 | <script> |
| 106 | // contract: the fields this page reads from ./books.json — kept in sync with |
| 107 | // deploy/status-publish.sh and checked by test/status-contract.test.mjs. |
| 108 | // (available/reason come from the degraded shape; the rest from the full one.) |
| 109 | const FIELDS = [ |
| 110 | "generated_at", "available", "reason", |
| 111 | "month", "currency", |
| 112 | "subscriptions.active", "subscriptions.by_tier[].tier", "subscriptions.by_tier[].count", |
| 113 | "mrr_cents", "collected_month_cents", |
| 114 | "connect.gmv_month_cents", "connect.commission_month_cents", |
| 115 | "expenses.month", "expenses.providers[].provider", "expenses.providers[].currency", |
| 116 | "expenses.providers[].month_to_date_cents", "expenses.providers[].monthly_cents", |
| 117 | "expenses.providers[].yearly_cents", "expenses.providers[].to_date_cents", |
| 118 | ]; |
| 119 | |
| 120 | const $ = (id) => document.getElementById(id); |
| 121 | const eur = new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR" }); |
| 122 | |
| 123 | // All rendering is textContent / createElement — never innerHTML of fetched |
| 124 | // data, even though the feed is our own. |
| 125 | function render(d) { |
| 126 | const t = new Date(d.generated_at); |
| 127 | $("asof").dateTime = d.generated_at; |
| 128 | $("asof").textContent = t.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); |
| 129 | const staleEl = $("staleness"); |
| 130 | staleEl.textContent = ""; |
| 131 | staleEl.className = ""; |
| 132 | if (Date.now() - t.getTime() > 20 * 60 * 1000) { |
| 133 | staleEl.className = "stale"; |
| 134 | staleEl.textContent = " — this feed looks stale"; |
| 135 | } |
| 136 | |
| 137 | // Income folds on the available flag INSIDE its own section — the degraded |
| 138 | // notice renders under the Income heading, the heading stays. Expenses ride |
| 139 | // BOTH shapes and render independently: a Stripe outage must not hide |
| 140 | // infrastructure spend the feed can still honestly state (and vice versa: |
| 141 | // no expenses just hides the Expenditure section). |
| 142 | if (!d.available) { |
| 143 | $("reason").textContent = d.reason || "not configured"; |
| 144 | $("unavailable").classList.add("show"); |
| 145 | } else { |
| 146 | $("unavailable").classList.remove("show"); |
| 147 | } |
| 148 | $("income-body").hidden = !d.available; |
| 149 | renderExpenses(d.expenses); |
| 150 | if (!d.available) return; |
| 151 | |
| 152 | $("t-active").textContent = String(d.subscriptions.active); |
| 153 | $("t-mrr").textContent = eur.format(d.mrr_cents / 100); |
| 154 | $("t-collected").textContent = eur.format(d.collected_month_cents / 100); |
| 155 | $("l-collected").textContent = "Collected in " + d.month; |
| 156 | void d.currency; // fixed "eur" today; the formatter would key on it if that changes |
| 157 | |
| 158 | // Connect (spaces' own sales): absent on an older feed → whole block stays |
| 159 | // hidden; present-but-zero renders as honest zeros. |
| 160 | const c = d.connect; |
| 161 | $("s-connect").hidden = !c; |
| 162 | if (c) { |
| 163 | $("t-gmv").textContent = eur.format(c.gmv_month_cents / 100); |
| 164 | $("t-commission").textContent = eur.format(c.commission_month_cents / 100); |
| 165 | $("l-gmv").textContent = "Sold by spaces in " + d.month; |
| 166 | } |
| 167 | |
| 168 | const list = $("tiers"); |
| 169 | list.replaceChildren(); |
| 170 | const tiers = d.subscriptions.by_tier || []; |
| 171 | const max = Math.max(1, ...tiers.map((x) => x.count)); |
| 172 | for (const x of tiers) { |
| 173 | const li = document.createElement("li"); |
| 174 | const name = document.createElement("span"); |
| 175 | name.className = "tier"; |
| 176 | name.textContent = x.tier; |
| 177 | const track = document.createElement("span"); |
| 178 | track.className = "track"; |
| 179 | track.setAttribute("aria-hidden", "true"); |
| 180 | const bar = document.createElement("span"); |
| 181 | bar.className = "bar"; |
| 182 | bar.style.width = (100 * x.count / max) + "%"; |
| 183 | bar.style.display = "block"; |
| 184 | track.append(bar); |
| 185 | const count = document.createElement("span"); |
| 186 | count.className = "count"; |
| 187 | count.textContent = String(x.count); |
| 188 | li.append(name, track, count); |
| 189 | list.append(li); |
| 190 | } |
| 191 | if (!tiers.length) { |
| 192 | const li = document.createElement("li"); |
| 193 | const s = document.createElement("span"); |
| 194 | s.className = "empty"; |
| 195 | s.textContent = "No active subscriptions yet."; |
| 196 | li.append(s); |
| 197 | list.append(li); |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | // One tile per provider, each formatted in the provider's own billing |
| 202 | // currency — never converted (no FX rate = no misstatement). Known providers |
| 203 | // get a friendly label; an unknown future provider still renders honestly |
| 204 | // under its raw name. A provider without a usable integer amount is skipped |
| 205 | // rather than shown as zero. |
| 206 | const EXPENSE_LABELS = { |
| 207 | "aws": "AWS · month to date", |
| 208 | "hetzner": "Hetzner · monthly flat", |
| 209 | "anthropic": "Anthropic API · month to date", |
| 210 | "claude.ai": "claude.ai · to date (approx.)", |
| 211 | "dnsimple": "DNSimple · month to date", |
| 212 | "apple": "Apple Developer · yearly", |
| 213 | }; |
| 214 | |
| 215 | function renderExpenses(exp) { |
| 216 | const sec = $("s-expenditure"); |
| 217 | const providers = (exp && exp.providers) || []; |
| 218 | const lines = $("exp-lines"); |
| 219 | lines.replaceChildren(); |
| 220 | // Totals are PER CURRENCY only — usd and eur never sum into one number |
| 221 | // (no FX), and the caveat owns the other honesty gap: the lines mix |
| 222 | // billing periods (month-to-date, monthly, yearly, to-date), so the tally |
| 223 | // is a scale read, not an invoice. |
| 224 | const totals = {}; |
| 225 | for (const p of providers) { |
| 226 | // Exactly one amount key per provider: metered month-to-date, flat |
| 227 | // monthly, flat yearly, or cumulative to-date — each shown as billed |
| 228 | // (a yearly fee is never divided into a fake monthly; a lifetime figure |
| 229 | // is labelled to date, not smeared across months). |
| 230 | const cents = [p.month_to_date_cents, p.monthly_cents, p.yearly_cents, p.to_date_cents].find((v) => typeof v === "number"); |
| 231 | if (typeof cents !== "number") continue; |
| 232 | const cur = (p.currency || "eur").toUpperCase(); |
| 233 | totals[cur] = (totals[cur] || 0) + cents; |
| 234 | const li = document.createElement("li"); |
| 235 | const label = document.createElement("span"); |
| 236 | label.textContent = EXPENSE_LABELS[p.provider] || p.provider; |
| 237 | const value = document.createElement("span"); |
| 238 | value.className = "amt"; |
| 239 | const fmt = new Intl.NumberFormat("en-IE", { style: "currency", currency: cur }); |
| 240 | value.textContent = fmt.format(cents / 100); |
| 241 | li.append(label, value); |
| 242 | lines.append(li); |
| 243 | } |
| 244 | const totalEl = $("exp-total"); |
| 245 | totalEl.replaceChildren(); |
| 246 | if (lines.childElementCount > 0) { |
| 247 | const label = document.createElement("span"); |
| 248 | label.textContent = "Total"; |
| 249 | const caveat = document.createElement("span"); |
| 250 | caveat.className = "caveat"; |
| 251 | caveat.textContent = "across mixed billing periods"; |
| 252 | const amt = document.createElement("span"); |
| 253 | amt.className = "amt"; |
| 254 | amt.textContent = Object.entries(totals).map(([c, v]) => |
| 255 | new Intl.NumberFormat("en-IE", { style: "currency", currency: c }).format(v / 100)).join(" + "); |
| 256 | label.append(document.createTextNode(" "), caveat); |
| 257 | totalEl.append(label, amt); |
| 258 | } |
| 259 | sec.hidden = lines.childElementCount === 0; |
| 260 | } |
| 261 | |
| 262 | async function refresh() { |
| 263 | try { |
| 264 | const res = await fetch("./books.json", { cache: "no-cache" }); |
| 265 | if (!res.ok) throw new Error("HTTP " + res.status); |
| 266 | render(await res.json()); |
| 267 | $("error").classList.remove("show"); |
| 268 | } catch (e) { |
| 269 | // Keep the previous render (no skeleton flash); surface the failure inline. |
| 270 | $("error").classList.add("show"); |
| 271 | } |
| 272 | } |
| 273 | refresh(); |
| 274 | setInterval(refresh, 60 * 1000); |
| 275 | void FIELDS; |
| 276 | </script> |
| 277 | </body> |
| 278 | </html> |
| 279 | |