package billing import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "fmt" "testing" "time" "github.com/google/uuid" ) func TestNormalizePlanTerm(t *testing.T) { plan, term, err := normalizePlanTerm("Starter", "YEARLY") if err != nil || plan != "starter" || term != "yearly" { t.Fatalf("got %s %s err=%v", plan, term, err) } plan, term, err = normalizePlanTerm("Plus", "monthly") if err != nil || plan != "plus" || term != "monthly" { t.Fatalf("plus: got %s %s err=%v", plan, term, err) } plan, term, err = normalizePlanTerm("scale", "yearly") if err != nil || plan != "scale" || term != "yearly" { t.Fatalf("scale: got %s %s err=%v", plan, term, err) } _, _, err = normalizePlanTerm("enterprise", "monthly") if err == nil { t.Fatal("enterprise must be rejected") } _, _, err = normalizePlanTerm("free", "monthly") if err == nil { t.Fatal("free must be rejected") } } func TestCheckoutReturnURL(t *testing.T) { success := checkoutReturnURL("https://app.example", "success", "starter", "monthly", "", true) if success != "https://app.example/billing?checkout=success&plan=starter&term=monthly&session_id={CHECKOUT_SESSION_ID}" { t.Fatalf("success url: %s", success) } cancel := checkoutReturnURL("https://app.example/", "cancel", "starter", "yearly", "", false) if cancel != "https://app.example/billing?checkout=cancel&plan=starter&term=yearly" { t.Fatalf("cancel url: %s", cancel) } pack := checkoutReturnURL("https://app.example", "success", "", "", "tiny", true) if pack != "https://app.example/billing?checkout=success&pack=tiny&session_id={CHECKOUT_SESSION_ID}" { t.Fatalf("pack url: %s", pack) } packCancel := checkoutReturnURL("https://app.example", "cancel", "", "", "tiny", false) if packCancel != "https://app.example/billing?checkout=cancel&pack=tiny" { t.Fatalf("pack cancel url: %s", packCancel) } } func TestVerifyStripeSignature(t *testing.T) { secret := "whsec_test_secret" payload := []byte(`{"id":"evt_1","type":"checkout.session.completed"}`) ts := time.Now().Unix() mac := hmac.New(sha256.New, []byte(secret)) _, _ = fmt.Fprintf(mac, "%d.", ts) _, _ = mac.Write(payload) sig := hex.EncodeToString(mac.Sum(nil)) header := fmt.Sprintf("t=%d,v1=%s", ts, sig) if err := verifyStripeSignature(payload, header, secret, 5*time.Minute); err != nil { t.Fatal(err) } if err := verifyStripeSignature(payload, "t="+fmt.Sprint(ts)+",v1=deadbeef", secret, 5*time.Minute); err == nil { t.Fatal("expected bad signature") } } func TestStripeConfigMockMode(t *testing.T) { if !(StripeConfig{}).MockMode() { t.Fatal("empty secret should be mock") } if (StripeConfig{SecretKey: "sk_test_x"}).MockMode() { t.Fatal("secret set should not mock") } if !(StripeConfig{SecretKey: "sk_test_x", ForceMock: true}).MockMode() { t.Fatal("ForceMock should override") } if (StripeConfig{}).AllowMockPurchase() { t.Fatal("empty secret alone must not allow mock purchase") } if (StripeConfig{SecretKey: "sk_test_x"}).AllowMockPurchase() { t.Fatal("live secret must not allow mock purchase") } if !(StripeConfig{ForceMock: true}).AllowMockPurchase() { t.Fatal("ForceMock should allow mock purchase") } } func TestEnsureSelfServeCheckout(t *testing.T) { live := &StripeService{Cfg: StripeConfig{SecretKey: "sk_test_x"}} if err := live.EnsureSelfServeCheckout(context.TODO(), false); err != nil { t.Fatalf("live stripe should allow any admin: %v", err) } mock := &StripeService{Cfg: StripeConfig{ForceMock: true}} if err := mock.EnsureSelfServeCheckout(context.TODO(), true); err != nil { t.Fatalf("mock + platform admin should allow: %v", err) } if err := mock.EnsureSelfServeCheckout(context.TODO(), false); !errors.Is(err, ErrStripeSelfServeUnavailable) { t.Fatalf("mock + non-platform admin: want ErrStripeSelfServeUnavailable, got %v", err) } empty := &StripeService{Cfg: StripeConfig{}} if err := empty.EnsureSelfServeCheckout(context.TODO(), true); !errors.Is(err, ErrStripeNotConfigured) { t.Fatalf("empty secret: want ErrStripeNotConfigured, got %v", err) } } func TestCreateCheckoutSessionFailsClosedWithoutSecret(t *testing.T) { s := &StripeService{Cfg: StripeConfig{WebOrigin: "http://localhost:5174"}} _, err := s.CreateCheckoutSession(context.TODO(), uuid.Nil, "a@b.c", "Acme", CheckoutRequest{Plan: "starter", Term: "monthly"}) if !errors.Is(err, ErrStripeNotConfigured) { t.Fatalf("want ErrStripeNotConfigured, got %v", err) } } func TestHandleWebhookRejectsUnsignedWithoutForceMock(t *testing.T) { // Empty secret (MockMode) without ForceMock must still reject unsigned webhooks. s := &StripeService{Cfg: StripeConfig{}} err := s.HandleWebhook(context.TODO(), []byte(`{"id":"evt_x","type":"ping"}`), "") if err != ErrStripeNotConfigured { t.Fatalf("want ErrStripeNotConfigured, got %v", err) } s2 := &StripeService{Cfg: StripeConfig{SecretKey: "sk_test_x"}} err = s2.HandleWebhook(context.TODO(), []byte(`{"id":"evt_y","type":"ping"}`), "") if err != ErrStripeNotConfigured { t.Fatalf("live without webhook secret: want ErrStripeNotConfigured, got %v", err) } } func TestHandleWebhookVerifiesEvenWhenForceMock(t *testing.T) { secret := "whsec_test_secret" payload := []byte(`{"id":"evt_1","type":"ping"}`) s := &StripeService{Cfg: StripeConfig{ForceMock: true, WebhookSecret: secret}} err := s.HandleWebhook(context.TODO(), payload, "t=1,v1=deadbeef") if !errors.Is(err, ErrStripeBadSignature) { t.Fatalf("ForceMock must still verify when WebhookSecret set, got %v", err) } } func TestHandleWebhookRejectsUnsignedInProductionEvenWithForceMock(t *testing.T) { t.Setenv("APP_ENV", "production") s := &StripeService{Cfg: StripeConfig{ForceMock: true}} err := s.HandleWebhook(context.TODO(), []byte(`{"id":"evt_prod","type":"ping"}`), "") if !errors.Is(err, ErrStripeNotConfigured) { t.Fatalf("production must reject unsigned ForceMock webhooks, got %v", err) } } func TestLoadStripePriceIDs(t *testing.T) { m := LoadStripePriceIDs(func(k string) string { switch k { case "STRIPE_PRICE_STARTER_MONTHLY": return "price_starter_m" case "STRIPE_PRICE_PACK_SMALL": return "price_pack_s" default: return "" } }) if m["starter:monthly"] != "price_starter_m" { t.Fatalf("got %#v", m) } if m["pack:small"] != "price_pack_s" { t.Fatalf("pack missing: %#v", m) } } func TestPlanFromPriceIDIgnoresPacks(t *testing.T) { s := &StripeService{Cfg: StripeConfig{PriceIDs: map[string]string{ "growth:monthly": "price_g_m", "pack:small": "price_pack_s", }}} if got := s.planFromPriceID("price_g_m"); got != "growth" { t.Fatalf("got %q", got) } if got := s.planFromPriceID("price_pack_s"); got != "" { t.Fatalf("pack price must not map to a plan, got %q", got) } } func TestCreditPackCatalog(t *testing.T) { if got := MonthlyCreditsForPlan("Starter", 0); got != 100 { t.Fatalf("starter cover: got %d", got) } if got := MonthlyCreditsForPlan("Plus", 0); got != 400 { t.Fatalf("plus cover: got %d", got) } if got := MonthlyCreditsForPlan("Growth", 0); got != 1200 { t.Fatalf("growth cover: got %d", got) } if got := MonthlyCreditsForPlan("Business", 0); got != 4000 { t.Fatalf("business cover: got %d", got) } if got := MonthlyCreditsForPlan("Scale", 0); got != 12000 { t.Fatalf("scale cover: got %d", got) } packs := DefaultCreditPacks() if len(packs) < 7 { t.Fatalf("want at least 7 packs, got %d", len(packs)) } tiny, ok := CreditPackByID("tiny") if !ok || tiny.Credits != 25 || tiny.PriceUSD != 29 { t.Fatalf("tiny pack: %#v ok=%v", tiny, ok) } small, ok := CreditPackByID("small") if !ok || small.Credits != 65 || small.PriceUSD != 59 { t.Fatalf("small pack: %#v ok=%v", small, ok) } med, ok := CreditPackByID("medium") if !ok || med.Credits != 200 || med.PriceUSD != 149 { t.Fatalf("medium pack: %#v ok=%v", med, ok) } mega, ok := CreditPackByID("mega") if !ok || mega.Credits != 8000 || mega.PriceUSD != 2999 { t.Fatalf("mega pack: %#v ok=%v", mega, ok) } if CreditPackSettingsKey("small") != "stripe.price.pack.small" { t.Fatalf("settings key") } if CreditPackEnvVar("xxl") != "STRIPE_PRICE_PACK_XXL" { t.Fatalf("env var") } if _, ok := CreditPackByID("nope"); ok { t.Fatal("unknown pack must miss") } } func TestPlanFromPriceID(t *testing.T) { s := &StripeService{Cfg: StripeConfig{PriceIDs: map[string]string{ "growth:monthly": "price_g_m", }}} if got := s.planFromPriceID("price_g_m"); got != "growth" { t.Fatalf("got %q", got) } } func TestNormalizeSubscriptionStatus(t *testing.T) { if got := NormalizeSubscriptionStatus(" Past_Due "); got != "past_due" { t.Fatalf("got %q", got) } } func TestIsPastDueSubscriptionStatus(t *testing.T) { if !IsPastDueSubscriptionStatus("past_due") { t.Fatal("expected past_due") } if !IsPastDueSubscriptionStatus(" Past_Due ") { t.Fatal("expected normalized past_due") } if IsPastDueSubscriptionStatus("active") { t.Fatal("active must not be past_due") } if IsPastDueSubscriptionStatus("") { t.Fatal("empty must not be past_due") } } func TestParseStripeStatusNote(t *testing.T) { note := FormatStripeStatusNote("Past_Due") if note != "stripe_status:past_due" { t.Fatalf("format got %q", note) } if got := ParseStripeStatusNote(¬e); got != "past_due" { t.Fatalf("parse got %q", got) } ops := "ops: keep forever" if got := ParseStripeStatusNote(&ops); got != "" { t.Fatalf("ops notes must be ignored, got %q", got) } if got := ParseStripeStatusNote(nil); got != "" { t.Fatalf("nil got %q", got) } } func TestCreatePortalSessionMock(t *testing.T) { s := &StripeService{Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"}} res, err := s.CreatePortalSession(context.TODO(), uuid.New()) if err != nil { t.Fatal(err) } if !res.Mock || res.URL != "http://localhost:5174/billing?portal=mock" { t.Fatalf("got %#v", res) } // Empty secret alone (MockMode without ForceMock) also returns mock portal deep-link. s2 := &StripeService{Cfg: StripeConfig{WebOrigin: "http://localhost:5174"}} res, err = s2.CreatePortalSession(context.TODO(), uuid.New()) if err != nil { t.Fatal(err) } if !res.Mock { t.Fatalf("mock mode portal expected, got %#v", res) } } func TestCreateCheckoutSessionMockRequiresBilling(t *testing.T) { s := &StripeService{Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"}} _, err := s.CreateCheckoutSession(context.TODO(), uuid.New(), "a@b.c", "Acme", CheckoutRequest{Plan: "starter", Term: "monthly"}) if err == nil || err.Error() != "billing service not configured" { t.Fatalf("want billing not configured, got %v", err) } } func TestCreateCreditPackCheckoutMockRequiresBilling(t *testing.T) { s := &StripeService{Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"}} _, err := s.CreateCreditPackCheckout(context.TODO(), uuid.New(), "a@b.c", "Acme", "small") if err == nil || err.Error() != "billing service not configured" { t.Fatalf("want billing not configured, got %v", err) } _, err = s.CreateCreditPackCheckout(context.TODO(), uuid.New(), "a@b.c", "Acme", "nope") if !errors.Is(err, ErrStripePlanUnsupported) { t.Fatalf("unknown pack: %v", err) } } func TestCreditsFromPackMetadata(t *testing.T) { got, err := creditsFromPackMetadata(map[string]string{"pack": "small", "credits": "999999"}) if err != nil { t.Fatal(err) } if got != 65 { t.Fatalf("catalog must win over inflated credits, got %d", got) } got, err = creditsFromPackMetadata(map[string]string{"pack": "small", "credits": "not-a-number"}) if err != nil { t.Fatal(err) } if got != 65 { t.Fatalf("catalog must win over garbage credits, got %d", got) } _, err = creditsFromPackMetadata(map[string]string{"pack": "unknown", "credits": "abc"}) if err == nil { t.Fatal("unknown pack with garbage credits must fail") } got, err = creditsFromPackMetadata(map[string]string{"pack": "custom", "credits": "42"}) if err != nil { t.Fatal(err) } if got != 42 { t.Fatalf("unknown pack may use positive credits metadata, got %d", got) } _, err = creditsFromPackMetadata(map[string]string{"pack": "nope"}) if !errors.Is(err, ErrStripePlanUnsupported) { t.Fatalf("empty credits unknown pack: %v", err) } } func TestHandleWebhookForceMockUnsignedNeedsStore(t *testing.T) { s := &StripeService{Cfg: StripeConfig{ForceMock: true}} err := s.HandleWebhook(context.TODO(), []byte(`{"id":"evt_local","type":"ping"}`), "") if err == nil || err.Error() != "stripe store not configured" { t.Fatalf("want store not configured, got %v", err) } } func signStripePayload(t *testing.T, secret string, payload []byte) string { t.Helper() ts := time.Now().Unix() mac := hmac.New(sha256.New, []byte(secret)) _, _ = fmt.Fprintf(mac, "%d.", ts) _, _ = mac.Write(payload) return fmt.Sprintf("t=%d,v1=%s", ts, hex.EncodeToString(mac.Sum(nil))) }