status: publish the tier economics as a seventh status page (#862)
* status: publish the tier economics as a seventh status page The public snapshot of docs/tier-economics.md (the repo is private, so the status site is what makes #562's 'price = cost + resilience' commitment actually checkable): the price book, storage allowances, input rates, the cost model, and per-tier revenue/cost/break-even estimates, beside the books. - web/status/economics.json — committed snapshot data (carbon/security pattern), re-derived by hand when prices or input rates change - web/status/economics.html — viewer page, FIELDS manifest, tables - nav gains Economics on all seven pages; status.css comments updated - deploy/status-publish.sh uploads the two new files - test/status-contract.test.mjs pins the arithmetic: net follows the Stripe fee line, profit ≈ net − cost, margin = profit/gross, and every tier's allowance sits below its break-even (structurally-profitable claim) - docs/status.md: page description, privacy rule (never the per-box instance cap or box disk size — they'd reconstruct the remaining-slot needle), and a 'Keeping the tier-economics page current' section; docs/tier-economics.md cross-references it so a re-derivation updates both in the same change Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * status+docs: honest copy — paying is not anonymous, self-hosting is no-obligation, AI is opt-in Paul's corrections to the tier-economics principles, applied at the source (docs/tier-economics.md), on the new Economics page, and everywhere the same claims echo (billing.go invariant comment, docs/cloud-provisioner.md, docs/planet-scale.md, the planet-scale guide's spaces page, books.html's income explainer): - 'Paying deanonymizes you to Stripe, not to us' was untrue — as the merchant we see names, emails, and card details in Stripe's dashboard. The honest invariant: OUR stores carry no billing identity. - 'Self-hosting is free forever' overclaims — the truth is no-obligation, and a self-hosted space leaning on shared glue (push relay, release feed) is warmly invited, never required, to contribute. - AI: never bundled AND off by default — always opt-in, metered when opted into. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * status+docs: storage allowances become a function of margin (80/20); C&F 10→20 GB Paul's rule: an allowance is the storage a space could fill forever with the tier still keeping ≥80% of gross — 20% of the price buys storage, 80% stays for resilience. allowance = (net − 0.80×gross − compute) ÷ (storage_rate×12), at the tighter monthly billing case. - C&F: 10 → 20 GB (enforced now — comparable box exposure to the long-standing Business 25 GB cap); estimates re-derived (profile 20 GB, cost ~€5.50, margins 84%/81%) - Business: enforced 25 GB holds (box-bound), published off-box target ~1 TB - Family: held at 5 GB as the documented price-bound exception (€5/y buys ~3 GB at strict 80/20; floor ~74%) — held, not shrunk - Free: 1 GB, cost containment (no revenue to derive from) - page + economics.json gain the target column/field; contract test pins target ≥ enforced and target < break-even - box env change (LCHAT_CLOUD_STORAGE_CAPS friends 10→20) ships separately Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * status: economics target column shows every tier's value, not dashes A column of em dashes read as missing data; a repeated value reads as 'already at its 80/20 target', which is the actual message. The ~ stays on Business alone — the one projection not yet enforced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * status: storage allowances render as a plain table again 'Enforced today' / '80/20 target (off-box)' were operator words on a member page — one allowance column, and Business's ~1 TB off-box rise moves into the note as a sentence. The schedule stays machine-readable (target_gb in economics.json, still pinned by the contract test); the page just stops making members parse it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * status+docs: the allowances ARE the targets — Business 1 TB, schedule retired One number per tier (1 GB / 5 GB / 20 GB / 1 TB); target_gb leaves the JSON and the contract test. The published table and LCHAT_CLOUD_STORAGE_CAPS must always match; Business's 1 TB exceeds the box's disk and is honored on demand (attach a volume, minutes) until attachments move off-box to R2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
17 files changed,
+537
−49
billing.go+3 −2deploy/status-publish.sh+11 −5docs/cloud-provisioner.md+8 −4docs/planet-scale-guide/spaces.html+1 −1docs/planet-scale.md+2 −1docs/status.md+35 −4docs/tier-economics.md+49 −23test/status-contract.test.mjs+59 −2web/status/books.html+5 −3web/status/carbon.html+1 −0web/status/contributors.html+1 −0web/status/economics.html+324 −0web/status/economics.json+30 −0web/status/index.html+1 −0web/status/prompts.html+1 −0web/status/security.html+1 −0web/status/status.css+5 −4
diff --git a/billing.go b/billing.go| index d54ac64..a5239f0 100644 |
| --- a/billing.go |
| +++ b/billing.go |
| @@ -10,8 +10,9 @@ package main |
| // |
| // Privacy invariants (see docs/cloud-provisioner.md § Billing): |
| // - We store two opaque Stripe ids, a flag, a tier key, and a period end. |
| -// Never an email, name, address, or card detail — Stripe holds those; |
| -// payment deanonymizes only on Stripe's side (#562's honest residual). |
| +// Never an email, name, address, or card detail — Stripe holds those, |
| +// and the operator can see them in Stripe's dashboard. Paying is not |
| +// anonymous; the invariant is that OUR stores carry no billing identity. |
| // - Checkout Sessions are created WITHOUT customer details; the only thing we |
| // tell Stripe about a space is its numeric ledger id (client_reference_id + |
| // subscription metadata), which is what routes webhook events back to it. |
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh| index 5ff9f39..da44bf9 100755 |
| --- a/deploy/status-publish.sh |
| +++ b/deploy/status-publish.sh |
| @@ -105,11 +105,12 @@ |
| # (an account API token — read-only use here, never |
| # echoed — plus the numeric account id). |
| # /opt/lchat/status-pages/ — the viewer pages, copied from web/status/ |
| -# (status.css, index.html, books.html, carbon.html, |
| -# carbon.json, contributors.html, contributors.json, |
| -# security.html, security.json, prompts.html, |
| -# icon-192.png). status.css is the shared stylesheet every |
| -# page links; it uploads before the pages (see publish()). |
| +# (status.css, index.html, books.html, economics.html, |
| +# economics.json, carbon.html, carbon.json, |
| +# contributors.html, contributors.json, security.html, |
| +# security.json, prompts.html, icon-192.png). status.css |
| +# is the shared stylesheet every page links; it uploads |
| +# before the pages (see publish()). |
| # aws CLI + sqlite3 (apt install awscli sqlite3). |
| set -euo pipefail |
| @@ -1219,6 +1220,11 @@ publish() { |
| [ -f "$PAGES_DIR/status.css" ] && upload "$PAGES_DIR/status.css" status.css "text/css; charset=utf-8" "max-age=300" |
| [ -f "$PAGES_DIR/index.html" ] && upload "$PAGES_DIR/index.html" index.html "text/html; charset=utf-8" "max-age=300" |
| [ -f "$PAGES_DIR/books.html" ] && upload "$PAGES_DIR/books.html" books.html "text/html; charset=utf-8" "max-age=300" |
| + # Tier economics: page + committed snapshot JSON (web/status/economics.json, |
| + # re-derived by hand when prices or input rates change — docs/status.md |
| + # § Keeping the tier-economics page current). Republished as-is. |
| + [ -f "$PAGES_DIR/economics.html" ] && upload "$PAGES_DIR/economics.html" economics.html "text/html; charset=utf-8" "max-age=300" |
| + [ -f "$PAGES_DIR/economics.json" ] && upload "$PAGES_DIR/economics.json" economics.json "application/json" "max-age=300" |
| [ -f "$PAGES_DIR/carbon.html" ] && upload "$PAGES_DIR/carbon.html" carbon.html "text/html; charset=utf-8" "max-age=300" |
| # The carbon tally is committed data (the built ledger) — republished as-is |
| # EXCEPT for a live `run` block injected here (build_carbon). Built untouched, |
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index c4905e2..a8590ed 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -356,8 +356,11 @@ sweep reconciles drift — self-healing, best-effort at every step. A |
| unit-managed instance is bounced (`try-restart`, seconds) so the cap applies |
| now; a hibernating one picks it up at next wake — never wake a space to limit |
| it. Unset = no enforcement, and operator-run instances (not in this ledger) |
| -are never touched. Caps are sized to the box today (see |
| -`tier-economics.md`); they rise when attachments move off-box (#550). |
| +are never touched. Caps follow the 80/20 margin |
| +rule in `tier-economics.md` (an allowance is what 20% of a tier's net buys |
| +in storage, forever) and always match the published allowance table. |
| +Business's 1 TB exceeds the box's disk — honored on demand (attach a Hetzner |
| +volume) until attachments move off-box (#550). |
| ## The usage pipe (#857, #668 slices 2–3) — AI usage into the ledger |
| @@ -436,8 +439,9 @@ key in both metadata blobs — that's how events route back to a space. |
| **Privacy (the point):** the ledger stores two opaque Stripe ids |
| (`stripe_customer_id`, `stripe_subscription_id`), the `entitlement` flag, and |
| `paid_until`. **Never an email, name, address, or card detail** — Stripe holds |
| -those on its side; paying deanonymizes you *to Stripe*, not to the ledger |
| -(#562's documented residual). Checkout Sessions are created with no customer |
| +those on its side, and the operator can see them in Stripe's dashboard. |
| +Paying is not anonymous; the invariant (#562's documented residual) is that |
| +the *ledger* carries no billing identity. Checkout Sessions are created with no customer |
| fields at all. The webhook is the one public unauthenticated-by-token endpoint; |
| its gate is Stripe's HMAC signature over the raw payload (±5 min replay window). |
diff --git a/docs/planet-scale-guide/spaces.html b/docs/planet-scale-guide/spaces.html| index ce7b48c..5437ee3 100644 |
| --- a/docs/planet-scale-guide/spaces.html |
| +++ b/docs/planet-scale-guide/spaces.html |
| @@ -102,7 +102,7 @@ admin link: https://chat-c.example.com/admin#token=9f2…</span></code></p |
| <li><strong>Caller tokens.</strong> Services that may request creations (in practice: the home server) authenticate with tokens the operator mints at runtime — <code>POST /api/admin/tokens</code> with the provisioner’s admin token; tokens are stored hashed and are revocable individually.</li> |
| <li><strong>Quotas.</strong> Each requesting user may hold at most <code>LCHAT_CLOUD_QUOTA</code> (default 3) <em>free</em> spaces at a time; a deleted space frees its slot, and a paid space does not count. There is also an optional fleet-wide circuit breaker, <code>LCHAT_CLOUD_MAX_CREATES_PER_DAY</code>, whose count deliberately survives create-and-delete games.</li> |
| <li><strong>Names.</strong> New spaces get short subdomains like <code>k3f.example.com</code> — an encoding (sqids) of the space’s ledger row number, so names are unique by construction, never reused, and carry no information. The base domain comes from <code>LCHAT_CLOUD_BASE_DOMAIN</code> and needs the wildcard DNS record.</li> |
| - <li><strong>The ledger.</strong> Its own small database (<code>/data/cloud.db</code>) records who created what, plus the dormant billing rails: an opaque Stripe customer pointer and an entitlement flag per space — deliberately no email, name, or card data anywhere. <span class="status partial">partially built</span> (dormant until the Stripe environment variables are set).</li> |
| + <li><strong>The ledger.</strong> Its own small database (<code>/data/cloud.db</code>) records who created what, plus the dormant billing rails: an opaque Stripe customer pointer and an entitlement flag per space — deliberately no email, name, or card data in the ledger itself (identity lives in Stripe, where the operator can see it). <span class="status partial">partially built</span> (dormant until the Stripe environment variables are set).</li> |
| </ul> |
| <p>Upstream of both sits the <strong>home server’s eligibility gate</strong>: it forwards a creation request only for a signed-in account that already holds at least one space backup, which keeps space creation invitation-shaped without the server reading anything encrypted. The member’s identity crosses to the provisioner only as an opaque random identifier — the quota is per that identifier, and no personal information flows down the chain.</p> |
diff --git a/docs/planet-scale.md b/docs/planet-scale.md| index 432827c..a5f0c10 100644 |
| --- a/docs/planet-scale.md |
| +++ b/docs/planet-scale.md |
| @@ -302,7 +302,8 @@ Small PRs that exist so later phases are config, not surgery: |
| - **Billing rails** (PR #628): the cloud ledger's spaces table holds exactly |
| Part 11's shape — `stripe_customer_id` (an opaque pointer into Stripe) and |
| an `entitlement` flag — no email, name, or card detail anywhere in our |
| - stores. Dormant until the Stripe env is set. |
| + stores (identity lives in Stripe, where the operator can see it). Dormant |
| + until the Stripe env is set. |
| - **The release-feed mirror** (PR #662, `deploy/release-server.sh` + |
| `updater.go`): the fleet self-updates from GitHub, whose CDN has served |
| stale manifests — the Part 9 SPOF. Publishing now mirrors the signed feed |
diff --git a/docs/status.md b/docs/status.md| index 29b90d6..bc5b350 100644 |
| --- a/docs/status.md |
| +++ b/docs/status.md |
| @@ -1,4 +1,4 @@ |
| -# 🤖 Public status dashboards — fleet, books, security, carbon, contributors & prompts |
| +# 🤖 Public status dashboards — fleet, books, economics, security, carbon, contributors & prompts |
| World-readable pages, published every 5 minutes from the router box: the |
| running half of #550 Part 11/12's transparency direction (the append-only |
| @@ -17,6 +17,14 @@ public *ledger* repo is still roadmap — see `docs/planet-scale.md`). |
| claude.ai, DNSimple, Apple Developer). Each section renders independently: |
| a degraded income feed shows its notice inside the Income section and never |
| hides Expenditure. |
| +- **Tier economics** (`economics.html` + `economics.json`) — the cost-model |
| + half of the books: the public price book, storage allowances, input rates, |
| + and per-tier revenue/cost/break-even estimates, published so #562's |
| + commitment — *the price is the cost plus resilience* — is a checkable |
| + claim. The JSON is **committed data** (`web/status/economics.json`), a |
| + hand-derived snapshot of `docs/tier-economics.md` (the repo is private; |
| + this page is what makes the doc actually public), republished as-is. See |
| + *Keeping the tier-economics page current* below. |
| - **The carbon tally** (`carbon.html` + `carbon.json`) — the running estimate |
| of the CO₂e cost of the LLM tokens spent *building* Eleven, as a cumulative |
| chart + ledger with everyday equivalents. Unlike the other two feeds, the |
| @@ -54,9 +62,9 @@ public *ledger* repo is still roadmap — see `docs/planet-scale.md`). |
| Pieces: generator `deploy/status-publish.sh` (box timer, mirrors |
| `deploy/fleet-backup.sh`), viewer pages `web/status/`, bucket + IAM |
| -`infra/prod/status.tf`, contract test `test/status-contract.test.mjs`. All six |
| +`infra/prod/status.tf`, contract test `test/status-contract.test.mjs`. All seven |
| pages share one stylesheet, `web/status/status.css` — the design tokens, the |
| -brand masthead, the six-item nav that steps through every dashboard, and the |
| +brand masthead, the seven-item nav that steps through every dashboard, and the |
| common components (tiles, bar lists, notes, footer). Each page keeps only a |
| small inline `<style>` for its own bits. It's same-origin in the status bucket |
| (no third-party request — the privacy line holds), and the publish script |
| @@ -84,6 +92,13 @@ aggregation boundary lives in the generator and holds by construction: |
| aggregated, never per-payer; invariant 12: no PII in our stores). Never |
| customer ids, emails, subscription ids, per-payer lines, or timestamps of |
| individual payments. |
| +- **Economics: the cost model, never live utilization.** Prices, storage |
| + allowances, unit rates, and per-tier estimates are fleet-level constants, |
| + published on purpose. Two source-doc numbers deliberately do NOT publish: |
| + the per-box instance cap and the box's disk size — either one, combined |
| + with the fleet feed's public instance count, would reconstruct the |
| + remaining-slot needle the capacity band words exist to hide. Keep the slot |
| + rate's denominator qualitative ("full instance capacity"), never the cap. |
| - **Expenses: itemized by design** — decision #19's other half: our spend is |
| ours to disclose, so it publishes one line per provider with the real |
| amount, in that provider's *own billing currency* (never FX-converted — no |
| @@ -318,6 +333,22 @@ status-pages dir. No hook nudges for this one: unlike the carbon tally there |
| is no pruning window, so a stale count costs nothing and catches up on the |
| next refresh. |
| +## Keeping the tier-economics page current |
| + |
| +`web/status/economics.json` is a hand-derived snapshot of |
| +`docs/tier-economics.md` — the formulas are the contract, the numbers a |
| +snapshot. Whenever prices, storage allowances, the free-space quota, or the |
| +input rates change (a Stripe price-book edit, a provider rate move, a quota |
| +default actually shipping), update BOTH in the same change: re-derive the |
| +doc's tables, mirror them into the JSON (and the page's static prose if a |
| +reading shifted), and run `test/status-contract.test.mjs` — it pins net |
| +against the Stripe fee line, profit/margin consistency, and that every |
| +tier's allowance stays below its break-even. Then copy |
| +`web/status/economics.{html,json}` to the box's `/opt/lchat/status-pages/`; |
| +the next 5-minute publish uploads them. No timer regenerates this feed — a |
| +stale snapshot is the failure mode, so treat a pricing change as incomplete |
| +until the snapshot moves with it. |
| + |
| ## Keeping the carbon tally accruing |
| The tally's raw material is the local Claude Code transcripts on **each |
| @@ -443,7 +474,7 @@ Accruing a machine's sessions: |
| completion key), and `STATUS_DNSIMPLE_TOKEN=…` + `STATUS_DNSIMPLE_ACCOUNT=…` |
| (a DNSimple account API token — read-only use here, never echoed — plus |
| the numeric account id). `chown lchat:lchat`, `chmod 0600`. |
| -3. **Pages on the box**: `scp web/status/{status.css,index.html,books.html,carbon.html,carbon.json,contributors.html,contributors.json,security.html,security.json,prompts.html,icon-192.png} |
| +3. **Pages on the box**: `scp web/status/{status.css,index.html,books.html,economics.html,economics.json,carbon.html,carbon.json,contributors.html,contributors.json,security.html,security.json,prompts.html,icon-192.png} |
| root@<box>:/opt/lchat/status-pages/`. `status.css` is the shared stylesheet |
| every page links (uploaded before the pages by the publish script). |
| 4. **Install the timer**: `scp deploy/status-publish.sh root@<box>:/opt/lchat/` |
diff --git a/docs/tier-economics.md b/docs/tier-economics.md| index a798b9d..959ea72 100644 |
| --- a/docs/tier-economics.md |
| +++ b/docs/tier-economics.md |
| @@ -12,17 +12,22 @@ Living doc: the *formulas* are the contract; the *numbers* are a snapshot |
| ## Principles (decided in #562, held here) |
| -- **Self-hosting is free forever.** The paid thing is our hosting convenience, |
| - never the software. |
| +- **Self-hosting is no-obligation.** The paid thing is our hosting |
| + convenience, never the software. A self-hosted space owes nothing — and one |
| + that leans on shared glue we run (the push relay, the signed release feed) |
| + is warmly invited, never required, to contribute. |
| - **We can only price what ciphertext exposes**: storage bytes, member counts, |
| space counts. Never content, never analytics. |
| -- **Paying deanonymizes you to Stripe, not to us.** The ledger holds two opaque |
| - Stripe ids, a tier key, and a period end per paid space — no email, name, or |
| - card detail, ever. |
| +- **Paying is not anonymous.** Checkout runs on Stripe, which collects a |
| + name, email, and card — and as the merchant we can see those in Stripe's |
| + dashboard. Our own stores still hold only two opaque Stripe ids, a tier |
| + key, and a period end per paid space, so a compromise of our systems |
| + exposes no billing identity. |
| - **The free tier is first-class.** Constraints (storage quotas) ratchet later |
| and only for *new* spaces; existing spaces keep what they have. |
| -- **AI is never bundled.** Model tokens are the one marginal cost that can |
| - dwarf a subscription; bot/AI usage is a metered add-on (design in #562). |
| +- **AI is never bundled — and off by default.** Model tokens are the one |
| + marginal cost that can dwarf a subscription; bot/AI usage is always opt-in, |
| + and when opted into it's a metered add-on (design in #562). |
| ## The price book (as of 2026-07-11) |
| @@ -36,26 +41,41 @@ What paying buys today: the space stops counting toward the account's |
| three-space creation limit, it is never reclaimed as unused, and it carries a |
| larger storage allowance (below). |
| -## Storage allowances (as of 2026-07-11 — insurance-grade) |
| +## Storage allowances (as of 2026-07-14 — margin-derived) |
| Space creation is also gated by an account quota (3 free spaces; paid spaces |
| don't count) and, when the home server enables it, a hashcash proof-of-work |
| per creation — the anti-Sybil cost that runs inside the create animation. |
| -| Tier | Included storage | |
| +Allowances are **a function of margin** (Paul's 80/20 rule, 2026-07-14): an |
| +allowance is the storage a space could fill *forever* with the tier still |
| +keeping ≥80% of gross as profit — |
| + |
| +``` |
| +allowance_GB = (net − 0.80 × gross − compute_cost) ÷ (storage_rate × 12) |
| +``` |
| + |
| +— computed at the tighter (monthly) billing case (Business: 1,068 GB annual |
| +/ 1,259 GB monthly, published as 1 TB). Two honest exceptions: the Free tier |
| +has no revenue (its 1 GB is pure cost containment), and Family at €5/year |
| +buys only ~3 GB at strict 80/20 — we hold it at 5 GB and accept a ~74% |
| +worst-case floor rather than shrink the family tier. |
| + |
| +| Tier | Allowance | |
| |---|---| |
| | Free | 1 GB | |
| | Family | 5 GB | |
| -| Community & Friends | 10 GB | |
| -| Business | 25 GB | |
| - |
| -These are deliberately **conservative, box-sized caps** — the shared machine |
| -currently holds ~38 GB of disk, so today's allowances are insurance against |
| -any one space exhausting it, not the product's ambition. The steady-state |
| -allowances this document's break-evens support (~20 GB Family, ~100–200 GB |
| -C&F, ~1–2 TB Business) arrive when attachment storage moves off-box to R2 |
| -(#550); raising a cap is one config change. Uploads past an allowance are |
| -refused (nothing is ever deleted, and messages always keep working). |
| +| Community & Friends | 20 GB | |
| +| Business | 1 TB | |
| + |
| +The allowances ARE the enforced caps (`LCHAT_CLOUD_STORAGE_CAPS`) — the |
| +published table and the config must always match. Business's 1 TB |
| +deliberately exceeds the shared machine's ~38 GB disk: the promise is |
| +honored **on demand** — a Business space genuinely growing toward its |
| +allowance gets a Hetzner volume attached (minutes, up to 10 TB per volume) |
| +until attachment storage moves off-box to R2 (#550), which retires that |
| +ceremony entirely. Uploads past an allowance are refused (nothing is ever |
| +deleted, and messages always keep working). |
| ## Input rates (snapshot, 2026-07-11) |
| @@ -142,15 +162,15 @@ break_even_GB = net ÷ (storage_rate × 12) # where a space stops coveri |
| Message *text* is never material: a decade of busy family chat is ~50 MB |
| (~€0.01/year). The only way a space outspends its price is stored media. |
| -## Estimates per tier (snapshot, 2026-07-11) |
| +## Estimates per tier (snapshot, 2026-07-14) |
| Usage profiles are typical-case estimates, not caps: |
| | Tier | Gross/yr | Net/yr | Profile (est.) | Cost/yr (est.) | Profit/yr (est.) | Margin | Break-even storage | |
| |---|---|---|---|---|---|---|---| |
| | Family €5/y | €5.00 | €4.68 | 10 members, 5 GB, mostly asleep | ~€1.00 | ~€3.70 | ~74% | ~28 GB | |
| -| C&F €40/y | €40.00 | €39.15 | 40 members, 25 GB, awake ~20% | ~€6.30 | ~€32.90 | ~82% | ~230 GB | |
| -| C&F €4/m | €48.00 | €44.28 | same | ~€6.30 | ~€38.00 | ~79% | ~265 GB | |
| +| C&F €40/y | €40.00 | €39.15 | 40 members, 20 GB, awake ~20% | ~€5.50 | ~€33.70 | ~84% | ~230 GB | |
| +| C&F €4/m | €48.00 | €44.28 | same | ~€5.50 | ~€38.80 | ~81% | ~265 GB | |
| | Business €999/y | €999.00 | €983.77 | 150 members, 100 GB, awake ~50% | ~€22.00 | ~€962 | ~96% | ~5.9 TB | |
| | Business €99/m | €1,188.00 | €1,167.18 | same | ~€22.00 | ~€1,145 | ~96% | ~7 TB | |
| @@ -186,7 +206,13 @@ Readings: |
| ## Re-deriving |
| Update the two snapshot tables (input rates + estimates) and the date stamps; |
| -the formulas don't change. Current prices live in the provisioner's price book |
| +the formulas don't change. **This document publishes:** the status site's |
| +Economics page (`web/status/economics.{html,json}`) is the public snapshot of |
| +these tables — update it in the same change and copy it to the box |
| +(`docs/status.md § Keeping the tier-economics page current`; the contract |
| +test pins the arithmetic). Two numbers stay out of the public copy by |
| +design: the per-box instance cap and the box disk size (the status site's |
| +capacity privacy rule — band words, never cap values). Current prices live in the provisioner's price book |
| (`LCHAT_STRIPE_PRICES` on the cloud service; see |
| `cloud-provisioner.md § Billing`); money truth lives in the Stripe dashboard; |
| entitlement truth in the provisioner ledger. Related: #550 (architecture + |
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs| index dd44511..2d3f926 100644 |
| --- a/test/status-contract.test.mjs |
| +++ b/test/status-contract.test.mjs |
| @@ -94,6 +94,11 @@ const carbonHtml = read("web/status/carbon.html"); |
| // The contributors index is COMMITTED data too (tools/contributors.mjs). |
| const contributors = JSON.parse(read("web/status/contributors.json")); |
| const contributorsHtml = read("web/status/contributors.html"); |
| +// The tier-economics snapshot is COMMITTED data (re-derived by hand from |
| +// docs/tier-economics.md when prices or input rates change) — the real |
| +// web/status/economics.json is its own fixture, like the carbon ledger. |
| +const economics = JSON.parse(read("web/status/economics.json")); |
| +const economicsHtml = read("web/status/economics.html"); |
| // The security ledger: the dry-run fixture (attention shape — proves the |
| // dirty path redacts) plus, when present, the committed real scan. The real |
| // file may legitimately be absent (the page then shows "scan pending", never |
| @@ -135,6 +140,12 @@ await test("contributors page fields all exist in the committed contributors.jso |
| } |
| }); |
| +await test("economics page fields all exist in the committed economics.json", () => { |
| + for (const f of fields(economicsHtml)) { |
| + assert.notEqual(resolve(economics, f), undefined, "economics.json is missing " + f); |
| + } |
| +}); |
| + |
| await test("security page fields all exist in the scanner's security.json", () => { |
| for (const [name, s] of securityShapes) { |
| for (const f of fields(securityHtml)) { |
| @@ -164,10 +175,56 @@ await test("contributors entries are well-formed, sorted, and email-free", () => |
| "an email address leaked into contributors.json"); |
| }); |
| +// The tier-economics snapshot must stay internally consistent as it's |
| +// re-derived (docs/tier-economics.md: the formulas are the contract, the |
| +// numbers a snapshot): net follows the Stripe fee line exactly (the two fee |
| +// constants below mirror the input-rates table — update both together), |
| +// profit ≈ net − cost within the doc's ~rounding, margin is profit over |
| +// GROSS, and every tier's included allowance sits below its break-even — |
| +// the "no tier can structurally lose money" claim the page makes in prose. |
| +await test("economics snapshot arithmetic + structurally-profitable claim hold", () => { |
| + assert.match(economics.as_of, /^\d{4}-\d{2}-\d{2}$/, "as_of is a date"); |
| + assert.equal(economics.currency, "eur"); |
| + const q = economics.free_spaces_per_account; |
| + assert.ok(Number.isInteger(q) && q >= 1, "free-space quota is a positive integer"); |
| + assert.ok(economics.price_book.length > 0 && economics.storage_allowances.length > 0 |
| + && economics.input_rates.length > 0 && economics.estimates.length > 0, "all four tables non-empty"); |
| + for (const p of economics.price_book) { |
| + assert.ok(Number.isInteger(p.yearly_cents) && p.yearly_cents > 0, p.tier + ": yearly_cents is integer cents"); |
| + if ("monthly_cents" in p) { |
| + assert.ok(Number.isInteger(p.monthly_cents) && p.monthly_cents > 0, p.tier + ": monthly_cents is integer cents"); |
| + // A monthly toggle must cost more per year than the annual price, or |
| + // the annual default the page defends would be the worse deal. |
| + assert.ok(12 * p.monthly_cents >= p.yearly_cents, p.tier + ": 12×monthly ≥ yearly"); |
| + } |
| + } |
| + const allowance = new Map(economics.storage_allowances.map((s) => [s.tier, s.gb])); |
| + for (const s of economics.storage_allowances) { |
| + assert.ok(Number.isInteger(s.gb) && s.gb > 0, s.tier + ": allowance is a positive GB integer"); |
| + } |
| + for (const e of economics.estimates) { |
| + const label = e.plan + " · " + e.billing; |
| + const charges = e.billing === "monthly" ? 12 : 1; |
| + // Stripe fee constants (1.5% + €0.25 per charge) from the input-rates table. |
| + const net = e.gross_year_cents - 25 * charges - 0.015 * e.gross_year_cents; |
| + assert.ok(Math.abs(e.net_year_cents - net) <= 1, |
| + label + ": net must follow the fee model (" + e.net_year_cents + " vs " + net.toFixed(1) + ")"); |
| + assert.ok(e.cost_year_cents > 0 && e.cost_year_cents < e.net_year_cents, label + ": cost sits inside net"); |
| + const tol = Math.max(5, Math.round(e.gross_year_cents * 0.005)); // costs are ~ estimates |
| + assert.ok(Math.abs(e.profit_year_cents - (e.net_year_cents - e.cost_year_cents)) <= tol, |
| + label + ": profit ≈ net − cost (" + e.profit_year_cents + ")"); |
| + assert.ok(Math.abs(e.margin_pct - (100 * e.profit_year_cents) / e.gross_year_cents) <= 1, |
| + label + ": margin is profit over gross"); |
| + const gb = allowance.get(e.plan); |
| + assert.ok(Number.isInteger(gb), label + ": plan has a storage-allowance row"); |
| + assert.ok(e.break_even_gb > gb, label + ": allowance must sit below break-even (structurally profitable)"); |
| + } |
| +}); |
| + |
| // A manifest entry nobody reads is drift too: each leaf property must appear |
| // in the page's render code, not just the manifest. |
| await test("every declared field is actually read by its page", () => { |
| - for (const [html, fs] of [[indexHtml, fields(indexHtml)], [booksHtml, fields(booksHtml)], [carbonHtml, fields(carbonHtml)], [contributorsHtml, fields(contributorsHtml)], [securityHtml, fields(securityHtml)]]) { |
| + for (const [html, fs] of [[indexHtml, fields(indexHtml)], [booksHtml, fields(booksHtml)], [carbonHtml, fields(carbonHtml)], [contributorsHtml, fields(contributorsHtml)], [securityHtml, fields(securityHtml)], [economicsHtml, fields(economicsHtml)]]) { |
| const body = html.slice(html.indexOf("];") + 2); |
| for (const f of fs) { |
| const leaf = f.split(".").pop().replace("[]", ""); |
| @@ -188,7 +245,7 @@ await test("degraded books shape carries available:false + reason", () => { |
| // customer, transaction, or provider-account/workspace id may appear in |
| // published output. |
| await test("fixtures contain no per-payer ids or hostnames", () => { |
| - for (const j of [fleet, fleetGF, fleetAC, books, booksDown, booksNoExp, booksHzConst, carbon, carbonRun, contributors, ...securityShapes.map(([, s]) => s)]) { |
| + for (const j of [fleet, fleetGF, fleetAC, books, booksDown, booksNoExp, booksHzConst, carbon, carbonRun, contributors, economics, ...securityShapes.map(([, s]) => s)]) { |
| const s = JSON.stringify(j); |
| assert.ok(!/sub_|cus_|txn_|wrkspc_|req_fixture|ce_fixture|dnsfix|hzfix|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80)); |
| } |
diff --git a/web/status/books.html b/web/status/books.html| index b1c808b..45e0593 100644 |
| --- a/web/status/books.html |
| +++ b/web/status/books.html |
| @@ -45,6 +45,7 @@ |
| <nav class="status-nav" aria-label="Status"> |
| <a href="./index.html">Fleet</a> |
| <span aria-current="page">Income & Expenditure</span> |
| + <a href="./economics.html">Economics</a> |
| <a href="./security.html">Security</a> |
| <a href="./carbon.html">Carbon use</a> |
| <a href="./contributors.html">Contributors</a> |
| @@ -63,9 +64,10 @@ |
| inside Income, and never hides Expenditure (or vice versa). --> |
| <section aria-labelledby="h-income" id="s-income"> |
| <h2 id="h-income">Income</h2> |
| - <p class="note explain"><strong>Aggregated by design:</strong> our billing |
| - records hold no customer identities (Stripe does), and what appears here |
| - is totals only — never a per-payer line, never an individual payment.</p> |
| + <p class="note explain"><strong>Aggregated by design:</strong> our own |
| + records hold no customer identities (they live in Stripe, where we can |
| + see them), and what appears here is totals only — never a per-payer |
| + line, never an individual payment.</p> |
| <p id="unavailable" role="status">Income is unavailable right now: <span id="reason"></span>.</p> |
| <div id="income-body"> |
| <div class="tiles"> |
diff --git a/web/status/carbon.html b/web/status/carbon.html| index 5968612..3aba61a 100644 |
| --- a/web/status/carbon.html |
| +++ b/web/status/carbon.html |
| @@ -104,6 +104,7 @@ |
| <nav class="status-nav" aria-label="Status"> |
| <a href="./index.html">Fleet</a> |
| <a href="./books.html">Income & Expenditure</a> |
| + <a href="./economics.html">Economics</a> |
| <a href="./security.html">Security</a> |
| <span aria-current="page">Carbon use</span> |
| <a href="./contributors.html">Contributors</a> |
diff --git a/web/status/contributors.html b/web/status/contributors.html| index 065e859..46d0993 100644 |
| --- a/web/status/contributors.html |
| +++ b/web/status/contributors.html |
| @@ -46,6 +46,7 @@ |
| <nav class="status-nav" aria-label="Status"> |
| <a href="./index.html">Fleet</a> |
| <a href="./books.html">Income & Expenditure</a> |
| + <a href="./economics.html">Economics</a> |
| <a href="./security.html">Security</a> |
| <a href="./carbon.html">Carbon use</a> |
| <span aria-current="page">Contributors</span> |
diff --git a/web/status/economics.html b/web/status/economics.html| new file mode 100644 |
| index 0000000..c395584 |
| --- /dev/null |
| +++ b/web/status/economics.html |
| @@ -0,0 +1,324 @@ |
| +<!-- 🤖 Public tier-economics page: what each tier pays us against what a space |
| + costs us to run — the public snapshot of docs/tier-economics.md (the repo |
| + is private; this page is what makes the #562 commitment checkable). The |
| + JSON is COMMITTED data (web/status/economics.json), re-derived by hand |
| + whenever prices or input rates change, and republished as-is by |
| + deploy/status-publish.sh. Fully self-contained: inline CSS/JS, no external |
| + requests except ./economics.json (relative). Two numbers from the source |
| + doc deliberately do NOT publish: the per-box instance cap and the box's |
| + disk size (the fleet feed's capacity rule — band words, never cap values). --> |
| +<!doctype html> |
| +<html lang="en"> |
| +<head> |
| +<meta charset="utf-8" /> |
| +<meta name="viewport" content="width=device-width, initial-scale=1" /> |
| +<meta name="color-scheme" content="light dark" /> |
| +<title>Eleven — tier economics</title> |
| +<link rel="icon" type="image/png" href="./icon-192.png" /> |
| +<link rel="stylesheet" href="./status.css" /> |
| +<style> |
| + /* Economics-only: the data tables and the cost-model block. Everything |
| + shared lives in status.css. Wide tables scroll inside their own wrapper — |
| + the page body never scrolls horizontally. */ |
| + .tablewrap { overflow-x: auto; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; } |
| + table { border-collapse: collapse; width: 100%; font-size: 14px; } |
| + caption { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } |
| + th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--grid); } |
| + thead th { font-size: 13px; color: var(--ink-2); font-weight: 650; white-space: nowrap; } |
| + tbody tr:last-child th, tbody tr:last-child td { border-bottom: none; } |
| + tbody th { font-weight: 600; white-space: nowrap; } |
| + .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } |
| + .profile { min-width: 170px; } |
| + |
| + .model { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; overflow-x: auto; font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; } |
| + |
| + /* Prose bullets (principles, readings) — body ink, comfortable rhythm. */ |
| + ul.prose { padding-left: 20px; display: grid; gap: 8px; font-size: 15px; } |
| + ul.prose .lead-in { font-weight: 650; } |
| +</style> |
| +</head> |
| +<body> |
| +<div class="wrap"> |
| + <header> |
| + <img src="./icon-192.png" alt="" /> |
| + <div> |
| + <p class="brand">Eleven Messenger</p> |
| + <h1>Tier economics</h1> |
| + </div> |
| + </header> |
| + <nav class="status-nav" aria-label="Status"> |
| + <a href="./index.html">Fleet</a> |
| + <a href="./books.html">Income & Expenditure</a> |
| + <span aria-current="page">Economics</span> |
| + <a href="./security.html">Security</a> |
| + <a href="./carbon.html">Carbon use</a> |
| + <a href="./contributors.html">Contributors</a> |
| + <a href="./prompts.html">Prompt Ledger</a> |
| + </nav> |
| + <p class="lede">How the space-upgrade prices relate to what spaces actually |
| + cost to run. The price is the cost plus resilience — and that should be a |
| + checkable claim, so here is the cost model, in the open beside the books.</p> |
| + <p class="asof">Snapshot dated <time id="asof" datetime="">—</time> — the |
| + formulas are the contract; the numbers are re-derived whenever prices or |
| + input rates change.</p> |
| + |
| + <p id="error" role="alert">Couldn’t load the economics snapshot. It may be mid-publish — this page retries every minute.</p> |
| + |
| + <section aria-labelledby="h-principles"> |
| + <h2 id="h-principles">Principles</h2> |
| + <ul class="prose"> |
| + <li><span class="lead-in">Self-hosting is no-obligation.</span> The paid |
| + thing is our hosting convenience, never the software. A self-hosted space |
| + owes us nothing — and one that leans on shared glue we run (the push |
| + relay, the signed release feed) is warmly invited, never required, to |
| + contribute.</li> |
| + <li><span class="lead-in">We can only price what ciphertext exposes:</span> |
| + storage bytes, member counts, space counts. Never content, never |
| + analytics.</li> |
| + <li><span class="lead-in">Paying is not anonymous.</span> Checkout runs |
| + on Stripe, which collects your name, email, and card — and as the |
| + merchant we can see those in Stripe’s dashboard. Our own stores still |
| + hold only two opaque Stripe ids, a tier key, and a period end per paid |
| + space, so a compromise of our systems exposes no billing identity.</li> |
| + <li><span class="lead-in">The free tier is first-class.</span> Constraints |
| + ratchet only for <em>new</em> spaces; existing spaces keep what they |
| + have.</li> |
| + <li><span class="lead-in">AI is never bundled — and off by default.</span> |
| + Model tokens are the one marginal cost that can dwarf a subscription, so |
| + bot/AI usage is always opt-in, and when opted into it’s a metered add-on — |
| + never hidden in a flat price.</li> |
| + </ul> |
| + </section> |
| + |
| + <section aria-labelledby="h-prices"> |
| + <h2 id="h-prices">The price book</h2> |
| + <div class="tablewrap"> |
| + <table> |
| + <caption>Tiers and prices</caption> |
| + <thead><tr><th scope="col">Tier</th><th scope="col" class="num">Price</th><th scope="col">Billing</th></tr></thead> |
| + <tbody id="prices"></tbody> |
| + </table> |
| + </div> |
| + <p class="note" style="margin-top:10px">What paying buys today: the space |
| + stops counting toward the account’s free-space limit, it is never reclaimed |
| + as unused, and it carries a larger storage allowance (below).</p> |
| + </section> |
| + |
| + <section aria-labelledby="h-storage"> |
| + <h2 id="h-storage">Storage allowances</h2> |
| + <div class="tablewrap"> |
| + <table> |
| + <caption>Included storage per tier</caption> |
| + <thead><tr><th scope="col">Tier</th><th scope="col" class="num">Included storage</th></tr></thead> |
| + <tbody id="storage"></tbody> |
| + </table> |
| + </div> |
| + <p class="note" style="margin-top:10px" id="quota-note"></p> |
| + <p class="note">Allowances are a function of margin: an allowance is the |
| + storage a space could fill <em>forever</em> with the tier still keeping at |
| + least 80% of gross — 20% of the price buys your storage, 80% stays for |
| + resilience. Two honest exceptions: Free has no revenue (its 1 GB is cost |
| + containment), and Family’s €5/year buys only ~3 GB at strict 80/20 — we |
| + hold it at 5 GB rather than shrink the family tier. Uploads past an |
| + allowance are refused; nothing is ever deleted, and messages always keep |
| + working.</p> |
| + </section> |
| + |
| + <section aria-labelledby="h-rates"> |
| + <h2 id="h-rates">Input rates</h2> |
| + <div class="tablewrap"> |
| + <table> |
| + <caption>Cost inputs to the model</caption> |
| + <thead><tr><th scope="col">Input</th><th scope="col" class="num">Rate</th><th scope="col">Source</th></tr></thead> |
| + <tbody id="rates"></tbody> |
| + </table> |
| + </div> |
| + <p class="note" style="margin-top:10px">Hibernation is live in production: |
| + an idle space parks as stored bytes, not a running process, so its marginal |
| + cost while asleep is storage alone — the compute rate applies only to the |
| + fraction of time it’s awake. One deliberate exception: a space with a bot |
| + sidecar never sleeps, one more reason AI usage carries its own meter.</p> |
| + </section> |
| + |
| + <section aria-labelledby="h-model"> |
| + <h2 id="h-model">The model</h2> |
| + <pre class="model" tabindex="0">For a tier with gross annual revenue G across n charges/year: |
| + |
| +net = G − (0.25 × n) − (0.015 × G) # Stripe fees out |
| +cost = GB_stored × storage_rate × 12 # media, the only real dial |
| + + slot_rate × 12 × awake_fraction # compute while awake |
| + + ~€0.10 # wakes, requests, push (noise) |
| +profit = net − cost |
| +break_even_GB = net ÷ (storage_rate × 12) # where a space stops covering itself</pre> |
| + <p class="note" style="margin-top:10px">Message <em>text</em> is never |
| + material — a decade of busy family chat is a few cents a year of storage. |
| + The only way a space outspends its price is stored media.</p> |
| + </section> |
| + |
| + <section aria-labelledby="h-estimates"> |
| + <h2 id="h-estimates">Estimates per tier</h2> |
| + <p class="note explain">Usage profiles are typical-case estimates, not |
| + caps. Gross and net are exact; costs and margins are estimates.</p> |
| + <div class="tablewrap"> |
| + <table> |
| + <caption>Per-tier revenue, cost, and break-even estimates</caption> |
| + <thead><tr> |
| + <th scope="col">Plan</th> |
| + <th scope="col" class="num">Gross/yr</th> |
| + <th scope="col" class="num">Net/yr</th> |
| + <th scope="col" class="profile">Profile (est.)</th> |
| + <th scope="col" class="num">Cost/yr</th> |
| + <th scope="col" class="num">Profit/yr</th> |
| + <th scope="col" class="num">Margin</th> |
| + <th scope="col" class="num">Break-even storage</th> |
| + </tr></thead> |
| + <tbody id="estimates"></tbody> |
| + </table> |
| + </div> |
| + <ul class="prose" style="margin-top:14px"> |
| + <li><span class="lead-in">Break-even storage is the number that |
| + matters.</span> Every tier’s included allowance sits far beneath its |
| + break-even, so no tier can structurally lose money — while still feeling |
| + unlimited in practice.</li> |
| + <li><span class="lead-in">Stripe’s fixed fee punishes small monthly |
| + charges,</span> which is why the picker defaults to annual (and why a |
| + space is priced as a years-scale commitment).</li> |
| + <li><span class="lead-in">The fixed floor is small.</span> A modest number |
| + of Family spaces, a dozen Community & Friends annuals, or a single |
| + Business space covers the whole serving plane; beyond that, margin is |
| + contribution toward resilience.</li> |
| + <li><span class="lead-in">Business margin is deliberately not an infra |
| + story.</span> At that margin the price is buying priority support and |
| + features to come, not bytes. If that feels wrong when read beside the |
| + books, this page exists to force that conversation.</li> |
| + </ul> |
| + </section> |
| + |
| + <section aria-labelledby="h-invert"> |
| + <h2 id="h-invert">What could invert these numbers</h2> |
| + <ul class="prose"> |
| + <li><span class="lead-in">AI/bot usage</span> — excluded above by design; |
| + metered add-on or bring-your-own key.</li> |
| + <li><span class="lead-in">Media hoarding past break-even</span> — bounded |
| + by the storage allowances.</li> |
| + <li><span class="lead-in">A space that never sleeps</span> — bot-sidecar |
| + spaces; today that’s a known subsidy, priced into the margins above.</li> |
| + <li><span class="lead-in">Rate changes</span> — Stripe, storage, or |
| + machine pricing move; the snapshot is re-derived.</li> |
| + </ul> |
| + </section> |
| + |
| + <footer> |
| + <p>A committed snapshot, republished with the status pages — the cost-model |
| + half of the books (income and spending as they stand: |
| + <a href="./books.html">Income & Expenditure</a>). Prices are EUR; money |
| + truth lives in the Stripe dashboard. Source: the repo’s |
| + docs/tier-economics.md, re-derived whenever prices or input rates change.</p> |
| + </footer> |
| +</div> |
| + |
| +<script> |
| +// contract: the fields this page reads from ./economics.json — committed data, |
| +// checked by test/status-contract.test.mjs (which also pins the arithmetic: |
| +// net = gross − Stripe fees, profit ≈ net − cost, margin = profit ÷ gross, |
| +// and every allowance < its tier's break-even). |
| +const FIELDS = [ |
| + "as_of", "currency", "free_spaces_per_account", |
| + "price_book[].tier", "price_book[].monthly_cents", "price_book[].yearly_cents", |
| + "storage_allowances[].tier", "storage_allowances[].gb", |
| + "input_rates[].input", "input_rates[].rate", "input_rates[].source", |
| + "estimates[].plan", "estimates[].billing", "estimates[].profile", |
| + "estimates[].gross_year_cents", "estimates[].net_year_cents", |
| + "estimates[].cost_year_cents", "estimates[].profit_year_cents", |
| + "estimates[].margin_pct", "estimates[].break_even_gb", |
| +]; |
| + |
| +const $ = (id) => document.getElementById(id); |
| +const eur = new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR" }); |
| +const n1 = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }); |
| +// Storage renders in the unit it reads best in — GB below a TB, TB above. |
| +const gbFmt = (gb) => gb >= 1000 ? n1.format(gb / 1000) + " TB" : n1.format(gb) + " GB"; |
| + |
| +// All rendering is textContent / createElement — never innerHTML of fetched |
| +// data, even though the feed is our own. |
| +const cell = (tag, cls, text) => { |
| + const el = document.createElement(tag); |
| + if (cls) el.className = cls; |
| + el.textContent = text; |
| + if (tag === "th") el.scope = "row"; |
| + return el; |
| +}; |
| + |
| +function render(d) { |
| + $("asof").dateTime = d.as_of; |
| + $("asof").textContent = new Date(d.as_of + "T00:00:00Z") |
| + .toLocaleDateString(undefined, { dateStyle: "long", timeZone: "UTC" }); |
| + void d.currency; // fixed "eur" today; the formatter would key on it if that changes |
| + |
| + const prices = $("prices"); |
| + prices.replaceChildren(); |
| + for (const p of d.price_book || []) { |
| + const tr = document.createElement("tr"); |
| + const hasMonthly = typeof p.monthly_cents === "number"; |
| + const price = (hasMonthly ? eur.format(p.monthly_cents / 100) + "/month or " : "") |
| + + eur.format(p.yearly_cents / 100) + "/year"; |
| + tr.append(cell("th", null, p.tier), |
| + cell("td", "num", price), |
| + cell("td", null, hasMonthly ? "monthly or annual" : "annual only")); |
| + prices.append(tr); |
| + } |
| + |
| + const storage = $("storage"); |
| + storage.replaceChildren(); |
| + for (const s of d.storage_allowances || []) { |
| + const tr = document.createElement("tr"); |
| + tr.append(cell("th", null, s.tier), cell("td", "num", gbFmt(s.gb))); |
| + storage.append(tr); |
| + } |
| + const q = d.free_spaces_per_account; |
| + $("quota-note").textContent = "Space creation is also gated by an account quota (" |
| + + n1.format(q) + (q === 1 ? " free space" : " free spaces") |
| + + "; paid spaces don’t count) and, when enabled, a proof-of-work per creation."; |
| + |
| + const rates = $("rates"); |
| + rates.replaceChildren(); |
| + for (const r of d.input_rates || []) { |
| + const tr = document.createElement("tr"); |
| + tr.append(cell("th", null, r.input), cell("td", "num", r.rate), cell("td", null, r.source)); |
| + rates.append(tr); |
| + } |
| + |
| + const est = $("estimates"); |
| + est.replaceChildren(); |
| + for (const e of d.estimates || []) { |
| + const tr = document.createElement("tr"); |
| + tr.append( |
| + cell("th", null, e.plan + " · " + (e.billing === "monthly" ? "monthly" : "annual")), |
| + cell("td", "num", eur.format(e.gross_year_cents / 100)), |
| + cell("td", "num", eur.format(e.net_year_cents / 100)), |
| + cell("td", "profile", e.profile), |
| + cell("td", "num", "~" + eur.format(e.cost_year_cents / 100)), |
| + cell("td", "num", "~" + eur.format(e.profit_year_cents / 100)), |
| + cell("td", "num", "~" + n1.format(e.margin_pct) + "%"), |
| + cell("td", "num", "~" + gbFmt(e.break_even_gb))); |
| + est.append(tr); |
| + } |
| +} |
| + |
| +async function refresh() { |
| + try { |
| + const res = await fetch("./economics.json", { cache: "no-cache" }); |
| + if (!res.ok) throw new Error("HTTP " + res.status); |
| + render(await res.json()); |
| + $("error").classList.remove("show"); |
| + } catch (e) { |
| + // Keep the previous render (no skeleton flash); surface the failure inline. |
| + $("error").classList.add("show"); |
| + } |
| +} |
| +refresh(); |
| +setInterval(refresh, 60 * 1000); |
| +void FIELDS; |
| +</script> |
| +</body> |
| +</html> |
diff --git a/web/status/economics.json b/web/status/economics.json| new file mode 100644 |
| index 0000000..32ff53a |
| --- /dev/null |
| +++ b/web/status/economics.json |
| @@ -0,0 +1,30 @@ |
| +{ |
| + "as_of": "2026-07-14", |
| + "currency": "eur", |
| + "free_spaces_per_account": 3, |
| + "price_book": [ |
| + { "tier": "Family", "yearly_cents": 500 }, |
| + { "tier": "Community & Friends", "monthly_cents": 400, "yearly_cents": 4000 }, |
| + { "tier": "Business", "monthly_cents": 9900, "yearly_cents": 99900 } |
| + ], |
| + "storage_allowances": [ |
| + { "tier": "Free", "gb": 1 }, |
| + { "tier": "Family", "gb": 5 }, |
| + { "tier": "Community & Friends", "gb": 20 }, |
| + { "tier": "Business", "gb": 1000 } |
| + ], |
| + "input_rates": [ |
| + { "input": "Stripe fee (EU consumer card)", "rate": "1.5% + €0.25 per charge", "source": "Stripe pricing; EUR settlement, no FX" }, |
| + { "input": "Attachment storage (R2)", "rate": "~€0.014 per GB-month", "source": "$0.015, zero egress" }, |
| + { "input": "DB snapshots (S3)", "rate": "~€0.021 per GB-month", "source": "$0.023" }, |
| + { "input": "Box (whole cloud plane)", "rate": "~€35 per month", "source": "Hetzner + fixed services" }, |
| + { "input": "Resident-process slot", "rate": "~€0.81 per month", "source": "the box rate divided across its full instance capacity" } |
| + ], |
| + "estimates": [ |
| + { "plan": "Family", "billing": "yearly", "gross_year_cents": 500, "net_year_cents": 468, "profile": "10 members, 5 GB, mostly asleep", "cost_year_cents": 100, "profit_year_cents": 370, "margin_pct": 74, "break_even_gb": 28 }, |
| + { "plan": "Community & Friends", "billing": "yearly", "gross_year_cents": 4000, "net_year_cents": 3915, "profile": "40 members, 20 GB, awake ~20%", "cost_year_cents": 550, "profit_year_cents": 3370, "margin_pct": 84, "break_even_gb": 230 }, |
| + { "plan": "Community & Friends", "billing": "monthly", "gross_year_cents": 4800, "net_year_cents": 4428, "profile": "40 members, 20 GB, awake ~20%", "cost_year_cents": 550, "profit_year_cents": 3880, "margin_pct": 81, "break_even_gb": 265 }, |
| + { "plan": "Business", "billing": "yearly", "gross_year_cents": 99900, "net_year_cents": 98377, "profile": "150 members, 100 GB, awake ~50%", "cost_year_cents": 2200, "profit_year_cents": 96200, "margin_pct": 96, "break_even_gb": 5900 }, |
| + { "plan": "Business", "billing": "monthly", "gross_year_cents": 118800, "net_year_cents": 116718, "profile": "150 members, 100 GB, awake ~50%", "cost_year_cents": 2200, "profit_year_cents": 114500, "margin_pct": 96, "break_even_gb": 7000 } |
| + ] |
| +} |
diff --git a/web/status/index.html b/web/status/index.html| index 66cbd55..e9e97e2 100644 |
| --- a/web/status/index.html |
| +++ b/web/status/index.html |
| @@ -63,6 +63,7 @@ |
| <nav class="status-nav" aria-label="Status"> |
| <span aria-current="page">Fleet</span> |
| <a href="./books.html">Income & Expenditure</a> |
| + <a href="./economics.html">Economics</a> |
| <a href="./security.html">Security</a> |
| <a href="./carbon.html">Carbon use</a> |
| <a href="./contributors.html">Contributors</a> |
diff --git a/web/status/prompts.html b/web/status/prompts.html| index 1934505..a8f2c1c 100644 |
| --- a/web/status/prompts.html |
| +++ b/web/status/prompts.html |
| @@ -111,6 +111,7 @@ |
| <nav class="status-nav" aria-label="Status"> |
| <a href="./index.html">Fleet</a> |
| <a href="./books.html">Income & Expenditure</a> |
| + <a href="./economics.html">Economics</a> |
| <a href="./security.html">Security</a> |
| <a href="./carbon.html">Carbon use</a> |
| <a href="./contributors.html">Contributors</a> |
diff --git a/web/status/security.html b/web/status/security.html| index 28611e8..51042d0 100644 |
| --- a/web/status/security.html |
| +++ b/web/status/security.html |
| @@ -65,6 +65,7 @@ |
| <nav class="status-nav" aria-label="Status"> |
| <a href="./index.html">Fleet</a> |
| <a href="./books.html">Income & Expenditure</a> |
| + <a href="./economics.html">Economics</a> |
| <span aria-current="page">Security</span> |
| <a href="./carbon.html">Carbon use</a> |
| <a href="./contributors.html">Contributors</a> |
diff --git a/web/status/status.css b/web/status/status.css| index 81b48fb..04f1abf 100644 |
| --- a/web/status/status.css |
| +++ b/web/status/status.css |
| @@ -1,7 +1,8 @@ |
| /* 🤖 Shared stylesheet for the public status pages (web/status/*.html): fleet, |
| - income & expenditure, security, carbon, contributors, and the prompt ledger. |
| + income & expenditure, tier economics, security, carbon, contributors, and |
| + the prompt ledger. |
| ONE design system — the design tokens, the reset, and every component the |
| - pages share (the brand masthead, the six-item nav, tiles, the single-series |
| + pages share (the brand masthead, the seven-item nav, tiles, the single-series |
| bar list, notes, the "couldn't load" alert, footer). Each page <link>s this |
| and keeps only a SMALL inline <style> for its own unique bits. |
| @@ -11,7 +12,7 @@ |
| this file BEFORE the html so a page is never served ahead of its stylesheet. |
| Palette: the dataviz-validated role set (six-checks, light + dark) already in |
| - use across all six pages — the violet slot (--series / --link) is the Eleven |
| + use across all seven pages — the violet slot (--series / --link) is the Eleven |
| brand family; --ink-2 carries ALL small text (>=4.5:1 in both modes). The |
| names are the ones every page already used; this file is simply their single |
| home (the union of every role any page referenced). */ |
| @@ -56,7 +57,7 @@ header img { width: 44px; height: 44px; border-radius: 10px; } |
| header h1 { font-size: 22px; font-weight: 650; } |
| header .brand { color: var(--ink-2); font-size: 14px; } |
| -/* The status nav — a horizontal stepper through the six public dashboards, |
| +/* The status nav — a horizontal stepper through the seven public dashboards, |
| wrapping onto more rows on narrow widths. The current page is a non-link, |
| marked aria-current and bolded, so weight (not color alone) carries it. */ |
| nav.status-nav { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 14px; margin: 8px 0 14px; } |