Files

191 lines
5.7 KiB
Go
Raw Permalink Normal View History

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
}
}