package billing import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/stripe/stripe-go/v85" billingportalsession "github.com/stripe/stripe-go/v85/billingportal/session" checkoutsession "github.com/stripe/stripe-go/v85/checkout/session" stripecustomer "github.com/stripe/stripe-go/v85/customer" stripeprice "github.com/stripe/stripe-go/v85/price" stripesub "github.com/stripe/stripe-go/v85/subscription" ) //go:generate go tool mockgen -source ./service.go -destination ./mocks/service.go type Service interface { GetStatus(ctx context.Context, networkID string) (*Status, error) CreateCheckoutSession(ctx context.Context, p CheckoutParams) (url string, err error) CreatePortalSession(ctx context.Context, networkID string) (url string, err error) // SyncSeats updates the Stripe subscription quantity with proration. // No-op if the network has no active subscription. SyncSeats(ctx context.Context, networkID string, seats int) error HandleWebhook(ctx context.Context, payload []byte, signature string) error // GetUsage reports today's freemium quota state for a network. // Pro networks get Limit=nil (unlimited); free networks get Limit=&FreemiumDailyLimit. GetUsage(ctx context.Context, networkID string) (*Usage, error) IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error } var ( ErrNoActiveSubscription = errors.New("network has no stripe customer yet") ErrInvalidCadence = errors.New("invalid billing cadence") ) type serviceImpl struct { cfg Config repo repository usageRepo usageRepository priceMonthlyCents int64 priceAnnualCents int64 } func NewService(ctx context.Context, pool *pgxpool.Pool, cfg Config) (Service, error) { if err := cfg.Validate(); err != nil { return nil, err } stripe.Key = cfg.SecretKey monthly, err := stripeprice.Get(cfg.PriceMonthlyID, &stripe.PriceParams{ Params: stripe.Params{Context: ctx}, }) if err != nil { return nil, fmt.Errorf("fetch stripe monthly price: %w", err) } annual, err := stripeprice.Get(cfg.PriceAnnualID, &stripe.PriceParams{ Params: stripe.Params{Context: ctx}, }) if err != nil { return nil, fmt.Errorf("fetch stripe annual price: %w", err) } return &serviceImpl{ cfg: cfg, repo: newRepository(pool), usageRepo: newUsageRepository(pool), priceMonthlyCents: monthly.UnitAmount, priceAnnualCents: annual.UnitAmount, }, nil } // NewServiceForWorker skips Stripe client setup since workers only exercise // the usage-tracking path. No API key required. func NewServiceForWorker(pool *pgxpool.Pool) Service { return &serviceImpl{ usageRepo: newUsageRepository(pool), repo: newRepository(pool), } } func (s *serviceImpl) GetStatus(ctx context.Context, networkID string) (*Status, error) { sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID) if err != nil && !errors.Is(err, errNotFound) { return nil, err } status := &Status{ Plan: PlanFree, PlanStatus: "active", PriceMonthlyCents: s.priceMonthlyCents, PriceAnnualCents: s.priceAnnualCents, } if sub != nil { cadence := sub.Cadence periodEnd := sub.CurrentPeriodEnd // past_due keeps access: Stripe still considers the subscription live // during the dunning window. switch stripe.SubscriptionStatus(sub.Status) { case stripe.SubscriptionStatusActive, stripe.SubscriptionStatusTrialing, stripe.SubscriptionStatusPastDue: status.Plan = PlanPro } status.PlanStatus = sub.Status status.Cadence = &cadence status.Seats = sub.Quantity status.CurrentPeriodEnd = &periodEnd status.CancelAtPeriodEnd = sub.CancelAtPeriodEnd } return status, nil } func (s *serviceImpl) CreateCheckoutSession(ctx context.Context, p CheckoutParams) (string, error) { if !p.Cadence.IsValid() { return "", ErrInvalidCadence } if p.Seats < 1 { return "", fmt.Errorf("seats must be >= 1") } customerID, err := s.ensureStripeCustomer(ctx, p) if err != nil { return "", fmt.Errorf("ensure stripe customer: %w", err) } priceID := s.cfg.PriceAnnualID switch p.Cadence { case CadenceAnnual: priceID = s.cfg.PriceAnnualID break case CadenceMonthly: priceID = s.cfg.PriceMonthlyID break } params := &stripe.CheckoutSessionParams{ Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), Customer: stripe.String(customerID), ClientReferenceID: stripe.String(p.NetworkID), SuccessURL: stripe.String(s.cfg.SuccessURL), CancelURL: stripe.String(s.cfg.CancelURL), LineItems: []*stripe.CheckoutSessionLineItemParams{{ Price: stripe.String(priceID), Quantity: stripe.Int64(int64(p.Seats)), }}, SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{ Metadata: map[string]string{ "network_id": p.NetworkID, "admin_human_id": p.AdminHumanID, "cadence": string(p.Cadence), }, }, } params.Context = ctx sess, err := checkoutsession.New(params) if err != nil { return "", fmt.Errorf("stripe checkout: %w", err) } return sess.URL, nil } func (s *serviceImpl) CreatePortalSession(ctx context.Context, networkID string) (string, error) { sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID) if errors.Is(err, errNotFound) { return "", ErrNoActiveSubscription } if err != nil { return "", err } params := &stripe.BillingPortalSessionParams{ Customer: stripe.String(sub.StripeCustomerID), ReturnURL: stripe.String(s.cfg.SuccessURL), } params.Context = ctx sess, err := billingportalsession.New(params) if err != nil { return "", fmt.Errorf("stripe portal: %w", err) } return sess.URL, nil } func (s *serviceImpl) SyncSeats(ctx context.Context, networkID string, seats int) error { sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID) if errors.Is(err, errNotFound) { return nil } if err != nil { return err } if sub.Quantity == seats { return nil } liveSub, err := stripesub.Get(sub.ID, &stripe.SubscriptionParams{ Params: stripe.Params{Context: ctx}, }) if err != nil { return fmt.Errorf("fetch stripe subscription: %w", err) } if len(liveSub.Items.Data) == 0 { return fmt.Errorf("stripe subscription %s has no items", sub.ID) } params := &stripe.SubscriptionParams{ ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)), Items: []*stripe.SubscriptionItemsParams{{ ID: stripe.String(liveSub.Items.Data[0].ID), Quantity: stripe.Int64(int64(seats)), }}, } params.Context = ctx if _, err := stripesub.Update(sub.ID, params); err != nil { return fmt.Errorf("update stripe subscription quantity: %w", err) } // customer.subscription.updated webhook arrives within seconds and // reconciles quantity in our DB. return nil } func (s *serviceImpl) IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error { return s.usageRepo.incrementDaily(ctx, networkID, at) } func (s *serviceImpl) GetUsage(ctx context.Context, networkID string) (*Usage, error) { now := time.Now() used, err := s.usageRepo.getDaily(ctx, networkID, now) if err != nil { return nil, fmt.Errorf("read daily usage: %w", err) } sub, err := s.GetStatus(ctx, networkID) if err != nil { return nil, fmt.Errorf("unable to get network billing status: %w", err) } u := &Usage{ Plan: sub.Plan, Used: used, ResetAt: nextUTCMidnight(now), Limit: nil, } if sub.Plan == PlanFree { limit := FreemiumDailyLimit u.Limit = &limit } return u, nil } func nextUTCMidnight(now time.Time) time.Time { utc := now.UTC() return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC).Add(24 * time.Hour) } func (s *serviceImpl) ensureStripeCustomer(ctx context.Context, p CheckoutParams) (string, error) { existing, err := s.repo.getStripeCustomerID(ctx, p.NetworkID) if err == nil { return existing, nil } if !errors.Is(err, errNotFound) { return "", err } params := &stripe.CustomerParams{ Email: stripe.String(p.AdminEmail), Metadata: map[string]string{ "network_id": p.NetworkID, "admin_human_id": p.AdminHumanID, }, } params.Context = ctx cust, err := stripecustomer.New(params) if err != nil { return "", fmt.Errorf("create stripe customer: %w", err) } if err := s.repo.setStripeCustomerID(ctx, p.NetworkID, cust.ID); err != nil { return "", fmt.Errorf("persist stripe customer id: %w", err) } return cust.ID, nil }