Files
descrybe/apps/api/internal/billing/entitlements.go
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

191 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}