Skip to content

fix(billing): derive effective plan, record churn, add webhook idempotency - #98

Merged
joaoh82 merged 1 commit into
mainfrom
fix/billing-status-and-idempotency
Jul 26, 2026
Merged

fix(billing): derive effective plan, record churn, add webhook idempotency#98
joaoh82 merged 1 commit into
mainfrom
fix/billing-status-and-idempotency

Conversation

@joaoh82

@joaoh82 joaoh82 commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Deploy this first. platform-api's changes write canceled_at and must satisfy the new CHECK constraint, both added here.

Why

A paying customer canceled on 29 June. The admin dashboard still listed them as payg, and nothing anywhere told us they'd gone. Root cause turned out to be a spelling mismatch.

platform-api's webhook wrote status = 'cancelled' (en-GB) — the only place that spelling was ever written. Five queries filter with != 'canceled' (en-US, the spelling migration 0006 documents). Since 'cancelled' != 'canceled' is TRUE, the canceled row passed every filter.

All five != 'canceled' filters were silent no-ops. Canceled subscriptions were treated as live everywhere they were read.

What's here

Migration 0014

  • Normalises stored rows to canceled, plus a CHECK constraint so the two spellings can't diverge again
  • Adds subscriptions.canceled_at (nothing recorded when a subscription ended — the cancel webhook didn't even touch updated_at), backfilled from current_period_end
  • Adds stripe_events for webhook idempotency (consumed by the platform-api PR)
  • First indexes on subscriptions — it had none

Effective plan. Fixing the spelling alone would have made it worse: the row would then be excluded, plan_name would go NULL, and the list would render . Subscribing UPGRADES the free row in place, so a canceled row leaves no free row to fall back to. plan_name is now derived (free when canceled) with previous_plan_name + canceled_at preserved separately, so churned-but-previously-paid users stay identifiable for win-back.

Filters. plan, auth_method, status, previously_paid, and sort by created/last_active/email (fixed ORDER BY strings — no caller input interpolated). Both correlated subqueries collapse into one LATERAL; the token count moves to its own, dropping GROUP BY entirely.

Verification

Against Postgres 16 — all 12 migrations apply clean, then with seeded churned/active/free users:

user plan_name previous_plan canceled_at
churned free payg 2026-06-29
freebie free
paying payg

Every filter combination returns the expected set; previously_paid returns exactly the churned user. UPDATE ... SET status='cancelled' is now rejected by the constraint.

cargo check, fmt --check, clippy -D warnings all pass.

🤖 Generated with Claude Code

…tency

Migration 0014 plus the admin query rewrite behind it.

Status vocabulary
  platform-api's webhook wrote status='cancelled' (en-GB); every reader
  compares against 'canceled' (en-US, the spelling 0006 documents). Since
  'cancelled' != 'canceled' is TRUE, all five `status != 'canceled'`
  filters were silent no-ops and canceled subscriptions were treated as
  live. 0014 normalises stored rows and adds a CHECK constraint so the
  two spellings cannot diverge again. The writer is fixed separately in
  platform-api.

Effective plan
  Subscribing UPGRADES the existing free row in place, so a canceled row
  leaves no free row to fall back to -- the admin list rendered a churned
  user as "payg". plan_name is now derived: 'free' when the latest
  subscription is canceled, with the paid plan preserved separately as
  previous_plan_name alongside canceled_at, so churned-but-previously-paid
  users stay identifiable for win-back campaigns.

Filters
  list/count now share one predicate set and accept plan, auth_method,
  status and previously_paid, plus sort by created/last_active/email
  (fixed ORDER BY strings, no caller input interpolated). Both correlated
  subqueries are replaced by a single LATERAL, and the token count moves
  to its own LATERAL, dropping GROUP BY entirely.

Also adds the first indexes on subscriptions (user_id, status).

Verified against Postgres 16: all 12 migrations apply clean, and a
seeded churned/active/free trio returns the expected effective plans and
filter results.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@marvin-agent-rockflow marvin-agent-rockflow left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hermes Agent Review

Head: c1cd57e · Files: 3 · +240 / -55
Verdict: Approve

Solid root-cause fix for the en-GB/en-US status split, with the right supporting schema (CHECK, canceled_at, stripe_events, indexes) and a clean admin-query rewrite.

Critical

  • None.

Warnings

  • None blocking. Deploy order called out in the PR body is mandatory: this migration must land before platform-api starts writing canceled_at / the constrained status set (paired with joaoh82/rustunnel-web#53).

Suggestions

  • Dynamic SQL via format!: Safe today because AdminUserSort::order_by() returns only &'static str literals and filters are bound params — good. A one-line comment near list_admin_users noting "ORDER BY is never caller-controlled" would lock that invariant for future readers.
  • previously_paid / effective plan: billing_model <> 'free' correctly excludes never-paid free rows. If a free-plan row can ever sit in status = 'canceled' (legacy/data quirks), it will correctly stay out of the win-back list — worth a quick assert in the seeded verification set.
  • Multi-sub edge: LATERAL picks latest subscriptions by created_at DESC. Fine if subscribe upgrades in place; if a user can ever accumulate multiple live rows, document that "latest wins" is intentional.
  • No automated regression test for the spelling/CHECK constraint — the manual PG 16 matrix in the PR body is convincing; a tiny SQL test or migration smoke would guard against a future writer reintroducing 'cancelled'.

Looks good

  • Migration 0014: normalize cancelledcanceled, CHECK vocabulary, canceled_at backfill, stripe_events PK for idempotency, first indexes on subscriptions.
  • Effective plan derivation (canceledfree + previous_plan_name) matches the in-place upgrade model so churned users no longer render as paid.
  • Filter/sort API: bind parameters only; unknown sort falls back to default rather than 500.
  • Correlated subqueries → single LATERAL + token LATERAL; drops GROUP BY; list/count share the same predicate set.
  • AdminUser fields align with the admin-dashboard consumer PR.

Automated hourly review by marvin-agent-rockflow (Hermes). Will re-review only if new commits land.

@joaoh82
joaoh82 merged commit 8be0999 into main Jul 26, 2026
1 check passed
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.

2 participants