eleven / messenger Public

web+server: books gain DNSimple + Apple expense providers and a visible Income / Expenditure split (#766)

Two new itemized expense lines in the public books, and a clearer page:

- dnsimple — the product domains (elevenmessenger.com, 11ch.at, 11factor.org):
  month-to-date from GET /v2/{account}/billing/charges (start_date/end_date,
  per_page=100; a second page fails the fetch — a partial sum never
  publishes). Amounts are decimal dollar strings with no currency field, so
  the published usd is the documented constant (DNSimple's price book), not
  parsed. Only `collected` charges sum — refunds are not spend. Auth =
  STATUS_DNSIMPLE_TOKEN + STATUS_DNSIMPLE_ACCOUNT (account API token,
  read-only use here, never echoed). Rides the shared 6h cost cache and the
  same omit-on-failure discipline as AWS/Anthropic.
- apple — the Apple Developer Program membership: fixed yearly, no billing
  API, so the operator-set constant STATUS_APPLE_YEARLY_CENTS (EUR),
  published as billed (yearly_cents) — never divided into a fake monthly.

books.html restructures into two clearly-headed sections mirroring the fleet
page's h2/section pattern: Income (subscriptions, MRR, collected, by-tier —
aggregated by design) and Expenditure (the provider tiles — itemized by
design), each with a one-line explainer. The degraded income notice now
renders inside the Income section; Expenditure renders independently, so a
Stripe outage never hides spend the feed can still honestly state.

Contract test allowlists the two providers (per-provider key sets enforce
exactly one of month_to_date_cents | monthly_cents | yearly_cents), pins the
new publish order, and greps the dnsimple fixture markers; dry-run gains a
DNSimple charges fixture (state filter exercised — a refunded charge must
not count) + apple constant across all STATUS_DRY_EXPENSES variants;
fixtures regenerated; docs/status.md updated.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5f5608b9605bd19a5fe8ff2224777277e97633b1 parent 9606529
9 files changed, +194 −74
  • deploy/status-publish.sh +100 −21
  • docs/status.md +35 −12
  • test/status-contract.test.mjs +13 −7
  • test/status-fixtures/books-no-expenses.json +1 −1
  • test/status-fixtures/books.json +1 −1
  • test/status-fixtures/fleet-at-capacity.json +1 −1
  • test/status-fixtures/fleet-getting-full.json +1 −1
  • test/status-fixtures/fleet.json +1 −1
  • web/status/books.html +41 −29
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh
index 05bf8ca..89e04ee 100755
--- a/deploy/status-publish.sh
+++ b/deploy/status-publish.sh
@@ -82,9 +82,12 @@
# bucket, no Delete, plus ce:GetCostAndUsage for the
# expenses section). chown lchat, chmod 0600. Optional
# expense vars ride here too: STATUS_HETZNER_MONTHLY_CENTS,
-# STATUS_CLAUDE_MONTHLY_CENTS, STATUS_ANTHROPIC_ADMIN_KEY
-# (an sk-ant-admin… key with read-only usage scope — NOT
-# the sidecars' completion key).
+# STATUS_CLAUDE_MONTHLY_CENTS, STATUS_APPLE_YEARLY_CENTS,
+# STATUS_ANTHROPIC_ADMIN_KEY (an sk-ant-admin… key with
+# read-only usage scope — NOT the sidecars' completion
+# key), and STATUS_DNSIMPLE_TOKEN + STATUS_DNSIMPLE_ACCOUNT
+# (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/
# (index.html, books.html, carbon.html, carbon.json,
# contributors.html, contributors.json, icon-192.png).
@@ -549,9 +552,47 @@ fetch_anthropic_cents() {
FROM json_each(readfile('$f'), '\$.data') d, json_each(d.value, '\$.results') r"
}
+# fetch_dnsimple_cents <month> — one billing-charges page from DNSimple's API
+# (the product domains live there): GET /v2/{account}/billing/charges with
+# start_date=<month>-01 / end_date=<tomorrow> (ISO dates; tomorrow so today's
+# charges are included), per_page=100. A month of domain charges is a handful —
+# pagination.total_pages > 1 counts as failure (a partial sum must never
+# publish). Amounts are decimal strings of DOLLARS ("14.50"); the response
+# carries NO currency field, so the published currency is the documented
+# constant (DNSimple's price book is USD), not parsed. Only `collected`
+# charges sum — a refunded charge is not spend — and every summed amount must
+# be a plain decimal or the fetch counts as failed. STATUS_DNSIMPLE_TOKEN is
+# an account API token, read-only use here: it rides only in the
+# Authorization header (-sS keeps curl from printing it) and must never be
+# echoed or logged. Dry-run twin reads a fixture instead.
+fetch_dnsimple_cents() {
+ local month=$1 f="$TMP/dnsimple-charges.json" end amt
+ if [ -n "$DRY_RUN" ]; then
+ [ -f "$FIXDIR/dnsimple-charges.json" ] || return 1
+ cp "$FIXDIR/dnsimple-charges.json" "$f"
+ else
+ [ -n "${STATUS_DNSIMPLE_TOKEN:-}" ] && [ -n "${STATUS_DNSIMPLE_ACCOUNT:-}" ] || return 1
+ end=$(sqj "SELECT date('now','+1 day')")
+ curl -sS --fail --max-time 30 \
+ -H "Authorization: Bearer $STATUS_DNSIMPLE_TOKEN" \
+ "https://api.dnsimple.com/v2/$STATUS_DNSIMPLE_ACCOUNT/billing/charges?start_date=${month}-01&end_date=$end&per_page=100" \
+ > "$f" || return 1
+ fi
+ [ "$(sqj "SELECT json_valid(readfile('$f')) AND json_type(readfile('$f'), '\$.data')='array'")" = "1" ] || return 1
+ [ "$(sqj "SELECT COALESCE(json_extract(readfile('$f'), '\$.pagination.total_pages'), 1) <= 1")" = "1" ] || return 1
+ while IFS= read -r amt; do
+ case "$amt" in ''|.|*[!0-9.]*|*.*.*) return 1 ;; esac
+ done < <(sqj "SELECT json_extract(c.value, '\$.total_amount')
+ FROM json_each(readfile('$f'), '\$.data') c
+ WHERE json_extract(c.value, '\$.state')='collected'")
+ sqj "SELECT CAST(ROUND(COALESCE(SUM(json_extract(c.value, '\$.total_amount')), 0) * 100) AS INTEGER)
+ FROM json_each(readfile('$f'), '\$.data') c
+ WHERE json_extract(c.value, '\$.state')='collected'"
+}
+
# cached_cost_cents <provider> <month> — the shared fetch-through-cache for
-# the metered providers (aws, anthropic — both fetch_<provider>_cents above).
-# Both APIs charge or rate-limit per request (Cost Explorer bills ~$0.01 per
+# the metered providers (aws, anthropic, dnsimple — fetch_<provider>_cents above).
+# These APIs charge or rate-limit per request (Cost Explorer bills ~$0.01 per
# query) and this script runs every 5 minutes — an uncached publisher would
# spend real money asking how much it spends. So each provider's result is
# cached in $COSTS_CACHE and refetched only when its entry is older than
@@ -608,6 +649,13 @@ cached_cost_cents() {
# the bot sidecars' API spend.
# claude.ai — the claude.ai subscription: fixed monthly, no API for it, so
# the operator-set constant STATUS_CLAUDE_MONTHLY_CENTS (USD).
+# dnsimple — the product domains (elevenmessenger.com, 11ch.at,
+# 11factor.org): billing-charges month-to-date (metered;
+# cached), USD.
+# apple — the Apple Developer Program membership: fixed YEARLY, no
+# billing API, so the operator-set constant
+# STATUS_APPLE_YEARLY_CENTS (EUR). Published as billed —
+# yearly_cents, never divided into a fake monthly.
expenses_object() {
local month providers="" cents
month=$(sqj "SELECT strftime('%Y-%m','now')")
@@ -624,6 +672,12 @@ expenses_object() {
cents=$(env_cents "${STATUS_CLAUDE_MONTHLY_CENTS:-}")
[ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','claude.ai','monthly_cents',$cents,'currency','usd')")"
+ cents=$(cached_cost_cents dnsimple "$month")
+ [ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','dnsimple','month_to_date_cents',$cents,'currency','usd')")"
+
+ cents=$(env_cents "${STATUS_APPLE_YEARLY_CENTS:-}")
+ [ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','apple','yearly_cents',$cents,'currency','eur')")"
+
[ -n "$providers" ] || return 0
sqj "SELECT json_object('month', '$month', 'providers', json('[$providers]'))"
}
@@ -899,11 +953,11 @@ EOF
echo "LCHAT_STRIPE_SECRET_KEY=sk_test_dryrun_not_a_real_key" > "$FIXDIR/cloud.env"
fi
- # Expense fixtures (STATUS_DRY_EXPENSES=omit|stale-cache; default = all four
+ # Expense fixtures (STATUS_DRY_EXPENSES=omit|stale-cache; default = all six
# providers resolve). The cost cache is repointed into FIXDIR — the dry run
- # never touches $DATA_DIR. Both metered fixtures carry fixture-only ids
- # (request id, workspace id): only the parsed integers may survive into
- # books.json — the leak grep below is the proof.
+ # never touches $DATA_DIR. The metered fixtures carry fixture-only ids
+ # (request id, workspace id, charge references/domains): only the parsed
+ # integers may survive into books.json — the leak grep below is the proof.
COSTS_CACHE="$FIXDIR/costs-cache.json"
case "${STATUS_DRY_EXPENSES:-}" in
omit)
@@ -911,21 +965,25 @@ EOF
# and then the whole expenses object, is omitted.
STATUS_HETZNER_MONTHLY_CENTS=""
STATUS_CLAUDE_MONTHLY_CENTS=""
+ STATUS_APPLE_YEARLY_CENTS=""
;;
stale-cache)
- # No metered fixtures (both "fetches" fail) but valid same-month cache
- # entries well past the max age → the stale figures serve rather than
- # vanishing (stale beats absent for month-to-date).
+ # No metered fixtures (all three "fetches" fail) but valid same-month
+ # cache entries well past the max age → the stale figures serve rather
+ # than vanishing (stale beats absent for month-to-date).
STATUS_HETZNER_MONTHLY_CENTS=""
STATUS_CLAUDE_MONTHLY_CENTS=""
+ STATUS_APPLE_YEARLY_CENTS=""
sqj "SELECT json_object(
'aws', json_object('fetched_at', strftime('%s','now') - 999999, 'month', strftime('%Y-%m','now'), 'month_to_date_cents', 999),
- 'anthropic', json_object('fetched_at', strftime('%s','now') - 999999, 'month', strftime('%Y-%m','now'), 'month_to_date_cents', 888))" \
+ 'anthropic', json_object('fetched_at', strftime('%s','now') - 999999, 'month', strftime('%Y-%m','now'), 'month_to_date_cents', 888),
+ 'dnsimple', json_object('fetched_at', strftime('%s','now') - 999999, 'month', strftime('%Y-%m','now'), 'month_to_date_cents', 777))" \
> "$COSTS_CACHE"
;;
*)
STATUS_HETZNER_MONTHLY_CENTS=1400
STATUS_CLAUDE_MONTHLY_CENTS=2000
+ STATUS_APPLE_YEARLY_CENTS=9900
# Cost Explorer shape; Amount 12.3399999 USD exercises the cents rounding (→ 1234).
cat > "$FIXDIR/aws-ce.json" <<'EOF'
{"ce_fixture_request_id":"req_fixture_1","ResultsByTime":[{"TimePeriod":{"Start":"2026-07-01","End":"2026-07-12"},"Total":{"UnblendedCost":{"Amount":"12.3399999","Unit":"USD"}},"Groups":[],"Estimated":true}]}
@@ -940,6 +998,21 @@ EOF
{"starting_at":"2026-07-02T00:00:00Z","ending_at":"2026-07-03T00:00:00Z",
"results":[{"amount":"99.5","currency":"USD","workspace_id":"wrkspc_fixture_1"}]}
],"has_more":false,"next_page":null}
+EOF
+ # DNSimple billing-charges shape (amounts = decimal DOLLAR strings, no
+ # currency field, pagination envelope): two collected charges
+ # (14.50 + 10.00 → 2450 cents) plus a refunded one that must NOT count —
+ # the state filter is part of the contract. References and domains are
+ # fixture markers that must not survive aggregation.
+ cat > "$FIXDIR/dnsimple-charges.json" <<'EOF'
+{"data":[
+ {"invoiced_at":"2026-07-02T09:00:00Z","total_amount":"14.50","balance_amount":"0.00","reference":"dnsfix-1001","state":"collected",
+ "items":[{"description":"Renewal dnsfix-one.example","amount":"14.50","product_id":11,"product_type":"domain-renewal","product_reference":"dnsfix-one.example"}]},
+ {"invoiced_at":"2026-07-03T09:00:00Z","total_amount":"10.00","balance_amount":"0.00","reference":"dnsfix-1002","state":"collected",
+ "items":[{"description":"Renewal dnsfix-two.example","amount":"10.00","product_id":12,"product_type":"domain-renewal","product_reference":"dnsfix-two.example"}]},
+ {"invoiced_at":"2026-07-04T09:00:00Z","total_amount":"14.50","balance_amount":"0.00","reference":"dnsfix-1003","state":"refunded",
+ "items":[{"description":"Register dnsfix-three.example","amount":"14.50","product_id":13,"product_type":"domain-registration","product_reference":"dnsfix-three.example"}]}
+],"pagination":{"current_page":1,"per_page":100,"total_entries":3,"total_pages":1}}
EOF
;;
esac
@@ -976,7 +1049,7 @@ EOF
# Self-checks: valid JSON, and no per-payer fixture value leaked through.
sqj "SELECT CASE WHEN json_valid(readfile('$outdir/fleet.json')) AND json_valid(readfile('$outdir/books.json')) THEN 'ok' ELSE 'INVALID' END" \
| grep -qx ok || { echo "dry-run: output is not valid JSON" >&2; exit 1; }
- if grep -qE 'sub_fixture|cus_fixture|txn_fixture|req_fixture|ce_fixture|wrkspc_fixture|\.example' "$outdir/fleet.json" "$outdir/books.json"; then
+ if grep -qE 'sub_fixture|cus_fixture|txn_fixture|req_fixture|ce_fixture|wrkspc_fixture|dnsfix|\.example' "$outdir/fleet.json" "$outdir/books.json"; then
echo "dry-run: PRIVACY LEAK — a per-payer/provider-account id or hostname reached the output" >&2
exit 1
fi
@@ -994,11 +1067,11 @@ EOF
exit 1
fi
# Expenses: itemized by design, but the shape is still pinned — and the
- # cache seams must behave (cached_cost_cents). Default: all four providers
+ # cache seams must behave (cached_cost_cents). Default: all six providers
# from fixtures/env, with the metered results freshly cached; omit: no
- # expenses key at all; stale-cache: both metered fetches failed and the
- # seeded same-month cache served (aws 999 / anthropic 888).
- local aws_c an_c
+ # expenses key at all; stale-cache: all three metered fetches failed and the
+ # seeded same-month cache served (aws 999 / anthropic 888 / dnsimple 777).
+ local aws_c an_c dn_c
aws_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[0].month_to_date_cents')")
case "${STATUS_DRY_EXPENSES:-}" in
omit)
@@ -1007,20 +1080,26 @@ EOF
;;
stale-cache)
an_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[1].month_to_date_cents')")
- [ "$aws_c" = "999" ] && [ "$an_c" = "888" ] ||
- { echo "dry-run: stale same-month cache must serve on fetch failure (got aws='$aws_c' anthropic='$an_c')" >&2; exit 1; }
+ dn_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[2].month_to_date_cents')")
+ [ "$aws_c" = "999" ] && [ "$an_c" = "888" ] && [ "$dn_c" = "777" ] ||
+ { echo "dry-run: stale same-month cache must serve on fetch failure (got aws='$aws_c' anthropic='$an_c' dnsimple='$dn_c')" >&2; exit 1; }
;;
*)
an_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[2].month_to_date_cents')")
+ dn_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[4].month_to_date_cents')")
[ "$aws_c" = "1234" ] || { echo "dry-run: CE fixture must yield 1234 cents (got '$aws_c')" >&2; exit 1; }
[ "$an_c" = "250" ] || { echo "dry-run: anthropic fixture must yield 250 cents (got '$an_c')" >&2; exit 1; }
+ [ "$dn_c" = "2450" ] || { echo "dry-run: dnsimple fixture must yield 2450 cents — collected only (got '$dn_c')" >&2; exit 1; }
[ "$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[1].monthly_cents')")" = "1400" ] ||
{ echo "dry-run: hetzner constant must yield 1400 cents" >&2; exit 1; }
[ "$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[3].monthly_cents')")" = "2000" ] ||
{ echo "dry-run: claude.ai constant must yield 2000 cents" >&2; exit 1; }
+ [ "$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[5].yearly_cents')")" = "9900" ] ||
+ { echo "dry-run: apple constant must yield 9900 cents, as billed (yearly)" >&2; exit 1; }
[ -f "$COSTS_CACHE" ] &&
[ "$(sqj "SELECT json_extract(readfile('$COSTS_CACHE'), '\$.aws.month_to_date_cents')")" = "1234" ] &&
- [ "$(sqj "SELECT json_extract(readfile('$COSTS_CACHE'), '\$.anthropic.month_to_date_cents')")" = "250" ] ||
+ [ "$(sqj "SELECT json_extract(readfile('$COSTS_CACHE'), '\$.anthropic.month_to_date_cents')")" = "250" ] &&
+ [ "$(sqj "SELECT json_extract(readfile('$COSTS_CACHE'), '\$.dnsimple.month_to_date_cents')")" = "2450" ] ||
{ echo "dry-run: metered results must be cached per provider" >&2; exit 1; }
;;
esac
diff --git a/docs/status.md b/docs/status.md
index 5b6d74d..3fa2eb1 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -10,10 +10,13 @@ public *ledger* repo is still roadmap — see `docs/planet-scale.md`).
band words — see below), the version distribution, whether the shared
services (home / push / cloud / connect) are up, and fleet-backup
replication health.
-- **The books** (`books.html` + `books.json`) — income, in aggregate: active
- paid subscriptions (total and by tier — tier names are the public price
- book), MRR in EUR, and gross collected this month — plus infrastructure
- spend, itemized per provider (AWS, Hetzner, Anthropic API, claude.ai).
+- **The books** (`books.html` + `books.json`) — two clearly-headed halves.
+ **Income**, in aggregate: active paid subscriptions (total and by tier —
+ tier names are the public price book), MRR in EUR, and gross collected this
+ month. **Expenditure**, itemized per provider (AWS, Hetzner, Anthropic API,
+ claude.ai, DNSimple, Apple Developer). Each section renders independently:
+ a degraded income feed shows its notice inside the Income section and never
+ hides Expenditure.
- **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
@@ -132,7 +135,9 @@ configured / Stripe is unreachable):
{ "provider": "aws", "month_to_date_cents": 1234, "currency": "usd" },
{ "provider": "hetzner", "monthly_cents": 1400, "currency": "eur" },
{ "provider": "anthropic", "month_to_date_cents": 250, "currency": "usd" },
- { "provider": "claude.ai", "monthly_cents": 2000, "currency": "usd" }
+ { "provider": "claude.ai", "monthly_cents": 2000, "currency": "usd" },
+ { "provider": "dnsimple", "month_to_date_cents": 2450, "currency": "usd" },
+ { "provider": "apple", "yearly_cents": 9900, "currency": "eur" }
] }
}
```
@@ -143,9 +148,11 @@ gross of positive `charge`/`payment` balance transactions since the month
started (refunds and payouts excluded — it's gross income, not net cash).
`expenses` is infrastructure spend, itemized per provider — the deliberate
-mirror image of income's aggregation (see the privacy rules above). Two
-provider shapes exist: metered providers carry `month_to_date_cents`, flat
-subscriptions carry `monthly_cents`; each in its own billing currency.
+mirror image of income's aggregation (see the privacy rules above). Three
+provider shapes exist, exactly one amount key each: metered providers carry
+`month_to_date_cents`, flat monthly subscriptions `monthly_cents`, flat
+yearly ones `yearly_cents` — each as billed, in its own billing currency (a
+yearly fee is published yearly, never divided into a fake monthly).
- **aws** — month-to-date UnblendedCost from Cost Explorer (a us-east-1
global endpoint, regardless of where the bucket lives). CE bills ~$0.01
@@ -165,6 +172,20 @@ subscriptions carry `monthly_cents`; each in its own billing currency.
not the sidecars' completion key. Same cache treatment as AWS.
- **claude.ai** — the claude.ai subscription: fixed monthly with no API for
it, so the operator-set constant `STATUS_CLAUDE_MONTHLY_CENTS` (USD).
+- **dnsimple** — the product domains (elevenmessenger.com, 11ch.at,
+ 11factor.org): month-to-date from the billing-charges API
+ (`GET https://api.dnsimple.com/v2/{account}/billing/charges` with
+ `start_date`/`end_date`; `collected` charges summed — refunds are not
+ spend). Amounts arrive as decimal dollar strings with **no currency field**,
+ so the published `usd` is the documented constant (DNSimple's price book is
+ USD), not parsed. Authenticated with `STATUS_DNSIMPLE_TOKEN` +
+ `STATUS_DNSIMPLE_ACCOUNT` — an account API token, read-only use here, never
+ echoed. Same cache treatment as AWS; more than one page of charges in a
+ month counts as failure (a partial sum never publishes).
+- **apple** — the Apple Developer Program membership: fixed yearly with no
+ billing API, so the operator-set constant `STATUS_APPLE_YEARLY_CENTS`
+ (EUR), published as billed (`yearly_cents`) — never divided into a fake
+ monthly.
No providers → the whole `expenses` object is omitted. Expenses ride the
degraded income-unavailable shape too — a Stripe outage doesn't hide spend
@@ -231,10 +252,12 @@ carries the sources and the "as of" date).
2. **Creds on the box**: write `/data/status.env` with
`AWS_ACCESS_KEY_ID=…` / `AWS_SECRET_ACCESS_KEY=…` (the status-publisher
key also covers the Cost Explorer read), plus the optional expense vars:
- `STATUS_HETZNER_MONTHLY_CENTS=…`, `STATUS_CLAUDE_MONTHLY_CENTS=…`, and
- `STATUS_ANTHROPIC_ADMIN_KEY=…` (an `sk-ant-admin…` key with read-only
- usage scope — never the sidecars' completion key). `chown lchat:lchat`,
- `chmod 0600`.
+ `STATUS_HETZNER_MONTHLY_CENTS=…`, `STATUS_CLAUDE_MONTHLY_CENTS=…`,
+ `STATUS_APPLE_YEARLY_CENTS=…`, `STATUS_ANTHROPIC_ADMIN_KEY=…` (an
+ `sk-ant-admin…` key with read-only usage scope — never the sidecars'
+ 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/{index.html,books.html,carbon.html,carbon.json,contributors.html,contributors.json,icon-192.png}
root@<box>:/opt/lchat/status-pages/`.
4. **Install the timer**: `scp deploy/status-publish.sh root@<box>:/opt/lchat/`
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs
index b516812..2cdc46d 100644
--- a/test/status-contract.test.mjs
+++ b/test/status-contract.test.mjs
@@ -145,35 +145,41 @@ await test("degraded books shape carries available:false + reason", () => {
await test("fixtures contain no per-payer ids or hostnames", () => {
for (const j of [fleet, fleetGF, fleetAC, books, booksDown, booksNoExp, carbon, contributors]) {
const s = JSON.stringify(j);
- assert.ok(!/sub_|cus_|txn_|wrkspc_|req_fixture|ce_fixture|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80));
+ assert.ok(!/sub_|cus_|txn_|wrkspc_|req_fixture|ce_fixture|dnsfix|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80));
}
});
// Expenses publish ITEMIZED by design — decision #19's other half (income
// aggregates, expenses itemize; the amounts are public on purpose). The
-// schema is still an ALLOWLIST: provider word + one amount + its own billing
-// currency, nothing else — so an account id, invoice id, or resource-level
-// line can't ship by accident. Currencies stay per-provider, never
-// FX-converted, and only usd/eur exist today.
+// schema is still an ALLOWLIST: provider word + EXACTLY ONE amount key
+// (month_to_date_cents for metered providers, monthly_cents / yearly_cents
+// for flat subscriptions, each as billed — a yearly fee is never divided into
+// a fake monthly) + its own billing currency, nothing else — so an account
+// id, invoice id, or resource-level line can't ship by accident. Currencies
+// stay per-provider, never FX-converted, and only usd/eur exist today.
await test("expenses carry only the allowed itemized schema", () => {
const exp = books.expenses;
assert.ok(exp, "books fixture carries expenses");
assert.deepEqual(Object.keys(exp).sort(), ["month", "providers"], "expenses keys");
assert.match(exp.month, /^\d{4}-\d{2}$/, "expenses.month is YYYY-MM");
+ // Each provider's key set carries exactly one of the three amount keys —
+ // the deepEqual below enforces the exactly-one rule per provider.
const allowed = {
"aws": ["currency", "month_to_date_cents", "provider"],
"hetzner": ["currency", "monthly_cents", "provider"],
"anthropic": ["currency", "month_to_date_cents", "provider"],
"claude.ai": ["currency", "monthly_cents", "provider"],
+ "dnsimple": ["currency", "month_to_date_cents", "provider"],
+ "apple": ["currency", "provider", "yearly_cents"],
};
// The dry-run's fixed fixture exercises every provider, in publish order.
assert.deepEqual(exp.providers.map((p) => p.provider),
- ["aws", "hetzner", "anthropic", "claude.ai"], "fixture exercises all providers");
+ ["aws", "hetzner", "anthropic", "claude.ai", "dnsimple", "apple"], "fixture exercises all providers");
for (const p of exp.providers) {
assert.ok(p.provider in allowed, "unknown provider " + p.provider);
assert.deepEqual(Object.keys(p).sort(), allowed[p.provider], p.provider + " keys");
assert.ok(["usd", "eur"].includes(p.currency), p.provider + " currency ∈ {usd,eur}, got " + p.currency);
- const cents = p.month_to_date_cents ?? p.monthly_cents;
+ const cents = p.month_to_date_cents ?? p.monthly_cents ?? p.yearly_cents;
assert.ok(Number.isInteger(cents) && cents >= 0, p.provider + " amount is integer cents ≥ 0");
}
});
diff --git a/test/status-fixtures/books-no-expenses.json b/test/status-fixtures/books-no-expenses.json
index 06d86c6..5591d17 100644
--- a/test/status-fixtures/books-no-expenses.json
+++ b/test/status-fixtures/books-no-expenses.json
@@ -1 +1 @@
-{"generated_at":"2026-07-11T21:42:11Z","available":true,"currency":"eur","month":"2026-07","subscriptions":{"active":3,"by_tier":[{"tier":"friends","count":2},{"tier":"family","count":1}]},"mrr_cents":1600,"collected_month_cents":1200}
+{"generated_at":"2026-07-12T06:20:50Z","available":true,"currency":"eur","month":"2026-07","subscriptions":{"active":3,"by_tier":[{"tier":"friends","count":2},{"tier":"family","count":1}]},"mrr_cents":1600,"collected_month_cents":1200}
diff --git a/test/status-fixtures/books.json b/test/status-fixtures/books.json
index 2de01a3..935d582 100644
--- a/test/status-fixtures/books.json
+++ b/test/status-fixtures/books.json
@@ -1 +1 @@
-{"generated_at":"2026-07-11T21:41:42Z","available":true,"currency":"eur","month":"2026-07","subscriptions":{"active":3,"by_tier":[{"tier":"friends","count":2},{"tier":"family","count":1}]},"mrr_cents":1600,"collected_month_cents":1200,"expenses":{"month":"2026-07","providers":[{"provider":"aws","month_to_date_cents":1234,"currency":"usd"},{"provider":"hetzner","monthly_cents":1400,"currency":"eur"},{"provider":"anthropic","month_to_date_cents":250,"currency":"usd"},{"provider":"claude.ai","monthly_cents":2000,"currency":"usd"}]}}
+{"generated_at":"2026-07-12T06:20:49Z","available":true,"currency":"eur","month":"2026-07","subscriptions":{"active":3,"by_tier":[{"tier":"friends","count":2},{"tier":"family","count":1}]},"mrr_cents":1600,"collected_month_cents":1200,"expenses":{"month":"2026-07","providers":[{"provider":"aws","month_to_date_cents":1234,"currency":"usd"},{"provider":"hetzner","monthly_cents":1400,"currency":"eur"},{"provider":"anthropic","month_to_date_cents":250,"currency":"usd"},{"provider":"claude.ai","monthly_cents":2000,"currency":"usd"},{"provider":"dnsimple","month_to_date_cents":2450,"currency":"usd"},{"provider":"apple","yearly_cents":9900,"currency":"eur"}]}}
diff --git a/test/status-fixtures/fleet-at-capacity.json b/test/status-fixtures/fleet-at-capacity.json
index ac470f4..54e36a6 100644
--- a/test/status-fixtures/fleet-at-capacity.json
+++ b/test/status-fixtures/fleet-at-capacity.json
@@ -1 +1 @@
-{"generated_at":"2026-07-11T18:41:14Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","always_on":2,"awake":2,"asleep":2,"load":"critical","region":"EU · Germany"}]},"capacity":{"level":"at-capacity"},"versions":[{"version":"v100","count":3},{"version":"v101","count":2},{"version":"unknown","count":1}],"services":{"home":true,"push":true,"cloud":true,"connect":false},"replication":{"healthy":true,"lag_seconds":42}}
+{"generated_at":"2026-07-12T06:20:50Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","always_on":2,"awake":2,"asleep":2,"load":"critical","region":"EU · Germany"}]},"capacity":{"level":"at-capacity"},"versions":[{"version":"v100","count":3},{"version":"v101","count":2},{"version":"unknown","count":1}],"services":{"home":true,"push":true,"cloud":true,"connect":false},"replication":{"healthy":true,"lag_seconds":42}}
diff --git a/test/status-fixtures/fleet-getting-full.json b/test/status-fixtures/fleet-getting-full.json
index 3e47143..11a868a 100644
--- a/test/status-fixtures/fleet-getting-full.json
+++ b/test/status-fixtures/fleet-getting-full.json
@@ -1 +1 @@
-{"generated_at":"2026-07-11T18:41:14Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","always_on":2,"awake":2,"asleep":2,"load":"comfortable","region":"EU · Germany"}]},"capacity":{"level":"getting-full"},"versions":[{"version":"v100","count":3},{"version":"v101","count":2},{"version":"unknown","count":1}],"services":{"home":true,"push":true,"cloud":true,"connect":false},"replication":{"healthy":true,"lag_seconds":42}}
+{"generated_at":"2026-07-12T06:20:49Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","always_on":2,"awake":2,"asleep":2,"load":"comfortable","region":"EU · Germany"}]},"capacity":{"level":"getting-full"},"versions":[{"version":"v100","count":3},{"version":"v101","count":2},{"version":"unknown","count":1}],"services":{"home":true,"push":true,"cloud":true,"connect":false},"replication":{"healthy":true,"lag_seconds":42}}
diff --git a/test/status-fixtures/fleet.json b/test/status-fixtures/fleet.json
index a8cd4db..1fc1f1a 100644
--- a/test/status-fixtures/fleet.json
+++ b/test/status-fixtures/fleet.json
@@ -1 +1 @@
-{"generated_at":"2026-07-11T18:41:14Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","always_on":2,"awake":2,"asleep":2,"load":"comfortable","region":"EU · Germany"}]},"capacity":{"level":"plenty"},"versions":[{"version":"v100","count":3},{"version":"v101","count":2},{"version":"unknown","count":1}],"services":{"home":true,"push":true,"cloud":true,"connect":false},"replication":{"healthy":true,"lag_seconds":42}}
+{"generated_at":"2026-07-12T06:20:49Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","always_on":2,"awake":2,"asleep":2,"load":"comfortable","region":"EU · Germany"}]},"capacity":{"level":"plenty"},"versions":[{"version":"v100","count":3},{"version":"v101","count":2},{"version":"unknown","count":1}],"services":{"home":true,"push":true,"cloud":true,"connect":false},"replication":{"healthy":true,"lag_seconds":42}}
diff --git a/web/status/books.html b/web/status/books.html
index dbfee9b..abbef14 100644
--- a/web/status/books.html
+++ b/web/status/books.html
@@ -58,9 +58,10 @@
section h2 { font-size: 15px; font-weight: 650; margin: 0 0 10px; }
section { margin-bottom: 28px; }
- /* Infrastructure note sits directly under its tiles, in secondary ink. */
- #infra-tiles { margin-bottom: 10px; }
+ section h3 { font-size: 14px; font-weight: 650; margin: 18px 0 10px; }
.note { color: var(--ink-2); font-size: 14px; }
+ /* One-line design explainer directly under each section heading. */
+ .explain { margin: -4px 0 12px; }
/* Per-tier subscriptions: single-series horizontal bar list; rows are real
text (their own table view), bars aria-hidden. Thin mark, 4px rounded
@@ -91,35 +92,39 @@
<h1>The books</h1>
</div>
</header>
- <p class="lede">Eleven’s income, published as it stands — part of running the
- service in the open. <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="lede">Eleven’s income and spending, published as they stand — part
+ of running the service in the open.</p>
<p class="asof">As of <time id="asof" datetime="">—</time><span id="staleness"></span></p>
<p id="error" role="alert">Couldn’t load the books feed. It may be mid-publish — this page retries every minute.</p>
- <p id="unavailable" role="status">Income is unavailable right now: <span id="reason"></span>.</p>
<div id="books">
+ <!-- Two clearly-headed halves, the deliberate mirror image of each other:
+ income aggregates, expenditure itemizes (decision #19). Each section
+ renders independently — a degraded income feed shows its notice here,
+ inside Income, and never hides Expenditure (or vice versa). -->
<section aria-labelledby="h-income" id="s-income">
<h2 id="h-income">Income</h2>
- <div class="tiles">
- <div class="tile"><div class="label">Active subscriptions</div><div class="value" id="t-active">—</div></div>
- <div class="tile"><div class="label">Monthly recurring revenue</div><div class="value" id="t-mrr">—</div></div>
- <div class="tile"><div class="label" id="l-collected">Collected this month</div><div class="value" id="t-collected">—</div></div>
+ <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 id="unavailable" role="status">Income is unavailable right now: <span id="reason"></span>.</p>
+ <div id="income-body">
+ <div class="tiles">
+ <div class="tile"><div class="label">Active subscriptions</div><div class="value" id="t-active">—</div></div>
+ <div class="tile"><div class="label">Monthly recurring revenue</div><div class="value" id="t-mrr">—</div></div>
+ <div class="tile"><div class="label" id="l-collected">Collected this month</div><div class="value" id="t-collected">—</div></div>
+ </div>
+ <h3 id="h-tiers">Subscriptions by tier</h3>
+ <ul class="bars" id="tiers"><li><span class="empty">Loading…</span></li></ul>
</div>
</section>
- <section aria-labelledby="h-infra" id="s-infra" hidden>
- <h2 id="h-infra">Infrastructure</h2>
- <div class="tiles" id="infra-tiles"></div>
- <p class="note">Expenses publish itemized by design — one line per
- provider, in that provider’s own billing currency.</p>
- </section>
-
- <section aria-labelledby="h-tiers" id="s-tiers">
- <h2 id="h-tiers">Subscriptions by tier</h2>
- <ul class="bars" id="tiers"><li><span class="empty">Loading…</span></li></ul>
+ <section aria-labelledby="h-expenditure" id="s-expenditure" hidden>
+ <h2 id="h-expenditure">Expenditure</h2>
+ <p class="note explain"><strong>Itemized by design:</strong> one line per
+ provider, in that provider’s own billing currency — never converted.</p>
+ <div class="tiles" id="exp-tiles"></div>
</section>
</div>
@@ -143,6 +148,7 @@ const FIELDS = [
"mrr_cents", "collected_month_cents",
"expenses.month", "expenses.providers[].provider", "expenses.providers[].currency",
"expenses.providers[].month_to_date_cents", "expenses.providers[].monthly_cents",
+ "expenses.providers[].yearly_cents",
];
const $ = (id) => document.getElementById(id);
@@ -162,17 +168,18 @@ function render(d) {
staleEl.textContent = " — this feed looks stale";
}
- // Income surfaces fold on the available flag; expenses ride BOTH shapes —
- // a Stripe outage must not hide infrastructure spend the feed can still
- // honestly state (and vice versa: no expenses just hides that section).
+ // Income folds on the available flag INSIDE its own section — the degraded
+ // notice renders under the Income heading, the heading stays. Expenses ride
+ // BOTH shapes and render independently: a Stripe outage must not hide
+ // infrastructure spend the feed can still honestly state (and vice versa:
+ // no expenses just hides the Expenditure section).
if (!d.available) {
$("reason").textContent = d.reason || "not configured";
$("unavailable").classList.add("show");
} else {
$("unavailable").classList.remove("show");
}
- $("s-income").hidden = !d.available;
- $("s-tiers").hidden = !d.available;
+ $("income-body").hidden = !d.available;
renderExpenses(d.expenses);
if (!d.available) return;
@@ -225,15 +232,20 @@ const EXPENSE_LABELS = {
"hetzner": "Hetzner · monthly flat",
"anthropic": "Anthropic API · month to date",
"claude.ai": "claude.ai · monthly",
+ "dnsimple": "DNSimple · month to date",
+ "apple": "Apple Developer · yearly",
};
function renderExpenses(exp) {
- const sec = $("s-infra");
+ const sec = $("s-expenditure");
const providers = (exp && exp.providers) || [];
- const tiles = $("infra-tiles");
+ const tiles = $("exp-tiles");
tiles.replaceChildren();
for (const p of providers) {
- const cents = typeof p.month_to_date_cents === "number" ? p.month_to_date_cents : p.monthly_cents;
+ // Exactly one amount key per provider: metered month-to-date, flat
+ // monthly, or flat yearly — each shown as billed (a yearly fee is never
+ // divided into a fake monthly).
+ const cents = [p.month_to_date_cents, p.monthly_cents, p.yearly_cents].find((v) => typeof v === "number");
if (typeof cents !== "number") continue;
const tile = document.createElement("div");
tile.className = "tile";