191 lines
5.7 KiB
Go
191 lines
5.7 KiB
Go
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
|
|||
|
|
}
|
|||
|
|
}
|