diff --git a/docs/superpowers/plans/2026-05-07-idle-auto-cancel.md b/docs/superpowers/plans/2026-05-07-idle-auto-cancel.md new file mode 100644 index 00000000..83af7be4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-idle-auto-cancel.md @@ -0,0 +1,2585 @@ +# Idle Auto-Cancel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Auto-cancel a Pro subscription via `cancel_at_period_end=true` when the user has been idle for two consecutive billing periods. No settings toggle — this is the default Sabermatic behavior. Reversible via email-link click or any authenticated activity during the cancel window. + +**Architecture:** Event-driven via Stripe `invoice.upcoming` webhook (~T-7 days before each renewal). Cancel decision happens inside a per-user-locked DB transaction (`SELECT ... FOR UPDATE`) with a generic `stripe_webhook_dedup` table for retry-storm protection and period-keyed `user_events` rows for multi-firing dedup. Reversal paths verify Stripe state before acting (handles partial-failure cache drift). Activity signal = `MAX(last_active)` from soft-deleted `auth_sessions`. + +**Tech Stack:** +- Go 1.25.x (`go.mod:5`); `github.com/stripe/stripe-go/v82` (v82.5.1) +- Postgres 16 with sqlc-generated queries +- River queue for async email send +- TypeScript/React frontend with ConnectRPC +- HMAC-SHA256 for keep-link tokens + +**Spec:** `docs/superpowers/specs/2026-05-07-idle-auto-cancel-design.md` (commit `780ef4ea`). + +**Key project conventions** (from `/Users/btc/Projects/src/drill/CLAUDE.md`): +- Frontend typecheck: `cd web && npx tsc -b` (NEVER bare `tsc --noEmit`) +- Full CI: `make test` (buf lint + codegen check + frontend tsc/lint/tests + backend tests) +- Test users via `backendtest.SeedUser` (handler tests) or `seedUser` (backend internal tests); never raw SQL inserts +- After UI-affecting commits: load in browser before declaring done +- `_ = err` for ignored errors; never blanket `nolint` +- After every code-modifying task, a separate review agent reads actual files and verifies against spec + +--- + +## Phase 1: Schema + +Each migration is a standalone task with paired `up.sql` / `down.sql`. After migration files land, sqlc generation must be re-run via `sqlc generate` (the existing project script — see `Makefile` if present, else use `sqlc generate` from the repo root). + +### Task 1: Migration 015 — `auth_sessions` soft-delete + +**Files:** +- Create: `sql/migrations/015_auth_sessions_soft_delete.up.sql` +- Create: `sql/migrations/015_auth_sessions_soft_delete.down.sql` +- Modify: `sql/queries/auth_sessions.sql` +- Modify: `sql/queries/users.sql` (rename existing logout-everywhere query for clarity) +- Modify: `internal/backend/auth.go:261` (sole caller of `DeleteAuthSession`) +- Test: `internal/backend/auth_test.go` (verify logout now soft-deletes; sessions persist for activity reads) + +- [ ] **Step 1: Write the up migration** + +```sql +-- 015_auth_sessions_soft_delete.up.sql +ALTER TABLE auth_sessions + ADD COLUMN revoked_at TIMESTAMPTZ; + +CREATE INDEX idx_auth_sessions_user_last_active + ON auth_sessions(user_id, last_active DESC); +``` + +- [ ] **Step 2: Write the down migration** + +```sql +-- 015_auth_sessions_soft_delete.down.sql +DROP INDEX IF EXISTS idx_auth_sessions_user_last_active; +ALTER TABLE auth_sessions DROP COLUMN revoked_at; +``` + +- [ ] **Step 3: Update `sql/queries/auth_sessions.sql`** + +Replace the file contents: + +```sql +-- name: CreateAuthSession :one +INSERT INTO auth_sessions (user_id, token_hash, expires_at, ip_address, user_agent) +VALUES ($1, $2, $3, $4, $5) +RETURNING *; + +-- name: GetAuthSessionByToken :one +-- Filters out soft-revoked sessions; only returns valid live sessions. +-- (Activity queries do NOT filter on revoked_at — see GetUserLastActive.) +SELECT s.*, u.email, u.display_name, u.role, u.plan, u.email_verified, + u.created_at AS user_created_at +FROM auth_sessions s +JOIN users u ON u.id = s.user_id +WHERE s.token_hash = $1 + AND s.expires_at > NOW() + AND s.revoked_at IS NULL + AND u.deleted_at IS NULL; + +-- name: TouchAuthSession :exec +UPDATE auth_sessions SET last_active = NOW() +WHERE id = $1; + +-- name: RevokeAuthSession :exec +-- Soft-delete: marks the row revoked but preserves it for the activity query. +UPDATE auth_sessions SET revoked_at = NOW() +WHERE id = $1 AND revoked_at IS NULL; + +-- name: RevokeUserAuthSessions :exec +-- Soft-delete every active session for a user (logout-everywhere). +UPDATE auth_sessions SET revoked_at = NOW() +WHERE user_id = $1 AND revoked_at IS NULL; + +-- name: GetUserLastActive :one +-- Reads across all history (including revoked sessions) — this is a +-- "when did the user last act, ever?" query, not a session-validity check. +SELECT MAX(last_active)::timestamptz +FROM auth_sessions +WHERE user_id = $1; +``` + +- [ ] **Step 4: Update `sql/queries/users.sql`** + +Locate the existing `DELETE FROM auth_sessions WHERE user_id = @id;` query (the account-deletion path). Rename for clarity: + +```sql +-- name: HardDeleteUserAuthSessions :exec +-- Used ONLY by account deletion (data minimization / GDPR). Logout uses +-- RevokeUserAuthSessions in auth_sessions.sql instead. +DELETE FROM auth_sessions WHERE user_id = @id; +``` + +- [ ] **Step 5: Update the sole caller in `internal/backend/auth.go:261`** + +Find the call to `queries.DeleteAuthSession` (Logout path) and replace with `queries.RevokeAuthSession`. The signatures are identical (`exec` query taking the session ID). + +Also locate any caller of `DeleteUserAuthSessions` (logout-everywhere paths) and rename to `RevokeUserAuthSessions`. Find with: `grep -rn "DeleteUserAuthSessions\|DeleteAuthSession" internal/`. + +For the account-deletion caller, use `HardDeleteUserAuthSessions`. + +- [ ] **Step 6: Run sqlc generate and verify** + +```bash +sqlc generate +``` + +Expected: regenerates `internal/db/auth_sessions.sql.go` and `internal/db/users.sql.go` with new query method names. + +- [ ] **Step 7: Run go build to confirm callsites compile** + +```bash +go build ./... +``` + +Expected: builds clean. Any remaining caller of the old query names will fail compilation — fix in this task before committing. + +- [ ] **Step 8: Write a test in `internal/backend/auth_test.go`** + +Add this test alongside existing logout tests: + +```go +func TestLogout_SoftDeletes_PreservesActivityHistory(t *testing.T) { + t.Parallel() + b := pg.NewBackend(t) + ctx := context.Background() + userID := backendtest.SeedUser(t, b) + + // Sign in to create an auth session, then log out. + // (Use whatever existing helper your project has for sign-in; + // search for an existing test that logs in then out and mirror it.) + tok := signinHelper(t, b, userID) + require.NoError(t, b.Logout(ctx, tok)) + + // The session row should still exist (soft delete) with revoked_at set. + var revokedAt sql.NullTime + err := b.Pool().QueryRow(ctx, + `SELECT revoked_at FROM auth_sessions WHERE user_id = $1`, + userID).Scan(&revokedAt) + require.NoError(t, err) + require.True(t, revokedAt.Valid, "revoked_at should be set after logout") + + // GetUserLastActive should still return a value (across all history). + last, err := db.New(b.Pool()).GetUserLastActive(ctx, userID) + require.NoError(t, err) + require.NotZero(t, last, "last_active must be visible across revoked sessions") +} +``` + +- [ ] **Step 9: Run the test to verify it passes** + +```bash +go test ./internal/backend/ -run TestLogout_SoftDeletes_PreservesActivityHistory -race -count=1 -v +``` + +Expected: PASS. + +- [ ] **Step 10: Run the full backend test suite to catch regressions** + +```bash +go test ./internal/... ./cmd/... -race -count=1 -timeout=300s +``` + +Expected: all pass. If any existing test fails, the soft-delete change broke an assumption — fix in this task. + +- [ ] **Step 11: Commit** + +```bash +git add sql/migrations/015_*.sql sql/queries/auth_sessions.sql sql/queries/users.sql \ + internal/backend/auth.go internal/backend/auth_test.go \ + internal/db/auth_sessions.sql.go internal/db/users.sql.go internal/db/models.go +git commit -m "auth_sessions: soft-delete on logout (migration 015)" +``` + +--- + +### Task 2: Migration 016 — users subscription state cache + +**Files:** +- Create: `sql/migrations/016_users_sub_state.up.sql` +- Create: `sql/migrations/016_users_sub_state.down.sql` +- Modify: `sql/queries/users.sql` (add new sub-state queries) +- Test: deferred to Task 5 where these columns are exercised end-to-end via AuthUser projection. + +- [ ] **Step 1: Write the up migration** + +```sql +-- 016_users_sub_state.up.sql +ALTER TABLE users + ADD COLUMN stripe_subscription_id TEXT, + ADD COLUMN sub_cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN sub_cancel_is_auto BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN sub_current_period_start TIMESTAMPTZ, + ADD COLUMN pending_kept_banner BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN idle_eligible_after TIMESTAMPTZ NOT NULL DEFAULT NOW(); +``` + +- [ ] **Step 2: Write the down migration** + +```sql +-- 016_users_sub_state.down.sql +ALTER TABLE users + DROP COLUMN idle_eligible_after, + DROP COLUMN pending_kept_banner, + DROP COLUMN sub_current_period_start, + DROP COLUMN sub_cancel_is_auto, + DROP COLUMN sub_cancel_at_period_end, + DROP COLUMN stripe_subscription_id; +``` + +- [ ] **Step 3: Append new queries to `sql/queries/users.sql`** + +```sql +-- name: SetUserAutoCancelState :exec +-- Called by idleunsub.HandleInvoiceUpcoming when our trigger fires. +-- Sets BOTH cache flags so the auto-reverse middleware gate fires for this user. +UPDATE users +SET sub_cancel_at_period_end = TRUE, + sub_cancel_is_auto = TRUE, + sub_current_period_start = $2 +WHERE id = $1; + +-- name: ClearUserAutoCancelState :exec +-- Called by KeepSubscription / AutoReverse on reversal. Sets banner flag. +UPDATE users +SET sub_cancel_at_period_end = FALSE, + sub_cancel_is_auto = FALSE, + pending_kept_banner = TRUE, + sub_current_period_start = $2 +WHERE id = $1; + +-- name: SyncSubStateFromWebhook :exec +-- Called by handleSubscriptionUpdated. Does NOT touch sub_cancel_is_auto: +-- only our handler sets that flag; webhook sync must not overwrite it. +UPDATE users +SET stripe_subscription_id = $2, + sub_cancel_at_period_end = $3, + sub_current_period_start = $4 +WHERE id = $1; + +-- name: ClearSubStateOnDeletion :exec +-- Called by handleSubscriptionDeleted. Clears all sub state and downgrades plan. +UPDATE users +SET stripe_subscription_id = NULL, + sub_cancel_at_period_end = FALSE, + sub_cancel_is_auto = FALSE, + sub_current_period_start = NULL, + plan = 'free' +WHERE id = $1; + +-- name: ClearKeptBanner :exec +UPDATE users SET pending_kept_banner = FALSE WHERE id = $1; + +-- name: ClearStaleKeptBanners :exec +-- Periodic hygiene: clear banners that are older than 14 days. +-- (Run from a tiny daily cron; the spec calls this out as low-priority cleanup.) +UPDATE users +SET pending_kept_banner = FALSE +WHERE pending_kept_banner = TRUE + AND id IN ( + SELECT user_id FROM user_events + WHERE event_type = 'subscription_kept' + AND created_at < NOW() - INTERVAL '14 days' + ); + +-- name: GetUserAutoCancelGates :one +-- Used by AutoReverse to defensively re-read both gates. +SELECT sub_cancel_at_period_end, sub_cancel_is_auto, + stripe_subscription_id, sub_current_period_start +FROM users +WHERE id = $1; + +-- name: LockUserForSubDecision :one +-- Per-user mutex for the cancel transaction. Acquires a row-level lock that +-- serializes concurrent invoice.upcoming evaluations for the same user. +-- Must be inside a transaction; releases on COMMIT or ROLLBACK. +SELECT id FROM users WHERE id = $1 FOR UPDATE; +``` + +- [ ] **Step 4: Run sqlc generate** + +```bash +sqlc generate +``` + +Expected: `internal/db/users.sql.go` gains the new methods; `internal/db/models.go` `User` struct gains the new fields. + +- [ ] **Step 5: Run go build** + +```bash +go build ./... +``` + +Expected: builds clean (no callers reference the new methods yet). + +- [ ] **Step 6: Commit** + +```bash +git add sql/migrations/016_*.sql sql/queries/users.sql \ + internal/db/users.sql.go internal/db/models.go +git commit -m "users: sub state cache columns (migration 016)" +``` + +--- + +### Task 3: Migration 017 — `stripe_webhook_dedup` table + +**Files:** +- Create: `sql/migrations/017_stripe_webhook_dedup.up.sql` +- Create: `sql/migrations/017_stripe_webhook_dedup.down.sql` +- Create: `sql/queries/stripe_webhook_dedup.sql` + +- [ ] **Step 1: Write the up migration** + +```sql +-- 017_stripe_webhook_dedup.up.sql +CREATE TABLE stripe_webhook_dedup ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +- [ ] **Step 2: Write the down migration** + +```sql +-- 017_stripe_webhook_dedup.down.sql +DROP TABLE IF EXISTS stripe_webhook_dedup; +``` + +- [ ] **Step 3: Create `sql/queries/stripe_webhook_dedup.sql`** + +```sql +-- name: TryClaimWebhookEvent :one +-- Returns the event_id on first claim; returns no row on subsequent claims. +-- Use the no-row return as the signal "this event was already handled". +INSERT INTO stripe_webhook_dedup (event_id, event_type) +VALUES ($1, $2) +ON CONFLICT (event_id) DO NOTHING +RETURNING event_id; +``` + +- [ ] **Step 4: Run sqlc generate and go build** + +```bash +sqlc generate && go build ./... +``` + +Expected: clean build. + +- [ ] **Step 5: Commit** + +```bash +git add sql/migrations/017_*.sql sql/queries/stripe_webhook_dedup.sql \ + internal/db/stripe_webhook_dedup.sql.go internal/db/models.go +git commit -m "stripe_webhook_dedup: generic webhook idempotency (migration 017)" +``` + +--- + +### Task 4: Migration 018 — `keep_link_token_uses` + period-keyed dedup queries + +**Files:** +- Create: `sql/migrations/018_keep_link_token_uses.up.sql` +- Create: `sql/migrations/018_keep_link_token_uses.down.sql` +- Create: `sql/queries/keep_link_token_uses.sql` +- Modify: `sql/queries/user_events.sql` (add the two period-keyed dedup queries; create the file if it doesn't exist) + +- [ ] **Step 1: Write the up migration** + +```sql +-- 018_keep_link_token_uses.up.sql +CREATE TABLE keep_link_token_uses ( + token_hash BYTEA PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + used_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +- [ ] **Step 2: Write the down migration** + +```sql +-- 018_keep_link_token_uses.down.sql +DROP TABLE IF EXISTS keep_link_token_uses; +``` + +- [ ] **Step 3: Create `sql/queries/keep_link_token_uses.sql`** + +```sql +-- name: TryClaimKeepToken :one +-- Single-use enforcement. Returns the hash on first claim, no row otherwise. +INSERT INTO keep_link_token_uses (token_hash, user_id) +VALUES ($1, $2) +ON CONFLICT (token_hash) DO NOTHING +RETURNING token_hash; +``` + +- [ ] **Step 4: Add period-keyed dedup queries** + +If `sql/queries/user_events.sql` exists, append; else create it with these queries: + +```sql +-- name: HasAutoCanceledThisPeriod :one +-- Returns true if a subscription_auto_canceled event already exists for +-- this (user, subscription, period). Backs the multi-firing dedup in §4.1. +-- metadata->>'current_period_start' is RFC3339 text written by cancelMetadata. +SELECT EXISTS ( + SELECT 1 FROM user_events + WHERE user_id = $1 + AND event_type = 'subscription_auto_canceled' + AND (metadata->>'subscription_id') = $2 + AND (metadata->>'current_period_start')::timestamptz = $3 +); + +-- name: GetMostRecentKeptOrCanceledForPeriod :one +-- For multi-firing dedup: did a 'subscription_kept' event arrive after the +-- most recent 'subscription_auto_canceled' for this period? If yes, the user +-- already kept their sub for this period; don't re-cancel. +SELECT event_type +FROM user_events +WHERE user_id = $1 + AND event_type IN ('subscription_auto_canceled', 'subscription_kept') + AND (metadata->>'subscription_id') = $2 + AND (metadata->>'current_period_start')::timestamptz = $3 +ORDER BY created_at DESC +LIMIT 1; + +-- name: InsertSubscriptionAutoCanceledEvent :exec +-- Composed insert for the cancel decision. metadata is already-marshaled JSON. +INSERT INTO user_events (user_id, event_type, metadata) +VALUES ($1, 'subscription_auto_canceled', $2); + +-- name: InsertSubscriptionKeptEvent :exec +INSERT INTO user_events (user_id, event_type, metadata) +VALUES ($1, 'subscription_kept', $2); +``` + +- [ ] **Step 5: Run sqlc generate and go build** + +```bash +sqlc generate && go build ./... +``` + +- [ ] **Step 6: Commit** + +```bash +git add sql/migrations/018_*.sql sql/queries/keep_link_token_uses.sql \ + sql/queries/user_events.sql \ + internal/db/keep_link_token_uses.sql.go internal/db/user_events.sql.go \ + internal/db/models.go +git commit -m "keep_link_token_uses + user_events dedup queries (migration 018)" +``` + +--- + +## Phase 2: AuthUser extension + +### Task 5: Project new sub-state columns into `AuthUser` + +The auto-reverse hook in `Backend.AuthenticateSession` gates on `user.SubCancelAtPeriodEnd && user.SubCancelIsAuto`. The `AuthUser` struct must carry these. + +**Files:** +- Modify: `internal/auth/user_context.go` (extend `AuthUser` struct) +- Modify: `sql/queries/auth_sessions.sql` (`GetAuthSessionByToken` projects new columns) +- Modify: `internal/backend/backend.go:267-285` (`AuthenticateSession` populates new fields) +- Test: `internal/backend/auth_test.go` (verify projection) + +- [ ] **Step 1: Update `internal/auth/user_context.go`** + +Add three fields to the `AuthUser` struct: + +```go +type AuthUser struct { + ID uuid.UUID + Email string + DisplayName string + Role string + Plan string + EmailVerified bool + CreatedAt time.Time + + // Auto-cancel state (migration 016). + SubCancelAtPeriodEnd bool + SubCancelIsAuto bool + PendingKeptBanner bool +} +``` + +- [ ] **Step 2: Update `GetAuthSessionByToken` in `sql/queries/auth_sessions.sql`** + +Extend the SELECT projection: + +```sql +-- name: GetAuthSessionByToken :one +SELECT s.*, u.email, u.display_name, u.role, u.plan, u.email_verified, + u.created_at AS user_created_at, + u.sub_cancel_at_period_end, u.sub_cancel_is_auto, u.pending_kept_banner +FROM auth_sessions s +JOIN users u ON u.id = s.user_id +WHERE s.token_hash = $1 + AND s.expires_at > NOW() + AND s.revoked_at IS NULL + AND u.deleted_at IS NULL; +``` + +- [ ] **Step 3: Run sqlc generate** + +```bash +sqlc generate +``` + +Expected: `GetAuthSessionByTokenRow` struct gains the three new fields. + +- [ ] **Step 4: Update `Backend.AuthenticateSession` in `internal/backend/backend.go`** + +Find the function (around line 267). After `row, err := queries.GetAuthSessionByToken(...)`, populate the new fields when constructing the returned `AuthUser`: + +```go +return &auth.AuthUser{ + ID: row.UserID, + Email: row.Email, + DisplayName: row.DisplayName, + Role: row.Role, + Plan: row.Plan, + EmailVerified: row.EmailVerified, + CreatedAt: row.UserCreatedAt, + SubCancelAtPeriodEnd: row.SubCancelAtPeriodEnd, + SubCancelIsAuto: row.SubCancelIsAuto, + PendingKeptBanner: row.PendingKeptBanner, +}, nil +``` + +(Match the existing field-assignment style; the new fields are appended.) + +- [ ] **Step 5: Write a test** + +In `internal/backend/auth_test.go` add: + +```go +func TestAuthenticateSession_ProjectsSubState(t *testing.T) { + t.Parallel() + b := pg.NewBackend(t) + ctx := context.Background() + userID := backendtest.SeedUser(t, b) + + // Force the cache columns to known values. + _, err := b.Pool().Exec(ctx, ` + UPDATE users + SET sub_cancel_at_period_end = TRUE, + sub_cancel_is_auto = TRUE, + pending_kept_banner = TRUE + WHERE id = $1`, userID) + require.NoError(t, err) + + tok := signinHelper(t, b, userID) // existing helper; mirror existing tests + tokenHash := auth.HashSessionToken(tok) + + user, err := b.AuthenticateSession(ctx, tokenHash) + require.NoError(t, err) + require.True(t, user.SubCancelAtPeriodEnd) + require.True(t, user.SubCancelIsAuto) + require.True(t, user.PendingKeptBanner) +} +``` + +- [ ] **Step 6: Run the test** + +```bash +go test ./internal/backend/ -run TestAuthenticateSession_ProjectsSubState -race -count=1 -v +``` + +Expected: PASS. + +- [ ] **Step 7: Run all backend tests** + +```bash +go test ./internal/... ./cmd/... -race -count=1 -timeout=300s +``` + +Expected: all pass. + +- [ ] **Step 8: Commit** + +```bash +git add internal/auth/user_context.go sql/queries/auth_sessions.sql \ + internal/backend/backend.go internal/backend/auth_test.go \ + internal/db/auth_sessions.sql.go +git commit -m "auth: project sub_cancel_at_period_end and friends into AuthUser" +``` + +--- + +## Phase 3: idleunsub package + +The feature package home. New convention: `internal/feat//` for cohesive feature packages. This is the first one. + +### Task 6: Token sign/verify + +**Files:** +- Create: `internal/feat/idleunsub/token.go` +- Create: `internal/feat/idleunsub/token_test.go` + +- [ ] **Step 1: Write the failing test first** + +Create `internal/feat/idleunsub/token_test.go`: + +```go +package idleunsub_test + +import ( + "crypto/rand" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/btc/drill/internal/feat/idleunsub" +) + +func newSigner(t *testing.T) *idleunsub.TokenSigner { + t.Helper() + key := make([]byte, 32) + _, err := rand.Read(key) + require.NoError(t, err) + return idleunsub.NewTokenSigner(key) +} + +func TestSignVerify_RoundTrip(t *testing.T) { + t.Parallel() + s := newSigner(t) + now := time.Now().UTC().Truncate(time.Second) + end := now.Add(7 * 24 * time.Hour) + + claims := idleunsub.KeepTokenClaims{ + UserID: uuid.New(), + SubscriptionID: "sub_123", + Action: "keep_subscription", + CurrentPeriodEnd: end, + IssuedAt: now.Unix(), + ExpiresAt: end.Unix(), + } + + tok := s.Sign(claims) + got, err := s.Verify(tok) + require.NoError(t, err) + require.Equal(t, claims.UserID, got.UserID) + require.Equal(t, claims.SubscriptionID, got.SubscriptionID) + require.Equal(t, claims.Action, got.Action) + require.True(t, claims.CurrentPeriodEnd.Equal(got.CurrentPeriodEnd)) + require.Equal(t, claims.ExpiresAt, got.ExpiresAt) +} + +func TestVerify_RejectsTampered(t *testing.T) { + t.Parallel() + s := newSigner(t) + tok := s.Sign(idleunsub.KeepTokenClaims{ + UserID: uuid.New(), SubscriptionID: "sub_x", Action: "keep_subscription", + CurrentPeriodEnd: time.Now().Add(time.Hour).UTC(), ExpiresAt: time.Now().Add(time.Hour).Unix(), + }) + tampered := tok[:len(tok)-2] + "AA" // mutate last two characters + _, err := s.Verify(tampered) + require.ErrorIs(t, err, idleunsub.ErrTokenInvalid) +} + +func TestVerify_RejectsExpired(t *testing.T) { + t.Parallel() + s := newSigner(t) + past := time.Now().Add(-time.Hour).UTC().Truncate(time.Second) + tok := s.Sign(idleunsub.KeepTokenClaims{ + UserID: uuid.New(), SubscriptionID: "sub_x", Action: "keep_subscription", + CurrentPeriodEnd: past, IssuedAt: past.Add(-24 * time.Hour).Unix(), ExpiresAt: past.Unix(), + }) + _, err := s.Verify(tok) + require.ErrorIs(t, err, idleunsub.ErrTokenExpired) +} + +func TestVerify_RejectsExpDriftFromPeriodEnd(t *testing.T) { + t.Parallel() + s := newSigner(t) + end := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + + claims := idleunsub.KeepTokenClaims{ + UserID: uuid.New(), SubscriptionID: "sub_x", Action: "keep_subscription", + CurrentPeriodEnd: end, + IssuedAt: time.Now().Unix(), + ExpiresAt: end.Add(24 * time.Hour).Unix(), // drift! + } + tok := s.Sign(claims) + _, err := s.Verify(tok) + require.ErrorIs(t, err, idleunsub.ErrTokenInvalid) +} +``` + +- [ ] **Step 2: Run the test to verify failure** + +```bash +go test ./internal/feat/idleunsub/ -run TestSignVerify -v +``` + +Expected: FAIL — package doesn't exist yet. + +- [ ] **Step 3: Implement `internal/feat/idleunsub/token.go`** + +```go +package idleunsub + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/google/uuid" +) + +var ( + ErrTokenInvalid = errors.New("idleunsub: token invalid") + ErrTokenExpired = errors.New("idleunsub: token expired") +) + +// KeepTokenClaims is the canonical signed payload. Field order is fixed by +// struct definition; encoding/json over a typed struct produces a stable +// serialization (no map iteration order ambiguity). +type KeepTokenClaims struct { + UserID uuid.UUID `json:"user_id"` + SubscriptionID string `json:"subscription_id"` + Action string `json:"action"` // "keep_subscription" + CurrentPeriodEnd time.Time `json:"current_period_end"` + IssuedAt int64 `json:"iat"` + ExpiresAt int64 `json:"exp"` // INVARIANT: == CurrentPeriodEnd.Unix() +} + +// TokenSigner signs and verifies KeepTokenClaims with HMAC-SHA256. +type TokenSigner struct { + key []byte + now func() time.Time +} + +// NewTokenSigner constructs a signer with the given HMAC key. +func NewTokenSigner(key []byte) *TokenSigner { + return &TokenSigner{key: key, now: func() time.Time { return time.Now() }} +} + +// Sign serializes and HMAC-signs the claims, returning a URL-safe base64 +// string of the form ".". +func (s *TokenSigner) Sign(c KeepTokenClaims) string { + body, _ := json.Marshal(c) // typed struct never errors + bodyB64 := base64.RawURLEncoding.EncodeToString(body) + sig := hmac.New(sha256.New, s.key) + sig.Write([]byte(bodyB64)) + sigB64 := base64.RawURLEncoding.EncodeToString(sig.Sum(nil)) + return bodyB64 + "." + sigB64 +} + +// Verify parses and validates a token. Returns the claims or an error. +// - ErrTokenInvalid: bad signature, malformed, or invariant violated +// - ErrTokenExpired: signature valid but exp < now +func (s *TokenSigner) Verify(tok string) (KeepTokenClaims, error) { + var zero KeepTokenClaims + parts := strings.SplitN(tok, ".", 2) + if len(parts) != 2 { + return zero, ErrTokenInvalid + } + bodyB64, sigB64 := parts[0], parts[1] + + expectedSig := hmac.New(sha256.New, s.key) + expectedSig.Write([]byte(bodyB64)) + givenSig, err := base64.RawURLEncoding.DecodeString(sigB64) + if err != nil || !hmac.Equal(givenSig, expectedSig.Sum(nil)) { + return zero, ErrTokenInvalid + } + + body, err := base64.RawURLEncoding.DecodeString(bodyB64) + if err != nil { + return zero, ErrTokenInvalid + } + var c KeepTokenClaims + if err := json.Unmarshal(body, &c); err != nil { + return zero, ErrTokenInvalid + } + + // Spec invariant: ExpiresAt must equal CurrentPeriodEnd.Unix(). + if c.ExpiresAt != c.CurrentPeriodEnd.Unix() { + return zero, ErrTokenInvalid + } + + if s.now().Unix() >= c.ExpiresAt { + return zero, ErrTokenExpired + } + return c, nil +} +``` + +- [ ] **Step 4: Run tests** + +```bash +go test ./internal/feat/idleunsub/ -race -count=1 -v +``` + +Expected: all four tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/feat/idleunsub/token.go internal/feat/idleunsub/token_test.go +git commit -m "idleunsub: HMAC keep-token sign/verify" +``` + +--- + +### Task 7: Service skeleton + `HandleInvoiceUpcoming` (the cancel decision) + +This is the largest task. It builds the trigger evaluation and the entire cancel transaction. Tests are integration-shaped (live DB, stubbed Stripe). + +**Files:** +- Create: `internal/feat/idleunsub/idleunsub.go` (Service + HandleInvoiceUpcoming) +- Create: `internal/feat/idleunsub/metadata.go` (cancelMetadata / keptMetadata structs) +- Create: `internal/feat/idleunsub/metrics.go` (OTEL counter wrappers) +- Create: `internal/feat/idleunsub/idleunsub_test.go` +- Create: `internal/feat/idleunsub/stripestub_test.go` (in-package fake Stripe client used only by tests) + +**Stripe test pattern note:** `internal/backend/billing_test.go` imports `stripe-go/v82` directly and constructs Stripe events via fixture data. We'll mirror that: hand-construct `stripe.Event` and `stripe.Subscription` payloads in tests; the Service takes a `StripeClient` interface so tests inject a fake. + +- [ ] **Step 1: Define the StripeClient interface and metadata structs** + +Create `internal/feat/idleunsub/metadata.go`: + +```go +package idleunsub + +import "time" + +// All timestamps are canonicalized to second precision via +// time.Unix(stripeInt64, 0).UTC() before assignment. Stripe period fields +// arrive as int64 Unix seconds; explicit second precision and UTC guard +// against future drift if a code path ever constructs a time.Time from a +// different source. JSON encoding via encoding/json produces RFC3339 +// ("...Z") which Postgres ::timestamptz parses reliably. + +type cancelMetadata struct { + SubscriptionID string `json:"subscription_id"` + StripeEventID string `json:"stripe_event_id"` + CurrentPeriodStart time.Time `json:"current_period_start"` + CurrentPeriodEnd time.Time `json:"current_period_end"` +} + +type keptMetadata struct { + SubscriptionID string `json:"subscription_id"` + Via string `json:"via"` // "link" | "auto_activity" + CurrentPeriodStart time.Time `json:"current_period_start"` // identifies the period kept +} +``` + +- [ ] **Step 2: Define the metrics shim** + +Create `internal/feat/idleunsub/metrics.go`: + +```go +package idleunsub + +import ( + "context" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +var ( + meter = otel.Meter("idleunsub") + cancelFired, _ = meter.Int64Counter("idleunsub.cancel.fired") + cancelSkipped, _ = meter.Int64Counter("idleunsub.cancel.skipped") + cancelError, _ = meter.Int64Counter("idleunsub.cancel.error") + cacheDrift, _ = meter.Int64Counter("idleunsub.cancel.cache_drift_corrected") + reverseLink, _ = meter.Int64Counter("idleunsub.reverse.link") + reverseAct, _ = meter.Int64Counter("idleunsub.reverse.activity") + emailEnqueue, _ = meter.Int64Counter("idleunsub.email.enqueue") +) + +func mCancelFired(ctx context.Context, subID string) { + cancelFired.Add(ctx, 1, metric.WithAttributes(attribute.String("sub_id", subID))) +} +func mCancelSkipped(ctx context.Context, reason string) { + cancelSkipped.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason))) +} +func mCancelError(ctx context.Context, reason string) { + cancelError.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason))) +} +func mCacheDriftCorrected(ctx context.Context, subID string) { + cacheDrift.Add(ctx, 1, metric.WithAttributes(attribute.String("sub_id", subID))) +} +func mReverseLink(ctx context.Context, subID string) { + reverseLink.Add(ctx, 1, metric.WithAttributes(attribute.String("sub_id", subID))) +} +func mReverseActivity(ctx context.Context, subID string) { + reverseAct.Add(ctx, 1, metric.WithAttributes(attribute.String("sub_id", subID))) +} +func mEmailEnqueue(ctx context.Context, kind, status string) { + emailEnqueue.Add(ctx, 1, metric.WithAttributes( + attribute.String("kind", kind), + attribute.String("status", status))) +} +``` + +- [ ] **Step 3: Define the Service struct + StripeClient interface** + +Create `internal/feat/idleunsub/idleunsub.go`: + +```go +package idleunsub + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + stripe "github.com/stripe/stripe-go/v82" + + "github.com/btc/drill/internal/db" + "github.com/btc/drill/internal/email" +) + +// StripeClient is the surface idleunsub needs from Stripe. Production wires +// this to wrappers around stripe-go's package-level functions; tests inject +// an in-memory fake. +type StripeClient interface { + GetSubscription(ctx context.Context, id string) (*stripe.Subscription, error) + UpdateSubscriptionCancel(ctx context.Context, id string, cancelAtPeriodEnd bool, idempotencyKey string) (*stripe.Subscription, error) +} + +// Service owns the cancel/keep/auto-reverse logic. +type Service struct { + pool *pgxpool.Pool + stripe StripeClient + mailer email.Sender + signer *TokenSigner + now func() time.Time + log *slog.Logger +} + +func NewService(pool *pgxpool.Pool, sc StripeClient, m email.Sender, sn *TokenSigner, log *slog.Logger) *Service { + return &Service{pool: pool, stripe: sc, mailer: m, signer: sn, now: time.Now, log: log} +} +``` + +- [ ] **Step 4: Implement `HandleInvoiceUpcoming`** + +Append to `idleunsub.go`: + +```go +// HandleInvoiceUpcoming evaluates the trigger rule. Idempotent: safe to call +// multiple times for the same Stripe event. +func (s *Service) HandleInvoiceUpcoming(ctx context.Context, event stripe.Event) error { + // Extract subscription ID from the invoice.upcoming event payload. + subID := event.GetObjectValue("subscription") + if subID == "" { + s.log.Warn("invoice.upcoming missing subscription", "event_id", event.ID) + return nil + } + + // 1. Fetch the subscription (authoritative source for current period and status). + sub, err := s.stripe.GetSubscription(ctx, subID) + if err != nil { + mCancelError(ctx, "stripe_get_failed") + return fmt.Errorf("get subscription %s: %w", subID, err) + } + + // 2. Status / state early returns. + if sub.Status != stripe.SubscriptionStatusActive { + mCancelSkipped(ctx, "non_active_status") + return nil + } + if sub.CancelAtPeriodEnd { + mCancelSkipped(ctx, "already_canceled") + return nil + } + if len(sub.Items.Data) == 0 { + s.log.Warn("subscription has no items", "sub_id", subID) + return nil + } + item := sub.Items.Data[0] + periodStart := time.Unix(item.CurrentPeriodStart, 0).UTC() + periodEnd := time.Unix(item.CurrentPeriodEnd, 0).UTC() + if item.Price == nil || item.Price.Recurring == nil { + s.log.Warn("subscription item missing price.recurring", "sub_id", subID) + return nil + } + threshold := subtractInterval(periodStart, item.Price.Recurring.Interval) + + // 3. Look up our user by stripe customer ID. + custID := sub.Customer.ID + q := db.New(s.pool) + user, err := q.GetUserByStripeCustomer(ctx, pgxText(custID)) + if err != nil { + return fmt.Errorf("get user by stripe customer %s: %w", custID, err) + } + + // 4. Begin the cancel transaction. + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + mCancelError(ctx, "db_begin_failed") + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck // rollback after commit is a no-op + + qtx := db.New(tx) + + // 4a. Per-user mutex. + if _, err := qtx.LockUserForSubDecision(ctx, user.ID); err != nil { + mCancelError(ctx, "db_lock_failed") + return fmt.Errorf("lock user: %w", err) + } + + // 4b. Webhook event dedup (retry-storm protection). + claimed, err := qtx.TryClaimWebhookEvent(ctx, db.TryClaimWebhookEventParams{ + EventID: event.ID, EventType: string(event.Type), + }) + if err == pgx.ErrNoRows { + mCancelSkipped(ctx, "duplicate_event") + return nil + } + if err != nil { + mCancelError(ctx, "db_dedup_failed") + return fmt.Errorf("claim webhook event: %w", err) + } + _ = claimed // we got the row; proceed + + // 4c. Period-keyed dedup queries. + mostRecent, err := qtx.GetMostRecentKeptOrCanceledForPeriod(ctx, + db.GetMostRecentKeptOrCanceledForPeriodParams{ + UserID: user.ID, SubID: subID, PeriodStart: pgxTime(periodStart), + }) + if err != nil && err != pgx.ErrNoRows { + mCancelError(ctx, "db_dedup_query_failed") + return fmt.Errorf("most recent decision: %w", err) + } + if mostRecent == "subscription_kept" { + mCancelSkipped(ctx, "already_kept_this_period") + return nil + } + hasCanceled, err := qtx.HasAutoCanceledThisPeriod(ctx, + db.HasAutoCanceledThisPeriodParams{ + UserID: user.ID, SubID: subID, PeriodStart: pgxTime(periodStart), + }) + if err != nil { + mCancelError(ctx, "db_dedup_query_failed") + return fmt.Errorf("has auto canceled: %w", err) + } + if hasCanceled { + mCancelSkipped(ctx, "already_canceled_this_period") + return nil + } + + // 4d. Read state and compute trigger. + lastActive, err := qtx.GetUserLastActive(ctx, user.ID) + if err != nil { + return fmt.Errorf("get last active: %w", err) + } + if !lastActive.Valid { + mCancelSkipped(ctx, "no_activity_history") + return nil + } + if !lastActive.Time.Before(threshold) { + mCancelSkipped(ctx, "active_in_window") + return nil + } + if periodStart.Before(user.IdleEligibleAfter) { + mCancelSkipped(ctx, "grandfathered") + return nil + } + + // 5. Trigger fires: insert audit row + cache update inside the TX. + mdJSON, err := marshalCancelMetadata(subID, event.ID, periodStart, periodEnd) + if err != nil { + return fmt.Errorf("marshal cancel metadata: %w", err) + } + if err := qtx.InsertSubscriptionAutoCanceledEvent(ctx, + db.InsertSubscriptionAutoCanceledEventParams{ + UserID: user.ID, Metadata: mdJSON, + }); err != nil { + return fmt.Errorf("insert event row: %w", err) + } + if err := qtx.SetUserAutoCancelState(ctx, db.SetUserAutoCancelStateParams{ + ID: user.ID, SubCurrentPeriodStart: pgxTime(periodStart), + }); err != nil { + return fmt.Errorf("set cache: %w", err) + } + if err := tx.Commit(ctx); err != nil { + mCancelError(ctx, "db_commit_failed") + return fmt.Errorf("commit: %w", err) + } + + // 6. Stripe call OUTSIDE the transaction. Idempotency key keeps retries safe. + if _, err := s.stripe.UpdateSubscriptionCancel(ctx, subID, true, event.ID); err != nil { + mCancelError(ctx, "stripe_update_failed") + s.log.Error("stripe update failed after commit", + "sub_id", subID, "event_id", event.ID, "err", err) + // Cache is now ahead of Stripe. AutoReverse re-checks Stripe state, + // so this drift will self-heal on the user's next authed request. + return fmt.Errorf("stripe update: %w", err) + } + + mCancelFired(ctx, subID) + + // 7. Enqueue cancel email. + if err := s.enqueueCancelEmail(ctx, user, subID, periodEnd); err != nil { + mEmailEnqueue(ctx, "cancel", "error") + s.log.Error("cancel email enqueue failed", "user_id", user.ID, "err", err) + } else { + mEmailEnqueue(ctx, "cancel", "ok") + } + return nil +} + +// subtractInterval returns t minus one Stripe billing interval. +func subtractInterval(t time.Time, interval stripe.PriceRecurringInterval) time.Time { + switch interval { + case stripe.PriceRecurringIntervalDay: + return t.AddDate(0, 0, -1) + case stripe.PriceRecurringIntervalWeek: + return t.AddDate(0, 0, -7) + case stripe.PriceRecurringIntervalMonth: + return t.AddDate(0, -1, 0) + case stripe.PriceRecurringIntervalYear: + return t.AddDate(-1, 0, 0) + default: + return t.AddDate(0, -1, 0) // safe default + } +} + +// Helpers (placeholders — implement based on existing project conventions +// for pgtype.Text / pgtype.Timestamptz): +func pgxText(s string) pgtype.Text { return pgtype.Text{String: s, Valid: true} } +func pgxTime(t time.Time) pgtype.Timestamptz { + return pgtype.Timestamptz{Time: t, Valid: true} +} +``` + +(Add the `import "github.com/jackc/pgx/v5/pgtype"` to the import block.) + +- [ ] **Step 5: Implement `marshalCancelMetadata` and email enqueue stub** + +Append to `idleunsub.go`: + +```go +import "encoding/json" + +func marshalCancelMetadata(subID, eventID string, start, end time.Time) ([]byte, error) { + return json.Marshal(cancelMetadata{ + SubscriptionID: subID, + StripeEventID: eventID, + CurrentPeriodStart: start.UTC(), + CurrentPeriodEnd: end.UTC(), + }) +} + +// enqueueCancelEmail composes and enqueues the cancel email. +// Implementation lands in Task 9 alongside the templates; for now this is a +// stub so the cancel path compiles. Task 9 replaces it with the real impl. +func (s *Service) enqueueCancelEmail(ctx context.Context, user db.User, subID string, periodEnd time.Time) error { + return nil // placeholder; replaced in Task 9 +} +``` + +- [ ] **Step 6: Add a sqlc query needed above (`GetUserByStripeCustomer`)** + +This may already exist; if so, skip. Otherwise add to `sql/queries/users.sql`: + +```sql +-- name: GetUserByStripeCustomer :one +SELECT * FROM users WHERE stripe_customer_id = $1 AND deleted_at IS NULL; +``` + +Run `sqlc generate` after. + +- [ ] **Step 7: Write integration test for happy path** + +Create `internal/feat/idleunsub/stripestub_test.go`: + +```go +package idleunsub_test + +import ( + "context" + "testing" + + stripe "github.com/stripe/stripe-go/v82" + + "github.com/btc/drill/internal/feat/idleunsub" +) + +// fakeStripe is an in-memory StripeClient for tests. +type fakeStripe struct { + subs map[string]*stripe.Subscription + updateCalls []updateCall + updateErr error +} + +type updateCall struct { + ID string + CancelAtPeriodEnd bool + IdempotencyKey string +} + +func (f *fakeStripe) GetSubscription(ctx context.Context, id string) (*stripe.Subscription, error) { + if s, ok := f.subs[id]; ok { + return s, nil + } + return nil, stripe.ErrInvalidRequest +} + +func (f *fakeStripe) UpdateSubscriptionCancel(ctx context.Context, id string, cap bool, key string) (*stripe.Subscription, error) { + f.updateCalls = append(f.updateCalls, updateCall{id, cap, key}) + if f.updateErr != nil { + return nil, f.updateErr + } + if s, ok := f.subs[id]; ok { + s.CancelAtPeriodEnd = cap + return s, nil + } + return nil, stripe.ErrInvalidRequest +} + +var _ idleunsub.StripeClient = (*fakeStripe)(nil) +``` + +Then `internal/feat/idleunsub/idleunsub_test.go`: + +```go +package idleunsub_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + stripe "github.com/stripe/stripe-go/v82" + + "github.com/btc/drill/internal/backendtest" + "github.com/btc/drill/internal/feat/idleunsub" +) + +func TestHandleInvoiceUpcoming_FiresCancel_WhenIdleTwoPeriods(t *testing.T) { + t.Parallel() + pgEnv := backendtest.NewPGEnv(t) // existing helper that gives a fresh DB + b := pgEnv.NewBackend(t) + ctx := context.Background() + userID := backendtest.SeedUser(t, b) + + // Set the user's Stripe customer ID and idle_eligible_after far in the past. + _, err := b.Pool().Exec(ctx, ` + UPDATE users + SET stripe_customer_id = $1, + idle_eligible_after = NOW() - INTERVAL '6 months', + plan = 'pro' + WHERE id = $2`, "cus_test", userID) + require.NoError(t, err) + + // Force MAX(last_active) to far in the past (3 months ago). + _, err = b.Pool().Exec(ctx, ` + UPDATE auth_sessions SET last_active = NOW() - INTERVAL '3 months' + WHERE user_id = $1`, userID) + require.NoError(t, err) + + // Fake Stripe sub: monthly, current period started 23 days ago. + now := time.Now().UTC().Truncate(time.Second) + periodStart := now.AddDate(0, 0, -23) + periodEnd := periodStart.AddDate(0, 1, 0) + subID := "sub_test" + fake := &fakeStripe{ + subs: map[string]*stripe.Subscription{ + subID: { + ID: subID, + Status: stripe.SubscriptionStatusActive, + CancelAtPeriodEnd: false, + Customer: &stripe.Customer{ID: "cus_test"}, + Items: &stripe.SubscriptionItemList{Data: []*stripe.SubscriptionItem{{ + CurrentPeriodStart: periodStart.Unix(), + CurrentPeriodEnd: periodEnd.Unix(), + Price: &stripe.Price{Recurring: &stripe.PriceRecurring{ + Interval: stripe.PriceRecurringIntervalMonth, + }}, + }}}, + }, + }, + } + + svc := idleunsub.NewService(b.Pool(), fake, &nullMailer{}, nil, b.Logger()) + + event := stripe.Event{ + ID: "evt_1", + Type: "invoice.upcoming", + Data: &stripe.EventData{Object: map[string]interface{}{ + "subscription": subID, + }}, + } + + require.NoError(t, svc.HandleInvoiceUpcoming(ctx, event)) + + // Stripe Update was called with cancel_at_period_end=true. + require.Len(t, fake.updateCalls, 1) + require.True(t, fake.updateCalls[0].CancelAtPeriodEnd) + require.Equal(t, "evt_1", fake.updateCalls[0].IdempotencyKey) + + // user_events row exists. + var count int + err = b.Pool().QueryRow(ctx, ` + SELECT COUNT(*) FROM user_events + WHERE user_id = $1 AND event_type = 'subscription_auto_canceled'`, + userID).Scan(&count) + require.NoError(t, err) + require.Equal(t, 1, count) + + // Cache flags flipped. + var cap, isAuto bool + err = b.Pool().QueryRow(ctx, + `SELECT sub_cancel_at_period_end, sub_cancel_is_auto FROM users WHERE id = $1`, + userID).Scan(&cap, &isAuto) + require.NoError(t, err) + require.True(t, cap) + require.True(t, isAuto) + + // Webhook dedup row claimed. + err = b.Pool().QueryRow(ctx, + `SELECT COUNT(*) FROM stripe_webhook_dedup WHERE event_id = 'evt_1'`).Scan(&count) + require.NoError(t, err) + require.Equal(t, 1, count) +} + +type nullMailer struct{} +func (nullMailer) Send(ctx context.Context, m email.Message) error { return nil } +``` + +(`backendtest.NewPGEnv` may need to be added if not present; if the project uses a different convention for spinning up a test DB, mirror what `internal/backend/billing_test.go` uses — typically `pg.NewBackend(t)`.) + +- [ ] **Step 8: Run the test to verify it passes** + +```bash +go test ./internal/feat/idleunsub/ -race -count=1 -v +``` + +Expected: PASS. + +- [ ] **Step 9: Add the early-return tests** + +Append to `idleunsub_test.go`: + +```go +func TestHandleInvoiceUpcoming_SkipsTrialing(t *testing.T) { + // Same setup but sub.Status = trialing → no Stripe Update call, no event row. + // Pattern: copy TestHandleInvoiceUpcoming_FiresCancel_WhenIdleTwoPeriods, + // change sub.Status, assert len(fake.updateCalls) == 0 and no user_events row. + // (Spec §3 early returns.) +} + +func TestHandleInvoiceUpcoming_SkipsAlreadyCanceled(t *testing.T) { + // sub.CancelAtPeriodEnd = true at fetch time → skip. +} + +func TestHandleInvoiceUpcoming_SkipsGrandfathered(t *testing.T) { + // idle_eligible_after = NOW() + 1 month (future) → skip even when idle. +} + +func TestHandleInvoiceUpcoming_DedupesRetryStorm(t *testing.T) { + // Call twice with same event.ID → second call is a no-op (one update call total). +} + +func TestHandleInvoiceUpcoming_DedupesPostKeep(t *testing.T) { + // Insert a subscription_kept user_events row for this period BEFORE calling + // HandleInvoiceUpcoming with a different event.ID → no re-cancel. +} + +func TestHandleInvoiceUpcoming_NoActivityHistory(t *testing.T) { + // User with no auth_sessions rows → MAX(last_active) is NULL → defensive skip. + // (Spec §8.1: "should be unreachable for a Pro user, but defensive".) +} + +func TestHandleInvoiceUpcoming_NotIdleStill(t *testing.T) { + // Activity within current period → trigger predicate false → skip. +} + +func TestHandleInvoiceUpcoming_FirstPeriodGrace(t *testing.T) { + // User just signed up; threshold predates their first activity → no cancel. +} +``` + +Implement each test by mirroring the happy-path test with the relevant precondition adjusted. Run all of them: + +```bash +go test ./internal/feat/idleunsub/ -race -count=1 -v +``` + +Expected: all PASS. + +- [ ] **Step 10: Commit** + +```bash +git add internal/feat/idleunsub/idleunsub.go \ + internal/feat/idleunsub/metadata.go \ + internal/feat/idleunsub/metrics.go \ + internal/feat/idleunsub/idleunsub_test.go \ + internal/feat/idleunsub/stripestub_test.go \ + sql/queries/users.sql internal/db/users.sql.go +git commit -m "idleunsub: HandleInvoiceUpcoming + transactional cancel decision" +``` + +--- + +### Task 8: `KeepSubscription` and `AutoReverse` + +**Files:** +- Modify: `internal/feat/idleunsub/idleunsub.go` (add two methods) +- Modify: `internal/feat/idleunsub/idleunsub_test.go` (new tests) + +- [ ] **Step 1: Implement `KeepSubscription`** + +Append to `idleunsub.go`: + +```go +// KeepSubscription reverses cancel_at_period_end after a verified, single-use +// keep-link click. The endpoint is responsible for verifying the token AND +// claiming single-use BEFORE invoking this method. Refuses to act if the +// stored cache says the cancel is NOT our auto-cancel (manual portal cancel). +func (s *Service) KeepSubscription(ctx context.Context, claims KeepTokenClaims) error { + q := db.New(s.pool) + gates, err := q.GetUserAutoCancelGates(ctx, claims.UserID) + if err != nil { + return fmt.Errorf("read gates: %w", err) + } + if !gates.SubCancelAtPeriodEnd || !gates.SubCancelIsAuto { + // Either the cancel was already reversed, or it's a manual portal cancel. + // Don't touch Stripe; render confirmation page idempotently. + return nil + } + updated, err := s.stripe.UpdateSubscriptionCancel(ctx, claims.SubscriptionID, false, "") + if err != nil { + return fmt.Errorf("stripe reverse: %w", err) + } + periodStart := time.Unix(updated.Items.Data[0].CurrentPeriodStart, 0).UTC() + + if err := q.ClearUserAutoCancelState(ctx, db.ClearUserAutoCancelStateParams{ + ID: claims.UserID, SubCurrentPeriodStart: pgxTime(periodStart), + }); err != nil { + return fmt.Errorf("clear gates: %w", err) + } + + mdJSON, err := marshalKeptMetadata(claims.SubscriptionID, "link", periodStart) + if err != nil { + return fmt.Errorf("marshal kept metadata: %w", err) + } + if err := q.InsertSubscriptionKeptEvent(ctx, db.InsertSubscriptionKeptEventParams{ + UserID: claims.UserID, Metadata: mdJSON, + }); err != nil { + return fmt.Errorf("insert kept event: %w", err) + } + + mReverseLink(ctx, claims.SubscriptionID) + + if err := s.enqueueKeptEmail(ctx, claims.UserID, claims.SubscriptionID, claims.CurrentPeriodEnd); err != nil { + mEmailEnqueue(ctx, "kept", "error") + s.log.Error("kept email enqueue failed", "user_id", claims.UserID, "err", err) + } else { + mEmailEnqueue(ctx, "kept", "ok") + } + return nil +} + +func marshalKeptMetadata(subID, via string, periodStart time.Time) ([]byte, error) { + return json.Marshal(keptMetadata{ + SubscriptionID: subID, + Via: via, + CurrentPeriodStart: periodStart.UTC(), + }) +} + +func (s *Service) enqueueKeptEmail(ctx context.Context, userID uuid.UUID, subID string, periodEnd time.Time) error { + return nil // placeholder; Task 9 +} +``` + +- [ ] **Step 2: Implement `AutoReverse`** + +Append: + +```go +// AutoReverse is invoked by the auth middleware when an authenticated request +// arrives from a user whose cached gates are SubCancelAtPeriodEnd && SubCancelIsAuto. +// The current request itself is the activity signal; we do NOT re-read +// last_active (would race against TouchAuthSession). +// +// AutoReverse verifies Stripe state before acting. If Stripe says the sub is +// NOT canceled (cache drift from a prior partial failure), AutoReverse silently +// clears the cache and returns without sending email or inserting an event row. +func (s *Service) AutoReverse(ctx context.Context, userID uuid.UUID) error { + q := db.New(s.pool) + gates, err := q.GetUserAutoCancelGates(ctx, userID) + if err != nil { + return fmt.Errorf("read gates: %w", err) + } + if !gates.SubCancelAtPeriodEnd || !gates.SubCancelIsAuto || !gates.StripeSubscriptionID.Valid { + return nil // cache says off, or no sub — nothing to do + } + subID := gates.StripeSubscriptionID.String + + // Verify Stripe state — handles the partial-failure window where our cache + // says canceled but Stripe never confirmed. + stripeSub, err := s.stripe.GetSubscription(ctx, subID) + if err != nil { + return fmt.Errorf("get sub: %w", err) + } + if !stripeSub.CancelAtPeriodEnd { + // Cache drift. Silently correct and return without email/event. + mCacheDriftCorrected(ctx, subID) + periodStart := time.Unix(stripeSub.Items.Data[0].CurrentPeriodStart, 0).UTC() + if err := q.ClearUserAutoCancelState(ctx, db.ClearUserAutoCancelStateParams{ + ID: userID, SubCurrentPeriodStart: pgxTime(periodStart), + }); err != nil { + return fmt.Errorf("clear cache (drift): %w", err) + } + // Note: ClearUserAutoCancelState sets pending_kept_banner=true even on drift + // correction. That's wrong for this case — we don't want to celebrate a + // reversal that didn't happen. Fix: use a dedicated clear-without-banner + // query, or split ClearUserAutoCancelState into two variants. + if err := q.ClearKeptBanner(ctx, userID); err != nil { + return fmt.Errorf("clear banner (drift): %w", err) + } + return nil + } + + // Real reversal. + updated, err := s.stripe.UpdateSubscriptionCancel(ctx, subID, false, "") + if err != nil { + return fmt.Errorf("stripe reverse: %w", err) + } + periodStart := time.Unix(updated.Items.Data[0].CurrentPeriodStart, 0).UTC() + periodEnd := time.Unix(updated.Items.Data[0].CurrentPeriodEnd, 0).UTC() + + if err := q.ClearUserAutoCancelState(ctx, db.ClearUserAutoCancelStateParams{ + ID: userID, SubCurrentPeriodStart: pgxTime(periodStart), + }); err != nil { + return fmt.Errorf("clear gates: %w", err) + } + + mdJSON, err := marshalKeptMetadata(subID, "auto_activity", periodStart) + if err != nil { + return fmt.Errorf("marshal kept metadata: %w", err) + } + if err := q.InsertSubscriptionKeptEvent(ctx, db.InsertSubscriptionKeptEventParams{ + UserID: userID, Metadata: mdJSON, + }); err != nil { + return fmt.Errorf("insert kept event: %w", err) + } + + mReverseActivity(ctx, subID) + + if err := s.enqueueKeptEmail(ctx, userID, subID, periodEnd); err != nil { + mEmailEnqueue(ctx, "kept", "error") + s.log.Error("kept email enqueue failed", "user_id", userID, "err", err) + } else { + mEmailEnqueue(ctx, "kept", "ok") + } + return nil +} +``` + +- [ ] **Step 3: Write tests** + +Append to `idleunsub_test.go`: + +```go +func TestKeepSubscription_HappyPath(t *testing.T) { + // Seed an auto-canceled state. Call KeepSubscription. Assert: + // - Stripe Update called with false + // - cache flipped (sub_cancel_at_period_end=false, sub_cancel_is_auto=false) + // - subscription_kept user_events row inserted with current_period_start + // - pending_kept_banner = true +} + +func TestKeepSubscription_RefusesManualCancel(t *testing.T) { + // Seed sub_cancel_at_period_end=true, sub_cancel_is_auto=FALSE. + // Call KeepSubscription. Assert: no Stripe call, no event row, no cache change. +} + +func TestKeepSubscription_Idempotent(t *testing.T) { + // Call twice. Second call is a no-op (cache already cleared after first). + // One Stripe call, one event row, one email enqueued. +} + +func TestAutoReverse_GateOff(t *testing.T) { + // User with sub_cancel_at_period_end=false → AutoReverse is no-op. + // (No Stripe call, no event row.) +} + +func TestAutoReverse_HappyPath(t *testing.T) { + // Seed auto-canceled state, Stripe state agrees (CancelAtPeriodEnd=true). + // Call AutoReverse. Assert: Stripe Update called, cache cleared, banner set, + // email enqueued, subscription_kept event with via='auto_activity'. +} + +func TestAutoReverse_CacheDriftCorrected(t *testing.T) { + // Seed auto-canceled state in DB, but Stripe says CancelAtPeriodEnd=false + // (simulating partial-failure window). + // Call AutoReverse. Assert: NO Stripe Update call, NO email, + // NO subscription_kept event, cache silently cleared, banner NOT set. +} + +func TestAutoReverse_RefusesManualCancel(t *testing.T) { + // Seed sub_cancel_at_period_end=true but sub_cancel_is_auto=false. + // AutoReverse must be a no-op — we don't touch a manual portal cancel. +} +``` + +Implement each test by setting up the appropriate state and calling the methods. + +- [ ] **Step 4: Run tests** + +```bash +go test ./internal/feat/idleunsub/ -race -count=1 -v +``` + +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/feat/idleunsub/idleunsub.go internal/feat/idleunsub/idleunsub_test.go +git commit -m "idleunsub: KeepSubscription + AutoReverse with cache-drift correction" +``` + +--- + +### Task 9: Email composition + templates + +**Files:** +- Create: `internal/feat/idleunsub/email.go` +- Create: `internal/feat/idleunsub/templates/cancel.html.tmpl` +- Create: `internal/feat/idleunsub/templates/cancel.txt.tmpl` +- Create: `internal/feat/idleunsub/templates/kept.html.tmpl` +- Create: `internal/feat/idleunsub/templates/kept.txt.tmpl` +- Create: `internal/feat/idleunsub/email_test.go` +- Modify: `internal/feat/idleunsub/idleunsub.go` (replace the placeholder enqueue functions) + +- [ ] **Step 1: Create the cancel templates** + +`internal/feat/idleunsub/templates/cancel.txt.tmpl`: + +``` +Subject: We won't charge you for the next period + +Hi {{.FirstName}}, + +We noticed you haven't been around Sabermatic in the last two billing +periods, so we've stopped your auto-renewal. You'll keep access through +{{.CurrentPeriodEnd}} — we won't charge you for the next period. + +If you'd like to keep your subscription active, one click does it: + + {{.KeepLink}} + +If you're done for now, no action needed. We'll be here whenever you +want to come back. + +— Sabermatic +``` + +`cancel.html.tmpl`: same content, marked up minimally (one `

