status: books publishes Connect GMV + commission (#1310)
Spaces' in-space sales charge on the admin's connected Stripe account, so the books aggregator (platform subscriptions + charge/payment balance transactions) never saw them — today's first €9 sale was invisible on books.html. Add the third and only new Stripe read, the application-fee list with each fee's charge expanded: commission = the fee amounts (the platform's actual income), GMV = the expanded charges' gross amounts (published deliberately — if we know it, it's public). Rendered as its own block under Income, never folded into collected. Dry-run fixtures assert 1450/145 with a USD decoy proving the currency filter; fee/charge/account ids join the leak grep.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+112
−12
deploy/status-publish.sh+67 −9docs/status.md+13 −0test/status-fixtures/books-hetzner-constant.json+1 −1test/status-fixtures/books-no-expenses.json+1 −1test/status-fixtures/books.json+1 −1web/status/books.html+29 −0
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh| index e44dd0a..0238973 100755 |
| --- a/deploy/status-publish.sh |
| +++ b/deploy/status-publish.sh |
| @@ -35,9 +35,12 @@ |
| # |
| # books.json — AGGREGATED INCOME ONLY. Active-subscription count (by tier — |
| # tier names are the public price book), MRR total in EUR, gross collected |
| -# this month. NEVER customer ids, emails, subscription ids, per-payer |
| -# lines, or timestamps of individual payments. The Stripe key is read for |
| -# the API calls and must never be echoed, logged, or uploaded. |
| +# this month, and the Connect aggregates (spaces' gross sales volume this |
| +# month + our commission on it — Paul's call: if we know the GMV, it's |
| +# public; totals only, like everything else here). NEVER customer ids, |
| +# emails, subscription ids, connected-account ids, per-payer 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 |
| @@ -665,7 +668,10 @@ stripe_get() { |
| if [ -n "$DRY_RUN" ]; then |
| STRIPE_FIXTURE_N=$((STRIPE_FIXTURE_N + 1)) |
| local kind=subs |
| - case "$1" in /v1/balance_transactions*) kind=bal ;; esac |
| + case "$1" in |
| + /v1/balance_transactions*) kind=bal ;; |
| + /v1/application_fees*) kind=fees ;; |
| + esac |
| cp "$FIXDIR/stripe-$kind-$STRIPE_FIXTURE_N.json" "$2" |
| return |
| fi |
| @@ -1008,17 +1014,26 @@ build_books() { |
| month=$(sqj "SELECT strftime('%Y-%m','now')") |
| month_start=$(sqj "SELECT strftime('%s', datetime('now','start of month'))") |
| - # The two aggregation sources (decision #19 — these are the ONLY Stripe |
| - # reads, both fleet-wide): |
| + # The three aggregation sources (decision #19 — these are the ONLY Stripe |
| + # reads, all fleet-wide): |
| # GET /v1/subscriptions?status=active&limit=100 (+ cursor pages) |
| # → active count, per-tier counts (metadata.tier = public price-book |
| # key), MRR from each item's price amount normalized to monthly. |
| # GET /v1/balance_transactions?created[gte]=<month start>&limit=100 |
| # → gross collected this month (positive charge/payment amounts). |
| + # GET /v1/application_fees?created[gte]=<month start>&expand[]=data.charge |
| + # → spaces selling to their own members via Stripe Connect |
| + # (subscriptions.go's stripeConnect: direct charges on the space |
| + # admin's connected account, an application fee to the platform). |
| + # Those charges live on the CONNECTED accounts, so the two reads |
| + # above never see them; the fee list is the platform's one honest |
| + # window: each fee's amount is our commission, and its expanded |
| + # charge carries the gross sale amount → GMV. |
| # Degrade to "unavailable" on any fetch failure — a Stripe outage must not |
| # publish half-numbers. |
| if ! fetch_pages /v1/subscriptions "status=active" subs || |
| - ! fetch_pages /v1/balance_transactions "created[gte]=$month_start" bal; then |
| + ! fetch_pages /v1/balance_transactions "created[gte]=$month_start" bal || |
| + ! fetch_pages /v1/application_fees "created[gte]=$month_start&expand[]=data.charge" fees; then |
| books_unavailable "$out" "stripe unreachable" "$expenses_json" |
| return |
| fi |
| @@ -1028,6 +1043,7 @@ build_books() { |
| { |
| echo "CREATE TABLE subs(j TEXT);" |
| echo "CREATE TABLE bal(j TEXT);" |
| + echo "CREATE TABLE fees(j TEXT);" |
| local f |
| for f in "$TMP"/subs-*.json; do |
| echo "INSERT INTO subs SELECT value FROM json_each(readfile('$f'), '\$.data');" |
| @@ -1035,9 +1051,17 @@ build_books() { |
| for f in "$TMP"/bal-*.json; do |
| echo "INSERT INTO bal SELECT value FROM json_each(readfile('$f'), '\$.data');" |
| done |
| + for f in "$TMP"/fees-*.json; do |
| + echo "INSERT INTO fees SELECT value FROM json_each(readfile('$f'), '\$.data');" |
| + done |
| # MRR: unit_amount × quantity per item, normalized to a month. EUR items |
| # only (the price book is EUR; mixing currencies without FX would lie). |
| # Unknown intervals contribute 0 to MRR but still count as subscribers. |
| + # Connect: commission = the fee amounts, GMV = each fee's expanded charge |
| + # amount — same gross semantics as collected_month_cents (positive EUR |
| + # amounts; refunds don't net). GMV is the spaces' money, not ours: our |
| + # income from these sales is exactly the commission line, which is why it |
| + # ships as its own field and never folds into collected_month_cents. |
| cat <<SQL |
| SELECT json_object( |
| 'generated_at', '$now', |
| @@ -1067,7 +1091,16 @@ 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)$expenses_kv); |
| + AND json_extract(b.j,'\$.amount') > 0), 0) AS INTEGER), |
| + 'connect', json_object( |
| + 'gmv_month_cents', CAST(COALESCE(( |
| + SELECT SUM(json_extract(f.j,'\$.charge.amount')) FROM fees f |
| + WHERE json_extract(f.j,'\$.charge.currency')='eur' |
| + AND json_extract(f.j,'\$.charge.amount') > 0), 0) AS INTEGER), |
| + 'commission_month_cents', CAST(COALESCE(( |
| + SELECT SUM(json_extract(f.j,'\$.amount')) FROM fees f |
| + WHERE json_extract(f.j,'\$.currency')='eur' |
| + AND json_extract(f.j,'\$.amount') > 0), 0) AS INTEGER))$expenses_kv); |
| SQL |
| } | sqlite3 :memory: > "$out" |
| } |
| @@ -1573,6 +1606,22 @@ EOF |
| {"id":"txn_fixture_3","type":"refund","amount":-400,"currency":"eur","created":1751500800}, |
| {"id":"txn_fixture_4","type":"payout","amount":-1200,"currency":"eur","created":1751587200} |
| ]} |
| +EOF |
| + # Application fees with the charge expanded (Connect: spaces selling to |
| + # their members). Two EUR sales — a €9.00 PWYW sale with its 90c commission |
| + # (the real first-sale story) plus a €5.50 one — and a USD decoy that must |
| + # not count on either side of the currency filter. GMV 900+550=1450, |
| + # commission 90+55=145. The fee/charge/account ids are fixture markers that |
| + # must not survive aggregation. |
| + cat > "$FIXDIR/stripe-fees-1.json" <<'EOF' |
| +{"object":"list","has_more":false,"data":[ |
| + {"id":"fee_fixture_1","account":"acct_fixture_a","amount":90,"currency":"eur","refunded":false, |
| + "charge":{"id":"ch_fixture_1","object":"charge","amount":900,"currency":"eur","paid":true}}, |
| + {"id":"fee_fixture_2","account":"acct_fixture_a","amount":55,"currency":"eur","refunded":false, |
| + "charge":{"id":"ch_fixture_2","object":"charge","amount":550,"currency":"eur","paid":true}}, |
| + {"id":"fee_fixture_3","account":"acct_fixture_b","amount":120,"currency":"usd","refunded":false, |
| + "charge":{"id":"ch_fixture_3","object":"charge","amount":1200,"currency":"usd","paid":true}} |
| +]} |
| EOF |
| build_fleet "$outdir/fleet.json" |
| @@ -1581,10 +1630,19 @@ 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|hzfix|\.example' "$outdir/fleet.json" "$outdir/books.json"; then |
| + if grep -qE 'sub_fixture|cus_fixture|txn_fixture|fee_fixture|ch_fixture|acct_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 |
| + # Connect aggregates: GMV from the expanded charges, commission from the |
| + # fee amounts, both EUR-only (the USD decoy proves the filter). Skipped in |
| + # the degraded-books mode, where no income fields exist at all. |
| + if [ "${STATUS_DRY_BOOKS:-}" != unavailable ]; then |
| + [ "$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.connect.gmv_month_cents')")" = "1450" ] || |
| + { echo "dry-run: connect GMV must sum the expanded charges, EUR only (want 1450)" >&2; exit 1; } |
| + [ "$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.connect.commission_month_cents')")" = "145" ] || |
| + { echo "dry-run: connect commission must sum the fee amounts, EUR only (want 145)" >&2; exit 1; } |
| + fi |
| # Capacity must publish as an abstract band word — a known word, and not a |
| # single digit anywhere in the capacity subtree (no slots, GB, percentages). |
| local lvl cap |
diff --git a/docs/status.md b/docs/status.md| index 5694532..4ff3bf7 100644 |
| --- a/docs/status.md |
| +++ b/docs/status.md |
| @@ -289,6 +289,7 @@ configured / Stripe is unreachable): |
| "subscriptions": { "active": 3, "by_tier": [ { "tier": "friends", "count": 2 } ] }, |
| "mrr_cents": 1600, |
| "collected_month_cents": 1200, |
| + "connect": { "gmv_month_cents": 1450, "commission_month_cents": 145 }, |
| "expenses": { "month": "2026-07", "providers": [ |
| { "provider": "aws", "month_to_date_cents": 1234, "currency": "usd" }, |
| { "provider": "hetzner", "monthly_cents": 2094, "currency": "eur" }, |
| @@ -305,6 +306,18 @@ 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). |
| +`connect` is spaces selling to their own members through Stripe Connect |
| +(in-space subscriptions charge directly on the space admin's connected |
| +account, with an application fee to the platform — `subscriptions.go`). |
| +Those charges never appear in the two reads above, so the fee list is the |
| +window: `commission_month_cents` sums this month's application-fee amounts |
| +(the platform's actual income from these sales), and `gmv_month_cents` sums |
| +each fee's expanded charge amount — the gross volume the spaces sold. The |
| +GMV is published deliberately (Paul's call: if we know it, it's public) but |
| +it is the spaces' money, not Eleven's — which is why neither figure folds |
| +into `collected_month_cents`. Same gross semantics as the rest: positive |
| +EUR amounts, refunds don't net. |
| + |
| `expenses` is infrastructure spend, itemized per provider — the deliberate |
| mirror image of income's aggregation (see the privacy rules above). Four |
| provider shapes exist, exactly one amount key each: metered providers carry |
diff --git a/test/status-fixtures/books-hetzner-constant.json b/test/status-fixtures/books-hetzner-constant.json| index 74247aa..753d052 100644 |
| --- a/test/status-fixtures/books-hetzner-constant.json |
| +++ b/test/status-fixtures/books-hetzner-constant.json |
| @@ -1 +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"}]}} |
| +{"generated_at":"2026-07-23T18:35: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,"connect":{"gmv_month_cents":1450,"commission_month_cents":145},"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 fe4e933..d9cbcf1 100644 |
| --- a/test/status-fixtures/books-no-expenses.json |
| +++ b/test/status-fixtures/books-no-expenses.json |
| @@ -1 +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} |
| +{"generated_at":"2026-07-23T18:35: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,"connect":{"gmv_month_cents":1450,"commission_month_cents":145}} |
diff --git a/test/status-fixtures/books.json b/test/status-fixtures/books.json| index 4488346..1e0d913 100644 |
| --- a/test/status-fixtures/books.json |
| +++ b/test/status-fixtures/books.json |
| @@ -1 +1 @@ |
| -{"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"}]}} |
| +{"generated_at":"2026-07-23T18:35:41Z","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,"connect":{"gmv_month_cents":1450,"commission_month_cents":145},"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 6380ce9..6388031 100644 |
| --- a/web/status/books.html |
| +++ b/web/status/books.html |
| @@ -64,6 +64,24 @@ |
| </div> |
| <h3 id="h-tiers">Subscriptions by tier</h3> |
| <ul class="bars" id="tiers"><li><span class="empty">Loading…</span></li></ul> |
| + <!-- Spaces selling to their own members (Stripe Connect). The gross |
| + volume is deliberately public — if we know it, you know it — but |
| + it is the spaces' money, not Eleven's: our income from these |
| + sales is the commission line. Hidden entirely when the feed |
| + predates these fields; zeros render honestly once it carries |
| + them. --> |
| + <div id="s-connect" hidden> |
| + <h3 id="h-connect">Spaces selling to their members</h3> |
| + <p class="note explain">Spaces can sell their own subscriptions, |
| + paid straight to the space’s owner — that money is theirs, not |
| + ours. We publish the total sold because we can see it, and what we |
| + can see should be public. Eleven’s own earning from these sales is |
| + the commission.</p> |
| + <div class="tiles"> |
| + <div class="tile"><div class="label" id="l-gmv">Sold by spaces</div><div class="value" id="t-gmv">—</div></div> |
| + <div class="tile"><div class="label">Eleven’s commission</div><div class="value" id="t-commission">—</div></div> |
| + </div> |
| + </div> |
| </div> |
| </section> |
| @@ -93,6 +111,7 @@ const FIELDS = [ |
| "month", "currency", |
| "subscriptions.active", "subscriptions.by_tier[].tier", "subscriptions.by_tier[].count", |
| "mrr_cents", "collected_month_cents", |
| + "connect.gmv_month_cents", "connect.commission_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[].to_date_cents", |
| @@ -136,6 +155,16 @@ function render(d) { |
| $("l-collected").textContent = "Collected in " + d.month; |
| void d.currency; // fixed "eur" today; the formatter would key on it if that changes |
| + // Connect (spaces' own sales): absent on an older feed → whole block stays |
| + // hidden; present-but-zero renders as honest zeros. |
| + const c = d.connect; |
| + $("s-connect").hidden = !c; |
| + if (c) { |
| + $("t-gmv").textContent = eur.format(c.gmv_month_cents / 100); |
| + $("t-commission").textContent = eur.format(c.commission_month_cents / 100); |
| + $("l-gmv").textContent = "Sold by spaces in " + d.month; |
| + } |
| + |
| const list = $("tiers"); |
| list.replaceChildren(); |
| const tiers = d.subscriptions.by_tier || []; |