eleven / messenger Public

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>
Paul Campbell a802d019bb4e91f13b03c6bf12af6d579d5c6994 parent 7d46108
6 files changed, +112 −12
  • deploy/status-publish.sh +67 −9
  • docs/status.md +13 −0
  • test/status-fixtures/books-hetzner-constant.json +1 −1
  • test/status-fixtures/books-no-expenses.json +1 −1
  • test/status-fixtures/books.json +1 −1
  • web/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 || [];