Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions internal/server/billing_emails.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
39 changes: 30 additions & 9 deletions internal/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down
24 changes: 22 additions & 2 deletions internal/server/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,20 +120,40 @@ func subscriptionCancelledEmail(plan string, periodEnd time.Time) (subject, html
subject = "Your instanode subscription has been cancelled"
untilLine := ""
if !periodEnd.IsZero() {
untilLine = fmt.Sprintf("<p>Paid access continues until <strong>%s</strong>. After that, your account reverts to the free tier.</p>", periodEnd.Format("2006-01-02"))
untilLine = fmt.Sprintf("<p>Paid access continues until <strong>%s</strong>. 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.</p>", periodEnd.Format("2006-01-02"))
} else {
untilLine = "<p>Your account will revert to the free tier at the end of the current billing period.</p>"
}
html = fmt.Sprintf(`<!doctype html>
<html><body style="font-family:system-ui,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#222;">
<p>We've cancelled your <strong>%s</strong> subscription — no further charges will be made.</p>
%s
<p>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 <a href="https://instanode.dev/pricing.html">pricing page</a> to keep them permanent.</p>
<p>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 <a href="https://instanode.dev/pricing.html">pricing page</a> to keep them permanent.</p>
<p style="color:#888;font-size:11px;margin-top:32px;">For any issues or queries, contact <a href="mailto:contact@instanode.dev" style="color:#888;">contact@instanode.dev</a>.</p>
</body></html>`, 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(`<!doctype html>
<html><body style="font-family:system-ui,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#222;">
<p>Your paid subscription ended on <strong>%s</strong>. Your account is now on the free tier.</p>
<p style="background:#fff5f5;border-left:3px solid #e33;padding:10px 14px;margin:16px 0;color:#933;font-size:13px;">
<strong>Action required:</strong> the databases and webhooks you provisioned on the paid plan will be deleted on <strong>%s</strong>.
Re-subscribe before then to restore permanent access — no data loss.
</p>
<p>To keep your data: <a href="https://instanode.dev/pricing.html">re-subscribe at instanode.dev/pricing.html</a>.</p>
<p>To export it: connect to each <code>connection_url</code> with <code>pg_dump</code> any time before the deletion date.</p>
<p style="color:#888;font-size:11px;margin-top:32px;">For any issues or queries, reply to this email.</p>
</body></html>`, 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
Expand Down
5 changes: 3 additions & 2 deletions internal/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 85 additions & 1 deletion internal/server/reaper.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"time"

"github.com/google/uuid"
"github.com/redis/go-redis/v9"
)

Expand All @@ -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)
Expand All @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions internal/server/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading