server: books — hetzner expense computed from the Cloud API; claude.ai goes cumulative (#770)
The hetzner expense provider upgrades from operator-constant-only to
API-computed: with STATUS_HETZNER_TOKEN (a read-only Hetzner Cloud API
token) the generator sums the account's monthly server cost from
GET /v1/servers — pages followed via meta.pagination.next_page, each
server priced by the ONE server_type.prices[] entry matching its own
location.name (price_monthly.gross, VAT-included EUR, rounded once at
the total). Server compute only (primary IPs/backups/volumes price via
the separate /pricing endpoint, deliberately excluded). The operator
constant STATUS_HETZNER_MONTHLY_CENTS still overrides the API — operator
truth wins — and neither set omits the provider, as before. The result
rides the shared cost cache with a 24h default freshness (prices change
~never; STATUS_COSTS_MAX_AGE_HOURS still governs when set). Published
key shape is unchanged: {"provider":"hetzner","monthly_cents":n,
"currency":"eur"}.
Also per Paul: the claude.ai provider switches to a cumulative shape —
the honest figure is lifetime spend, not a monthly. New amount variant
to_date_cents via STATUS_CLAUDE_TO_DATE_CENTS (EUR, operator-attested
estimate); the page labels it "claude.ai · to date (approx.)" and the
schema allowlist now permits exactly one of month_to_date_cents |
monthly_cents | yearly_cents | to_date_cents.
Tests: dry-run gains a two-page hetzner servers fixture (pins
pagination, the per-location price match against decoy entries, and
cents rounding: 16.4288 + 4.5100 → 2094) plus a hetzner-constant
variant (constant wins AND the API is never called — no cache entry);
stale-cache now seeds/asserts hetzner too; omission unchanged. Contract
test pins the API math and the override via the regenerated books.json
and the new books-hetzner-constant.json fixture, and hzfix joins the
privacy leak greps.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+274
−86
deploy/status-publish.sh+179 −48docs/status.md+51 −17test/status-contract.test.mjs+35 −14test/status-fixtures/books-hetzner-constant.json+1 −0test/status-fixtures/books-no-expenses.json+1 −1test/status-fixtures/books.json+1 −1web/status/books.html+6 −5
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh| index 89e04ee..55ccfb3 100755 |
| --- a/deploy/status-publish.sh |
| +++ b/deploy/status-publish.sh |
| @@ -81,8 +81,12 @@ |
| # (infra/prod/status.tf; Put/Get/List on the status |
| # 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_APPLE_YEARLY_CENTS, |
| +# expense vars ride here too: STATUS_HETZNER_TOKEN (a |
| +# read-only Hetzner Cloud API token — computes the |
| +# monthly server cost; never echoed) and/or |
| +# STATUS_HETZNER_MONTHLY_CENTS (operator constant — |
| +# overrides the token when both are set), |
| +# STATUS_CLAUDE_TO_DATE_CENTS, STATUS_APPLE_YEARLY_CENTS, |
| # STATUS_ANTHROPIC_ADMIN_KEY (an sk-ant-admin… key with |
| # read-only usage scope — NOT the sidecars' completion |
| # key), and STATUS_DNSIMPLE_TOKEN + STATUS_DNSIMPLE_ACCOUNT |
| @@ -520,6 +524,67 @@ fetch_aws_cents() { |
| sqj "SELECT CAST(ROUND($amount * 100) AS INTEGER)" |
| } |
| +# hetzner_get_page <page> <outfile> — one authenticated GET of the Hetzner |
| +# Cloud servers list (Bearer auth; the token rides only in the header — -sS |
| +# keeps curl from ever printing it). Dry-run reads numbered fixture pages |
| +# instead, so the caller's pagination loop is exercised by the twin. |
| +hetzner_get_page() { |
| + if [ -n "$DRY_RUN" ]; then |
| + [ -f "$FIXDIR/hetzner-servers-$1.json" ] || return 1 |
| + cp "$FIXDIR/hetzner-servers-$1.json" "$2" |
| + return |
| + fi |
| + [ -n "${STATUS_HETZNER_TOKEN:-}" ] || return 1 |
| + curl -sS --fail --max-time 30 \ |
| + -H "Authorization: Bearer $STATUS_HETZNER_TOKEN" \ |
| + "https://api.hetzner.cloud/v1/servers?page=$1&per_page=50" > "$2" |
| +} |
| + |
| +# fetch_hetzner_cents <month> — the account's monthly server cost, computed |
| +# from the Cloud API: GET /v1/servers, pages followed via |
| +# meta.pagination.next_page (hard cap 10 pages / 500 servers — a partial sum |
| +# must never publish). Each server embeds its type's per-location price list |
| +# (server_type.prices[]); the entry whose location matches the server's own |
| +# location.name prices it — price_monthly.gross, a decimal string, VAT |
| +# included. EXACTLY one entry may match per server or the fetch counts as |
| +# failed: no match means the server can't be honestly priced, and a duplicate |
| +# would double-count. The prices carry no currency field, so the published |
| +# `eur` is the documented constant (Hetzner bills in EUR), not parsed. The |
| +# figure is SERVER COMPUTE ONLY, deliberately: primary IPs, backups, volumes, |
| +# and traffic overages price via the separate /pricing endpoint, not |
| +# per-server — revisit if that delta ever matters. The <month> arg is unused |
| +# (a flat price list, not month-to-date); it exists for cached_cost_cents' |
| +# shared interface. STATUS_HETZNER_TOKEN is a READ-ONLY Cloud API token and |
| +# must never be echoed or logged; the operator constant |
| +# STATUS_HETZNER_MONTHLY_CENTS overrides this fetch entirely (expenses_object). |
| +fetch_hetzner_cents() { |
| + local page=1 n=0 f next amt load="CREATE TABLE srv(j TEXT);" |
| + local match="json_extract(p.value,'\$.location') = json_extract(s.j,'\$.location.name')" |
| + while :; do |
| + n=$((n + 1)) |
| + f="$TMP/hetzner-servers-$n.json" |
| + hetzner_get_page "$page" "$f" || return 1 |
| + [ "$(sqj "SELECT json_valid(readfile('$f')) AND json_type(readfile('$f'), '\$.servers')='array' |
| + AND json_type(readfile('$f'), '\$.meta.pagination')='object'")" = "1" ] || return 1 |
| + load="$load INSERT INTO srv SELECT value FROM json_each(readfile('$f'), '\$.servers');" |
| + next=$(sqj "SELECT COALESCE(json_extract(readfile('$f'), '\$.meta.pagination.next_page'), '')") |
| + [ -n "$next" ] || break |
| + case "$next" in *[!0-9]*) return 1 ;; esac |
| + [ "$n" -lt 10 ] || return 1 |
| + page=$next |
| + done |
| + # Exactly one matching price row per server (see above), and every matched |
| + # amount a plain decimal — or the figure is omitted, never misstated. |
| + [ "$(sqj "$load SELECT COUNT(*) FROM srv s |
| + WHERE (SELECT COUNT(*) FROM json_each(s.j, '\$.server_type.prices') p WHERE $match) <> 1")" = "0" ] || return 1 |
| + while IFS= read -r amt; do |
| + case "$amt" in ''|.|*[!0-9.]*|*.*.*) return 1 ;; esac |
| + done < <(sqj "$load SELECT json_extract(p.value, '\$.price_monthly.gross') |
| + FROM srv s, json_each(s.j, '\$.server_type.prices') p WHERE $match") |
| + sqj "$load SELECT CAST(ROUND(COALESCE(SUM(json_extract(p.value, '\$.price_monthly.gross')), 0) * 100) AS INTEGER) |
| + FROM srv s, json_each(s.j, '\$.server_type.prices') p WHERE $match" |
| +} |
| + |
| # 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 |
| @@ -590,21 +655,25 @@ fetch_dnsimple_cents() { |
| WHERE json_extract(c.value, '\$.state')='collected'" |
| } |
| -# cached_cost_cents <provider> <month> — the shared fetch-through-cache for |
| -# the metered providers (aws, anthropic, dnsimple — fetch_<provider>_cents above). |
| -# These APIs charge or rate-limit per request (Cost Explorer bills ~$0.01 per |
| -# query) and this script runs every 5 minutes — an uncached publisher would |
| -# spend real money asking how much it spends. So each provider's result is |
| -# cached in $COSTS_CACHE and refetched only when its entry is older than |
| -# 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 <provider> <month> [default-age-hours] — the shared |
| +# fetch-through-cache for the API-computed providers (aws, hetzner, anthropic, |
| +# dnsimple — fetch_<provider>_cents above). These APIs charge or rate-limit |
| +# per request (Cost Explorer bills ~$0.01 per query) and this script runs |
| +# every 5 minutes — an uncached publisher would spend real money asking how |
| +# much it spends. So each provider's result is cached in $COSTS_CACHE and |
| +# refetched only when its entry is older than STATUS_COSTS_MAX_AGE_HOURS |
| +# (default: the caller's default-age-hours arg, else 6 — hetzner passes 24, |
| +# its flat price list changes ~never) or from a previous month (last month's |
| +# total is not this month's spend — a cross-month entry never publishes; for |
| +# hetzner that just means one refetch at the month turn). A failed refetch |
| +# falls back to the valid-but-stale same-month entry (stale beats absent); |
| +# no usable entry + failure → echoes nothing and the provider is omitted. |
| +# The cache field name month_to_date_cents is generic storage — for hetzner |
| +# it holds the flat monthly figure. |
| 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} |
| + max_age_h=${STATUS_COSTS_MAX_AGE_HOURS:-${3:-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 |
| @@ -641,14 +710,22 @@ cached_cost_cents() { |
| # 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. |
| +# hetzner — the flat-rate Hetzner Cloud servers. No billing API exists, |
| +# but the servers API prices each server (its type's embedded |
| +# per-location price list), so STATUS_HETZNER_TOKEN computes |
| +# the account's monthly server cost (fetch_hetzner_cents — |
| +# server compute only; cached, 24h default freshness). The |
| +# operator-set constant STATUS_HETZNER_MONTHLY_CENTS overrides |
| +# the API when set — operator truth wins. EUR either way |
| +# (Hetzner bills in EUR). Neither set → omitted. |
| # 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). |
| +# claude.ai — the claude.ai subscriptions: no API for them, and the honest |
| +# figure Paul can attest is LIFETIME spend, not a monthly — so |
| +# the operator-set constant STATUS_CLAUDE_TO_DATE_CENTS (EUR) |
| +# publishes as to_date_cents, the cumulative shape. It's an |
| +# operator-attested estimate by nature, which is why the page |
| +# labels it "(approx.)". |
| # dnsimple — the product domains (elevenmessenger.com, 11ch.at, |
| # 11factor.org): billing-charges month-to-date (metered; |
| # cached), USD. |
| @@ -663,14 +740,18 @@ expenses_object() { |
| cents=$(cached_cost_cents aws "$month") |
| [ -n "$cents" ] && providers=$(sqj "SELECT json_object('provider','aws','month_to_date_cents',$cents,'currency','usd')") |
| + # Operator constant first — when set, the API is never called (or cached). |
| cents=$(env_cents "${STATUS_HETZNER_MONTHLY_CENTS:-}") |
| + [ -n "$cents" ] || cents=$(cached_cost_cents hetzner "$month" 24) |
| [ -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')")" |
| + # Cumulative, not monthly: the operator attests lifetime spend to date (an |
| + # approximation by nature — the page carries the "(approx.)" marker). |
| + cents=$(env_cents "${STATUS_CLAUDE_TO_DATE_CENTS:-}") |
| + [ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','claude.ai','to_date_cents',$cents,'currency','eur')")" |
| cents=$(cached_cost_cents dnsimple "$month") |
| [ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','dnsimple','month_to_date_cents',$cents,'currency','usd')")" |
| @@ -953,40 +1034,76 @@ 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 six |
| - # providers resolve). The cost cache is repointed into FIXDIR — the dry run |
| - # never touches $DATA_DIR. The metered fixtures carry fixture-only ids |
| - # (request id, workspace id, charge references/domains): only the parsed |
| - # integers may survive into books.json — the leak grep below is the proof. |
| + # Expense fixtures (STATUS_DRY_EXPENSES=omit|stale-cache|hetzner-constant; |
| + # default = all six providers resolve, hetzner from its API fixture). The |
| + # cost cache is repointed into FIXDIR — the dry run never touches $DATA_DIR. |
| + # The API fixtures carry fixture-only ids (request id, workspace id, charge |
| + # references/domains, server names): 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, |
| + # No API fixtures, no cache, no operator constants → every provider, |
| # and then the whole expenses object, is omitted. |
| STATUS_HETZNER_MONTHLY_CENTS="" |
| - STATUS_CLAUDE_MONTHLY_CENTS="" |
| + STATUS_CLAUDE_TO_DATE_CENTS="" |
| STATUS_APPLE_YEARLY_CENTS="" |
| ;; |
| stale-cache) |
| - # No metered fixtures (all three "fetches" fail) but valid same-month |
| - # cache entries well past the max age → the stale figures serve rather |
| - # than vanishing (stale beats absent for month-to-date). |
| + # No API fixtures (all four "fetches" fail) but valid same-month cache |
| + # entries well past the max age → the stale figures serve rather than |
| + # vanishing (stale beats absent for month-to-date). |
| STATUS_HETZNER_MONTHLY_CENTS="" |
| - STATUS_CLAUDE_MONTHLY_CENTS="" |
| + STATUS_CLAUDE_TO_DATE_CENTS="" |
| STATUS_APPLE_YEARLY_CENTS="" |
| sqj "SELECT json_object( |
| 'aws', json_object('fetched_at', strftime('%s','now') - 999999, 'month', strftime('%Y-%m','now'), 'month_to_date_cents', 999), |
| + 'hetzner', json_object('fetched_at', strftime('%s','now') - 999999, 'month', strftime('%Y-%m','now'), 'month_to_date_cents', 1399), |
| 'anthropic', json_object('fetched_at', strftime('%s','now') - 999999, 'month', strftime('%Y-%m','now'), 'month_to_date_cents', 888), |
| 'dnsimple', json_object('fetched_at', strftime('%s','now') - 999999, 'month', strftime('%Y-%m','now'), 'month_to_date_cents', 777))" \ |
| > "$COSTS_CACHE" |
| ;; |
| *) |
| - STATUS_HETZNER_MONTHLY_CENTS=1400 |
| - STATUS_CLAUDE_MONTHLY_CENTS=2000 |
| + # Default: hetzner's constant is cleared so its API fixture drives the |
| + # figure (and an ambient operator env can't leak in); |
| + # STATUS_DRY_EXPENSES=hetzner-constant sets it WITH the fixture still |
| + # present, asserting the constant wins over a resolvable API path. |
| + STATUS_HETZNER_MONTHLY_CENTS="" |
| + [ "${STATUS_DRY_EXPENSES:-}" = hetzner-constant ] && STATUS_HETZNER_MONTHLY_CENTS=1400 |
| + STATUS_CLAUDE_TO_DATE_CENTS=229000 |
| STATUS_APPLE_YEARLY_CENTS=9900 |
| # Cost Explorer shape; Amount 12.3399999 USD exercises the cents rounding (→ 1234). |
| cat > "$FIXDIR/aws-ce.json" <<'EOF' |
| {"ce_fixture_request_id":"req_fixture_1","ResultsByTime":[{"TimePeriod":{"Start":"2026-07-01","End":"2026-07-12"},"Total":{"UnblendedCost":{"Amount":"12.3399999","Unit":"USD"}},"Groups":[],"Estimated":true}]} |
| +EOF |
| + # Hetzner servers shape, TWO pages (exercises meta.pagination.next_page): |
| + # each server carries its type's per-location price list, and only the |
| + # entry matching the server's own location.name may price it — the |
| + # decoy entries for the other location differ on purpose. Gross monthly |
| + # EUR strings: 16.4288 (cpx31 @ hel1; NOT the fsn1 16.1840) + 4.5100 |
| + # (cx22 @ fsn1; NOT the hel1 4.6900) = 20.9388 → rounds to 2094 cents. |
| + # Server names are fixture markers that must not survive aggregation. |
| + cat > "$FIXDIR/hetzner-servers-1.json" <<'EOF' |
| +{"servers":[ |
| + {"id":101,"name":"hzfix-alpha","status":"running", |
| + "location":{"id":3,"name":"hel1","description":"Helsinki DC Park 1","country":"FI","city":"Helsinki","network_zone":"eu-central"}, |
| + "server_type":{"id":104,"name":"cpx31","cores":4,"memory":8,"disk":160,"deprecated":false, |
| + "prices":[ |
| + {"location":"fsn1","price_hourly":{"net":"0.0225","gross":"0.0268"},"price_monthly":{"net":"13.6000","gross":"16.1840"}}, |
| + {"location":"hel1","price_hourly":{"net":"0.0229","gross":"0.0273"},"price_monthly":{"net":"13.8057","gross":"16.4288"}} |
| + ]}} |
| +],"meta":{"pagination":{"page":1,"per_page":1,"previous_page":null,"next_page":2,"last_page":2,"total_entries":2}}} |
| +EOF |
| + cat > "$FIXDIR/hetzner-servers-2.json" <<'EOF' |
| +{"servers":[ |
| + {"id":102,"name":"hzfix-bravo","status":"running", |
| + "location":{"id":1,"name":"fsn1","description":"Falkenstein DC Park 1","country":"DE","city":"Falkenstein","network_zone":"eu-central"}, |
| + "server_type":{"id":22,"name":"cx22","cores":2,"memory":4,"disk":40,"deprecated":false, |
| + "prices":[ |
| + {"location":"fsn1","price_hourly":{"net":"0.0063","gross":"0.0075"},"price_monthly":{"net":"3.7900","gross":"4.5100"}}, |
| + {"location":"hel1","price_hourly":{"net":"0.0066","gross":"0.0079"},"price_monthly":{"net":"3.9400","gross":"4.6900"}} |
| + ]}} |
| +],"meta":{"pagination":{"page":2,"per_page":1,"previous_page":1,"next_page":null,"last_page":2,"total_entries":2}}} |
| 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 |
| @@ -1049,7 +1166,7 @@ EOF |
| # Self-checks: valid JSON, and no per-payer fixture value leaked through. |
| sqj "SELECT CASE WHEN json_valid(readfile('$outdir/fleet.json')) AND json_valid(readfile('$outdir/books.json')) THEN 'ok' ELSE 'INVALID' END" \ |
| | grep -qx ok || { echo "dry-run: output is not valid JSON" >&2; exit 1; } |
| - if grep -qE 'sub_fixture|cus_fixture|txn_fixture|req_fixture|ce_fixture|wrkspc_fixture|dnsfix|\.example' "$outdir/fleet.json" "$outdir/books.json"; then |
| + if grep -qE 'sub_fixture|cus_fixture|txn_fixture|req_fixture|ce_fixture|wrkspc_fixture|dnsfix|hzfix|\.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 |
| @@ -1068,10 +1185,12 @@ EOF |
| fi |
| # Expenses: itemized by design, but the shape is still pinned — and the |
| # cache seams must behave (cached_cost_cents). Default: all six providers |
| - # from fixtures/env, with the metered results freshly cached; omit: no |
| - # expenses key at all; stale-cache: all three metered fetches failed and the |
| - # seeded same-month cache served (aws 999 / anthropic 888 / dnsimple 777). |
| - local aws_c an_c dn_c |
| + # from fixtures/env (hetzner computed from its servers-API fixture), with |
| + # the API results freshly cached; hetzner-constant: same fixtures but the |
| + # operator constant wins; omit: no expenses key at all; stale-cache: all |
| + # four API fetches failed and the seeded same-month cache served |
| + # (aws 999 / hetzner 1399 / anthropic 888 / dnsimple 777). |
| + local aws_c hz_c an_c dn_c |
| aws_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[0].month_to_date_cents')") |
| case "${STATUS_DRY_EXPENSES:-}" in |
| omit) |
| @@ -1079,10 +1198,11 @@ EOF |
| { 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')") |
| - dn_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[2].month_to_date_cents')") |
| - [ "$aws_c" = "999" ] && [ "$an_c" = "888" ] && [ "$dn_c" = "777" ] || |
| - { echo "dry-run: stale same-month cache must serve on fetch failure (got aws='$aws_c' anthropic='$an_c' dnsimple='$dn_c')" >&2; exit 1; } |
| + hz_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[1].monthly_cents')") |
| + an_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[2].month_to_date_cents')") |
| + dn_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[3].month_to_date_cents')") |
| + [ "$aws_c" = "999" ] && [ "$hz_c" = "1399" ] && [ "$an_c" = "888" ] && [ "$dn_c" = "777" ] || |
| + { echo "dry-run: stale same-month cache must serve on fetch failure (got aws='$aws_c' hetzner='$hz_c' anthropic='$an_c' dnsimple='$dn_c')" >&2; exit 1; } |
| ;; |
| *) |
| an_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[2].month_to_date_cents')") |
| @@ -1090,10 +1210,21 @@ EOF |
| [ "$aws_c" = "1234" ] || { echo "dry-run: CE fixture must yield 1234 cents (got '$aws_c')" >&2; exit 1; } |
| [ "$an_c" = "250" ] || { echo "dry-run: anthropic fixture must yield 250 cents (got '$an_c')" >&2; exit 1; } |
| [ "$dn_c" = "2450" ] || { echo "dry-run: dnsimple fixture must yield 2450 cents — collected only (got '$dn_c')" >&2; exit 1; } |
| - [ "$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[1].monthly_cents')")" = "1400" ] || |
| - { echo "dry-run: hetzner constant must yield 1400 cents" >&2; exit 1; } |
| - [ "$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[3].monthly_cents')")" = "2000" ] || |
| - { echo "dry-run: claude.ai constant must yield 2000 cents" >&2; exit 1; } |
| + hz_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[1].monthly_cents')") |
| + if [ "${STATUS_DRY_EXPENSES:-}" = hetzner-constant ]; then |
| + [ "$hz_c" = "1400" ] || |
| + { echo "dry-run: hetzner constant must override the API fixture — 1400 cents (got '$hz_c')" >&2; exit 1; } |
| + # The constant must short-circuit the API entirely: nothing cached. |
| + [ "$(sqj "SELECT json_extract(readfile('$COSTS_CACHE'), '\$.hetzner') IS NULL")" = "1" ] || |
| + { echo "dry-run: hetzner constant must skip the API (a hetzner cache entry exists)" >&2; exit 1; } |
| + else |
| + [ "$hz_c" = "2094" ] || |
| + { echo "dry-run: hetzner servers fixture must yield 2094 cents — location-matched gross, rounded (got '$hz_c')" >&2; exit 1; } |
| + [ "$(sqj "SELECT json_extract(readfile('$COSTS_CACHE'), '\$.hetzner.month_to_date_cents')")" = "2094" ] || |
| + { echo "dry-run: hetzner API result must be cached" >&2; exit 1; } |
| + fi |
| + [ "$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[3].to_date_cents')")" = "229000" ] || |
| + { echo "dry-run: claude.ai constant must yield 229000 cents, cumulative (to_date_cents)" >&2; exit 1; } |
| [ "$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[5].yearly_cents')")" = "9900" ] || |
| { echo "dry-run: apple constant must yield 9900 cents, as billed (yearly)" >&2; exit 1; } |
| [ -f "$COSTS_CACHE" ] && |
diff --git a/docs/status.md b/docs/status.md| index 3fa2eb1..b1bd90b 100644 |
| --- a/docs/status.md |
| +++ b/docs/status.md |
| @@ -133,9 +133,9 @@ configured / Stripe is unreachable): |
| "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": "hetzner", "monthly_cents": 2094, "currency": "eur" }, |
| { "provider": "anthropic", "month_to_date_cents": 250, "currency": "usd" }, |
| - { "provider": "claude.ai", "monthly_cents": 2000, "currency": "usd" }, |
| + { "provider": "claude.ai", "to_date_cents": 229000, "currency": "eur" }, |
| { "provider": "dnsimple", "month_to_date_cents": 2450, "currency": "usd" }, |
| { "provider": "apple", "yearly_cents": 9900, "currency": "eur" } |
| ] } |
| @@ -148,11 +148,13 @@ 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). Three |
| +mirror image of income's aggregation (see the privacy rules above). Four |
| provider shapes exist, exactly one amount key each: metered providers carry |
| `month_to_date_cents`, flat monthly subscriptions `monthly_cents`, flat |
| -yearly ones `yearly_cents` — each as billed, in its own billing currency (a |
| -yearly fee is published yearly, never divided into a fake monthly). |
| +yearly ones `yearly_cents`, and a cumulative operator-attested figure |
| +`to_date_cents` — each as billed, in its own billing currency (a yearly fee |
| +is published yearly, never divided into a fake monthly; a lifetime figure is |
| +published to date, never smeared into months). |
| - **aws** — month-to-date UnblendedCost from Cost Explorer (a us-east-1 |
| global endpoint, regardless of where the bucket lives). CE bills ~$0.01 |
| @@ -162,16 +164,34 @@ yearly fee is published yearly, never divided into a fake monthly). |
| `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. |
| +- **hetzner** — the flat-rate Hetzner Cloud servers. Hetzner has no billing |
| + API, but the servers API prices each server: with `STATUS_HETZNER_TOKEN` |
| + (a **read-only** Cloud API token) the generator computes the account's |
| + monthly server cost from `GET /v1/servers` (pagination followed via |
| + `meta.pagination.next_page`) — each server's embedded |
| + `server_type.prices[]` entry for its own location, `price_monthly.gross` |
| + summed, rounded once at the total. The prices carry no currency field, so |
| + the published `eur` is the documented constant (Hetzner bills in EUR). |
| + The figure is **server compute only** — primary IPs, backups, volumes, |
| + and traffic overages price via the separate `/pricing` endpoint and are |
| + deliberately not included. Same cache treatment as AWS but with 24h |
| + default freshness (a flat price list changes ~never; |
| + `STATUS_COSTS_MAX_AGE_HOURS` still overrides). Exactly one price entry |
| + must match per server and every amount must parse cleanly, or the fetch |
| + fails — and a failed figure is omitted, never misstated. The operator-set |
| + constant `STATUS_HETZNER_MONTHLY_CENTS` (EUR) **overrides the API** when |
| + set — operator truth wins; neither set omits the provider. |
| - **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). |
| +- **claude.ai** — the claude.ai subscriptions: no API for them, and the |
| + honest figure the operator can attest is **lifetime spend**, not a |
| + monthly — so the operator-set constant `STATUS_CLAUDE_TO_DATE_CENTS` |
| + (EUR) publishes as `to_date_cents`, the cumulative shape. It's an |
| + estimate by nature, which is why the page labels it |
| + "claude.ai · to date (approx.)". |
| - **dnsimple** — the product domains (elevenmessenger.com, 11ch.at, |
| 11factor.org): month-to-date from the billing-charges API |
| (`GET https://api.dnsimple.com/v2/{account}/billing/charges` with |
| @@ -252,7 +272,13 @@ carries the sources and the "as of" date). |
| 2. **Creds on the box**: write `/data/status.env` with |
| `AWS_ACCESS_KEY_ID=…` / `AWS_SECRET_ACCESS_KEY=…` (the status-publisher |
| key also covers the Cost Explorer read), plus the optional expense vars: |
| - `STATUS_HETZNER_MONTHLY_CENTS=…`, `STATUS_CLAUDE_MONTHLY_CENTS=…`, |
| + `STATUS_HETZNER_TOKEN=…` (a Hetzner Cloud API token minted **read-only** |
| + in the project's Security → API tokens — it only lists servers here, and |
| + is never echoed; store it in 1Password like the other per-service |
| + tokens) and/or `STATUS_HETZNER_MONTHLY_CENTS=…` (operator constant — |
| + overrides the token's API-computed figure when both are set), |
| + `STATUS_CLAUDE_TO_DATE_CENTS=…` (cumulative lifetime claude.ai spend — |
| + an operator-attested estimate, labelled "(approx.)" on the page), |
| `STATUS_APPLE_YEARLY_CENTS=…`, `STATUS_ANTHROPIC_ADMIN_KEY=…` (an |
| `sk-ant-admin…` key with read-only usage scope — never the sidecars' |
| completion key), and `STATUS_DNSIMPLE_TOKEN=…` + `STATUS_DNSIMPLE_ACCOUNT=…` |
| @@ -287,15 +313,23 @@ origin unchanged. |
| 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 |
| + object is omitted); `STATUS_DRY_EXPENSES=stale-cache` fails all four API |
| fetches against a seeded, past-max-age same-month cache (asserts the stale |
| - figures serve) — so the cache seams are testable end-to-end. |
| + figures serve) — so the cache seams are testable end-to-end. The default |
| + run computes hetzner from a two-page servers fixture (asserts pagination, |
| + the per-location price match, and the cents rounding); |
| + `STATUS_DRY_EXPENSES=hetzner-constant` sets the operator constant with the |
| + fixture still resolvable (asserts the constant wins and the API is never |
| + called). |
| - `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 **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`). |
| + capacity band (`fleet-getting-full.json`, `fleet-at-capacity.json`), pins |
| + expense omission (`books-no-expenses.json`), and pins hetzner's |
| + API-computed cents + constant override (`books.json` / |
| + `books-hetzner-constant.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`, the hetzner-constant |
| + one via `STATUS_DRY_EXPENSES=hetzner-constant`). |
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs| index 2cdc46d..a277452 100644 |
| --- a/test/status-contract.test.mjs |
| +++ b/test/status-contract.test.mjs |
| @@ -2,11 +2,13 @@ |
| // 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, |
| -// 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. |
| +// books-no-expenses.json, books-hetzner-constant.json; regenerate with |
| +// `./deploy/status-publish.sh --dry-run <dir>` after changing the generator — |
| +// the no-expenses variant via STATUS_DRY_EXPENSES=omit, the hetzner-constant |
| +// one via STATUS_DRY_EXPENSES=hetzner-constant) 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"; |
| @@ -31,6 +33,7 @@ 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")); |
| +const booksHzConst = JSON.parse(read("test/status-fixtures/books-hetzner-constant.json")); |
| // Pull the FIELDS manifest out of a page's inline script. |
| function fields(html) { |
| @@ -143,9 +146,9 @@ await test("degraded books shape carries available:false + reason", () => { |
| // customer, transaction, or provider-account/workspace id may appear in |
| // published output. |
| await test("fixtures contain no per-payer ids or hostnames", () => { |
| - for (const j of [fleet, fleetGF, fleetAC, books, booksDown, booksNoExp, carbon, contributors]) { |
| + for (const j of [fleet, fleetGF, fleetAC, books, booksDown, booksNoExp, booksHzConst, carbon, contributors]) { |
| const s = JSON.stringify(j); |
| - assert.ok(!/sub_|cus_|txn_|wrkspc_|req_fixture|ce_fixture|dnsfix|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80)); |
| + assert.ok(!/sub_|cus_|txn_|wrkspc_|req_fixture|ce_fixture|dnsfix|hzfix|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80)); |
| } |
| }); |
| @@ -153,22 +156,24 @@ await test("fixtures contain no per-payer ids or hostnames", () => { |
| // aggregates, expenses itemize; the amounts are public on purpose). The |
| // schema is still an ALLOWLIST: provider word + EXACTLY ONE amount key |
| // (month_to_date_cents for metered providers, monthly_cents / yearly_cents |
| -// for flat subscriptions, each as billed — a yearly fee is never divided into |
| -// a fake monthly) + its own billing currency, nothing else — so an account |
| -// id, invoice id, or resource-level line can't ship by accident. Currencies |
| -// stay per-provider, never FX-converted, and only usd/eur exist today. |
| +// for flat subscriptions, to_date_cents for a cumulative operator-attested |
| +// lifetime figure — each as billed: a yearly fee is never divided into a |
| +// fake monthly, a lifetime figure never smeared across months) + its own |
| +// billing currency, nothing else — so an account id, invoice id, or |
| +// resource-level line can't ship by accident. Currencies stay per-provider, |
| +// never FX-converted, and only usd/eur exist today. |
| await test("expenses carry only the allowed itemized schema", () => { |
| const exp = books.expenses; |
| assert.ok(exp, "books fixture carries expenses"); |
| assert.deepEqual(Object.keys(exp).sort(), ["month", "providers"], "expenses keys"); |
| assert.match(exp.month, /^\d{4}-\d{2}$/, "expenses.month is YYYY-MM"); |
| - // Each provider's key set carries exactly one of the three amount keys — |
| + // Each provider's key set carries exactly one of the four amount keys — |
| // the deepEqual below enforces the exactly-one rule per provider. |
| const allowed = { |
| "aws": ["currency", "month_to_date_cents", "provider"], |
| "hetzner": ["currency", "monthly_cents", "provider"], |
| "anthropic": ["currency", "month_to_date_cents", "provider"], |
| - "claude.ai": ["currency", "monthly_cents", "provider"], |
| + "claude.ai": ["currency", "provider", "to_date_cents"], |
| "dnsimple": ["currency", "month_to_date_cents", "provider"], |
| "apple": ["currency", "provider", "yearly_cents"], |
| }; |
| @@ -179,7 +184,7 @@ await test("expenses carry only the allowed itemized schema", () => { |
| 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 ?? p.yearly_cents; |
| + const cents = p.month_to_date_cents ?? p.monthly_cents ?? p.yearly_cents ?? p.to_date_cents; |
| assert.ok(Number.isInteger(cents) && cents >= 0, p.provider + " amount is integer cents ≥ 0"); |
| } |
| }); |
| @@ -191,6 +196,22 @@ await test("expenses omit entirely when no provider resolves", () => { |
| assert.equal(booksNoExp.available, true, "omission is independent of income availability"); |
| }); |
| +// Hetzner's figure is API-computed when the operator constant is unset: the |
| +// default fixture pins the servers-API math (two dry-run fixture pages, each |
| +// server priced by the ONE server_type.prices[] entry matching its own |
| +// location — gross monthly EUR 16.4288 + 4.5100 = 20.9388, rounded once at |
| +// the total to 2094 cents). And when STATUS_HETZNER_MONTHLY_CENTS is set the |
| +// constant wins over a resolvable API path — operator truth beats computed. |
| +// Either way the key shape is identical (monthly_cents, eur). |
| +await test("hetzner: API-computed cents with rounding; constant overrides", () => { |
| + const hz = books.expenses.providers.find((p) => p.provider === "hetzner"); |
| + assert.deepEqual(hz, { provider: "hetzner", monthly_cents: 2094, currency: "eur" }, |
| + "API path: location-matched gross sum, rounded"); |
| + const hzc = booksHzConst.expenses.providers.find((p) => p.provider === "hetzner"); |
| + assert.deepEqual(hzc, { provider: "hetzner", monthly_cents: 1400, currency: "eur" }, |
| + "constant override: the operator figure publishes, same shape"); |
| +}); |
| + |
| // 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-hetzner-constant.json b/test/status-fixtures/books-hetzner-constant.json| new file mode 100644 |
| index 0000000..74247aa |
| --- /dev/null |
| +++ b/test/status-fixtures/books-hetzner-constant.json |
| @@ -0,0 +1 @@ |
| +{"generated_at":"2026-07-12T06:44:21Z","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","to_date_cents":229000,"currency":"eur"},{"provider":"dnsimple","month_to_date_cents":2450,"currency":"usd"},{"provider":"apple","yearly_cents":9900,"currency":"eur"}]}} |
diff --git a/test/status-fixtures/books-no-expenses.json b/test/status-fixtures/books-no-expenses.json| index 5591d17..fe4e933 100644 |
| --- a/test/status-fixtures/books-no-expenses.json |
| +++ b/test/status-fixtures/books-no-expenses.json |
| @@ -1 +1 @@ |
| -{"generated_at":"2026-07-12T06:20:50Z","available":true,"currency":"eur","month":"2026-07","subscriptions":{"active":3,"by_tier":[{"tier":"friends","count":2},{"tier":"family","count":1}]},"mrr_cents":1600,"collected_month_cents":1200} |
| +{"generated_at":"2026-07-12T06:44:21Z","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 935d582..4488346 100644 |
| --- a/test/status-fixtures/books.json |
| +++ b/test/status-fixtures/books.json |
| @@ -1 +1 @@ |
| -{"generated_at":"2026-07-12T06:20:49Z","available":true,"currency":"eur","month":"2026-07","subscriptions":{"active":3,"by_tier":[{"tier":"friends","count":2},{"tier":"family","count":1}]},"mrr_cents":1600,"collected_month_cents":1200,"expenses":{"month":"2026-07","providers":[{"provider":"aws","month_to_date_cents":1234,"currency":"usd"},{"provider":"hetzner","monthly_cents":1400,"currency":"eur"},{"provider":"anthropic","month_to_date_cents":250,"currency":"usd"},{"provider":"claude.ai","monthly_cents":2000,"currency":"usd"},{"provider":"dnsimple","month_to_date_cents":2450,"currency":"usd"},{"provider":"apple","yearly_cents":9900,"currency":"eur"}]}} |
| +{"generated_at":"2026-07-12T06:44:20Z","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":2094,"currency":"eur"},{"provider":"anthropic","month_to_date_cents":250,"currency":"usd"},{"provider":"claude.ai","to_date_cents":229000,"currency":"eur"},{"provider":"dnsimple","month_to_date_cents":2450,"currency":"usd"},{"provider":"apple","yearly_cents":9900,"currency":"eur"}]}} |
diff --git a/web/status/books.html b/web/status/books.html| index abbef14..41f47ab 100644 |
| --- a/web/status/books.html |
| +++ b/web/status/books.html |
| @@ -148,7 +148,7 @@ const FIELDS = [ |
| "mrr_cents", "collected_month_cents", |
| "expenses.month", "expenses.providers[].provider", "expenses.providers[].currency", |
| "expenses.providers[].month_to_date_cents", "expenses.providers[].monthly_cents", |
| - "expenses.providers[].yearly_cents", |
| + "expenses.providers[].yearly_cents", "expenses.providers[].to_date_cents", |
| ]; |
| const $ = (id) => document.getElementById(id); |
| @@ -231,7 +231,7 @@ const EXPENSE_LABELS = { |
| "aws": "AWS · month to date", |
| "hetzner": "Hetzner · monthly flat", |
| "anthropic": "Anthropic API · month to date", |
| - "claude.ai": "claude.ai · monthly", |
| + "claude.ai": "claude.ai · to date (approx.)", |
| "dnsimple": "DNSimple · month to date", |
| "apple": "Apple Developer · yearly", |
| }; |
| @@ -243,9 +243,10 @@ function renderExpenses(exp) { |
| tiles.replaceChildren(); |
| for (const p of providers) { |
| // Exactly one amount key per provider: metered month-to-date, flat |
| - // monthly, or flat yearly — each shown as billed (a yearly fee is never |
| - // divided into a fake monthly). |
| - const cents = [p.month_to_date_cents, p.monthly_cents, p.yearly_cents].find((v) => typeof v === "number"); |
| + // monthly, flat yearly, or cumulative to-date — each shown as billed |
| + // (a yearly fee is never divided into a fake monthly; a lifetime figure |
| + // is labelled to date, not smeared across months). |
| + const cents = [p.month_to_date_cents, p.monthly_cents, p.yearly_cents, p.to_date_cents].find((v) => typeof v === "number"); |
| if (typeof cents !== "number") continue; |
| const tile = document.createElement("div"); |
| tile.className = "tile"; |