Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
90dc376
docs(spec): aippatch — proto/SQL PATCH framework design
btc May 8, 2026
780ef4e
spec: idle auto-cancel design (3 review rounds applied)
btc May 8, 2026
c5454a1
docs(spec): aippatch — apply round-1 review fixes
btc May 8, 2026
9e74abb
docs(spec): aippatch — apply round-2 review fixes
btc May 8, 2026
73e32ae
docs(spec): aippatch — apply round-3 review fixes (final pass)
btc May 8, 2026
c79c7df
plan: idle auto-cancel implementation
btc May 8, 2026
7f87d0b
docs(spec): aippatch — apply round-4 review fixes
btc May 8, 2026
a3868a1
auth_sessions: soft-delete on logout (migration 015)
btc May 8, 2026
bf758e3
users: sub state cache columns (migration 016)
btc May 8, 2026
93e402a
stripe_webhook_dedup: generic webhook idempotency (migration 017)
btc May 8, 2026
f6541c9
keep_link_token_uses + user_events dedup queries (migration 018)
btc May 8, 2026
7b321d1
auth: project sub_cancel_at_period_end and friends into AuthUser
btc May 8, 2026
90aa5df
auth: tighten AuthUser projection test
btc May 8, 2026
c9513cf
idleunsub: HMAC keep-token sign/verify
btc May 8, 2026
80e1ff8
idleunsub: tighten token verify test coverage
btc May 8, 2026
2dab2f3
idleunsub: HandleInvoiceUpcoming + transactional cancel decision
btc May 8, 2026
1fd3938
idleunsub: address Task 7 code review feedback
btc May 8, 2026
ede0943
idleunsub: KeepSubscription + AutoReverse with cache-drift correction
btc May 8, 2026
0e5f160
idleunsub: address Task 8 code review feedback
btc May 8, 2026
a811866
idleunsub: email composition + templates
btc May 8, 2026
81014d0
idleunsub: address Task 9 code review feedback
btc May 9, 2026
30fecd0
billing: route invoice.upcoming and subscription.created to idleunsub
btc May 9, 2026
737e66c
billing: assert realStripeClient satisfies idleunsub.StripeClient
btc May 9, 2026
44037d0
handler: GET /sub/keep with single-use token claim
btc May 9, 2026
7fd5793
idleunsubtest: hoist shared FakeStripe and mailers
btc May 9, 2026
c48419f
idleunsub,handler: pin Verify contract + degraded-mode test
btc May 9, 2026
62564d7
auth: fire idleunsub.AutoReverse on authed requests when gates set
btc May 9, 2026
28da87d
idleunsubtest: hoist NewServiceWithFake helper
btc May 9, 2026
6e65d24
user: pending_kept_banner field and AckKeptBanner RPC
btc May 9, 2026
8417bbd
user: address Task 13 code review feedback
btc May 9, 2026
aa43a99
web: KeptBanner component for post-reverse welcome-back UX
btc May 9, 2026
39dee85
web: KeptBanner — drop orphan period, add render test
btc May 9, 2026
478dc65
idleunsub: end-to-end integration tests for cancel + reverse
btc May 9, 2026
dddd652
idleunsub: promote testBaseURL to file-level const
btc May 9, 2026
eec5a36
idleunsub: enqueue emails via River SendEmailJob (spec §4.1, §4.3)
btc May 9, 2026
5d56327
idleunsub: address branch review feedback
btc May 9, 2026
f9bd09d
idleunsub: fix stale-banner correctness + maintenance queue routing
btc May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,585 changes: 2,585 additions & 0 deletions docs/superpowers/plans/2026-05-07-idle-auto-cancel.md

Large diffs are not rendered by default.

1,161 changes: 1,161 additions & 0 deletions docs/superpowers/specs/2026-05-07-aippatch-design.md

Large diffs are not rendered by default.

656 changes: 656 additions & 0 deletions docs/superpowers/specs/2026-05-07-idle-auto-cancel-design.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions internal/auth/user_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions internal/backend/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
269 changes: 266 additions & 3 deletions internal/backend/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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) {
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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{
Expand All @@ -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)
Expand Down Expand Up @@ -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")
}
Loading
Loading