Admin analytics (#33 phase 2): overview, accounts, timeline + Paddle checkout funnel #36

Merged
Cordy merged 4 commits from feat/admin-analytics-33 into main 2026-08-25 18:08:14 +00:00
Owner

Phase 2 of the staff console: the real analytics endpoints, all staff-gated and all derived from state the server already holds plus a small funnel-events table.

Endpoints

  • GET /v1/admin/overview — MRR / ARR / seats licensed / median seats (unlimited-key sentinel excluded), expiring-in-90-days (count/seats/value), churn (scheduled cancellations / rate), plus a guarded signup→checkout→paid funnel snapshot. freeOver40 and failedPayments return 0 (not observable without, respectively, free-tier check-ins and a Paddle payment-failed feed).
  • GET /v1/admin/accounts — roster with derived state (paid / cancelled / abandoned / free), seats, MRR, license count, created, last-seen.
  • GET /v1/admin/accounts/{id} — one account.
  • GET /v1/admin/accounts/{id}/timeline — account creation + license issuance + checkout events for that email.

Funnel events

recordPaddleEvent is called once from the webhook (spliced in): transaction.createdcheckout_open, transaction.completedcheckout_paid, human-checkout origins only (subscription renewals excluded). It captures the transaction id, total seat cap, and buyer email; distinct transaction ids give opened / paid / abandoned and a recoverable-value estimate.

Privacy (ADR-0002)

The events table stores an email + seat count only, is pruned to 180 days, and never leaves the Swiss VPS. admin.go still owns all its tables directly; no change to the store save()/load() path or the account struct.

Deliberately still on fixtures

The dedicated Funnel and Geography tabs keep admin.html's client-side fixtures: vFunnel divides by raw counts (shows NaN% on sparse real data — needs a small frontend guard first), and geo needs an offline GeoIP source (a genuine dependency decision). overview's own funnel block is division-safe, so the main dashboard is fully real. Those two remain as a documented follow-up.

Tests

analytics_test.go: overview rollups (seats 200, MRR $300, ARR $3600, median 100, expiring-90 {1,80}); funnel event capture (2 opened / 1 paid / 1 abandoned, renewal ignored); accounts view (paid state, seats, org) + timeline (issued + created) + 404 on unknown id.

Phase 2 of the staff console: the real analytics endpoints, all staff-gated and all derived from state the server already holds plus a small funnel-events table. ## Endpoints - `GET /v1/admin/overview` — MRR / ARR / seats licensed / median seats (unlimited-key sentinel excluded), expiring-in-90-days (count/seats/value), churn (scheduled cancellations / rate), plus a **guarded** signup→checkout→paid funnel snapshot. `freeOver40` and `failedPayments` return 0 (not observable without, respectively, free-tier check-ins and a Paddle payment-failed feed). - `GET /v1/admin/accounts` — roster with derived state (paid / cancelled / abandoned / free), seats, MRR, license count, created, last-seen. - `GET /v1/admin/accounts/{id}` — one account. - `GET /v1/admin/accounts/{id}/timeline` — account creation + license issuance + checkout events for that email. ## Funnel events `recordPaddleEvent` is called once from the webhook (spliced in): `transaction.created` → `checkout_open`, `transaction.completed` → `checkout_paid`, **human-checkout origins only** (subscription renewals excluded). It captures the transaction id, total seat cap, and buyer email; distinct transaction ids give opened / paid / abandoned and a recoverable-value estimate. ## Privacy (ADR-0002) The `events` table stores an email + seat count only, is pruned to 180 days, and never leaves the Swiss VPS. `admin.go` still owns all its tables directly; no change to the store `save()`/`load()` path or the `account` struct. ## Deliberately still on fixtures The dedicated **Funnel** and **Geography** tabs keep admin.html's client-side fixtures: `vFunnel` divides by raw counts (shows `NaN%` on sparse real data — needs a small frontend guard first), and geo needs an offline GeoIP source (a genuine dependency decision). overview's own funnel block is division-safe, so the main dashboard is fully real. Those two remain as a documented follow-up. ## Tests `analytics_test.go`: overview rollups (seats 200, MRR $300, ARR $3600, median 100, expiring-90 {1,80}); funnel event capture (2 opened / 1 paid / 1 abandoned, renewal ignored); accounts view (paid state, seats, org) + timeline (issued + created) + 404 on unknown id.
Reads derived from existing license/account state (MRR, ARR, seats, median,
expiring-in-90d, churn, account roster + per-account timeline) plus a checkout
funnel fed by an events table. recordPaddleEvent is called once from the webhook
(transaction.created -> checkout_open, transaction.completed -> checkout_paid,
human-checkout origins only), capturing txn id + seat total + email; distinct txn
ids give opened / paid / abandoned. Events are email+seatcount only, pruned to
180 days, and never leave the VPS (ADR-0002).

The dedicated Funnel and Geography tabs stay on admin.html fixtures for now:
vFunnel divides by raw counts (NaN on sparse real data, needs a frontend guard)
and geo needs an offline GeoIP source. overview's own funnel block is div-safe.
analytics: tests for overview rollups, checkout funnel events, accounts + timeline
All checks were successful
ci / test-and-build (pull_request) Successful in 17s
3a24634f6b
Cordy merged commit 1d7b284628 into main 2026-08-25 18:08:14 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/cairn-license-server#36
No description provided.