thread-master/apps/backend/internal/module/billing/service_test.go

312 lines
12 KiB
Go

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))
}