Record churn and snapshot daily subscriber metrics - #1158
Draft
lylo wants to merge 2 commits into
Draft
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Cancelled and deleted accounts currently take their history with them.
DestroyUserJobonly discards; seven days lateraccounts:purge_cancellationshard-destroys the user, anddependent: :destroytakes 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 liveCOUNT(*)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
churnstable that outlives the account. No foreign key, and deliberately nohas_manyonUser, 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_commitonSubscriptionkeyed oncancelled_at, rather than call sites, because cancellation flows through four paths today (in-app cancel, thesubscription.canceledwebhook,subscription.updatedwith a scheduled cancel, and Paddle's own portal). And one line inaccounts:purge_cancellations, which is the last moment the data exists.Churn.recordis idempotent, so the in-app cancel and the webhook that follows it produce one row.A daily snapshot into the existing
rollupstable.RecordDailyMetricsJobwrites 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 themrr_centsrollup, so one annual cancellation doesn't dwarf ten monthly ones.Behaviour changes
Nothing customer-facing. No change to the cancel flow:
CancellationMaileralready 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
Subscriptioncallback and the purge task both referenceChurn, 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. Thepaid_subscribersandmrr_centsseries 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 andpaddle_eventskeeps 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.