package billing import ( "context" "errors" "sync" "testing" "github.com/stretchr/testify/require" ) type fakeProvider struct { mu sync.Mutex creates int lastCheckout CheckoutParams event *Event checkout ProviderCheckout subscription ProviderSubscription } func (p *fakeProvider) CreateCheckout(_ context.Context, input CheckoutParams) (*ProviderCheckout, error) { p.mu.Lock() defer p.mu.Unlock() p.creates++ p.lastCheckout = input out := p.checkout out.PriceID = input.PriceID return &out, nil } func (p *fakeProvider) GetCheckout(context.Context, string) (*ProviderCheckout, error) { out := p.checkout return &out, nil } func (p *fakeProvider) GetSubscription(context.Context, string) (*ProviderSubscription, error) { out := p.subscription return &out, nil } func (p *fakeProvider) CreatePortal(context.Context, string, string) (string, error) { return "https://portal.test", nil } func (p *fakeProvider) VerifyEvent([]byte, string, string) (*Event, error) { if p.event == nil { return nil, ErrInvalidSignature } out := *p.event return &out, nil } type fakeRepo struct { mu sync.Mutex attempts map[string]*CheckoutAttempt requests map[string]string processed map[string]bool state SubscriptionState lastCreated int64 lastEvent string purchases map[string]*PurchaseAudit entitlementErr error } func newFakeRepo() *fakeRepo { return &fakeRepo{attempts: map[string]*CheckoutAttempt{}, requests: map[string]string{}, processed: map[string]bool{}, purchases: map[string]*PurchaseAudit{}, state: SubscriptionState{PlanID: PlanFree}} } func (r *fakeRepo) InsertCheckout(_ context.Context, a *CheckoutAttempt) (bool, error) { r.mu.Lock() defer r.mu.Unlock() key := formatUID(a.UID) + ":" + a.RequestID if _, ok := r.requests[key]; ok { return false, nil } cp := *a r.attempts[a.ID] = &cp r.requests[key] = a.ID return true, nil } func (r *fakeRepo) GetCheckoutByRequest(_ context.Context, uid int64, request string) (*CheckoutAttempt, error) { r.mu.Lock() defer r.mu.Unlock() return r.copyAttempt(r.requests[formatUID(uid)+":"+request]) } func (r *fakeRepo) GetCheckout(_ context.Context, uid int64, id string) (*CheckoutAttempt, error) { r.mu.Lock() defer r.mu.Unlock() a, err := r.copyAttempt(id) if err != nil || a.UID != uid { return nil, ErrNotFound } return a, nil } func (r *fakeRepo) GetCheckoutBySession(_ context.Context, session string) (*CheckoutAttempt, error) { r.mu.Lock() defer r.mu.Unlock() for _, a := range r.attempts { if a.SessionID == session { cp := *a return &cp, nil } } return nil, ErrNotFound } func (r *fakeRepo) copyAttempt(id string) (*CheckoutAttempt, error) { a := r.attempts[id] if a == nil { return nil, ErrNotFound } cp := *a return &cp, nil } func (r *fakeRepo) UpdateCheckout(_ context.Context, a *CheckoutAttempt) error { r.mu.Lock() defer r.mu.Unlock() cp := *a r.attempts[a.ID] = &cp return nil } func (r *fakeRepo) GetSubscription(context.Context, int64) (*SubscriptionState, error) { r.mu.Lock() defer r.mu.Unlock() cp := r.state return &cp, nil } func (r *fakeRepo) FindUIDByCustomer(_ context.Context, customer string) (int64, error) { r.mu.Lock() defer r.mu.Unlock() if r.state.CustomerID == customer { return r.state.UID, nil } for _, a := range r.attempts { if a.CustomerID == customer { return a.UID, nil } } return 0, ErrNotFound } func (r *fakeRepo) ApplyEntitlement(_ context.Context, u EntitlementUpdate) (bool, error) { r.mu.Lock() defer r.mu.Unlock() if r.entitlementErr != nil { return false, r.entitlementErr } if u.EventCreatedAt < r.lastCreated || (u.EventCreatedAt == r.lastCreated && u.EventID <= r.lastEvent) { return false, nil } r.lastCreated, r.lastEvent = u.EventCreatedAt, u.EventID r.state.UID, r.state.Status, r.state.CustomerID, r.state.SubscriptionID = u.UID, u.Status, u.CustomerID, u.SubscriptionID r.state.PaidPlanID = u.PaidPlanID r.state.CurrentPeriodStart, r.state.CurrentPeriodEnd, r.state.CancelAtPeriodEnd = u.CurrentPeriodStart, u.CurrentPeriodEnd, u.CancelAtPeriodEnd if !r.state.AdminOverride { r.state.PlanID = u.PlanID } return true, nil } func (r *fakeRepo) WebhookProcessed(_ context.Context, id string) (bool, error) { r.mu.Lock() defer r.mu.Unlock() return r.processed[id], nil } func (r *fakeRepo) MarkWebhookProcessed(_ context.Context, id, _ string, _ int64) error { r.mu.Lock() defer r.mu.Unlock() r.processed[id] = true return nil } func (r *fakeRepo) UpsertPurchase(_ context.Context, p *PurchaseAudit) error { r.mu.Lock() defer r.mu.Unlock() cp := *p r.purchases[p.StripeEventID] = &cp return nil } func newService(repo Repository, provider Provider) *Service { return &Service{Repo: repo, Provider: provider, Enabled: true, WebhookSecret: "whsec_test", StarterPriceID: "price_starter", ProPriceID: "price_pro", SuccessURL: "https://app/success", CancelURL: "https://app/cancel", PortalReturnURL: "https://app/billing"} } func TestPlanPriceMapping(t *testing.T) { s := newService(newFakeRepo(), &fakeProvider{}) price, err := s.PriceForPlan(" STARTER ") require.NoError(t, err) require.Equal(t, "price_starter", price) plan, err := s.PlanForPrice("price_pro") require.NoError(t, err) require.Equal(t, PlanPro, plan) _, err = s.PriceForPlan(PlanFree) require.ErrorIs(t, err, ErrInvalidPlan) _, err = s.PlanForPrice("price_spoofed") require.ErrorIs(t, err, ErrInvalidPlan) } func TestCreateCheckoutIsIdempotent(t *testing.T) { r := newFakeRepo() p := &fakeProvider{checkout: ProviderCheckout{SessionID: "cs_1", URL: "https://checkout", ExpiresAt: 123}} s := newService(r, p) a, err := s.CreateCheckout(context.Background(), 7, PlanStarter, "request-1") require.NoError(t, err) b, err := s.CreateCheckout(context.Background(), 7, PlanStarter, "request-1") require.NoError(t, err) require.Equal(t, a.ID, b.ID) require.Equal(t, 1, p.creates) _, err = s.CreateCheckout(context.Background(), 7, PlanPro, "request-1") require.ErrorIs(t, err, ErrConflict) } func TestCreateCheckoutInjectsReturnURLTokens(t *testing.T) { r := newFakeRepo() p := &fakeProvider{checkout: ProviderCheckout{SessionID: "cs_1", URL: "https://checkout"}} s := newService(r, p) s.SuccessURL = "https://app.test/app/usage/checkout?result=success&checkout_id={CHECKOUT_ID}&plan={PLAN_ID}" s.CancelURL = "https://app.test/app/usage/checkout?result=cancel&plan={PLAN_ID}" a, err := s.CreateCheckout(context.Background(), 7, PlanStarter, "request with spaces") require.NoError(t, err) require.Contains(t, p.lastCheckout.SuccessURL, "checkout_id="+a.ID) require.Contains(t, p.lastCheckout.SuccessURL, "plan=starter") require.NotContains(t, p.lastCheckout.SuccessURL, "{") require.Equal(t, "https://app.test/app/usage/checkout?result=cancel&plan=starter", p.lastCheckout.CancelURL) } func TestSubscriptionRemainsReadableWhenStripeDisabled(t *testing.T) { r := newFakeRepo() r.state = SubscriptionState{UID: 7, PlanID: PlanFree} s := newService(r, &fakeProvider{}) s.Enabled = false state, err := s.Subscription(context.Background(), 7) require.NoError(t, err) require.Equal(t, PlanFree, state.PlanID) } func TestWebhookDuplicateOutOfOrderDowngradeAndAdminOverride(t *testing.T) { r := newFakeRepo() r.state = SubscriptionState{UID: 7, PlanID: PlanFree, CustomerID: "cus_1"} p := &fakeProvider{} s := newService(r, p) p.event = &Event{ID: "evt_new", Type: "customer.subscription.updated", Created: 20, ObjectID: "sub_1", CustomerID: "cus_1", PriceID: "price_pro", Status: "active"} require.NoError(t, s.HandleWebhook(context.Background(), []byte("new"), "sig")) require.Equal(t, PlanPro, r.state.PlanID) require.NoError(t, s.HandleWebhook(context.Background(), []byte("duplicate"), "sig")) require.Len(t, r.purchases, 1) p.event = &Event{ID: "evt_old", Type: "customer.subscription.deleted", Created: 10, ObjectID: "sub_1", CustomerID: "cus_1", Status: "canceled"} require.NoError(t, s.HandleWebhook(context.Background(), []byte("old"), "sig")) require.Equal(t, PlanPro, r.state.PlanID) p.event = &Event{ID: "evt_delete", Type: "customer.subscription.deleted", Created: 30, ObjectID: "sub_1", CustomerID: "cus_1", Status: "canceled"} require.NoError(t, s.HandleWebhook(context.Background(), []byte("delete"), "sig")) require.Equal(t, PlanFree, r.state.PlanID) r.state.AdminOverride, r.state.PlanID = true, PlanStarter p.event = &Event{ID: "evt_admin", Type: "customer.subscription.updated", Created: 40, ObjectID: "sub_1", CustomerID: "cus_1", PriceID: "price_pro", Status: "active"} require.NoError(t, s.HandleWebhook(context.Background(), []byte("admin"), "sig")) require.Equal(t, PlanStarter, r.state.PlanID) require.Equal(t, "active", r.state.Status) } func TestPaidCheckoutActivationUsesRetrievedPrice(t *testing.T) { r := newFakeRepo() a := &CheckoutAttempt{ID: "attempt_1", UID: 9, RequestID: "r1", PlanID: PlanStarter, PriceID: "price_starter", SessionID: "cs_1", FulfillmentStatus: "pending"} r.attempts[a.ID] = a p := &fakeProvider{checkout: ProviderCheckout{SessionID: "cs_1", PaymentStatus: "paid", Status: "complete", PriceID: "price_pro", CustomerID: "cus_9", SubscriptionID: "sub_9"}, subscription: ProviderSubscription{ID: "sub_9", CustomerID: "cus_9", Status: "active", PriceID: "price_pro", CurrentPeriodStart: 10, CurrentPeriodEnd: 20}} s := newService(r, p) p.event = &Event{ID: "evt_checkout", Type: "checkout.session.completed", Created: 30, ObjectID: "cs_1"} require.NoError(t, s.HandleWebhook(context.Background(), []byte("paid"), "sig")) require.Equal(t, PlanPro, r.state.PlanID) require.Equal(t, "fulfilled", r.attempts[a.ID].FulfillmentStatus) } func TestPaidCheckoutIsNotFulfilledWhenEntitlementWriteFails(t *testing.T) { r := newFakeRepo() r.entitlementErr = errors.New("entitlement unavailable") a := &CheckoutAttempt{ID: "attempt_1", UID: 9, SessionID: "cs_1", FulfillmentStatus: "pending"} r.attempts[a.ID] = a p := &fakeProvider{checkout: ProviderCheckout{SessionID: "cs_1", PaymentStatus: "paid", Status: "complete", PriceID: "price_pro", CustomerID: "cus_9", SubscriptionID: "sub_9"}, subscription: ProviderSubscription{ID: "sub_9", CustomerID: "cus_9", Status: "active", PriceID: "price_pro"}} s := newService(r, p) p.event = &Event{ID: "evt_checkout", Type: "checkout.session.completed", Created: 30, ObjectID: "cs_1"} err := s.HandleWebhook(context.Background(), []byte("paid"), "sig") require.ErrorContains(t, err, "entitlement unavailable") require.Equal(t, "pending", r.attempts[a.ID].FulfillmentStatus) require.False(t, r.processed["evt_checkout"]) } func TestUnpaidCompletedCheckoutWaitsForAsyncSuccess(t *testing.T) { r := newFakeRepo() a := &CheckoutAttempt{ID: "attempt_1", UID: 9, SessionID: "cs_1", FulfillmentStatus: "pending"} r.attempts[a.ID] = a p := &fakeProvider{checkout: ProviderCheckout{SessionID: "cs_1", PaymentStatus: "unpaid", Status: "complete", PriceID: "price_pro", CustomerID: "cus_9", SubscriptionID: "sub_9"}} s := newService(r, p) p.event = &Event{ID: "evt_unpaid", Type: "checkout.session.completed", Created: 30, ObjectID: "cs_1"} require.NoError(t, s.HandleWebhook(context.Background(), []byte("unpaid"), "sig")) require.Equal(t, PlanFree, r.state.PlanID) require.Empty(t, r.purchases) } func TestCheckoutFailureAndInvoicePaid(t *testing.T) { r := newFakeRepo() a := &CheckoutAttempt{ID: "attempt_1", UID: 9, SessionID: "cs_1", FulfillmentStatus: "pending"} r.attempts[a.ID] = a r.state = SubscriptionState{UID: 9, PlanID: PlanStarter, CustomerID: "cus_9", SubscriptionID: "sub_9"} p := &fakeProvider{subscription: ProviderSubscription{ID: "sub_9", CustomerID: "cus_9", Status: "past_due", PriceID: "price_pro", CurrentPeriodStart: 10, CurrentPeriodEnd: 20}} s := newService(r, p) p.event = &Event{ID: "evt_failed", Type: "checkout.session.async_payment_failed", Created: 10, ObjectID: "cs_1"} require.NoError(t, s.HandleWebhook(context.Background(), []byte("failed"), "sig")) require.Equal(t, "failed", r.attempts[a.ID].FulfillmentStatus) // The provider's current subscription price is authoritative, not an invoice metadata/line hint. p.event = &Event{ID: "evt_invoice", Type: "invoice.paid", Created: 20, ObjectID: "in_1", SubscriptionID: "sub_9", CustomerID: "cus_9", PriceID: "price_starter"} require.NoError(t, s.HandleWebhook(context.Background(), []byte("invoice"), "sig")) require.Equal(t, PlanPro, r.state.PlanID) require.Equal(t, "active", r.state.Status) } func TestDisabled(t *testing.T) { _, err := (&Service{}).CreateCheckout(context.Background(), 1, PlanStarter, "x") require.True(t, errors.Is(err, ErrDisabled)) }