` per paragraph; `Keep my subscription` for the link). Keep it simple — no styling beyond what's in the existing wrapper template (`internal/email/templates/wrapper.html`). + +- [ ] **Step 2: Create the kept templates** + +`internal/feat/idleunsub/templates/kept.txt.tmpl`: + +``` +Subject: Your subscription is still active + +Hi {{.FirstName}}, + +You're all set — your Sabermatic subscription will renew normally on +{{.NextRenewalDate}}. + +Welcome back. + +— Sabermatic +``` + +`kept.html.tmpl`: same, minimal markup. + +- [ ] **Step 3: Implement `email.go`** + +```go +package idleunsub + +import ( + "bytes" + "context" + "embed" + "fmt" + "html/template" + "text/template" + "time" + + "github.com/btc/drill/internal/email" +) + +//go:embed templates/* +var templatesFS embed.FS + +type cancelEmailData struct { + FirstName string + CurrentPeriodEnd string // formatted like "March 1, 2026" + KeepLink string +} + +type keptEmailData struct { + FirstName string + NextRenewalDate string +} + +// composeCancelEmail renders the cancel email and returns an email.Message. +func composeCancelEmail(toEmail, firstName, keepURL string, periodEnd time.Time) (email.Message, error) { + data := cancelEmailData{ + FirstName: firstName, + CurrentPeriodEnd: periodEnd.Format("January 2, 2006"), + KeepLink: keepURL, + } + return renderEmail("cancel", toEmail, "We won't charge you for the next period", data) +} + +func composeKeptEmail(toEmail, firstName string, nextRenewal time.Time) (email.Message, error) { + data := keptEmailData{ + FirstName: firstName, + NextRenewalDate: nextRenewal.Format("January 2, 2006"), + } + return renderEmail("kept", toEmail, "Your subscription is still active", data) +} + +func renderEmail(name, to, subject string, data interface{}) (email.Message, error) { + htmlT, err := template.ParseFS(templatesFS, "templates/"+name+".html.tmpl") + if err != nil { + return email.Message{}, fmt.Errorf("parse %s.html: %w", name, err) + } + txtT, err := texttemplate.ParseFS(templatesFS, "templates/"+name+".txt.tmpl") + if err != nil { + return email.Message{}, fmt.Errorf("parse %s.txt: %w", name, err) + } + var htmlBuf, txtBuf bytes.Buffer + if err := htmlT.Execute(&htmlBuf, data); err != nil { + return email.Message{}, fmt.Errorf("execute %s.html: %w", name, err) + } + if err := txtT.Execute(&txtBuf, data); err != nil { + return email.Message{}, fmt.Errorf("execute %s.txt: %w", name, err) + } + return email.Message{ + To: to, Subject: subject, + HTMLBody: htmlBuf.String(), TextBody: txtBuf.String(), + }, nil +} +``` + +(Note: the import block needs both `html/template` and `text/template` — alias them as the snippet shows: `template` for HTML, `texttemplate` for text. If your project already has a different rendering helper, mirror it.) + +- [ ] **Step 4: Replace the placeholder enqueue functions in `idleunsub.go`** + +```go +// Replace `enqueueCancelEmail` placeholder with: +func (s *Service) enqueueCancelEmail(ctx context.Context, user db.User, subID string, periodEnd time.Time) error { + keepURL, err := s.buildKeepURL(user.ID, subID, periodEnd) + if err != nil { + return fmt.Errorf("build keep url: %w", err) + } + msg, err := composeCancelEmail(user.Email, user.FirstName(), keepURL, periodEnd) + if err != nil { + return fmt.Errorf("compose cancel: %w", err) + } + return s.mailer.Send(ctx, msg) +} + +// Replace `enqueueKeptEmail`: +func (s *Service) enqueueKeptEmail(ctx context.Context, userID uuid.UUID, subID string, periodEnd time.Time) error { + q := db.New(s.pool) + user, err := q.GetUser(ctx, userID) + if err != nil { + return fmt.Errorf("get user: %w", err) + } + msg, err := composeKeptEmail(user.Email, user.FirstName(), periodEnd) + if err != nil { + return fmt.Errorf("compose kept: %w", err) + } + return s.mailer.Send(ctx, msg) +} + +// buildKeepURL constructs the public keep-link URL with a signed token. +func (s *Service) buildKeepURL(userID uuid.UUID, subID string, periodEnd time.Time) (string, error) { + if s.signer == nil { + return "", fmt.Errorf("token signer not configured") + } + periodEnd = periodEnd.UTC().Truncate(time.Second) + tok := s.signer.Sign(KeepTokenClaims{ + UserID: userID, + SubscriptionID: subID, + Action: "keep_subscription", + CurrentPeriodEnd: periodEnd, + IssuedAt: s.now().Unix(), + ExpiresAt: periodEnd.Unix(), + }) + return fmt.Sprintf("https://sabermatic.dev/sub/keep?t=%s", tok), nil +} +``` + +(`db.User.FirstName()` may not exist. If display_name is "Jane Doe", split on first space; or use display_name directly. Inspect `internal/db/models.go` for the User struct and pick a sensible field.) + +- [ ] **Step 5: Write tests** + +`internal/feat/idleunsub/email_test.go`: + +```go +package idleunsub + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestComposeCancelEmail_Renders(t *testing.T) { + t.Parallel() + end := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + msg, err := composeCancelEmail("user@example.com", "Jane", + "https://sabermatic.dev/sub/keep?t=abc.def", end) + require.NoError(t, err) + require.Equal(t, "user@example.com", msg.To) + require.Contains(t, msg.TextBody, "Hi Jane,") + require.Contains(t, msg.TextBody, "March 1, 2026") + require.Contains(t, msg.TextBody, "https://sabermatic.dev/sub/keep?t=abc.def") + require.Contains(t, msg.HTMLBody, "Jane") +} + +func TestComposeKeptEmail_Renders(t *testing.T) { + t.Parallel() + end := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + msg, err := composeKeptEmail("user@example.com", "Jane", end) + require.NoError(t, err) + require.Contains(t, msg.TextBody, "March 1, 2026") +} +``` + +- [ ] **Step 6: Run tests** + +```bash +go test ./internal/feat/idleunsub/ -race -count=1 -v +``` + +Expected: all PASS, including the previously written cancel/keep happy-path tests now exercising real email composition. + +- [ ] **Step 7: Commit** + +```bash +git add internal/feat/idleunsub/email.go \ + internal/feat/idleunsub/email_test.go \ + internal/feat/idleunsub/templates/ \ + internal/feat/idleunsub/idleunsub.go +git commit -m "idleunsub: email composition + templates" +``` + +--- + +## Phase 4: Integration + +### Task 10: Stripe webhook integration + +**Files:** +- Modify: `internal/backend/billing.go` (add `case "invoice.upcoming"` and `case "customer.subscription.created"`; extend `handleSubscriptionUpdated` to call `SyncSubStateFromWebhook`; add `handleSubscriptionDeleted` to call `ClearSubStateOnDeletion`) +- Modify: `internal/backend/backend.go` (wire `idleunsub.Service` into the `Backend` struct constructor) +- Test: `internal/backend/billing_test.go` (add tests for the new dispatch cases and the sync behavior) + +- [ ] **Step 1: Wire `idleunsub.Service` into `Backend`** + +In `internal/backend/backend.go`, find the `Backend` struct definition and add a field: + +```go +type Backend struct { + // ... existing fields ... + idleunsub *idleunsub.Service +} +``` + +Find the `NewBackend` (or equivalent) constructor and accept/construct the service. The exact signature depends on existing setup; add the parameter at the end of the existing argument list. In `cmd/.../main.go` (or wherever Backend is constructed), instantiate `idleunsub.NewService` with the pool, a real Stripe client wrapper, the email sender, the token signer, and the logger. + +- [ ] **Step 2: Add `case "invoice.upcoming"` to `HandleStripeWebhook`** + +In `internal/backend/billing.go`, find the switch: + +```go +switch event.Type { +case "checkout.session.completed": + return b.handleCheckoutCompleted(ctx, event) +case "invoice.paid": + return b.handleInvoicePaid(ctx, event) +case "invoice.upcoming": // NEW + return b.idleunsub.HandleInvoiceUpcoming(ctx, event) +case "customer.subscription.created": // NEW — same body as updated + return b.handleSubscriptionUpdated(ctx, event) +case "customer.subscription.deleted": + return b.handleSubscriptionDeleted(ctx, event) +case "customer.subscription.updated": + return b.handleSubscriptionUpdated(ctx, event) +default: + slog.Info("unhandled stripe event", "type", event.Type, "id", event.ID) + return nil +} +``` + +- [ ] **Step 3: Extend `handleSubscriptionUpdated` to call `SyncSubStateFromWebhook`** + +After the existing body (which calls `UpdatePlanByStripeCustomer`), parse the subscription from the event and call: + +```go +sub := &stripe.Subscription{} +if err := json.Unmarshal(event.Data.Raw, sub); err != nil { + return fmt.Errorf("unmarshal subscription: %w", err) +} +custID := sub.Customer.ID +user, err := b.queries.GetUserByStripeCustomer(ctx, pgxText(custID)) +if err != nil { + return fmt.Errorf("get user by customer: %w", err) +} +var periodStart pgtype.Timestamptz +if len(sub.Items.Data) > 0 { + periodStart = pgxTime(time.Unix(sub.Items.Data[0].CurrentPeriodStart, 0).UTC()) +} +if err := b.queries.SyncSubStateFromWebhook(ctx, db.SyncSubStateFromWebhookParams{ + ID: user.ID, + StripeSubscriptionID: pgxText(sub.ID), + SubCancelAtPeriodEnd: sub.CancelAtPeriodEnd, + SubCurrentPeriodStart: periodStart, +}); err != nil { + return fmt.Errorf("sync sub state: %w", err) +} +``` + +The order matters: keep existing plan-update behavior, then sync. Both can fail independently — if plan update succeeds and sync fails, retry of the webhook will re-attempt sync (idempotent UPDATE). + +**Note** the SyncSubStateFromWebhook query intentionally does NOT touch `sub_cancel_is_auto`. This means after our cancel path sets both flags, a subsequent webhook with `cancel_at_period_end=true` overwrites only `sub_cancel_at_period_end` (with the same `true` value) and `sub_cancel_is_auto` stays `true`. Round-trip correct. + +- [ ] **Step 4: Add `handleSubscriptionDeleted` body (or extend existing)** + +If a `handleSubscriptionDeleted` already exists, extend it; otherwise add: + +```go +func (b *Backend) handleSubscriptionDeleted(ctx context.Context, event stripe.Event) error { + sub := &stripe.Subscription{} + if err := json.Unmarshal(event.Data.Raw, sub); err != nil { + return fmt.Errorf("unmarshal subscription: %w", err) + } + user, err := b.queries.GetUserByStripeCustomer(ctx, pgxText(sub.Customer.ID)) + if err != nil { + return fmt.Errorf("get user by customer: %w", err) + } + return b.queries.ClearSubStateOnDeletion(ctx, user.ID) +} +``` + +- [ ] **Step 5: Tests** + +In `internal/backend/billing_test.go`, add: + +```go +func TestHandleSubscriptionUpdated_SyncsCacheColumns(t *testing.T) { + // Fire an updated event with cancel_at_period_end=true and a known + // current_period_start. Assert users row reflects both. +} + +func TestHandleSubscriptionUpdated_DoesNotTouchSubCancelIsAuto(t *testing.T) { + // Pre-set users.sub_cancel_is_auto=true. Fire updated event. + // Assert users.sub_cancel_is_auto is STILL true after sync. +} + +func TestHandleSubscriptionDeleted_ClearsCache(t *testing.T) { + // Pre-set all cache columns. Fire deleted event. + // Assert users plan='free' and all cache columns cleared. +} + +func TestWebhookSwitch_InvoiceUpcoming_RoutesToIdleunsub(t *testing.T) { + // Construct a minimal invoice.upcoming event. Call HandleStripeWebhook. + // Assert idleunsub.HandleInvoiceUpcoming was invoked (use a recorder + // version of Service or fake Stripe and check the side effects). +} + +func TestWebhookSwitch_SubscriptionCreated_RoutesToUpdated(t *testing.T) { + // Construct subscription.created event. Assert SyncSubStateFromWebhook ran. +} +``` + +- [ ] **Step 6: Run all tests** + +```bash +go test ./internal/... -race -count=1 -timeout=300s +``` + +Expected: all pass. + +- [ ] **Step 7: Commit** + +```bash +git add internal/backend/billing.go internal/backend/backend.go \ + internal/backend/billing_test.go cmd/ +git commit -m "billing: route invoice.upcoming and subscription.created to idleunsub" +``` + +--- + +### Task 11: `/sub/keep` endpoint + +**Files:** +- Create: `internal/handler/keep.go` +- Modify: `cmd/.../main.go` (or wherever HTTP routes are mounted) to register the new endpoint +- Create: `internal/handler/keep_test.go` + +- [ ] **Step 1: Implement `keep.go`** + +```go +package handler + +import ( + "errors" + "fmt" + "html/template" + "net/http" + + "github.com/btc/drill/internal/db" + "github.com/btc/drill/internal/feat/idleunsub" +) + +// KeepHandler serves GET /sub/keep?t=. Mounted publicly (no RequireAuth). +type KeepHandler struct { + signer *idleunsub.TokenSigner + svc *idleunsub.Service + pool *pgxpool.Pool // for the single-use claim query +} + +func NewKeepHandler(signer *idleunsub.TokenSigner, svc *idleunsub.Service, pool *pgxpool.Pool) *KeepHandler { + return &KeepHandler{signer: signer, svc: svc, pool: pool} +} + +func (h *KeepHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + tok := r.URL.Query().Get("t") + claims, err := h.signer.Verify(tok) + switch { + case errors.Is(err, idleunsub.ErrTokenInvalid): + renderInvalidLink(w) + return + case errors.Is(err, idleunsub.ErrTokenExpired): + renderPeriodEnded(w) + return + case err != nil: + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // Single-use claim. + tokenHash := sha256Of(tok) // returns []byte + claimed, err := db.New(h.pool).TryClaimKeepToken(ctx, db.TryClaimKeepTokenParams{ + TokenHash: tokenHash, + UserID: claims.UserID, + }) + if err != nil && err != pgx.ErrNoRows { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err == pgx.ErrNoRows { + // Token was already used — render the same confirmation page using + // claims.CurrentPeriodEnd (still in the verified token). + renderKeptConfirmation(w, claims.CurrentPeriodEnd) + return + } + _ = claimed + + if err := h.svc.KeepSubscription(r.Context(), claims); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + renderKeptConfirmation(w, claims.CurrentPeriodEnd) +} + +// Render functions (use html/template; templates can live in +// internal/handler/templates/ or be inline strings — match existing convention). +func renderInvalidLink(w http.ResponseWriter) { /* 400 */ } +func renderPeriodEnded(w http.ResponseWriter) { /* 200, "your sub already ended" */ } +func renderKeptConfirmation(w http.ResponseWriter, end time.Time) { /* 200, "you're all set, next renewal: " */ } +func sha256Of(s string) []byte { /* sha256 helper */ } +``` + +(Fill in the rendering helpers using minimal HTML; the spec doesn't require pretty pages but they should not be empty.) + +- [ ] **Step 2: Mount the route** + +In `cmd/.../main.go` (search for where other handlers like `/api/auth/...` are mounted), add: + +```go +mux.Handle("GET /sub/keep", handler.NewKeepHandler(tokenSigner, idleunsubSvc, pool)) +``` + +The route is mounted publicly — do NOT wrap in `RequireAuth`. The token IS the auth. + +- [ ] **Step 3: Tests** + +`internal/handler/keep_test.go`: + +```go +func TestKeep_HappyPath(t *testing.T) // valid token, first click → reverses + renders confirmation +func TestKeep_TamperedToken(t *testing.T) // bad signature → 400 +func TestKeep_ExpiredToken(t *testing.T) // expired but valid signature → 200 with period-ended page +func TestKeep_Replay(t *testing.T) // valid token used twice → second renders same page, no Stripe call +func TestKeep_RefusesManualCancel(t *testing.T) // sub_cancel_is_auto=false → no Stripe call (KeepSubscription handles this) +``` + +- [ ] **Step 4: Run tests** + +```bash +go test ./internal/handler/ -race -count=1 -v +``` + +- [ ] **Step 5: Commit** + +```bash +git add internal/handler/keep.go internal/handler/keep_test.go cmd/ +git commit -m "handler: GET /sub/keep with single-use token claim" +``` + +--- + +### Task 12: AutoReverse hook in `Backend.AuthenticateSession` + +**Files:** +- Modify: `internal/backend/backend.go:267-285` (add second fire-and-forget goroutine) +- Modify: `internal/backend/auth_test.go` (test that AutoReverse fires when gates are set) + +- [ ] **Step 1: Add the goroutine** + +In `Backend.AuthenticateSession`, immediately after the existing `TouchAuthSession` goroutine: + +```go +// Existing TouchAuthSession goroutine — leave unchanged. +go func() { + touchCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + queries.TouchAuthSession(touchCtx, row.ID) +}() + +// NEW: AutoReverse hook for users with our auto-cancel set. +if row.SubCancelAtPeriodEnd && row.SubCancelIsAuto { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := b.idleunsub.AutoReverse(ctx, row.UserID); err != nil { + b.log.Warn("auto-reverse failed", "user_id", row.UserID, "err", err) + } + }() +} +``` + +- [ ] **Step 2: Test** + +```go +func TestAuthenticateSession_FiresAutoReverseWhenGatesSet(t *testing.T) { + // Seed user with sub_cancel_at_period_end=true, sub_cancel_is_auto=true, + // and a fake Stripe that says CancelAtPeriodEnd=true. + // Authenticate. Wait briefly for the goroutine. Assert cache cleared, + // banner set, subscription_kept event row exists. +} + +func TestAuthenticateSession_DoesNotFireAutoReverseForManualCancel(t *testing.T) { + // sub_cancel_at_period_end=true but sub_cancel_is_auto=false. + // Authenticate. Assert no Stripe call, no event row, cache unchanged. +} +``` + +(Goroutine timing in tests: a brief sleep or a `require.Eventually` polling assertion.) + +- [ ] **Step 3: Run tests** + +```bash +go test ./internal/backend/ -race -count=1 -v +``` + +- [ ] **Step 4: Commit** + +```bash +git add internal/backend/backend.go internal/backend/auth_test.go +git commit -m "auth: fire idleunsub.AutoReverse on authed requests when gates set" +``` + +--- + +## Phase 5: RPC + Frontend + +### Task 13: User-info RPC + `AckKeptBanner` RPC + +**Files:** +- Modify: `pb/drill/v1/user.proto` (add `pending_kept_banner` to user-info response; add `AckKeptBanner` RPC method) +- Run: `buf generate` after proto changes +- Modify: `internal/rpc/user/server.go` (project the new field; implement `AckKeptBanner`) +- Modify: `web/src/pb/...` (regenerated by buf — should be automatic) +- Test: existing user-info RPC tests + a new `TestAckKeptBanner` test + +- [ ] **Step 1: Update the proto** + +In `pb/drill/v1/user.proto`, find the user-info message (search for `GetUser`, `WhoAmI`, or similar) and add: + +```proto +message User { + // ... existing fields ... + bool pending_kept_banner = 20; // tag picks the next free number +} + +// Add the new RPC: +service UserService { + // ... existing methods ... + rpc AckKeptBanner(AckKeptBannerRequest) returns (AckKeptBannerResponse); +} + +message AckKeptBannerRequest {} +message AckKeptBannerResponse {} +``` + +(Use the actual existing service / message names — search the file.) + +- [ ] **Step 2: Run buf generate** + +```bash +buf generate +``` + +Expected: regenerates `internal/pb/drill/v1/user.pb.go` and the corresponding ConnectRPC service files, plus `web/src/pb/drill/v1/user_pb.ts` and `_connect.ts`. + +- [ ] **Step 3: Project `pending_kept_banner` into the user-info response** + +In `internal/rpc/user/server.go`, find the user-info handler and add the field to the response: + +```go +return &drillv1.User{ + // ... existing fields ... + PendingKeptBanner: user.PendingKeptBanner, +}, nil +``` + +- [ ] **Step 4: Implement `AckKeptBanner`** + +```go +func (s *Server) AckKeptBanner(ctx context.Context, req *connect.Request[drillv1.AckKeptBannerRequest]) (*connect.Response[drillv1.AckKeptBannerResponse], error) { + user := auth.UserFromContext(ctx.Context()) + if user == nil { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("not authenticated")) + } + if err := s.queries.ClearKeptBanner(ctx.Context(), user.ID); err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("clear banner: %w", err)) + } + return connect.NewResponse(&drillv1.AckKeptBannerResponse{}), nil +} +``` + +- [ ] **Step 5: Tests** + +```go +func TestAckKeptBanner_ClearsFlag(t *testing.T) { + // Seed a user with pending_kept_banner=true. + // Call AckKeptBanner. + // Assert: pending_kept_banner=false in DB. +} + +func TestAckKeptBanner_RequiresAuth(t *testing.T) { + // Call without auth context → CodeUnauthenticated. +} +``` + +- [ ] **Step 6: Run all tests** + +```bash +make test +``` + +Expected: full pipeline (buf lint, codegen check, frontend tsc, lint, vitest, backend tests) passes. + +- [ ] **Step 7: Commit** + +```bash +git add pb/drill/v1/user.proto internal/pb/ web/src/pb/ \ + internal/rpc/user/server.go +git commit -m "user: pending_kept_banner field and AckKeptBanner RPC" +``` + +--- + +### Task 14: Frontend KeptBanner component + +**Files:** +- Create: `web/src/components/KeptBanner.tsx` +- Modify: app shell (e.g., `web/src/App.tsx` or wherever the top-level layout lives) to mount `` in a place visible after auth + +- [ ] **Step 1: Implement the banner component** + +```tsx +import { useState, useEffect } from "react"; +import { useUserStore } from "../store/user"; // existing convention; adjust import +import { userClient } from "../api/client"; // existing ConnectRPC client + +export function KeptBanner() { + const user = useUserStore((s) => s.user); + const [dismissed, setDismissed] = useState(false); + + if (!user?.pendingKeptBanner || dismissed) return null; + + const dismiss = async () => { + setDismissed(true); // optimistic + try { + await userClient.ackKeptBanner({}); + } catch (e) { + console.warn("ackKeptBanner failed", e); + // banner stays dismissed in this session; next page load reads server state + } + }; + + return ( +

