2026-08-09 22:47:43 +02:00
|
|
|
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 (
|
2026-08-16 11:37:42 +02:00
|
|
|
ErrStripeNotConfigured = errors.New("stripe not configured")
|
|
|
|
|
ErrStripeSelfServeUnavailable = errors.New("self-serve checkout is unavailable until Stripe is 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")
|
2026-08-09 22:47:43 +02:00
|
|
|
)
|
|
|
|
|
|
2026-08-16 11:37:42 +02:00
|
|
|
// EnsureSelfServeCheckout allows live Stripe for any caller, and mock checkout
|
|
|
|
|
// only for platform admins (so new tenant users cannot buy while STRIPE_MOCK is on).
|
|
|
|
|
func (s *StripeService) EnsureSelfServeCheckout(ctx context.Context, platformAdmin bool) error {
|
|
|
|
|
_, cfg, err := s.bindCfg(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if !cfg.MockMode() {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if cfg.AllowMockPurchase() && platformAdmin {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if cfg.AllowMockPurchase() {
|
|
|
|
|
return ErrStripeSelfServeUnavailable
|
|
|
|
|
}
|
|
|
|
|
return ErrStripeNotConfigured
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 22:47:43 +02:00
|
|
|
// 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
|
|
|
|
|
}
|