From 8229300b294ad274418a0d4c224b960bf0c7d4f9 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Sat, 25 Apr 2026 14:56:20 +0530 Subject: [PATCH] fix: downgrade cancelled subs past period_end + warn user before delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/server/billing_emails.go | 64 +++++++++++++++++++++++ internal/server/config.go | 39 ++++++++++---- internal/server/email.go | 24 ++++++++- internal/server/main.go | 5 +- internal/server/reaper.go | 86 ++++++++++++++++++++++++++++++- internal/server/schema.sql | 4 ++ 6 files changed, 208 insertions(+), 14 deletions(-) diff --git a/internal/server/billing_emails.go b/internal/server/billing_emails.go index c6e3f67..0ca83cb 100644 --- a/internal/server/billing_emails.go +++ b/internal/server/billing_emails.go @@ -158,6 +158,70 @@ func sendCancelIfUnsent(ctx context.Context, db *sql.DB, em *emailer, userID uui slog.Info("billing email: cancel claimed + sent", "user_id", userID, "period", claim.Period) } +// expiredClaim carries the data needed to compose a subscriptionExpiredEmail. +type expiredClaim struct { + Email string + PeriodEnd time.Time + DeletionDate time.Time +} + +// claimSubscriptionExpiredEmail atomically reserves the right to send one +// "subscription expired" email + downgrades the user's tier in the same +// UPDATE so the user can't be charged for a stale paid resource limit +// in the gap between email-claim and tier-downgrade. Only fires when the +// caller has already verified the user is in the cancelled+past-period +// state — the WHERE clause is the second-line defence. +func claimSubscriptionExpiredEmail(ctx context.Context, db *sql.DB, userID uuid.UUID, deletionDate time.Time) (expiredClaim, bool) { + var ( + email string + periodEnd *time.Time + ) + err := db.QueryRowContext(ctx, ` + UPDATE users + SET subscription_expired_email_sent_at = NOW(), + plan_tier = 'free', + subscription_status = 'expired' + WHERE id = $1 + AND plan_tier = 'paid' + AND subscription_status = 'cancelled' + AND current_period_end IS NOT NULL + AND current_period_end < NOW() + AND subscription_expired_email_sent_at IS NULL + RETURNING email, current_period_end`, + userID, + ).Scan(&email, &periodEnd) + if err != nil { + if err != sql.ErrNoRows { + slog.Warn("claimSubscriptionExpiredEmail: query failed", "error", err, "user_id", userID) + } + return expiredClaim{}, false + } + end := time.Time{} + if periodEnd != nil { + end = *periodEnd + } + return expiredClaim{Email: email, PeriodEnd: end, DeletionDate: deletionDate}, true +} + +// sendExpiredIfUnsent atomically downgrades the user + claims the email +// slot. Caller is expected to also set expires_at on the user's still-paid +// resources (separate UPDATE so the row count tells the reaper how many +// resources got grace-tagged, useful for slogging). +func sendExpiredIfUnsent(ctx context.Context, db *sql.DB, em *emailer, userID uuid.UUID, deletionDate time.Time) bool { + claim, ok := claimSubscriptionExpiredEmail(ctx, db, userID, deletionDate) + if !ok || claim.Email == "" { + return false + } + if em != nil { + subject, html := subscriptionExpiredEmail(claim.PeriodEnd, claim.DeletionDate) + em.SendAsync(claim.Email, subject, html) + } + slog.Info("billing email: expired claimed + sent", + "user_id", userID, "period_end", claim.PeriodEnd.Format("2006-01-02"), + "deletion_date", claim.DeletionDate.Format("2006-01-02")) + return true +} + // planSwitchScheduledClaim carries the data needed to compose a // "switch scheduled" email. type planSwitchScheduledClaim struct { diff --git a/internal/server/config.go b/internal/server/config.go index ce1773c..8b7cc37 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -159,6 +159,11 @@ type LimitsConfig struct { WebhookMaxStored int64 `yaml:"webhook_max_stored"` IPv4CIDRPrefix int `yaml:"ipv4_cidr_prefix"` IPv6CIDRPrefix int `yaml:"ipv6_cidr_prefix"` + // SubscriptionGracePeriod is how long after current_period_end a + // cancelled paying user retains access to their existing resources + // before they're deleted by the reaper. Default 168h (7 days) — gives + // the user a working week to either re-subscribe or export their data. + SubscriptionGracePeriod string `yaml:"subscription_grace_period"` } type ReaperConfig struct { @@ -217,15 +222,16 @@ func DefaultConfig() *Config { URL: "redis://localhost:6379", }, Limits: LimitsConfig{ - RateRequestsPerSecond: 10, - RateBurst: 20, - MaxProvisionsPerDay: 5, - AnonTTL: "24h", - MaxRequestBodyBytes: 1 << 20, // 1 MB - WebhookMaxBodyBytes: 1 << 20, // 1 MB - WebhookMaxStored: 100, - IPv4CIDRPrefix: 24, - IPv6CIDRPrefix: 48, + RateRequestsPerSecond: 10, + RateBurst: 20, + MaxProvisionsPerDay: 5, + AnonTTL: "24h", + MaxRequestBodyBytes: 1 << 20, // 1 MB + WebhookMaxBodyBytes: 1 << 20, // 1 MB + WebhookMaxStored: 100, + IPv4CIDRPrefix: 24, + IPv6CIDRPrefix: 48, + SubscriptionGracePeriod: "168h", // 7 days }, Reaper: ReaperConfig{ Interval: "5m", @@ -427,6 +433,21 @@ func (c *Config) ParsedAnonTTL() time.Duration { return d } +// ParsedSubscriptionGracePeriod is how long a cancelled-and-period-elapsed +// paying user keeps their existing resources before the reaper drops them. +// Defaults to 7 days when unset or invalid. +func (c *Config) ParsedSubscriptionGracePeriod() time.Duration { + if c.Limits.SubscriptionGracePeriod == "" { + return 7 * 24 * time.Hour + } + d, err := time.ParseDuration(c.Limits.SubscriptionGracePeriod) + if err != nil { + slog.Warn("config: invalid subscription_grace_period, defaulting to 168h", "error", err) + return 7 * 24 * time.Hour + } + return d +} + func (c *Config) ParsedReaperInterval() time.Duration { d, err := time.ParseDuration(c.Reaper.Interval) if err != nil { diff --git a/internal/server/email.go b/internal/server/email.go index 27452f1..7e0788d 100644 --- a/internal/server/email.go +++ b/internal/server/email.go @@ -120,7 +120,7 @@ func subscriptionCancelledEmail(plan string, periodEnd time.Time) (subject, html subject = "Your instanode subscription has been cancelled" untilLine := "" if !periodEnd.IsZero() { - untilLine = fmt.Sprintf("

