eleven / messenger Public

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>
Paul Campbell 876ebdeaae6f374837e96750f7be89e9fae8d235 parent 35ea94a
7 files changed, +490 −66
  • deploy/status-publish.sh +271 −13
  • docs/status.md +66 −14
  • infra/prod/status.tf +19 −5
  • test/status-contract.test.mjs +64 −19
  • test/status-fixtures/books-no-expenses.json +1 −0
  • test/status-fixtures/books.json +1 −1
  • web/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" });