Files
descrybe/apps/api/internal/billing/stripe.go
T

1047 lines
33 KiB
Go
Raw Normal View History

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
}