fix: downgrade cancelled subs past period_end + warn user before delete#11
Merged
mastermanas805 merged 1 commit intomasterfrom Apr 25, 2026
Merged
Conversation
Closes the gap surfaced when adding the dashboard banner that promised
"After that you're back on the free tier" — the backend never made that
true. Cancelled-then-elapsed users stayed plan_tier='paid' forever and
their permanent resources never got reaped.
Three pieces:
1. Schema: new column users.subscription_expired_email_sent_at acts as
the claim-lock for the warning email. Default 7-day grace via new
Limits.SubscriptionGracePeriod (configurable).
2. New email template subscriptionExpiredEmail. Sent once, atomically
with the tier downgrade, naming the exact data-deletion date.
Cancellation email copy updated to mention the grace window so the
warning isn't a surprise.
3. New reaper pass expireCancelledSubscriptions, runs alongside the
existing reapExpired and enforceStorageQuota every interval.
Order:
a. UPDATE resources SET expires_at = NOW + grace WHERE
migrated_to_user_id = $1 AND status = 'active' AND
expires_at IS NULL. Tags the user's permanent resources for
the existing per-resource reaper to drop after grace.
b. claimSubscriptionExpiredEmail: atomic UPDATE that downgrades
plan_tier→free, status→expired, claims the email slot, and
returns email + period_end to send the warning.
Idempotent — already-downgraded users are filtered out by the WHERE
clause on subscription_expired_email_sent_at IS NULL. Two-step write
is crash-safe in the right direction: if we crash between step a and
b, resources are tagged for deletion (correct end state) and the user
re-enters the loop next tick to finish the downgrade.
Default grace 168h (7 days) — gives a working week to re-subscribe
or pg_dump. Configurable via subscription_grace_period in config.
go build / go vet / go fmt / go test all pass.
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.
Why
When you cancel your auto-pay mandate, Razorpay fires
`subscription.cancelled` to us, our handler stores
`subscription_status='cancelled'` and `current_period_end`. Email +
dashboard banner both promise "After that you're back on the free
tier." But **nothing in the backend ever made that promise true."
Cancelled-then-elapsed users stayed at `plan_tier='paid'` forever and
their permanent resources never got reaped.
This PR closes the loop and adds the warning email you asked for.
What changes
Schema
```sql
ALTER TABLE users
ADD COLUMN IF NOT EXISTS subscription_expired_email_sent_at TIMESTAMPTZ;
```
Same claim-lock semantics as `receipt_email_sent_at` and
`cancel_email_sent_at` — exactly-once email per lifecycle.
Config
New `Limits.SubscriptionGracePeriod` field, default `168h` (7 days).
Configurable per-deploy.
Email
exact data-deletion date and points at /pricing.html for re-subscribe.
window so the warning email isn't a surprise.
Reaper
New pass `expireCancelledSubscriptions` runs alongside the existing
reaper passes every interval. Two-step write per user:
them and they're still permanent. Existing per-resource reaper drops
them naturally once grace elapses — no second deletion path.
email + period_end. Fires the warning.
Filters on `subscription_expired_email_sent_at IS NULL` make it
idempotent. Crash-safe: between steps a/b, resources are tagged for
deletion (correct end state) and the user re-enters the loop on the
next tick.
What the user sees
Verification
reaper tick (within 5 min of expiry) downgrades them and emails them.
The 7 days is grace BEFORE deletion, not before downgrade.
Future polish
who missed the first warning. Out of scope for this PR.
to 14d once we have telemetry on actual time-to-export.