Skip to content

Record churn and snapshot daily subscriber metrics - #1158

Draft
lylo wants to merge 2 commits into
mainfrom
churn-metrics
Draft

Record churn and snapshot daily subscriber metrics#1158
lylo wants to merge 2 commits into
mainfrom
churn-metrics

Conversation

@lylo

@lylo lylo commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Cancelled and deleted accounts currently take their history with them. DestroyUserJob only discards; seven days later accounts:purge_cancellations hard-destroys the user, and dependent: :destroy takes the subscription row and the whole Paddle webhook log with it. Nothing is written anywhere first, so free-user churn is unrecoverable. Separately, the only business metrics in the app are five live COUNT(*) calls in the admin users view, so nothing can answer "how many paid subscribers did I have in March".

Two pieces that cover each other's blind spots.

A churns table that outlives the account. No foreign key, and deliberately no has_many on User, so nothing can cascade it away. Everything worth keeping is denormalised at write time: plan, price, paid and account tenure, signup referrer, blog subdomain, post count.

Two write sites. An after_update_commit on Subscription keyed on cancelled_at, rather than call sites, because cancellation flows through four paths today (in-app cancel, the subscription.canceled webhook, subscription.updated with a scheduled cancel, and Paddle's own portal). And one line in accounts:purge_cancellations, which is the last moment the data exists. Churn.record is idempotent, so the in-app cancel and the webhook that follows it produce one row.

A daily snapshot into the existing rollups table. RecordDailyMetricsJob writes seven counters at 5:20am: users, trialing, paid subscribers, supporters, comped, churning, and MRR in cents. Rollup rows are written directly rather than through the gem's relation API, which aggregates historic rows by a time column and is the wrong shape for a point-in-time count. Re-running a day updates in place.

/admin/churns, in the More menu: last 30 days, a month-by-month table, and a paginated list of departures. MRR lost is normalised the same way as the mrr_cents rollup, so one annual cancellation doesn't dwarf ten monthly ones.

Behaviour changes

Nothing customer-facing. No change to the cancel flow: CancellationMailer already asks people to reply and that stays the feedback channel.

One new cron entry, and one extra query per purged account in the nightly task.

Notes

Ship in a single deploy. The Subscription callback and the purge task both reference Churn, so the model must not land ahead of the migration.

Passive lapses (a failed card where Paddle never sends subscription.canceled) don't produce a churn row. The paid_subscribers and mrr_cents series show the drop instead, which is why both halves exist.

Plan downgrades aren't recorded per customer. Annual to monthly is blocked in change_plan, so supporter to annual is the only real one, and unlike a deletion that data isn't lost: the subscription row survives and paddle_events keeps the payload.

The subscriber and MRR series starts from the first cron run. It isn't backfilled, because past free-user counts can't be reconstructed and a fabricated series is worse than a short one. Historical paid cancellations can be backfilled from Paddle separately.

lylo added 2 commits August 7, 2026 09:14
Drop RecordDailyMetricsJob's date argument: the counts are all live, so
writing them under a past date would misrepresent that day. Rename the
two generic rollups to total_users and trialing_users, since the table is
a shared keyspace with the page view series.

Move the MRR-lost arithmetic onto Churn so the view stops carrying two
slightly different copies of it, rescue RecordNotUnique for when an
in-app cancellation and its Paddle webhook land together, and count
tenure in calendar months rather than 30-day units.

Relabel the admin page: "Churned" rather than the ambiguous "Left", and
the tenure unit moved into the column header.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant