Skip to content

Commit 914bb37

Browse files
fix(api): tier upgrade promotes team default TTL + auto_24h deploys (P1) (#212)
* fix(api): tier upgrade promotes team default TTL + auto_24h deploys A Pro-tier user (mastermanas805) just got an "expires in 6 hours" email the day after upgrading free→pro. Root cause: subscription.charged only called UpgradeTeamAllTiers, which lifts per-deploy ttl_policy as a side-effect but never touches teams.default_deployment_ttl_policy — so every NEXT POST /deploy/new still inherited 'auto_24h' and re-fired the 24h-expiry reminder cycle. Wires a new models.PromoteDeploymentTTLsForTeam (single tx) into handleSubscriptionCharged for tiers >= hobby: - flips teams.default_deployment_ttl_policy auto_24h → permanent (user-explicit non-auto_24h defaults are LEFT UNTOUCHED) - promotes every non-terminal ttl_policy='auto_24h' deploy to permanent + clears expires_at + resets the reminders ledger - emits team.ttl_policies_promoted audit row + counter instant_tier_upgrade_ttl_promote_total{outcome=success|noop|error} Fail-open: the upgrade tx has already committed by promote-time, so a promote error never 500s the webhook (operator runs cmd/backfill-tier-ttl to repair the residual; the function is idempotent). Coverage block (CLAUDE.md rule 17): Symptom: team.default stays 'auto_24h' + auto_24h deploys keep firing "expires in N hours" emails after paid upgrade Enumeration: rg -F 'UpdatePlanTier' rg -F 'UpgradeTeamAllTiers' rg -F 'default_deployment_ttl_policy' Sites found: 4 (billing webhook, /internal/set-tier, admin tier change, PATCH /api/v1/team/settings) Sites touched: 1 — the Razorpay webhook is the ONLY paid-tier promotion path (set-tier is dev-only, admin demote keeps existing state by design, team-settings is the user's own override and must not trigger promote) Coverage test: e2e/reliability_contract_test.go's audit-kinds registry iterator (rule 18) flags any new AuditKind* constant that ships without a downstream-consumer entry, and TestPlansRegistryUpgradeTargets_AllInvokePromoteGuard iterates plans.Registry tiers so a new tier added between free and hobby would loudly fail the guard. Live verified: awaiting user verification of the next paid upgrade — the new metric NR alert (tier-upgrade-ttl-promote-failed) pages on any outcome=error tick within 10m. Backfill: operator runs `DATABASE_URL=… go run ./cmd/backfill-tier-ttl -apply` once to repair every paid team with stale auto_24h state. The script is idempotent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(backfill-tier-ttl): silence errcheck on all fmt.Fprint calls * test(api#212): close 100% patch-coverage gap on tier-upgrade TTL promote Adds the missing branch coverage for the Pro-upgrade auto-promote TTL fix landed in 82de682, taking the three changed surfaces to 100%: - cmd/backfill-tier-ttl: refactored main() → exitFn-wrapped run() with injectable openDB + promoteFn seams (mirrors cmd/openapi-snapshot). Ten tests cover usage errors, DB open/ping/query/scan/rows failures, dry-run vs apply modes, mixed ok/error per-team tallying, env-var fallback, and the main() exit-code dispatch. - models.PromoteDeploymentTTLsForTeam: six sqlmock-driven tests for the begin/exec/rows-affected/commit error wrappers that a real Postgres test DB can't drive on demand. - handlers.handleSubscriptionCharged + emitTTLPoliciesPromotedAudit: added the promoteDeploymentTTLsForTeamFn seam (same pattern as billingPortalFactory) so the fail-open promote-error branch is reachable. Two tests: nil-db audit early-return + webhook still 200s on a simulated promote tx failure. No production behaviour change: the cmd refactor keeps the same exit codes and the handler seam is a package-level var pointing at the real models call, swapped only by tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(backfill-tier-ttl): cover openDB default factory (line 113) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent db10a0e commit 914bb37

11 files changed

Lines changed: 1757 additions & 0 deletions

cmd/backfill-tier-ttl/main.go

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Command backfill-tier-ttl is a one-off operator tool that repairs the
2+
// deployment-TTL state of every paid team whose data predates the
3+
// 2026-05-31 "tier upgrade auto-promotes deployment TTLs" fix.
4+
//
5+
// THE BUG (P1, mastermanas805 report 2026-05-31)
6+
//
7+
// Before the fix, the Razorpay subscription.charged webhook called
8+
// models.UpgradeTeamAllTiersWithSubscription but did NOT call
9+
// models.PromoteDeploymentTTLsForTeam — so a team that upgraded
10+
// free→pro carried forward its 'auto_24h' team default (every
11+
// future POST /deploy/new still inherited a 24h TTL) AND, for
12+
// teams whose upgrade landed before the broader ElevateDeployments
13+
// tx also set ttl_policy='permanent', their pre-upgrade auto_24h
14+
// deploys kept auto-expiring. The user got "expires in 6 hours"
15+
// emails for deploys they thought they had paid to keep.
16+
//
17+
// WHAT THIS DOES
18+
//
19+
// For every team where plan_tier ∈ {hobby, hobby_plus, pro, growth, team}:
20+
// - Calls models.PromoteDeploymentTTLsForTeam, which (inside one tx)
21+
// (a) flips teams.default_deployment_ttl_policy 'auto_24h' →
22+
// 'permanent' iff the current value is 'auto_24h' (user-explicit
23+
// non-auto values are LEFT UNTOUCHED — see the model doc), and
24+
// (b) updates every non-terminal deployment row with
25+
// ttl_policy='auto_24h' SET ttl_policy='permanent',
26+
// expires_at=NULL, reminders_sent=0, last_reminder_at=NULL.
27+
//
28+
// Anonymous and free teams are NEVER touched — those tiers don't get
29+
// permanent deploys, and a flip would be a contract change.
30+
//
31+
// USAGE
32+
//
33+
// # Dry-run first (default — prints what WOULD change, mutates nothing):
34+
// DATABASE_URL=postgres://... go run ./cmd/backfill-tier-ttl
35+
//
36+
// # Apply the backfill (after eyeballing the dry-run summary):
37+
// DATABASE_URL=postgres://... go run ./cmd/backfill-tier-ttl -apply
38+
//
39+
// # Production: connect through the bastion/kubectl port-forward to
40+
// # api/internal/handlers/billing.go's source-of-truth platform DB.
41+
// # DO NOT run this against any other instance.
42+
//
43+
// SAFETY
44+
//
45+
// The function is idempotent — every UPDATE has a "only-if-still-stale"
46+
// WHERE predicate, so running this twice on the same DB is a no-op the
47+
// second time. It is safe to re-run after a partial failure.
48+
//
49+
// Per-team work runs in its own tx; a single team's failure does NOT roll
50+
// back the teams processed before it. The exit code reports how many
51+
// teams errored — operator should re-run for the residual.
52+
package main
53+
54+
import (
55+
"context"
56+
"database/sql"
57+
"errors"
58+
"flag"
59+
"fmt"
60+
"io"
61+
"os"
62+
"time"
63+
64+
"github.com/google/uuid"
65+
_ "github.com/lib/pq"
66+
67+
"instant.dev/internal/models"
68+
)
69+
70+
const (
71+
// backfillExitOK reports a clean run (every team either succeeded or was
72+
// excluded by the tier filter).
73+
backfillExitOK = 0
74+
// backfillExitUsage means CLI args / env config were wrong.
75+
backfillExitUsage = 2
76+
// backfillExitPartial means at least one team's promote tx errored.
77+
// The operator should re-run; the function is idempotent.
78+
backfillExitPartial = 3
79+
)
80+
81+
// paidTierFilter is the SQL fragment selecting the teams the backfill
82+
// targets — paid tiers only (hobby and above). plans.Rank() would be more
83+
// portable but a literal IN-list is what the operator can paste into
84+
// `psql` to preview the candidate set independently.
85+
const paidTierFilter = `plan_tier IN ('hobby', 'hobby_plus', 'pro', 'growth', 'team')`
86+
87+
// candidateTeamSQL selects teams that actually have something to backfill:
88+
// either the team default is still 'auto_24h' OR they have at least one
89+
// non-terminal auto_24h deploy. Excluding already-promoted teams keeps the
90+
// dry-run summary readable on a large customer base.
91+
const candidateTeamSQL = `
92+
SELECT t.id, t.plan_tier,
93+
COALESCE(t.default_deployment_ttl_policy, 'auto_24h') AS team_default,
94+
(
95+
SELECT count(*) FROM deployments d
96+
WHERE d.team_id = t.id
97+
AND d.ttl_policy = 'auto_24h'
98+
AND d.status NOT IN ('deleted', 'expired')
99+
) AS auto_deploy_count
100+
FROM teams t
101+
WHERE ` + paidTierFilter + `
102+
ORDER BY t.created_at ASC
103+
`
104+
105+
// exitFn is os.Exit at runtime; tests swap it so the main() body becomes
106+
// a measurable statement instead of an irreducible coverage hole. Mirrors
107+
// the pattern in cmd/openapi-snapshot/main.go.
108+
var exitFn = os.Exit
109+
110+
// openDB is the *sql.DB factory; tests swap it for a sqlmock-backed handle
111+
// so the run() body is exercisable without a real postgres listener. Default
112+
// uses lib/pq.
113+
var openDB = func(dsn string) (*sql.DB, error) { return sql.Open("postgres", dsn) }
114+
115+
// promoteFn is the model call the apply-loop drives. Tests swap it so the
116+
// success/error reporting branches at the bottom of run() are reachable
117+
// without a populated platform DB.
118+
var promoteFn = models.PromoteDeploymentTTLsForTeam
119+
120+
func main() { exitFn(run(os.Args[1:], os.Stdout, os.Stderr)) }
121+
122+
// run is the testable body of main — splits CLI parsing from os.Exit so the
123+
// command's exit-code surface can be pinned by a unit test.
124+
func run(args []string, stdout, stderr io.Writer) int {
125+
fs := flag.NewFlagSet("backfill-tier-ttl", flag.ContinueOnError)
126+
fs.SetOutput(stderr)
127+
apply := fs.Bool("apply", false, "actually mutate the DB (default: dry-run, no mutations)")
128+
dbURL := fs.String("database-url", os.Getenv("DATABASE_URL"),
129+
"platform_db connection string (defaults to $DATABASE_URL)")
130+
if err := fs.Parse(args); err != nil {
131+
return backfillExitUsage
132+
}
133+
if *dbURL == "" {
134+
_, _ = fmt.Fprintln(stderr, "backfill-tier-ttl: DATABASE_URL is unset and -database-url not supplied")
135+
return backfillExitUsage
136+
}
137+
138+
db, err := openDB(*dbURL)
139+
if err != nil {
140+
_, _ = fmt.Fprintf(stderr, "backfill-tier-ttl: open db: %v\n", err)
141+
return backfillExitUsage
142+
}
143+
defer func() { _ = db.Close() }()
144+
145+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
146+
defer cancel()
147+
if err := db.PingContext(ctx); err != nil {
148+
_, _ = fmt.Fprintf(stderr, "backfill-tier-ttl: ping db: %v\n", err)
149+
return backfillExitUsage
150+
}
151+
152+
rows, err := db.QueryContext(ctx, candidateTeamSQL)
153+
if err != nil {
154+
_, _ = fmt.Fprintf(stderr, "backfill-tier-ttl: list candidates: %v\n", err)
155+
return backfillExitUsage
156+
}
157+
defer func() { _ = rows.Close() }()
158+
159+
var candidates []candidate
160+
for rows.Next() {
161+
var c candidate
162+
if err := rows.Scan(&c.teamID, &c.tier, &c.teamDefault, &c.autoDeployCount); err != nil {
163+
_, _ = fmt.Fprintf(stderr, "backfill-tier-ttl: scan: %v\n", err)
164+
return backfillExitUsage
165+
}
166+
// Skip teams already fully promoted — nothing to do, keeps the
167+
// summary readable.
168+
if c.teamDefault != "auto_24h" && c.autoDeployCount == 0 {
169+
continue
170+
}
171+
candidates = append(candidates, c)
172+
}
173+
if err := rows.Err(); err != nil {
174+
_, _ = fmt.Fprintf(stderr, "backfill-tier-ttl: rows: %v\n", err)
175+
return backfillExitUsage
176+
}
177+
178+
mode := "DRY-RUN"
179+
if *apply {
180+
mode = "APPLY"
181+
}
182+
_, _ = fmt.Fprintf(stdout, "backfill-tier-ttl: mode=%s candidates=%d\n", mode, len(candidates))
183+
for _, c := range candidates {
184+
_, _ = fmt.Fprintf(stdout, " team=%s tier=%s team_default=%s auto_deploys=%d\n",
185+
c.teamID, c.tier, c.teamDefault, c.autoDeployCount)
186+
}
187+
if !*apply {
188+
_, _ = fmt.Fprintln(stdout, "backfill-tier-ttl: dry-run complete — re-run with -apply to mutate")
189+
return backfillExitOK
190+
}
191+
192+
var ok, errored int
193+
for _, c := range candidates {
194+
result, promoteErr := promoteFn(ctx, db, c.teamID)
195+
if promoteErr != nil {
196+
errored++
197+
_, _ = fmt.Fprintf(stderr, " team=%s ERROR: %v\n", c.teamID, promoteErr)
198+
continue
199+
}
200+
ok++
201+
_, _ = fmt.Fprintf(stdout, " team=%s OK promoted_deploys=%d team_default_flipped=%t\n",
202+
c.teamID, result.DeploysPromoted, result.TeamDefaultFlipped)
203+
}
204+
_, _ = fmt.Fprintf(stdout, "backfill-tier-ttl: applied — ok=%d errored=%d\n", ok, errored)
205+
if errored > 0 {
206+
// The function is idempotent — operator re-runs for the residual.
207+
return backfillExitPartial
208+
}
209+
return backfillExitOK
210+
}
211+
212+
// candidate is one row from candidateTeamSQL.
213+
type candidate struct {
214+
teamID uuid.UUID
215+
tier string
216+
teamDefault string
217+
autoDeployCount int
218+
}
219+
220+
// ensureModelsImportUsed is a compile-time guard: if a future refactor
221+
// removes the only PromoteDeploymentTTLsForTeam call site above, this var
222+
// keeps the import live so the godoc still cross-references the function.
223+
var _ = errors.New

0 commit comments

Comments
 (0)