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.
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user