From 483387dfc0ea8cba13aaa890a225c95d14b51f95 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Wed, 13 May 2026 08:22:20 +0530 Subject: [PATCH] audit: emit onboarding.claimed + subscription.{upgraded,downgraded,canceled} for Loops forwarder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire 4 of the 7 audit_log.kind values that the Loops worker (PR #10) forwards to Loops.so but which nothing in the API was actually writing. After this PR the welcome / upgrade / downgrade / cancellation lifecycle emails will fire instead of silently no-op'ing. Sites added: - onboarding.Claim — onboarding.claimed (after JWT mark + session mint, pre-response, in a detached goroutine) - billing.handleSubscriptionCharged — subscription.upgraded or subscription.downgraded, classified by tierRank(prior) vs tierRank(new). Same-tier renewals emit nothing so monthly Pro renewals don't trigger the upgrade email. - billing.handleSubscriptionCancelled — subscription.canceled (single-l US spelling matches the Loops forwarder map; Razorpay's double-l event name is handled inside the dispatcher). Fail-open invariant enforced and tested: when audit_log writes fail (e.g. the table doesn't exist), the originating handler still returns success and the tier mutation still commits. Razorpay never sees a retry-worthy status from an audit miss. Named constants live in internal/models/audit_kinds.go so the emit sites and the Loops forwarder match on identity rather than re-typing strings. Skipped from the original 6 missing kinds: - admin.tier_changed / admin.promo_issued — Track A's admin_customers.go is not on master at HEAD d3fa539, so there is no admin tier or promo handler to attach to. Deferred until Track A lands. - resource.expiry_imminent — lives in the worker repo, out of scope for this PR per the brief. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/handlers/billing.go | 123 +++++++++++ internal/handlers/billing_test.go | 304 +++++++++++++++++++++++++++ internal/handlers/onboarding.go | 41 ++++ internal/handlers/onboarding_test.go | 60 ++++++ internal/models/audit_kinds.go | 35 +++ internal/testhelpers/testhelpers.go | 17 ++ 6 files changed, 580 insertions(+) create mode 100644 internal/models/audit_kinds.go diff --git a/internal/handlers/billing.go b/internal/handlers/billing.go index faeb0ec..3919302 100644 --- a/internal/handlers/billing.go +++ b/internal/handlers/billing.go @@ -389,6 +389,15 @@ func (h *BillingHandler) handleSubscriptionCharged(ctx context.Context, c *fiber tier := h.planIDToTier(sub.PlanID) + // Snapshot the prior tier BEFORE the update so we can classify the + // transition as upgrade / downgrade / same. A miss here just means we + // emit no audit row and the Loops lifecycle email is skipped — the + // upgrade itself proceeds. + fromTier := "" + if team, lookupErr := models.GetTeamByID(ctx, h.db, teamID); lookupErr == nil && team != nil { + fromTier = team.PlanTier + } + if updateErr := models.UpdatePlanTier(ctx, h.db, teamID, tier); updateErr != nil { slog.Error("billing.subscription.charged.update_plan_failed", "error", updateErr, "team_id", teamID) @@ -412,6 +421,10 @@ func (h *BillingHandler) handleSubscriptionCharged(ctx context.Context, c *fiber slog.Info("billing.subscription.charged", "team_id", teamID, "plan_tier", tier, "subscription_id", sub.ID) metrics.ConversionFunnel.WithLabelValues("paid").Inc() + + // Best-effort audit emit for the Loops forwarder. Fail-open: an audit + // error must not undo the tier update we already committed. + emitSubscriptionChangeAudit(ctx, h.db, teamID, fromTier, tier, sub.ID) } // handleSubscriptionCancelled processes subscription.cancelled events (cancel → downgrade to hobby). @@ -429,6 +442,13 @@ func (h *BillingHandler) handleSubscriptionCancelled(ctx context.Context, c *fib return } + // Snapshot the prior tier so the audit row can capture from→to. Failure + // to read it is non-fatal — we just emit with from_tier="". + fromTier := "" + if team, lookupErr := models.GetTeamByID(ctx, h.db, teamID); lookupErr == nil && team != nil { + fromTier = team.PlanTier + } + // Downgrade behaviour: a cancellation with zero paid invoices means the // user never actually paid, so they fall back to 'free' (claimed-but- // unpaid). 'anonymous' would be wrong — they still have a team_id. A @@ -446,6 +466,11 @@ func (h *BillingHandler) handleSubscriptionCancelled(ctx context.Context, c *fib slog.Info("billing.subscription.cancelled", "team_id", teamID, "subscription_id", sub.ID, "new_tier", tier) + + // Best-effort audit emit for the Loops cancellation email. Fail-open: + // the downgrade above is already committed and must not be reverted on + // an audit failure. + emitSubscriptionCanceledAudit(ctx, h.db, teamID, fromTier, tier, sub.ID) } // handlePaymentFailed processes payment.failed events. @@ -848,3 +873,101 @@ func (h *BillingHandler) ChangePlanAPI(c *fiber.Ctx) error { "short_url": res.CheckoutShort, }) } + +// tierRank maps a plan tier name to a totally-ordered rank used to classify +// transitions as upgrade vs downgrade. Higher rank = more capacity. +// Unknown tiers map to -1 so any comparison involving them returns the safe +// "no transition direction" verdict (callers emit nothing rather than a +// misleading audit row). +func tierRank(tier string) int { + switch strings.ToLower(strings.TrimSpace(tier)) { + case "anonymous": + return 0 + case "free": + return 1 + case "hobby": + return 2 + case "growth": + return 3 + case "pro": + return 4 + case "team": + return 5 + } + return -1 +} + +// emitSubscriptionChangeAudit writes a subscription.upgraded or +// subscription.downgraded row for the Loops forwarder when a charged-webhook +// transition strictly changes the team's tier. Same-tier renewals (the +// monthly Pro→Pro re-charge case) emit nothing — Loops shouldn't send an +// upgrade email on every renewal. +// +// Best-effort: a write failure logs but never surfaces. Called synchronously +// from the webhook handler because the handler already runs in a request +// goroutine that completes before Razorpay sees a 200. +func emitSubscriptionChangeAudit(ctx context.Context, db *sql.DB, teamID uuid.UUID, fromTier, toTier, subID string) { + fromR := tierRank(fromTier) + toR := tierRank(toTier) + // Unknown tiers (-1) or no-change cases produce no audit row. + if fromR < 0 || toR < 0 || fromR == toR { + return + } + + kind := models.AuditKindSubscriptionUpgraded + summary := "team upgraded from " + fromTier + " to " + toTier + if fromR > toR { + kind = models.AuditKindSubscriptionDowngraded + summary = "team downgraded from " + fromTier + " to " + toTier + } + + meta := map[string]string{ + "from_tier": fromTier, + "to_tier": toTier, + "subscription_id": subID, + } + metaBlob, _ := json.Marshal(meta) + + if err := models.InsertAuditEvent(ctx, db, models.AuditEvent{ + TeamID: teamID, + Actor: "system", + Kind: kind, + Summary: summary, + Metadata: metaBlob, + }); err != nil { + slog.Warn("audit.emit.failed", + "kind", kind, + "team_id", teamID, + "from_tier", fromTier, + "to_tier", toTier, + "error", err, + ) + } +} + +// emitSubscriptionCanceledAudit writes the subscription.canceled audit row. +// Always emits on cancellation (regardless of the courtesy fall-back tier) +// because the Loops cancellation email is about the cancellation event +// itself, not the resulting tier delta. Best-effort: failures log only. +func emitSubscriptionCanceledAudit(ctx context.Context, db *sql.DB, teamID uuid.UUID, fromTier, toTier, subID string) { + meta := map[string]string{ + "from_tier": fromTier, + "to_tier": toTier, + "subscription_id": subID, + } + metaBlob, _ := json.Marshal(meta) + + if err := models.InsertAuditEvent(ctx, db, models.AuditEvent{ + TeamID: teamID, + Actor: "system", + Kind: models.AuditKindSubscriptionCanceled, + Summary: "subscription canceled", + Metadata: metaBlob, + }); err != nil { + slog.Warn("audit.emit.failed", + "kind", models.AuditKindSubscriptionCanceled, + "team_id", teamID, + "error", err, + ) + } +} diff --git a/internal/handlers/billing_test.go b/internal/handlers/billing_test.go index 0c6313d..e7993d5 100644 --- a/internal/handlers/billing_test.go +++ b/internal/handlers/billing_test.go @@ -375,6 +375,310 @@ func TestBillingWebhook_MissingSignature_Returns400(t *testing.T) { } } +// ── Audit emit on Razorpay webhooks (Track E) ──────────────────────────────── +// +// These tests exercise the new subscription.upgraded / subscription.downgraded +// / subscription.canceled audit_log rows that feed the Loops worker. They run +// against a real test Postgres so the JSONB metadata is round-tripped through +// the actual driver, not a mock. +// +// Two contract guarantees per kind: +// 1. The happy path writes exactly one audit row with the expected kind + +// metadata. +// 2. The fail-open invariant: when audit emit cannot fire (e.g. unknown +// from_tier), the webhook still returns 200 and the team-level tier +// mutation lands in the DB. + +// billingWebhookDBApp builds a Fiber app like billingTestApp but backed by a +// real test DB so the webhook's audit emits and tier updates actually land. +// Returns the handler-bound config so tests can read plan IDs back out. +func billingWebhookDBApp(t *testing.T, db *sql.DB) (*fiber.App, *config.Config) { + t.Helper() + cfg := &config.Config{ + JWTSecret: testhelpers.TestJWTSecret, + RazorpayWebhookSecret: testWebhookSecret, + // Configured plan_ids so the webhook can classify plan_id → tier + // without falling back to the default "pro" mapping. Match prod env + // var names but use fixed strings — tests don't care about format. + RazorpayPlanIDHobby: "plan_test_hobby", + RazorpayPlanIDPro: "plan_test_pro", + RazorpayPlanIDTeam: "plan_test_team", + } + bh := handlers.NewBillingHandler(db, cfg, email.New("")) + + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": "internal_error"}) + }, + }) + app.Use(middleware.RequestID()) + app.Post("/razorpay/webhook", bh.RazorpayWebhook) + return app, cfg +} + +// decodeAuditMetadata parses an audit_log.metadata::text payload back into a +// map. Postgres JSONB re-serialises keys in a canonical order and adds +// whitespace, so callers compare structural values rather than raw text. +func decodeAuditMetadata(t *testing.T, raw string) map[string]string { + t.Helper() + var m map[string]string + if err := json.Unmarshal([]byte(raw), &m); err != nil { + t.Fatalf("decodeAuditMetadata: %v\n raw=%s", err, raw) + } + return m +} + +// makeSubscriptionChargedPayloadWithPlan extends makeSubscriptionChargedPayload +// to set the plan_id field — required to test the upgrade/downgrade +// classification, which reads sub.plan_id via planIDToTier. +func makeSubscriptionChargedPayloadWithPlan(t *testing.T, teamID, subscriptionID, planID string) []byte { + t.Helper() + notes := map[string]any{} + if teamID != "" { + notes["team_id"] = teamID + } + subEntity, _ := json.Marshal(map[string]any{ + "id": subscriptionID, + "entity": "subscription", + "plan_id": planID, + "status": "active", + "notes": notes, + }) + event := map[string]any{ + "entity": "event", + "event": "subscription.charged", + "payload": map[string]any{ + "subscription": map[string]any{ + "entity": json.RawMessage(subEntity), + }, + }, + } + payload, err := json.Marshal(event) + if err != nil { + t.Fatalf("makeSubscriptionChargedPayloadWithPlan: %v", err) + } + return payload +} + +// TestBillingWebhook_SubscriptionUpgraded_EmitsAuditRow exercises the happy +// path for an upgrade: a team currently on `hobby` receives subscription. +// charged with the pro plan_id, the handler elevates the team to `pro`, and +// one audit_log row with kind = subscription.upgraded is written for the +// Loops forwarder. +func TestBillingWebhook_SubscriptionUpgraded_EmitsAuditRow(t *testing.T) { + db, cleanDB := billingStateNeedsDB(t) + defer cleanDB() + + app, cfg := billingWebhookDBApp(t, db) + + // Seed a hobby team — handleSubscriptionCharged reads its current tier + // before updating to derive the upgrade direction. + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID) + + payload := makeSubscriptionChargedPayloadWithPlan( + t, teamID, "sub_test_"+uuid.NewString(), cfg.RazorpayPlanIDPro, + ) + req := signedWebhookRequest(t, payload) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Tier must have moved to pro. + var newTier string + require.NoError(t, db.QueryRow(`SELECT plan_tier FROM teams WHERE id = $1::uuid`, teamID).Scan(&newTier)) + assert.Equal(t, "pro", newTier) + + // And exactly one subscription.upgraded audit row must exist. + var kind, summary, metaText string + require.NoError(t, db.QueryRow(` + SELECT kind, summary, metadata::text + FROM audit_log + WHERE team_id = $1::uuid AND kind = 'subscription.upgraded' + ORDER BY created_at DESC + LIMIT 1`, teamID).Scan(&kind, &summary, &metaText)) + assert.Equal(t, "subscription.upgraded", kind) + assert.Contains(t, summary, "hobby") + assert.Contains(t, summary, "pro") + + meta := decodeAuditMetadata(t, metaText) + assert.Equal(t, "hobby", meta["from_tier"]) + assert.Equal(t, "pro", meta["to_tier"]) +} + +// TestBillingWebhook_SubscriptionDowngraded_EmitsAuditRow covers the +// downgrade direction: a pro team receives a charged-webhook for the hobby +// plan (the eventual delivery after ChangePlan settles), and the handler +// writes a subscription.downgraded audit row. +func TestBillingWebhook_SubscriptionDowngraded_EmitsAuditRow(t *testing.T) { + db, cleanDB := billingStateNeedsDB(t) + defer cleanDB() + + app, cfg := billingWebhookDBApp(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID) + + payload := makeSubscriptionChargedPayloadWithPlan( + t, teamID, "sub_test_"+uuid.NewString(), cfg.RazorpayPlanIDHobby, + ) + req := signedWebhookRequest(t, payload) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var newTier string + require.NoError(t, db.QueryRow(`SELECT plan_tier FROM teams WHERE id = $1::uuid`, teamID).Scan(&newTier)) + assert.Equal(t, "hobby", newTier) + + var kind, metaText string + require.NoError(t, db.QueryRow(` + SELECT kind, metadata::text + FROM audit_log + WHERE team_id = $1::uuid AND kind = 'subscription.downgraded' + ORDER BY created_at DESC + LIMIT 1`, teamID).Scan(&kind, &metaText)) + assert.Equal(t, "subscription.downgraded", kind) + + meta := decodeAuditMetadata(t, metaText) + assert.Equal(t, "pro", meta["from_tier"]) + assert.Equal(t, "hobby", meta["to_tier"]) +} + +// TestBillingWebhook_SubscriptionCharged_SameTier_EmitsNoTransitionRow +// guards against the monthly-renewal noise case: a pro team receives a +// charged webhook for the pro plan_id (just a renewal, not a transition), +// and the handler must NOT write an upgrade / downgrade audit row. The +// Loops upgrade email firing on every renewal would be a regression. +func TestBillingWebhook_SubscriptionCharged_SameTier_EmitsNoTransitionRow(t *testing.T) { + db, cleanDB := billingStateNeedsDB(t) + defer cleanDB() + + app, cfg := billingWebhookDBApp(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID) + + payload := makeSubscriptionChargedPayloadWithPlan( + t, teamID, "sub_test_"+uuid.NewString(), cfg.RazorpayPlanIDPro, + ) + req := signedWebhookRequest(t, payload) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var count int + require.NoError(t, db.QueryRow(` + SELECT count(*) FROM audit_log + WHERE team_id = $1::uuid + AND kind IN ('subscription.upgraded', 'subscription.downgraded')`, + teamID).Scan(&count)) + assert.Equal(t, 0, count, + "same-tier renewals must NOT emit upgrade or downgrade rows") +} + +// TestBillingWebhook_SubscriptionCancelled_EmitsAuditRow covers the +// cancellation path: subscription.cancelled webhook arrives, the team is +// dropped to hobby (or free if never paid), and exactly one +// subscription.canceled audit row is written. +func TestBillingWebhook_SubscriptionCancelled_EmitsAuditRow(t *testing.T) { + db, cleanDB := billingStateNeedsDB(t) + defer cleanDB() + + app, _ := billingWebhookDBApp(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID) + + payload := makeSubscriptionCancelledPayload(t, teamID, "sub_test_"+uuid.NewString()) + req := signedWebhookRequest(t, payload) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Tier dropped to hobby (courtesy floor when at least one paid invoice + // happened — paid_count omitted from the payload defaults to nil, which + // the handler treats as "non-zero paid count" → hobby). + var newTier string + require.NoError(t, db.QueryRow(`SELECT plan_tier FROM teams WHERE id = $1::uuid`, teamID).Scan(&newTier)) + assert.Equal(t, "hobby", newTier) + + var kind, metaText string + require.NoError(t, db.QueryRow(` + SELECT kind, metadata::text + FROM audit_log + WHERE team_id = $1::uuid AND kind = 'subscription.canceled' + ORDER BY created_at DESC + LIMIT 1`, teamID).Scan(&kind, &metaText)) + assert.Equal(t, "subscription.canceled", kind) + + meta := decodeAuditMetadata(t, metaText) + assert.Equal(t, "pro", meta["from_tier"]) +} + +// TestBillingWebhook_SubscriptionCharged_FailOpen_AuditMissDoesNotRevertTier +// verifies the fail-open contract: when the audit emit silently fails +// (because the audit_log table is missing — simulating a partial migration +// state), the team-tier update still lands and the webhook returns 200. +// +// We force the failure by dropping the audit_log table inside the test, then +// recreating it after for other tests that share the DB. +func TestBillingWebhook_SubscriptionCharged_FailOpen_AuditMissDoesNotRevertTier(t *testing.T) { + db, cleanDB := billingStateNeedsDB(t) + defer cleanDB() + + app, cfg := billingWebhookDBApp(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "hobby") + defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID) + + // Snapshot the audit_log table definition before nuking it. The defer + // re-creates it so subsequent tests sharing this DB still work. + _, err := db.Exec(`DROP TABLE IF EXISTS audit_log CASCADE`) + require.NoError(t, err) + defer db.Exec(`CREATE TABLE IF NOT EXISTS audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + actor TEXT NOT NULL DEFAULT 'agent', + kind TEXT NOT NULL, + resource_type TEXT, + resource_id UUID, + summary TEXT NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`) + + payload := makeSubscriptionChargedPayloadWithPlan( + t, teamID, "sub_test_"+uuid.NewString(), cfg.RazorpayPlanIDPro, + ) + req := signedWebhookRequest(t, payload) + resp, err := app.Test(req, 5000) + require.NoError(t, err, "audit emit failure must not propagate as a Go error") + defer resp.Body.Close() + + // Webhook still returns 200 — Razorpay must not retry on audit misses. + assert.Equal(t, http.StatusOK, resp.StatusCode, + "audit emit failure must not turn the webhook into a 4xx/5xx") + + // And the tier elevation still landed despite the audit miss. + var newTier string + require.NoError(t, db.QueryRow(`SELECT plan_tier FROM teams WHERE id = $1::uuid`, teamID).Scan(&newTier)) + assert.Equal(t, "pro", newTier, + "tier update must commit even when audit emit fails (fail-open contract)") +} + // ── GetBillingState (GET /api/v1/billing) ─────────────────────────────────── // billingStateApp builds a Fiber app wired with the real BillingHandler plus a diff --git a/internal/handlers/onboarding.go b/internal/handlers/onboarding.go index 4c34f76..bebb202 100644 --- a/internal/handlers/onboarding.go +++ b/internal/handlers/onboarding.go @@ -1,10 +1,13 @@ package handlers import ( + "context" "database/sql" + "encoding/json" "errors" "log/slog" "net/url" + "strconv" "time" "github.com/gofiber/fiber/v2" @@ -373,6 +376,11 @@ func (h *OnboardingHandler) Claim(c *fiber.Ctx) error { "request_id", requestID, ) + // Best-effort audit emit — feeds the Loops forwarder for the welcome + // email. Fails open: a Loops miss must NEVER fail an otherwise-successful + // claim. Detached context so the goroutine outlives the request cycle. + go emitOnboardingClaimedAudit(h.db, team.ID, newUser.ID, len(claimedIDs), body.Email) + resp := fiber.Map{ "ok": true, "team_id": team.ID, @@ -384,3 +392,36 @@ func (h *OnboardingHandler) Claim(c *fiber.Ctx) error { } return c.Status(fiber.StatusCreated).JSON(resp) } + +// emitOnboardingClaimedAudit writes one audit_log row signalling that an +// anonymous session was upgraded into a registered team. Best-effort — +// callers fire this in a goroutine and ignore the outcome. The Loops +// forwarder picks the row up and triggers the welcome email; a miss here +// only loses the email, never the claim itself. +func emitOnboardingClaimedAudit(db *sql.DB, teamID, userID uuid.UUID, resourcesTransferred int, email string) { + // Detached context so the goroutine outlives the request cycle. + ctx := context.Background() + + // Metadata is serialized into JSONB. Marshal failure is fundamentally + // impossible for this fixed shape, but we still fall through with nil + // rather than panicking — same convention as experiments.go. + metaBlob, _ := json.Marshal(map[string]string{ + "email": email, + "resources_transferred": strconv.Itoa(resourcesTransferred), + }) + + if err := models.InsertAuditEvent(ctx, db, models.AuditEvent{ + TeamID: teamID, + UserID: uuid.NullUUID{UUID: userID, Valid: userID != uuid.Nil}, + Actor: "user", + Kind: models.AuditKindOnboardingClaimed, + Summary: "team claimed and onboarded", + Metadata: metaBlob, + }); err != nil { + slog.Warn("audit.emit.failed", + "kind", models.AuditKindOnboardingClaimed, + "team_id", teamID, + "error", err, + ) + } +} diff --git a/internal/handlers/onboarding_test.go b/internal/handlers/onboarding_test.go index 141d790..f5e60e4 100644 --- a/internal/handlers/onboarding_test.go +++ b/internal/handlers/onboarding_test.go @@ -402,3 +402,63 @@ func TestOnboarding_JWTWithFutureIssuedAt_Returns400(t *testing.T) { assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "token with future IssuedAt must be rejected with 400") } + +// TestOnboarding_PostClaim_EmitsAuditLogRow verifies that a successful POST +// /claim writes one audit_log row with kind = "onboarding.claimed". The row +// drives the Loops "welcome" lifecycle email; if the emit is silently dropped +// the user gets no email even though their claim succeeded. +// +// The audit write runs in a detached goroutine, so the test polls for up to +// ~2s for the row to land (same pattern as TestExperimentsConverted_*). +func TestOnboarding_PostClaim_EmitsAuditLogRow(t *testing.T) { + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + rdb, cleanRedis := testhelpers.SetupTestRedis(t) + defer cleanRedis() + + app, cleanApp := testhelpers.NewTestApp(t, db, rdb) + defer cleanApp() + + fp := testhelpers.UniqueFingerprint(t) + res := testhelpers.MustProvisionCacheFull(t, app, fp) + require.NotEmpty(t, res.JWT, "provision response must include an onboarding JWT") + defer db.Exec(`DELETE FROM resources WHERE token = $1`, res.Token) + + email := testhelpers.UniqueEmail(t) + body := map[string]any{ + "jwt": res.JWT, + "email": email, + "team_name": "audit-claim-" + uuid.NewString()[:8], + } + claimResp := testhelpers.PostJSON(t, app, "/claim", body) + defer claimResp.Body.Close() + require.Equal(t, http.StatusCreated, claimResp.StatusCode) + + // Resolve the team_id that was created by the claim so we can scope the + // audit_log lookup. The claim response carries it directly. + var claimBody map[string]any + testhelpers.DecodeJSON(t, claimResp, &claimBody) + teamID, _ := claimBody["team_id"].(string) + require.NotEmpty(t, teamID, "claim response must carry team_id") + defer db.Exec(`DELETE FROM teams WHERE id = $1::uuid`, teamID) + + // The audit write is async — poll for up to ~2s for the row to land. + var kind, summary, metaText string + for i := 0; i < 40; i++ { + err := db.QueryRow(` + SELECT kind, summary, metadata::text + FROM audit_log + WHERE team_id = $1::uuid AND kind = 'onboarding.claimed' + ORDER BY created_at DESC + LIMIT 1`, teamID).Scan(&kind, &summary, &metaText) + if err == nil { + break + } + time.Sleep(50 * time.Millisecond) + } + require.Equal(t, "onboarding.claimed", kind, + "audit_log row with kind='onboarding.claimed' must exist after a successful claim") + assert.NotEmpty(t, summary) + assert.Contains(t, metaText, email, + "audit metadata should capture the claiming user's email for Loops payload") +} diff --git a/internal/models/audit_kinds.go b/internal/models/audit_kinds.go new file mode 100644 index 0000000..34ed16d --- /dev/null +++ b/internal/models/audit_kinds.go @@ -0,0 +1,35 @@ +package models + +// audit_kinds.go — named constants for audit_log.kind values that downstream +// systems (e.g. the Loops worker) match on. Centralising these strings stops +// callers from typo-drifting "subscription.canceled" vs "subscription.cancelled" +// at emit sites; the Loops forwarder consumes the exact value of these +// constants. +// +// New kinds added here MUST also be wired into the Loops forwarder map (see +// PR #10 in the worker repo) or they will be dropped silently. + +const ( + // AuditKindOnboardingClaimed fires once per successful POST /claim — the + // anonymous-to-claimed conversion completing. Drives the "welcome" Loops + // lifecycle email. + AuditKindOnboardingClaimed = "onboarding.claimed" + + // AuditKindSubscriptionUpgraded fires when a Razorpay subscription.charged + // webhook moves a team to a strictly higher tier (e.g. hobby → pro). Does + // NOT fire on first-charge from free/anonymous — see AuditKindSubscriptionStarted + // when that kind is added. + AuditKindSubscriptionUpgraded = "subscription.upgraded" + + // AuditKindSubscriptionDowngraded fires when a Razorpay subscription.charged + // webhook moves a team to a strictly lower tier (e.g. pro → hobby) — for + // example after a plan change that bills the cheaper plan. + AuditKindSubscriptionDowngraded = "subscription.downgraded" + + // AuditKindSubscriptionCanceled fires on subscription.cancelled webhook. + // Drives the "we'd love to know why" Loops cancellation email. Note the + // single-l US spelling — matches the Loops forwarder map. The Razorpay + // event name uses the double-l UK spelling, which is handled inside the + // billing handler. + AuditKindSubscriptionCanceled = "subscription.canceled" +) diff --git a/internal/testhelpers/testhelpers.go b/internal/testhelpers/testhelpers.go index b3e72df..488f262 100644 --- a/internal/testhelpers/testhelpers.go +++ b/internal/testhelpers/testhelpers.go @@ -189,6 +189,23 @@ func runMigrations(t *testing.T, db *sql.DB) { ts TIMESTAMPTZ NOT NULL DEFAULT now() )`, `CREATE INDEX IF NOT EXISTS idx_vault_audit_team_ts ON vault_audit_log (team_id, ts DESC)`, + // 012_audit_log — per-team event stream consumed by the dashboard's + // Recent Activity feed. Mirrored here so callers that bring up a fresh + // test DB via SetupTestDB get the table without needing the SQL + // migrations to have been applied separately. + `CREATE TABLE IF NOT EXISTS audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + actor TEXT NOT NULL DEFAULT 'agent', + kind TEXT NOT NULL, + resource_type TEXT, + resource_id UUID, + summary TEXT NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`, + `CREATE INDEX IF NOT EXISTS idx_audit_team_at ON audit_log (team_id, created_at DESC)`, // 003_deployments — Phase 6 container deployments `CREATE TABLE IF NOT EXISTS deployments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(),