Paid access continues until %s. After that, your account reverts to the free tier.

", periodEnd.Format("2006-01-02")) + untilLine = fmt.Sprintf("

Paid access continues until %s. After that, you'll have a short grace window to re-subscribe before your existing databases and webhooks are deleted — we'll email you again with the exact deletion date.

", periodEnd.Format("2006-01-02")) } else { untilLine = "

Your account will revert to the free tier at the end of the current billing period.

" } @@ -128,12 +128,32 @@ func subscriptionCancelledEmail(plan string, periodEnd time.Time) (subject, html

We've cancelled your %s subscription — no further charges will be made.

%s -

Resources provisioned while you were on the paid plan stay reachable, but they'll start to expire on the free-tier TTL once your access downgrades. Re-subscribe any time from the pricing page to keep them permanent.

+

Resources provisioned while you were on the paid plan stay reachable through your paid period and the grace window after it. Re-subscribe any time from the pricing page to keep them permanent.

For any issues or queries, contact contact@instanode.dev.

`, plan, untilLine) return } +// subscriptionExpiredEmail fires once when the reaper downgrades a user +// whose paid period elapsed and who hasn't re-subscribed. It tells them +// the exact date their existing databases and webhooks will be deleted — +// the grace window — and how to keep them. +func subscriptionExpiredEmail(periodEnd, deletionDate time.Time) (subject, html string) { + subject = "Your instanode access has ended — data deletion in " + deletionDate.Format("2006-01-02") + html = fmt.Sprintf(` + +

Your paid subscription ended on %s. Your account is now on the free tier.

+

+ Action required: the databases and webhooks you provisioned on the paid plan will be deleted on %s. + Re-subscribe before then to restore permanent access — no data loss. +

+

To keep your data: re-subscribe at instanode.dev/pricing.html.

+

To export it: connect to each connection_url with pg_dump any time before the deletion date.

+

For any issues or queries, reply to this email.

