package billing import ( "context" "fmt" "net/url" "strconv" "strings" "time" "github.com/google/uuid" ) type Service struct { Repo Repository Provider Provider Enabled bool WebhookSecret string StarterPriceID string ProPriceID string SuccessURL string CancelURL string PortalReturnURL string Now func() time.Time } func (s *Service) available() error { if s == nil || !s.Enabled || s.Repo == nil || s.Provider == nil || strings.TrimSpace(s.StarterPriceID) == "" || strings.TrimSpace(s.ProPriceID) == "" || strings.TrimSpace(s.SuccessURL) == "" || strings.TrimSpace(s.CancelURL) == "" || strings.TrimSpace(s.PortalReturnURL) == "" { return ErrDisabled } return nil } func (s *Service) PriceForPlan(plan string) (string, error) { switch strings.ToLower(strings.TrimSpace(plan)) { case PlanStarter: return s.StarterPriceID, nil case PlanPro: return s.ProPriceID, nil default: return "", ErrInvalidPlan } } func (s *Service) PlanForPrice(price string) (string, error) { switch price { case s.StarterPriceID: return PlanStarter, nil case s.ProPriceID: return PlanPro, nil default: return "", ErrInvalidPlan } } func (s *Service) CreateCheckout(ctx context.Context, uid int64, planID, requestID string) (*CheckoutAttempt, error) { if err := s.available(); err != nil { return nil, err } requestID = strings.TrimSpace(requestID) if uid <= 0 || requestID == "" || len(requestID) > 128 { return nil, ErrInvalidRequest } priceID, err := s.PriceForPlan(planID) if err != nil { return nil, err } now := time.Now().UTC() if s.Now != nil { now = s.Now().UTC() } a := &CheckoutAttempt{ID: uuid.NewString(), UID: uid, RequestID: requestID, PlanID: strings.ToLower(strings.TrimSpace(planID)), PriceID: priceID, Status: "creating", FulfillmentStatus: "pending", CreatedAt: now.UnixNano(), UpdatedAt: now.UnixNano()} inserted, err := s.Repo.InsertCheckout(ctx, a) if err != nil { return nil, err } if !inserted { existing, err := s.Repo.GetCheckoutByRequest(ctx, uid, requestID) if err != nil { return nil, err } if existing.PlanID != a.PlanID { return nil, ErrConflict } if existing.SessionID != "" { return existing, nil } a = existing } idempotencyKey := fmt.Sprintf("billing-checkout-%d-%s", uid, requestID) state, err := s.Repo.GetSubscription(ctx, uid) if err != nil { return nil, err } successURL := replaceURLTokens(s.SuccessURL, a.ID, a.PlanID) cancelURL := replaceURLTokens(s.CancelURL, a.ID, a.PlanID) co, err := s.Provider.CreateCheckout(ctx, CheckoutParams{UID: uid, AttemptID: a.ID, PlanID: a.PlanID, PriceID: priceID, CustomerID: state.CustomerID, SuccessURL: successURL, CancelURL: cancelURL, IdempotencyKey: idempotencyKey}) if err != nil { a.Status, a.Error, a.UpdatedAt = "failed", err.Error(), time.Now().UTC().UnixNano() _ = s.Repo.UpdateCheckout(ctx, a) return nil, err } s.applyCheckout(a, co) if err := s.Repo.UpdateCheckout(ctx, a); err != nil { return nil, err } return a, nil } func (s *Service) GetCheckout(ctx context.Context, uid int64, id string) (*CheckoutAttempt, error) { if err := s.available(); err != nil { return nil, err } a, err := s.Repo.GetCheckout(ctx, uid, id) if err != nil { return nil, err } if a.SessionID == "" { return a, nil } co, err := s.Provider.GetCheckout(ctx, a.SessionID) if err != nil { return nil, err } s.applyCheckout(a, co) if co.PriceID != "" { plan, mapErr := s.PlanForPrice(co.PriceID) if mapErr != nil { return nil, mapErr } a.PlanID = plan } _ = s.Repo.UpdateCheckout(ctx, a) return a, nil } func (s *Service) applyCheckout(a *CheckoutAttempt, co *ProviderCheckout) { a.SessionID, a.URL, a.ExpiresAt = co.SessionID, co.URL, co.ExpiresAt a.Status, a.PaymentStatus = co.Status, co.PaymentStatus a.CustomerID, a.SubscriptionID = co.CustomerID, co.SubscriptionID if co.PriceID != "" { a.PriceID = co.PriceID } a.UpdatedAt = time.Now().UTC().UnixNano() } func (s *Service) Subscription(ctx context.Context, uid int64) (*SubscriptionState, error) { if s == nil || s.Repo == nil { return nil, ErrDisabled } return s.Repo.GetSubscription(ctx, uid) } func (s *Service) Portal(ctx context.Context, uid int64) (string, error) { if err := s.available(); err != nil { return "", err } state, err := s.Repo.GetSubscription(ctx, uid) if err != nil { return "", err } if state.CustomerID == "" { return "", ErrCustomerRequired } return s.Provider.CreatePortal(ctx, state.CustomerID, s.PortalReturnURL) } func (s *Service) HandleWebhook(ctx context.Context, payload []byte, signature string) error { if err := s.available(); err != nil { return err } if strings.TrimSpace(s.WebhookSecret) == "" { return ErrDisabled } e, err := s.Provider.VerifyEvent(payload, signature, s.WebhookSecret) if err != nil { return err } done, err := s.Repo.WebhookProcessed(ctx, e.ID) if err != nil || done { return err } if err := s.processEvent(ctx, e); err != nil { return err } return s.Repo.MarkWebhookProcessed(ctx, e.ID, e.Type, e.Created) } func (s *Service) processEvent(ctx context.Context, e *Event) error { switch e.Type { case "checkout.session.expired", "checkout.session.async_payment_failed": a, err := s.Repo.GetCheckoutBySession(ctx, e.ObjectID) if err != nil { return err } a.Status = strings.TrimPrefix(e.Type, "checkout.session.") a.FulfillmentStatus = "failed" a.UpdatedAt = time.Now().UTC().UnixNano() return s.Repo.UpdateCheckout(ctx, a) case "checkout.session.completed", "checkout.session.async_payment_succeeded": return s.fulfillCheckout(ctx, e) case "customer.subscription.updated", "customer.subscription.deleted": return s.applySubscriptionEvent(ctx, e) case "invoice.paid": return s.applyInvoiceEvent(ctx, e) default: return nil } } func (s *Service) fulfillCheckout(ctx context.Context, e *Event) error { a, err := s.Repo.GetCheckoutBySession(ctx, e.ObjectID) if err != nil { return err } co, err := s.Provider.GetCheckout(ctx, e.ObjectID) if err != nil { return err } plan, err := s.PlanForPrice(co.PriceID) if err != nil { return err } if co.PaymentStatus != "paid" && co.PaymentStatus != "no_payment_required" { s.applyCheckout(a, co) a.FulfillmentStatus = "pending" return s.Repo.UpdateCheckout(ctx, a) } sub, err := s.Provider.GetSubscription(ctx, co.SubscriptionID) if err != nil { return err } if sub.PriceID != co.PriceID { return ErrInvalidPlan } s.applyCheckout(a, co) if err := s.apply(ctx, e, a.UID, plan, sub, a.SessionID); err != nil { return err } a.PlanID, a.FulfillmentStatus = plan, "fulfilled" return s.Repo.UpdateCheckout(ctx, a) } func (s *Service) applySubscriptionEvent(ctx context.Context, e *Event) error { uid, err := s.Repo.FindUIDByCustomer(ctx, e.CustomerID) if err != nil { return err } plan := PlanFree if e.Type != "customer.subscription.deleted" { plan, err = s.PlanForPrice(e.PriceID) if err != nil { return err } } else if e.PriceID != "" { if mapped, mapErr := s.PlanForPrice(e.PriceID); mapErr == nil { plan = mapped } } else if state, stateErr := s.Repo.GetSubscription(ctx, uid); stateErr == nil && state.PaidPlanID != "" { plan = state.PaidPlanID } sub := &ProviderSubscription{ID: e.ObjectID, CustomerID: e.CustomerID, Status: e.Status, PriceID: e.PriceID, CurrentPeriodStart: e.PeriodStart, CurrentPeriodEnd: e.PeriodEnd, CancelAtPeriodEnd: e.CancelAtPeriodEnd} if e.Type == "customer.subscription.deleted" { sub.Status = "canceled" } return s.apply(ctx, e, uid, plan, sub, "") } func (s *Service) applyInvoiceEvent(ctx context.Context, e *Event) error { uid, err := s.Repo.FindUIDByCustomer(ctx, e.CustomerID) if err != nil { return err } subscriptionID := e.SubscriptionID if subscriptionID == "" { state, stateErr := s.Repo.GetSubscription(ctx, uid) if stateErr != nil { return stateErr } subscriptionID = state.SubscriptionID } sub, err := s.Provider.GetSubscription(ctx, subscriptionID) if err != nil { return err } plan, err := s.PlanForPrice(sub.PriceID) if err != nil { return err } if sub.CustomerID == "" { sub.CustomerID = e.CustomerID } sub.Status = "active" return s.apply(ctx, e, uid, plan, sub, "") } func (s *Service) apply(ctx context.Context, e *Event, uid int64, plan string, sub *ProviderSubscription, sessionID string) error { activePlan := plan if sub.Status != "active" && sub.Status != "trialing" { activePlan = PlanFree } _, err := s.Repo.ApplyEntitlement(ctx, EntitlementUpdate{EventID: e.ID, EventCreatedAt: e.Created * int64(time.Second), UID: uid, PlanID: activePlan, PaidPlanID: plan, Status: sub.Status, CustomerID: sub.CustomerID, SubscriptionID: sub.ID, CurrentPeriodStart: sub.CurrentPeriodStart * int64(time.Second), CurrentPeriodEnd: sub.CurrentPeriodEnd * int64(time.Second), CancelAtPeriodEnd: sub.CancelAtPeriodEnd}) if err != nil { return err } // Audit upsert must still run when entitlement was already applied. return s.Repo.UpsertPurchase(ctx, &PurchaseAudit{ID: e.ID, UID: uid, PlanID: plan, StripeEventID: e.ID, StripeSessionID: sessionID, StripeCustomerID: sub.CustomerID, StripeSubscriptionID: sub.ID, Status: sub.Status, CreatedAt: e.Created * int64(time.Second)}) } func ParseUID(v string) (int64, error) { return strconv.ParseInt(v, 10, 64) } func replaceURLTokens(value, checkoutID, planID string) string { value = strings.ReplaceAll(value, "{CHECKOUT_ID}", url.QueryEscape(checkoutID)) return strings.ReplaceAll(value, "{PLAN_ID}", url.QueryEscape(planID)) }