package billing import ( "context" "encoding/json" "fmt" "os" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) func openStripeMockPool(t *testing.T) (*pgxpool.Pool, context.Context, context.CancelFunc) { t.Helper() dsn := os.Getenv("DATABASE_URL") if dsn == "" { t.Skip("DATABASE_URL not set") } ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) pg, err := pgxpool.New(ctx, dsn) if err != nil { cancel() t.Fatal(err) } t.Cleanup(func() { pg.Close() }) return pg, ctx, cancel } func seedStripeMockCompany(t *testing.T, pg *pgxpool.Pool, ctx context.Context) uuid.UUID { t.Helper() companyID := uuid.New() _, err := pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "stripe-mock-"+companyID.String()[:8]) if err != nil { t.Fatal(err) } t.Cleanup(func() { cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 15*time.Second) defer cleanupCancel() _, _ = pg.Exec(cleanupCtx, `DELETE FROM stripe_webhook_events WHERE company_id = $1`, companyID) _, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID) }) return companyID } func creditTotal(t *testing.T, pg *pgxpool.Pool, ctx context.Context, companyID uuid.UUID) int { t.Helper() var total int err := pg.QueryRow(ctx, `SELECT COALESCE(total_credits, 0) FROM credit_balances WHERE company_id = $1`, companyID).Scan(&total) if err != nil { return 0 } return total } func activePlanName(t *testing.T, pg *pgxpool.Pool, ctx context.Context, companyID uuid.UUID) string { t.Helper() var name string err := pg.QueryRow(ctx, ` SELECT lower(p.name) FROM company_plans cp JOIN plans p ON p.id = cp.plan_id WHERE cp.company_id = $1 AND cp.is_active = true ORDER BY cp.created_at DESC LIMIT 1`, companyID).Scan(&name) if err != nil { return "" } return name } func TestMockCheckoutPlanAssignsAndGrants(t *testing.T) { pg, ctx, cancel := openStripeMockPool(t) defer cancel() companyID := seedStripeMockCompany(t, pg, ctx) billing := &Service{Pool: pg} if err := billing.EnsureDefaultPlans(ctx); err != nil { t.Fatal(err) } s := &StripeService{ Pool: pg, Billing: billing, Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"}, } res, err := s.CreateCheckoutSession(ctx, companyID, "mock@example.com", "Mock Co", CheckoutRequest{Plan: "starter", Term: "monthly"}) if err != nil { t.Fatal(err) } if !res.Mock || !res.Applied { t.Fatalf("expected mock applied checkout, got %#v", res) } if activePlanName(t, pg, ctx, companyID) != "starter" { t.Fatalf("plan=%q want starter", activePlanName(t, pg, ctx, companyID)) } want := MonthlyCreditsForPlan("Starter", 0) if got := creditTotal(t, pg, ctx, companyID); got != want { t.Fatalf("credits=%d want %d", got, want) } var subID *string _ = pg.QueryRow(ctx, ` SELECT stripe_subscription_id FROM company_plans WHERE company_id = $1 AND is_active = true`, companyID).Scan(&subID) if subID == nil || *subID == "" { t.Fatal("mock checkout must set stripe_subscription_id") } } func TestMockCreditPackCheckoutGrants(t *testing.T) { pg, ctx, cancel := openStripeMockPool(t) defer cancel() companyID := seedStripeMockCompany(t, pg, ctx) billing := &Service{Pool: pg} s := &StripeService{ Pool: pg, Billing: billing, Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"}, } before := creditTotal(t, pg, ctx, companyID) res, err := s.CreateCreditPackCheckout(ctx, companyID, "mock@example.com", "Mock Co", "small") if err != nil { t.Fatal(err) } if !res.Mock || !res.Applied { t.Fatalf("expected mock applied pack, got %#v", res) } pack, _ := CreditPackByID("small") if got := creditTotal(t, pg, ctx, companyID); got != before+pack.Credits { t.Fatalf("credits=%d want %d", got, before+pack.Credits) } } func TestWebhookClaimIdempotentAndCreditGrant(t *testing.T) { pg, ctx, cancel := openStripeMockPool(t) defer cancel() companyID := seedStripeMockCompany(t, pg, ctx) billing := &Service{Pool: pg} s := &StripeService{ Pool: pg, Billing: billing, Cfg: StripeConfig{ForceMock: true}, // unsigned allowed locally; no webhook secret } eventID := "evt_mock_credit_" + companyID.String()[:8] payload, err := json.Marshal(map[string]any{ "id": eventID, "type": "checkout.session.completed", "data": map[string]any{ "object": map[string]any{ "id": "cs_mock_1", "client_reference_id": companyID.String(), "metadata": map[string]string{ "kind": "credit_pack", "pack": "tiny", "credits": "9999", // must be ignored for catalog pack }, }, }, }) if err != nil { t.Fatal(err) } before := creditTotal(t, pg, ctx, companyID) if err := s.HandleWebhook(ctx, payload, ""); err != nil { t.Fatal(err) } pack, _ := CreditPackByID("tiny") if got := creditTotal(t, pg, ctx, companyID); got != before+pack.Credits { t.Fatalf("after grant credits=%d want %d", got, before+pack.Credits) } mid := creditTotal(t, pg, ctx, companyID) if err := s.HandleWebhook(ctx, payload, ""); err != nil { t.Fatal(err) } if got := creditTotal(t, pg, ctx, companyID); got != mid { t.Fatalf("idempotent claim must not double-grant: got %d mid %d", got, mid) } } func TestWebhookSubscriptionDeletedDowngrades(t *testing.T) { pg, ctx, cancel := openStripeMockPool(t) defer cancel() companyID := seedStripeMockCompany(t, pg, ctx) billing := &Service{Pool: pg} if err := billing.EnsureDefaultPlans(ctx); err != nil { t.Fatal(err) } s := &StripeService{ Pool: pg, Billing: billing, Cfg: StripeConfig{ForceMock: true}, } if _, err := s.CreateCheckoutSession(ctx, companyID, "mock@example.com", "Mock Co", CheckoutRequest{Plan: "plus", Term: "monthly"}); err != nil { t.Fatal(err) } if activePlanName(t, pg, ctx, companyID) != "plus" { t.Fatalf("precondition plan=%q", activePlanName(t, pg, ctx, companyID)) } eventID := "evt_mock_del_" + companyID.String()[:8] payload, err := json.Marshal(map[string]any{ "id": eventID, "type": "customer.subscription.deleted", "data": map[string]any{ "object": map[string]any{ "id": "sub_mock_del", "customer": "cus_mock", "status": "canceled", "metadata": map[string]string{"company_id": companyID.String()}, }, }, }) if err != nil { t.Fatal(err) } if err := s.HandleWebhook(ctx, payload, ""); err != nil { t.Fatal(err) } if got := activePlanName(t, pg, ctx, companyID); got != "free" { t.Fatalf("after delete plan=%q want free", got) } } func TestWebhookVerifyStillRequiredWithSecretUnderForceMock(t *testing.T) { pg, ctx, cancel := openStripeMockPool(t) defer cancel() companyID := seedStripeMockCompany(t, pg, ctx) secret := "whsec_mock_local" s := &StripeService{ Pool: pg, Billing: &Service{Pool: pg}, Cfg: StripeConfig{ForceMock: true, WebhookSecret: secret}, } payload, err := json.Marshal(map[string]any{ "id": "evt_signed_" + companyID.String()[:8], "type": "ping", "data": map[string]any{"object": map[string]any{}}, }) if err != nil { t.Fatal(err) } if err := s.HandleWebhook(ctx, payload, ""); err == nil { t.Fatal("unsigned must fail when webhook secret set") } sig := signStripePayload(t, secret, payload) if err := s.HandleWebhook(ctx, payload, sig); err != nil { t.Fatalf("valid signature under ForceMock: %v", err) } // Second delivery is an idempotent no-op. if err := s.HandleWebhook(ctx, payload, sig); err != nil { t.Fatal(err) } var n int if err := pg.QueryRow(ctx, `SELECT COUNT(*) FROM stripe_webhook_events WHERE event_id = $1`, fmt.Sprintf("evt_signed_%s", companyID.String()[:8])).Scan(&n); err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("claim rows=%d want 1", n) } }