312 lines
12 KiB
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))
|
|
}
|