package billing import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/url" "strconv" "strings" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/config" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // StripeConfig holds Stripe settings (env bootstrap + optional platform_settings). // Empty SecretKey enables mock mode for local/dev; checkout still fails closed // unless ForceMock (STRIPE_MOCK / stripe.mock) is set. Live keys may live in // admin platform settings; production only forbids STRIPE_MOCK=true at boot. type StripeConfig struct { SecretKey string WebhookSecret string WebOrigin string PublicAPIURL string // Price IDs keyed as "starter:monthly", "growth:yearly", … PriceIDs map[string]string // ForceMock runs mock even when SecretKey is set (local QA). ForceMock bool HTTP *http.Client } func (c StripeConfig) MockMode() bool { if c.ForceMock { return true } return strings.TrimSpace(c.SecretKey) == "" } // AllowMockPurchase is true only when STRIPE_MOCK / ForceMock is explicit. // An empty SecretKey alone must NOT grant paid plans (misconfigured staging). func (c StripeConfig) AllowMockPurchase() bool { return c.ForceMock } func (c StripeConfig) client() *http.Client { if c.HTTP != nil { return c.HTTP } return &http.Client{Timeout: 30 * time.Second} } // StripeService creates Checkout / Portal sessions and applies webhook events. type StripeService struct { Pool *pgxpool.Pool Billing *Service Cfg StripeConfig // ResolveCfg optionally merges admin platform_settings over Cfg per request. ResolveCfg func(ctx context.Context, base StripeConfig) (StripeConfig, error) } func (s *StripeService) effectiveCfg(ctx context.Context) (StripeConfig, error) { if s == nil { return StripeConfig{}, ErrStripeNotConfigured } if s.ResolveCfg == nil { return s.Cfg, nil } return s.ResolveCfg(ctx, s.Cfg) } type stripeCfgCtxKey struct{} // bindCfg resolves platform settings into ctx so concurrent requests stay isolated. func (s *StripeService) bindCfg(ctx context.Context) (context.Context, StripeConfig, error) { if ctx == nil { ctx = context.Background() } cfg, err := s.effectiveCfg(ctx) if err != nil { return ctx, s.Cfg, err } return context.WithValue(ctx, stripeCfgCtxKey{}, cfg), cfg, nil } func (s *StripeService) cfg(ctx context.Context) StripeConfig { if v, ok := ctx.Value(stripeCfgCtxKey{}).(StripeConfig); ok { return v } return s.Cfg } var ( ErrStripeNotConfigured = errors.New("stripe not configured") ErrStripePlanUnsupported = errors.New("plan is not available for self-serve checkout") ErrStripePriceMissing = errors.New("stripe price id not configured for plan/term") ErrStripeBadSignature = errors.New("invalid stripe signature") ) // CheckoutRequest is the body for POST /api/billing/checkout. // Set Pack for a one-time AI credit top-up, or Plan (+ Term) for a subscription. type CheckoutRequest struct { Plan string `json:"plan"` // starter | plus | growth | business | scale Term string `json:"term"` // monthly | yearly Pack string `json:"pack"` // small | medium | large | xl (one-time credits) } // CheckoutResult is returned to the UI (redirect to URL). type CheckoutResult struct { URL string `json:"url"` Mock bool `json:"mock"` Applied bool `json:"applied,omitempty"` Message string `json:"message,omitempty"` } // PortalResult is returned for Customer Portal. type PortalResult struct { URL string `json:"url"` Mock bool `json:"mock"` } // StatusResult reports whether live Stripe or mock mode is active. type StatusResult struct { Configured bool `json:"configured"` Mock bool `json:"mock"` HasCustomer bool `json:"has_customer"` HasSubscription bool `json:"has_subscription"` CustomerID string `json:"customer_id,omitempty"` SubscriptionID string `json:"subscription_id,omitempty"` SubscriptionStatus string `json:"subscription_status,omitempty"` } func normalizePlanTerm(plan, term string) (string, string, error) { plan = strings.ToLower(strings.TrimSpace(plan)) term = strings.ToLower(strings.TrimSpace(term)) if term == "" { term = "monthly" } switch plan { case "starter", "plus", "growth", "business", "scale": default: return "", "", ErrStripePlanUnsupported } if term != "monthly" && term != "yearly" { return "", "", fmt.Errorf("%w: term must be monthly or yearly", ErrStripePlanUnsupported) } return plan, term, nil } func priceKey(plan, term string) string { return plan + ":" + term } // Status returns Stripe linkage for the company. func (s *StripeService) Status(ctx context.Context, companyID uuid.UUID) (StatusResult, error) { ctx, cfg, err := s.bindCfg(ctx) if err != nil { return StatusResult{}, err } out := StatusResult{ Configured: !cfg.MockMode(), Mock: cfg.MockMode(), } var customerID *string err = s.Pool.QueryRow(ctx, ` SELECT stripe_customer_id FROM companies WHERE id = $1`, companyID).Scan(&customerID) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return out, err } if customerID != nil && strings.TrimSpace(*customerID) != "" { out.HasCustomer = true out.CustomerID = strings.TrimSpace(*customerID) } var subID *string var planNotes *string _ = s.Pool.QueryRow(ctx, ` SELECT stripe_subscription_id, notes FROM company_plans WHERE company_id = $1 AND is_active = true ORDER BY created_at DESC LIMIT 1`, companyID).Scan(&subID, &planNotes) if subID != nil && strings.TrimSpace(*subID) != "" { out.HasSubscription = true out.SubscriptionID = strings.TrimSpace(*subID) } out.SubscriptionStatus = ParseStripeStatusNote(planNotes) if out.SubscriptionStatus == "" && out.HasSubscription && !cfg.MockMode() { if status, statusErr := s.fetchSubscriptionStatus(ctx, out.SubscriptionID); statusErr == nil { out.SubscriptionStatus = status _ = s.setSubscriptionStatusNote(ctx, companyID, status) } else { slog.Warn("stripe_subscription_status_fetch_failed", "company_id", companyID, "subscription_id", out.SubscriptionID, "err", statusErr) } } return out, nil } // fetchSubscriptionStatus reads the live Stripe subscription status (best-effort). func (s *StripeService) fetchSubscriptionStatus(ctx context.Context, subscriptionID string) (string, error) { subscriptionID = strings.TrimSpace(subscriptionID) if subscriptionID == "" { return "", errors.New("empty subscription id") } var sub struct { Status string `json:"status"` } endpoint := "https://api.stripe.com/v1/subscriptions/" + url.PathEscape(subscriptionID) if err := s.stripeGET(ctx, endpoint, &sub); err != nil { return "", err } return NormalizeSubscriptionStatus(sub.Status), nil } // NormalizeSubscriptionStatus lowercases and trims a Stripe subscription status. func NormalizeSubscriptionStatus(status string) string { return strings.ToLower(strings.TrimSpace(status)) } // IsPastDueSubscriptionStatus is true for past_due (grace — do not hard-lock day 1). func IsPastDueSubscriptionStatus(status string) bool { return NormalizeSubscriptionStatus(status) == "past_due" } const stripeStatusNotePrefix = "stripe_status:" // FormatStripeStatusNote stores subscription status in company_plans.notes (managed prefix only). func FormatStripeStatusNote(status string) string { return stripeStatusNotePrefix + NormalizeSubscriptionStatus(status) } // ParseStripeStatusNote reads a managed stripe_status note; other notes are ignored. func ParseStripeStatusNote(notes *string) string { if notes == nil { return "" } s := strings.TrimSpace(*notes) if !strings.HasPrefix(s, stripeStatusNotePrefix) { return "" } return NormalizeSubscriptionStatus(strings.TrimPrefix(s, stripeStatusNotePrefix)) } // checkoutReturnURL builds the billing return page with plan/pack/term context. // When withCheckoutSessionID is true, appends Stripe's unescaped {CHECKOUT_SESSION_ID} template. func checkoutReturnURL(web, status, plan, term, pack string, withCheckoutSessionID bool) string { q := url.Values{} q.Set("checkout", status) if plan != "" { q.Set("plan", plan) } if term != "" { q.Set("term", term) } if pack != "" { q.Set("pack", pack) } out := strings.TrimRight(web, "/") + "/billing?" + q.Encode() if withCheckoutSessionID { out += "&session_id={CHECKOUT_SESSION_ID}" } return out } // CreateCheckoutSession starts Stripe Checkout for a plan subscription or credit pack. func (s *StripeService) CreateCheckoutSession(ctx context.Context, companyID uuid.UUID, email, companyName string, req CheckoutRequest) (CheckoutResult, error) { if strings.TrimSpace(req.Pack) != "" { return s.CreateCreditPackCheckout(ctx, companyID, email, companyName, req.Pack) } ctx, cfg, err := s.bindCfg(ctx) if err != nil { return CheckoutResult{}, err } plan, term, err := normalizePlanTerm(req.Plan, req.Term) if err != nil { return CheckoutResult{}, err } web := strings.TrimRight(cfg.WebOrigin, "/") if web == "" { web = "http://localhost:5174" } if cfg.AllowMockPurchase() { if s.Billing == nil { return CheckoutResult{}, errors.New("billing service not configured") } if err := s.applyPlanPurchase(ctx, companyID, plan, "cus_mock_"+companyID.String()[:8], "sub_mock_"+uuid.NewString()[:8], "price_mock_"+plan+"_"+term); err != nil { return CheckoutResult{}, err } return CheckoutResult{ URL: checkoutReturnURL(web, "success", plan, term, "", false) + "&mock=1", Mock: true, Applied: true, Message: "Mock mode: plan assigned and credits granted without Stripe.", }, nil } if cfg.MockMode() { return CheckoutResult{}, ErrStripeNotConfigured } priceID := strings.TrimSpace(cfg.PriceIDs[priceKey(plan, term)]) if priceID == "" { return CheckoutResult{}, fmt.Errorf("%w: %s %s", ErrStripePriceMissing, plan, term) } customerID, err := s.ensureCustomer(ctx, companyID, email, companyName) if err != nil { return CheckoutResult{}, err } form := url.Values{} form.Set("mode", "subscription") form.Set("success_url", checkoutReturnURL(web, "success", plan, term, "", true)) form.Set("cancel_url", checkoutReturnURL(web, "cancel", plan, term, "", false)) form.Set("client_reference_id", companyID.String()) form.Set("metadata[company_id]", companyID.String()) form.Set("metadata[kind]", "plan") form.Set("metadata[plan]", plan) form.Set("metadata[term]", term) form.Set("subscription_data[metadata][company_id]", companyID.String()) form.Set("subscription_data[metadata][plan]", plan) form.Set("subscription_data[metadata][term]", term) form.Set("line_items[0][price]", priceID) form.Set("line_items[0][quantity]", "1") form.Set("allow_promotion_codes", "true") if customerID != "" { form.Set("customer", customerID) } else if email != "" { form.Set("customer_email", email) } var sess struct { ID string `json:"id"` URL string `json:"url"` } if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/checkout/sessions", form, &sess); err != nil { return CheckoutResult{}, err } if sess.URL == "" { return CheckoutResult{}, errors.New("stripe checkout session missing url") } return CheckoutResult{URL: sess.URL, Mock: false}, nil } // CreateCreditPackCheckout starts a one-time Stripe Checkout (or mock-grants credits). func (s *StripeService) CreateCreditPackCheckout(ctx context.Context, companyID uuid.UUID, email, companyName, packID string) (CheckoutResult, error) { pack, ok := CreditPackByID(packID) if !ok { return CheckoutResult{}, fmt.Errorf("%w: unknown credit pack", ErrStripePlanUnsupported) } ctx, cfg, err := s.bindCfg(ctx) if err != nil { return CheckoutResult{}, err } web := strings.TrimRight(cfg.WebOrigin, "/") if web == "" { web = "http://localhost:5174" } if cfg.AllowMockPurchase() { if s.Billing == nil { return CheckoutResult{}, errors.New("billing service not configured") } if err := s.Billing.AddCredits(ctx, companyID, pack.Credits); err != nil { return CheckoutResult{}, err } return CheckoutResult{ URL: checkoutReturnURL(web, "success", "", "", pack.ID, false) + "&mock=1&credits=" + strconv.Itoa(pack.Credits), Mock: true, Applied: true, Message: fmt.Sprintf("Mock mode: added %d AI credits without Stripe.", pack.Credits), }, nil } if cfg.MockMode() { return CheckoutResult{}, ErrStripeNotConfigured } priceID := strings.TrimSpace(cfg.PriceIDs[CreditPackPriceKey(pack.ID)]) if priceID == "" { return CheckoutResult{}, fmt.Errorf("%w: credit pack %s", ErrStripePriceMissing, pack.ID) } customerID, err := s.ensureCustomer(ctx, companyID, email, companyName) if err != nil { return CheckoutResult{}, err } form := url.Values{} form.Set("mode", "payment") form.Set("success_url", checkoutReturnURL(web, "success", "", "", pack.ID, true)) form.Set("cancel_url", checkoutReturnURL(web, "cancel", "", "", pack.ID, false)) form.Set("client_reference_id", companyID.String()) form.Set("metadata[company_id]", companyID.String()) form.Set("metadata[kind]", "credit_pack") form.Set("metadata[pack]", pack.ID) form.Set("metadata[credits]", strconv.Itoa(pack.Credits)) form.Set("line_items[0][price]", priceID) form.Set("line_items[0][quantity]", "1") form.Set("allow_promotion_codes", "true") if customerID != "" { form.Set("customer", customerID) } else if email != "" { form.Set("customer_email", email) } var sess struct { ID string `json:"id"` URL string `json:"url"` } if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/checkout/sessions", form, &sess); err != nil { return CheckoutResult{}, err } if sess.URL == "" { return CheckoutResult{}, errors.New("stripe checkout session missing url") } return CheckoutResult{URL: sess.URL, Mock: false}, nil } // CreatePortalSession opens the Stripe Customer Portal (or a mock billing deep-link). func (s *StripeService) CreatePortalSession(ctx context.Context, companyID uuid.UUID) (PortalResult, error) { ctx, cfg, err := s.bindCfg(ctx) if err != nil { return PortalResult{}, err } web := strings.TrimRight(cfg.WebOrigin, "/") if web == "" { web = "http://localhost:5174" } if cfg.AllowMockPurchase() || cfg.MockMode() { // Portal deep-link only; does not mutate billing. return PortalResult{URL: web + "/billing?portal=mock", Mock: true}, nil } var customerID *string err = s.Pool.QueryRow(ctx, ` SELECT stripe_customer_id FROM companies WHERE id = $1`, companyID).Scan(&customerID) if errors.Is(err, pgx.ErrNoRows) || customerID == nil || strings.TrimSpace(*customerID) == "" { return PortalResult{}, ErrStripeNoCustomer } if err != nil { return PortalResult{}, err } form := url.Values{} form.Set("customer", strings.TrimSpace(*customerID)) form.Set("return_url", web+"/billing") var sess struct { URL string `json:"url"` } if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/billing_portal/sessions", form, &sess); err != nil { return PortalResult{}, err } if sess.URL == "" { return PortalResult{}, errors.New("stripe portal session missing url") } return PortalResult{URL: sess.URL, Mock: false}, nil } func (s *StripeService) ensureCustomer(ctx context.Context, companyID uuid.UUID, email, name string) (string, error) { var existing *string err := s.Pool.QueryRow(ctx, ` SELECT stripe_customer_id FROM companies WHERE id = $1`, companyID).Scan(&existing) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return "", err } if existing != nil && strings.TrimSpace(*existing) != "" { return strings.TrimSpace(*existing), nil } form := url.Values{} form.Set("metadata[company_id]", companyID.String()) if email != "" { form.Set("email", email) } if name != "" { form.Set("name", name) } var cust struct { ID string `json:"id"` } if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/customers", form, &cust); err != nil { return "", err } if cust.ID == "" { return "", errors.New("stripe customer create returned empty id") } _, err = s.Pool.Exec(ctx, ` UPDATE companies SET stripe_customer_id = $2, updated_at = now() WHERE id = $1`, companyID, cust.ID) return cust.ID, err } func (s *StripeService) stripeForm(ctx context.Context, method, endpoint string, form url.Values, dest any) error { cfg := s.cfg(ctx) req, err := http.NewRequestWithContext(ctx, method, endpoint, strings.NewReader(form.Encode())) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(cfg.SecretKey)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return s.doStripeJSON(req, dest) } func (s *StripeService) stripeGET(ctx context.Context, endpoint string, dest any) error { cfg := s.cfg(ctx) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(cfg.SecretKey)) return s.doStripeJSON(req, dest) } func (s *StripeService) doStripeJSON(req *http.Request, dest any) error { resp, err := s.cfg(req.Context()).client().Do(req) if err != nil { return err } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return err } if resp.StatusCode >= 300 { return fmt.Errorf("stripe api %s: %s", resp.Status, truncate(string(body), 400)) } if dest == nil { return nil } return json.Unmarshal(body, dest) } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" } // HandleWebhook verifies Stripe-Signature when WebhookSecret is set; unsigned only if ForceMock. func (s *StripeService) HandleWebhook(ctx context.Context, payload []byte, sigHeader string) error { ctx, cfg, err := s.bindCfg(ctx) if err != nil { return err } secret := strings.TrimSpace(cfg.WebhookSecret) // Always verify when a webhook secret is configured — even if STRIPE_MOCK=true. // Production never accepts unsigned events (ForceMock is also rejected at boot). if secret != "" { if err := verifyStripeSignature(payload, sigHeader, secret, 5*time.Minute); err != nil { return err } } else if !cfg.ForceMock || config.IsProductionEnv() { // Unsigned events only for explicit local mock (STRIPE_MOCK=true, no webhook secret). return ErrStripeNotConfigured } var event stripeEvent if err := json.Unmarshal(payload, &event); err != nil { return err } if event.ID == "" { return errors.New("stripe event missing id") } claimed, err := s.claimWebhookEvent(ctx, event.ID, event.Type, nil) if err != nil { return err } if !claimed { return nil // idempotent no-op } companyID, applyErr := s.dispatchEvent(ctx, event) if applyErr != nil { // Release claim so Stripe can retry after a transient apply failure. _, _ = s.Pool.Exec(ctx, `DELETE FROM stripe_webhook_events WHERE event_id = $1`, event.ID) return applyErr } if companyID != nil { _, _ = s.Pool.Exec(ctx, ` UPDATE stripe_webhook_events SET company_id = $2 WHERE event_id = $1`, event.ID, *companyID) } return nil } func (s *StripeService) claimWebhookEvent(ctx context.Context, eventID, eventType string, companyID *uuid.UUID) (bool, error) { if s.Pool == nil { return false, errors.New("stripe store not configured") } ct, err := s.Pool.Exec(ctx, ` INSERT INTO stripe_webhook_events (event_id, event_type, company_id) VALUES ($1, $2, $3) ON CONFLICT (event_id) DO NOTHING`, eventID, eventType, companyID) if err != nil { return false, err } return ct.RowsAffected() > 0, nil } type stripeEvent struct { ID string `json:"id"` Type string `json:"type"` Data json.RawMessage `json:"data"` } type stripeEventData struct { Object json.RawMessage `json:"object"` } func (s *StripeService) dispatchEvent(ctx context.Context, event stripeEvent) (*uuid.UUID, error) { var data stripeEventData if len(event.Data) > 0 { _ = json.Unmarshal(event.Data, &data) } switch event.Type { case "checkout.session.completed": return s.onCheckoutCompleted(ctx, data.Object) case "customer.subscription.updated", "customer.subscription.created": return s.onSubscriptionUpsert(ctx, data.Object) case "customer.subscription.deleted": return s.onSubscriptionDeleted(ctx, data.Object) default: return nil, nil } } type checkoutSessionObj struct { ID string `json:"id"` Customer string `json:"customer"` Subscription string `json:"subscription"` ClientReferenceID string `json:"client_reference_id"` Metadata map[string]string `json:"metadata"` } func (s *StripeService) onCheckoutCompleted(ctx context.Context, raw json.RawMessage) (*uuid.UUID, error) { var sess checkoutSessionObj if err := json.Unmarshal(raw, &sess); err != nil { return nil, err } companyID, err := parseCompanyID(sess.ClientReferenceID, sess.Metadata) if err != nil { return nil, err } kind := strings.ToLower(strings.TrimSpace(sess.Metadata["kind"])) if kind == "credit_pack" || strings.TrimSpace(sess.Metadata["pack"]) != "" { if err := s.applyCreditPackPurchase(ctx, companyID, sess.Metadata); err != nil { return &companyID, err } return &companyID, nil } if kind == "sales_quote" { planID, _ := strconv.ParseInt(strings.TrimSpace(sess.Metadata["plan_id"]), 10, 64) quoteID, _ := uuid.Parse(strings.TrimSpace(sess.Metadata["quote_id"])) priceID := "" if err := s.applySalesQuotePurchase(ctx, companyID, planID, quoteID, sess.Customer, sess.Subscription, priceID); err != nil { return &companyID, err } return &companyID, nil } plan := strings.ToLower(strings.TrimSpace(sess.Metadata["plan"])) term := strings.ToLower(strings.TrimSpace(sess.Metadata["term"])) if plan == "" { plan = "starter" } if term == "" { term = "monthly" } priceID := strings.TrimSpace(s.cfg(ctx).PriceIDs[priceKey(plan, term)]) if err := s.applyPlanPurchase(ctx, companyID, plan, sess.Customer, sess.Subscription, priceID); err != nil { return &companyID, err } return &companyID, nil } // applyCreditPackPurchase grants one-time AI credits from Checkout metadata (mode=payment). func (s *StripeService) applyCreditPackPurchase(ctx context.Context, companyID uuid.UUID, meta map[string]string) error { if s.Billing == nil { return errors.New("billing service not configured") } credits, err := creditsFromPackMetadata(meta) if err != nil { return err } return s.Billing.AddCredits(ctx, companyID, credits) } // creditsFromPackMetadata resolves one-time credit grants from Checkout metadata. // Known catalog packs always win over metadata credits (anti-tamper), including // garbage/non-numeric credits fields when pack id is valid. func creditsFromPackMetadata(meta map[string]string) (int, error) { packID := "" if meta != nil { packID = strings.ToLower(strings.TrimSpace(meta["pack"])) } if pack, ok := CreditPackByID(packID); ok { return pack.Credits, nil } credits := 0 if meta != nil { if raw := strings.TrimSpace(meta["credits"]); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n <= 0 { return 0, fmt.Errorf("invalid credit pack credits metadata: %q", raw) } credits = n } } if credits <= 0 { return 0, fmt.Errorf("%w: credit pack %q", ErrStripePlanUnsupported, packID) } return credits, nil } type subscriptionObj struct { ID string `json:"id"` Customer string `json:"customer"` Status string `json:"status"` Metadata map[string]string `json:"metadata"` Items struct { Data []struct { Price struct { ID string `json:"id"` } `json:"price"` } `json:"data"` } `json:"items"` } func (s *StripeService) onSubscriptionUpsert(ctx context.Context, raw json.RawMessage) (*uuid.UUID, error) { var sub subscriptionObj if err := json.Unmarshal(raw, &sub); err != nil { return nil, err } companyID, err := s.resolveCompanyForSubscription(ctx, sub) if err != nil { return nil, err } status := strings.ToLower(sub.Status) kind := strings.ToLower(strings.TrimSpace(sub.Metadata["kind"])) if status == "canceled" || status == "unpaid" || status == "incomplete_expired" { // Sales-quote installments end via cancel_at after the paid term — keep the custom plan. if kind == "sales_quote" { _ = s.setSubscriptionStatusNote(ctx, companyID, status) return &companyID, nil } if err := s.downgradeToFree(ctx, companyID); err != nil { return &companyID, err } return &companyID, nil } if kind == "sales_quote" { planID, _ := strconv.ParseInt(strings.TrimSpace(sub.Metadata["plan_id"]), 10, 64) quoteID, _ := uuid.Parse(strings.TrimSpace(sub.Metadata["quote_id"])) priceID := "" if len(sub.Items.Data) > 0 { priceID = sub.Items.Data[0].Price.ID } if planID > 0 { if err := s.applySalesQuotePurchase(ctx, companyID, planID, quoteID, sub.Customer, sub.ID, priceID); err != nil { return &companyID, err } } _ = s.setSubscriptionStatusNote(ctx, companyID, status) return &companyID, nil } plan := strings.ToLower(strings.TrimSpace(sub.Metadata["plan"])) priceID := "" if len(sub.Items.Data) > 0 { priceID = sub.Items.Data[0].Price.ID } if plan == "" { plan = s.planFromPriceIDCtx(ctx, priceID) } if plan == "" { _ = s.setSubscriptionStatusNote(ctx, companyID, status) return &companyID, nil } if err := s.applyPlanPurchase(ctx, companyID, plan, sub.Customer, sub.ID, priceID); err != nil { return &companyID, err } _ = s.setSubscriptionStatusNote(ctx, companyID, status) return &companyID, nil } func (s *StripeService) onSubscriptionDeleted(ctx context.Context, raw json.RawMessage) (*uuid.UUID, error) { var sub subscriptionObj if err := json.Unmarshal(raw, &sub); err != nil { return nil, err } companyID, err := s.resolveCompanyForSubscription(ctx, sub) if err != nil { return nil, err } // Installment schedule complete: retain assigned custom plan (paid term). if strings.EqualFold(strings.TrimSpace(sub.Metadata["kind"]), "sales_quote") { _ = s.setSubscriptionStatusNote(ctx, companyID, "canceled") return &companyID, nil } if err := s.downgradeToFree(ctx, companyID); err != nil { return &companyID, err } return &companyID, nil } func (s *StripeService) resolveCompanyForSubscription(ctx context.Context, sub subscriptionObj) (uuid.UUID, error) { if id, err := parseCompanyID("", sub.Metadata); err == nil { return id, nil } if sub.Customer != "" { var id uuid.UUID err := s.Pool.QueryRow(ctx, ` SELECT id FROM companies WHERE stripe_customer_id = $1`, sub.Customer).Scan(&id) if err == nil { return id, nil } } if sub.ID != "" { var id uuid.UUID err := s.Pool.QueryRow(ctx, ` SELECT company_id FROM company_plans WHERE stripe_subscription_id = $1 ORDER BY created_at DESC LIMIT 1`, sub.ID).Scan(&id) if err == nil { return id, nil } } return uuid.Nil, errors.New("could not resolve company for stripe subscription") } func parseCompanyID(clientRef string, meta map[string]string) (uuid.UUID, error) { if clientRef != "" { if id, err := uuid.Parse(clientRef); err == nil { return id, nil } } if meta != nil { if v := strings.TrimSpace(meta["company_id"]); v != "" { return uuid.Parse(v) } } return uuid.Nil, errors.New("company_id missing from stripe session") } func planNameFromPriceKey(key string) string { key = strings.ToLower(strings.TrimSpace(key)) if key == "" || strings.HasPrefix(key, "pack:") { return "" } parts := strings.SplitN(key, ":", 2) if len(parts) == 0 { return "" } switch parts[0] { case "starter", "plus", "growth", "business", "scale": return parts[0] default: return "" } } func (s *StripeService) planFromPriceID(priceID string) string { priceID = strings.TrimSpace(priceID) if priceID == "" { return "" } for key, id := range s.Cfg.PriceIDs { if id == priceID { return planNameFromPriceKey(key) } } return "" } // planFromPriceIDCtx prefers request-bound PriceIDs from platform settings. func (s *StripeService) planFromPriceIDCtx(ctx context.Context, priceID string) string { priceID = strings.TrimSpace(priceID) if priceID == "" { return "" } for key, id := range s.cfg(ctx).PriceIDs { if id == priceID { return planNameFromPriceKey(key) } } return s.planFromPriceID(priceID) } func (s *StripeService) applyPlanPurchase(ctx context.Context, companyID uuid.UUID, planName, customerID, subscriptionID, priceID string) error { if s.Billing == nil || s.Pool == nil { return errors.New("billing service not configured") } planName = strings.ToLower(strings.TrimSpace(planName)) if !IsPublicProductPlan(planName) || IsFreePlanName(planName) || strings.EqualFold(planName, "enterprise") { return ErrStripePlanUnsupported } _ = s.Billing.EnsureDefaultPlans(ctx) var planID int64 err := s.Pool.QueryRow(ctx, ` SELECT id FROM plans WHERE lower(name) = lower($1) ORDER BY id LIMIT 1`, planName).Scan(&planID) if errors.Is(err, pgx.ErrNoRows) { return errors.New("plan not found: " + planName) } if err != nil { return err } if err := s.Billing.AssignPlan(ctx, companyID, planID, false, 0); err != nil { return err } if customerID != "" { _, _ = s.Pool.Exec(ctx, ` UPDATE companies SET stripe_customer_id = $2, updated_at = now() WHERE id = $1`, companyID, customerID) } _, _ = s.Pool.Exec(ctx, ` UPDATE company_plans SET stripe_subscription_id = NULLIF($2, ''), stripe_price_id = NULLIF($3, ''), updated_at = now() WHERE company_id = $1 AND is_active = true`, companyID, subscriptionID, priceID) _ = s.setSubscriptionStatusNote(ctx, companyID, "active") return nil } func (s *StripeService) setSubscriptionStatusNote(ctx context.Context, companyID uuid.UUID, status string) error { status = NormalizeSubscriptionStatus(status) if status == "" { return nil } note := FormatStripeStatusNote(status) _, err := s.Pool.Exec(ctx, ` UPDATE company_plans SET notes = $2, updated_at = now() WHERE company_id = $1 AND is_active = true AND (notes IS NULL OR btrim(notes) = '' OR notes LIKE 'stripe_status:%')`, companyID, note) return err } func (s *StripeService) clearSubscriptionStatusNote(ctx context.Context, companyID uuid.UUID) error { _, err := s.Pool.Exec(ctx, ` UPDATE company_plans SET notes = NULL, updated_at = now() WHERE company_id = $1 AND is_active = true AND notes LIKE 'stripe_status:%'`, companyID) return err } func (s *StripeService) downgradeToFree(ctx context.Context, companyID uuid.UUID) error { if s.Billing == nil || s.Pool == nil { return errors.New("billing service not configured") } _ = s.Billing.EnsureDefaultPlans(ctx) var planID int64 err := s.Pool.QueryRow(ctx, ` SELECT id FROM plans WHERE lower(name) = 'free' ORDER BY id LIMIT 1`).Scan(&planID) if err != nil { return err } if err := s.Billing.AssignPlan(ctx, companyID, planID, false, 0); err != nil { return err } _, _ = s.Pool.Exec(ctx, ` UPDATE company_plans SET stripe_subscription_id = NULL, stripe_price_id = NULL, updated_at = now() WHERE company_id = $1 AND is_active = true`, companyID) _ = s.clearSubscriptionStatusNote(ctx, companyID) return nil } // verifyStripeSignature implements Stripe's signed payload check (t=,v1=). func verifyStripeSignature(payload []byte, header, secret string, tolerance time.Duration) error { header = strings.TrimSpace(header) if header == "" { return ErrStripeBadSignature } var timestamp int64 var signatures []string for _, part := range strings.Split(header, ",") { kv := strings.SplitN(strings.TrimSpace(part), "=", 2) if len(kv) != 2 { continue } switch kv[0] { case "t": ts, err := strconv.ParseInt(kv[1], 10, 64) if err != nil { return ErrStripeBadSignature } timestamp = ts case "v1": signatures = append(signatures, kv[1]) } } if timestamp == 0 || len(signatures) == 0 { return ErrStripeBadSignature } if tolerance > 0 { age := time.Since(time.Unix(timestamp, 0)) if age > tolerance || age < -tolerance { return fmt.Errorf("%w: timestamp outside tolerance", ErrStripeBadSignature) } } mac := hmac.New(sha256.New, []byte(secret)) _, _ = fmt.Fprintf(mac, "%d.", timestamp) _, _ = mac.Write(payload) expected := hex.EncodeToString(mac.Sum(nil)) for _, sig := range signatures { if hmac.Equal([]byte(expected), []byte(sig)) { return nil } } return ErrStripeBadSignature } // LoadStripePriceIDs reads STRIPE_PRICE_* env into the price map. func LoadStripePriceIDs(getenv func(string) string) map[string]string { out := map[string]string{} pairs := []struct { key string env string }{ {"starter:monthly", "STRIPE_PRICE_STARTER_MONTHLY"}, {"starter:yearly", "STRIPE_PRICE_STARTER_YEARLY"}, {"plus:monthly", "STRIPE_PRICE_PLUS_MONTHLY"}, {"plus:yearly", "STRIPE_PRICE_PLUS_YEARLY"}, {"growth:monthly", "STRIPE_PRICE_GROWTH_MONTHLY"}, {"growth:yearly", "STRIPE_PRICE_GROWTH_YEARLY"}, {"business:monthly", "STRIPE_PRICE_BUSINESS_MONTHLY"}, {"business:yearly", "STRIPE_PRICE_BUSINESS_YEARLY"}, {"scale:monthly", "STRIPE_PRICE_SCALE_MONTHLY"}, {"scale:yearly", "STRIPE_PRICE_SCALE_YEARLY"}, } for _, p := range pairs { if v := strings.TrimSpace(getenv(p.env)); v != "" { out[p.key] = v } } for _, pack := range DefaultCreditPacks() { if v := strings.TrimSpace(getenv(CreditPackEnvVar(pack.ID))); v != "" { out[CreditPackPriceKey(pack.ID)] = v } } return out }