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

1205 lines
44 KiB
Go
Raw Normal View History

package billing
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Service struct {
Pool *pgxpool.Pool
// Hot-path caches: costs are seeded once per process and rarely change.
costsSeeded atomic.Bool
costMu sync.RWMutex
costCache map[string]int
// Platform feature gates are global and change rarely (admin writes).
// Short TTL + invalidate-on-write avoids a full table read on every
// CapabilitiesForCompany / IsAllowed / CreditsOverview call.
gatesMu sync.RWMutex
gatesCache *FeatureGatesView
gatesCachedAt time.Time
}
const featureGatesCacheTTL = 30 * time.Second
type CreditsOverview struct {
TotalCredits int `json:"total_credits"`
UsedCredits int `json:"used_credits"`
Remaining int `json:"remaining"`
RemainingCredits int `json:"remaining_credits"`
LowCredits bool `json:"low_credits"`
LowCreditsThreshold int `json:"low_credits_threshold"`
ProductCount int `json:"product_count"`
MaxProducts *int `json:"max_products,omitempty"`
AtProductLimit bool `json:"at_product_limit"`
Plan map[string]any `json:"plan,omitempty"`
// HasActivePlan is true only when an active company_plans row exists.
// Missing/skipped plans still use Free entitlement gates but must not look like intentional Free / Unlimited in the UI.
HasActivePlan bool `json:"has_active_plan"`
// Entitlements — Free has can_use_ai=false; normalize/specs/fill still allowed.
CanUseAI bool `json:"can_use_ai"`
CanUseEPREL bool `json:"can_use_eprel"`
IsFreePlan bool `json:"is_free_plan"`
IsPaidPlan bool `json:"is_paid_plan"`
// Plan feature permissions (effective = plan ∩ global). Omitted when resolution fails.
Features map[string]bool `json:"features,omitempty"`
Sections map[string]bool `json:"sections,omitempty"`
DisabledFeatures []string `json:"disabled_features,omitempty"`
FeatureETag string `json:"feature_etag,omitempty"`
2026-08-22 18:51:17 +02:00
// Per-member access overlay (settings > team). Set by the /me handler so the
// dashboard can say "ask your administrator" rather than "upgrade your plan".
MemberRestricted bool `json:"member_restricted,omitempty"`
MemberDeniedFeatures []string `json:"member_denied_features,omitempty"`
}
func (s *Service) CreditsOverview(ctx context.Context, companyID uuid.UUID, lowThreshold int) (CreditsOverview, error) {
if lowThreshold <= 0 {
lowThreshold = 100
}
var total, used int
err := s.Pool.QueryRow(ctx, `
SELECT total_credits, used_credits FROM credit_balances WHERE company_id = $1`, companyID).
Scan(&total, &used)
if errors.Is(err, pgx.ErrNoRows) {
_, _ = s.Pool.Exec(ctx, `INSERT INTO credit_balances (company_id) VALUES ($1) ON CONFLICT DO NOTHING`, companyID)
total, used = 0, 0
} else if err != nil {
return CreditsOverview{}, err
}
remaining := RemainingCreditsClamped(total, used)
out := CreditsOverview{
TotalCredits: total,
UsedCredits: used,
Remaining: remaining,
RemainingCredits: remaining,
LowCreditsThreshold: lowThreshold,
}
_ = s.Pool.QueryRow(ctx, `
SELECT count(*) FROM processed_products WHERE company_id = $1`, companyID).Scan(&out.ProductCount)
var planName string
var monthlyVal int
var monthly, maxProducts *int
var isTrial, isCustom, isLegacy bool
var nextBilling *time.Time
var planNotes *string
var featuresRaw []byte
err = s.Pool.QueryRow(ctx, `
SELECT p.name, p.monthly_credits, p.max_products, p.is_custom, COALESCE(p.is_legacy, false),
cp.is_trial, cp.next_billing_date, cp.notes, COALESCE(p.features, '{}'::jsonb)
FROM company_plans cp
JOIN plans p ON p.id = cp.plan_id
WHERE cp.company_id = $1 AND cp.is_active = true
ORDER BY cp.created_at DESC LIMIT 1`, companyID).
Scan(&planName, &monthly, &maxProducts, &isCustom, &isLegacy, &isTrial, &nextBilling, &planNotes, &featuresRaw)
if err != nil && isUndefinedColumn(err) {
err = s.Pool.QueryRow(ctx, `
SELECT p.name, p.monthly_credits, p.max_products, p.is_custom,
cp.is_trial, cp.next_billing_date, cp.notes, COALESCE(p.features, '{}'::jsonb)
FROM company_plans cp
JOIN plans p ON p.id = cp.plan_id
WHERE cp.company_id = $1 AND cp.is_active = true
ORDER BY cp.created_at DESC LIMIT 1`, companyID).
Scan(&planName, &monthly, &maxProducts, &isCustom, &isTrial, &nextBilling, &planNotes, &featuresRaw)
if err == nil {
isLegacy = IsLegacyPlanName(planName)
} else if isUndefinedColumn(err) {
err = s.Pool.QueryRow(ctx, `
SELECT p.name, p.monthly_credits, p.max_products, p.is_custom,
cp.is_trial, cp.next_billing_date, cp.notes
FROM company_plans cp
JOIN plans p ON p.id = cp.plan_id
WHERE cp.company_id = $1 AND cp.is_active = true
ORDER BY cp.created_at DESC LIMIT 1`, companyID).
Scan(&planName, &monthly, &maxProducts, &isCustom, &isTrial, &nextBilling, &planNotes)
if err == nil {
isLegacy = IsLegacyPlanName(planName)
featuresRaw = []byte("{}")
}
}
}
if err == nil {
out.HasActivePlan = true
out.MaxProducts = maxProducts
if maxProducts != nil && *maxProducts > 0 {
out.AtProductLimit = out.ProductCount >= *maxProducts
}
if monthly != nil {
monthlyVal = *monthly
}
out.Plan = map[string]any{
"name": planName,
"monthly_credits": monthlyVal,
"max_products": maxProducts,
"is_custom": isCustom,
"is_trial": isTrial,
"next_billing_date": nextBilling,
}
if status := ParseStripeStatusNote(planNotes); status != "" {
out.Plan["subscription_status"] = status
}
} else {
// Skipped/missing company_plans: gate like Free, but HasActivePlan stays false for UX recovery.
planName = "Free"
featuresRaw = []byte("{}")
}
ent := ComputeEntitlements(planName, monthlyVal, remaining, isTrial)
out.CanUseAI = ent.CanUseAI
out.CanUseEPREL = ent.CanUseEPREL
out.IsFreePlan = ent.IsFreePlan
out.IsPaidPlan = ent.IsPaidPlan
// Free (0 monthly credits) is not "low credits" — AI is simply unavailable.
out.LowCredits = ent.CanUseAI && remaining <= lowThreshold && remaining > 0
// Resolve features from the plan row already loaded (avoids a second CapabilitiesForCompany
// round-trip that re-queries company_plans + credit_balances). Still uses GetFeatureGates cache
// and plans.features JSON overrides via decodeFeaturesJSON + ResolveEffectiveFeaturesEx.
if gates, gerr := s.GetFeatureGates(ctx); gerr == nil {
if overrides, oerr := decodeFeaturesJSON(featuresRaw); oerr == nil {
feats, sections, disabled := ResolveEffectiveFeaturesEx(
planName, isCustom, IsLegacyPlan(planName, isLegacy), overrides, gates)
out.Features = feats
out.Sections = sections
out.DisabledFeatures = disabled
out.FeatureETag = featureETag(feats)
}
}
return out, nil
}
var (
ErrInsufficientCredits = errors.New("insufficient credits")
ErrProductLimitExceeded = errors.New("product limit exceeded")
)
// AIBrandApplyAllowed reports whether brand-kit voice may be injected into AI prompts.
// Free (and unknown/no plan) can edit the brand kit but AI apply is gated to paid plans
// and the capability.brand_ai_apply / marketing.brand_ai_apply feature keys.
func (s *Service) AIBrandApplyAllowed(ctx context.Context, companyID uuid.UUID) bool {
if s == nil || s.Pool == nil {
return false
}
ent, err := s.EntitlementsForCompany(ctx, companyID)
if err != nil {
return false
}
if !(ent.IsPaidPlan || ent.IsTrial) {
return false
}
ok, err := s.IsAllowed(ctx, companyID, "capability.brand_ai_apply")
if err != nil || !ok {
return false
}
ok, err = s.IsAllowed(ctx, companyID, "marketing.brand_ai_apply")
if err != nil {
return false
}
return ok
}
// ProcessingGateOpts controls credit checks for a processing job start.
type ProcessingGateOpts struct {
// RequiresAI when true enforces can_use_ai + credit wallet (AI-only job types).
RequiresAI bool
// RequiresEPREL when true enforces can_use_eprel (EPREL-only job types).
RequiresEPREL bool
}
// AssertCanStartProcessing enforces plan SKU caps always; credit wallet only when RequiresAI.
// Free-tier normalize/specs/fill jobs pass with 0 credits.
func (s *Service) AssertCanStartProcessing(ctx context.Context, companyID uuid.UUID, batchSize int, opts ProcessingGateOpts) error {
if batchSize <= 0 {
return errors.New("no products selected")
}
ent, err := s.EntitlementsForCompany(ctx, companyID)
if err != nil {
return err
}
if opts.RequiresEPREL && !ent.CanUseEPREL {
return fmt.Errorf("%w — EPREL is included on every plan; check platform EPREL settings", ErrEPRELRequiresUpgrade)
}
if opts.RequiresAI {
if !ent.CanUseAI {
return fmt.Errorf("%w — upgrade your plan or add AI credits", ErrAIRequiresUpgrade)
}
if ent.RemainingCredits < 1 {
return fmt.Errorf("%w: no credits remaining — upgrade your plan to continue AI processing", ErrInsufficientCredits)
}
if ent.RemainingCredits < batchSize {
return fmt.Errorf("%w: need at least %d credits for this batch (have %d) — upgrade or select fewer products",
ErrInsufficientCredits, batchSize, ent.RemainingCredits)
}
}
var maxProducts *int
err = s.Pool.QueryRow(ctx, `
SELECT p.max_products
FROM company_plans cp
JOIN plans p ON p.id = cp.plan_id
WHERE cp.company_id = $1 AND cp.is_active = true
ORDER BY cp.created_at DESC LIMIT 1`, companyID).Scan(&maxProducts)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
if maxProducts == nil || *maxProducts <= 0 {
return nil
}
var productCount int
if err := s.Pool.QueryRow(ctx, `
SELECT count(*) FROM processed_products WHERE company_id = $1`, companyID).Scan(&productCount); err != nil {
return err
}
slotsLeft := *maxProducts - productCount
if slotsLeft <= 0 {
return fmt.Errorf("%w: plan allows up to %d products — upgrade to process more",
ErrProductLimitExceeded, *maxProducts)
}
if batchSize > slotsLeft {
return fmt.Errorf("%w: only %d product slots left on your plan (limit %d) — upgrade or select fewer products",
ErrProductLimitExceeded, slotsLeft, *maxProducts)
}
return nil
}
// ConsumeCredits debits company credits for one processed product.
// tokenCount is LLM tokens used for this item (0 = flat product cost only).
// Debit = feature base + ceil(tokens/1000)*openai_token_k.
// Free / no-AI path (tokenCount==0 and !CanUseAI): no debit — normalize/specs/fill is free.
// The wallet UPDATE is atomic (WHERE remaining >= debit) so concurrent consumes cannot go negative.
//
// Contends on the single credit_balances row per company (row lock until commit).
// Prefer this over batching when deliverables must not persist without a successful debit
// (pipeline processOne). Use ConsumeCreditsBatch only when the caller already holds
// results in memory and can discard them all on ErrInsufficientCredits.
func (s *Service) ConsumeCredits(ctx context.Context, companyID uuid.UUID, tokenCount int, featureName string) error {
return s.consumeCredits(ctx, nil, companyID, tokenCount, featureName, 1)
}
// ConsumeCreditsTx applies the same debit as ConsumeCredits on an existing
// transaction so callers (processOne) can commit debit+persist atomically.
func (s *Service) ConsumeCreditsTx(ctx context.Context, tx pgx.Tx, companyID uuid.UUID, tokenCount int, featureName string) error {
if tx == nil {
return fmt.Errorf("ConsumeCreditsTx: nil tx")
}
return s.consumeCredits(ctx, tx, companyID, tokenCount, featureName, 1)
}
// ConsumeCreditsBatch applies one wallet/cycle update for productCount items and
// combined tokenCount. Reduces credit_balances lock acquisitions vs N×ConsumeCredits.
// Debit uses DebitAmountN (packs on combined tokens) — may undercharge vs summing
// per-item DebitAmount when token packs cross 1k boundaries; for exact parity sum
// DebitAmount per item and prefer N×ConsumeCredits (or a future debit-amount API).
func (s *Service) ConsumeCreditsBatch(ctx context.Context, companyID uuid.UUID, tokenCount, productCount int, featureName string) error {
if productCount < 1 {
productCount = 1
}
return s.consumeCredits(ctx, nil, companyID, tokenCount, featureName, productCount)
}
func (s *Service) consumeCredits(ctx context.Context, tx pgx.Tx, companyID uuid.UUID, tokenCount int, featureName string, productCount int) error {
if featureName == "" {
featureName = "product_processing"
}
if tokenCount < 0 {
tokenCount = 0
}
if productCount < 1 {
productCount = 1
}
// Entitlements gate only matters for flat (0-token) debits on Free — skip the
// two-query load on the AI hot path where tokenCount > 0.
if tokenCount == 0 {
ent, err := s.EntitlementsForCompany(ctx, companyID)
if err == nil && !ent.CanUseAI {
return nil
}
}
_ = s.EnsureDefaultCosts(ctx)
featureCost := s.lookupCost(ctx, featureName, 1)
tokenKCost := 1
if tokenCount > 0 {
tokenKCost = s.lookupCost(ctx, "openai_token_k", 1)
}
var debit int
if productCount == 1 {
debit = DebitAmount(featureCost, tokenKCost, tokenCount)
} else {
debit = DebitAmountN(featureCost, tokenKCost, tokenCount, productCount)
}
return s.applyCreditDebit(ctx, tx, companyID, debit, productCount)
}
// applyCreditDebit atomically increments used_credits and open-cycle usage.
// Ensure-row + one CTE (balance + cycle) keeps the credit_balances row lock for two
// round-trips instead of an out-of-TX insert plus two separate UPDATEs.
// When tx is nil, begins and commits its own transaction; otherwise runs on tx
// without committing (caller owns the transaction).
func (s *Service) applyCreditDebit(ctx context.Context, tx pgx.Tx, companyID uuid.UUID, debit, productCount int) error {
if debit < 1 {
debit = 1
}
if productCount < 1 {
productCount = 1
}
ownTx := tx == nil
if ownTx {
begun, err := s.Pool.Begin(ctx)
if err != nil {
return err
}
defer begun.Rollback(ctx)
tx = begun
}
// Separate from the debit CTE: Postgres data-modifying CTEs share one snapshot
// and cannot see sibling INSERT effects in the same statement.
_, err := tx.Exec(ctx, `
INSERT INTO credit_balances (company_id) VALUES ($1)
ON CONFLICT (company_id) DO NOTHING`, companyID)
if err != nil {
return err
}
var balRows, cycRows int
err = tx.QueryRow(ctx, `
WITH upd AS (
UPDATE credit_balances
SET used_credits = used_credits + $2, updated_at = now()
WHERE company_id = $1 AND (total_credits - used_credits) >= $2
RETURNING company_id
),
cyc AS (
UPDATE billing_cycles
SET credits_used = credits_used + $2,
products_processed = products_processed + $3,
updated_at = now()
WHERE id = (
SELECT id FROM billing_cycles
WHERE company_id = $1 AND end_date > now()
ORDER BY start_date DESC LIMIT 1
)
AND EXISTS (SELECT 1 FROM upd)
RETURNING id
)
SELECT
(SELECT count(*)::int FROM upd),
(SELECT count(*)::int FROM cyc)`, companyID, debit, productCount).
Scan(&balRows, &cycRows)
if err != nil {
return err
}
if balRows == 0 {
return ErrInsufficientCredits
}
if cycRows == 0 {
if err := s.ensureOpenBillingCycle(ctx, tx, companyID, debit, productCount); err != nil {
return err
}
}
if ownTx {
return tx.Commit(ctx)
}
return nil
}
// ensureOpenBillingCycle opens a cycle from the active company_plan window when missing.
// When poolTx is nil, uses the service pool. initialUsed/products seed the new row (usually the debit just applied).
func (s *Service) ensureOpenBillingCycle(ctx context.Context, tx pgx.Tx, companyID uuid.UUID, initialUsed, products int) error {
exec := s.Pool.Exec
queryRow := s.Pool.QueryRow
if tx != nil {
exec = tx.Exec
queryRow = tx.QueryRow
}
var start, end time.Time
err := queryRow(ctx, `
SELECT billing_cycle_start, next_billing_date
FROM company_plans
WHERE company_id = $1 AND is_active = true
ORDER BY created_at DESC LIMIT 1`, companyID).Scan(&start, &end)
if err != nil {
now := time.Now().UTC()
start, end = now, now.AddDate(0, 1, 0)
}
_, err = exec(ctx, `
INSERT INTO billing_cycles (company_id, start_date, end_date, credits_used, products_processed)
VALUES ($1, $2, $3, $4, $5)`, companyID, start, end, initialUsed, products)
return err
}
func (s *Service) lookupCost(ctx context.Context, feature string, fallback int) int {
s.costMu.RLock()
if s.costCache != nil {
if cost, ok := s.costCache[feature]; ok {
s.costMu.RUnlock()
if cost <= 0 {
return fallback
}
return cost
}
}
s.costMu.RUnlock()
var cost int
err := s.Pool.QueryRow(ctx, `
SELECT cost_per_unit FROM processing_costs
WHERE feature_name = $1 AND is_active = true`, feature).Scan(&cost)
if err != nil || cost <= 0 {
return fallback
}
s.costMu.Lock()
if s.costCache == nil {
s.costCache = make(map[string]int, 8)
}
s.costCache[feature] = cost
s.costMu.Unlock()
return cost
}
// EnsureDefaultCosts seeds plan-aligned processing cost rows (idempotent).
// After a successful seed in this process, subsequent calls no-op (worker seeds at boot).
func (s *Service) EnsureDefaultCosts(ctx context.Context) error {
if s.costsSeeded.Load() {
return nil
}
_, err := s.Pool.Exec(ctx, `
INSERT INTO processing_costs (feature_name, cost_per_unit, description, is_active)
VALUES
('product_processing', 1, 'Credits per processed product (base)', true),
('openai_token_k', 1, 'Credits per 1000 LLM tokens', true),
('seo_meta_ai', 1, 'Credits per SEO AI meta apply (base)', true),
('campaign_copy', 1, 'Credits per campaign AI generate (base)', true)
ON CONFLICT (feature_name) DO NOTHING`)
if err == nil {
s.costsSeeded.Store(true)
}
return err
}
// EstimateDebit returns the credit cost for a feature + token pack (same math as ConsumeCredits).
func (s *Service) EstimateDebit(ctx context.Context, featureName string, tokenCount int) int {
if featureName == "" {
featureName = "product_processing"
}
_ = s.EnsureDefaultCosts(ctx)
featureCost := s.lookupCost(ctx, featureName, 1)
tokenKCost := 1
if tokenCount > 0 {
tokenKCost = s.lookupCost(ctx, "openai_token_k", 1)
}
return DebitAmount(featureCost, tokenKCost, tokenCount)
}
type Plan struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
MonthlyCredits int `json:"monthly_credits"`
YearlyCredits *int `json:"yearly_credits,omitempty"`
MaxProducts *int `json:"max_products,omitempty"`
IsCustom bool `json:"is_custom"`
IsLegacy bool `json:"is_legacy,omitempty"`
Term string `json:"term"`
Features map[string]bool `json:"features,omitempty"`
// ResolvedFeatures is plan_allows only (globals ignored); populated on admin list/get.
ResolvedFeatures map[string]bool `json:"resolved_features,omitempty"`
// AiCoverPercent is derived from PlanAICoverPercent (not a DB column).
AiCoverPercent int `json:"ai_cover_percent,omitempty"`
}
// EnterpriseUnlimitedCredits is the managed AI credit pack for the public Enterprise plan.
// Marketing copy says "Unlimited"; the wallet still uses a finite high grant so debit accounting works.
// Demo seed assigns this plan to Platform Demo.
const EnterpriseUnlimitedCredits = 1_000_000
// defaultPublicPlans is the self-serve ladder (PRICING doc packaging).
// Free intentionally grants 0 AI credits — normalize/specs/fill only until upgrade.
// Client-specific deals (A1, Merkur trial, etc.) must never appear here or on public pricing.
func defaultPublicPlans() []Plan {
freeDesc := "Forever free — map a sample feed, normalize & fill specs (no AI credits)"
starterDesc := "Up to 100 SKUs, entry AI (~100 credits / ~50% cover), full WooCommerce sync"
plusDesc := "Up to 400 SKUs, Woo+Shopify, AI (~400 credits / ~50% of credit base)"
growthDesc := "Up to 1,200 SKUs, full stores, BYOK, AI (~1,200 credits / ~50% of credit base)"
businessDesc := "Up to 4,000 SKUs, BYOK, AI (~4,000 credits / ~50% of credit base)"
scaleDesc := "Up to 12,000 SKUs, AI (~12,000 credits / ~50% cover); packs/BYOK for more"
enterpriseDesc := "Unlimited SKUs & feeds — large managed AI grant or BYOK, SLA, account team"
return []Plan{
{Name: "Free", Description: &freeDesc, MonthlyCredits: 0, MaxProducts: PlanMaxProducts("Free"), Term: "monthly"},
{Name: "Starter", Description: &starterDesc, MonthlyCredits: MonthlyCreditsForPlan("Starter", 0), MaxProducts: PlanMaxProducts("Starter"), Term: "monthly"},
{Name: "Plus", Description: &plusDesc, MonthlyCredits: MonthlyCreditsForPlan("Plus", 0), MaxProducts: PlanMaxProducts("Plus"), Term: "monthly"},
{Name: "Growth", Description: &growthDesc, MonthlyCredits: MonthlyCreditsForPlan("Growth", 0), MaxProducts: PlanMaxProducts("Growth"), Term: "monthly"},
{Name: "Business", Description: &businessDesc, MonthlyCredits: MonthlyCreditsForPlan("Business", 0), MaxProducts: PlanMaxProducts("Business"), Term: "monthly"},
{Name: "Scale", Description: &scaleDesc, MonthlyCredits: MonthlyCreditsForPlan("Scale", 0), MaxProducts: PlanMaxProducts("Scale"), Term: "monthly"},
// MaxProducts nil = unlimited SKU cap in AssertCanStartProcessing.
{Name: "Enterprise", Description: &enterpriseDesc, MonthlyCredits: EnterpriseUnlimitedCredits, MaxProducts: PlanMaxProducts("Enterprise"), IsCustom: true, Term: "monthly"},
}
}
// IsPublicProductPlan reports whether name is on the public marketing ladder.
// Client deals (A1, Merkur trial, legacy Basic/Professional, …) return false.
func IsPublicProductPlan(name string) bool {
switch strings.ToLower(strings.TrimSpace(name)) {
case "free", "starter", "plus", "growth", "business", "scale", "enterprise":
return true
default:
return false
}
}
// EnsureDefaultPlans upserts Free / Starter / Plus / Growth / Business / Scale / Enterprise by name.
// Aligns with PRICING packaging; Free monthly_credits = 0 (no AI grant on signup).
func (s *Service) EnsureDefaultPlans(ctx context.Context) error {
for _, p := range defaultPublicPlans() {
var id int64
err := s.Pool.QueryRow(ctx, `
SELECT id FROM plans WHERE lower(name) = lower($1) ORDER BY id LIMIT 1`, p.Name).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
_, err = s.Pool.Exec(ctx, `
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term)
VALUES ($1, $2, $3, NULL, $4, $5, $6)`,
p.Name, p.Description, p.MonthlyCredits, p.MaxProducts, p.IsCustom, p.Term)
if err != nil {
return err
}
continue
}
if err != nil {
return err
}
// Sync public ladder meters only — never clobber plans.features overrides.
// Named client deals (A1, Merkur, …) are not in defaultPublicPlans and stay untouched.
_, err = s.Pool.Exec(ctx, `
UPDATE plans SET description = $2, monthly_credits = $3, max_products = $4,
is_custom = $5, term = $6, updated_at = now()
WHERE id = $1`, id, p.Description, p.MonthlyCredits, p.MaxProducts, p.IsCustom, p.Term)
if err != nil {
return err
}
}
// Global section gates default ON; empty plan.features stay unset (DefaultPlanFeatures).
// Then Legacy plan row + A1 cohort assignment + sparse legacy feature backfill.
if err := s.EnsureDefaultFeatureSeeds(ctx); err != nil {
return err
}
if err := s.EnsurePlanCatalogHygiene(ctx); err != nil {
return err
}
return s.EnsureLegacyDefaults(ctx)
}
// ProvisionFreePlan assigns the Free plan (0 AI credits) to a new company.
// Best-effort: registration must not fail if Free is missing.
// Never falls back to another plan (Enterprise may have a lower id after seed-demo).
func (s *Service) ProvisionFreePlan(ctx context.Context, companyID uuid.UUID) error {
if err := s.EnsureDefaultPlans(ctx); err != nil {
return err
}
var planID int64
err := s.Pool.QueryRow(ctx, `
SELECT id FROM plans WHERE lower(name) = 'free' ORDER BY id LIMIT 1`).Scan(&planID)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
return s.AssignPlan(ctx, companyID, planID, false, 0)
}
func (s *Service) ListPlans(ctx context.Context) ([]Plan, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, name, description, monthly_credits, yearly_credits, max_products, is_custom, term,
COALESCE(features, '{}'::jsonb)
FROM plans ORDER BY id`)
if err != nil {
if isUndefinedColumn(err) {
return s.listPlansWithoutFeatures(ctx)
}
return nil, err
}
defer rows.Close()
out := make([]Plan, 0)
for rows.Next() {
var p Plan
var raw []byte
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.MonthlyCredits, &p.YearlyCredits, &p.MaxProducts, &p.IsCustom, &p.Term, &raw); err != nil {
return nil, err
}
overrides, derr := decodeFeaturesJSON(raw)
if derr != nil {
return nil, derr
}
p.Features = overrides
p.IsLegacy = IsLegacyPlanName(p.Name)
p.ResolvedFeatures = planFeaturesView(p.ID, p.Name, p.IsCustom, p.IsLegacy, overrides).ResolvedFeatures
out = append(out, p)
}
return out, rows.Err()
}
func (s *Service) listPlansWithoutFeatures(ctx context.Context) ([]Plan, error) {
rows, err := s.Pool.Query(ctx, `
SELECT id, name, description, monthly_credits, yearly_credits, max_products, is_custom, term
FROM plans ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]Plan, 0)
for rows.Next() {
var p Plan
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.MonthlyCredits, &p.YearlyCredits, &p.MaxProducts, &p.IsCustom, &p.Term); err != nil {
return nil, err
}
p.Features = map[string]bool{}
p.IsLegacy = IsLegacyPlanName(p.Name)
p.ResolvedFeatures = planFeaturesView(p.ID, p.Name, p.IsCustom, p.IsLegacy, nil).ResolvedFeatures
out = append(out, p)
}
return out, rows.Err()
}
// ListPublicPlans returns only Free / Starter / Plus / Growth / Business / Scale / Enterprise.
// Keeps client-specific plans (A1, Merkur trial, …) assignable via admin ListPlans.
func (s *Service) ListPublicPlans(ctx context.Context) ([]Plan, error) {
all, err := s.ListPlans(ctx)
if err != nil {
return nil, err
}
order := map[string]int{
"free": 0, "starter": 1, "plus": 2, "growth": 3, "business": 4, "scale": 5, "enterprise": 6,
}
out := make([]Plan, 0, 7)
for _, p := range all {
if !IsPublicProductPlan(p.Name) {
continue
}
p.AiCoverPercent = PlanAICoverPercent(p.Name)
out = append(out, p)
}
sort.SliceStable(out, func(i, j int) bool {
return order[strings.ToLower(out[i].Name)] < order[strings.ToLower(out[j].Name)]
})
return out, nil
}
func (s *Service) UpsertPlan(ctx context.Context, p Plan) (Plan, error) {
p.Name = strings.TrimSpace(p.Name)
if p.Name == "" {
return Plan{}, ErrPlanNameRequired
}
if p.Term == "" {
p.Term = "monthly"
}
creating := p.ID == 0
featuresProvided := p.Features != nil
prepareCustomPackageCreateFeatures(&p, creating, featuresProvided)
if IsLegacyPlan(p.Name, p.IsLegacy) {
p.IsLegacy = true
}
if p.Features != nil {
if err := validateFeatureOverrides(p.Features); err != nil {
return Plan{}, err
}
}
featuresJSON, err := encodeFeaturesJSON(p.Features)
if err != nil {
return Plan{}, err
}
if p.ID > 0 {
if p.Features != nil {
_, err = s.Pool.Exec(ctx, `
UPDATE plans SET name=$2, description=$3, monthly_credits=$4, yearly_credits=$5,
max_products=$6, is_custom=$7, term=$8, features=$9::jsonb, is_legacy=$10, updated_at=now()
WHERE id=$1`, p.ID, p.Name, p.Description, p.MonthlyCredits, p.YearlyCredits, p.MaxProducts, p.IsCustom, p.Term, featuresJSON, p.IsLegacy)
} else {
_, err = s.Pool.Exec(ctx, `
UPDATE plans SET name=$2, description=$3, monthly_credits=$4, yearly_credits=$5,
max_products=$6, is_custom=$7, term=$8, is_legacy=$9, updated_at=now()
WHERE id=$1`, p.ID, p.Name, p.Description, p.MonthlyCredits, p.YearlyCredits, p.MaxProducts, p.IsCustom, p.Term, p.IsLegacy)
}
if err != nil {
if isUndefinedColumn(err) {
// Pre-is_legacy migration: fall back without the column.
if p.Features != nil {
_, err = s.Pool.Exec(ctx, `
UPDATE plans SET name=$2, description=$3, monthly_credits=$4, yearly_credits=$5,
max_products=$6, is_custom=$7, term=$8, features=$9::jsonb, updated_at=now()
WHERE id=$1`, p.ID, p.Name, p.Description, p.MonthlyCredits, p.YearlyCredits, p.MaxProducts, p.IsCustom, p.Term, featuresJSON)
} else {
_, err = s.Pool.Exec(ctx, `
UPDATE plans SET name=$2, description=$3, monthly_credits=$4, yearly_credits=$5,
max_products=$6, is_custom=$7, term=$8, updated_at=now()
WHERE id=$1`, p.ID, p.Name, p.Description, p.MonthlyCredits, p.YearlyCredits, p.MaxProducts, p.IsCustom, p.Term)
}
if err != nil && p.Features != nil && isUndefinedColumn(err) {
return Plan{}, errors.New("plans.features column missing — run migration 026_plan_features")
}
}
if err != nil {
return Plan{}, err
}
}
} else {
if p.Features != nil {
err = s.Pool.QueryRow(ctx, `
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term, features, is_legacy)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9) RETURNING id`,
p.Name, p.Description, p.MonthlyCredits, p.YearlyCredits, p.MaxProducts, p.IsCustom, p.Term, featuresJSON, p.IsLegacy,
).Scan(&p.ID)
} else {
err = s.Pool.QueryRow(ctx, `
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term, is_legacy)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
p.Name, p.Description, p.MonthlyCredits, p.YearlyCredits, p.MaxProducts, p.IsCustom, p.Term, p.IsLegacy,
).Scan(&p.ID)
}
if err != nil {
if isUndefinedColumn(err) {
if p.Features != nil {
err = s.Pool.QueryRow(ctx, `
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term, features)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb) RETURNING id`,
p.Name, p.Description, p.MonthlyCredits, p.YearlyCredits, p.MaxProducts, p.IsCustom, p.Term, featuresJSON,
).Scan(&p.ID)
} else {
err = s.Pool.QueryRow(ctx, `
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
p.Name, p.Description, p.MonthlyCredits, p.YearlyCredits, p.MaxProducts, p.IsCustom, p.Term,
).Scan(&p.ID)
}
if err != nil && p.Features != nil && isUndefinedColumn(err) {
return Plan{}, errors.New("plans.features column missing — run migration 026_plan_features")
}
}
if err != nil {
return Plan{}, err
}
}
}
view, gerr := s.GetPlanFeatures(ctx, p.ID)
if gerr == nil {
p.Features = view.Features
p.ResolvedFeatures = view.ResolvedFeatures
p.IsCustom = view.IsCustom
p.IsLegacy = view.IsLegacy
p.Name = view.PlanName
}
return p, nil
}
func (s *Service) AssignPlan(ctx context.Context, companyID uuid.UUID, planID int64, isTrial bool, trialCredits int) error {
var monthly int
err := s.Pool.QueryRow(ctx, `SELECT monthly_credits FROM plans WHERE id = $1`, planID).Scan(&monthly)
if errors.Is(err, pgx.ErrNoRows) {
return ErrPlanNotFound
}
if err != nil {
return err
}
tx, err := s.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `UPDATE company_plans SET is_active = false, updated_at = now() WHERE company_id = $1 AND is_active = true`, companyID)
if err != nil {
return err
}
now := time.Now().UTC()
next := now.AddDate(0, 1, 0)
_, err = tx.Exec(ctx, `
INSERT INTO company_plans (company_id, plan_id, is_active, billing_cycle_start, next_billing_date, is_trial, trial_credits)
VALUES ($1,$2,true,$3,$4,$5,$6)`, companyID, planID, now, next, isTrial, trialCredits)
if err != nil {
return err
}
alloc := monthly
if isTrial && trialCredits > 0 {
alloc = trialCredits
}
_, err = tx.Exec(ctx, `
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
VALUES ($1, $2, 0, now())
ON CONFLICT (company_id) DO UPDATE SET total_credits = EXCLUDED.total_credits, used_credits = 0, updated_at = now()`,
companyID, alloc)
if err != nil {
return err
}
// Close any still-open cycles so UsageSummary / ConsumeCredits never read stale ended rows as "current".
_, err = tx.Exec(ctx, `
UPDATE billing_cycles SET end_date = $2, updated_at = now()
WHERE company_id = $1 AND end_date > $2`, companyID, now)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `
INSERT INTO billing_cycles (company_id, start_date, end_date, credits_used, products_processed)
VALUES ($1, $2, $3, 0, 0)`, companyID, now, next)
if err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Service) AddCredits(ctx context.Context, companyID uuid.UUID, amount int) error {
if amount == 0 {
return ErrAmountRequired
}
_, _ = s.Pool.Exec(ctx, `INSERT INTO credit_balances (company_id) VALUES ($1) ON CONFLICT DO NOTHING`, companyID)
// Clawbacks (negative amount) must not push total below used_credits (no negative remaining).
_, err := s.Pool.Exec(ctx, `
UPDATE credit_balances
SET total_credits = GREATEST(used_credits, GREATEST(0, total_credits + $2)), updated_at = now()
WHERE company_id = $1`, companyID, amount)
return err
}
// UsageDayPoint is one UTC day of company product/token activity.
type UsageDayPoint struct {
Date string `json:"date"`
Products int64 `json:"products"`
Tokens int64 `json:"tokens"`
}
// UsageSummary is company usage for the billing UI.
// Credits always come from live credit_balances (not stale billing_cycles rows).
// Products/tokens respect Range; cycle dates come from the active company_plans row.
type UsageSummary struct {
CompanyID uuid.UUID `json:"company_id"`
Range string `json:"range"`
CreditsUsed int `json:"credits_used"`
CreditsTotal int `json:"credits_total"`
CreditsRemaining int `json:"credits_remaining"`
ProductsProcessed int `json:"products_processed"`
ProductsTotal int `json:"products_total"`
Tokens int64 `json:"tokens"`
FeedsInput int `json:"feeds_input"`
FeedsExport int `json:"feeds_export"`
JobsTotal int `json:"jobs_total"`
CycleStart *time.Time `json:"cycle_start,omitempty"`
CycleEnd *time.Time `json:"cycle_end,omitempty"`
Series []UsageDayPoint `json:"series,omitempty"`
Notes []string `json:"notes,omitempty"`
}
// ParseUsageRange normalizes billing usage range query values.
func ParseUsageRange(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "7d", "30d", "cycle", "all":
return strings.ToLower(strings.TrimSpace(raw))
default:
return "30d"
}
}
func (s *Service) UsageSummary(ctx context.Context, companyID uuid.UUID, rangeRaw string) (UsageSummary, error) {
rangeKey := ParseUsageRange(rangeRaw)
out := UsageSummary{
CompanyID: companyID,
Range: rangeKey,
Notes: make([]string, 0, 3),
}
var total, used int
err := s.Pool.QueryRow(ctx, `
SELECT total_credits, used_credits FROM credit_balances WHERE company_id = $1`, companyID).
Scan(&total, &used)
if errors.Is(err, pgx.ErrNoRows) {
total, used = 0, 0
} else if err != nil {
return out, err
}
out.CreditsTotal = total
out.CreditsUsed = used
out.CreditsRemaining = RemainingCreditsClamped(total, used)
out.Notes = append(out.Notes,
"Credits are the live wallet (credit_balances). Daily credit history is not ledgered yet — range filters apply to products and tokens only.")
var cycleStart, cycleEnd *time.Time
_ = s.Pool.QueryRow(ctx, `
SELECT cp.billing_cycle_start, cp.next_billing_date
FROM company_plans cp
WHERE cp.company_id = $1 AND cp.is_active = true
ORDER BY cp.created_at DESC LIMIT 1`, companyID).Scan(&cycleStart, &cycleEnd)
out.CycleStart = cycleStart
out.CycleEnd = cycleEnd
now := time.Now().UTC()
var since *time.Time
var until *time.Time
switch rangeKey {
case "7d":
t := now.Truncate(24*time.Hour).AddDate(0, 0, -6)
since = &t
case "30d":
t := now.Truncate(24*time.Hour).AddDate(0, 0, -29)
since = &t
case "cycle":
if cycleStart != nil {
since = cycleStart
}
if cycleEnd != nil {
until = cycleEnd
}
if since == nil {
out.Notes = append(out.Notes, "No active billing cycle on company_plans — showing all-time products/tokens.")
rangeKey = "all"
out.Range = "all"
}
case "all":
// no time filter
}
_ = s.Pool.QueryRow(ctx, `
SELECT COUNT(*)::int FROM input_feeds WHERE company_id = $1`, companyID).Scan(&out.FeedsInput)
_ = s.Pool.QueryRow(ctx, `
SELECT COUNT(*)::int FROM export_feeds WHERE company_id = $1`, companyID).Scan(&out.FeedsExport)
_ = s.Pool.QueryRow(ctx, `
SELECT COUNT(*)::int FROM processing_jobs WHERE company_id = $1`, companyID).Scan(&out.JobsTotal)
// One scan of processed_products: all-time total plus optional range stats via FILTER.
switch {
case since != nil && until != nil:
_ = s.Pool.QueryRow(ctx, `
SELECT COUNT(*)::int,
COUNT(*) FILTER (WHERE created_at >= $2 AND created_at < $3)::int,
COALESCE(SUM(COALESCE(total_tokens, 0)) FILTER (WHERE created_at >= $2 AND created_at < $3), 0)::bigint
FROM processed_products
WHERE company_id = $1`,
companyID, *since, *until).Scan(&out.ProductsTotal, &out.ProductsProcessed, &out.Tokens)
case since != nil:
_ = s.Pool.QueryRow(ctx, `
SELECT COUNT(*)::int,
COUNT(*) FILTER (WHERE created_at >= $2)::int,
COALESCE(SUM(COALESCE(total_tokens, 0)) FILTER (WHERE created_at >= $2), 0)::bigint
FROM processed_products
WHERE company_id = $1`,
companyID, *since).Scan(&out.ProductsTotal, &out.ProductsProcessed, &out.Tokens)
default:
_ = s.Pool.QueryRow(ctx, `
SELECT COUNT(*)::int, COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint
FROM processed_products
WHERE company_id = $1`, companyID).Scan(&out.ProductsTotal, &out.Tokens)
out.ProductsProcessed = out.ProductsTotal
}
if rangeKey == "7d" || rangeKey == "30d" || (rangeKey == "cycle" && since != nil) {
seriesSince := now.Truncate(24*time.Hour).AddDate(0, 0, -29)
days := 30
if rangeKey == "7d" {
seriesSince = now.Truncate(24*time.Hour).AddDate(0, 0, -6)
days = 7
} else if rangeKey == "cycle" && since != nil {
seriesSince = since.UTC().Truncate(24 * time.Hour)
end := now.UTC().Truncate(24 * time.Hour)
if until != nil && until.Before(end) {
end = until.UTC().Truncate(24 * time.Hour)
}
days = int(end.Sub(seriesSince).Hours()/24) + 1
if days < 1 {
days = 1
}
if days > 90 {
days = 90
seriesSince = end.AddDate(0, 0, -(days - 1))
}
}
out.Series = s.usageDaySeries(ctx, companyID, seriesSince, days)
}
return out, nil
}
func (s *Service) usageDaySeries(ctx context.Context, companyID uuid.UUID, since time.Time, days int) []UsageDayPoint {
byDay := map[string]UsageDayPoint{}
rows, err := s.Pool.Query(ctx, `
SELECT (created_at AT TIME ZONE 'UTC')::date AS d,
COUNT(*)::bigint,
COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint
FROM processed_products
WHERE company_id = $1 AND created_at >= $2
GROUP BY 1
ORDER BY 1`, companyID, since)
if err == nil {
for rows.Next() {
var d time.Time
var products, tokens int64
if err := rows.Scan(&d, &products, &tokens); err != nil {
break
}
key := d.UTC().Format("2006-01-02")
byDay[key] = UsageDayPoint{Date: key, Products: products, Tokens: tokens}
}
rows.Close()
}
out := make([]UsageDayPoint, 0, days)
for i := 0; i < days; i++ {
key := since.AddDate(0, 0, i).UTC().Format("2006-01-02")
if p, ok := byDay[key]; ok {
out = append(out, p)
continue
}
out = append(out, UsageDayPoint{Date: key})
}
return out
}
// DueBillingCyclesResult counts successful rolls and permanent per-row failures.
// Skipped claims (SKIP LOCKED / no longer due) are neither processed nor failed.
type DueBillingCyclesResult struct {
Processed int `json:"processed"`
Failed int `json:"failed"`
}
// recordDueCycleAttempt updates counters for one claimAndRollDueCompanyPlan outcome.
// Permanent failures increment Failed and return a wrapped error for aggregation.
func recordDueCycleAttempt(res *DueBillingCyclesResult, rowID int64, ok bool, err error) error {
if err != nil {
res.Failed++
return fmt.Errorf("company_plan %d: %w", rowID, err)
}
if ok {
res.Processed++
}
return nil
}
// RunDueBillingCycles rolls due company_plans into a new cycle and refreshes monthly credits.
// Concurrent callers are safe: each due row is re-claimed with FOR UPDATE SKIP LOCKED inside
// its processing transaction (same pattern as processing.ClaimNext / shopify ClaimNextPendingJob).
// Per-row permanent failures are fail-closed (counted in Failed, aggregated into the returned
// error) without aborting the rest of the multi-company run.
func (s *Service) RunDueBillingCycles(ctx context.Context) (DueBillingCyclesResult, error) {
var res DueBillingCyclesResult
rows, err := s.Pool.Query(ctx, `
SELECT cp.id
FROM company_plans cp
WHERE cp.is_active = true AND cp.next_billing_date <= now()
ORDER BY cp.next_billing_date ASC, cp.id ASC`)
if err != nil {
return res, err
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return res, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return res, err
}
var errs []error
for _, rowID := range ids {
ok, rollErr := s.claimAndRollDueCompanyPlan(ctx, rowID)
if attemptErr := recordDueCycleAttempt(&res, rowID, ok, rollErr); attemptErr != nil {
slog.Error("billing_cycle_roll_failed", "company_plan_id", rowID, "err", rollErr)
errs = append(errs, attemptErr)
}
}
if len(errs) > 0 {
return res, errors.Join(errs...)
}
return res, nil
}
// claimAndRollDueCompanyPlan claims one company_plans row with FOR UPDATE SKIP LOCKED and
// rolls it when still due. ok=false, err=nil means another worker claimed it or it is no longer due.
// Insert/update/commit failures return ok=false with a non-nil error (fail-closed).
func (s *Service) claimAndRollDueCompanyPlan(ctx context.Context, rowID int64) (ok bool, err error) {
tx, err := s.Pool.Begin(ctx)
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback(ctx) }()
var (
companyID uuid.UUID
start time.Time
next time.Time
monthly int
)
// Claim still-due row; SKIP LOCKED yields no row when another worker holds the lock.
err = tx.QueryRow(ctx, `
SELECT cp.company_id, cp.billing_cycle_start, cp.next_billing_date, p.monthly_credits
FROM company_plans cp
JOIN plans p ON p.id = cp.plan_id
WHERE cp.id = $1
AND cp.is_active = true
AND cp.next_billing_date <= now()
FOR UPDATE OF cp SKIP LOCKED`, rowID).Scan(&companyID, &start, &next, &monthly)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
var used int
err = tx.QueryRow(ctx, `
SELECT used_credits FROM credit_balances WHERE company_id = $1 FOR UPDATE`, companyID).Scan(&used)
if errors.Is(err, pgx.ErrNoRows) {
used = 0
} else if err != nil {
return false, err
}
_, err = tx.Exec(ctx, `
INSERT INTO billing_cycles (company_id, start_date, end_date, credits_used, products_processed)
VALUES ($1, $2, $3, $4, 0)`, companyID, start, next, used)
if err != nil {
return false, err
}
newStart := next
newNext := next.AddDate(0, 1, 0)
_, err = tx.Exec(ctx, `
UPDATE company_plans SET billing_cycle_start = $2, next_billing_date = $3, updated_at = now()
WHERE id = $1`, rowID, newStart, newNext)
if err != nil {
return false, err
}
// monthly_credits == 0 (Free / A1 PAYG): preserve wallet — no monthly grant to replace.
// Otherwise a cycle roll would wipe topped-up or migrated credits (A1 dump / packs).
if monthly > 0 {
_, err = tx.Exec(ctx, `
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
VALUES ($1, $2, 0, now())
ON CONFLICT (company_id) DO UPDATE SET total_credits = EXCLUDED.total_credits, used_credits = 0, updated_at = now()`,
companyID, monthly)
if err != nil {
return false, err
}
}
if err := tx.Commit(ctx); err != nil {
return false, err
}
return true, nil
}