+`, periodEnd.Format("January 2, 2006"), deletionDate.Format("January 2, 2006")) + return +} + // planSwitchScheduledEmail is sent immediately after POST /billing/change-plan // succeeds. fromPlan / toPlan are the human labels (from planLabelFor). // effectiveAt is the current_period_end at request time — when the switch will diff --git a/internal/server/main.go b/internal/server/main.go index c6ab8d1..6b706fa 100644 --- a/internal/server/main.go +++ b/internal/server/main.go @@ -122,8 +122,9 @@ func Run() { payment: payment, } - // Start the expired resource reaper. - startReaper(db, rdb, cfg, cfg.Database.CustomerURL) + // Start the expired resource reaper. Also runs the subscription- + // expiry pass that downgrades cancelled users past their grace period. + startReaper(db, rdb, s.email, cfg, cfg.Database.CustomerURL) // Start the inbound-email reconciler. Polls Brevo's /v3/inbound/events API // on an interval and backfills metadata for any message whose webhook diff --git a/internal/server/reaper.go b/internal/server/reaper.go index e8807af..c3dc463 100644 --- a/internal/server/reaper.go +++ b/internal/server/reaper.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/google/uuid" "github.com/redis/go-redis/v9" ) @@ -16,7 +17,7 @@ import ( // and marks resource rows as 'expired'. It also enforces storage quotas // on active Postgres resources (Postgres has no native per-DB disk quota, // so we scan pg_database_size() periodically and lock over-limit DBs). -func startReaper(db *sql.DB, rdb *redis.Client, cfg *Config, custDBURL string) { +func startReaper(db *sql.DB, rdb *redis.Client, em *emailer, cfg *Config, custDBURL string) { interval := cfg.ParsedReaperInterval() go func() { ticker := time.NewTicker(interval) @@ -26,12 +27,95 @@ func startReaper(db *sql.DB, rdb *redis.Client, cfg *Config, custDBURL string) { ctx, cancel := context.WithTimeout(context.Background(), timeout) reapExpired(ctx, db, rdb, cfg, custDBURL) enforceStorageQuota(ctx, db, cfg, custDBURL) + expireCancelledSubscriptions(ctx, db, em, cfg) cancel() } }() slog.Info("reaper started", "interval", interval.String()) } +// expireCancelledSubscriptions downgrades any user whose subscription was +// cancelled and whose paid period has elapsed. For each match: +// +// 1. Set expires_at on their still-permanent resources to NOW + grace, so +// the existing per-resource reaper pass naturally drops them after the +// grace window — no second deletion path to maintain. +// 2. Atomically downgrade plan_tier='paid' → 'free', set +// subscription_status='expired', set the email-sent claim flag, and +// send the warning email (one-time, claim-locked). +// +// Idempotent: re-running over an already-downgraded user is a no-op +// (subscription_expired_email_sent_at IS NOT NULL filters them out). +// +// Order matters: the resources UPDATE runs FIRST so a crash between the +// two writes leaves the resources tagged for deletion (correct end state) +// rather than the user downgraded but resources still permanent (which +// would then never be picked up because the WHERE clause requires +// plan_tier='paid'). +func expireCancelledSubscriptions(ctx context.Context, db *sql.DB, em *emailer, cfg *Config) { + grace := cfg.ParsedSubscriptionGracePeriod() + deletionDate := time.Now().UTC().Add(grace) + + rows, err := db.QueryContext(ctx, ` + SELECT id + FROM users + WHERE plan_tier = 'paid' + AND subscription_status = 'cancelled' + AND current_period_end IS NOT NULL + AND current_period_end < NOW() + AND subscription_expired_email_sent_at IS NULL + LIMIT $1`, cfg.Reaper.BatchSize) + if err != nil { + slog.Error("reaper: subscription-expiry query failed", "error", err) + return + } + defer rows.Close() + + type pending struct{ id string } + var users []pending + for rows.Next() { + var p pending + if err := rows.Scan(&p.id); err != nil { + slog.Error("reaper: subscription-expiry scan failed", "error", err) + continue + } + users = append(users, p) + } + rows.Close() + + for _, u := range users { + // Step 1: tag the user's permanent resources with the grace-window + // expires_at. The per-resource reaper pass will drop them naturally + // once expires_at elapses. + res, err := db.ExecContext(ctx, ` + UPDATE resources + SET expires_at = $1 + WHERE migrated_to_user_id = $2 + AND status = 'active' + AND expires_at IS NULL`, + deletionDate, u.id) + if err != nil { + slog.Error("reaper: tag resources for grace expiry failed", + "user_id", u.id, "error", err) + continue + } + taggedCount, _ := res.RowsAffected() + + // Step 2: claim the email slot, downgrade tier, send warning email. + // All in one atomic UPDATE via the claim helper. + userID, perr := uuid.Parse(u.id) + if perr != nil { + slog.Error("reaper: parse user id failed", "id", u.id, "error", perr) + continue + } + if sendExpiredIfUnsent(ctx, db, em, userID, deletionDate) { + slog.Info("reaper: subscription expired", + "user_id", u.id, "resources_tagged", taggedCount, + "deletion_date", deletionDate.Format("2006-01-02")) + } + } +} + func reapExpired(ctx context.Context, db *sql.DB, rdb *redis.Client, cfg *Config, custDBURL string) { // Two sources of reapable rows: // (a) TTL expired — status='active', expires_at < NOW() diff --git a/internal/server/schema.sql b/internal/server/schema.sql index cb1713d..823c2fe 100644 --- a/internal/server/schema.sql +++ b/internal/server/schema.sql @@ -42,6 +42,10 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS razorpay_subscription_short_url TEXT; -- ran simultaneously. ALTER TABLE users ADD COLUMN IF NOT EXISTS receipt_email_sent_at TIMESTAMPTZ; ALTER TABLE users ADD COLUMN IF NOT EXISTS cancel_email_sent_at TIMESTAMPTZ; +-- Set when the reaper downgrades a cancelled-past-period user to the free +-- tier. Same claim-lock semantics as the others — exactly-once email per +-- subscription lifecycle. +ALTER TABLE users ADD COLUMN IF NOT EXISTS subscription_expired_email_sent_at TIMESTAMPTZ; -- Plan-switch (monthly ↔ annual). The switch is scheduled — we don't tear -- down the current subscription the moment the user clicks, because the