Skip to content

fix: downgrade cancelled subs past period_end + warn user before delete#11

Merged
mastermanas805 merged 1 commit intomasterfrom
fix/expire-cancelled-subs-and-warn
Apr 25, 2026
Merged

fix: downgrade cancelled subs past period_end + warn user before delete#11
mastermanas805 merged 1 commit intomasterfrom
fix/expire-cancelled-subs-and-warn

Conversation

@mastermanas805
Copy link
Copy Markdown
Member

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

  • New `subscriptionExpiredEmail` — fires once at downgrade, names the
    exact data-deletion date and points at /pricing.html for re-subscribe.
  • Existing `subscriptionCancelledEmail` copy updated to mention the grace
    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:

  1. UPDATE resources SET expires_at = NOW + grace where the user owns
    them and they're still permanent. Existing per-resource reaper drops
    them naturally once grace elapses — no second deletion path.
  2. Atomic UPDATE that claims the email slot, downgrades tier, returns
    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

Stage Email Banner
Cancellation webhook "Paid access until {periodEnd}, grace window after, we'll email again" "Paid access continues until {periodEnd}…"
Period ends + reaper runs "Your access has ended — data deletion in {periodEnd + 7d}" (downgraded; old banner hidden)
Grace elapses (no further email) (free tier UI; resources auto-reaped)
Re-subscribe before deletion existing receipt email active banner

Verification

  • `go build`, `go vet`, `gofmt`, `go test` all pass
  • Post-deploy: when a user's `current_period_end` passes, the next
    reaper tick (within 5 min of expiry) downgrades them and emails them.
    The 7 days is grace BEFORE deletion, not before downgrade.

Future polish

  • A T-1-day reminder email before final deletion would catch anyone
    who missed the first warning. Out of scope for this PR.
  • For users with very large datasets, consider increasing default grace
    to 14d once we have telemetry on actual time-to-export.

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.
@mastermanas805 mastermanas805 merged commit 6ebe8c8 into master Apr 25, 2026
1 check passed
@mastermanas805 mastermanas805 deleted the fix/expire-cancelled-subs-and-warn branch April 25, 2026 09:27
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