web+server: infrastructure spend on the public books page (#740)
books.json gains an itemized expenses object (decision #19's other half: income aggregates, expenses itemize β the amounts are public on purpose): AWS month-to-date via Cost Explorer and the Anthropic API via the Admin cost report (both cached 6h in $DATA_DIR/status-costs-cache.json β CE bills per request and the publisher runs every 5 minutes; a failed refetch serves the same-month stale cache), plus the two flat subscriptions (Hetzner, claude.ai) as honest operator-set constants. Currencies stay per-provider β never FX-converted. Unresolvable providers are omitted (never zero, never a guess), no providers omits the whole object, and expenses ride the degraded income-unavailable shape too. books.html grows an Infrastructure section (one tile per provider, each in its own currency) + the itemized-by-design note; the contract test allowlists the expenses schema and pins omission; dry-run gains STATUS_DRY_EXPENSES=omit|stale-cache twins so the cache seams are tested end-to-end; status.tf grants ce:GetCostAndUsage (comment-only scope rationale; NOT applied).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+490
−66
deploy/status-publish.sh+271 −13docs/status.md+66 −14infra/prod/status.tf+19 −5test/status-contract.test.mjs+64 −19test/status-fixtures/books-no-expenses.json+1 −0test/status-fixtures/books.json+1 −1web/status/books.html+68 −14
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh| index e50556a..3936c36 100755 |
| --- a/deploy/status-publish.sh |
| +++ b/deploy/status-publish.sh |
| @@ -1,9 +1,9 @@ |
| #!/usr/bin/env bash |
| # π€ Public status publish (issue #550 Parts 11/12): every 5 minutes, build two |
| # small world-readable JSONs β fleet.json (fleet state) and books.json (the |
| -# books: Stripe aggregates) β and upload them plus the static viewer pages to |
| -# the public status bucket (infra/prod/status.tf). docs/status.md is the |
| -# operator guide. |
| +# books: Stripe income aggregates + itemized infrastructure expenses) β and |
| +# upload them plus the static viewer pages to the public status bucket |
| +# (infra/prod/status.tf). docs/status.md is the operator guide. |
| # |
| # ============================ PRIVACY β NON-NEGOTIABLE ======================== |
| # Everything this script uploads is PUBLIC. The rules (decision #19 in #550: |
| @@ -39,6 +39,14 @@ |
| # lines, or timestamps of individual payments. The Stripe key is read for |
| # the API calls and must never be echoed, logged, or uploaded. |
| # |
| +# books.json expenses β the deliberate mirror image (decision #19's other |
| +# half: income aggregates, EXPENSES PUBLISH ITEMIZED β the amounts are |
| +# public on purpose). One line per provider: provider word + amount + its |
| +# own billing currency (never FX-converted β no rate, no misstatement). |
| +# NEVER account ids, invoice ids, workspace ids, resource-level lines, or |
| +# usage breakdowns. The AWS creds and the Anthropic Admin key are read for |
| +# the API calls and must never be echoed, logged, or uploaded. |
| +# |
| # carbon.json β NOT generated here: it's COMMITTED data (the AI-build carbon |
| # tally, web/status/carbon.json, accrued by tools/carbon-tally.mjs), |
| # staged with the pages and republished as-is. Aggregate token counts and |
| @@ -67,7 +75,12 @@ |
| # Prerequisites on the box: |
| # /data/status.env β AWS creds for the status-publisher IAM user |
| # (infra/prod/status.tf; Put/Get/List on the status |
| -# bucket only, no Delete). chown lchat, chmod 0600. |
| +# 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). |
| # /opt/lchat/status-pages/ β the viewer pages, copied from web/status/ |
| # (index.html, books.html, carbon.html, carbon.json, |
| # icon-192.png). |
| @@ -86,6 +99,12 @@ CONTROL_ENV=${STATUS_CONTROL_ENV:-/data/control.env} |
| # uses fixture twins instead and never reads live box state). |
| MEMINFO=${STATUS_MEMINFO:-/proc/meminfo} |
| DATA_DIR=${STATUS_DATA_DIR:-/data} |
| +# Metered-expense cache (see cached_cost_cents): AWS Cost Explorer and the |
| +# Anthropic cost report both bill/limit per request while this script runs |
| +# every 5 minutes, so their results persist here between runs (one JSON object |
| +# per provider: {fetched_at, month, month_to_date_cents}). Lives in /data so |
| +# it survives reboots; the dry-run repoints it into its fixture dir. |
| +COSTS_CACHE=${STATUS_COSTS_CACHE:-$DATA_DIR/status-costs-cache.json} |
| # Coarse public region of this box β continent + country ONLY ("EU Β· Germany"); |
| # see PRIVACY above. Optional: unset omits the field from fleet.json. Per-box |
| # regions will come from mesh heartbeats when multi-box lands. Resolution: |
| @@ -446,20 +465,183 @@ fetch_pages() { |
| STRIPE_FIXTURE_N=0 |
| } |
| -books_unavailable() { # $1=out $2=reason (a fixed, script-controlled string) |
| - sqj "SELECT json_object('generated_at', '$(now_iso)', 'available', json('false'), 'reason', '$2')" > "$1" |
| +books_unavailable() { # $1=out $2=reason (fixed, script-controlled) $3=expenses json ("" = omit) |
| + local exp_kv="" |
| + [ -n "${3:-}" ] && exp_kv=", 'expenses', json('$3')" |
| + sqj "SELECT json_object('generated_at', '$(now_iso)', 'available', json('false'), 'reason', '$2'$exp_kv)" > "$1" |
| +} |
| + |
| +# ---- expenses (infrastructure spend) ----------------------------------------- |
| +# Decision #19's other half: income aggregates, EXPENSES PUBLISH ITEMIZED β |
| +# one line per provider, real amount, that provider's own billing currency |
| +# (never FX-converted: publishing USD as USD and EUR as EUR means there is no |
| +# exchange rate to misstate). A provider that can't be honestly resolved is |
| +# OMITTED β never zero, never a guess β and with no providers at all the whole |
| +# expenses object is omitted, matching books_unavailable's degrade discipline. |
| + |
| +# env_cents <value> β validate an operator-set integer-cents constant; echoes |
| +# nothing for anything that isn't a plain non-negative integer. |
| +env_cents() { case "${1:-}" in ''|*[!0-9]*) ;; *) printf '%s' "$1" ;; esac; } |
| + |
| +# fetch_aws_cents <month> β one live Cost Explorer query: month start β |
| +# tomorrow (so today's partial day is included), MONTHLY granularity, |
| +# UnblendedCost. Cost Explorer is a GLOBAL endpoint homed in us-east-1 β the |
| +# --region below is the CE API's home, unrelated to STATUS_REGION (the status |
| +# bucket's region). Integrity before publishing: the Amount must be a plain |
| +# decimal and the Unit USD, or the fetch counts as failed β a garbled or |
| +# negative figure (credits) is omitted, never misstated. Dry-run twin reads a |
| +# fixture instead of calling AWS. |
| +fetch_aws_cents() { |
| + local month=$1 f="$TMP/aws-ce.json" end amount unit |
| + if [ -n "$DRY_RUN" ]; then |
| + [ -f "$FIXDIR/aws-ce.json" ] || return 1 |
| + cp "$FIXDIR/aws-ce.json" "$f" |
| + else |
| + command -v aws >/dev/null 2>&1 || return 1 |
| + end=$(sqj "SELECT date('now','+1 day')") |
| + aws ce get-cost-and-usage --region us-east-1 \ |
| + --time-period "Start=$month-01,End=$end" \ |
| + --granularity MONTHLY --metrics UnblendedCost \ |
| + --cli-connect-timeout 10 --cli-read-timeout 30 \ |
| + --output json > "$f" 2>/dev/null || return 1 |
| + fi |
| + [ "$(sqj "SELECT json_valid(readfile('$f'))")" = "1" ] || return 1 |
| + amount=$(sqj "SELECT json_extract(readfile('$f'), '\$.ResultsByTime[0].Total.UnblendedCost.Amount')") |
| + unit=$(sqj "SELECT json_extract(readfile('$f'), '\$.ResultsByTime[0].Total.UnblendedCost.Unit')") |
| + [ "$unit" = "USD" ] || return 1 |
| + case "$amount" in ''|.|*[!0-9.]*|*.*.*) return 1 ;; esac |
| + sqj "SELECT CAST(ROUND($amount * 100) AS INTEGER)" |
| +} |
| + |
| +# fetch_anthropic_cents <month> β one live Admin API cost-report call: |
| +# GET /v1/organizations/cost_report, daily buckets from the month start |
| +# (limit=31 covers a full month in one page; no ending_at β runs to now, so |
| +# today's partial day is included). Amounts come back as decimal strings of |
| +# CENTS ("123.45" = $1.23), currency documented as always USD β a non-USD row |
| +# or a has_more page we didn't fetch counts as failure (never publish a |
| +# partial or unverified figure). STATUS_ANTHROPIC_ADMIN_KEY is an ADMIN key |
| +# (sk-ant-adminβ¦) with read-only usage scope β NOT the sidecars' completion |
| +# key: it can read org cost/usage metadata and nothing else. It rides only in |
| +# the x-api-key header (-sS keeps curl from printing it) and must never be |
| +# echoed or logged. Dry-run twin reads a fixture instead. |
| +fetch_anthropic_cents() { |
| + local month=$1 f="$TMP/anthropic-cost.json" |
| + if [ -n "$DRY_RUN" ]; then |
| + [ -f "$FIXDIR/anthropic-cost.json" ] || return 1 |
| + cp "$FIXDIR/anthropic-cost.json" "$f" |
| + else |
| + [ -n "${STATUS_ANTHROPIC_ADMIN_KEY:-}" ] || return 1 |
| + curl -sS --fail --max-time 30 \ |
| + -H "x-api-key: $STATUS_ANTHROPIC_ADMIN_KEY" \ |
| + -H "anthropic-version: 2023-06-01" \ |
| + "https://api.anthropic.com/v1/organizations/cost_report?starting_at=${month}-01T00:00:00Z&bucket_width=1d&limit=31" \ |
| + > "$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'), '\$.has_more'), 0)")" != "1" ] || return 1 |
| + [ "$(sqj "SELECT COUNT(*) FROM json_each(readfile('$f'), '\$.data') d, json_each(d.value, '\$.results') r |
| + WHERE json_extract(r.value, '\$.currency') <> 'USD'")" = "0" ] || return 1 |
| + sqj "SELECT CAST(ROUND(COALESCE(SUM(json_extract(r.value, '\$.amount')), 0)) AS INTEGER) |
| + FROM json_each(readfile('$f'), '\$.data') d, json_each(d.value, '\$.results') r" |
| +} |
| + |
| +# 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 |
| +# 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 |
| +# STATUS_COSTS_MAX_AGE_HOURS (default 6) or from a previous month (last |
| +# month's total is not this month's spend β a cross-month entry never |
| +# publishes). A failed refetch falls back to the valid-but-stale same-month |
| +# entry (stale beats absent for a month-to-date figure); no usable entry + |
| +# failure β echoes nothing and the provider is omitted. |
| +cached_cost_cents() { |
| + local provider=$1 month=$2 now have_cache="" cache_ts=0 cache_month="" cache_cents="" max_age_h cents |
| + now=$(date -u +%s) |
| + max_age_h=${STATUS_COSTS_MAX_AGE_HOURS:-6} |
| + case "$max_age_h" in ''|*[!0-9]*) max_age_h=6 ;; esac |
| + |
| + if [ -f "$COSTS_CACHE" ] && [ "$(sqj "SELECT json_valid(readfile('$COSTS_CACHE'))" 2>/dev/null)" = "1" ]; then |
| + have_cache=1 |
| + cache_ts=$(sqj "SELECT COALESCE(json_extract(readfile('$COSTS_CACHE'), '\$.$provider.fetched_at'), 0)") |
| + cache_month=$(sqj "SELECT COALESCE(json_extract(readfile('$COSTS_CACHE'), '\$.$provider.month'), '')") |
| + cache_cents=$(sqj "SELECT COALESCE(json_extract(readfile('$COSTS_CACHE'), '\$.$provider.month_to_date_cents'), '')") |
| + fi |
| + case "$cache_ts" in ''|*[!0-9]*) cache_ts=0 ;; esac |
| + case "$cache_cents" in ''|*[!0-9]*) cache_cents="" ;; esac |
| + [ "$cache_month" = "$month" ] || cache_cents="" # cross-month cache never publishes |
| + |
| + if [ -n "$cache_cents" ] && [ $((now - cache_ts)) -lt $((max_age_h * 3600)) ]; then |
| + echo "$cache_cents" |
| + return 0 |
| + fi |
| + |
| + cents=$("fetch_${provider}_cents" "$month") || cents="" |
| + case "$cents" in ''|*[!0-9]*) cents="" ;; esac |
| + if [ -n "$cents" ]; then |
| + # Merge this provider's entry into the cache (other providers' entries survive). |
| + local base="'{}'" |
| + [ -n "$have_cache" ] && base="readfile('$COSTS_CACHE')" |
| + sqj "SELECT json_set($base, '\$.$provider', |
| + json_object('fetched_at', $now, 'month', '$month', 'month_to_date_cents', $cents))" \ |
| + > "$COSTS_CACHE.tmp" && mv "$COSTS_CACHE.tmp" "$COSTS_CACHE" |
| + echo "$cents" |
| + return 0 |
| + fi |
| + [ -n "$cache_cents" ] && echo "$cache_cents" |
| + return 0 |
| +} |
| + |
| +# expenses_object β the expenses JSON object, or nothing when no provider |
| +# resolves. Providers, in publish order: |
| +# aws β Cost Explorer month-to-date (metered; cached above), USD. |
| +# hetzner β no billing API exists: the box is a flat-rate Hetzner Cloud |
| +# server, so the figure is the honest operator-set constant |
| +# STATUS_HETZNER_MONTHLY_CENTS (EUR β Hetzner bills in EUR). |
| +# Revisit if Hetzner ever ships a billing API. |
| +# anthropic β Admin API cost report month-to-date (metered; cached), USD β |
| +# 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). |
| +expenses_object() { |
| + local month providers="" cents |
| + month=$(sqj "SELECT strftime('%Y-%m','now')") |
| + |
| + cents=$(cached_cost_cents aws "$month") |
| + [ -n "$cents" ] && providers=$(sqj "SELECT json_object('provider','aws','month_to_date_cents',$cents,'currency','usd')") |
| + |
| + cents=$(env_cents "${STATUS_HETZNER_MONTHLY_CENTS:-}") |
| + [ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','hetzner','monthly_cents',$cents,'currency','eur')")" |
| + |
| + cents=$(cached_cost_cents anthropic "$month") |
| + [ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','anthropic','month_to_date_cents',$cents,'currency','usd')")" |
| + |
| + cents=$(env_cents "${STATUS_CLAUDE_MONTHLY_CENTS:-}") |
| + [ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','claude.ai','monthly_cents',$cents,'currency','usd')")" |
| + |
| + [ -n "$providers" ] || return 0 |
| + sqj "SELECT json_object('month', '$month', 'providers', json('[$providers]'))" |
| } |
| build_books() { |
| - local out=$1 now month month_start |
| + local out=$1 now month month_start expenses_json expenses_kv="" |
| now=$(now_iso) |
| + # Expenses resolve independently of Stripe β income being unavailable must |
| + # not hide infrastructure spend we can honestly state, so the expenses |
| + # object rides the degraded shape too (and is simply absent when no |
| + # provider resolves). |
| + expenses_json=$(expenses_object) |
| + [ -n "$expenses_json" ] && expenses_kv=", |
| + 'expenses', json('$expenses_json')" |
| + |
| # The Stripe key comes from the cloud provisioner's env (billing.go reads the |
| # same var). Absent β billing isn't configured here; publish an honest |
| # "unavailable" rather than zeros that would read as "no income". |
| STRIPE_KEY=$(grep -E '^LCHAT_STRIPE_SECRET_KEY=' "$CLOUD_ENV" 2>/dev/null | head -n1 | cut -d= -f2- || true) |
| if [ -z "${STRIPE_KEY:-}" ]; then |
| - books_unavailable "$out" "billing not configured" |
| + books_unavailable "$out" "billing not configured" "$expenses_json" |
| return |
| fi |
| @@ -478,7 +660,7 @@ build_books() { |
| # publish half-numbers. |
| if ! fetch_pages /v1/subscriptions "status=active" subs || |
| ! fetch_pages /v1/balance_transactions "created[gte]=$month_start" bal; then |
| - books_unavailable "$out" "stripe unreachable" |
| + books_unavailable "$out" "stripe unreachable" "$expenses_json" |
| return |
| fi |
| @@ -526,7 +708,7 @@ SELECT json_object( |
| SELECT SUM(json_extract(b.j,'\$.amount')) FROM bal b |
| WHERE json_extract(b.j,'\$.type') IN ('charge','payment') |
| AND json_extract(b.j,'\$.currency')='eur' |
| - AND json_extract(b.j,'\$.amount') > 0), 0) AS INTEGER)); |
| + AND json_extract(b.j,'\$.amount') > 0), 0) AS INTEGER)$expenses_kv); |
| SQL |
| } | sqlite3 :memory: > "$out" |
| } |
| @@ -710,6 +892,51 @@ 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 |
| + # 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. |
| + COSTS_CACHE="$FIXDIR/costs-cache.json" |
| + case "${STATUS_DRY_EXPENSES:-}" in |
| + omit) |
| + # No metered fixtures, no cache, no operator constants β every provider, |
| + # and then the whole expenses object, is omitted. |
| + STATUS_HETZNER_MONTHLY_CENTS="" |
| + STATUS_CLAUDE_MONTHLY_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). |
| + STATUS_HETZNER_MONTHLY_CENTS="" |
| + STATUS_CLAUDE_MONTHLY_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))" \ |
| + > "$COSTS_CACHE" |
| + ;; |
| + *) |
| + STATUS_HETZNER_MONTHLY_CENTS=1400 |
| + STATUS_CLAUDE_MONTHLY_CENTS=2000 |
| + # 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}]} |
| +EOF |
| + # Anthropic cost-report shape: two daily buckets, amounts are decimal |
| + # strings of CENTS (150.25 + 99.5 β rounds to 250). The workspace id is |
| + # a fixture marker that must not survive aggregation. |
| + cat > "$FIXDIR/anthropic-cost.json" <<'EOF' |
| +{"data":[ |
| + {"starting_at":"2026-07-01T00:00:00Z","ending_at":"2026-07-02T00:00:00Z", |
| + "results":[{"amount":"150.25","currency":"USD","workspace_id":"wrkspc_fixture_1"}]}, |
| + {"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 |
| + ;; |
| + esac |
| + |
| # Two subscription pages (exercises pagination) + one balance page. Shapes |
| # follow Stripe's list envelope; per-payer fields present in the fixture |
| # (ids, customer) must NOT survive into books.json β that's the test. |
| @@ -742,8 +969,8 @@ 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|\.example' "$outdir/fleet.json" "$outdir/books.json"; then |
| - echo "dry-run: PRIVACY LEAK β a per-payer id or hostname reached the output" >&2 |
| + if grep -qE 'sub_fixture|cus_fixture|txn_fixture|req_fixture|ce_fixture|wrkspc_fixture|\.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 |
| # Capacity must publish as an abstract band word β a known word, and not a |
| @@ -759,9 +986,40 @@ EOF |
| echo "dry-run: PRIVACY β capacity subtree carries a number: $cap" >&2 |
| 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 |
| + # 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 |
| + aws_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[0].month_to_date_cents')") |
| + case "${STATUS_DRY_EXPENSES:-}" in |
| + omit) |
| + [ "$(sqj "SELECT json_type(readfile('$outdir/books.json'), '\$.expenses') IS NULL")" = "1" ] || |
| + { echo "dry-run: expenses must be omitted entirely when no provider resolves" >&2; exit 1; } |
| + ;; |
| + 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; } |
| + ;; |
| + *) |
| + an_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[2].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; } |
| + [ "$(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; } |
| + [ -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" ] || |
| + { echo "dry-run: metered results must be cached per provider" >&2; exit 1; } |
| + ;; |
| + esac |
| echo "--- fleet.json ---"; cat "$outdir/fleet.json" |
| echo "--- books.json ---"; cat "$outdir/books.json" |
| - echo "dry-run ok: valid JSON, no hostnames, no per-payer fields, capacity abstract ($lvl)" |
| + echo "dry-run ok: valid JSON, no hostnames, no per-payer fields, capacity abstract ($lvl), expenses ${STATUS_DRY_EXPENSES:-itemized}" |
| } |
| case "${1:-}" in |
diff --git a/docs/status.md b/docs/status.md| index dbca0a4..53c1234 100644 |
| --- a/docs/status.md |
| +++ b/docs/status.md |
| @@ -12,7 +12,8 @@ public *ledger* repo is still roadmap β see `docs/planet-scale.md`). |
| 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. |
| + book), MRR in EUR, and gross collected this month β plus infrastructure |
| + spend, itemized per provider (AWS, Hetzner, Anthropic API, claude.ai). |
| - **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 |
| @@ -46,7 +47,14 @@ aggregation boundary lives in the generator and holds by construction: |
| - **Books: aggregated income only** (decision #19 in #550: income publishes |
| 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. Expenses/disbursements publish *itemized* β but those |
| + individual payments. |
| +- **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 |
| + exchange rate to misstate). Still never account ids, invoice ids, workspace |
| + ids, or resource-level lines β provider word + amount + currency is the |
| + entire schema, and the contract test allowlists it. A provider that can't |
| + be honestly resolved is omitted, never zeroed or guessed. Disbursements |
| belong to the future ledger repo, not these dashboards. |
| - **Carbon: aggregate token counts and kg estimates only.** Never per-session |
| data, never prompt or transcript content β the tally reads token *usage |
| @@ -106,7 +114,13 @@ configured / Stripe is unreachable): |
| "month": "2026-07", |
| "subscriptions": { "active": 3, "by_tier": [ { "tier": "friends", "count": 2 } ] }, |
| "mrr_cents": 1600, |
| - "collected_month_cents": 1200 |
| + "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" } |
| + ] } |
| } |
| ``` |
| @@ -115,6 +129,34 @@ to a month (yearly Γ· 12, etc.), EUR items only. Collected-this-month is the |
| 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. |
| + |
| +- **aws** β month-to-date UnblendedCost from Cost Explorer (a us-east-1 |
| + global endpoint, regardless of where the bucket lives). CE bills ~$0.01 |
| + per request and the publisher runs every 5 minutes, so the result is |
| + cached in `$DATA_DIR/status-costs-cache.json` (per-provider entries with |
| + `fetched_at`) and refetched only when older than |
| + `STATUS_COSTS_MAX_AGE_HOURS` (default 6) or from a previous month. A |
| + failed refetch serves the same-month stale cache; failure with no cache |
| + omits the entry. |
| +- **hetzner** β Hetzner has no billing API, so the flat-rate box publishes |
| + the honest operator-set constant `STATUS_HETZNER_MONTHLY_CENTS` (EUR); |
| + unset omits it. Revisit if Hetzner ships a billing API. |
| +- **anthropic** β the bot sidecars' API spend: month-to-date from the Admin |
| + API cost report (`GET /v1/organizations/cost_report`, daily buckets summed; |
| + amounts arrive as decimal cents, USD). Authenticated with |
| + `STATUS_ANTHROPIC_ADMIN_KEY` β an *Admin* key with read-only usage scope, |
| + 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). |
| + |
| +No providers β the whole `expenses` object is omitted. Expenses ride the |
| +degraded income-unavailable shape too β a Stripe outage doesn't hide spend |
| +the generator can still honestly state. |
| + |
| `carbon.json` (committed at `web/status/carbon.json`, republished as-is): an |
| `entries[]` ledger of measurement periods β `period_start`/`period_end`, |
| `source` (which contributor machine, as an anonymous name-pair label β same |
| @@ -163,7 +205,11 @@ carries the sources and the "as of" date). |
| `aws iam create-access-key --user-name status-publisher` (keys never land |
| in tofu state). |
| 2. **Creds on the box**: write `/data/status.env` with |
| - `AWS_ACCESS_KEY_ID=β¦` / `AWS_SECRET_ACCESS_KEY=β¦`; `chown lchat:lchat`, |
| + `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`. |
| 3. **Pages on the box**: `scp web/status/{index.html,books.html,carbon.html,carbon.json,icon-192.png} |
| root@<box>:/opt/lchat/status-pages/`. |
| @@ -187,16 +233,22 @@ origin unchanged. |
| ## Testing |
| - `./deploy/status-publish.sh --dry-run <dir>` builds both JSONs from |
| - self-constructed fake registry/Stripe fixtures (exercising pagination and |
| - the awake/asleep split) and fails on invalid JSON or a privacy leak β |
| - including any digit inside the capacity subtree. No box state, no network, |
| - no upload. `STATUS_DRY_CAPACITY=getting-full|at-capacity` swaps in the |
| - capacity fixture set for that band (default: plenty), so every abstract |
| - level is derivable in tests. |
| + self-constructed fake registry/Stripe/provider-billing fixtures (exercising |
| + pagination and the awake/asleep split) and fails on invalid JSON or a |
| + privacy leak β including any digit inside the capacity subtree, or a |
| + fixture workspace/request id surviving into the expenses. No box state, no |
| + network, no upload. `STATUS_DRY_CAPACITY=getting-full|at-capacity` swaps in |
| + the capacity fixture set for that band (default: plenty). |
| + `STATUS_DRY_EXPENSES=omit` removes every expense source (asserts the whole |
| + object is omitted); `STATUS_DRY_EXPENSES=stale-cache` fails both metered |
| + fetches against a seeded, past-max-age same-month cache (asserts the stale |
| + figures serve) β so the cache seams are testable end-to-end. |
| - `node test/status-contract.test.mjs` (in `npm test`) pins the pages' FIELDS |
| manifests against the dry-run fixtures in `test/status-fixtures/` β if the |
| generator's shape and the pages drift, it fails. It also allowlists the |
| - hosts/capacity schema (any extra key fails, so a precise number can't ship) |
| - and pins one fleet fixture per capacity band (`fleet-getting-full.json`, |
| - `fleet-at-capacity.json`). Regenerate fixtures from a fresh dry-run after |
| - changing the generator (the per-band ones via `STATUS_DRY_CAPACITY`). |
| + hosts/capacity schema **and** the expenses schema (any extra key fails, so |
| + a precise number or an account id can't ship), pins one fleet fixture per |
| + capacity band (`fleet-getting-full.json`, `fleet-at-capacity.json`), and |
| + pins expense omission (`books-no-expenses.json`). Regenerate fixtures from |
| + a fresh dry-run after changing the generator (the per-band ones via |
| + `STATUS_DRY_CAPACITY`, the no-expenses one via `STATUS_DRY_EXPENSES=omit`). |
diff --git a/infra/prod/status.tf b/infra/prod/status.tf| index 62cc151..a445b44 100644 |
| --- a/infra/prod/status.tf |
| +++ b/infra/prod/status.tf |
| @@ -3,7 +3,8 @@ |
| # deploy/status-publish.sh on the router box) and the two static viewer pages |
| # (web/status/). Everything in it is AGGREGATE BY DESIGN: fleet counts and |
| # version distribution with no hostnames, and Stripe income as totals only β |
| -# decision #19 (income publishes aggregated, never per-payer) and invariant 12 |
| +# decision #19 (income publishes aggregated, never per-payer; expenses are the |
| +# deliberate mirror image and publish itemized per provider) and invariant 12 |
| # (no PII in our stores) are enforced upstream in the generator; this bucket |
| # only ever receives what passed that gate. docs/status.md is the guide. |
| # |
| @@ -79,10 +80,11 @@ resource "aws_s3_bucket_policy" "status" { |
| depends_on = [aws_s3_bucket_public_access_block.status] |
| } |
| -# The publisher's credentials: object put/get + list on this bucket, nothing |
| -# else in the account β and no DeleteObject, so a leaked publisher key can |
| -# overwrite the current dashboards (noncurrent versions survive; versioning is |
| -# the rollback) but never destroy history. Mint the access key by hand |
| +# The publisher's credentials: object put/get + list on this bucket plus |
| +# read-only Cost Explorer, nothing else in the account β and no DeleteObject, |
| +# so a leaked publisher key can overwrite the current dashboards (noncurrent |
| +# versions survive; versioning is the rollback) but never destroy history. |
| +# Mint the access key by hand |
| # (aws iam create-access-key --user-name status-publisher) β keys never land |
| # in tofu state; they go in /data/status.env on the router box. |
| resource "aws_iam_user" "status_publisher" { |
| @@ -104,6 +106,18 @@ data "aws_iam_policy_document" "status_publisher" { |
| ] |
| resources = ["${aws_s3_bucket.status.arn}/*"] |
| } |
| + |
| + # The books page's expenses section: the publisher asks Cost Explorer for |
| + # the account's month-to-date spend (deploy/status-publish.sh, cached β CE |
| + # bills per request). ce:GetCostAndUsage has no resource-level scoping, so |
| + # "*" is the narrowest possible grant; it exposes only read-only cost |
| + # metadata, which is exactly what we deliberately publish anyway (decision |
| + # #19: expenses itemize). |
| + statement { |
| + sid = "ReadCostAndUsage" |
| + actions = ["ce:GetCostAndUsage"] |
| + resources = ["*"] |
| + } |
| } |
| resource "aws_iam_user_policy" "status_publisher" { |
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs| index 3137ac3..be4cf8d 100644 |
| --- a/test/status-contract.test.mjs |
| +++ b/test/status-contract.test.mjs |
| @@ -1,11 +1,12 @@ |
| // π€ Contract test for the public status dashboards: the fields each page in |
| // web/status/ declares it reads (its FIELDS manifest) must exist in the JSON |
| // deploy/status-publish.sh generates. The fixtures in test/status-fixtures/ |
| -// are the script's own --dry-run output (fleet.json, books.json; regenerate |
| -// with `./deploy/status-publish.sh --dry-run <dir>` after changing the |
| -// generator) plus the hand-pinned degraded shape (books-unavailable.json, |
| -// matching books_unavailable()). If this fails, page and generator have |
| -// drifted β fix whichever side changed, then the fixtures. |
| +// are the script's own --dry-run output (fleet.json, books.json, |
| +// books-no-expenses.json; regenerate with `./deploy/status-publish.sh |
| +// --dry-run <dir>` after changing the generator β the no-expenses variant via |
| +// STATUS_DRY_EXPENSES=omit) plus the hand-pinned degraded shape |
| +// (books-unavailable.json, matching books_unavailable()). If this fails, page |
| +// and generator have drifted β fix whichever side changed, then the fixtures. |
| // node test/status-contract.test.mjs |
| import assert from "node:assert/strict"; |
| import { readFileSync } from "node:fs"; |
| @@ -29,6 +30,7 @@ const fleetGF = JSON.parse(read("test/status-fixtures/fleet-getting-full.json")) |
| const fleetAC = JSON.parse(read("test/status-fixtures/fleet-at-capacity.json")); |
| const books = JSON.parse(read("test/status-fixtures/books.json")); |
| const booksDown = JSON.parse(read("test/status-fixtures/books-unavailable.json")); |
| +const booksNoExp = JSON.parse(read("test/status-fixtures/books-no-expenses.json")); |
| // Pull the FIELDS manifest out of a page's inline script. |
| function fields(html) { |
| @@ -37,22 +39,28 @@ function fields(html) { |
| return [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]); |
| } |
| -// Resolve a dotted path against an object; "seg[]" descends into the first |
| -// element of a non-empty array (the fixture must exercise the array). |
| +// Resolve a dotted path against an object; "seg[]" descends into a non-empty |
| +// array β ANY element carrying the rest of the path satisfies it (the |
| +// expenses providers are heterogeneous by design: month-to-date providers |
| +// carry month_to_date_cents, flat-rate ones monthly_cents). |
| function resolve(obj, path) { |
| - let cur = obj; |
| - for (const seg of path.split(".")) { |
| + const segs = path.split("."); |
| + function walk(cur, i) { |
| + if (i === segs.length) return cur; |
| if (cur === null || typeof cur !== "object") return undefined; |
| + const seg = segs[i]; |
| if (seg.endsWith("[]")) { |
| - cur = cur[seg.slice(0, -2)]; |
| - if (!Array.isArray(cur) || cur.length === 0) return undefined; |
| - cur = cur[0]; |
| - } else { |
| - cur = cur[seg]; |
| + const arr = cur[seg.slice(0, -2)]; |
| + if (!Array.isArray(arr) || arr.length === 0) return undefined; |
| + for (const el of arr) { |
| + const v = walk(el, i + 1); |
| + if (v !== undefined) return v; |
| + } |
| + return undefined; |
| } |
| - if (cur === undefined) return undefined; |
| + return walk(cur[seg], i + 1); |
| } |
| - return cur; |
| + return walk(obj, 0); |
| } |
| const indexHtml = read("web/status/index.html"); |
| @@ -102,14 +110,51 @@ await test("degraded books shape carries available:false + reason", () => { |
| // Privacy re-check on the committed fixtures (the dry-run asserts this too β |
| // belt and suspenders on what's in git): no fixture hostname, subscription, |
| -// customer, or transaction id may appear in published output. |
| +// 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, carbon]) { |
| + for (const j of [fleet, fleetGF, fleetAC, books, booksDown, booksNoExp, carbon]) { |
| const s = JSON.stringify(j); |
| - assert.ok(!/sub_|cus_|txn_|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80)); |
| + assert.ok(!/sub_|cus_|txn_|wrkspc_|req_fixture|ce_fixture|\.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. |
| +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"); |
| + 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"], |
| + }; |
| + // 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"); |
| + 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; |
| + assert.ok(Number.isInteger(cents) && cents >= 0, p.provider + " amount is integer cents β₯ 0"); |
| } |
| }); |
| +// No providers resolve β the whole expenses object is omitted (degrade, never |
| +// zero, never guess). The degraded income shape needn't carry it either. |
| +await test("expenses omit entirely when no provider resolves", () => { |
| + assert.ok(!("expenses" in booksNoExp), "no-expenses fixture must omit the key"); |
| + assert.equal(booksNoExp.available, true, "omission is independent of income availability"); |
| +}); |
| + |
| // The carbon tally must stay internally consistent as entries accrue β the |
| // totals are the sum of the ledger, each entry's kg derives from its output |
| // tokens Γ the published factors, and each SOURCE's periods are chronological |
diff --git a/test/status-fixtures/books-no-expenses.json b/test/status-fixtures/books-no-expenses.json| new file mode 100644 |
| index 0000000..06d86c6 |
| --- /dev/null |
| +++ b/test/status-fixtures/books-no-expenses.json |
| @@ -0,0 +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} |
diff --git a/test/status-fixtures/books.json b/test/status-fixtures/books.json| index 241338b..2de01a3 100644 |
| --- a/test/status-fixtures/books.json |
| +++ b/test/status-fixtures/books.json |
| @@ -1 +1 @@ |
| -{"generated_at":"2026-07-11T18:41:14Z","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-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"}]}} |
diff --git a/web/status/books.html b/web/status/books.html| index 819e5af..b8c9734 100644 |
| --- a/web/status/books.html |
| +++ b/web/status/books.html |
| @@ -1,7 +1,9 @@ |
| <!-- π€ Public "books" page (issue #550 Parts 11/12, decision #19: income |
| - publishes AGGREGATED, never per-payer). Fully self-contained: inline |
| - CSS/JS, no external requests except ./books.json (relative). Published by |
| - deploy/status-publish.sh from Stripe aggregates β totals only. --> |
| + publishes AGGREGATED, never per-payer β and expenses publish ITEMIZED, |
| + the deliberate mirror image). Fully self-contained: inline CSS/JS, no |
| + external requests except ./books.json (relative). Published by |
| + deploy/status-publish.sh from Stripe aggregates (totals only) plus |
| + per-provider infrastructure spend. --> |
| <!doctype html> |
| <html lang="en"> |
| <head> |
| @@ -56,6 +58,9 @@ |
| 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; } |
| + .note { color: var(--ink-2); font-size: 14px; } |
| /* Per-tier subscriptions: single-series horizontal bar list; rows are real |
| text (their own table view), bars aria-hidden. Thin mark, 4px rounded |
| @@ -93,10 +98,10 @@ |
| <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">The books are unavailable right now: <span id="reason"></span>.</p> |
| + <p id="unavailable" role="status">Income is unavailable right now: <span id="reason"></span>.</p> |
| <div id="books"> |
| - <section aria-labelledby="h-income"> |
| + <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> |
| @@ -105,7 +110,14 @@ |
| </div> |
| </section> |
| - <section aria-labelledby="h-tiers"> |
| + <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> |
| @@ -113,10 +125,10 @@ |
| <footer> |
| <p><a href="./index.html">Fleet status</a> Β· <a href="./carbon.html">The carbon tally</a></p> |
| - <p>Published every 5 minutes from Stripe aggregates. Tier names are the |
| - public price book; amounts are EUR. Expenses and disbursements will join |
| - these pages as the public ledger lands (see the roadmap in |
| - docs/planet-scale.md).</p> |
| + <p>Published every 5 minutes from Stripe aggregates and provider billing. |
| + Tier names are the public price book; income amounts are EUR, expenses in |
| + each providerβs own billing currency. Disbursements join when the public |
| + ledger lands (see the roadmap in docs/planet-scale.md).</p> |
| </footer> |
| </div> |
| @@ -129,6 +141,8 @@ const FIELDS = [ |
| "month", "currency", |
| "subscriptions.active", "subscriptions.by_tier[].tier", "subscriptions.by_tier[].count", |
| "mrr_cents", "collected_month_cents", |
| + "expenses.month", "expenses.providers[].provider", "expenses.providers[].currency", |
| + "expenses.providers[].month_to_date_cents", "expenses.providers[].monthly_cents", |
| ]; |
| const $ = (id) => document.getElementById(id); |
| @@ -148,14 +162,19 @@ 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). |
| if (!d.available) { |
| $("reason").textContent = d.reason || "not configured"; |
| $("unavailable").classList.add("show"); |
| - $("books").style.display = "none"; |
| - return; |
| + } else { |
| + $("unavailable").classList.remove("show"); |
| } |
| - $("unavailable").classList.remove("show"); |
| - $("books").style.display = ""; |
| + $("s-income").hidden = !d.available; |
| + $("s-tiers").hidden = !d.available; |
| + renderExpenses(d.expenses); |
| + if (!d.available) return; |
| $("t-active").textContent = String(d.subscriptions.active); |
| $("t-mrr").textContent = eur.format(d.mrr_cents / 100); |
| @@ -196,6 +215,41 @@ function render(d) { |
| } |
| } |
| +// One tile per provider, each formatted in the provider's own billing |
| +// currency β never converted (no FX rate = no misstatement). Known providers |
| +// get a friendly label; an unknown future provider still renders honestly |
| +// under its raw name. A provider without a usable integer amount is skipped |
| +// rather than shown as zero. |
| +const EXPENSE_LABELS = { |
| + "aws": "AWS Β· month to date", |
| + "hetzner": "Hetzner Β· monthly flat", |
| + "anthropic": "Anthropic API Β· month to date", |
| + "claude.ai": "claude.ai Β· monthly", |
| +}; |
| + |
| +function renderExpenses(exp) { |
| + const sec = $("s-infra"); |
| + const providers = (exp && exp.providers) || []; |
| + const tiles = $("infra-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; |
| + if (typeof cents !== "number") continue; |
| + const tile = document.createElement("div"); |
| + tile.className = "tile"; |
| + const label = document.createElement("div"); |
| + label.className = "label"; |
| + label.textContent = EXPENSE_LABELS[p.provider] || p.provider; |
| + const value = document.createElement("div"); |
| + value.className = "value"; |
| + const fmt = new Intl.NumberFormat("en-IE", { style: "currency", currency: (p.currency || "eur").toUpperCase() }); |
| + value.textContent = fmt.format(cents / 100); |
| + tile.append(label, value); |
| + tiles.append(tile); |
| + } |
| + sec.hidden = tiles.childElementCount === 0; |
| +} |
| + |
| async function refresh() { |
| try { |
| const res = await fetch("./books.json", { cache: "no-cache" }); |