Files
descrybe/apps/api/internal/billing/stripe_test.go
T
2026-08-16 11:37:42 +02:00

369 lines
13 KiB
Go

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(&note); 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)))
}