+ Welcome back — we kept your subscription active. + Manage subscription + +
+ ); +} +``` + +(Match existing styling conventions — inspect a sibling component to see whether the project uses CSS modules, Tailwind, or plain CSS.) + +- [ ] **Step 2: Mount it** + +In the top-level layout component (likely `web/src/App.tsx` or `web/src/components/AppShell.tsx`), import and render `` somewhere users see it on next page load — typically just inside the authenticated-routes wrapper. + +- [ ] **Step 3: Frontend typecheck** + +```bash +cd web && npx tsc -b +``` + +Expected: no errors. + +- [ ] **Step 4: Frontend lint** + +```bash +cd web && npm run lint +``` + +Expected: no errors. (If your project uses a different lint command, mirror it.) + +- [ ] **Step 5: Browser smoke test** + +Per project convention (CLAUDE.md: "After any UI-affecting commit, load the page in the browser and verify before moving on"): + +```bash +make dev # starts overmind: vite + air on :8080 +``` + +Manually: +1. Sign in as a test user. +2. Set `users.pending_kept_banner = TRUE` for that user via psql: + ```sql + UPDATE users SET pending_kept_banner = TRUE WHERE email = 'your-test@example.com'; + ``` +3. Reload the page. Banner appears. +4. Click "×" — banner disappears. +5. Reload again — banner stays gone (server-side flag was cleared via AckKeptBanner). + +- [ ] **Step 6: Commit** + +```bash +git add web/src/components/KeptBanner.tsx web/src/App.tsx +git commit -m "web: KeptBanner component for post-reverse welcome-back UX" +``` + +--- + +## Phase 6: Verification + +### Task 15: End-to-end integration test + full `make test` cleanup + +**Files:** +- Create: `internal/feat/idleunsub/integration_test.go` (an end-to-end test that exercises the full path: webhook → cancel → email → keep-link click → reverse) +- Possibly: cleanup of any test leftovers from prior tasks + +- [ ] **Step 1: Write the end-to-end happy-path test** + +```go +//go:build integration + +package idleunsub_test + +import ( + "context" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" + stripe "github.com/stripe/stripe-go/v82" + + "github.com/btc/drill/internal/backendtest" + "github.com/btc/drill/internal/feat/idleunsub" + "github.com/btc/drill/internal/handler" +) + +func TestE2E_CancelAndKeepViaLink(t *testing.T) { + t.Parallel() + b := pg.NewBackend(t) + ctx := context.Background() + userID := backendtest.SeedUser(t, b) + subID := "sub_e2e" + + // Set up the user as a Pro subscriber idle for 2+ periods. + _, err := b.Pool().Exec(ctx, ` + UPDATE users + SET stripe_customer_id = $1, + stripe_subscription_id = $2, + plan = 'pro', + idle_eligible_after = NOW() - INTERVAL '6 months' + WHERE id = $3`, "cus_e2e", subID, userID) + require.NoError(t, err) + _, err = b.Pool().Exec(ctx, ` + UPDATE auth_sessions SET last_active = NOW() - INTERVAL '3 months' + WHERE user_id = $1`, userID) + require.NoError(t, err) + + // Fake Stripe with a sub that's mid-monthly-cycle. + now := time.Now().UTC().Truncate(time.Second) + periodStart := now.AddDate(0, 0, -23) + periodEnd := periodStart.AddDate(0, 1, 0) + fake := newFakeStripeWithSub(subID, "cus_e2e", periodStart, periodEnd) + + signer := idleunsub.NewTokenSigner([]byte("test-key")) + mailer := newRecordingMailer() + svc := idleunsub.NewService(b.Pool(), fake, mailer, signer, b.Logger()) + + // 1. Fire invoice.upcoming → sub gets canceled. + require.NoError(t, svc.HandleInvoiceUpcoming(ctx, makeUpcomingEvent("evt_1", subID))) + require.True(t, fake.subs[subID].CancelAtPeriodEnd) + require.Len(t, mailer.sent, 1) // cancel email enqueued + require.Contains(t, mailer.sent[0].Subject, "next period") + + // 2. Extract the keep link from the email body. + keepURL := extractKeepURL(t, mailer.sent[0].TextBody) + + // 3. Click the link → /sub/keep endpoint. + mux := http.NewServeMux() + mux.Handle("GET /sub/keep", handler.NewKeepHandler(signer, svc, b.Pool())) + server := httptest.NewServer(mux) + defer server.Close() + + resp, err := http.Get(server.URL + "/sub/keep?t=" + extractToken(keepURL)) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // 4. Verify reversal happened. + require.False(t, fake.subs[subID].CancelAtPeriodEnd) + require.Len(t, mailer.sent, 2) // kept email enqueued + require.Contains(t, mailer.sent[1].Subject, "still active") + + // 5. Verify cache and event rows. + var cap, isAuto, banner bool + err = b.Pool().QueryRow(ctx, ` + SELECT sub_cancel_at_period_end, sub_cancel_is_auto, pending_kept_banner + FROM users WHERE id = $1`, userID).Scan(&cap, &isAuto, &banner) + require.NoError(t, err) + require.False(t, cap) + require.False(t, isAuto) + require.True(t, banner) + + // 6. Replay the keep link → idempotent (still 200, no second email). + resp2, err := http.Get(server.URL + "/sub/keep?t=" + extractToken(keepURL)) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp2.StatusCode) + require.Len(t, mailer.sent, 2) // STILL 2; no new email +} +``` + +(Helpers `newFakeStripeWithSub`, `newRecordingMailer`, `makeUpcomingEvent`, `extractKeepURL`, `extractToken`: small constructors. Implement at the bottom of the test file.) + +- [ ] **Step 2: Add the auto-reverse e2e test** + +```go +func TestE2E_AutoReverseOnLogin(t *testing.T) { + // Same setup, but instead of clicking the link, simulate an authed request + // by calling Backend.AuthenticateSession with a valid session token. + // Wait briefly for the goroutine. Assert reversal + banner set + email sent. +} +``` + +- [ ] **Step 3: Run all tests** + +```bash +go test -tags=integration ./internal/feat/idleunsub/ -race -count=1 -v +make test +``` + +Expected: full pipeline passes. + +- [ ] **Step 4: Manually verify metrics and logs** + +Run `make dev` and trigger a cancel by hand: + +1. Use `stripe trigger invoice.upcoming` (Stripe CLI) against the local webhook, OR +2. Insert a fixture event into the dev DB manually and call the webhook handler. + +Watch for: +- `idleunsub.cancel.fired` counter increments +- `subscription_auto_canceled` row in `user_events` +- Cancel email visible in the local mail log (Mailgun stub or `LogSender`) + +- [ ] **Step 5: Final commit** + +```bash +git add internal/feat/idleunsub/integration_test.go +git commit -m "idleunsub: end-to-end integration tests for cancel + reverse" +``` + +--- + +## Acceptance Criteria + +The feature is shippable when: + +1. **Schema migrations 015–018 apply cleanly** on a fresh DB and on a DB seeded with the current production schema (no data loss, no broken FKs). +2. **`make test` is green** end-to-end (buf lint, codegen check, frontend typecheck/lint/vitest, backend tests with `-race`). +3. **All spec test cases pass** (§8 of the design doc): + - Trigger rule edge cases (idle/active/grandfathered/no-history/first-period) + - Idempotency (retry storm + multi-firing) + - Reversal paths (link, auto-activity, manual cancel guard) + - Token (sign/verify/tampering/expired/replay/invariant) + - Concurrent webhook race (`SELECT FOR UPDATE`) + - Cache drift correction +4. **Manual smoke test** (browser, per `CLAUDE.md`): banner appears on auto-reverse, dismisses cleanly, doesn't reappear on next load. +5. **Observability**: counters from §9 of the spec are emitted on each path. Verify via local OTEL exporter or by inspecting the metrics handler output. + +## Out of Scope (Per Spec §10) + +These are explicitly *not* implemented in this plan; revisit later if needed: +- Multiple subscriptions per user +- Annual subscriptions (rule generalizes; no special handling shipped) +- Settings toggle to opt out +- Cross-Spanda-product code library +- Cancel-window UI surface beyond the banner (e.g., billing-page indicator) +- Auto-prune of `auth_sessions` and `keep_link_token_uses` (boy-scout cleanup, not blocking) +- Generic webhook dedup adoption by other handlers (`invoice.paid`, etc.) +- `sub_current_period_end` cache column (we read end from `subscription.Update` response) diff --git a/docs/superpowers/specs/2026-05-07-aippatch-design.md b/docs/superpowers/specs/2026-05-07-aippatch-design.md new file mode 100644 index 00000000..4fd29261 --- /dev/null +++ b/docs/superpowers/specs/2026-05-07-aippatch-design.md @@ -0,0 +1,1161 @@ +# aippatch — proto/SQL PATCH framework + +**Status:** spec +**Author:** Brian Tiger Chow +**Date:** 2026-05-07 + +## Summary + +`aippatch` is a small Go library plus a sibling code-generator that turn AIP-134 +PATCH RPCs into safe, dynamic Postgres `UPDATE` statements. The proto message +and `FieldMask` carry intent on the wire; an `aippatch.yaml` config plus +codegen produce a typed `Mapping[T]` per resource; a runtime `Apply[T]` call +validates the mask, builds the SQL, executes it, and returns the post-update +proto. Built first inside drill at `thirdparty/aippatch/`; designed to be +lifted into a standalone Go module and re-used across Spanda LLC products. + +## Goals + +- **Common case is one yaml entry.** Adding a new writable field on a resource + is: declare it in `aippatch.yaml`, regenerate, ship. +- **Hard case is possible.** Name divergences, enum codecs, always-set columns + (e.g. `updated_at`), and opt-outs are expressible as overrides without + escape hatches into custom Go. +- **No type casting in user code.** The public API is generic over the proto + message type; callers never see `proto.Message` erasure. +- **Schema drift is a build-time error.** When proto fields rename, columns + rename, or types diverge, the codegen tool fails CI before runtime can. +- **Replicable across projects.** Three artifacts (runtime library, codegen + binary, yaml file) port to any Go service speaking ConnectRPC + pgx. +- **AIP-134-aligned with documented divergences.** Wire shape (resource + + FieldMask in, full updated resource out) follows AIP-134. Empty-mask + semantics deviate intentionally; see *Wire conformance note* below. + +## Non-goals (v0) + +- Declarative authorization (per-field write gating). v0 keeps all authz in + handler code. +- Declarative validation (`NonEmptyTrimmed`, `LenBetween`, `URL`, …). v0 keeps + per-field validation in handler code. +- AIP-193 error-code mapping (`unique_violation` → `AlreadyExists`, etc.). v0 + returns `Internal` for unmapped pgx errors and `NotFound` for zero-row + updates. +- Optimistic concurrency / ETag (AIP-154). v0 has no version column awareness. +- JSONB, repeated, oneof, message-as-jsonb, proto3 explicit-optional / NULL + semantics. v0 codecs cover scalars, timestamps, and enums only; everything + else fails at codegen with a diagnostic. +- **Nullable bound columns.** v0 bindings must reference `NOT NULL` columns. + Nullable columns require `pgtype.*` decode handling and are deferred to v1. +- **`bytes`, `float`, `double` proto kinds.** Common but unused in drill v0 + resources; deferred to v1. +- **Nested mask paths.** v0 supports top-level proto fields only. Mask paths + with dots (`address.street`) are rejected by codegen with a diagnostic. +- **Audit logging hooks.** v0 has no pre/post hook for emitting audit events. + Callers wrap `Apply` themselves until v1 adds returned-diff or hooks. +- Replacing sqlc for SELECTs and non-PATCH UPDATEs. aippatch only owns dynamic + PATCH UPDATEs. + +## Wire conformance note + +aippatch follows AIP-134 wire shape (resource + `FieldMask` in, full updated +resource out) with two intentional divergences: + +1. **Empty `FieldMask`** is rejected with `InvalidArgument` (default + `ErrorOnEmpty` policy). AIP-134 §Update specifies that an omitted mask + "MUST" be treated as an implied mask covering all populated fields. drill + prefers explicit intent over implicit broad updates; clients that need + full-field updates must enumerate paths. The `EmptyMaskPolicy` is + wire-affecting and considered permanent for any deployed service — + document the chosen policy in the resource's API documentation. +2. **Nested mask paths** (`address.street`) are not supported in v0 because + the codec set excludes nested message types. Codegen rejects yaml entries + whose proto fields would require nested support. Roadmap: v1+. + +A separate behavior change is wire-visible during the drill migration: today's +`UpdateProfile` returns `Internal` when the user is soft-deleted (the sqlc +`UpdateUserDisplayName` includes `AND deleted_at IS NULL`, returns +`pgx.ErrNoRows`, the handler wraps as `Internal`). aippatch returns +`NotFound` for the same case (zero-row update). This is the more correct AIP +behavior. The rollout plan calls it out so clients depending on the prior +code can adapt. + +All other AIP-134 requirements (return the updated resource, honor mask paths +that are valid, reject unknown paths) are upheld. + +## First-principles mechanics + +To turn a PATCH RPC into `UPDATE … WHERE … RETURNING ` you need nine +things: + +1. **Presence detection** — which fields to apply. +2. **Proto-field → SQL-column mapping.** +3. **Value coercion** for the write side (proto value → SQL parameter). +4. **Row identity** — the primary key. +5. **Scope predicates** — tenancy / soft-delete / additional WHERE clauses. +6. **Per-field write authorization.** +7. **Per-field value validation.** +8. **Returned representation** — the post-update resource on the wire. +9. **Optimistic concurrency.** + +`aippatch` v0 owns 1, 2, 3, 4, 5, and 8. Items **6** (authorization) and **7** +(validation) stay in the handler in v0; v2 promotes them to declarative. +Item **9** (concurrency) is deferred to v3. Read-back (item 8) requires +bidirectional coercion, so the v0 codec set covers every type the v0 target +resources use. + +A tenth concern — **audit logging** — is intentionally out of v0; callers wrap +`Apply` for now. v1 considers a returned-diff or hooks API. + +## Architecture + +``` +┌─────────────────────────┐ ┌──────────────────────────┐ +│ pb/drill/v1/*.proto │ │ sql/migrations/*.up.sql │ +│ (proto contracts) │ │ (logical schema) │ +└──────────┬──────────────┘ └────────────┬─────────────┘ + │ buf build -o buf.binpb │ pg_query_go/v6 + │ (FileDescriptorSet) │ + ▼ ▼ + ┌──────────────────────────────────────────────────────┐ + │ thirdparty/aippatch/cmd/aippatchgen/ │ + │ (standalone Go binary; CGO required for pg_query_go)│ + │ reads: descriptors + SQL schema + aippatch.yaml │ + │ writes: typed Mapping[T] literals + InitPatches() │ + └─────────────────┬────────────────────────────────────┘ + │ ▲ + │ │ aippatch.yaml + ▼ │ (codecs, overrides, writable, auto_set) + ┌──────────────────────────────────────────────────────┐ + │ internal/patches/*.gen.go (committed) │ + │ e.g. var UserPatch = aippatch.Mapping[*User]{ … } │ + │ var Codecs = map[string]aippatch.EnumCodec{ … } │ + │ func InitPatches() error { Validate all mappings } │ + └─────────────────┬────────────────────────────────────┘ + │ imported by + ▼ + ┌──────────────────────────────────────────────────────┐ + │ internal/rpc//server.go (handler) │ + │ aippatch.Apply(ctx, pool, │ + │ patches.UserPatch, Op[*User]{...}) │ + └─────────────────┬────────────────────────────────────┘ + │ uses + ▼ + ┌──────────────────────────────────────────────────────┐ + │ thirdparty/aippatch/ (runtime library) │ + │ • Mapping[T], Binding, AutoSetClause, Op[T], │ + │ EmptyMaskPolicy, EnumCodec │ + │ • Apply[T] — validate → build → exec → scan │ + │ • Codec dispatch: scalar / timestamp / enum │ + │ • Self-contained: no drill imports │ + └──────────────────────────────────────────────────────┘ +``` + +Five components, three new: + +1. **`thirdparty/aippatch/`** — runtime library. Self-contained, no drill + imports, ready to lift into a standalone Go module. Imports: + `google.golang.org/protobuf`, `github.com/jackc/pgx/v5`, + `github.com/huandu/go-sqlbuilder`, `github.com/google/uuid` (for + `[16]byte`→canonical uuid string formatting on the read side). Reads back + via `pgx.Rows.Values()` and populates the proto via reflection — no + third-party row scanner is needed. +2. **`thirdparty/aippatch/cmd/aippatchgen/`** — codegen binary. Imports: + `google.golang.org/protobuf` + `github.com/pganalyze/pg_query_go/v6`. + Requires CGO (libpg_query); see *Risks* §1. +3. **`aippatch.yaml`** — at the repo root. Source of truth for codecs, + resource bindings, name overrides, writable allow-list, and always-set + columns. + +Pre-existing components shrink: + +4. **`internal/patches/*.gen.go`** — committed generated code, one file per + resource, plus a single `init.gen.go` that emits the shared + `var Codecs = map[string]aippatch.EnumCodec{...}` registry and + `InitPatches() error`. +5. **`internal/rpc//server.go`** — handlers shrink to ~12 lines. + +### Boundary properties + +- The runtime imports nothing from drill. It speaks proto and pgx. +- `aippatchgen` imports nothing from the runtime — it produces Go literals + whose types satisfy the public runtime API. +- Handlers import nothing from `aippatchgen` — they import the runtime and the + generated `patches` package. +- sqlc still owns SELECT, INSERT, and any non-PATCH UPDATE. aippatch only + writes the dynamic PATCH UPDATE. +- The runtime's `DBTX` interface is satisfied by `*pgxpool.Pool`, `*pgx.Conn`, + and `pgx.Tx` — `Apply` participates in a caller's transaction transparently + when a `pgx.Tx` is passed. + +## Public API (runtime) + +```go +package aippatch + +import "sync/atomic" + +// Mapping is the static description of how a proto message round-trips +// through a SQL table. Generated by aippatchgen; never hand-edited. +type Mapping[T proto.Message] struct { + Table string + PK string // column name (PK value comes from Op) + SoftDelete string // "" if none; framework adds "AND col IS NULL" + EmptyMask EmptyMaskPolicy // ErrorOnEmpty (v0 default) + Bindings []Binding // ordered alphabetically by Proto for stable diff + AutoSet []AutoSetClause // always-set columns regardless of mask + + // Populated by Validate(); unexported. + bindingsByProto map[string]*Binding + bindingsByColumn map[string]*Binding + codecs map[string]EnumCodec // shared codec map passed in via Validate + validated atomic.Bool // Apply rejects unvalidated mappings +} + +// Binding pairs one proto field with one SQL column. +type Binding struct { + Proto string // proto field name, e.g. "display_name" + Column string // SQL column, e.g. "display_name" (or "created_at") + SQLType string // diagnostic: "text", "timestamptz", "uuid", "boolean", "integer", "smallint", "bigint" + Writable bool // PATCH may set this column; default false (deny-by-default) + Codec string // "" (scalar pass-through) | "timestamp" | "enum:" +} + +// AutoSetClause defines a SQL expression always written into the SET clause. +// Typical use: { Column: "updated_at", SQLLiteral: "NOW()" }. The literal is +// emitted as raw SQL — never sourced from user input. Codegen verifies the +// column exists in the table, is NOT NULL, and is not also a binding. +type AutoSetClause struct { + Column string + SQLLiteral string +} + +// EnumCodec maps a proto enum to/from a SQL text column. Generated literal +// form lives in patches/init.gen.go's Codecs registry. Validate() copies +// the relevant subset into Mapping.codecs. +type EnumCodec struct { + ProtoEnum string // e.g. "drill.v1.UserRole" + ToText map[int32]string // proto enum number → SQL text + FromText map[string]int32 // SQL text → proto enum number (built by Validate from ToText) +} + +// Op carries a single PATCH invocation's runtime data. +type Op[T proto.Message] struct { + Message T // input proto carrying the new values; must be non-nil + Mask *fieldmaskpb.FieldMask // which fields to apply + PKValue any // value for the PK column (e.g. uuid.UUID) + Where map[string]any // optional extra equality predicates. + // KEYS MUST BE BOUND COLUMNS — runtime validates + // against m.bindingsByColumn before composing SQL. + // Keys are NOT escaped; values ARE pgx-parameterized. + // Values must be scalar / pgx-bindable; v0 framework + // supports equality only. List comparators (IN, !=, + // <, etc.) are deferred — passing a slice value + // produces SQL like `col = ARRAY[…]`, not `col IN (…)`. +} + +// DBTX is the minimal pgx interface aippatch needs. It is a strict subset of +// pgx's query surface and is satisfied by *pgxpool.Pool, *pgx.Conn, and pgx.Tx +// — Apply participates in a caller's transaction when a pgx.Tx is passed. +// (Note: this is *not* the same DBTX that sqlc generates; aippatch's is +// smaller and read-only on the connection from a control-flow perspective.) +type DBTX interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) +} + +// EmptyMaskPolicy controls behavior when Op.Mask has zero paths. +type EmptyMaskPolicy int +const ( + ErrorOnEmpty EmptyMaskPolicy = iota // v0 default; rejects with InvalidArgument + UpdateAllWritable // future opt-in (not implemented in v0) +) + +// Apply executes the PATCH described by op against m, and returns the +// updated proto message populated from the RETURNING row. op.Message must +// be non-nil; the returned T is a clone (via proto.CloneOf) of op.Message +// with mapped columns overwritten from RETURNING. +func Apply[T proto.Message]( + ctx context.Context, db DBTX, + m *Mapping[T], op Op[T], +) (T, error) + +// Validate is called once at startup, by InitPatches(). It indexes the +// binding maps; copies relevant codecs into m.codecs (subset reachable from +// m.Bindings); builds each EnumCodec's FromText from ToText; verifies every +// binding's proto path exists on T; verifies every binding's "enum:" +// codec reference exists in the supplied codecs map; and sets the validated +// atomic flag. Returns error rather than panicking, per drill's +// no-panic-at-init rule. Idempotent up to the validated flag — repeated +// calls return nil after the first success. On error, validated is left +// false and the caller may retry after fixing the cause. +// +// Concurrency: callers should invoke Validate before serving any RPCs (the +// generated InitPatches() runs synchronously at startup). Two concurrent +// Validate calls on the same Mapping that both observe validated == false +// would each rebuild the indexes (harmless but redundant). v0 does not use +// sync.Once because real callers serialize startup; if strict-once +// semantics are needed, wrap InitPatches() in a sync.Once at the call site. +func (m *Mapping[T]) Validate(codecs map[string]EnumCodec) error +``` + +### Codec dispatch + +The codec field on each Binding selects how the proto value is encoded to +SQL and decoded back: + +- `""` — scalar pass-through (`StringKind`, `BoolKind`, `Int32Kind`, + `Sint32Kind`, `Int64Kind`, `Sint64Kind`). +- `"timestamp"` — `google.protobuf.Timestamp` ↔ `time.Time` via `.AsTime()` / + `timestamppb.New(...)`. +- `"enum:"` — proto enum number ↔ SQL text via `m.codecs[]`. + Out-of-map values on the read side return `Internal` (data invariant + violation); on the write side return `InvalidArgument`. Codecs are global + to the yaml file and reusable across resources. + +`Validate()` populates `m.codecs` from the shared `Codecs` map by selecting +only the codec names referenced by `m.Bindings`. The resulting per-Mapping +map is read-only after `Validate` returns, eliminating any concern about +shared-state mutation. + +### Errors + +All errors returned by `Apply` are `*connect.Error` with appropriate codes: + +- `CodeInvalidArgument` — empty mask (when policy is `ErrorOnEmpty`), unknown + mask path, non-writable mask path, nested mask path, enum write value not + in declared map, nil `op.Message`, `op.Where` key not in `bindingsByColumn`. +- `CodeNotFound` — `UPDATE` matched zero rows (PK wrong, scope filter + excluded the row, or row is soft-deleted). +- `CodeUnimplemented` — `EmptyMaskPolicy` is `UpdateAllWritable`. Codegen is + the primary defense (rejects `update_writable` in yaml in v0); this is a + defense-in-depth runtime check that fires only if a Mapping is constructed + by hand or by a future codegen version. +- `CodeInternal` — pgx error, codec read-side data invariant violation, + binding/proto desync that escaped boot-time `Validate`, or `Apply` called + on an unvalidated `Mapping` (`InitPatches()` not invoked). + +### Transactions + +`Apply` does not start its own transaction. `DBTX` accepts both pools and +`pgx.Tx`; passing a tx makes `Apply` participate. On error, the caller's +transaction state is the caller's responsibility — pgx aborts an open tx on +any non-nil error per its standard contract. For multi-statement atomic +operations (e.g. PATCH + audit-event insert), wrap in `pgx.BeginFunc`: + +```go +err := pgx.BeginFunc(ctx, s.b.Pool(), func(tx pgx.Tx) error { + _, err := aippatch.Apply(ctx, tx, patches.UserPatch, op) + if err != nil { return err } + _, err = tx.Exec(ctx, "INSERT INTO audit_events ...") + return err +}) +``` + +## Codegen tool: `aippatchgen` + +### CLI + +``` +aippatchgen [--check] [--config aippatch.yaml] [--out internal/patches] + [--proto buf.binpb] [--migrations sql/migrations] +``` + +Defaults: reads `./aippatch.yaml`, writes to `./internal/patches/`, reads +`./buf.binpb` and `./sql/migrations/`. `--check` exits non-zero if any +generated file would change. + +**CGO requirement.** `aippatchgen` links `libpg_query` via +`github.com/pganalyze/pg_query_go/v6`, which requires `CGO_ENABLED=1`. This +is the default in Go's `go build`, but some shops set `CGO_ENABLED=0` +globally; document at the top of `cmd/aippatchgen/main.go` and in +`thirdparty/aippatch/README.md`. The runtime library has no CGO requirement. + +Wired into `Makefile` (drill's existing `generate` target gains the +`buf build -o buf.binpb` and `aippatchgen` steps): + +``` +generate: + buf generate + buf build -o buf.binpb + sqlc generate + go run ./thirdparty/aippatch/cmd/aippatchgen + +test: + ... && go run ./thirdparty/aippatch/cmd/aippatchgen --check && ... +``` + +`buf.binpb` is committed to the repo (small, deterministic; CI can regenerate +and verify if desired). + +### Inputs + +1. **`buf.binpb`** — emitted by `buf build -o buf.binpb` (added as a new step + in `make generate`; the existing `buf generate` does not emit a descriptor + set). Unmarshaled into `*descriptorpb.FileDescriptorSet`; walked via + `protoreflect.FileDescriptor`. +2. **`sql/migrations/*.up.sql`** — read in lexical order. Each statement + parsed by `pg_query_go/v6`. The tool accumulates a logical schema: + - `CREATE TABLE` → register table with columns `(name, type, not_null, + default_present)`. + - `ALTER TABLE … ADD COLUMN` → add column. + - `ALTER TABLE … DROP COLUMN` → remove column. + - `ALTER TABLE … ALTER COLUMN … TYPE` → change type. + - `ALTER TABLE … ALTER COLUMN … SET / DROP NOT NULL` → flip nullability. + - `ALTER TABLE … RENAME COLUMN` → rename. + - `DROP TABLE` → remove table. + - `ALTER TABLE … ADD/DROP CONSTRAINT`, `ADD/DROP DEFAULT`, and any other + `AlterTableCmd` kind not enumerated above: ignored in v0. + - Other statements (indexes, foreign-key constraints, CHECK constraints) + are ignored in v0. CHECK constraint extraction (to validate enum codec + maps) is a v1 feature. + + **Nullability** is interpreted from `NOT NULL`, `PRIMARY KEY` (implies + NOT NULL), and `SET / DROP NOT NULL` migrations. The codegen's + compatibility check enforces v0's "bound columns must be NOT NULL" rule. +3. **`aippatch.yaml`** — codecs + resource declarations + auto_set blocks + (schema below). + +### Algorithm + +1. Load `buf.binpb` → `FileDescriptorSet`. +2. Replay migrations to build the logical schema state. +3. For each `resources[i]` in `aippatch.yaml`: + 1. Look up the proto message descriptor by full name. + 2. Look up the SQL table from the schema; resolve the PK column. + 3. For each proto field in the message, in **field-number order** (so + diagnostic messages line up with the proto file's declaration order): + - If `overrides[field].skip` is true → emit no binding; the field is + registered as intentionally skipped and is not flagged by step 4 + below. + - If `overrides[field].column` set → use that column. + - Else → snake-case name match with the SQL column list. + - If no match → diagnostic: "field X has no matching column; suggest + `{ skip: true }` or `{ column: }`." + - Compatibility check between proto kind and column type (table below). + On v0 also enforce: column is NOT NULL. On incompatibility → + diagnostic. + - Determine codec: + - `MessageKind` with full name `google.protobuf.Timestamp` → + `"timestamp"`. + - Enum kind → require `overrides[field].codec` to name a declared + codec; emit `"enum:"`. If missing → diagnostic. + - Scalar kind → `""`. + - Anything else → diagnostic: "unsupported in v0; mark `skip: true`." + - `Writable` = field name is in `resources[i].writable`. + 4. After processing, every proto field must either have a binding or + `skip: true`. Any unmatched field is a diagnostic (this prevents the + `proto.CloneOf`-base case from silently passing through unmapped + fields with input-message values). + 5. For each `auto_set[col]` entry: verify the column exists in the table + and is NOT NULL. **Reject if the column is *any* binding** (writable + or non-writable) — auto-set must own the column entirely. The literal + expression is emitted verbatim as raw SQL; codegen further validates + the literal by parsing it with `pg_query_go` and rejecting + multi-statement input or non-expression payloads. (v0 effectively + restricts callers to a few well-known forms: `NOW()`, + `CURRENT_TIMESTAMP`, integer constants, etc.) + 6. Validate enum-codec yaml entries: every codec's `map` values must be + unique (no two enum values map to the same SQL text), to prevent the + derived FromText map from being lossy. Diagnostic on duplicates. + 7. Validate yaml-side names: reject any `writable` or `overrides` key + containing dots (these would imply nested-message paths, which v0 + does not support). + 8. Sort `Bindings` alphabetically by `Proto` and `AutoSet` alphabetically + by `Column` for **stable diff output** (different from step 3's + processing order). + 9. Reject `empty_mask: update_writable` with a diagnostic — v0 does not + implement this policy. The runtime carries a defense-in-depth check + that returns `CodeUnimplemented`, but the yaml is the primary + enforcement point. + 10. Verify column uniqueness across emitted bindings: if two proto fields + (after applying overrides) bind to the same SQL column, emit a + diagnostic identifying both fields and the shared column. Without + this check, a misconfigured yaml could produce a duplicate + `RETURNING` list and a `SET` clause that Postgres rejects with + "multiple assignments to column". +4. Emit one Go file per resource, plus one `init.gen.go` that emits a + shared `var Codecs = map[string]aippatch.EnumCodec{...}` registry and + `InitPatches() error` calling `Validate(Codecs)` on each mapping. +5. If `--check`: byte-compare to existing files; exit 1 on any diff. + +### Type compatibility (v0) + +| Proto kind | SQL types accepted | Codec | Notes | +|---|---|---|---| +| `StringKind` | `text`, `varchar`, `citext`, `uuid` | `""` | `uuid` columns: pgx returns `[16]byte`; runtime decodes via `uuid.UUID(v.([16]byte)).String()`. | +| `BoolKind` | `boolean` | `""` | | +| `Int32Kind`, `Sint32Kind` | `integer` | `""` | pgx returns `int32`. | +| `Int32Kind`, `Sint32Kind` | `smallint` | `""` | pgx returns `int16`; runtime widens to `int32` before `protoreflect.Set`. | +| `Int64Kind`, `Sint64Kind` | `bigint` | `""` | pgx returns `int64`. | +| `MessageKind`: `google.protobuf.Timestamp` | `timestamptz`, `timestamp` | `"timestamp"` | | +| `EnumKind` | `text`, `varchar` | `"enum:"` | yaml-declared map keyed by enum value name. | +| **Deferred (v1)** | | | | +| `BytesKind` | `bytea` | (TBD) | Codegen rejects in v0. | +| `FloatKind`, `DoubleKind` | `real`, `double precision` | (TBD) | Codegen rejects in v0. | +| Any kind ↔ nullable column | | | Codegen rejects in v0; v1 adds `pgtype.*` decode. | +| `MessageKind` (non-Timestamp) | `jsonb` | `"jsonb"` | v1. | +| anything else | — | — | codegen error | + +`uuid`-as-string is special-cased: a proto `string` field maps to a `uuid` +column when the column type is `uuid`, with `[16]byte`↔canonical-string +conversion in the runtime. The runtime imports `github.com/google/uuid` for +the canonical formatter. + +### Diagnostics + +Codegen failures are clear and actionable: + +``` +aippatchgen: drill.v1.User: field "create_time" has no matching column + proto field type: google.protobuf.Timestamp + candidate columns: [created_at, updated_at, deleted_at] + hint: add to aippatch.yaml: + overrides: + create_time: { column: created_at } + +aippatchgen: drill.v1.User: field "role" has unsupported type without codec + proto field kind: enum (drill.v1.UserRole) + hint: declare codec and override: + codecs: + enum_role: { proto_enum: drill.v1.UserRole, map: { ... } } + resources: + - message: drill.v1.User + overrides: { role: { codec: enum_role } } + +aippatchgen: drill.v1.User: writable field "display_name" not present in proto descriptor + +aippatchgen: drill.v1.User.password_hash → users.password_hash: nullable column not supported in v0 + hint: mark { skip: true } or wait for v1 nullable support. + +aippatchgen: drill.v1.User.address.street: nested mask paths not supported in v0 + +aippatchgen: drill.v1.User: auto_set column "updated_at" not found in table users + hint: ensure migrations have run and column exists. + +aippatchgen: drill.v1.User: auto_set column "display_name" conflicts with binding + hint: auto_set columns must not also be bindings; remove the proto field's binding or pick a different column. +``` + +## Configuration: `aippatch.yaml` + +```yaml +# Codecs are global and reusable across resources. +codecs: + enum_role: + proto_enum: drill.v1.UserRole + map: + USER_ROLE_CANDIDATE: candidate + USER_ROLE_ADMIN: admin + enum_plan: + proto_enum: drill.v1.UserPlan + map: + USER_PLAN_FREE: free + USER_PLAN_PRO: pro + +resources: + - message: drill.v1.User + table: users + pk: id + soft_delete: deleted_at + empty_mask: error # error → ErrorOnEmpty (default; v0 only valid value) + writable: [display_name] # deny-by-default + auto_set: + # NOTE: NOW() is constant within a transaction. If a test runs + # SELECT-before, Apply, SELECT-after inside a single BeginFunc, the + # before/after timestamps will be equal. Test patterns that need to + # observe the bump must use clock_timestamp() instead, run the SELECTs + # outside the surrounding transaction, or compare against a captured + # NOW() bound (post >= captured). See *Testing strategy*. + updated_at: NOW() # raw SQL, applied to every PATCH; pg_query_go-validated as expression + overrides: + create_time: { column: created_at } + role: { codec: enum_role } + plan: { codec: enum_plan } +``` + +The `users` table has SQL-only columns (`password_hash`, `stripe_customer_id`, +`free_full_educators_used`, `updated_at`, `deleted_at`) that have no +corresponding `User` proto field. Codegen iterates *proto fields*, not SQL +columns, so SQL-only columns are simply not visited and produce no diagnostic +or binding. No yaml entry is needed for them. + +One file per repo. `~10–20` lines per resource. Reviewers see policy and +mapping deltas in a single diff. Adding a writable field is one line. + +The yaml-string `error` maps to the Go enum `ErrorOnEmpty`. The yaml-string +`update_writable` would map to `UpdateAllWritable`, but v0 codegen rejects +this value with a diagnostic (the runtime path is defense-in-depth only — +see *Errors* and Algorithm step 9). v1 will implement the policy and remove +the codegen rejection. + +## Generated file shape + +`internal/patches/user.gen.go`: + +```go +// Code generated by aippatchgen. DO NOT EDIT. +package patches + +import ( + drillv1 "github.com/btc/drill/internal/pb/drill/v1" + "github.com/btc/drill/thirdparty/aippatch" +) + +// UserPatch is the PATCH mapping for drill.v1.User → users. +// Validate(Codecs) is called from InitPatches() at startup, never at package init. +var UserPatch = &aippatch.Mapping[*drillv1.User]{ + Table: "users", + PK: "id", + SoftDelete: "deleted_at", + EmptyMask: aippatch.ErrorOnEmpty, + Bindings: []aippatch.Binding{ + {Proto: "create_time", Column: "created_at", SQLType: "timestamptz", Writable: false, Codec: "timestamp"}, + {Proto: "display_name", Column: "display_name", SQLType: "text", Writable: true, Codec: ""}, + {Proto: "email", Column: "email", SQLType: "text", Writable: false, Codec: ""}, + {Proto: "email_verified", Column: "email_verified", SQLType: "boolean", Writable: false, Codec: ""}, + {Proto: "id", Column: "id", SQLType: "uuid", Writable: false, Codec: ""}, + {Proto: "plan", Column: "plan", SQLType: "text", Writable: false, Codec: "enum:enum_plan"}, + {Proto: "role", Column: "role", SQLType: "text", Writable: false, Codec: "enum:enum_role"}, + }, + AutoSet: []aippatch.AutoSetClause{ + {Column: "updated_at", SQLLiteral: "NOW()"}, + }, +} +``` + +The `id` column appears as a non-writable binding because the proto field +`id` should round-trip in the response. The framework does not deduplicate +PK from Bindings — PK identifies the row to update via `WHERE`, while +Bindings carries the read-back representation. If a future resource has a +PK column without a corresponding proto field (e.g. a synthetic table key +that's not exposed via the API), that column simply doesn't appear in +Bindings; `Returning(boundCols...)` won't include it; the WHERE clause still +uses `m.PK` for row identity. + +`internal/patches/init.gen.go`: + +```go +// Code generated by aippatchgen. DO NOT EDIT. +package patches + +import ( + drillv1 "github.com/btc/drill/internal/pb/drill/v1" + "github.com/btc/drill/thirdparty/aippatch" +) + +// Codecs is the shared registry of enum codecs declared in aippatch.yaml. +// Validate(Codecs) selects the codecs reachable from each Mapping's Bindings. +var Codecs = map[string]aippatch.EnumCodec{ + "enum_role": { + ProtoEnum: "drill.v1.UserRole", + ToText: map[int32]string{ + int32(drillv1.UserRole_USER_ROLE_CANDIDATE): "candidate", + int32(drillv1.UserRole_USER_ROLE_ADMIN): "admin", + }, + // FromText is built by Validate from ToText; no need to emit twice. + }, + "enum_plan": { + ProtoEnum: "drill.v1.UserPlan", + ToText: map[int32]string{ + int32(drillv1.UserPlan_USER_PLAN_FREE): "free", + int32(drillv1.UserPlan_USER_PLAN_PRO): "pro", + }, + }, +} + +// InitPatches validates every generated mapping. Call once at server startup. +// Returns the first validation error encountered; never panics. +func InitPatches() error { + for _, fn := range []func(map[string]aippatch.EnumCodec) error{ + UserPatch.Validate, + // …one entry per resource… + } { + if err := fn(Codecs); err != nil { + return err + } + } + return nil +} +``` + +**Init wiring.** `cmd/drill/main.go` calls `patches.InitPatches()` during +startup, propagating the error normally per drill's no-panic-at-init rule. No +package-level `mustValidate` helper is generated — `Validate(Codecs)` is +invoked explicitly. This eliminates init-time panics and makes the validation +point greppable. + +`Apply` reads the `validated` atomic on every call; calling `Apply` before +`InitPatches()` returns `Internal` ("Mapping not initialized; call +InitPatches()") rather than a misleading "unknown field" error. The atomic +also prevents data races under `-race` if `InitPatches` and `Apply` are +hypothetically interleaved (in practice `InitPatches` runs synchronously +before any RPC handler is registered). + +## Runtime: `Apply` walkthrough + +```go +func Apply[T proto.Message]( + ctx context.Context, db DBTX, + m *Mapping[T], op Op[T], +) (T, error) { + var zero T + + // 0. Sanity guards. + if m == nil || !m.validated.Load() { + return zero, connectInternal("aippatch.Mapping not initialized; call your generated InitPatches() (or Mapping.Validate) during startup") + } + // ProtoReflect().IsValid() returns false for typed-nil pointers and + // un-initialized messages, sidestepping the typed-nil interface trap + // (`any(op.Message) == nil` is false for a nil *T). ProtoReflect itself + // never returns a nil interface for protoc-gen-go-generated types. + src := op.Message.ProtoReflect() + if !src.IsValid() { + return zero, connectInvalidArg("op.Message must not be nil") + } + + // 1. Mask validation. GetPaths is nil-safe (returns nil for both nil + // mask and empty paths), so a single len() check covers both. + paths := op.Mask.GetPaths() + if len(paths) == 0 { + if m.EmptyMask == ErrorOnEmpty { + return zero, connectInvalidArg("update_mask must not be empty") + } + // UpdateAllWritable: not implemented in v0. CodeUnimplemented + // signals "feature not yet built" rather than a server bug. + return zero, connect.NewError(connect.CodeUnimplemented, + errors.New("UpdateAllWritable is unimplemented in v0")) + } + + // 2. Resolve paths to bindings; reject unknown / non-writable / nested. + // sqlbuilder.PostgreSQL.NewUpdateBuilder() is required (not the default + // NewUpdateBuilder) — only the PostgreSQL flavor emits $1 placeholders + // and a working RETURNING clause. + desc := src.Descriptor() + ub := sqlbuilder.PostgreSQL.NewUpdateBuilder() + ub.Update(m.Table) + + // Iterate Bindings (not client-supplied paths) in stable order so the + // emitted SQL is deterministic regardless of the order the client put + // paths in the mask. This makes pgx's prepared-statement cache hit + // across calls with the same mask shape, and makes golden-test SQL + // deterministic. + maskSet := make(map[string]struct{}, len(paths)) + for _, p := range paths { + if strings.Contains(p, ".") { + return zero, connectInvalidArg("nested mask path not supported in v0: %q", p) + } + if _, ok := m.bindingsByProto[p]; !ok { + return zero, connectInvalidArg("unknown field in update_mask: %q", p) + } + if !m.bindingsByProto[p].Writable { + return zero, connectInvalidArg("field not writable: %q", p) + } + maskSet[p] = struct{}{} + } + sets := make([]string, 0, len(maskSet)+len(m.AutoSet)) + for i := range m.Bindings { + b := &m.Bindings[i] + if _, ok := maskSet[b.Proto]; !ok { continue } + fd := desc.Fields().ByName(protoreflect.Name(b.Proto)) + if fd == nil { + return zero, connectInternal("binding/proto desync: %q", b.Proto) + } + v, err := encode(op.Message, fd, b.Codec, m.codecs) + if err != nil { return zero, err } + sets = append(sets, ub.Assign(b.Column, v)) + } + + // 3. AutoSet columns: append raw SQL fragments unconditionally. + for _, a := range m.AutoSet { + sets = append(sets, fmt.Sprintf("%s = %s", a.Column, a.SQLLiteral)) + } + ub.Set(sets...) + + // 4. WHERE clauses. Sort op.Where keys for deterministic SQL (so pgx's + // prepared-statement cache hits across calls with the same shape). + ub.Where(ub.Equal(m.PK, op.PKValue)) + if m.SoftDelete != "" { + ub.Where(ub.IsNull(m.SoftDelete)) // first-class helper, not string concat + } + if len(op.Where) > 0 { + keys := make([]string, 0, len(op.Where)) + for k := range op.Where { keys = append(keys, k) } + sort.Strings(keys) + for _, col := range keys { + // Reject columns that are not bound — avoids any chance of + // attacker-controlled identifiers reaching the SQL string. + if _, ok := m.bindingsByColumn[col]; !ok { + return zero, connectInvalidArg("unknown column in op.Where: %q", col) + } + ub.Where(ub.Equal(col, op.Where[col])) + } + } + + // 5. Returning explicit bound columns (not RETURNING *). + boundCols := make([]string, len(m.Bindings)) + for i, b := range m.Bindings { boundCols[i] = b.Column } + ub.Returning(boundCols...) + + sqlStr, args := ub.Build() + + // 6. Execute and read back. + rows, err := db.Query(ctx, sqlStr, args...) + if err != nil { return zero, connectInternal("query: %w", err) } + defer rows.Close() + + if !rows.Next() { + if err := rows.Err(); err != nil { + return zero, connectInternal("query: %w", err) + } + return zero, connectNotFound("resource not found, soft-deleted, or filtered out") + } + cols := rows.FieldDescriptions() + vals, err := rows.Values() + if err != nil { return zero, connectInternal("scan: %w", err) } + + // 7. Build result proto from input message clone + RETURNING values. + result := proto.CloneOf(op.Message) + msg := result.ProtoReflect() + for i, c := range cols { + b, ok := m.bindingsByColumn[string(c.Name)] + if !ok { continue } + fd := msg.Descriptor().Fields().ByName(protoreflect.Name(b.Proto)) + if fd == nil { + return zero, connectInternal("binding/proto desync on read: %q", b.Proto) + } + if err := decode(msg, fd, vals[i], b.Codec, m.codecs); err != nil { + return zero, connectInternal("decode %s: %w", b.Proto, err) + } + } + return result, nil +} +``` + +Notable details: + +- **`proto.CloneOf`** (added in protobuf-go v1.36.6; project uses v1.36.11) + is type-safe: returns `T` directly, no `.(T)` assertion. +- **`Returning(boundCols...)`** sends only mapped columns over the wire from + Postgres to Go. Unmapped columns (e.g. `password_hash`, + `stripe_customer_id`) are not transmitted at all. Note: bound non-writable + columns (e.g. `email`) ARE transmitted — they appear in the response proto + per AIP-134's "return the updated resource" requirement. The benefit of + `Returning(boundCols)` over `RETURNING *` is excluding *unmapped* + columns, not all non-writable ones. +- **`ub.Set(sets...)`** is variadic over assignment strings. `ub.Assign(col, + val)` returns `"col = $N"` with parameterized placeholder; raw expressions + for `AutoSet` are formatted directly (`"updated_at = NOW()"`), with codegen + guaranteeing the column and literal are safe (column existence + NOT NULL + + not-also-a-binding; literal parsed by `pg_query_go` as a single + expression). +- **`ub.Returning(...)`** is the library's first-class API; we do not use the + marker-position-dependent `ub.SQL(...)` for this. +- **`op.Where` keys are validated** against `m.bindingsByColumn` — keys must + be bound columns. Values are pgx-parameterized; keys are not escaped, and + the validation step is the safety guarantee. + +`encode` and `decode` are bounded switches over field kind × codec. The total +runtime is approximately 350 LoC including codec dispatch, error +constructors, and `Validate`. + +### pgx native types on the read side + +| SQL type | pgx returns | Proto kind expected | Conversion | +|---|---|---|---| +| `text`, `varchar`, `citext` | `string` | `StringKind` | direct | +| `uuid` | `[16]byte` | `StringKind` | `uuid.UUID(v.([16]byte)).String()` | +| `boolean` | `bool` | `BoolKind` | direct | +| `integer` | `int32` | `Int32Kind`, `Sint32Kind` | direct | +| `smallint` | `int16` | `Int32Kind`, `Sint32Kind` | widen: `int32(v.(int16))` | +| `bigint` | `int64` | `Int64Kind`, `Sint64Kind` | direct | +| `timestamptz`, `timestamp` | `time.Time` | `MessageKind: Timestamp` | `timestamppb.New(v.(time.Time))` | +| `text` (with enum codec) | `string` | `EnumKind` | reverse-map declared codec | + +**Nullable columns return `pgtype.Text`/`pgtype.Timestamptz`/etc. from +`rows.Values()` rather than the underlying scalar.** v0 codegen rejects +nullable bound columns; v1 adds explicit `pgtype.*` decode. Any unexpected +pgx-native type at runtime is `Internal` (should be impossible if codegen +accepted the binding). + +## Handler call site + +drill's existing `UpdateProfile` (currently `internal/rpc/user/server.go:100-141`) +shrinks from ~45 lines to ~14: + +```go +func (s *Server) UpdateProfile( + ctx context.Context, + req *connect.Request[drillv1.UpdateProfileRequest], +) (*connect.Response[drillv1.UpdateProfileResponse], error) { + u := auth.UserFromContext(ctx) + if u == nil { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("authentication required")) + } + + // Per-field validation lives in the handler in v0. v2 makes it declarative. + if slices.Contains(req.Msg.GetUpdateMask().GetPaths(), "display_name") { + trimmed := strings.TrimSpace(req.Msg.GetUser().GetDisplayName()) + if trimmed == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("display_name must not be empty")) + } + req.Msg.GetUser().DisplayName = trimmed + } + + updated, err := aippatch.Apply(ctx, s.b.Pool(), patches.UserPatch, aippatch.Op[*drillv1.User]{ + Message: req.Msg.GetUser(), + Mask: req.Msg.GetUpdateMask(), + PKValue: u.ID, + }) + if err != nil { return nil, err } + + return connect.NewResponse(&drillv1.UpdateProfileResponse{User: updated}), nil +} +``` + +(`s.b.Pool()` is a method on `*backend.Backend` returning `*pgxpool.Pool`.) + +The hand-rolled `implementedUserFields` allow-list and per-path validation +loop disappear. Authorization (the unauthenticated check) and per-field value +validation (the trim + non-empty check) remain in the handler. + +## Testing strategy + +Three layers: + +### Runtime unit tests (`thirdparty/aippatch/apply_test.go`) + +Table-driven against testcontainers Postgres. Drill already uses +`testcontainers-go/modules/postgres` (per `go.mod`); aippatch tests reuse the +same approach with a small fixture schema independent of drill's migrations. + +Cases: +- empty mask → `InvalidArgument` +- nil `op.Message` (typed-nil pointer) → `InvalidArgument` +- unvalidated mapping (`InitPatches` not called) → `Internal` +- unknown mask path → `InvalidArgument` +- non-writable mask path → `InvalidArgument` +- nested mask path → `InvalidArgument` +- `op.Where` key not in bindings → `InvalidArgument` +- writable scalar (string, bool, int32 from `integer`, int32 from `smallint`, + int64) write + read-back +- writable timestamp write + read-back +- writable enum write (valid & invalid) + read-back +- AutoSet column is bumped on every PATCH. **Important:** `NOW()` returns + the transaction-start timestamp and is constant for the duration of a + single transaction, so a `BeginFunc(SELECT before; Apply; SELECT after)` + test would see equal values. Use one of: + - `clock_timestamp()` in the test fixture's `auto_set` instead of `NOW()`, + which advances within a transaction; or + - run the SELECT-before, `Apply`, SELECT-after as separate top-level + statements (no surrounding `BeginFunc`); or + - SELECT `NOW()` to capture the test's own transaction-time bound + before `Apply`, then SELECT the row's `updated_at` after `Apply`, + asserting it's >= the captured time. +- soft-delete WHERE filters out deleted rows → `NotFound` +- PK mismatch → `NotFound` +- extra `Op.Where` predicate (valid bound column) excludes row → `NotFound` +- `Returning(boundCols)` does not include unmapped columns +- `proto.CloneOf` preserves input-message fields that have no binding +- map-iteration determinism: same `Op.Where` produces the same SQL string + (assert via `pgx.LogQuery` or capturing builder output) + +### Codegen golden tests (`thirdparty/aippatch/cmd/aippatchgen/aippatchgen_test.go`) + +Fixtures under `testdata/`: +- `simple/` — proto + 2 migrations + yaml → expected `*.gen.go` +- `name_divergence/` — `create_time` ↔ `created_at` +- `enum_codec/` — proto enum + declared codec +- `auto_set/` — `updated_at: NOW()` and verified output +- `auto_set_conflict/` — `auto_set` column also a binding; expects diagnostic +- `auto_set_bad_literal/` — yaml literal is multi-statement; expects diagnostic +- `unsupported_kind/` — proto with bytes field; expects diagnostic +- `nullable_bound/` — writable on a nullable column; expects diagnostic +- `nested_path/` — proto with submessage, attempted writable; expects diagnostic +- `missing_column/` — proto field with no candidate; expects diagnostic +- `unmatched_proto_field/` — proto field neither matched nor `skip: true`; + expects diagnostic +- `--check_drift/` — fixture with stale `*.gen.go`; expects exit 1 + +### Handler integration test (`internal/rpc/user/server_test.go`) + +Existing tests in this file already assert via `connect.CodeOf(err)` (per +`internal/rpc/user/server_test.go:151`, `:169`, `:187`, etc.) — *not* on +error message text — so the migration to aippatch does **not** require +updating any test assertions. + +The existing cases continue to cover the right wire behaviors: +- valid PATCH on `display_name` → response carries updated User; DB row's + `display_name` and `updated_at` both change. +- empty mask → `InvalidArgument`. +- unknown / non-writable mask path → `InvalidArgument`. +- empty display_name → `InvalidArgument` (handler-side validation still + runs). +- unauthenticated → `Unauthenticated`. + +One new case added by the rollout: +- soft-deleted user PATCH → `NotFound` (was `Internal`; see *Wire conformance + note*). + +## Drill rollout plan + +1. **Dependency adds.** `go get github.com/huandu/go-sqlbuilder@v1.36.0 + github.com/pganalyze/pg_query_go/v6` and commit `go.mod`/`go.sum`. + The `@v1.36.0` floor is required — `UpdateBuilder.Returning(...)` was + added in that release. (`github.com/google/uuid` is already in `go.mod`.) +2. **Add framework.** Land `thirdparty/aippatch/` runtime + `cmd/aippatchgen/` + binary. +3. **Wire codegen.** Extend the existing `make generate` target (line 125 of + `Makefile`) to add `buf build -o buf.binpb` and the `aippatchgen` step + after `buf generate`. Add `aippatchgen --check` to `make test`. +4. **Add config.** `aippatch.yaml` at repo root with the `User` resource, + enum codecs, and `auto_set: { updated_at: NOW() }`. +5. **Generate.** Create `internal/patches/`; run `make generate`. Review the + diff manually first time, including `internal/patches/user.gen.go` and + `internal/patches/init.gen.go`. +6. **Wire init.** Call `patches.InitPatches()` in `cmd/drill/main.go` + startup; surface any error from `Validate(Codecs)` per drill's no-panic + rule. +7. **Switch handler.** Replace `UpdateProfile` handler body with the shrunk + version. Verify the proto wire contract is unchanged (or document the + one wire change: soft-deleted user now returns `NotFound`, was `Internal`). +8. **Audit consumers of `b.UpdateDisplayName`.** Grep the codebase for + callers; confirm only `UpdateProfile` calls it before deletion. +9. **Remove superseded sqlc.** Delete `UpdateUserDisplayName` from + `sql/queries/users.sql` and `b.UpdateDisplayName`; regenerate sqlc. +10. **Verify.** `make test` (full CI: buf lint, codegen check, frontend + typecheck+lint+tests, backend tests with race). + +Rollback: single-commit revert. The proto wire contract is unchanged; only +the soft-deleted-user code differs (and clients should treat +`Internal`/`NotFound` symmetrically as transient or missing-resource). + +## Spanda replication + +Each Spanda repo gets three artifacts: + +1. The `thirdparty/aippatch/` directory (initially copied from drill; once + stable, extracted to its own module — see *Roadmap v0.5*). +2. The `aippatchgen` binary — `go install + ./thirdparty/aippatch/cmd/aippatchgen`. +3. An `aippatch.yaml` skeleton. + +Each project's `Makefile` wires `aippatchgen` into its `codegen` and `test` +targets. The runtime library and codegen binary contain no drill-specific +code; the yaml file, generated `*.gen.go`, and `Makefile` wiring are +project-specific by design. (Note for the eventual extraction: scrub +drill-specific examples from `thirdparty/aippatch/README.md` before +publishing the module.) + +## Roadmap + +| Tier | Feature | Notes | +|---|---|---| +| v0.5 | Extract `thirdparty/aippatch/` to its own Go module | Trigger: a second Spanda project consumes aippatch in production. New module path `github.com//aippatch`; drill's `go.mod` switches from local replace to versioned import; thirdparty/ directory removed. | +| v1 | Nullable bound columns (`pgtype.*` decode) | First wave of demand; many natural settings columns are nullable. | +| v1 | JSONB codec | Marshals proto sub-messages or `[]byte` to `jsonb` columns. | +| v1 | `bytes`, `float`, `double` proto kinds | Bytea / real / double precision support. | +| v1 | Pre/post hooks (or returned diff) for audit logging | `Apply` returns `(updated T, diff Diff, err error)` where Diff carries before/after for mask paths; handler emits audit events. | +| v1 | Proto3 explicit-optional + NULL semantics | AIP-134 clearing rule (`mask path + zero value → NULL`); meaningful for `optional` fields. | +| v1 | CHECK constraint extraction | Validate enum codec maps against `CHECK (col IN (…))` at codegen. | +| v1 | `UpdateAllWritable` empty-mask policy | Implement the "all populated/writable fields" path. v0 codegen rejects `update_writable` in yaml; the runtime defense-in-depth check returns `CodeUnimplemented` if a Mapping is somehow constructed with this policy. | +| v2 | Declarative validators | `NonEmptyTrimmed`, `LenBetween`, `URL`, `OneOf`. Per-resource yaml + handler-side composition. | +| v2 | AIP-193 error mapping | pgx error inspection: `unique_violation` → `AlreadyExists`, `fk_violation` → `FailedPrecondition`, `not_null_violation` / `check_violation` → `InvalidArgument`. Per-resource override map. | +| v3 | Per-field declarative authz | `admin_only_fields:` in yaml; layered with handler narrowing. | +| v3 | Concurrency / ETag (AIP-154) | Resource declares version column; Apply requires inbound etag and bumps on success. | +| v4 | Buf plugin | Proto annotations replace yaml entries; same generated output. | +| Out of scope | repeated, oneof, sub-resources | AIP-134 punts these to sub-resource RPCs. | + +Each tier is backwards-compatible: v0 call sites do not change when later +tiers ship. New features are opt-in via `aippatch.yaml`. + +## Risks + +1. **`pg_query_go/v6` is a CGO dependency.** It wraps `libpg_query`. drill's + production binary builds may run with `CGO_ENABLED=0` in some paths. + Mitigation: `aippatchgen` is a developer/CI tool, not part of the + production binary; CGO is only required where `aippatchgen` runs. + Document at the top of `cmd/aippatchgen/main.go`: + `// Requires CGO (libpg_query).` Add a `README.md` next to it stating + the same. CI runners must have a C toolchain — drill's CI already does + for testcontainers. **First-build cost:** `pg_query_go/v6` compiles part + of the PostgreSQL parser from C source on first use; on a cold build + cache this can take several minutes (varies by runner). CI runners + should preserve `GOCACHE` and `GOMODCACHE` across runs (drill's CI + already does). + +2. **pgx-native ↔ proto type drift.** New SQL types added to drill in the + future may not be in the runtime's `decode` switch. Mitigation: + `aippatchgen` rejects unknown SQL types at codegen with a clear + diagnostic; the runtime never sees a type the codegen accepted. + `Returning(boundCols...)` (not `RETURNING *`) further reduces blast + radius — unmapped columns are not transmitted from Postgres at all. + +3. **`EmptyMaskPolicy` is wire-affecting and AIP-134-divergent.** + `ErrorOnEmpty` deviates from AIP-134 §Update's "MUST treat omitted mask as + all populated fields" — this is documented in *Wire conformance note*. + Switching policies post-deploy is a breaking change visible to clients; + document choice per resource in API docs. + +4. **Validation duplication in v0.** Per-field validation lives in handlers + until v2. New PATCH RPCs added before v2 must hand-roll trimming / + non-empty / length checks. Mitigation: ship v2 quickly if duplication + becomes painful; document the v0 expectation in the README. + +5. **Audit logging is the caller's responsibility in v0.** Wrapping `Apply` + in a transaction is the documented pattern for atomically logging audit + events. v1 adds returned-diff support to remove the wrap. Mitigation: if + audit comes due before v1 ships, the wrap-in-tx pattern is sufficient. + +6. **Soft-deleted user wire change.** Today `UpdateProfile` returns + `Internal` when the user is soft-deleted; aippatch returns `NotFound`. + This is more correct AIP behavior, but is wire-visible. Mitigation: + document in *Wire conformance note* and the rollout plan. + +7. **`buf.binpb` drift.** If `buf.binpb` is committed and a developer regens + `pb/*.pb.go` without re-running `buf build -o buf.binpb`, the codegen + will be stale. Mitigation: `make generate` runs both in order; + `aippatchgen --check` in CI catches drift. + +8. **`op.Where` raw-identifier surface.** Keys in the map are interpolated + into the SQL string by `go-sqlbuilder` (`ub.Equal(col, val)` parameterizes + only the value). Mitigation: runtime validates every key against + `m.bindingsByColumn` before composing SQL — keys must be a bound column. + Document the constraint in the API; add a unit test for the rejection + path. + +## Decisions (locked, with rationale) + +| # | Decision | Why | +|---|---|---| +| 1 | Runtime library + standalone codegen binary; not a buf plugin | Cleaner separation from buf's plugin machinery; reusable in non-buf contexts. | +| 2 | Working name `aippatch`; lives at `drill/thirdparty/aippatch/` | Signals AIP-134 lineage; thirdparty/ prepares clean extraction. | +| 3 | Generic `Mapping[T proto.Message]` (single type parameter) | No row-type coupling; framework is sqlc-independent. No type casts in user code. | +| 4 | Codegen consumes proto FileDescriptorSet + SQL migrations + yaml | Both schemas already on disk; yaml carries policy + overrides + auto_set only. | +| 5 | Generated `*.gen.go` files committed to repo | Mapping is reviewable in PRs; CI checks for drift via `--check`. | +| 6 | SQL builder: `huandu/go-sqlbuilder` (private to package) | Mature; `PostgreSQL.NewUpdateBuilder()` emits `$1` placeholders cleanly; `Returning(...)` is a first-class method. | +| 7 | Row scan: direct `pgx.Rows.Values()` + proto reflection (no third-party scanner) | We populate a proto via reflection rather than a Go row struct; avoids an unnecessary dependency and a proto-aware shim. | +| 8 | Empty FieldMask rejected with `InvalidArgument` (default) | drill prefers explicit intent; documented divergence from AIP-134; permanent per resource once deployed (see *Wire conformance note*). | +| 9 | Deny-by-default writable; opt in via `writable:` list | Security posture; consistent with AIP-134 §Update_Mask "must not allow output-only fields." | +| 10 | Codegen errors on unsupported field types | Bad fields stop at codegen; runtime never sees a type it cannot handle. | +| 11 | Framework reads back via `RETURNING ` (not `*`) and returns the populated proto | One round-trip; AIP-134 compliant; explicit column list excludes unmapped columns from the wire. (Bound non-writable columns are still returned — that is the AIP contract.) | +| 12 | v0 codec set: scalars + timestamps + enum, NOT-NULL columns only | Smallest set that covers drill's `User` and most Spanda CRUD shapes. JSONB / nullable / bytes / float in v1. | +| 13 | v0 first user: drill's `UpdateProfile` | Validates the framework against an existing target; replaces the most boilerplate-heavy code path today. | +| 14 | Boot validation via `Mapping.Validate(Codecs) error` propagated to `main` through generated `InitPatches() error` | drill's no-panic-at-init rule. No `mustValidate` panic helper in generated code. | +| 15 | `AutoSet` clauses (e.g. `updated_at: NOW()`) declared per-resource in yaml | Replaces sqlc's hand-rolled `updated_at = NOW()` in every UPDATE; codegen-checked column existence, NOT-NULL, not-also-a-binding, and pg_query_go-validated literal. | +| 16 | Always commit `buf.binpb` and regenerate via `buf build -o buf.binpb` | Eliminates need for a live buf-build dependency at codegen time; CI's `aippatchgen --check` catches drift. | +| 17 | Codecs declared once in yaml under `codecs:`, emitted as a shared `var Codecs` registry in `init.gen.go`, passed to each `Mapping.Validate(Codecs)` | Single source of truth; cross-resource reuse; no per-Mapping duplication; unexported `m.codecs` populated from the subset reachable from `m.Bindings`. | +| 18 | `op.Where` keys must be bound columns; runtime validates before composing SQL | Prevents identifier injection through the map-key surface; values are pgx-parameterized. | +| 19 | `validated` is `atomic.Bool`; `Apply` rejects unvalidated mappings with `Internal` | Defends against `Apply` calls that race ahead of `InitPatches()` under `-race`; production code never interleaves the two but the guard is cheap. | + +## Open questions (deferred) + +- **JSONB shape (v1)** — for proto sub-messages, marshal via `protojson` or + accept opaque `[]byte` from the handler? Trade-offs around schema + evolution. +- **AIP-154 ETag column type (v3)** — `bigint` counter, `uuid` token, or + per-resource choice? Defer until use case is concrete. +- **Buf plugin migration path (v4)** — when (and if) v4 ships, the yaml + format remains the source of truth for policy; only mappings move to proto + annotations. Migration mechanics TBD. +- **Diff API shape (v1)** — for audit logging, is the returned `Diff` a + `map[string]struct{Before, After any}`, or a typed proto-aware structure? + Decide when v1 work begins. diff --git a/docs/superpowers/specs/2026-05-07-idle-auto-cancel-design.md b/docs/superpowers/specs/2026-05-07-idle-auto-cancel-design.md new file mode 100644 index 00000000..7b930555 --- /dev/null +++ b/docs/superpowers/specs/2026-05-07-idle-auto-cancel-design.md @@ -0,0 +1,656 @@ +# Idle Auto-Cancel — Design Spec + +**Date**: 2026-05-07 +**Status**: Draft (rounds 1 + 2 + 3 review applied) +**Scope**: Auto-cancel a Pro subscription when the user has been idle for two consecutive billing periods. No settings toggle — this is the default Sabermatic behavior. Establishes a Spanda, LLC product tenet: earn money for ongoing value delivered. + +--- + +## 1. Motivation + +A subscription that bills a user month after month for a service they haven't touched generates negative sentiment — remorse, distrust, "why am I still paying for this?" Industry convention is to keep collecting until the customer notices and cancels manually. We reject that convention. + +This feature implements the inverse: if a Pro user goes idle for a *full* billing period, and the next period also looks idle as it nears renewal, we set `cancel_at_period_end=true` *before* the next charge fires. The customer is informed, given a frictionless way to keep the subscription, and given access through their already-paid period regardless. They are never billed for a period they're about to skip too. + +This is a tenet for all Spanda, LLC products — Sabermatic[.DEV] is the first to implement it. + +--- + +## 2. Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Activity definition | Any authenticated request — touches `auth_sessions.last_active` | Matches user intent: "visiting the site while logged in counts." Drill activity is a strict subset (drill RPCs are authenticated). | +| Activity SQL source | `MAX(last_active)` from `auth_sessions` for the user, **across all rows including soft-revoked ones** | Single-table query. The activity question is *historical* ("when did this user last act?"), not *current* ("is this session valid?"), so it intentionally ignores `revoked_at`. | +| `auth_sessions` retention | Soft delete: add `revoked_at`; logout = `UPDATE`, account-delete = hard `DELETE` | Preserves rows through logout so the activity query can read across history. Audit trail. Consistent with existing `users.deleted_at` pattern. | +| Trigger rule | `MAX(last_active) < (current_period.start − 1 interval)` AND `current_period.start ≥ users.idle_eligible_after` | Two periods of evidence, evaluated near renewal. Second clause grandfathers periods that began before this feature shipped. | +| Evaluation moment | Stripe `invoice.upcoming` webhook (~7 days before next renewal) | Event-driven; no cron; Stripe carries the period boundaries. | +| Period boundaries source | `stripe-go/v82` puts these on `SubscriptionItem`, not `Subscription`. We read `sub.Items.Data[0].CurrentPeriodStart` and `Price.Recurring.Interval` from a fresh `subscription.Get(...)` at evaluation time. | The `invoice.upcoming` event's `Invoice.Lines.Data[0].Period` covers the *upcoming* period (after renewal). To get the period currently ending, we fetch the subscription. | +| JSONB timestamp format for period anchors in `user_events.metadata` | RFC3339 strings, written via `json.Marshal` of a Go `time.Time` field on a typed metadata struct. Read via `(metadata->>'current_period_start')::timestamptz`. | Postgres can cast RFC3339 text to `timestamptz` reliably across versions and timezones. Avoids the int64-Unix vs ISO ambiguity. The Go writer is the only producer; using a typed struct prevents drift. | +| Cancel mechanic | `cancel_at_period_end=true` | User keeps already-paid period. No refund. Reversible. | +| Distinguishing auto vs manual cancel | `users.sub_cancel_is_auto` BOOLEAN | Set true *only* when our handler initiates the cancel. Stripe's `cancel_at_period_end` is shared with manual portal cancels; without this distinction, our auto-reverse path would silently undo user-initiated cancellations. | +| Notification | Single email at decision moment with `[Keep my subscription]` link | One transparent message; the link reverses the cancel via signed token. | +| Reversal triggers | (a) clicking the email link, (b) any authed activity during the cancel window — but only if `sub_cancel_is_auto=true` | Activity in the current period invalidates the trigger condition; auto-reverse is rule-consistent. We never reverse a user's deliberate cancellation. | +| Reversal feedback | Banner on next page load + confirmation email on every reversal | Transparent and communicative. | +| Settings toggle | None | This is the default behavior; making it opt-in dilutes the tenet. | +| Code home | `internal/feat/idleunsub/` | New `internal/feat/` convention for cohesive feature packages. First entry. | +| Idempotency | New `stripe_webhook_dedup` table with `event_id TEXT PRIMARY KEY`. Insert-then-act pattern: ON CONFLICT DO NOTHING; if the insert returns no row, the event was already handled. | Stripe retries webhooks. Same event ID = same logical event. A separate dedup table is cleaner than embedding `stripe_event_id` in `user_events.metadata` (which can't be UNIQUE-constrained without expression indexes). | +| Multi-firing of `invoice.upcoming` for same period | State-aware predicate before acting: skip if Stripe says `cancel_at_period_end=true` already; skip if a `subscription_kept` event exists more recently than the most recent `subscription_auto_canceled` for the same `(user_id, subscription_id, current_period_start)`. | Stripe re-fires `invoice.upcoming` (with a fresh `event.ID`) when the upcoming invoice changes (proration, plan switch, coupon edit). Event-ID dedup alone would re-act on each firing. | +| Skip evaluation when sub is `trialing`, `past_due`, `unpaid`, `incomplete` | Early return | Trial: no prior period to evaluate. Past due / unpaid: Stripe's dunning logic owns the lifecycle; we don't fight it. | +| Period storage | Cache `sub_current_period_start` and `stripe_subscription_id` on `users` for hot-path reads (auto-reverse middleware gate). Authoritative read for cancel decisions = fresh Stripe `subscription.Get`. | Cache supports the cheap "is this user's sub auto-canceled and they just acted?" check. Authoritative reads avoid stale-cache risk on the cancel decision itself. | +| Multiple subscriptions per user | Out of scope | Drill assumes one subscription per user (`users.plan` is single-valued). If/when multi-sub is added, this feature is revisited. | + +--- + +## 3. Trigger Rule + +**Rule:** at the moment Stripe fires `invoice.upcoming` for subscription S of user U, evaluate: + +``` +// 1. Early returns +fetch S = subscription.Get(subID) +if S.Status not in {active}: return // skip trial/past_due/unpaid +if S.CancelAtPeriodEnd is already true: return // already canceled (by us or user) +if NOT TryClaimWebhookEvent(event.ID): return // retry-storm dedup + // (insert-on-conflict-do-nothing; + // no-row return = already handled) +if mostRecentPeriodDecisionWasKeep(U, subID, period): return // post-keep multi-firing dedup +if alreadyAutoCanceledThisPeriod(U, subID, period): return // pre-keep multi-firing dedup + +// 2. Read state +last_active = SELECT MAX(last_active) + FROM auth_sessions + WHERE user_id = U.id + -- (no revoked_at filter; activity is across history) + +cur_period_start = S.Items.Data[0].CurrentPeriodStart // see Data Sources below +interval = S.Items.Data[0].Price.Recurring.Interval // e.g., "month" +threshold = cur_period_start - 1 interval // start of period N-1 + +// 3. Trigger +if last_active < threshold AND cur_period_start >= U.idle_eligible_after: + set cancel_at_period_end = true on S + persist user_events row, cache update, queue email (see §4.1) +``` + +### Data Sources + +The Stripe Go SDK v82 splits subscription period fields between `Subscription` and `SubscriptionItem`. The fields the rule cares about live here: + +- `S.Items.Data[0].CurrentPeriodStart` (`int64` Unix seconds) — start of the period currently ending. +- `S.Items.Data[0].CurrentPeriodEnd` — end of the period currently ending; equals the next renewal moment. +- `S.Items.Data[0].Price.Recurring.Interval` — `"month"`, `"year"`, etc. + +The `invoice.upcoming` event payload (`stripe.Invoice`) carries `Invoice.Lines.Data[0].Period.Start` and `.End` for the *upcoming* period (after renewal) — not what we want. We fetch the subscription explicitly at evaluation time. + +### Example timeline + +User signs up Feb 1 with monthly billing. The trigger fires for each upcoming invoice: + +| When | Event | `last_active` | `cur_period_start` | `threshold` | Decision | +|---|---|---|---|---|---| +| Feb 22 | invoice.upcoming for Mar 1 renewal | Feb 8 (signup + early use) | Feb 1 | Jan 1 | last_active ≥ threshold → no cancel | +| Mar 22 | invoice.upcoming for Apr 1 renewal | Feb 8 (no Mar activity) | Mar 1 | Feb 1 | last_active ≥ threshold → no cancel (Feb activity counts) | +| Apr 22 | invoice.upcoming for May 1 renewal | Feb 8 (no Mar, no Apr activity) | Apr 1 | Mar 1 | last_active < threshold → **cancel_at_period_end=true** | + +The user pays for **two** unused periods (March and April in this example) before the cancel kicks in — the first while we're establishing the idle baseline, the second while we're confirming sustained idleness. The cancel prevents the *next* charge (May 1) and every charge after. + +A user who signs up Feb 1 and never returns has their cancel triggered at the Apr ~22 evaluation and loses access at May 1 — about 90 days after signup, after paying 3 monthly bills (Feb, Mar, Apr) of which two were unused. + +This two-billed-but-unused-periods cost is the deliberate price of the two-period rule. The alternative (one-period rule, which would prevent the second charge) was rejected because it can't be evaluated reliably at `invoice.upcoming` (only ~23 of 30 days are observed at that moment) and because a single quiet month is a noisy signal. + +### Why two periods, not one + +A one-period rule would have to evaluate at `invoice.upcoming` (~T-7 days before period_end), but that means the current period isn't actually fully observed yet — we'd be calling 23 of 30 days "the full period." Inconsistent. The two-period rule resolves it: at evaluation time, period N−1 is provably 100% complete and idle, regardless of what happens in the remaining 7 days of period N. + +A one-period rule is also too aggressive: a user who happens to take a single quiet month (vacation, busy quarter, surgery, parental leave) gets canceled. Two periods is meaningful evidence; one is noise. + +### Plan changes, pause/resume + +Stripe shifts `current_period_start` to the change date on plan changes. The same is true for portal-driven pause + resume (`pause_collection` flag + later resume). We accept both: a user paying enough attention to upgrade or resume is by definition not idle, so resetting the idle clock is correct. Documented as a known property, not a bug. + +Note: paused subscriptions retain `Status='active'` (`pause_collection` is a separate field). They pass our status early-return and are evaluated normally. An idle paused sub is still idle from this feature's perspective. If we later decide we want to *exclude* paused subs from evaluation, the gate is `S.PauseCollection != nil`. Out of scope for v1. + +--- + +## 4. Behavior + +### 4.1 Cancel path + +1. **T-7**: Stripe fires `invoice.upcoming`. Webhook handler in `internal/backend/billing.go` delegates to `idleunsub.HandleInvoiceUpcoming`. +2. Fetch the subscription from Stripe (`subscription.Get(subID, &SubscriptionParams{Expand: ["items"]})`). +3. Run early returns from §3 (status, already-canceled). +4. **Begin DB transaction:** + a. `SELECT id FROM users WHERE id = $1 FOR UPDATE`. **This is the per-user mutex**: two concurrent `invoice.upcoming` deliveries for the same user cannot both pass the dedup queries below before either has committed. Without this lock, two transactions starting under READ COMMITTED can both see no prior `subscription_auto_canceled` row, both proceed, and both fire Stripe + email. The lock serializes evaluations per user; webhook concurrency is low so contention is negligible. + b. `TryClaimWebhookEvent(event.ID, 'invoice.upcoming')` — `INSERT ... ON CONFLICT DO NOTHING RETURNING event_id`. If no row returned, ROLLBACK and return (event already handled). + c. Run period-keyed dedup queries (`mostRecentPeriodDecisionWasKeep`, `alreadyAutoCanceledThisPeriod`). If either signals a prior decision for this period, ROLLBACK and return. Both queries are kept (rather than collapsing to just `alreadyAutoCanceledThisPeriod`) for log/observability clarity — `cancel.skipped{reason=already_kept_this_period}` vs `cancel.skipped{reason=already_canceled_this_period}` distinguish two operationally interesting cases. + d. Read `last_active`, compute `threshold`. If trigger does not fire, ROLLBACK and return (no point claiming the event — let a future re-fire of `invoice.upcoming` for this same period have a fresh shot if state changes). + e. **Trigger fires:** insert a `user_events` row with `event_type='subscription_auto_canceled'`, metadata `{subscription_id, stripe_event_id, current_period_start, current_period_end}` (timestamps as RFC3339 strings via the typed metadata struct, canonicalized to second precision via `time.Unix(stripeInt64, 0).UTC()`). + f. Update `users` cache: `sub_cancel_at_period_end = true`, `sub_cancel_is_auto = true`, `sub_current_period_start = ...`, `stripe_subscription_id = ...`. + g. **COMMIT.** The lock, the dedup row, the audit row, and the cache update are now durable together. If the transaction rolls back at any point, the next webhook retry sees an unclaimed event and starts fresh. +5. Call Stripe: `subscription.Update(id, cancel_at_period_end=true)` with `Idempotency-Key: `. **Outside the transaction.** Stripe `Update` is naturally idempotent. +6. Queue the cancel email via `SendEmailJob` (River) using the `current_period_end` from step 4d's metadata. +7. **T-0** (period_end): Stripe naturally lets the subscription end. The existing `customer.subscription.deleted` handler resets `users.plan = 'free'` and clears the cache columns. + +**Failure-mode reasoning:** +- DB transaction fails before COMMIT → no Stripe call attempted, dedup not claimed, retry will see unclaimed event and start fresh. ✓ +- COMMIT succeeds, Stripe `Update` fails → cache says canceled, Stripe says not canceled. **This is the partial-failure window.** It must be handled in two places: + 1. **Server-side**: emit `idleunsub.cancel.error{reason=stripe_update_failed}` counter, page ops. Ops manually verifies Stripe state and either re-issues the cancel via Stripe API or clears the cache flag (`UPDATE users SET sub_cancel_at_period_end=false, sub_cancel_is_auto=false WHERE id=$1`). + 2. **Client-side**: `AutoReverse` (§4.3) verifies Stripe state via `subscription.Get` before calling `subscription.Update`. If Stripe says `cancel_at_period_end` is already `false`, AutoReverse silently clears the cache flag, logs a warning, and skips the email/`subscription_kept` event row. This prevents a spurious "subscription kept" email and audit row when no real reversal happened. Cost: one extra Stripe API call on the cold path that only fires for users whose cache flag is true. +- COMMIT succeeds, Stripe `Update` succeeds, email enqueue fails → cache and Stripe agree; user sees the banner on next visit and Stripe's own renewal-prevention behavior. Logged. +- Concurrent webhook deliveries for the same user → serialized by the `SELECT ... FOR UPDATE` in step 4a; only one transaction proceeds, the other waits and then sees the prior decision via the dedup queries. + +### 4.2 Reversal — email link click + +1. User receives the cancel email containing a signed token URL: `https://sabermatic.dev/sub/keep?t=`. +2. The token is HMAC-signed JSON serialized via a typed Go struct with explicit field order: `{user_id, subscription_id, action: "keep_subscription", current_period_end, iat, exp: current_period_end}`. The token carries `current_period_end` so the confirmation page (and replay) has the date without re-fetching from Stripe or relying on a possibly-stale cache. Encoded as URL-safe base64. +3. **Single-use enforcement**: the public endpoint `GET /sub/keep?t=...` (in `internal/handler/keep.go`): + a. Verifies signature. + - Bad signature / malformed token → **400** with a generic "this link is invalid" page. Distinguished from expiry to avoid leaking whether a given (user, sub, period) tuple ever existed. + - Valid signature but expired (current time > `exp`) → **200** with a friendly "your subscription period has already ended" page (no Stripe call, no email). The token doesn't grant any access; expiry just means the keep window is over. A bare 400 here is user-hostile per the tenet — distinguish from tampering. + - Spec also enforces invariant: `claims.ExpiresAt == claims.CurrentPeriodEnd.Unix()`. If they disagree (signer bug or struct drift), reject as malformed. + b. Valid signature + not expired: computes `token_hash = sha256(token)`. + c. INSERTs into `keep_link_token_uses (token_hash PRIMARY KEY, used_at)` with `ON CONFLICT DO NOTHING RETURNING token_hash`. If no row returned, the token was already used — render the same confirmation page using `claims.CurrentPeriodEnd` from the (still-valid-signature) token. No Stripe call, no email. + d. On first use: calls `idleunsub.KeepSubscription(ctx, claims)`, which: + - Confirms the subscription is still in `cancel_at_period_end=true` and `sub_cancel_is_auto=true` state (refuses to reverse a manual cancel). + - Calls `subscription.Update(id, cancel_at_period_end=false)`. Captures the returned `subscription` object for its updated period info. + - Updates `users` cache: `sub_cancel_at_period_end = false`, `sub_cancel_is_auto = false`, `pending_kept_banner = true`, `sub_current_period_start = `. + - Sends the "subscription kept" confirmation email; uses the returned subscription's `CurrentPeriodEnd` for the renewal date in the body. + - Inserts a `user_events` row with `event_type='subscription_kept'`, metadata `{subscription_id, via: 'link', current_period_start: }`. The `current_period_start` here matches the period the keep applies to and is what `mostRecentPeriodDecisionWasKeep` queries against. + e. Renders a confirmation page ("You're all set — your Sabermatic subscription is still active. Next renewal: {claims.CurrentPeriodEnd}."). +4. The endpoint is mounted publicly (no `RequireAuth`); the token IS the authentication. + +### 4.3 Reversal — auto-reverse on activity + +While `cancel_at_period_end=true` AND `sub_cancel_is_auto=true`, any authenticated request from the user invalidates the trigger condition. The hook lives in `Backend.AuthenticateSession` alongside the existing `TouchAuthSession` fire-and-forget pattern (`internal/backend/backend.go:280-285`): + +```go +// After existing TouchAuthSession goroutine, before returning user: +if user.SubCancelAtPeriodEnd && user.SubCancelIsAuto { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = idleunsub.AutoReverse(ctx, user.ID) // best-effort + }() +} +``` + +`AutoReverse`: +1. Re-reads `users.sub_cancel_at_period_end` and `users.sub_cancel_is_auto` (defensive: these may have flipped via webhook between the gate read and goroutine execution). If either is false, no-op. +2. **The current request itself is the activity signal.** We do *not* re-read `MAX(last_active)` — that would race against the in-flight `TouchAuthSession` goroutine, producing a stale read. The gate `SubCancelAtPeriodEnd && SubCancelIsAuto` plus the fact that this request authenticated successfully is sufficient evidence that the user is active in the current period. +3. **Verifies Stripe state** via `subscription.Get`. If Stripe says `cancel_at_period_end` is already `false`, the cache is stale (probably from a prior partial failure where COMMIT succeeded but Stripe `Update` did not). Silently clear the cache flags (`sub_cancel_at_period_end=false`, `sub_cancel_is_auto=false`), log a warning, emit `idleunsub.cancel.cache_drift_corrected`, and return without sending email or inserting `subscription_kept`. No real reversal happened. +4. Otherwise, calls `subscription.Update(id, cancel_at_period_end=false)`. Stripe call is idempotent (setting `false` when already `false` is a no-op, but step 3 already filtered that case). Captures the returned subscription for its `CurrentPeriodStart` and `CurrentPeriodEnd`. +5. Updates `users` cache: `sub_cancel_at_period_end = false`, `sub_cancel_is_auto = false`, `pending_kept_banner = true`, `sub_current_period_start = `. +6. Sends the confirmation email via `SendEmailJob`, using the returned subscription's `CurrentPeriodEnd` for the renewal date. +7. Inserts a `user_events` row with `event_type='subscription_kept'`, metadata `{subscription_id, via: 'auto_activity', current_period_start: }`. The `current_period_start` is what `mostRecentPeriodDecisionWasKeep` queries against — without it, multi-firing dedup would wrongly re-cancel after a keep. + +The TOCTOU window between step 1 (gate read) and step 4 (Stripe `Update`) is benign: setting `cancel_at_period_end=false` when Stripe says it's already `false` is a no-op, and the post-Get verification in step 3 short-circuits anyway. + +The cached `sub_cancel_at_period_end` flag is the gate that prevents Stripe API calls on every authed request — it's `false` for almost every user almost all the time. After AutoReverse flips it, subsequent requests skip the entire path. The `sub_cancel_is_auto` second gate prevents this code from reversing a user's manual portal cancellation. + +### 4.4 No reversal (cancel completes) + +User does nothing. At period_end, Stripe naturally deletes the subscription. The existing `customer.subscription.deleted` webhook handler runs, resets `users.plan = 'free'`, clears the cache columns (`sub_cancel_at_period_end`, `sub_cancel_is_auto`, `sub_current_period_start`, `stripe_subscription_id`). User retains anything they own (purchased grants, free-tier minutes), loses Pro features. + +### 4.5 Banner + +The `pending_kept_banner` flag on `users` is set during reversal (link or auto). The frontend reads it from the user-info RPC payload. The banner clears on **dismiss** (explicit user action — close button, a small RPC call sets the flag to `false`), not on render. This way, a user who closes the tab before noticing the banner gets it on the next page load. Auto-dismiss after 14 days as a hygiene measure. + +--- + +## 5. Schema Changes + +### Migration 015: `auth_sessions` soft delete + +```sql +-- 015_auth_sessions_soft_delete.up.sql +ALTER TABLE auth_sessions + ADD COLUMN revoked_at TIMESTAMPTZ; + +CREATE INDEX idx_auth_sessions_user_last_active + ON auth_sessions(user_id, last_active DESC); +``` + +```sql +-- 015_auth_sessions_soft_delete.down.sql +DROP INDEX IF EXISTS idx_auth_sessions_user_last_active; +ALTER TABLE auth_sessions DROP COLUMN revoked_at; +``` + +The new index is `DESC` on `last_active` so `MAX(last_active) WHERE user_id = $1` is a one-row scan. No `WHERE revoked_at IS NULL` filter — the activity query reads across history. + +**Write amplification note:** this index is updated on every `TouchAuthSession` (one per authed request). Cost is one additional B-tree update per request. Acceptable; the same row is hot anyway. + +**Retention:** rows are never physically deleted on logout under this migration. Account deletion still hard-deletes (§7.2). Rows are tiny (~100 bytes); we accept unbounded growth for v1 and revisit if it becomes a real cost. A future cleanup job could hard-delete `revoked_at < NOW() - 1 year` while preserving each user's most recent revoked row — out of scope here. + +### Migration 016: cache subscription state on users + +```sql +-- 016_users_sub_state.up.sql +ALTER TABLE users + ADD COLUMN stripe_subscription_id TEXT, + ADD COLUMN sub_cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN sub_cancel_is_auto BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN sub_current_period_start TIMESTAMPTZ, + ADD COLUMN pending_kept_banner BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN idle_eligible_after TIMESTAMPTZ NOT NULL DEFAULT NOW(); +``` + +| Column | Purpose | Populated by | +|---|---|---| +| `stripe_subscription_id` | The sub ID we need to call `subscription.Update` on | `handleSubscriptionUpdated` (Stripe fires this on subscription create as well as update — see §7.2 note). Cleared on `customer.subscription.deleted`. | +| `sub_cancel_at_period_end` | Hot-path gate for auto-reverse middleware | Synced from `customer.subscription.updated` events; written directly by `idleunsub` cancel/keep paths. | +| `sub_cancel_is_auto` | Distinguishes our auto-cancel from a user's manual portal cancel | Set `true` by `idleunsub.HandleInvoiceUpcoming` only. Set `false` by `KeepSubscription`/`AutoReverse`. **Never** set by webhook sync. | +| `sub_current_period_start` | Cached so we can detect "user came back since the cancel decision" without a Stripe round-trip; also populated by reversal paths from the `subscription.Update` response | Populated by `handleSubscriptionUpdated`, the cancel path (from the fetched-at-eval-time subscription), and the keep/auto-reverse paths (from the `subscription.Update` response). | +| `pending_kept_banner` | "Welcome back" banner state | Set by reversal paths; cleared on banner dismiss; auto-cleared after 14 days by a tiny periodic job. | +| `idle_eligible_after` | Period-grandfathering anchor for launch-day wave protection | Defaults to `NOW()` at migration. Migration applies to existing users → their first eligible period_start is whatever begins after deploy. New users get `NOW()` at signup. | + +### Migration 017: webhook event idempotency + +```sql +-- 017_stripe_webhook_dedup.up.sql +CREATE TABLE stripe_webhook_dedup ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +Generic table for webhook retry-storm protection. Other webhook handlers can adopt it incrementally; the existing `grants.stripe_event_id UNIQUE` pattern continues to work for grant creation. + +### Migration 018: keep-link token single-use + +```sql +-- 018_keep_link_token_uses.up.sql +CREATE TABLE keep_link_token_uses ( + token_hash BYTEA PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + used_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +`token_hash` is `sha256(raw_token)`; storing the hash, not the token, prevents leak risk if the table is ever exposed. + +### sqlc query changes (`sql/queries/auth_sessions.sql`) + +| Existing | Change | +|---|---| +| `GetAuthSessionByToken` | Add `AND s.revoked_at IS NULL` to WHERE; project the new `users` cache columns into the returned row. | +| `DeleteAuthSession` (single logout — sole call site is `internal/backend/auth.go:261`) | Rename to `RevokeAuthSession`; becomes `UPDATE auth_sessions SET revoked_at = NOW() WHERE id = $1`. | +| `DeleteUserAuthSessions` (logout-everywhere) | Rename to `RevokeUserAuthSessions`; becomes `UPDATE auth_sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL`. | +| `users.sql` `DELETE FROM auth_sessions WHERE user_id = @id` (account deletion) | Stays as hard DELETE, renamed for clarity to `HardDeleteUserAuthSessions`. | + +### New sqlc queries + +```sql +-- name: GetUserLastActive :one +-- Reads across all history (including revoked sessions) — this is a +-- "when did the user last act, ever?" query, not a session-validity check. +SELECT MAX(last_active)::timestamptz +FROM auth_sessions +WHERE user_id = $1; + +-- name: SetUserAutoCancelState :exec +UPDATE users +SET sub_cancel_at_period_end = TRUE, + sub_cancel_is_auto = TRUE, + sub_current_period_start = $2 +WHERE id = $1; + +-- name: ClearUserAutoCancelState :exec +UPDATE users +SET sub_cancel_at_period_end = FALSE, + sub_cancel_is_auto = FALSE, + pending_kept_banner = TRUE +WHERE id = $1; + +-- name: SyncSubStateFromWebhook :exec +-- Used by handleSubscriptionUpdated. Does NOT touch sub_cancel_is_auto: +-- that flag is only set by our handler, never by webhook sync. +UPDATE users +SET stripe_subscription_id = $2, + sub_cancel_at_period_end = $3, + sub_current_period_start = $4 +WHERE id = $1; + +-- name: ClearSubStateOnDeletion :exec +-- Used by handleSubscriptionDeleted. +UPDATE users +SET stripe_subscription_id = NULL, + sub_cancel_at_period_end = FALSE, + sub_cancel_is_auto = FALSE, + sub_current_period_start = NULL, + plan = 'free' +WHERE id = $1; + +-- name: ClearKeptBanner :exec +UPDATE users SET pending_kept_banner = FALSE WHERE id = $1; + +-- name: TryClaimWebhookEvent :one +-- Returns the event_id on first claim, no row on subsequent claims. +INSERT INTO stripe_webhook_dedup (event_id, event_type) +VALUES ($1, $2) +ON CONFLICT (event_id) DO NOTHING +RETURNING event_id; + +-- name: TryClaimKeepToken :one +INSERT INTO keep_link_token_uses (token_hash, user_id) +VALUES ($1, $2) +ON CONFLICT (token_hash) DO NOTHING +RETURNING token_hash; + +-- name: HasAutoCanceledThisPeriod :one +-- Returns true if a subscription_auto_canceled event already exists for this period. +-- metadata->>'current_period_start' is RFC3339 text written by the typed metadata struct. +SELECT EXISTS ( + SELECT 1 FROM user_events + WHERE user_id = $1 + AND event_type = 'subscription_auto_canceled' + AND (metadata->>'subscription_id') = $2 + AND (metadata->>'current_period_start')::timestamptz = $3 +); + +-- name: GetMostRecentKeptOrCanceledForPeriod :one +-- For multi-firing dedup: did a 'subscription_kept' event arrive after the +-- most recent 'subscription_auto_canceled' for this period? If yes, the user +-- already kept their sub for this period; don't re-cancel. +-- Both event types include current_period_start in their metadata (RFC3339 string). +SELECT event_type +FROM user_events +WHERE user_id = $1 + AND event_type IN ('subscription_auto_canceled', 'subscription_kept') + AND (metadata->>'subscription_id') = $2 + AND (metadata->>'current_period_start')::timestamptz = $3 +ORDER BY created_at DESC +LIMIT 1; +``` + +**Metadata schema** (the canonical Go writer struct, ensuring RFC3339 timestamps): + +```go +package idleunsub + +// All timestamps are canonicalized to second precision via +// time.Unix(stripeInt64, 0).UTC() before assignment. Stripe period fields +// arrive as int64 Unix seconds (no sub-second component); explicit second +// precision and UTC guard against future drift if a code path ever +// constructs a time.Time from a different source. JSON encoding via +// encoding/json produces RFC3339 ("...Z") which Postgres ::timestamptz +// parses reliably. + +type cancelMetadata struct { + SubscriptionID string `json:"subscription_id"` + StripeEventID string `json:"stripe_event_id"` + CurrentPeriodStart time.Time `json:"current_period_start"` + CurrentPeriodEnd time.Time `json:"current_period_end"` +} + +type keptMetadata struct { + SubscriptionID string `json:"subscription_id"` + Via string `json:"via"` // "link" | "auto_activity" + CurrentPeriodStart time.Time `json:"current_period_start"` // identifies the period kept; + // load-bearing for mostRecentPeriodDecisionWasKeep dedup +} +``` + +--- + +## 6. Emails + +Two messages, both sent via the existing `email.Sender` (Mailgun in prod, log-only in test). Templates live in `internal/feat/idleunsub/templates/`. The phrase "the last two billing periods" is composed at send time so it works for non-monthly intervals (annual: "the last two annual billing periods"; weekly: "the last two weeks"). + +### 6.1 Cancel decision email + +``` +Subject: We won't charge you for the next period + +Hi {first_name}, + +We noticed you haven't been around Sabermatic in the last two billing +periods, so we've stopped your auto-renewal. You'll keep access through +{current_period_end} — we won't charge you for the next period. + +If you'd like to keep your subscription active, one click does it: + + [Keep my subscription] + +If you're done for now, no action needed. We'll be here whenever you +want to come back. + +— Sabermatic +``` + +### 6.2 Subscription kept (confirmation) + +``` +Subject: Your subscription is still active + +Hi {first_name}, + +You're all set — your Sabermatic subscription will renew normally on +{next_renewal_date}. + +Welcome back. + +— Sabermatic +``` + +A "period has ended" email is intentionally out of scope. The existing `customer.subscription.deleted` handler does not need a new message under this feature; if we want one, it lives in the existing subscription-deletion path, not here. + +--- + +## 7. Implementation Surface + +### 7.1 Feature package — `internal/feat/idleunsub/` + +``` +internal/feat/idleunsub/ + idleunsub.go // public API + decision/action logic + email.go // compose the cancel + kept emails + templates/ + cancel.html.tmpl + cancel.txt.tmpl + kept.html.tmpl + kept.txt.tmpl + token.go // HMAC sign/verify for the keep-link + metrics.go // OTEL counter wrappers (see §9) + idleunsub_test.go // domain tests +``` + +Public API (Go): + +```go +package idleunsub + +// HandleInvoiceUpcoming evaluates the trigger and, if it fires, +// sets cancel_at_period_end on Stripe and sends the cancel email. +// Idempotent: safe to call multiple times with the same event. +func (s *Service) HandleInvoiceUpcoming(ctx context.Context, event stripe.Event) error + +// KeepSubscription reverses cancel_at_period_end. Called from the +// keep-link endpoint after token verification + single-use claim. +// Refuses to reverse if sub_cancel_is_auto is false (manual cancel). +// Takes the verified token claims (which carry CurrentPeriodEnd for the +// confirmation email) so we don't need an extra Stripe round-trip just +// for the date. +func (s *Service) KeepSubscription(ctx context.Context, claims KeepTokenClaims) error + +// AutoReverse reverses cancel_at_period_end when activity is detected +// in the current period. Looks up subscription state from cached users row. +// No-op if cache says cancel is off, or if cancel is not the auto kind. +func (s *Service) AutoReverse(ctx context.Context, userID uuid.UUID) error + +// SignKeepToken returns a signed token for embedding in the cancel email. +// Token is a Go struct serialized via canonical JSON (typed fields, fixed +// order); HMAC-SHA256; URL-safe base64. +func (s *Service) SignKeepToken(claims KeepTokenClaims) string + +// VerifyKeepToken parses and validates a token from the keep-link URL. +// Does NOT consume single-use; that's the endpoint's responsibility. +func (s *Service) VerifyKeepToken(token string) (KeepTokenClaims, error) + +type KeepTokenClaims struct { + UserID uuid.UUID `json:"user_id"` + SubscriptionID string `json:"subscription_id"` + Action string `json:"action"` // "keep_subscription" + CurrentPeriodEnd time.Time `json:"current_period_end"` // RFC3339; for confirmation email + replay page + IssuedAt int64 `json:"iat"` // Unix seconds (JWT-style) + ExpiresAt int64 `json:"exp"` // Unix seconds; INVARIANT: == CurrentPeriodEnd.Unix() +} + +// Invariant enforced by both Sign and Verify: +// ExpiresAt == CurrentPeriodEnd.Unix() +// SignKeepToken populates ExpiresAt from CurrentPeriodEnd; VerifyKeepToken +// rejects tokens where the two disagree (struct drift / forged token). +``` + +`Service` is constructed once at startup with: a Stripe client, the database, the email sender, the HMAC signing key (already used elsewhere — see existing tokens.go), a clock, and an `slog.Logger`. + +### 7.2 Integration adapters (live in conventional places) + +| File | Change | +|---|---| +| `internal/backend/billing.go` `HandleStripeWebhook` | Add `case "invoice.upcoming"` → delegate to `b.idleunsub.HandleInvoiceUpcoming`. Also handle `customer.subscription.created` (currently unhandled) → route to `handleSubscriptionUpdated` (same body works for both create and update — Stripe sends both events on subscription creation, with identical payload shape). | +| `internal/backend/billing.go` `handleSubscriptionUpdated` | **Extends the existing function body** (currently calls only `UpdatePlanByStripeCustomer`). Add a `SyncSubStateFromWebhook` call that reads `id`, `current_period_start`, and `cancel_at_period_end` from the event's Subscription object and writes them to `users`. This becomes the sole population path for `users.stripe_subscription_id` and `sub_current_period_start` — Stripe fires `customer.subscription.updated` (and `.created`) immediately after checkout completion, on every period renewal, on plan changes, on portal cancel/resume, so this single hook covers all cases. **Does not touch `sub_cancel_is_auto`** (only `idleunsub.HandleInvoiceUpcoming` sets it). The existing `handleCheckoutCompleted` is unchanged — its `mode != "payment"` early return still applies; subscription-mode checkouts populate state via `customer.subscription.created`/`.updated` instead. **Round-trip note:** when our cancel path sets both `sub_cancel_at_period_end=true` and `sub_cancel_is_auto=true`, Stripe then fires `customer.subscription.updated` with `cancel_at_period_end=true` in the payload. `SyncSubStateFromWebhook` overwrites our `sub_cancel_at_period_end` (with the same `true` value) but does *not* touch `sub_cancel_is_auto`, which stays `true`. The flag round-trip is correct by construction. | +| `internal/backend/billing.go` `handleSubscriptionDeleted` | Use `ClearSubStateOnDeletion`. | +| `internal/backend/backend.go` | Wire the `idleunsub.Service` into `Backend` at construction. | +| `internal/handler/keep.go` (new) | `GET /sub/keep` endpoint: verify token, claim single-use, call `idleunsub.KeepSubscription`, render confirmation page. Mounted publicly (no `RequireAuth`). | +| `internal/backend/backend.go` `AuthenticateSession` | After the existing `TouchAuthSession` goroutine, add a second fire-and-forget goroutine that calls `idleunsub.AutoReverse(ctx, user.ID)` iff `user.SubCancelAtPeriodEnd && user.SubCancelIsAuto`. Same 5s timeout pattern. | +| `internal/auth/user_context.go` | Extend `AuthUser` struct with `SubCancelAtPeriodEnd bool`, `SubCancelIsAuto bool`, `PendingKeptBanner bool`. | +| `internal/backend/auth.go:261` (Logout) | Switch from `DeleteAuthSession` to `RevokeAuthSession` (the renamed query). Sole caller. | +| `web/src/components/KeptBanner.tsx` (new) | One-time banner. Reads `pending_kept_banner` from the user-info RPC. Cleared on user dismiss via small ack RPC. Auto-dismisses after 14 days server-side. | +| `internal/rpc/user/server.go` | Include `pending_kept_banner` in the user-info RPC response. Add an `AckKeptBanner` RPC method that calls `ClearKeptBanner`. (ConnectRPC handlers live under `internal/rpc/{service}/` per project convention.) | +| `sql/queries/auth_sessions.sql`, `sql/queries/users.sql` | Updates per §5. | +| `sql/migrations/015_*.sql`, `016_*.sql`, `017_*.sql`, `018_*.sql` | New migrations per §5. | + +### 7.3 Email link / token + +- Token payload: typed `KeepTokenClaims` struct serialized with `encoding/json` over a fixed field order. Canonical because the struct is the only writer. +- Signing: HMAC-SHA256 with a separate `KEEP_TOKEN_HMAC_KEY` env var, derived at startup. Separating from the session-token signing key prevents cross-purpose token forgery. +- Encoding: URL-safe base64. +- Expiry: `exp = current_period_end`. After period end, the cancel either took effect or was reversed; reusing the token has no effect. +- **Replay protection**: server-side single-use via `keep_link_token_uses` (token-hash PRIMARY KEY). The first click "consumes" the token; subsequent clicks render the same confirmation page without side effects. +- **Leak surface**: tokens land in our own server access logs. Mitigation: the token is single-use, so a logged token cannot be replayed for action. The token does not grant any other access (no session, no API auth) — it specifically and only triggers the keep action for the one subscription it was minted for. + +--- + +## 8. Testing + +### 8.1 Domain tests (in `idleunsub_test.go`) + +- **Trigger rule** — table-driven cases: + - `last_active` exactly equals threshold → not idle (`<` is strict). + - `last_active` one second before threshold → idle, cancel. + - `last_active` is `NULL` (no rows for user — should be unreachable for a Pro user, but defensive) → no cancel; no evidence to act on. + - First-period evaluation (user signed up in current period) → threshold predates signup → trivially not idle → no cancel. + - `cur_period_start < idle_eligible_after` → period grandfathered → no cancel even if idle. + - Sub status `trialing` / `past_due` / `unpaid` / `incomplete` → early return. + - Sub already `cancel_at_period_end=true` → early return (no duplicate email). + - Same `event_id` re-delivered → second call is a no-op (dedup table claim fails). + - Same period, different event_id (multi-firing) → second call sees prior `subscription_auto_canceled` row → no-op. + - Same period, prior `subscription_kept` more recent than prior `subscription_auto_canceled` → no-op (don't re-cancel after keep). +- **KeepSubscription**: + - Refuses to reverse when `sub_cancel_is_auto=false` (manual cancel scenario). + - Idempotent: token already claimed → no Stripe call, no email. +- **AutoReverse**: + - Gate: `sub_cancel_at_period_end=false` → no-op. + - Gate: `sub_cancel_is_auto=false` → no-op (don't reverse manual cancels). + - Both true → reverses, flips cache, sends email, sets banner flag. + - **Race-safety:** does not depend on `MAX(last_active)` — the request itself is the activity signal. +- **Token**: tampered token → verify fails. Expired token → verify fails. Single-use enforced. + +### 8.2 Integration tests (live DB + stub Stripe) + +Following the existing `internal/backendtest/` convention (use `backendtest.SeedUser` and the production `Signup` path; never raw SQL inserts). + +- **End-to-end happy path** — seed a Pro user with `last_active` in Feb, fire `invoice.upcoming` for May renewal, assert: Stripe `Update` called with `cancel_at_period_end=true`, `users.sub_cancel_is_auto=true` set, email sent, `user_events` row inserted with `event_type='subscription_auto_canceled'`, `stripe_webhook_dedup` row created. +- **End-to-end reversal via link** — given an auto-canceled sub, hit `GET /sub/keep?t=...` with a valid token, assert reversal + flag flips + confirmation email + `subscription_kept` row + `keep_link_token_uses` row. Re-click → idempotent confirmation page, no second email. +- **End-to-end auto-reverse via activity** — given an auto-canceled sub, simulate an authed request, assert reversal + cache flips + confirmation email + banner flag set. +- **Manual cancel guard** — user manually cancels via portal (simulate via webhook); `sub_cancel_at_period_end=true` but `sub_cancel_is_auto=false`; subsequent authed request → AutoReverse is a no-op; user's choice respected. +- **Idempotent webhook re-delivery** — fire same `invoice.upcoming` event twice → one cancel call, one email, one dedup row. +- **Multi-firing of `invoice.upcoming`** — fire two distinct events for same period → second one is no-op via period dedup. +- **Launch wave** — seed an existing Pro user with `idle_eligible_after = NOW() + 1 month`, fire `invoice.upcoming` with `cur_period_start < idle_eligible_after`, assert no cancel. +- **Token tampering** — modified token → 400 with generic "invalid link" page. No DB or Stripe state change. +- **Token expired** — past-period token (valid signature, expired) → 200 with friendly "your subscription period has already ended" page. No DB or Stripe state change. Distinguished from tampering on purpose. +- **Token signer/verifier invariant** — token where `claims.ExpiresAt != claims.CurrentPeriodEnd.Unix()` → rejected as malformed. +- **Token replay** — valid token used twice → first works, second returns the same confirmation page idempotently with no Stripe call. +- **Concurrent webhook race** — two simultaneous `invoice.upcoming` deliveries for the same user → `SELECT FOR UPDATE` serializes them; one runs to completion, the other waits and then sees the prior decision via `alreadyAutoCanceledThisPeriod`. +- **Cache drift correction** — seed a Pro user with `sub_cancel_at_period_end=true` (cache) but Stripe-side `cancel_at_period_end=false` (simulating partial-failure window) → next authed request triggers AutoReverse → AutoReverse's `subscription.Get` detects drift → silently clears cache, emits `idleunsub.cancel.cache_drift_corrected`, no email, no `subscription_kept` row. + +--- + +## 9. Operational Notes + +### Observability + +Each decision emits an `slog` record AND an OTEL counter: + +| Counter | Tags | When | +|---|---|---| +| `idleunsub.cancel.fired` | `sub_id` | Trigger fires, cancel set on Stripe | +| `idleunsub.cancel.skipped` | `reason ∈ {trialing, past_due, already_canceled, dedup, grandfathered}` | Early-return in handler | +| `idleunsub.reverse.link` | `sub_id` | Reversal via email-link click | +| `idleunsub.reverse.activity` | `sub_id` | Reversal via auto-activity middleware | +| `idleunsub.email.enqueue` | `kind ∈ {cancel, kept}, status ∈ {ok, error}` | Email enqueued (or enqueue itself failed). Delivery success/failure is the email worker's concern, not this counter. | +| `idleunsub.cancel.error` | `reason ∈ {stripe_update_failed, db_commit_failed}` | Stripe `Update` returned error after DB commit, or DB transaction failed mid-cancel. Pages ops. | +| `idleunsub.cancel.cache_drift_corrected` | `sub_id` | AutoReverse detected the local cache disagreed with Stripe (cache said canceled, Stripe said not). Cache silently corrected; no email/event row. Should be near-zero in steady state — sustained nonzero indicates a Stripe-failure pattern worth investigating. | + +A dashboard panel for `cancel.fired` minus `reverse.{link,activity}` shows net cancellations per period. Sustained large drift in one direction is worth investigating (regression where `last_active` isn't being updated, misconfigured `idle_eligible_after`, etc.). + +### Email deliverability + +§4.1 sequences: DB transaction (dedup-claim + user_events + cache) → COMMIT → Stripe call → email enqueue. If the Stripe call succeeds but email enqueue fails, the cancel still applies (Stripe and our cache agree). The user discovers via the in-app banner on next visit, or via Stripe's own renewal-prevention behavior. Email failures are logged and counted via `idleunsub.email.enqueue{status=error}`. + +### HMAC key rotation + +Rotating `KEEP_TOKEN_HMAC_KEY` invalidates all outstanding keep-link tokens (max age = period length, typically ≤ 30 days from email send for monthly subs). Mitigations, in order of preference: + +1. **Don't rotate routinely.** This key has a small surface (only signs keep-link tokens) and is not in the request hot path. +2. **Two-key verification window.** During rotation, accept tokens signed with either the old or new key for one period; retire the old key after. +3. **Accept the breakage.** Users with broken links can still use AutoReverse on next login (they just won't have the email link path during the rotation window). + +Default policy for v1: option 1 (no scheduled rotation). Document option 2 as the path forward if a security incident requires rotation. + +### Backfill + +- `auth_sessions.revoked_at` defaults to NULL — no backfill. +- `users.idle_eligible_after` defaults to `NOW()` at migration — every existing Pro user is grandfathered for at least their current period (and for their prior period, since `cur_period_start < idle_eligible_after` until the next period rolls over). +- `users.stripe_subscription_id` will be NULL for existing users until their next `customer.subscription.updated` webhook fires (which Stripe re-fires on any subscription state change). For users who don't change anything, the field stays NULL and AutoReverse is a no-op via its existing gates. Optional one-time backfill: query Stripe for every user with `stripe_customer_id IS NOT NULL` and a current sub, populate the new field. Non-blocking; can ship without. + +### Rollback + +- Migration down-scripts restore prior state. Dropping `auth_sessions.revoked_at` reverts the table; the only data loss is "knowing when sessions were revoked." +- The feature can be turned off without rolling back schema by removing `case "invoice.upcoming"` from `HandleStripeWebhook`. The package stays in place dormant. +- If a bad cancel slips through, `KeepSubscription` is the per-user remedy; for a wider issue, a one-shot ops script can scan `user_events` for `subscription_auto_canceled` rows in a time window and call `subscription.Update(cancel_at_period_end=false)` for each. + +### Launch wave protection + +The `idle_eligible_after` column (default `NOW()` at migration) ensures no existing Pro user is canceled on day 1 of deploy. Their first eligible period is the first one whose `current_period_start ≥ idle_eligible_after`. For monthly subs, this means earliest cancel for an existing user is ~60 days post-deploy (if they were already idle and continue to be). + +--- + +## 10. Out of Scope / Open Questions + +- **Multiple subscriptions per user.** Drill assumes one. If a user ever has multiple, this feature needs revisiting. +- **Annual subscriptions.** Rule generalizes — "previous period start" is just `current_period_start − 1 interval`. Annual means a user signing up and going idle wouldn't be canceled until ~24 months later. Probably correct (annual buyers are presumably committed); revisit if Sabermatic ever offers annual. +- **Toggle later?** If we ever discover users want to opt *out* of this protection (e.g., enterprise customers with shared seats where idleness doesn't reflect intent), we revisit. Default-on holds until then. +- **Spanda portability.** The *tenet* is portable; the *code* is not — each Spanda product implements its own version against its own data model. A future cross-product library might extract the trigger evaluator if it pays for itself. +- **Cancel-window UI surface beyond the banner.** Should the billing page show "your subscription will end on {date} unless you log in or click here"? Current spec says no; easy add-on if we want it. +- **Package name.** Round 1 review: both Opus and Sonnet flagged `idleunsub` as awkward (reads like newsletter-unsub). Author chose it explicitly. Open: rename to `idlecancel` or `autocancel` later if the awkwardness compounds during implementation. +- **Keep semantics for future periods.** A click on the email keep-link reverses *this* period's cancel but does not update `last_active`. If the user clicks keep but then never logs in, the rule re-fires next period (after another full idle period N+1 passes). v1 treats this as correct: clicking keep is "I want this period," not "I want to keep paying forever even without using." Revisit if real users hit a re-cancel loop. +- **Auto-prune of `auth_sessions`.** Soft-delete leaves rows forever. v1 accepts the bloat; revisit when storage cost becomes real. +- **Auto-prune of `keep_link_token_uses`.** Rows are only created on first click. After `exp = period_end` passes, the token is expired regardless and the row's no-replay protection becomes moot. A periodic `DELETE FROM keep_link_token_uses WHERE used_at < NOW() - INTERVAL '60 days'` keeps the table trim. Boy-scout cleanup; not blocking. +- **Generic webhook dedup adoption.** `stripe_webhook_dedup` is generic; other handlers (`invoice.paid`, `customer.subscription.*`) could adopt it for consistency. Out of scope for this feature. + +--- + +## 11. Glossary + +- **Period N** (a.k.a. the *current period*): the billing period the user is currently in (read from `SubscriptionItem.CurrentPeriodStart` / `.CurrentPeriodEnd` in stripe-go v82). +- **Period N−1**: the previous period (`current_period_start − 1 interval` to `current_period_start`). +- **Threshold**: `current_period_start − 1 interval` — the moment before which `last_active` must fall for the trigger to fire. +- **invoice.upcoming**: Stripe webhook fired ~7 days before each renewal (default; configurable per Stripe account). +- **`cancel_at_period_end`**: Stripe subscription flag. When `true`, Stripe completes the current paid period and then deletes the subscription instead of renewing. Reversible until period ends. +- **`sub_cancel_is_auto`**: our boolean distinguishing an auto-cancel set by this feature from a manual cancel set by the user via Stripe portal. Only the former is reversible by AutoReverse. +- **Keep-link**: signed-token URL embedded in the cancel email; clicking it reverses `cancel_at_period_end`. Single-use enforced server-side. +- **`idle_eligible_after`**: per-user timestamp before which `current_period_start` values are grandfathered (no cancel). Used to prevent a wave of cancellations at deploy time. diff --git a/internal/auth/user_context.go b/internal/auth/user_context.go index 8ec28cac..3ef062ba 100644 --- a/internal/auth/user_context.go +++ b/internal/auth/user_context.go @@ -22,6 +22,11 @@ type AuthUser struct { Plan string EmailVerified bool CreatedAt time.Time + + // Auto-cancel state cache. + SubCancelAtPeriodEnd bool + SubCancelIsAuto bool + PendingKeptBanner bool } // SessionAuthenticator validates a hashed session token and returns the diff --git a/internal/backend/auth.go b/internal/backend/auth.go index ef86ebec..d57c5483 100644 --- a/internal/backend/auth.go +++ b/internal/backend/auth.go @@ -258,8 +258,8 @@ func (b *Backend) Logout(ctx context.Context, sessionToken string) (err error) { session, err := queries.GetAuthSessionByToken(ctx, tokenHash) if err == nil { - if delErr := queries.DeleteAuthSession(ctx, session.ID); delErr != nil { - slog.Error("delete auth session", "error", delErr) + if revokeErr := queries.RevokeAuthSession(ctx, session.ID); revokeErr != nil { + slog.Error("revoke auth session", "error", revokeErr) } } return nil @@ -384,9 +384,9 @@ func (b *Backend) ResetPassword(ctx context.Context, p ResetPasswordParams) (err return fmt.Errorf("update password: %w", err) } - // Invalidate all sessions. - if err := queries.DeleteUserAuthSessions(ctx, userID); err != nil { - return fmt.Errorf("delete user sessions: %w", err) + // Revoke all sessions (soft-delete so activity history is preserved). + if err := queries.RevokeUserAuthSessions(ctx, userID); err != nil { + return fmt.Errorf("revoke user sessions: %w", err) } return nil } diff --git a/internal/backend/auth_test.go b/internal/backend/auth_test.go index 5dff2312..38a6f079 100644 --- a/internal/backend/auth_test.go +++ b/internal/backend/auth_test.go @@ -8,10 +8,13 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/require" + stripe "github.com/stripe/stripe-go/v82" "github.com/btc/drill/internal/auth" "github.com/btc/drill/internal/backend" + "github.com/btc/drill/internal/backendtest" "github.com/btc/drill/internal/db" + "github.com/btc/drill/internal/feat/idleunsub/idleunsubtest" "github.com/btc/drill/internal/jobs" ) @@ -274,11 +277,12 @@ func TestLogout_Success(t *testing.T) { err = b.Logout(ctx, loginRes.Token) require.NoError(t, err) - // Session should be deleted -- lookup by hash should fail. + // Session should be revoked (soft-deleted) -- lookup by token should fail + // because GetAuthSessionByToken filters on revoked_at IS NULL. tokenHash := auth.HashSessionToken(loginRes.Token) queries := db.New(b.Pool()) _, err = queries.GetAuthSessionByToken(ctx, tokenHash) - require.Error(t, err) // pgx.ErrNoRows + require.Error(t, err) // pgx.ErrNoRows — revoked session is invisible } func TestLogout_NonExistentToken(t *testing.T) { @@ -291,6 +295,55 @@ func TestLogout_NonExistentToken(t *testing.T) { require.NoError(t, err) } +func TestLogout_SoftDeletes_PreservesActivityHistory(t *testing.T) { + t.Parallel() + b := pg.NewBackend(t) + ctx := context.Background() + + signupUser(t, b, "softlogout@example.com", "strongpass1", "SoftLogout") + loginRes, err := b.Login(ctx, backend.LoginParams{ + Email: "softlogout@example.com", + Password: "strongpass1", + IP: "127.0.0.1:1234", + }) + require.NoError(t, err) + + // Logout should soft-delete the session. + require.NoError(t, b.Logout(ctx, loginRes.Token)) + + // GetAuthSessionByToken should no longer find it (revoked_at IS NULL filter). + tokenHash := auth.HashSessionToken(loginRes.Token) + queries := db.New(b.Pool()) + _, err = queries.GetAuthSessionByToken(ctx, tokenHash) + require.Error(t, err, "revoked session should not be returned by GetAuthSessionByToken") + + // The session row should still exist with revoked_at set (soft delete). + var revokedAt pgtype.Timestamptz + err = b.Pool().QueryRow(ctx, + `SELECT revoked_at FROM auth_sessions WHERE user_id = $1`, + loginRes.UserID).Scan(&revokedAt) + require.NoError(t, err) + require.True(t, revokedAt.Valid, "revoked_at should be set after logout") + + // GetUserLastActive should still return a value (reads across revoked sessions). + last, err := queries.GetUserLastActive(ctx, loginRes.UserID) + require.NoError(t, err) + require.True(t, last.Valid, "last_active should have a value across revoked sessions") +} + +func TestGetUserLastActive_NoSessions(t *testing.T) { + t.Parallel() + b := pg.NewBackend(t) + ctx := context.Background() + // SeedUser calls Signup only — no auth_sessions row is created, so no + // DELETE needed to reach the "zero sessions" precondition. + userID := backendtest.SeedUser(t, b) + + last, err := db.New(b.Pool()).GetUserLastActive(ctx, userID) + require.NoError(t, err) + require.False(t, last.Valid, "MAX over zero rows should be NULL") +} + // --------------------------------------------------------------------------- // VerifyEmail // --------------------------------------------------------------------------- @@ -418,7 +471,7 @@ func TestResetPassword_Success(t *testing.T) { tokenHash := auth.HashSessionToken(loginRes.Token) queries := db.New(b.Pool()) _, err = queries.GetAuthSessionByToken(ctx, tokenHash) - require.Error(t, err) // session deleted + require.Error(t, err) // session revoked (soft-deleted) — invisible to token lookup // Can login with new password. newLoginRes, err := b.Login(ctx, backend.LoginParams{ @@ -436,6 +489,51 @@ func TestResetPassword_Success(t *testing.T) { require.ErrorIs(t, err, backend.ErrInvalidCredentials) } +// --------------------------------------------------------------------------- +// AuthenticateSession +// --------------------------------------------------------------------------- + +func TestAuthenticateSession_ProjectsSubState(t *testing.T) { + t.Parallel() + b := pg.NewBackend(t) + ctx := context.Background() + + signupRes := signupUser(t, b, "substate@example.com", "testpassword123", "SubState") + + loginRes, err := b.Login(ctx, backend.LoginParams{ + Email: "substate@example.com", + Password: "testpassword123", + }) + require.NoError(t, err) + + tokenHash := auth.HashSessionToken(loginRes.Token) + + // Assert schema defaults: all three booleans must be FALSE before mutation. + user, err := b.AuthenticateSession(ctx, tokenHash) + require.NoError(t, err) + require.False(t, user.SubCancelAtPeriodEnd) + require.False(t, user.SubCancelIsAuto) + require.False(t, user.PendingKeptBanner) + + // Raw SQL: SetUserAutoCancelState doesn't touch pending_kept_banner, + // and we want to assert all three projections at once. + _, err = b.Pool().Exec(ctx, ` + UPDATE users + SET sub_cancel_at_period_end = TRUE, + sub_cancel_is_auto = TRUE, + pending_kept_banner = TRUE + WHERE id = $1`, signupRes.UserID) + require.NoError(t, err) + + // AuthenticateSession re-reads the user row on every call — same token is valid. + user, err = b.AuthenticateSession(ctx, tokenHash) + require.NoError(t, err) + require.True(t, user.SubCancelAtPeriodEnd) + require.True(t, user.SubCancelIsAuto) + require.True(t, user.PendingKeptBanner) + require.Equal(t, "substate@example.com", user.Email) +} + func TestResetPassword_InvalidToken(t *testing.T) { t.Parallel() b := pg.NewBackend(t) @@ -626,3 +724,168 @@ func TestDeleteAccount_Idempotent(t *testing.T) { err = b.DeleteAccount(ctx, signupRes.UserID) require.NoError(t, err) } + +// --------------------------------------------------------------------------- +// AuthenticateSession — AutoReverse hook +// --------------------------------------------------------------------------- + +// TestAuthenticateSession_FiresAutoReverseWhenGatesSet verifies that +// AuthenticateSession triggers AutoReverse in the background when a user's +// sub_cancel_at_period_end and sub_cancel_is_auto are both TRUE. +func TestAuthenticateSession_FiresAutoReverseWhenGatesSet(t *testing.T) { + t.Parallel() + b := pg.NewBackend(t) + ctx := context.Background() + + signupRes := signupUser(t, b, "autoreverse@example.com", "testpassword123", "AutoReverse") + userID := signupRes.UserID + + subID := "sub_test_autoreverse_" + userID.String()[:8] + now := time.Now().UTC().Truncate(time.Second) + periodStart := now.AddDate(0, -1, 0) // one month ago + periodEnd := now.AddDate(0, 0, 7) // one week from now + + // Mutate user to look like it was auto-canceled. + _, err := b.Pool().Exec(ctx, ` + UPDATE users + SET sub_cancel_at_period_end = TRUE, + sub_cancel_is_auto = TRUE, + stripe_customer_id = $2, + stripe_subscription_id = $3, + sub_current_period_start = $4 + WHERE id = $1`, + userID, + "cus_test_autoreverse", + subID, + pgtype.Timestamptz{Time: periodStart, Valid: true}, + ) + require.NoError(t, err) + + // Fake Stripe: subscription still has CancelAtPeriodEnd=true, so + // AutoReverse will take the real-reversal branch. + fakeSub := &stripe.Subscription{ + ID: subID, + Status: stripe.SubscriptionStatusActive, + CancelAtPeriodEnd: true, + Items: &stripe.SubscriptionItemList{Data: []*stripe.SubscriptionItem{{ + CurrentPeriodStart: periodStart.Unix(), + CurrentPeriodEnd: periodEnd.Unix(), + }}}, + } + fakeStripe := idleunsubtest.NewFakeStripe() + fakeStripe.Subs[subID] = fakeSub + svc := idleunsubtest.NewServiceWithFake(t, b.Pool(), fakeStripe) + b.ApplyTestOverrides(backend.TestOverrides{Idleunsub: svc}) + + // Login to get a valid session token. + loginRes, err := b.Login(ctx, backend.LoginParams{ + Email: "autoreverse@example.com", + Password: "testpassword123", + IP: "127.0.0.1:1234", + }) + require.NoError(t, err) + tokenHash := auth.HashSessionToken(loginRes.Token) + + // AuthenticateSession fires the goroutine. + _, err = b.AuthenticateSession(ctx, tokenHash) + require.NoError(t, err) + + // Wait for the background goroutine to clear the cache. + require.Eventually(t, func() bool { + var cancelAtEnd bool + err := b.Pool().QueryRow(ctx, + `SELECT sub_cancel_at_period_end FROM users WHERE id = $1`, + userID).Scan(&cancelAtEnd) + return err == nil && !cancelAtEnd + }, 2*time.Second, 50*time.Millisecond, "AutoReverse goroutine did not clear sub_cancel_at_period_end") + + // Cache cleared: both gates must be FALSE, banner must be TRUE. + var cancelAtEnd, cancelIsAuto, pendingBanner bool + err = b.Pool().QueryRow(ctx, + `SELECT sub_cancel_at_period_end, sub_cancel_is_auto, pending_kept_banner FROM users WHERE id = $1`, + userID).Scan(&cancelAtEnd, &cancelIsAuto, &pendingBanner) + require.NoError(t, err) + require.False(t, cancelAtEnd, "sub_cancel_at_period_end should be cleared") + require.False(t, cancelIsAuto, "sub_cancel_is_auto should be cleared") + require.True(t, pendingBanner, "pending_kept_banner should be set") + + // One subscription_kept event row with via=auto_activity. + var count int + err = b.Pool().QueryRow(ctx, + `SELECT COUNT(*) FROM user_events + WHERE user_id = $1 AND event_type = 'subscription_kept' + AND metadata->>'via' = 'auto_activity'`, + userID).Scan(&count) + require.NoError(t, err) + require.Equal(t, 1, count, "expected 1 subscription_kept event with via=auto_activity") +} + +// TestAuthenticateSession_DoesNotFireAutoReverseForManualCancel verifies that +// the AutoReverse goroutine is NOT triggered when sub_cancel_is_auto is FALSE +// (manual portal cancel). +func TestAuthenticateSession_DoesNotFireAutoReverseForManualCancel(t *testing.T) { + t.Parallel() + b := pg.NewBackend(t) + ctx := context.Background() + + signupRes := signupUser(t, b, "manualcancel@example.com", "testpassword123", "ManualCancel") + userID := signupRes.UserID + + subID := "sub_test_manualcancel_" + userID.String()[:8] + now := time.Now().UTC().Truncate(time.Second) + periodStart := now.AddDate(0, -1, 0) + + // sub_cancel_at_period_end=TRUE but sub_cancel_is_auto=FALSE — manual cancel. + _, err := b.Pool().Exec(ctx, ` + UPDATE users + SET sub_cancel_at_period_end = TRUE, + sub_cancel_is_auto = FALSE, + stripe_customer_id = $2, + stripe_subscription_id = $3, + sub_current_period_start = $4 + WHERE id = $1`, + userID, + "cus_test_manualcancel", + subID, + pgtype.Timestamptz{Time: periodStart, Valid: true}, + ) + require.NoError(t, err) + + fakeStripe := idleunsubtest.NewFakeStripe() + svc := idleunsubtest.NewServiceWithFake(t, b.Pool(), fakeStripe) + b.ApplyTestOverrides(backend.TestOverrides{Idleunsub: svc}) + + loginRes, err := b.Login(ctx, backend.LoginParams{ + Email: "manualcancel@example.com", + Password: "testpassword123", + IP: "127.0.0.1:1234", + }) + require.NoError(t, err) + tokenHash := auth.HashSessionToken(loginRes.Token) + + _, err = b.AuthenticateSession(ctx, tokenHash) + require.NoError(t, err) + + // Short fixed sleep to give the goroutine time to NOT fire. We're proving + // absence, so there's no positive signal to wait for. + time.Sleep(200 * time.Millisecond) + + // Zero Stripe calls — goroutine must not have fired. + require.Empty(t, fakeStripe.UpdateCalls, "AutoReverse must not call Stripe for manual cancel") + + // Zero subscription_kept events. + var count int + err = b.Pool().QueryRow(ctx, + `SELECT COUNT(*) FROM user_events WHERE user_id = $1 AND event_type = 'subscription_kept'`, + userID).Scan(&count) + require.NoError(t, err) + require.Equal(t, 0, count, "expected no subscription_kept events for manual cancel") + + // Cache unchanged: sub_cancel_at_period_end still TRUE. + var cancelAtEnd bool + err = b.Pool().QueryRow(ctx, + `SELECT sub_cancel_at_period_end FROM users WHERE id = $1`, + userID).Scan(&cancelAtEnd) + require.NoError(t, err) + require.True(t, cancelAtEnd, "sub_cancel_at_period_end should remain set for manual cancel") +} diff --git a/internal/backend/backend.go b/internal/backend/backend.go index ee6d08dc..4e8baa9c 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -23,6 +23,7 @@ import ( "github.com/btc/drill/internal/drilotel" "github.com/btc/drill/internal/email" "github.com/btc/drill/internal/events" + "github.com/btc/drill/internal/feat/idleunsub" "github.com/btc/drill/internal/jobs" samplesvc "github.com/btc/drill/internal/rpc/sample" "github.com/btc/drill/internal/storage" @@ -46,6 +47,7 @@ type Backend struct { tts ai.Synthesizer store storage.Store events *events.Emitter + idleunsub *idleunsub.Service SampleService *samplesvc.SampleService } @@ -125,6 +127,19 @@ func New(cfg *config.Config, em *events.Emitter) (*Backend, error) { // River client emailSender := email.NewSender(&cfg.Email) + + // Idle auto-cancel signer. Degraded mode (no keep-link emails) when the + // HMAC key is not configured: cancel decisions still fire, but the email + // path is skipped per the spec. Never panic at init. The Service itself + // is constructed after riverClient is available so we can wire the + // River-backed EmailEnqueuer. + var keepSigner *idleunsub.TokenSigner + if cfg.Idleunsub.KeepTokenHMACKey != "" { + keepSigner = idleunsub.NewTokenSigner([]byte(cfg.Idleunsub.KeepTokenHMACKey)) + } else { + slog.Warn("KEEP_TOKEN_HMAC_KEY not configured; idle auto-cancel keep emails will be skipped") + } + workers, workerRefs := jobs.RegisterWorkers(cfg, emailSender, pool, llmClient, geminiClient, store, em) riverClient, err := river.NewClient(riverpgxv5.New(pool), &river.Config{ Queues: map[string]river.QueueConfig{ @@ -159,6 +174,15 @@ func New(cfg *config.Config, em *events.Emitter) (*Backend, error) { }, nil, ), + river.NewPeriodicJob( + // Daily hygiene: clear pending_kept_banner rows older than 14 days. + // Spec §4.5 — low-priority cleanup; once a day is plenty. + river.PeriodicInterval(24*time.Hour), + func() (river.JobArgs, *river.InsertOpts) { + return jobs.ClearStaleKeptBannersArgs{}, &river.InsertOpts{Queue: jobs.QueueMaintenance} + }, + nil, + ), }, }) if err != nil { @@ -169,6 +193,19 @@ func New(cfg *config.Config, em *events.Emitter) (*Backend, error) { workerRefs.Cleanup.Jobs = riverClient workerRefs.Coach.Jobs = riverClient workerRefs.SweepImages.Jobs = riverClient + + // Idle auto-cancel service: now that riverClient is alive, wire the + // River-backed EmailEnqueuer so cancel/kept emails are sent out-of-band + // instead of blocking the webhook handler or the auth middleware. + idleunsubSvc := idleunsub.NewService( + pool, + realStripeClient{}, + &idleunsubEnqueuer{jobs: riverClient, cfg: &cfg.Email}, + keepSigner, + cfg.Auth.BaseURL, + slog.Default(), + ) + if err := riverClient.Start(context.Background()); err != nil { pool.Close() return nil, fmt.Errorf("start river: %w", err) @@ -212,6 +249,7 @@ func New(cfg *config.Config, em *events.Emitter) (*Backend, error) { tts: tts, store: store, events: em, + idleunsub: idleunsubSvc, SampleService: ss, }, nil } @@ -226,15 +264,19 @@ func (b *Backend) Events() *events.Emitter { // Backend is constructed manually (without New). func (b *Backend) SetConfig(cfg *config.Config) { b.cfg = cfg } -// TestOverrides replaces AI dependencies for testing. Only call from tests. +// TestOverrides replaces injected dependencies for testing. Only call from +// tests. AI fields swap stub clients; Idleunsub swaps the idle-auto-cancel +// service so tests can inject a fake-Stripe-backed Service. type TestOverrides struct { - LLM *ai.Client - STT ai.Transcriber - TTS ai.Synthesizer - Store storage.Store + LLM *ai.Client + STT ai.Transcriber + TTS ai.Synthesizer + Store storage.Store + Idleunsub *idleunsub.Service } -// ApplyTestOverrides replaces AI dependencies for testing. Only call from tests. +// ApplyTestOverrides replaces injected dependencies for testing. Only call +// from tests. func (b *Backend) ApplyTestOverrides(o TestOverrides) { if o.LLM != nil { b.llm = o.LLM @@ -248,6 +290,9 @@ func (b *Backend) ApplyTestOverrides(o TestOverrides) { if o.Store != nil { b.store = o.Store } + if o.Idleunsub != nil { + b.idleunsub = o.Idleunsub + } } // Config returns the Backend's configuration. @@ -278,20 +323,39 @@ func (b *Backend) AuthenticateSession(ctx context.Context, tokenHash string) (_ } // Touch session last_active (fire-and-forget, don't block the request). + // Errors are logged but never bubble up — TouchAuthSession failure must + // not break authentication; activity tracking is best-effort. go func() { touchCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - queries.TouchAuthSession(touchCtx, row.ID) + if err := queries.TouchAuthSession(touchCtx, row.ID); err != nil { + slog.Warn("touch auth session", "user_id", row.UserID, "err", err) + } }() + // AutoReverse hook: when both gates are set the current request is the + // activity signal — reverse the auto-cancel in the background. + if row.SubCancelAtPeriodEnd && row.SubCancelIsAuto { + go func() { + reverseCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := b.idleunsub.AutoReverse(reverseCtx, row.UserID); err != nil { + slog.Warn("auto-reverse failed", "user_id", row.UserID, "err", err) + } + }() + } + return &auth.AuthUser{ - ID: row.UserID, - Email: row.Email, - DisplayName: row.DisplayName, - Role: row.Role, - Plan: row.Plan, - EmailVerified: row.EmailVerified, - CreatedAt: row.UserCreatedAt, + ID: row.UserID, + Email: row.Email, + DisplayName: row.DisplayName, + Role: row.Role, + Plan: row.Plan, + EmailVerified: row.EmailVerified, + CreatedAt: row.UserCreatedAt, + SubCancelAtPeriodEnd: row.SubCancelAtPeriodEnd, + SubCancelIsAuto: row.SubCancelIsAuto, + PendingKeptBanner: row.PendingKeptBanner, }, nil } diff --git a/internal/backend/billing.go b/internal/backend/billing.go index d5d8a0fb..3fe75198 100644 --- a/internal/backend/billing.go +++ b/internal/backend/billing.go @@ -2,11 +2,15 @@ package backend import ( "context" + "encoding/json" + "errors" "fmt" "log/slog" "strconv" + "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" stripe "github.com/stripe/stripe-go/v82" portalsession "github.com/stripe/stripe-go/v82/billingportal/session" @@ -244,8 +248,15 @@ func (b *Backend) HandleStripeWebhook(ctx context.Context, event stripe.Event) ( return b.handleCheckoutCompleted(ctx, event) case "invoice.paid": return b.handleInvoicePaid(ctx, event) + case "invoice.upcoming": + return b.idleunsub.HandleInvoiceUpcoming(ctx, event) case "customer.subscription.deleted": return b.handleSubscriptionDeleted(ctx, event) + case "customer.subscription.created": + // Newly created subscriptions land here. Reuse the updated handler so + // the cache is seeded (stripe_subscription_id, period_start) on the + // first event, not waiting for a later updated event. + return b.handleSubscriptionUpdated(ctx, event) case "customer.subscription.updated": return b.handleSubscriptionUpdated(ctx, event) default: @@ -391,19 +402,21 @@ func (b *Backend) handleSubscriptionDeleted(ctx context.Context, event stripe.Ev return nil } - n, err := db.New(b.pool).UpdatePlanByStripeCustomer(ctx, db.UpdatePlanByStripeCustomerParams{ - Plan: "free", - StripeCustomerID: pgtype.Text{String: custID, Valid: true}, - }) + q := db.New(b.pool) + user, err := q.GetUserByStripeCustomer(ctx, pgtype.Text{String: custID, Valid: true}) if err != nil { - return fmt.Errorf("revert plan to free: %w", err) + if errors.Is(err, pgx.ErrNoRows) { + slog.Warn("subscription.deleted unknown customer", "event_id", event.ID, "customer_id", custID) + return nil + } + return fmt.Errorf("get user by customer: %w", err) } - if n == 0 { - slog.Warn("subscription.deleted unknown customer", "event_id", event.ID, "customer_id", custID) - return nil + if err := q.ClearSubStateOnDeletion(ctx, user.ID); err != nil { + return fmt.Errorf("clear sub state: %w", err) } - slog.Info("subscription deleted, plan set to free", "customer_id", custID, "event_id", event.ID) + slog.Info("subscription deleted, plan set to free + sub state cleared", + "customer_id", custID, "event_id", event.ID) return nil } @@ -423,7 +436,8 @@ func (b *Backend) handleSubscriptionUpdated(ctx context.Context, event stripe.Ev planName = "free" } - n, err := db.New(b.pool).UpdatePlanByStripeCustomer(ctx, db.UpdatePlanByStripeCustomerParams{ + q := db.New(b.pool) + n, err := q.UpdatePlanByStripeCustomer(ctx, db.UpdatePlanByStripeCustomerParams{ Plan: planName, StripeCustomerID: pgtype.Text{String: custID, Valid: true}, }) @@ -435,6 +449,54 @@ func (b *Backend) handleSubscriptionUpdated(ctx context.Context, event stripe.Ev return nil } + // Sync sub-state cache columns so AutoReverse / link-keep flows have an + // accurate stripe_subscription_id, sub_cancel_at_period_end, and + // sub_current_period_start. Spec §6: this is the single sole population + // path for stripe_subscription_id; idleunsub itself never writes it. + // + // Skip sync when the event payload omits the subscription ID (malformed + // event). Writing NULL to stripe_subscription_id would clobber a valid + // cached value from a prior event and break AutoReverse for that user. + if len(event.Data.Raw) == 0 { + slog.Info("subscription updated", "customer_id", custID, "plan", planName, "status", status, "event_id", event.ID) + return nil + } + sub := &stripe.Subscription{} + if err := json.Unmarshal(event.Data.Raw, sub); err != nil { + return fmt.Errorf("unmarshal subscription: %w", err) + } + if sub.ID == "" { + slog.Info("subscription updated", "customer_id", custID, "plan", planName, "status", status, "event_id", event.ID) + return nil + } + var periodStart pgtype.Timestamptz + if sub.Items != nil && len(sub.Items.Data) > 0 { + periodStart = pgtype.Timestamptz{ + Time: time.Unix(sub.Items.Data[0].CurrentPeriodStart, 0).UTC(), + Valid: true, + } + } + user, err := q.GetUserByStripeCustomer(ctx, pgtype.Text{String: custID, Valid: true}) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + // UpdatePlanByStripeCustomer matched a row above (n > 0), so this + // would be unexpected. Log and return nil: the plan update already + // landed, and the sync is best-effort cache hygiene. + slog.Warn("subscription.updated user lookup empty after plan update", + "event_id", event.ID, "customer_id", custID) + return nil + } + return fmt.Errorf("get user by customer: %w", err) + } + if err := q.SyncSubStateFromWebhook(ctx, db.SyncSubStateFromWebhookParams{ + ID: user.ID, + StripeSubscriptionID: pgtype.Text{String: sub.ID, Valid: true}, + SubCancelAtPeriodEnd: sub.CancelAtPeriodEnd, + SubCurrentPeriodStart: periodStart, + }); err != nil { + return fmt.Errorf("sync sub state: %w", err) + } + slog.Info("subscription updated", "customer_id", custID, "plan", planName, "status", status, "event_id", event.ID) return nil } diff --git a/internal/backend/billing_test.go b/internal/backend/billing_test.go index 9b19d23d..cd951136 100644 --- a/internal/backend/billing_test.go +++ b/internal/backend/billing_test.go @@ -2,6 +2,8 @@ package backend_test import ( "context" + "encoding/json" + "fmt" "testing" "time" @@ -14,6 +16,7 @@ import ( "github.com/btc/drill/internal/backend" "github.com/btc/drill/internal/backendtest" "github.com/btc/drill/internal/db" + "github.com/btc/drill/internal/feat/idleunsub/idleunsubtest" ) // --------------------------------------------------------------------------- @@ -485,13 +488,20 @@ func TestFullRefundSessionMinutes_Idempotent(t *testing.T) { // makeEvent constructs a stripe.Event with the given type, ID, and object data. // GetObjectValue reads from Data.Object (a map[string]interface{}), so we set -// that directly without going through JSON round-tripping. +// that directly. Data.Raw is also populated by JSON-encoding the object map +// so handlers that unmarshal the typed object (e.g. handleSubscriptionUpdated) +// see a faithful payload — production webhooks have both Object and Raw set. func makeEvent(eventType, eventID string, object map[string]interface{}) stripe.Event { + raw, err := json.Marshal(object) + if err != nil { + panic(fmt.Sprintf("makeEvent: marshal object: %v", err)) + } return stripe.Event{ ID: eventID, Type: stripe.EventType(eventType), Data: &stripe.EventData{ Object: object, + Raw: raw, }, } } @@ -827,6 +837,277 @@ func TestHandleSubscriptionUpdated_UnknownCustomerIsNoOp(t *testing.T) { require.NoError(t, err) } +// --------------------------------------------------------------------------- +// Idle auto-cancel webhook integration tests (Task 10). +// +// These tests exercise the full Backend.HandleStripeWebhook dispatch: +// - invoice.upcoming routes to idleunsub.Service.HandleInvoiceUpcoming. +// - customer.subscription.created routes to handleSubscriptionUpdated +// (cache seed on first event). +// - customer.subscription.updated extends to call SyncSubStateFromWebhook +// after the plan update. +// - customer.subscription.deleted now uses ClearSubStateOnDeletion (clears +// stripe_subscription_id, sub_cancel_at_period_end, sub_cancel_is_auto, +// sub_current_period_start, and sets plan='free' in one query). +// --------------------------------------------------------------------------- + +// makeSubscriptionEvent constructs a Stripe webhook event whose Data.Raw is +// the JSON encoding of the *stripe.Subscription. Object is also populated +// (just the customer key) so handlers that read GetObjectValue still work. +// Mirrors how stripe-go decodes a real webhook payload. +func makeSubscriptionEvent(eventType, eventID string, sub *stripe.Subscription, status string) stripe.Event { + raw, err := json.Marshal(sub) + if err != nil { + panic(fmt.Sprintf("makeSubscriptionEvent: marshal: %v", err)) + } + custID := "" + if sub.Customer != nil { + custID = sub.Customer.ID + } + return stripe.Event{ + ID: eventID, + Type: stripe.EventType(eventType), + Data: &stripe.EventData{ + Object: map[string]interface{}{ + "customer": custID, + "status": status, + }, + Raw: raw, + }, + } +} + +// TestWebhookSwitch_InvoiceUpcoming_RoutesToIdleunsub asserts that the +// dispatch in Backend.HandleStripeWebhook actually reaches idleunsub for +// invoice.upcoming events. We inject a fake-Stripe-backed Service via +// TestOverrides and fire an event matching the fake's seeded subscription. +func TestWebhookSwitch_InvoiceUpcoming_RoutesToIdleunsub(t *testing.T) { + t.Parallel() + b := pg.NewBackend(t) + ctx := context.Background() + + // Seed an idle Pro user wired to a Stripe customer + subscription. + userID, custID := seedUserWithStripeCustomer(t, b) + subID := "sub_test_" + uuid.NewString()[:8] + _, err := b.Pool().Exec(ctx, ` + UPDATE users + SET plan = 'pro', + idle_eligible_after = NOW() - INTERVAL '6 months' + WHERE id = $1`, userID) + require.NoError(t, err) + _, err = b.Pool().Exec(ctx, ` + INSERT INTO auth_sessions (user_id, token_hash, expires_at, last_active) + VALUES ($1, $2, NOW() + INTERVAL '30 days', NOW() - INTERVAL '3 months')`, + userID, "tok_test_"+uuid.NewString()) + require.NoError(t, err) + + // Build the fake Stripe subscription. Period started 23 days ago, + // monthly billing — threshold (= periodStart - 1 month) sits ~53 days + // ago, so a 3-months-ago last_active is "before" it: cancel fires. + now := time.Now().UTC().Truncate(time.Second) + periodStart := now.AddDate(0, 0, -23) + periodEnd := periodStart.AddDate(0, 1, 0) + sub := &stripe.Subscription{ + ID: subID, + Status: stripe.SubscriptionStatusActive, + CancelAtPeriodEnd: false, + Customer: &stripe.Customer{ID: custID}, + Items: &stripe.SubscriptionItemList{Data: []*stripe.SubscriptionItem{{ + CurrentPeriodStart: periodStart.Unix(), + CurrentPeriodEnd: periodEnd.Unix(), + Price: &stripe.Price{Recurring: &stripe.PriceRecurring{ + Interval: stripe.PriceRecurringIntervalMonth, + }}, + }}}, + } + fake := &idleunsubtest.FakeStripe{Subs: map[string]*stripe.Subscription{subID: sub}} + svc := idleunsubtest.NewServiceWithFake(t, b.Pool(), fake) + b.ApplyTestOverrides(backend.TestOverrides{Idleunsub: svc}) + + eventID := "evt_invoice_upcoming_" + uuid.NewString()[:8] + event := stripe.Event{ + ID: eventID, + Type: "invoice.upcoming", + Data: &stripe.EventData{ + Object: map[string]interface{}{"subscription": subID}, + }, + } + + require.NoError(t, b.HandleStripeWebhook(ctx, event)) + + // idleunsub fired the cancel through the fake Stripe client. + require.Len(t, fake.UpdateCalls, 1, "expected exactly one Stripe update call") + assert.Equal(t, subID, fake.UpdateCalls[0].ID) + assert.True(t, fake.UpdateCalls[0].CancelAtPeriodEnd) + assert.Equal(t, eventID, fake.UpdateCalls[0].IdempotencyKey) + + // Audit row landed in user_events. + var count int + err = b.Pool().QueryRow(ctx, ` + SELECT COUNT(*) FROM user_events + WHERE user_id = $1 AND event_type = 'subscription_auto_canceled'`, + userID).Scan(&count) + require.NoError(t, err) + assert.Equal(t, 1, count) +} + +// TestHandleSubscriptionUpdated_SyncsSubStateCache verifies that, after the +// plan-update step, the handler also syncs stripe_subscription_id, +// sub_cancel_at_period_end, and sub_current_period_start from the webhook +// payload (Spec §6: this is the single sole population path). +func TestHandleSubscriptionUpdated_SyncsSubStateCache(t *testing.T) { + t.Parallel() + b := pg.NewBackend(t) + ctx := context.Background() + + userID, custID := seedUserWithStripeCustomer(t, b) + + subID := "sub_sync_" + uuid.NewString()[:8] + periodStart := time.Now().Add(-24 * time.Hour).UTC().Truncate(time.Second) + sub := &stripe.Subscription{ + ID: subID, + Status: stripe.SubscriptionStatusActive, + CancelAtPeriodEnd: true, + Customer: &stripe.Customer{ID: custID}, + Items: &stripe.SubscriptionItemList{Data: []*stripe.SubscriptionItem{{ + CurrentPeriodStart: periodStart.Unix(), + }}}, + } + event := makeSubscriptionEvent("customer.subscription.updated", + "evt_sync_"+uuid.NewString()[:8], sub, "active") + + require.NoError(t, b.HandleStripeWebhook(ctx, event)) + + var ( + gotSubID pgtype.Text + gotCancelAtEnd bool + gotPeriodStart pgtype.Timestamptz + ) + err := b.Pool().QueryRow(ctx, ` + SELECT stripe_subscription_id, sub_cancel_at_period_end, sub_current_period_start + FROM users WHERE id = $1`, userID).Scan(&gotSubID, &gotCancelAtEnd, &gotPeriodStart) + require.NoError(t, err) + require.True(t, gotSubID.Valid, "stripe_subscription_id should be populated") + assert.Equal(t, subID, gotSubID.String) + assert.True(t, gotCancelAtEnd, "sub_cancel_at_period_end should be synced from event") + require.True(t, gotPeriodStart.Valid) + assert.WithinDuration(t, periodStart, gotPeriodStart.Time, time.Second) +} + +// TestHandleSubscriptionUpdated_DoesNotOverwriteSubCancelIsAuto guards spec +// §6's invariant: SyncSubStateFromWebhook must NOT touch sub_cancel_is_auto. +// Only idleunsub.HandleInvoiceUpcoming sets that flag, and a webhook race +// must not flip it back to false. +func TestHandleSubscriptionUpdated_DoesNotOverwriteSubCancelIsAuto(t *testing.T) { + t.Parallel() + b := pg.NewBackend(t) + ctx := context.Background() + + userID, custID := seedUserWithStripeCustomer(t, b) + + // Pretend an idle auto-cancel just landed: sub_cancel_is_auto = true. + _, err := b.Pool().Exec(ctx, ` + UPDATE users + SET sub_cancel_at_period_end = TRUE, + sub_cancel_is_auto = TRUE + WHERE id = $1`, userID) + require.NoError(t, err) + + subID := "sub_isauto_" + uuid.NewString()[:8] + periodStart := time.Now().Add(-24 * time.Hour).UTC().Truncate(time.Second) + sub := &stripe.Subscription{ + ID: subID, + Customer: &stripe.Customer{ID: custID}, + CancelAtPeriodEnd: true, + Items: &stripe.SubscriptionItemList{Data: []*stripe.SubscriptionItem{{ + CurrentPeriodStart: periodStart.Unix(), + }}}, + } + event := makeSubscriptionEvent("customer.subscription.updated", + "evt_isauto_"+uuid.NewString()[:8], sub, "active") + + require.NoError(t, b.HandleStripeWebhook(ctx, event)) + + var isAuto bool + err = b.Pool().QueryRow(ctx, + `SELECT sub_cancel_is_auto FROM users WHERE id = $1`, userID).Scan(&isAuto) + require.NoError(t, err) + assert.True(t, isAuto, "sub_cancel_is_auto must not be overwritten by webhook sync") +} + +// TestHandleSubscriptionCreated_SeedsCache verifies the new dispatch case: +// a customer.subscription.created event reuses handleSubscriptionUpdated and +// seeds the cache columns on the very first event (rather than waiting for a +// later update). +func TestHandleSubscriptionCreated_SeedsCache(t *testing.T) { + t.Parallel() + b := pg.NewBackend(t) + ctx := context.Background() + + userID, custID := seedUserWithStripeCustomer(t, b) + + subID := "sub_create_" + uuid.NewString()[:8] + periodStart := time.Now().Add(-1 * time.Minute).UTC().Truncate(time.Second) + sub := &stripe.Subscription{ + ID: subID, + Status: stripe.SubscriptionStatusActive, + CancelAtPeriodEnd: false, + Customer: &stripe.Customer{ID: custID}, + Items: &stripe.SubscriptionItemList{Data: []*stripe.SubscriptionItem{{ + CurrentPeriodStart: periodStart.Unix(), + }}}, + } + event := makeSubscriptionEvent("customer.subscription.created", + "evt_create_"+uuid.NewString()[:8], sub, "active") + + require.NoError(t, b.HandleStripeWebhook(ctx, event)) + + user, err := db.New(b.Pool()).GetUserByID(ctx, userID) + require.NoError(t, err) + require.True(t, user.StripeSubscriptionID.Valid) + assert.Equal(t, subID, user.StripeSubscriptionID.String) + assert.False(t, user.SubCancelAtPeriodEnd) + assert.Equal(t, "pro", user.Plan, "subscription.created with active status upgrades to pro") +} + +// TestHandleSubscriptionDeleted_ClearsSubStateAndPlan replaces the original +// "sets plan to free" behavior with the new ClearSubStateOnDeletion call, +// which clears all sub-state cache columns AND downgrades the plan in a +// single statement. +func TestHandleSubscriptionDeleted_ClearsSubStateAndPlan(t *testing.T) { + t.Parallel() + b := pg.NewBackend(t) + ctx := context.Background() + + userID, custID := seedUserWithStripeCustomer(t, b) + + // Seed a fully-populated sub state to simulate an active Pro subscriber + // who is then deleted. + _, err := b.Pool().Exec(ctx, ` + UPDATE users + SET plan = 'pro', + stripe_subscription_id = $1, + sub_cancel_at_period_end = TRUE, + sub_cancel_is_auto = TRUE, + sub_current_period_start = NOW() - INTERVAL '1 day' + WHERE id = $2`, "sub_to_delete_"+uuid.NewString()[:8], userID) + require.NoError(t, err) + + event := makeEvent("customer.subscription.deleted", + "evt_sub_deleted_clear_"+uuid.NewString()[:8], + map[string]interface{}{"customer": custID}) + + require.NoError(t, b.HandleStripeWebhook(ctx, event)) + + user, err := db.New(b.Pool()).GetUserByID(ctx, userID) + require.NoError(t, err) + assert.Equal(t, "free", user.Plan) + assert.False(t, user.StripeSubscriptionID.Valid, "stripe_subscription_id should be cleared") + assert.False(t, user.SubCancelAtPeriodEnd, "sub_cancel_at_period_end should be cleared") + assert.False(t, user.SubCancelIsAuto, "sub_cancel_is_auto should be cleared") + assert.False(t, user.SubCurrentPeriodStart.Valid, "sub_current_period_start should be cleared") +} + // --------------------------------------------------------------------------- // Free grant expiry tests // --------------------------------------------------------------------------- diff --git a/internal/backend/idleunsub_enqueuer.go b/internal/backend/idleunsub_enqueuer.go new file mode 100644 index 00000000..6f456ac9 --- /dev/null +++ b/internal/backend/idleunsub_enqueuer.go @@ -0,0 +1,34 @@ +package backend + +import ( + "context" + "fmt" + + "github.com/btc/drill/internal/config" + "github.com/btc/drill/internal/feat/idleunsub" + "github.com/btc/drill/internal/jobs" +) + +// idleunsubEnqueuer adapts the Backend's River client to idleunsub.EmailEnqueuer. +// The webhook and AutoReverse paths must not block on Mailgun delivery, so +// idleunsub queues SendEmailJobs through River and lets the notifications +// worker pool deliver them out-of-band (spec §4.1, §4.3, §9). +// +// The enqueuer holds the Jobs interface (not *river.Client directly) so unit +// tests of the Backend can swap the implementation without standing up a real +// River client. +type idleunsubEnqueuer struct { + jobs Jobs + cfg *config.Email +} + +// EnqueueSendEmail inserts a SendEmailJob via River, applying the project's +// shared notifications-queue insert options. +func (e *idleunsubEnqueuer) EnqueueSendEmail(ctx context.Context, args jobs.SendEmailArgs) error { + if _, err := e.jobs.Insert(ctx, args, jobs.SendEmailInsertOpts(e.cfg)); err != nil { + return fmt.Errorf("insert send_email job: %w", err) + } + return nil +} + +var _ idleunsub.EmailEnqueuer = (*idleunsubEnqueuer)(nil) diff --git a/internal/backend/idleunsub_stripe.go b/internal/backend/idleunsub_stripe.go new file mode 100644 index 00000000..319c654b --- /dev/null +++ b/internal/backend/idleunsub_stripe.go @@ -0,0 +1,39 @@ +package backend + +import ( + "context" + + stripe "github.com/stripe/stripe-go/v82" + "github.com/stripe/stripe-go/v82/subscription" + + "github.com/btc/drill/internal/feat/idleunsub" +) + +// TODO: stripe-go v82's subscription package functions are not context-aware +// (Get/Update don't accept a ctx). Tracing and cancellation cannot propagate +// into the Stripe HTTP call. Revisit when the library exposes a contextual API. + +// realStripeClient adapts stripe-go's package-level subscription API to +// idleunsub.StripeClient. Used in production wiring; tests inject a fake. +type realStripeClient struct{} + +// GetSubscription fetches a subscription by ID via Stripe's REST API. +// The stripe-go package consults the package-level stripe.Key for auth, +// configured at startup by config.Stripe.Init(). +func (realStripeClient) GetSubscription(_ context.Context, id string) (*stripe.Subscription, error) { + return subscription.Get(id, nil) +} + +// UpdateSubscriptionCancel toggles cancel_at_period_end on the subscription. +// idempotencyKey is sent in the Idempotency-Key header; pass "" to omit. +func (realStripeClient) UpdateSubscriptionCancel(_ context.Context, id string, cancelAtPeriodEnd bool, idempotencyKey string) (*stripe.Subscription, error) { + params := &stripe.SubscriptionParams{ + CancelAtPeriodEnd: stripe.Bool(cancelAtPeriodEnd), + } + if idempotencyKey != "" { + params.SetIdempotencyKey(idempotencyKey) + } + return subscription.Update(id, params) +} + +var _ idleunsub.StripeClient = realStripeClient{} diff --git a/internal/backend/keep.go b/internal/backend/keep.go new file mode 100644 index 00000000..6fb308af --- /dev/null +++ b/internal/backend/keep.go @@ -0,0 +1,104 @@ +package backend + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/btc/drill/internal/db" + "github.com/btc/drill/internal/drilotel" + "github.com/btc/drill/internal/feat/idleunsub" +) + +// KeepLinkStatus is the high-level outcome of HandleKeepLink. The handler +// layer maps each value to a rendered HTML page + HTTP status code. +type KeepLinkStatus int + +const ( + // KeepLinkInvalid means the token failed signature verification, was + // malformed, or violated the spec invariants. Render a "bad link" page + // with HTTP 400. + KeepLinkInvalid KeepLinkStatus = iota + // KeepLinkExpired means the signature is valid but the period (and the + // matching exp) has already passed. Render a "period ended" page with + // HTTP 200 — the request was authentic, just too late. + KeepLinkExpired + // KeepLinkKept means the token was accepted: either the first claim + // triggered a real reversal, or this is a replay of an already-used + // token (idempotent — render the same confirmation page). + KeepLinkKept +) + +// KeepLinkOutcome is the data the handler needs to render the response page. +// PeriodEnd is populated for KeepLinkExpired and KeepLinkKept; zero for +// KeepLinkInvalid (no trustworthy claims to expose). +type KeepLinkOutcome struct { + Status KeepLinkStatus + PeriodEnd time.Time +} + +// HandleKeepLink verifies a keep-link token, atomically claims single-use, +// and (on first claim) reverses the cancel via idleunsub.KeepSubscription. +// Replays of an already-claimed token return KeepLinkKept idempotently with +// no Stripe side effect. +// +// Returns a non-nil error only on infrastructure failure (DB down, signer +// misconfigured, Stripe failure mid-reversal). Token-level outcomes +// (invalid signature, expired) are returned via Outcome.Status with a nil +// error — the handler renders a page either way. +func (b *Backend) HandleKeepLink(ctx context.Context, token string) (_ KeepLinkOutcome, err error) { + ctx, span := tracer.Start(ctx, "Backend.HandleKeepLink") + defer func() { drilotel.End(span, err) }() + + signer := b.idleunsub.Signer() + if signer == nil { + // Degraded mode: KEEP_TOKEN_HMAC_KEY was unset at startup, so cancel + // emails were skipped — there should be no live keep-links pointing + // here. Treat as invalid so we don't 500 the user. + return KeepLinkOutcome{Status: KeepLinkInvalid}, nil + } + + claims, err := signer.Verify(token) + if err != nil { + switch { + case errors.Is(err, idleunsub.ErrTokenInvalid): + return KeepLinkOutcome{Status: KeepLinkInvalid}, nil + case errors.Is(err, idleunsub.ErrTokenExpired): + return KeepLinkOutcome{Status: KeepLinkExpired, PeriodEnd: claims.CurrentPeriodEnd}, nil + default: + return KeepLinkOutcome{}, fmt.Errorf("verify keep token: %w", err) + } + } + + tokenHash := sha256Of(token) + if _, err := db.New(b.pool).TryClaimKeepToken(ctx, db.TryClaimKeepTokenParams{ + TokenHash: tokenHash, + UserID: claims.UserID, + }); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + // Replay: the token was already used. Render the same confirmation + // page idempotently. No Stripe call. + return KeepLinkOutcome{Status: KeepLinkKept, PeriodEnd: claims.CurrentPeriodEnd}, nil + } + return KeepLinkOutcome{}, fmt.Errorf("claim keep token: %w", err) + } + + // First claim: perform the reversal. KeepSubscription is itself idempotent + // at the gates layer (manual portal cancels, already-cleared gates) and + // returns nil in those benign cases — we still render the kept page. + if err := b.idleunsub.KeepSubscription(ctx, claims); err != nil { + return KeepLinkOutcome{}, fmt.Errorf("keep subscription: %w", err) + } + return KeepLinkOutcome{Status: KeepLinkKept, PeriodEnd: claims.CurrentPeriodEnd}, nil +} + +// sha256Of returns the SHA-256 of s as a byte slice. Used to derive the +// keep-link token hash for the single-use claim row. +func sha256Of(s string) []byte { + sum := sha256.Sum256([]byte(s)) + return sum[:] +} diff --git a/internal/backend/user.go b/internal/backend/user.go index 922e404a..84928a4f 100644 --- a/internal/backend/user.go +++ b/internal/backend/user.go @@ -30,3 +30,11 @@ func (b *Backend) UpdateDisplayName(ctx context.Context, userID uuid.UUID, displ } return user, nil } + +// ClearKeptBanner clears the pending_kept_banner flag for the given user. +// Idempotent: clearing an already-clear flag is a no-op. +func (b *Backend) ClearKeptBanner(ctx context.Context, userID uuid.UUID) (err error) { + ctx, span := tracer.Start(ctx, "Backend.ClearKeptBanner") + defer func() { drilotel.End(span, err) }() + return db.New(b.pool).ClearKeptBanner(ctx, userID) +} diff --git a/internal/config/config.go b/internal/config/config.go index 9f3fdbad..bb5a7acd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,20 +11,21 @@ import ( ) type Config struct { - GCP GCP - Server Server - Database Database - Log Log - LLM LLM - Speech Speech - Email Email - River River - Auth Auth - OAuth OAuth - Otel Otel - Storage Storage - Stripe Stripe - Gemini Gemini + GCP GCP + Server Server + Database Database + Log Log + LLM LLM + Speech Speech + Email Email + River River + Auth Auth + OAuth OAuth + Otel Otel + Storage Storage + Stripe Stripe + Gemini Gemini + Idleunsub Idleunsub } // GCP holds Google Cloud Platform settings shared across subsystems @@ -144,6 +145,18 @@ type Gemini struct { Location string `env:"GEMINI_LOCATION,default=global"` } +// Idleunsub holds settings for the idle auto-cancel subsystem. +// +// KeepTokenHMACKey is the secret used to sign keep-link tokens emailed to +// users when their subscription is auto-canceled for idleness. Empty key +// places the subsystem in degraded mode: cancel decisions still fire, but +// keep-link emails cannot be generated and are skipped (with a logged +// warning at startup). Production deployments must set this env var to a +// 32-byte secret. +type Idleunsub struct { + KeepTokenHMACKey string `env:"KEEP_TOKEN_HMAC_KEY"` +} + // Configured reports whether Stripe credentials are present. func (s *Stripe) Configured() bool { return s.SecretKey != "" } diff --git a/internal/db/auth_sessions.sql.go b/internal/db/auth_sessions.sql.go index 13797f27..a7a03be7 100644 --- a/internal/db/auth_sessions.sql.go +++ b/internal/db/auth_sessions.sql.go @@ -17,7 +17,7 @@ import ( const createAuthSession = `-- name: CreateAuthSession :one INSERT INTO auth_sessions (user_id, token_hash, expires_at, ip_address, user_agent) VALUES ($1, $2, $3, $4, $5) -RETURNING id, user_id, token_hash, expires_at, last_active, ip_address, user_agent, created_at +RETURNING id, user_id, token_hash, expires_at, last_active, ip_address, user_agent, created_at, revoked_at ` type CreateAuthSessionParams struct { @@ -46,55 +46,46 @@ func (q *Queries) CreateAuthSession(ctx context.Context, arg CreateAuthSessionPa &i.IpAddress, &i.UserAgent, &i.CreatedAt, + &i.RevokedAt, ) return i, err } -const deleteAuthSession = `-- name: DeleteAuthSession :exec -DELETE FROM auth_sessions WHERE id = $1 -` - -func (q *Queries) DeleteAuthSession(ctx context.Context, id uuid.UUID) error { - _, err := q.db.Exec(ctx, deleteAuthSession, id) - return err -} - -const deleteUserAuthSessions = `-- name: DeleteUserAuthSessions :exec -DELETE FROM auth_sessions WHERE user_id = $1 -` - -func (q *Queries) DeleteUserAuthSessions(ctx context.Context, userID uuid.UUID) error { - _, err := q.db.Exec(ctx, deleteUserAuthSessions, userID) - return err -} - const getAuthSessionByToken = `-- name: GetAuthSessionByToken :one -SELECT s.id, s.user_id, s.token_hash, s.expires_at, s.last_active, s.ip_address, s.user_agent, s.created_at, u.email, u.display_name, u.role, u.plan, u.email_verified, - u.created_at AS user_created_at +SELECT s.id, s.user_id, s.token_hash, s.expires_at, s.last_active, s.ip_address, s.user_agent, s.created_at, s.revoked_at, u.email, u.display_name, u.role, u.plan, u.email_verified, + u.created_at AS user_created_at, + u.sub_cancel_at_period_end, u.sub_cancel_is_auto, u.pending_kept_banner FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.expires_at > NOW() + AND s.revoked_at IS NULL AND u.deleted_at IS NULL ` type GetAuthSessionByTokenRow struct { - ID uuid.UUID `json:"id"` - UserID uuid.UUID `json:"user_id"` - TokenHash string `json:"token_hash"` - ExpiresAt time.Time `json:"expires_at"` - LastActive time.Time `json:"last_active"` - IpAddress *netip.Addr `json:"ip_address"` - UserAgent pgtype.Text `json:"user_agent"` - CreatedAt time.Time `json:"created_at"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - Role string `json:"role"` - Plan string `json:"plan"` - EmailVerified bool `json:"email_verified"` - UserCreatedAt time.Time `json:"user_created_at"` + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + TokenHash string `json:"token_hash"` + ExpiresAt time.Time `json:"expires_at"` + LastActive time.Time `json:"last_active"` + IpAddress *netip.Addr `json:"ip_address"` + UserAgent pgtype.Text `json:"user_agent"` + CreatedAt time.Time `json:"created_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + Plan string `json:"plan"` + EmailVerified bool `json:"email_verified"` + UserCreatedAt time.Time `json:"user_created_at"` + SubCancelAtPeriodEnd bool `json:"sub_cancel_at_period_end"` + SubCancelIsAuto bool `json:"sub_cancel_is_auto"` + PendingKeptBanner bool `json:"pending_kept_banner"` } +// Filters out soft-revoked sessions; only returns valid live sessions. +// (Activity queries do NOT filter on revoked_at — see GetUserLastActive.) func (q *Queries) GetAuthSessionByToken(ctx context.Context, tokenHash string) (GetAuthSessionByTokenRow, error) { row := q.db.QueryRow(ctx, getAuthSessionByToken, tokenHash) var i GetAuthSessionByTokenRow @@ -107,16 +98,68 @@ func (q *Queries) GetAuthSessionByToken(ctx context.Context, tokenHash string) ( &i.IpAddress, &i.UserAgent, &i.CreatedAt, + &i.RevokedAt, &i.Email, &i.DisplayName, &i.Role, &i.Plan, &i.EmailVerified, &i.UserCreatedAt, + &i.SubCancelAtPeriodEnd, + &i.SubCancelIsAuto, + &i.PendingKeptBanner, ) return i, err } +const getUserLastActive = `-- name: GetUserLastActive :one +SELECT a.last_active +FROM (SELECT 1) AS _placeholder +LEFT JOIN auth_sessions a ON a.user_id = $1 +ORDER BY a.last_active DESC +LIMIT 1 +` + +// Reads across all history (including revoked sessions). Returns NULL +// when the user has no auth_sessions rows. +// +// The LEFT-JOIN-from-placeholder shape (rather than the simpler MAX()) is +// a workaround: in sqlc 1.25 with pgx/v5, MAX(timestamptz_not_null) is +// generated as a non-nullable time.Time even though the SQL semantics are +// "NULL when zero rows match." LEFT JOIN forces sqlc's nullability +// inference correctly. If a future sqlc version makes MAX() nullable for +// this case, simplify back to: +// +// SELECT MAX(last_active)::timestamptz FROM auth_sessions WHERE user_id = $1; +func (q *Queries) GetUserLastActive(ctx context.Context, userID uuid.UUID) (pgtype.Timestamptz, error) { + row := q.db.QueryRow(ctx, getUserLastActive, userID) + var last_active pgtype.Timestamptz + err := row.Scan(&last_active) + return last_active, err +} + +const revokeAuthSession = `-- name: RevokeAuthSession :exec +UPDATE auth_sessions SET revoked_at = NOW() +WHERE id = $1 AND revoked_at IS NULL +` + +// Soft-delete: marks the row revoked but preserves it for the activity query. +func (q *Queries) RevokeAuthSession(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, revokeAuthSession, id) + return err +} + +const revokeUserAuthSessions = `-- name: RevokeUserAuthSessions :exec +UPDATE auth_sessions SET revoked_at = NOW() +WHERE user_id = $1 AND revoked_at IS NULL +` + +// Soft-delete every active session for a user (logout-everywhere). +func (q *Queries) RevokeUserAuthSessions(ctx context.Context, userID uuid.UUID) error { + _, err := q.db.Exec(ctx, revokeUserAuthSessions, userID) + return err +} + const touchAuthSession = `-- name: TouchAuthSession :exec UPDATE auth_sessions SET last_active = NOW() WHERE id = $1 diff --git a/internal/db/keep_link_token_uses.sql.go b/internal/db/keep_link_token_uses.sql.go new file mode 100644 index 00000000..c2ff5987 --- /dev/null +++ b/internal/db/keep_link_token_uses.sql.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.25.0 +// source: keep_link_token_uses.sql + +package db + +import ( + "context" + + "github.com/google/uuid" +) + +const tryClaimKeepToken = `-- name: TryClaimKeepToken :one +INSERT INTO keep_link_token_uses (token_hash, user_id) +VALUES ($1, $2) +ON CONFLICT (token_hash) DO NOTHING +RETURNING token_hash +` + +type TryClaimKeepTokenParams struct { + TokenHash []byte `json:"token_hash"` + UserID uuid.UUID `json:"user_id"` +} + +// Single-use enforcement. Returns the hash on first claim, no row otherwise. +func (q *Queries) TryClaimKeepToken(ctx context.Context, arg TryClaimKeepTokenParams) ([]byte, error) { + row := q.db.QueryRow(ctx, tryClaimKeepToken, arg.TokenHash, arg.UserID) + var token_hash []byte + err := row.Scan(&token_hash) + return token_hash, err +} diff --git a/internal/db/models.go b/internal/db/models.go index fde20198..d265a2f8 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -22,14 +22,15 @@ type Annotation struct { } type AuthSession struct { - ID uuid.UUID `json:"id"` - UserID uuid.UUID `json:"user_id"` - TokenHash string `json:"token_hash"` - ExpiresAt time.Time `json:"expires_at"` - LastActive time.Time `json:"last_active"` - IpAddress *netip.Addr `json:"ip_address"` - UserAgent pgtype.Text `json:"user_agent"` - CreatedAt time.Time `json:"created_at"` + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + TokenHash string `json:"token_hash"` + ExpiresAt time.Time `json:"expires_at"` + LastActive time.Time `json:"last_active"` + IpAddress *netip.Addr `json:"ip_address"` + UserAgent pgtype.Text `json:"user_agent"` + CreatedAt time.Time `json:"created_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` } type CoachAnalysis struct { @@ -98,6 +99,12 @@ type InterviewSession struct { GeneratingSince pgtype.Timestamptz `json:"generating_since"` } +type KeepLinkTokenUse struct { + TokenHash []byte `json:"token_hash"` + UserID uuid.UUID `json:"user_id"` + UsedAt time.Time `json:"used_at"` +} + type LedgerEntry struct { ID uuid.UUID `json:"id"` UserID uuid.UUID `json:"user_id"` @@ -163,6 +170,12 @@ type Question struct { FeaturedOrder pgtype.Int4 `json:"featured_order"` } +type StripeWebhookDedup struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + ProcessedAt time.Time `json:"processed_at"` +} + type User struct { ID uuid.UUID `json:"id"` Email string `json:"email"` @@ -176,6 +189,12 @@ type User struct { UpdatedAt time.Time `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` FreeFullEducatorsUsed int32 `json:"free_full_educators_used"` + StripeSubscriptionID pgtype.Text `json:"stripe_subscription_id"` + SubCancelAtPeriodEnd bool `json:"sub_cancel_at_period_end"` + SubCancelIsAuto bool `json:"sub_cancel_is_auto"` + SubCurrentPeriodStart pgtype.Timestamptz `json:"sub_current_period_start"` + PendingKeptBanner bool `json:"pending_kept_banner"` + IdleEligibleAfter time.Time `json:"idle_eligible_after"` } type UserEvent struct { diff --git a/internal/db/querier.go b/internal/db/querier.go index c6126472..066b2e17 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -22,6 +22,19 @@ type Querier interface { CancelSession(ctx context.Context, arg CancelSessionParams) error // Reset sessions stuck in 'generating' for too long (crash recovery). CleanupStaleGenerating(ctx context.Context) error + ClearKeptBanner(ctx context.Context, id uuid.UUID) error + // Periodic hygiene: clear banners that are older than 14 days, where "older" + // means the user's MOST RECENT subscription_kept event is >14 days old. + // Grouping by user_id and using MAX(created_at) prevents clearing a fresh + // banner when an older kept-event also exists for the same user. + ClearStaleKeptBanners(ctx context.Context) error + // Called by handleSubscriptionDeleted. Clears all sub state and downgrades plan. + ClearSubStateOnDeletion(ctx context.Context, id uuid.UUID) error + // Called by KeepSubscription / AutoReverse on reversal. Sets banner flag. + ClearUserAutoCancelState(ctx context.Context, arg ClearUserAutoCancelStateParams) error + // For cache-drift correction in AutoReverse: clear gates without setting the + // banner. We only flip the banner when an actual reversal happened. + ClearUserAutoCancelStateNoBanner(ctx context.Context, arg ClearUserAutoCancelStateNoBannerParams) error // Batch-completes abandoned sessions that have at least one candidate message. // These are real interviews that the user forgot to end. CompleteAbandonedActiveSessions(ctx context.Context) ([]CompleteAbandonedActiveSessionsRow, error) @@ -44,11 +57,11 @@ type Querier interface { // grants_created=0 for duplicate event. CreateSubscriptionGrant(ctx context.Context, arg CreateSubscriptionGrantParams) (CreateSubscriptionGrantRow, error) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) - // Soft-deletes the user and wipes all auth sessions in one round-trip. - // Idempotent: re-calling on a deleted user is a no-op on the user row. + // Soft-deletes the user and hard-deletes all auth sessions in one round-trip + // (data minimization / GDPR). Logout uses RevokeAuthSession / RevokeUserAuthSessions + // in auth_sessions.sql instead. Idempotent: re-calling on a deleted user is a + // no-op on the user row. DeleteAccount(ctx context.Context, id uuid.UUID) error - DeleteAuthSession(ctx context.Context, id uuid.UUID) error - DeleteUserAuthSessions(ctx context.Context, userID uuid.UUID) error // Creates the one-time free trial grant + ledger entry atomically. Called // only at account creation (Signup, OAuthLogin). If the grant already exists // (ON CONFLICT), both the INSERT and the ledger SELECT produce zero rows — a @@ -60,6 +73,8 @@ type Querier interface { // Idempotent: returns 0 rows if session_refund ledger entries already exist. FullRefundSessionMinutes(ctx context.Context, arg FullRefundSessionMinutesParams) ([]FullRefundSessionMinutesRow, error) GetAnnotationsByEvaluation(ctx context.Context, evaluationID uuid.UUID) ([]GetAnnotationsByEvaluationRow, error) + // Filters out soft-revoked sessions; only returns valid live sessions. + // (Activity queries do NOT filter on revoked_at — see GetUserLastActive.) GetAuthSessionByToken(ctx context.Context, tokenHash string) (GetAuthSessionByTokenRow, error) // Single query to fetch all billing state needed for entitlement checks. // Fetch inside the caller's transaction to avoid TOCTOU. @@ -73,6 +88,10 @@ type Querier interface { GetMessagesBySessionAfterSeq(ctx context.Context, arg GetMessagesBySessionAfterSeqParams) ([]Message, error) // Return messages after the given offset (for known_message_count cursor). GetMessagesBySessionOffset(ctx context.Context, arg GetMessagesBySessionOffsetParams) ([]Message, error) + // For multi-firing dedup: did a 'subscription_kept' event arrive after the + // most recent 'subscription_auto_canceled' for this period? If yes, the user + // already kept their sub for this period; don't re-cancel. + GetMostRecentKeptOrCanceledForPeriod(ctx context.Context, arg GetMostRecentKeptOrCanceledForPeriodParams) (string, error) GetOAuthAccount(ctx context.Context, arg GetOAuthAccountParams) (OauthAccount, error) GetOAuthAccountsByUser(ctx context.Context, userID uuid.UUID) ([]OauthAccount, error) GetQuestion(ctx context.Context, id uuid.UUID) (Question, error) @@ -86,12 +105,31 @@ type Querier interface { GetSessionForTurn(ctx context.Context, id uuid.UUID) (GetSessionForTurnRow, error) // Lightweight status check for EndSession/CancelSession polling. GetSessionStatus(ctx context.Context, id uuid.UUID) (GetSessionStatusRow, error) + // Used by AutoReverse to defensively re-read both gates. + GetUserAutoCancelGates(ctx context.Context, id uuid.UUID) (GetUserAutoCancelGatesRow, error) GetUserByEmail(ctx context.Context, email string) (User, error) GetUserByEmailForUpdate(ctx context.Context, email string) (User, error) GetUserByEmailIncludingDeleted(ctx context.Context, email string) (User, error) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) GetUserByIDIncludingDeleted(ctx context.Context, id uuid.UUID) (User, error) + // Used by idleunsub.HandleInvoiceUpcoming to map a Stripe customer to our user. + GetUserByStripeCustomer(ctx context.Context, stripeCustomerID pgtype.Text) (User, error) + // Reads across all history (including revoked sessions). Returns NULL + // when the user has no auth_sessions rows. + // + // The LEFT-JOIN-from-placeholder shape (rather than the simpler MAX()) is + // a workaround: in sqlc 1.25 with pgx/v5, MAX(timestamptz_not_null) is + // generated as a non-nullable time.Time even though the SQL semantics are + // "NULL when zero rows match." LEFT JOIN forces sqlc's nullability + // inference correctly. If a future sqlc version makes MAX() nullable for + // this case, simplify back to: + // SELECT MAX(last_active)::timestamptz FROM auth_sessions WHERE user_id = $1; + GetUserLastActive(ctx context.Context, userID uuid.UUID) (pgtype.Timestamptz, error) GetUserUsageSummary(ctx context.Context, userID uuid.UUID) (GetUserUsageSummaryRow, error) + // Returns true if a subscription_auto_canceled event already exists for + // this (user, subscription, period). Backs the multi-firing dedup in §4.1. + // metadata->>'current_period_start' is RFC3339 text written by cancelMetadata. + HasAutoCanceledThisPeriod(ctx context.Context, arg HasAutoCanceledThisPeriodParams) (bool, error) IncrementFreeEducatorUsed(ctx context.Context, arg IncrementFreeEducatorUsedParams) (int32, error) // Inline crash recovery for EndSession/CancelSession. // Conditional WHERE makes this idempotent and race-free. @@ -104,6 +142,9 @@ type Querier interface { InsertLLMCallContent(ctx context.Context, arg InsertLLMCallContentParams) error InsertMessage(ctx context.Context, arg InsertMessageParams) (Message, error) InsertQuestion(ctx context.Context, arg InsertQuestionParams) (uuid.UUID, error) + // Composed insert for the cancel decision. metadata is already-marshaled JSON. + InsertSubscriptionAutoCanceledEvent(ctx context.Context, arg InsertSubscriptionAutoCanceledEventParams) error + InsertSubscriptionKeptEvent(ctx context.Context, arg InsertSubscriptionKeptEventParams) error LinkOAuthAccount(ctx context.Context, arg LinkOAuthAccountParams) (OauthAccount, error) ListActiveGrants(ctx context.Context, userID uuid.UUID) ([]ListActiveGrantsRow, error) ListFeaturedQuestions(ctx context.Context) ([]ListFeaturedQuestionsRow, error) @@ -111,6 +152,10 @@ type Querier interface { ListQuestionsWithoutImages(ctx context.Context) ([]uuid.UUID, error) ListSeedQuestions(ctx context.Context) ([]ListSeedQuestionsRow, error) ListSessionsByUser(ctx context.Context, userID uuid.UUID) ([]ListSessionsByUserRow, error) + // Per-user mutex for the cancel transaction. Acquires a row-level lock that + // serializes concurrent invoice.upcoming evaluations for the same user. + // Must be inside a transaction; releases on COMMIT or ROLLBACK. + LockUserForSubDecision(ctx context.Context, id uuid.UUID) (uuid.UUID, error) MarkSessionCompleted(ctx context.Context, id uuid.UUID) error ReactivateUser(ctx context.Context, id uuid.UUID) error // Refunds unused minutes for a completed session based on wall-clock duration. @@ -123,10 +168,25 @@ type Querier interface { // Returns one row per grant debited. Returns zero rows if balance is insufficient // (all-or-nothing: no mutations occur when balance < requested). ReserveMinutes(ctx context.Context, arg ReserveMinutesParams) ([]ReserveMinutesRow, error) + // Soft-delete: marks the row revoked but preserves it for the activity query. + RevokeAuthSession(ctx context.Context, id uuid.UUID) error + // Soft-delete every active session for a user (logout-everywhere). + RevokeUserAuthSessions(ctx context.Context, userID uuid.UUID) error SetAudioURL(ctx context.Context, arg SetAudioURLParams) error SetQuestionImageURL(ctx context.Context, arg SetQuestionImageURLParams) error + // Called by idleunsub.HandleInvoiceUpcoming when our trigger fires. + // Sets BOTH cache flags so the auto-reverse middleware gate fires for this user. + SetUserAutoCancelState(ctx context.Context, arg SetUserAutoCancelStateParams) error SoftDeleteUser(ctx context.Context, id uuid.UUID) error + // Called by handleSubscriptionUpdated. Does NOT touch sub_cancel_is_auto: + // only our handler sets that flag; webhook sync must not overwrite it. + SyncSubStateFromWebhook(ctx context.Context, arg SyncSubStateFromWebhookParams) error TouchAuthSession(ctx context.Context, id uuid.UUID) error + // Single-use enforcement. Returns the hash on first claim, no row otherwise. + TryClaimKeepToken(ctx context.Context, arg TryClaimKeepTokenParams) ([]byte, error) + // Returns the event_id on first claim; returns no row on subsequent claims. + // Use the no-row return as the signal "this event was already handled". + TryClaimWebhookEvent(ctx context.Context, arg TryClaimWebhookEventParams) (string, error) UpdateEducatorAnalysisContent(ctx context.Context, arg UpdateEducatorAnalysisContentParams) error UpdateEducatorAnalysisStatus(ctx context.Context, arg UpdateEducatorAnalysisStatusParams) error UpdatePlanByStripeCustomer(ctx context.Context, arg UpdatePlanByStripeCustomerParams) (int64, error) diff --git a/internal/db/stripe_webhook_dedup.sql.go b/internal/db/stripe_webhook_dedup.sql.go new file mode 100644 index 00000000..88c62422 --- /dev/null +++ b/internal/db/stripe_webhook_dedup.sql.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.25.0 +// source: stripe_webhook_dedup.sql + +package db + +import ( + "context" +) + +const tryClaimWebhookEvent = `-- name: TryClaimWebhookEvent :one +INSERT INTO stripe_webhook_dedup (event_id, event_type) +VALUES ($1, $2) +ON CONFLICT (event_id) DO NOTHING +RETURNING event_id +` + +type TryClaimWebhookEventParams struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` +} + +// Returns the event_id on first claim; returns no row on subsequent claims. +// Use the no-row return as the signal "this event was already handled". +func (q *Queries) TryClaimWebhookEvent(ctx context.Context, arg TryClaimWebhookEventParams) (string, error) { + row := q.db.QueryRow(ctx, tryClaimWebhookEvent, arg.EventID, arg.EventType) + var event_id string + err := row.Scan(&event_id) + return event_id, err +} diff --git a/internal/db/user_events.sql.go b/internal/db/user_events.sql.go new file mode 100644 index 00000000..75ad011c --- /dev/null +++ b/internal/db/user_events.sql.go @@ -0,0 +1,97 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.25.0 +// source: user_events.sql + +package db + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +const getMostRecentKeptOrCanceledForPeriod = `-- name: GetMostRecentKeptOrCanceledForPeriod :one +SELECT event_type +FROM user_events +WHERE user_id = $1 + AND event_type IN ('subscription_auto_canceled', 'subscription_kept') + AND (metadata->>'subscription_id') = $2::text + AND (metadata->>'current_period_start')::timestamptz = $3::timestamptz +ORDER BY created_at DESC +LIMIT 1 +` + +type GetMostRecentKeptOrCanceledForPeriodParams struct { + UserID uuid.UUID `json:"user_id"` + SubscriptionID string `json:"subscription_id"` + CurrentPeriodStart time.Time `json:"current_period_start"` +} + +// For multi-firing dedup: did a 'subscription_kept' event arrive after the +// most recent 'subscription_auto_canceled' for this period? If yes, the user +// already kept their sub for this period; don't re-cancel. +func (q *Queries) GetMostRecentKeptOrCanceledForPeriod(ctx context.Context, arg GetMostRecentKeptOrCanceledForPeriodParams) (string, error) { + row := q.db.QueryRow(ctx, getMostRecentKeptOrCanceledForPeriod, arg.UserID, arg.SubscriptionID, arg.CurrentPeriodStart) + var event_type string + err := row.Scan(&event_type) + return event_type, err +} + +const hasAutoCanceledThisPeriod = `-- name: HasAutoCanceledThisPeriod :one +SELECT EXISTS ( + SELECT 1 FROM user_events + WHERE user_id = $1 + AND event_type = 'subscription_auto_canceled' + AND (metadata->>'subscription_id') = $2::text + AND (metadata->>'current_period_start')::timestamptz = $3::timestamptz +) +` + +type HasAutoCanceledThisPeriodParams struct { + UserID uuid.UUID `json:"user_id"` + SubscriptionID string `json:"subscription_id"` + CurrentPeriodStart time.Time `json:"current_period_start"` +} + +// Returns true if a subscription_auto_canceled event already exists for +// this (user, subscription, period). Backs the multi-firing dedup in §4.1. +// metadata->>'current_period_start' is RFC3339 text written by cancelMetadata. +func (q *Queries) HasAutoCanceledThisPeriod(ctx context.Context, arg HasAutoCanceledThisPeriodParams) (bool, error) { + row := q.db.QueryRow(ctx, hasAutoCanceledThisPeriod, arg.UserID, arg.SubscriptionID, arg.CurrentPeriodStart) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const insertSubscriptionAutoCanceledEvent = `-- name: InsertSubscriptionAutoCanceledEvent :exec +INSERT INTO user_events (user_id, event_type, metadata) +VALUES ($1, 'subscription_auto_canceled', $2) +` + +type InsertSubscriptionAutoCanceledEventParams struct { + UserID uuid.UUID `json:"user_id"` + Metadata []byte `json:"metadata"` +} + +// Composed insert for the cancel decision. metadata is already-marshaled JSON. +func (q *Queries) InsertSubscriptionAutoCanceledEvent(ctx context.Context, arg InsertSubscriptionAutoCanceledEventParams) error { + _, err := q.db.Exec(ctx, insertSubscriptionAutoCanceledEvent, arg.UserID, arg.Metadata) + return err +} + +const insertSubscriptionKeptEvent = `-- name: InsertSubscriptionKeptEvent :exec +INSERT INTO user_events (user_id, event_type, metadata) +VALUES ($1, 'subscription_kept', $2) +` + +type InsertSubscriptionKeptEventParams struct { + UserID uuid.UUID `json:"user_id"` + Metadata []byte `json:"metadata"` +} + +func (q *Queries) InsertSubscriptionKeptEvent(ctx context.Context, arg InsertSubscriptionKeptEventParams) error { + _, err := q.db.Exec(ctx, insertSubscriptionKeptEvent, arg.UserID, arg.Metadata) + return err +} diff --git a/internal/db/users.sql.go b/internal/db/users.sql.go index 236059c7..4f5eb5f5 100644 --- a/internal/db/users.sql.go +++ b/internal/db/users.sql.go @@ -12,10 +12,96 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const clearKeptBanner = `-- name: ClearKeptBanner :exec +UPDATE users SET pending_kept_banner = FALSE WHERE id = $1 +` + +func (q *Queries) ClearKeptBanner(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, clearKeptBanner, id) + return err +} + +const clearStaleKeptBanners = `-- name: ClearStaleKeptBanners :exec +UPDATE users +SET pending_kept_banner = FALSE +WHERE pending_kept_banner = TRUE + AND id IN ( + SELECT user_id FROM user_events + WHERE event_type = 'subscription_kept' + GROUP BY user_id + HAVING MAX(created_at) < NOW() - INTERVAL '14 days' + ) +` + +// Periodic hygiene: clear banners that are older than 14 days, where "older" +// means the user's MOST RECENT subscription_kept event is >14 days old. +// Grouping by user_id and using MAX(created_at) prevents clearing a fresh +// banner when an older kept-event also exists for the same user. +func (q *Queries) ClearStaleKeptBanners(ctx context.Context) error { + _, err := q.db.Exec(ctx, clearStaleKeptBanners) + return err +} + +const clearSubStateOnDeletion = `-- name: ClearSubStateOnDeletion :exec +UPDATE users +SET stripe_subscription_id = NULL, + sub_cancel_at_period_end = FALSE, + sub_cancel_is_auto = FALSE, + sub_current_period_start = NULL, + plan = 'free' +WHERE id = $1 +` + +// Called by handleSubscriptionDeleted. Clears all sub state and downgrades plan. +func (q *Queries) ClearSubStateOnDeletion(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, clearSubStateOnDeletion, id) + return err +} + +const clearUserAutoCancelState = `-- name: ClearUserAutoCancelState :exec +UPDATE users +SET sub_cancel_at_period_end = FALSE, + sub_cancel_is_auto = FALSE, + pending_kept_banner = TRUE, + sub_current_period_start = $2 +WHERE id = $1 +` + +type ClearUserAutoCancelStateParams struct { + ID uuid.UUID `json:"id"` + SubCurrentPeriodStart pgtype.Timestamptz `json:"sub_current_period_start"` +} + +// Called by KeepSubscription / AutoReverse on reversal. Sets banner flag. +func (q *Queries) ClearUserAutoCancelState(ctx context.Context, arg ClearUserAutoCancelStateParams) error { + _, err := q.db.Exec(ctx, clearUserAutoCancelState, arg.ID, arg.SubCurrentPeriodStart) + return err +} + +const clearUserAutoCancelStateNoBanner = `-- name: ClearUserAutoCancelStateNoBanner :exec +UPDATE users +SET sub_cancel_at_period_end = FALSE, + sub_cancel_is_auto = FALSE, + sub_current_period_start = $2 +WHERE id = $1 +` + +type ClearUserAutoCancelStateNoBannerParams struct { + ID uuid.UUID `json:"id"` + SubCurrentPeriodStart pgtype.Timestamptz `json:"sub_current_period_start"` +} + +// For cache-drift correction in AutoReverse: clear gates without setting the +// banner. We only flip the banner when an actual reversal happened. +func (q *Queries) ClearUserAutoCancelStateNoBanner(ctx context.Context, arg ClearUserAutoCancelStateNoBannerParams) error { + _, err := q.db.Exec(ctx, clearUserAutoCancelStateNoBanner, arg.ID, arg.SubCurrentPeriodStart) + return err +} + const createOAuthUser = `-- name: CreateOAuthUser :one INSERT INTO users (email, email_verified, display_name) VALUES ($1, TRUE, $2) -RETURNING id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used +RETURNING id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used, stripe_subscription_id, sub_cancel_at_period_end, sub_cancel_is_auto, sub_current_period_start, pending_kept_banner, idle_eligible_after ` type CreateOAuthUserParams struct { @@ -39,6 +125,12 @@ func (q *Queries) CreateOAuthUser(ctx context.Context, arg CreateOAuthUserParams &i.UpdatedAt, &i.DeletedAt, &i.FreeFullEducatorsUsed, + &i.StripeSubscriptionID, + &i.SubCancelAtPeriodEnd, + &i.SubCancelIsAuto, + &i.SubCurrentPeriodStart, + &i.PendingKeptBanner, + &i.IdleEligibleAfter, ) return i, err } @@ -47,7 +139,7 @@ const createOAuthUserOrNoop = `-- name: CreateOAuthUserOrNoop :one INSERT INTO users (email, email_verified, display_name) VALUES ($1, TRUE, $2) ON CONFLICT (email) DO NOTHING -RETURNING id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used +RETURNING id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used, stripe_subscription_id, sub_cancel_at_period_end, sub_cancel_is_auto, sub_current_period_start, pending_kept_banner, idle_eligible_after ` type CreateOAuthUserOrNoopParams struct { @@ -71,6 +163,12 @@ func (q *Queries) CreateOAuthUserOrNoop(ctx context.Context, arg CreateOAuthUser &i.UpdatedAt, &i.DeletedAt, &i.FreeFullEducatorsUsed, + &i.StripeSubscriptionID, + &i.SubCancelAtPeriodEnd, + &i.SubCancelIsAuto, + &i.SubCurrentPeriodStart, + &i.PendingKeptBanner, + &i.IdleEligibleAfter, ) return i, err } @@ -78,7 +176,7 @@ func (q *Queries) CreateOAuthUserOrNoop(ctx context.Context, arg CreateOAuthUser const createUser = `-- name: CreateUser :one INSERT INTO users (email, password_hash, display_name) VALUES ($1, $2, $3) -RETURNING id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used +RETURNING id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used, stripe_subscription_id, sub_cancel_at_period_end, sub_cancel_is_auto, sub_current_period_start, pending_kept_banner, idle_eligible_after ` type CreateUserParams struct { @@ -103,6 +201,12 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.UpdatedAt, &i.DeletedAt, &i.FreeFullEducatorsUsed, + &i.StripeSubscriptionID, + &i.SubCancelAtPeriodEnd, + &i.SubCancelIsAuto, + &i.SubCurrentPeriodStart, + &i.PendingKeptBanner, + &i.IdleEligibleAfter, ) return i, err } @@ -115,15 +219,44 @@ WITH soft_delete AS ( DELETE FROM auth_sessions WHERE user_id = $1 ` -// Soft-deletes the user and wipes all auth sessions in one round-trip. -// Idempotent: re-calling on a deleted user is a no-op on the user row. +// Soft-deletes the user and hard-deletes all auth sessions in one round-trip +// (data minimization / GDPR). Logout uses RevokeAuthSession / RevokeUserAuthSessions +// in auth_sessions.sql instead. Idempotent: re-calling on a deleted user is a +// no-op on the user row. func (q *Queries) DeleteAccount(ctx context.Context, id uuid.UUID) error { _, err := q.db.Exec(ctx, deleteAccount, id) return err } +const getUserAutoCancelGates = `-- name: GetUserAutoCancelGates :one +SELECT sub_cancel_at_period_end, sub_cancel_is_auto, + stripe_subscription_id, sub_current_period_start +FROM users +WHERE id = $1 +` + +type GetUserAutoCancelGatesRow struct { + SubCancelAtPeriodEnd bool `json:"sub_cancel_at_period_end"` + SubCancelIsAuto bool `json:"sub_cancel_is_auto"` + StripeSubscriptionID pgtype.Text `json:"stripe_subscription_id"` + SubCurrentPeriodStart pgtype.Timestamptz `json:"sub_current_period_start"` +} + +// Used by AutoReverse to defensively re-read both gates. +func (q *Queries) GetUserAutoCancelGates(ctx context.Context, id uuid.UUID) (GetUserAutoCancelGatesRow, error) { + row := q.db.QueryRow(ctx, getUserAutoCancelGates, id) + var i GetUserAutoCancelGatesRow + err := row.Scan( + &i.SubCancelAtPeriodEnd, + &i.SubCancelIsAuto, + &i.StripeSubscriptionID, + &i.SubCurrentPeriodStart, + ) + return i, err +} + const getUserByEmail = `-- name: GetUserByEmail :one -SELECT id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used FROM users +SELECT id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used, stripe_subscription_id, sub_cancel_at_period_end, sub_cancel_is_auto, sub_current_period_start, pending_kept_banner, idle_eligible_after FROM users WHERE email = $1 AND deleted_at IS NULL ` @@ -143,12 +276,18 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error &i.UpdatedAt, &i.DeletedAt, &i.FreeFullEducatorsUsed, + &i.StripeSubscriptionID, + &i.SubCancelAtPeriodEnd, + &i.SubCancelIsAuto, + &i.SubCurrentPeriodStart, + &i.PendingKeptBanner, + &i.IdleEligibleAfter, ) return i, err } const getUserByEmailForUpdate = `-- name: GetUserByEmailForUpdate :one -SELECT id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used FROM users +SELECT id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used, stripe_subscription_id, sub_cancel_at_period_end, sub_cancel_is_auto, sub_current_period_start, pending_kept_banner, idle_eligible_after FROM users WHERE email = $1 FOR UPDATE ` @@ -169,12 +308,18 @@ func (q *Queries) GetUserByEmailForUpdate(ctx context.Context, email string) (Us &i.UpdatedAt, &i.DeletedAt, &i.FreeFullEducatorsUsed, + &i.StripeSubscriptionID, + &i.SubCancelAtPeriodEnd, + &i.SubCancelIsAuto, + &i.SubCurrentPeriodStart, + &i.PendingKeptBanner, + &i.IdleEligibleAfter, ) return i, err } const getUserByEmailIncludingDeleted = `-- name: GetUserByEmailIncludingDeleted :one -SELECT id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used FROM users +SELECT id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used, stripe_subscription_id, sub_cancel_at_period_end, sub_cancel_is_auto, sub_current_period_start, pending_kept_banner, idle_eligible_after FROM users WHERE email = $1 ` @@ -194,12 +339,18 @@ func (q *Queries) GetUserByEmailIncludingDeleted(ctx context.Context, email stri &i.UpdatedAt, &i.DeletedAt, &i.FreeFullEducatorsUsed, + &i.StripeSubscriptionID, + &i.SubCancelAtPeriodEnd, + &i.SubCancelIsAuto, + &i.SubCurrentPeriodStart, + &i.PendingKeptBanner, + &i.IdleEligibleAfter, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used FROM users +SELECT id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used, stripe_subscription_id, sub_cancel_at_period_end, sub_cancel_is_auto, sub_current_period_start, pending_kept_banner, idle_eligible_after FROM users WHERE id = $1 AND deleted_at IS NULL ` @@ -219,12 +370,18 @@ func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) { &i.UpdatedAt, &i.DeletedAt, &i.FreeFullEducatorsUsed, + &i.StripeSubscriptionID, + &i.SubCancelAtPeriodEnd, + &i.SubCancelIsAuto, + &i.SubCurrentPeriodStart, + &i.PendingKeptBanner, + &i.IdleEligibleAfter, ) return i, err } const getUserByIDIncludingDeleted = `-- name: GetUserByIDIncludingDeleted :one -SELECT id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used FROM users +SELECT id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used, stripe_subscription_id, sub_cancel_at_period_end, sub_cancel_is_auto, sub_current_period_start, pending_kept_banner, idle_eligible_after FROM users WHERE id = $1 ` @@ -244,6 +401,44 @@ func (q *Queries) GetUserByIDIncludingDeleted(ctx context.Context, id uuid.UUID) &i.UpdatedAt, &i.DeletedAt, &i.FreeFullEducatorsUsed, + &i.StripeSubscriptionID, + &i.SubCancelAtPeriodEnd, + &i.SubCancelIsAuto, + &i.SubCurrentPeriodStart, + &i.PendingKeptBanner, + &i.IdleEligibleAfter, + ) + return i, err +} + +const getUserByStripeCustomer = `-- name: GetUserByStripeCustomer :one +SELECT id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used, stripe_subscription_id, sub_cancel_at_period_end, sub_cancel_is_auto, sub_current_period_start, pending_kept_banner, idle_eligible_after FROM users +WHERE stripe_customer_id = $1 AND deleted_at IS NULL +` + +// Used by idleunsub.HandleInvoiceUpcoming to map a Stripe customer to our user. +func (q *Queries) GetUserByStripeCustomer(ctx context.Context, stripeCustomerID pgtype.Text) (User, error) { + row := q.db.QueryRow(ctx, getUserByStripeCustomer, stripeCustomerID) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.EmailVerified, + &i.PasswordHash, + &i.DisplayName, + &i.Role, + &i.StripeCustomerID, + &i.Plan, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.FreeFullEducatorsUsed, + &i.StripeSubscriptionID, + &i.SubCancelAtPeriodEnd, + &i.SubCancelIsAuto, + &i.SubCurrentPeriodStart, + &i.PendingKeptBanner, + &i.IdleEligibleAfter, ) return i, err } @@ -267,6 +462,19 @@ func (q *Queries) IncrementFreeEducatorUsed(ctx context.Context, arg IncrementFr return free_full_educators_used, err } +const lockUserForSubDecision = `-- name: LockUserForSubDecision :one +SELECT id FROM users WHERE id = $1 FOR UPDATE +` + +// Per-user mutex for the cancel transaction. Acquires a row-level lock that +// serializes concurrent invoice.upcoming evaluations for the same user. +// Must be inside a transaction; releases on COMMIT or ROLLBACK. +func (q *Queries) LockUserForSubDecision(ctx context.Context, id uuid.UUID) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, lockUserForSubDecision, id) + err := row.Scan(&id) + return id, err +} + const reactivateUser = `-- name: ReactivateUser :exec UPDATE users SET deleted_at = NULL, email_verified = TRUE, updated_at = NOW() WHERE id = $1 @@ -277,6 +485,26 @@ func (q *Queries) ReactivateUser(ctx context.Context, id uuid.UUID) error { return err } +const setUserAutoCancelState = `-- name: SetUserAutoCancelState :exec +UPDATE users +SET sub_cancel_at_period_end = TRUE, + sub_cancel_is_auto = TRUE, + sub_current_period_start = $2 +WHERE id = $1 +` + +type SetUserAutoCancelStateParams struct { + ID uuid.UUID `json:"id"` + SubCurrentPeriodStart pgtype.Timestamptz `json:"sub_current_period_start"` +} + +// Called by idleunsub.HandleInvoiceUpcoming when our trigger fires. +// Sets BOTH cache flags so the auto-reverse middleware gate fires for this user. +func (q *Queries) SetUserAutoCancelState(ctx context.Context, arg SetUserAutoCancelStateParams) error { + _, err := q.db.Exec(ctx, setUserAutoCancelState, arg.ID, arg.SubCurrentPeriodStart) + return err +} + const softDeleteUser = `-- name: SoftDeleteUser :exec UPDATE users SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 @@ -287,6 +515,33 @@ func (q *Queries) SoftDeleteUser(ctx context.Context, id uuid.UUID) error { return err } +const syncSubStateFromWebhook = `-- name: SyncSubStateFromWebhook :exec +UPDATE users +SET stripe_subscription_id = $2, + sub_cancel_at_period_end = $3, + sub_current_period_start = $4 +WHERE id = $1 +` + +type SyncSubStateFromWebhookParams struct { + ID uuid.UUID `json:"id"` + StripeSubscriptionID pgtype.Text `json:"stripe_subscription_id"` + SubCancelAtPeriodEnd bool `json:"sub_cancel_at_period_end"` + SubCurrentPeriodStart pgtype.Timestamptz `json:"sub_current_period_start"` +} + +// Called by handleSubscriptionUpdated. Does NOT touch sub_cancel_is_auto: +// only our handler sets that flag; webhook sync must not overwrite it. +func (q *Queries) SyncSubStateFromWebhook(ctx context.Context, arg SyncSubStateFromWebhookParams) error { + _, err := q.db.Exec(ctx, syncSubStateFromWebhook, + arg.ID, + arg.StripeSubscriptionID, + arg.SubCancelAtPeriodEnd, + arg.SubCurrentPeriodStart, + ) + return err +} + const updatePlanByStripeCustomer = `-- name: UpdatePlanByStripeCustomer :execrows UPDATE users SET plan = $1, updated_at = NOW() WHERE stripe_customer_id = $2 AND deleted_at IS NULL @@ -308,7 +563,7 @@ func (q *Queries) UpdatePlanByStripeCustomer(ctx context.Context, arg UpdatePlan const updateUserDisplayName = `-- name: UpdateUserDisplayName :one UPDATE users SET display_name = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL -RETURNING id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used +RETURNING id, email, email_verified, password_hash, display_name, role, stripe_customer_id, plan, created_at, updated_at, deleted_at, free_full_educators_used, stripe_subscription_id, sub_cancel_at_period_end, sub_cancel_is_auto, sub_current_period_start, pending_kept_banner, idle_eligible_after ` type UpdateUserDisplayNameParams struct { @@ -332,6 +587,12 @@ func (q *Queries) UpdateUserDisplayName(ctx context.Context, arg UpdateUserDispl &i.UpdatedAt, &i.DeletedAt, &i.FreeFullEducatorsUsed, + &i.StripeSubscriptionID, + &i.SubCancelAtPeriodEnd, + &i.SubCancelIsAuto, + &i.SubCurrentPeriodStart, + &i.PendingKeptBanner, + &i.IdleEligibleAfter, ) return i, err } diff --git a/internal/feat/idleunsub/clear_stale_kept_banners_test.go b/internal/feat/idleunsub/clear_stale_kept_banners_test.go new file mode 100644 index 00000000..38c6f99b --- /dev/null +++ b/internal/feat/idleunsub/clear_stale_kept_banners_test.go @@ -0,0 +1,82 @@ +package idleunsub_test + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/btc/drill/internal/backendtest" + "github.com/btc/drill/internal/db" +) + +// TestClearStaleKeptBanners_MostRecentEventGates verifies that the +// ClearStaleKeptBanners query uses the MOST RECENT subscription_kept event to +// decide whether a user's banner should be cleared. +// +// Scenario: +// - User A: banner=true, one kept event 30 days ago — should be cleared. +// - User B: banner=true, one kept event 30 days ago AND one 2 days ago — +// banner must be retained (recent event is fresh). +// - User C: banner=true, one kept event 5 days ago — banner must be retained +// (entirely within the 14-day window). +func TestClearStaleKeptBanners_MostRecentEventGates(t *testing.T) { + t.Parallel() + ctx := context.Background() + b := pg.NewBackend(t) + + userA := backendtest.SeedUser(t, b) + userB := backendtest.SeedUser(t, b) + userC := backendtest.SeedUser(t, b) + + // Set all three users to pending_kept_banner=true. + for _, id := range []uuid.UUID{userA, userB, userC} { + _, err := b.Pool().Exec(ctx, + `UPDATE users SET pending_kept_banner = TRUE WHERE id = $1`, id) + require.NoError(t, err) + } + + now := time.Now().UTC() + + // User A: one stale event (30 days ago). + _, err := b.Pool().Exec(ctx, ` + INSERT INTO user_events (user_id, event_type, created_at) + VALUES ($1, 'subscription_kept', $2)`, + userA, now.AddDate(0, 0, -30)) + require.NoError(t, err) + + // User B: one stale event (30 days ago) + one recent event (2 days ago). + _, err = b.Pool().Exec(ctx, ` + INSERT INTO user_events (user_id, event_type, created_at) + VALUES ($1, 'subscription_kept', $2), + ($1, 'subscription_kept', $3)`, + userB, now.AddDate(0, 0, -30), now.AddDate(0, 0, -2)) + require.NoError(t, err) + + // User C: one recent event (5 days ago). + _, err = b.Pool().Exec(ctx, ` + INSERT INTO user_events (user_id, event_type, created_at) + VALUES ($1, 'subscription_kept', $2)`, + userC, now.AddDate(0, 0, -5)) + require.NoError(t, err) + + require.NoError(t, db.New(b.Pool()).ClearStaleKeptBanners(ctx)) + + readBanner := func(id uuid.UUID) bool { + t.Helper() + var banner bool + err := b.Pool().QueryRow(ctx, + `SELECT pending_kept_banner FROM users WHERE id = $1`, id).Scan(&banner) + require.NoError(t, err) + return banner + } + + // User A: only stale event — banner must be cleared. + require.False(t, readBanner(userA), "user A: stale-only events must clear the banner") + // User B: has a recent event — banner must be retained. + require.True(t, readBanner(userB), "user B: recent event must protect the banner") + // User C: entirely recent — banner must be retained. + require.True(t, readBanner(userC), "user C: recent event must protect the banner") +} diff --git a/internal/feat/idleunsub/email.go b/internal/feat/idleunsub/email.go new file mode 100644 index 00000000..cbd9a756 --- /dev/null +++ b/internal/feat/idleunsub/email.go @@ -0,0 +1,75 @@ +package idleunsub + +import ( + "bytes" + "embed" + "fmt" + "html/template" + "time" + + texttemplate "text/template" + + "github.com/btc/drill/internal/email" +) + +//go:embed templates/* +var templatesFS embed.FS + +// Embedded templates are validated by package tests; ParseFS cannot fail at +// runtime. CLAUDE.md "never panic at init time" allows template.Must here +// because the FS is fixed at compile time and parse errors fail `go test`. +var ( + cancelHTMLTpl = template.Must(template.ParseFS(templatesFS, "templates/cancel.html.tmpl")) + cancelTextTpl = texttemplate.Must(texttemplate.ParseFS(templatesFS, "templates/cancel.txt.tmpl")) + keptHTMLTpl = template.Must(template.ParseFS(templatesFS, "templates/kept.html.tmpl")) + keptTextTpl = texttemplate.Must(texttemplate.ParseFS(templatesFS, "templates/kept.txt.tmpl")) +) + +type cancelEmailData struct { + DisplayName string + CurrentPeriodEnd string + KeepLink string +} + +type keptEmailData struct { + DisplayName string + NextRenewalDate string +} + +func composeCancelEmail(toEmail, displayName, keepURL string, periodEnd time.Time) (email.Message, error) { + data := cancelEmailData{ + DisplayName: displayName, + CurrentPeriodEnd: periodEnd.UTC().Format("January 2, 2006"), + KeepLink: keepURL, + } + return renderEmail(cancelHTMLTpl, cancelTextTpl, toEmail, + "We won't charge you for the next period", data) +} + +func composeKeptEmail(toEmail, displayName string, nextRenewal time.Time) (email.Message, error) { + data := keptEmailData{ + DisplayName: displayName, + NextRenewalDate: nextRenewal.UTC().Format("January 2, 2006"), + } + return renderEmail(keptHTMLTpl, keptTextTpl, toEmail, + "Your subscription is still active", data) +} + +// renderEmail executes pre-parsed templates against data. Templates are +// loaded once at package init (see cancelHTMLTpl/keptHTMLTpl/etc above) so +// the per-Send cost is just two Execute calls and a couple of buffer copies. +func renderEmail(htmlTpl *template.Template, txtTpl *texttemplate.Template, to, subject string, data interface{}) (email.Message, error) { + var htmlBuf, txtBuf bytes.Buffer + if err := htmlTpl.Execute(&htmlBuf, data); err != nil { + return email.Message{}, fmt.Errorf("execute html: %w", err) + } + if err := txtTpl.Execute(&txtBuf, data); err != nil { + return email.Message{}, fmt.Errorf("execute text: %w", err) + } + return email.Message{ + To: to, + Subject: subject, + HTML: htmlBuf.String(), + Text: txtBuf.String(), + }, nil +} diff --git a/internal/feat/idleunsub/email_test.go b/internal/feat/idleunsub/email_test.go new file mode 100644 index 00000000..6a7cf155 --- /dev/null +++ b/internal/feat/idleunsub/email_test.go @@ -0,0 +1,42 @@ +package idleunsub + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestComposeCancelEmail_Renders(t *testing.T) { + t.Parallel() + end := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) + msg, err := composeCancelEmail("user@example.com", "Jane", + "https://sabermatic.dev/sub/keep?t=abc.def", end) + require.NoError(t, err) + require.Equal(t, "user@example.com", msg.To) + require.Equal(t, "We won't charge you for the next period", msg.Subject) + require.Contains(t, msg.Text, "Hi Jane,") + require.Contains(t, msg.Text, "March 1, 2026") + require.Contains(t, msg.Text, "https://sabermatic.dev/sub/keep?t=abc.def") + require.Contains(t, msg.HTML, "Jane") + require.Contains(t, msg.HTML, `X", + "https://x.example/y", end) + require.NoError(t, err) + require.NotContains(t, msg.HTML, "