eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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.)
109const 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
120const $ = (id) => document.getElementById(id);
121const 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.
125function 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.
206const 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
215function 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
262async 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}
273refresh();
274setInterval(refresh, 60 * 1000);
275void FIELDS;
276</script>
277</body>
278</html>
279