package billing import ( "context" "errors" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // Entitlements describes plan-gated capabilities for a company. type Entitlements struct { PlanName string `json:"plan_name"` IsFreePlan bool `json:"is_free_plan"` IsPaidPlan bool `json:"is_paid_plan"` IsTrial bool `json:"is_trial"` MonthlyCredits int `json:"monthly_credits"` RemainingCredits int `json:"remaining_credits"` // CanUseAI is true when remaining credits > 0 OR the company is on a paid plan (not Free). CanUseAI bool `json:"can_use_ai"` // CanUseEPREL is always true: EU EPREL is public free data (no credits). Platform may still disable the enricher via eprel.enabled / EPREL_ENABLED. CanUseEPREL bool `json:"can_use_eprel"` } // ErrAIRequiresUpgrade is returned when a job is AI-only and the company cannot use AI. var ErrAIRequiresUpgrade = errors.New("ai features require a paid plan or AI credits") // ErrEPRELRequiresUpgrade is retained for API error shaping only; CanUseEPREL is always true // (public EU data, no credits). Do not surface "upgrade for EPREL" in product copy. var ErrEPRELRequiresUpgrade = errors.New("EPREL enrichment unavailable") // IsFreePlanName reports whether the plan name is the forever-free tier. func IsFreePlanName(name string) bool { return strings.EqualFold(strings.TrimSpace(name), "free") } // RemainingCreditsClamped returns max(0, total-used) so corrupted wallets never report negative spendable credits. func RemainingCreditsClamped(total, used int) int { r := total - used if r < 0 { return 0 } return r } // ApplyCreditDelta returns the next total_credits after amount (grants or clawbacks). // Never below used_credits or below 0 — prevents negative remaining balances. func ApplyCreditDelta(total, used, amount int) int { next := total + amount if next < used { next = used } if next < 0 { next = 0 } return next } // DebitAmount is the per-product credit burn: feature base + ceil(tokens/1000)*tokenK. // Negative tokenCount is treated as 0. Costs below 1 fall back to 1. func DebitAmount(featureCost, tokenKCost, tokenCount int) int { if featureCost < 1 { featureCost = 1 } if tokenCount < 0 { tokenCount = 0 } debit := featureCost if tokenCount > 0 { if tokenKCost < 1 { tokenKCost = 1 } packs := (tokenCount + 999) / 1000 debit += packs * tokenKCost } if debit < 1 { debit = 1 } return debit } // DebitAmountN scales the feature base by productCount and adds token packs on the // combined tokenCount. packs(sum) can be less than sum(packs) — for exact parity with // N×ConsumeCredits, sum DebitAmount per item instead of using this helper. func DebitAmountN(featureCost, tokenKCost, tokenCount, productCount int) int { if productCount < 1 { productCount = 1 } if featureCost < 1 { featureCost = 1 } if tokenCount < 0 { tokenCount = 0 } debit := featureCost * productCount if tokenCount > 0 { if tokenKCost < 1 { tokenKCost = 1 } packs := (tokenCount + 999) / 1000 debit += packs * tokenKCost } if debit < 1 { debit = 1 } return debit } // ComputeEntitlements builds entitlements from plan + wallet state (pure; testable). func ComputeEntitlements(planName string, monthlyCredits, remaining int, isTrial bool) Entitlements { if remaining < 0 { remaining = 0 } free := IsFreePlanName(planName) || planName == "" paid := !free canAI := remaining > 0 || paid return Entitlements{ PlanName: planName, IsFreePlan: free, IsPaidPlan: paid, IsTrial: isTrial, MonthlyCredits: monthlyCredits, RemainingCredits: remaining, CanUseAI: canAI, CanUseEPREL: true, } } // EntitlementsForCompany loads active plan + credit wallet entitlements. func (s *Service) EntitlementsForCompany(ctx context.Context, companyID uuid.UUID) (Entitlements, error) { 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 Entitlements{}, err } remaining := RemainingCreditsClamped(total, used) var planName string var monthly int var isTrial bool err = s.Pool.QueryRow(ctx, ` SELECT COALESCE(p.name, ''), COALESCE(p.monthly_credits, 0), cp.is_trial 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, &isTrial) if errors.Is(err, pgx.ErrNoRows) { return ComputeEntitlements("Free", 0, remaining, false), nil } if err != nil { return Entitlements{}, err } return ComputeEntitlements(planName, monthly, remaining, isTrial), nil } // ProcessingTypeRequiresAI reports whether the request is an AI-only intent // (cannot be silently downgraded to normalize/specs/fill). func ProcessingTypeRequiresAI(processingType string) bool { switch strings.ToLower(strings.TrimSpace(processingType)) { case "enhance", "enhance_only", "enhance-only", "title", "description", "seo", "seo_ai": return true default: return false } } // ProcessingTypeRequiresEPREL reports whether the request is EPREL-only. func ProcessingTypeRequiresEPREL(processingType string) bool { switch strings.ToLower(strings.TrimSpace(processingType)) { case "eprel", "eprel_only": return true default: return false } } // ProcessingTypeIsEmailCampaignAI is reserved for future email-campaign AI endpoints. func ProcessingTypeIsEmailCampaignAI(processingType string) bool { switch strings.ToLower(strings.TrimSpace(processingType)) { case "email_campaign", "email_campaign_ai", "campaign_ai": return true default: return false } }