2026-08-09 22:47:43 +02:00
|
|
|
|
package billing
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"log/slog"
|
|
|
|
|
|
"sort"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"sync"
|
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/google/uuid"
|
|
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type Service struct {
|
|
|
|
|
|
Pool *pgxpool.Pool
|
|
|
|
|
|
|
|
|
|
|
|
// Hot-path caches: costs are seeded once per process and rarely change.
|
|
|
|
|
|
costsSeeded atomic.Bool
|
|
|
|
|
|
costMu sync.RWMutex
|
|
|
|
|
|
costCache map[string]int
|
|
|
|
|
|
|
|
|
|
|
|
// Platform feature gates are global and change rarely (admin writes).
|
|
|
|
|
|
// Short TTL + invalidate-on-write avoids a full table read on every
|
|
|
|
|
|
// CapabilitiesForCompany / IsAllowed / CreditsOverview call.
|
|
|
|
|
|
gatesMu sync.RWMutex
|
|
|
|
|
|
gatesCache *FeatureGatesView
|
|
|
|
|
|
gatesCachedAt time.Time
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const featureGatesCacheTTL = 30 * time.Second
|
|
|
|
|
|
|
|
|
|
|
|
type CreditsOverview struct {
|
|
|
|
|
|
TotalCredits int `json:"total_credits"`
|
|
|
|
|
|
UsedCredits int `json:"used_credits"`
|
|
|
|
|
|
Remaining int `json:"remaining"`
|
|
|
|
|
|
RemainingCredits int `json:"remaining_credits"`
|
|
|
|
|
|
LowCredits bool `json:"low_credits"`
|
|
|
|
|
|
LowCreditsThreshold int `json:"low_credits_threshold"`
|
|
|
|
|
|
ProductCount int `json:"product_count"`
|
|
|
|
|
|
MaxProducts *int `json:"max_products,omitempty"`
|
|
|
|
|
|
AtProductLimit bool `json:"at_product_limit"`
|
|
|
|
|
|
Plan map[string]any `json:"plan,omitempty"`
|
|
|
|
|
|
// HasActivePlan is true only when an active company_plans row exists.
|
|
|
|
|
|
// Missing/skipped plans still use Free entitlement gates but must not look like intentional Free / Unlimited in the UI.
|
|
|
|
|
|
HasActivePlan bool `json:"has_active_plan"`
|
|
|
|
|
|
// Entitlements — Free has can_use_ai=false; normalize/specs/fill still allowed.
|
|
|
|
|
|
CanUseAI bool `json:"can_use_ai"`
|
|
|
|
|
|
CanUseEPREL bool `json:"can_use_eprel"`
|
|
|
|
|
|
IsFreePlan bool `json:"is_free_plan"`
|
|
|
|
|
|
IsPaidPlan bool `json:"is_paid_plan"`
|
|
|
|
|
|
// Plan feature permissions (effective = plan ∩ global). Omitted when resolution fails.
|
|
|
|
|
|
Features map[string]bool `json:"features,omitempty"`
|
|
|
|
|
|
Sections map[string]bool `json:"sections,omitempty"`
|
|
|
|
|
|
DisabledFeatures []string `json:"disabled_features,omitempty"`
|
|
|
|
|
|
FeatureETag string `json:"feature_etag,omitempty"`
|
2026-08-22 18:51:17 +02:00
|
|
|
|
// Per-member access overlay (settings > team). Set by the /me handler so the
|
|
|
|
|
|
// dashboard can say "ask your administrator" rather than "upgrade your plan".
|
|
|
|
|
|
MemberRestricted bool `json:"member_restricted,omitempty"`
|
|
|
|
|
|
MemberDeniedFeatures []string `json:"member_denied_features,omitempty"`
|
2026-08-09 22:47:43 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Service) CreditsOverview(ctx context.Context, companyID uuid.UUID, lowThreshold int) (CreditsOverview, error) {
|
|
|
|
|
|
if lowThreshold <= 0 {
|
|
|
|
|
|
lowThreshold = 100
|
|
|
|
|
|
}
|
|
|
|
|
|
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) {
|
|
|
|
|
|
_, _ = s.Pool.Exec(ctx, `INSERT INTO credit_balances (company_id) VALUES ($1) ON CONFLICT DO NOTHING`, companyID)
|
|
|
|
|
|
total, used = 0, 0
|
|
|
|
|
|
} else if err != nil {
|
|
|
|
|
|
return CreditsOverview{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
remaining := RemainingCreditsClamped(total, used)
|
|
|
|
|
|
out := CreditsOverview{
|
|
|
|
|
|
TotalCredits: total,
|
|
|
|
|
|
UsedCredits: used,
|
|
|
|
|
|
Remaining: remaining,
|
|
|
|
|
|
RemainingCredits: remaining,
|
|
|
|
|
|
LowCreditsThreshold: lowThreshold,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_ = s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT count(*) FROM processed_products WHERE company_id = $1`, companyID).Scan(&out.ProductCount)
|
|
|
|
|
|
|
|
|
|
|
|
var planName string
|
|
|
|
|
|
var monthlyVal int
|
|
|
|
|
|
var monthly, maxProducts *int
|
|
|
|
|
|
var isTrial, isCustom, isLegacy bool
|
|
|
|
|
|
var nextBilling *time.Time
|
|
|
|
|
|
var planNotes *string
|
|
|
|
|
|
var featuresRaw []byte
|
|
|
|
|
|
err = s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT p.name, p.monthly_credits, p.max_products, p.is_custom, COALESCE(p.is_legacy, false),
|
|
|
|
|
|
cp.is_trial, cp.next_billing_date, cp.notes, COALESCE(p.features, '{}'::jsonb)
|
|
|
|
|
|
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, &maxProducts, &isCustom, &isLegacy, &isTrial, &nextBilling, &planNotes, &featuresRaw)
|
|
|
|
|
|
if err != nil && isUndefinedColumn(err) {
|
|
|
|
|
|
err = s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT p.name, p.monthly_credits, p.max_products, p.is_custom,
|
|
|
|
|
|
cp.is_trial, cp.next_billing_date, cp.notes, COALESCE(p.features, '{}'::jsonb)
|
|
|
|
|
|
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, &maxProducts, &isCustom, &isTrial, &nextBilling, &planNotes, &featuresRaw)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
isLegacy = IsLegacyPlanName(planName)
|
|
|
|
|
|
} else if isUndefinedColumn(err) {
|
|
|
|
|
|
err = s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT p.name, p.monthly_credits, p.max_products, p.is_custom,
|
|
|
|
|
|
cp.is_trial, cp.next_billing_date, cp.notes
|
|
|
|
|
|
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, &maxProducts, &isCustom, &isTrial, &nextBilling, &planNotes)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
isLegacy = IsLegacyPlanName(planName)
|
|
|
|
|
|
featuresRaw = []byte("{}")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
out.HasActivePlan = true
|
|
|
|
|
|
out.MaxProducts = maxProducts
|
|
|
|
|
|
if maxProducts != nil && *maxProducts > 0 {
|
|
|
|
|
|
out.AtProductLimit = out.ProductCount >= *maxProducts
|
|
|
|
|
|
}
|
|
|
|
|
|
if monthly != nil {
|
|
|
|
|
|
monthlyVal = *monthly
|
|
|
|
|
|
}
|
|
|
|
|
|
out.Plan = map[string]any{
|
|
|
|
|
|
"name": planName,
|
|
|
|
|
|
"monthly_credits": monthlyVal,
|
|
|
|
|
|
"max_products": maxProducts,
|
|
|
|
|
|
"is_custom": isCustom,
|
|
|
|
|
|
"is_trial": isTrial,
|
|
|
|
|
|
"next_billing_date": nextBilling,
|
|
|
|
|
|
}
|
|
|
|
|
|
if status := ParseStripeStatusNote(planNotes); status != "" {
|
|
|
|
|
|
out.Plan["subscription_status"] = status
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Skipped/missing company_plans: gate like Free, but HasActivePlan stays false for UX recovery.
|
|
|
|
|
|
planName = "Free"
|
|
|
|
|
|
featuresRaw = []byte("{}")
|
|
|
|
|
|
}
|
|
|
|
|
|
ent := ComputeEntitlements(planName, monthlyVal, remaining, isTrial)
|
|
|
|
|
|
out.CanUseAI = ent.CanUseAI
|
|
|
|
|
|
out.CanUseEPREL = ent.CanUseEPREL
|
|
|
|
|
|
out.IsFreePlan = ent.IsFreePlan
|
|
|
|
|
|
out.IsPaidPlan = ent.IsPaidPlan
|
|
|
|
|
|
// Free (0 monthly credits) is not "low credits" — AI is simply unavailable.
|
|
|
|
|
|
out.LowCredits = ent.CanUseAI && remaining <= lowThreshold && remaining > 0
|
|
|
|
|
|
// Resolve features from the plan row already loaded (avoids a second CapabilitiesForCompany
|
|
|
|
|
|
// round-trip that re-queries company_plans + credit_balances). Still uses GetFeatureGates cache
|
|
|
|
|
|
// and plans.features JSON overrides via decodeFeaturesJSON + ResolveEffectiveFeaturesEx.
|
|
|
|
|
|
if gates, gerr := s.GetFeatureGates(ctx); gerr == nil {
|
|
|
|
|
|
if overrides, oerr := decodeFeaturesJSON(featuresRaw); oerr == nil {
|
|
|
|
|
|
feats, sections, disabled := ResolveEffectiveFeaturesEx(
|
|
|
|
|
|
planName, isCustom, IsLegacyPlan(planName, isLegacy), overrides, gates)
|
|
|
|
|
|
out.Features = feats
|
|
|
|
|
|
out.Sections = sections
|
|
|
|
|
|
out.DisabledFeatures = disabled
|
|
|
|
|
|
out.FeatureETag = featureETag(feats)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
|
ErrInsufficientCredits = errors.New("insufficient credits")
|
|
|
|
|
|
ErrProductLimitExceeded = errors.New("product limit exceeded")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// AIBrandApplyAllowed reports whether brand-kit voice may be injected into AI prompts.
|
|
|
|
|
|
// Free (and unknown/no plan) can edit the brand kit but AI apply is gated to paid plans
|
|
|
|
|
|
// and the capability.brand_ai_apply / marketing.brand_ai_apply feature keys.
|
|
|
|
|
|
func (s *Service) AIBrandApplyAllowed(ctx context.Context, companyID uuid.UUID) bool {
|
|
|
|
|
|
if s == nil || s.Pool == nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
ent, err := s.EntitlementsForCompany(ctx, companyID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if !(ent.IsPaidPlan || ent.IsTrial) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
ok, err := s.IsAllowed(ctx, companyID, "capability.brand_ai_apply")
|
|
|
|
|
|
if err != nil || !ok {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
ok, err = s.IsAllowed(ctx, companyID, "marketing.brand_ai_apply")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return ok
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ProcessingGateOpts controls credit checks for a processing job start.
|
|
|
|
|
|
type ProcessingGateOpts struct {
|
|
|
|
|
|
// RequiresAI when true enforces can_use_ai + credit wallet (AI-only job types).
|
|
|
|
|
|
RequiresAI bool
|
|
|
|
|
|
// RequiresEPREL when true enforces can_use_eprel (EPREL-only job types).
|
|
|
|
|
|
RequiresEPREL bool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// AssertCanStartProcessing enforces plan SKU caps always; credit wallet only when RequiresAI.
|
|
|
|
|
|
// Free-tier normalize/specs/fill jobs pass with 0 credits.
|
|
|
|
|
|
func (s *Service) AssertCanStartProcessing(ctx context.Context, companyID uuid.UUID, batchSize int, opts ProcessingGateOpts) error {
|
|
|
|
|
|
if batchSize <= 0 {
|
|
|
|
|
|
return errors.New("no products selected")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ent, err := s.EntitlementsForCompany(ctx, companyID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if opts.RequiresEPREL && !ent.CanUseEPREL {
|
|
|
|
|
|
return fmt.Errorf("%w — EPREL is included on every plan; check platform EPREL settings", ErrEPRELRequiresUpgrade)
|
|
|
|
|
|
}
|
|
|
|
|
|
if opts.RequiresAI {
|
|
|
|
|
|
if !ent.CanUseAI {
|
|
|
|
|
|
return fmt.Errorf("%w — upgrade your plan or add AI credits", ErrAIRequiresUpgrade)
|
|
|
|
|
|
}
|
|
|
|
|
|
if ent.RemainingCredits < 1 {
|
|
|
|
|
|
return fmt.Errorf("%w: no credits remaining — upgrade your plan to continue AI processing", ErrInsufficientCredits)
|
|
|
|
|
|
}
|
|
|
|
|
|
if ent.RemainingCredits < batchSize {
|
|
|
|
|
|
return fmt.Errorf("%w: need at least %d credits for this batch (have %d) — upgrade or select fewer products",
|
|
|
|
|
|
ErrInsufficientCredits, batchSize, ent.RemainingCredits)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var maxProducts *int
|
|
|
|
|
|
err = s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT p.max_products
|
|
|
|
|
|
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(&maxProducts)
|
|
|
|
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if maxProducts == nil || *maxProducts <= 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var productCount int
|
|
|
|
|
|
if err := s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT count(*) FROM processed_products WHERE company_id = $1`, companyID).Scan(&productCount); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
slotsLeft := *maxProducts - productCount
|
|
|
|
|
|
if slotsLeft <= 0 {
|
|
|
|
|
|
return fmt.Errorf("%w: plan allows up to %d products — upgrade to process more",
|
|
|
|
|
|
ErrProductLimitExceeded, *maxProducts)
|
|
|
|
|
|
}
|
|
|
|
|
|
if batchSize > slotsLeft {
|
|
|
|
|
|
return fmt.Errorf("%w: only %d product slots left on your plan (limit %d) — upgrade or select fewer products",
|
|
|
|
|
|
ErrProductLimitExceeded, slotsLeft, *maxProducts)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ConsumeCredits debits company credits for one processed product.
|
|
|
|
|
|
// tokenCount is LLM tokens used for this item (0 = flat product cost only).
|
|
|
|
|
|
// Debit = feature base + ceil(tokens/1000)*openai_token_k.
|
|
|
|
|
|
// Free / no-AI path (tokenCount==0 and !CanUseAI): no debit — normalize/specs/fill is free.
|
|
|
|
|
|
// The wallet UPDATE is atomic (WHERE remaining >= debit) so concurrent consumes cannot go negative.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Contends on the single credit_balances row per company (row lock until commit).
|
|
|
|
|
|
// Prefer this over batching when deliverables must not persist without a successful debit
|
|
|
|
|
|
// (pipeline processOne). Use ConsumeCreditsBatch only when the caller already holds
|
|
|
|
|
|
// results in memory and can discard them all on ErrInsufficientCredits.
|
|
|
|
|
|
func (s *Service) ConsumeCredits(ctx context.Context, companyID uuid.UUID, tokenCount int, featureName string) error {
|
|
|
|
|
|
return s.consumeCredits(ctx, nil, companyID, tokenCount, featureName, 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ConsumeCreditsTx applies the same debit as ConsumeCredits on an existing
|
|
|
|
|
|
// transaction so callers (processOne) can commit debit+persist atomically.
|
|
|
|
|
|
func (s *Service) ConsumeCreditsTx(ctx context.Context, tx pgx.Tx, companyID uuid.UUID, tokenCount int, featureName string) error {
|
|
|
|
|
|
if tx == nil {
|
|
|
|
|
|
return fmt.Errorf("ConsumeCreditsTx: nil tx")
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.consumeCredits(ctx, tx, companyID, tokenCount, featureName, 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ConsumeCreditsBatch applies one wallet/cycle update for productCount items and
|
|
|
|
|
|
// combined tokenCount. Reduces credit_balances lock acquisitions vs N×ConsumeCredits.
|
|
|
|
|
|
// Debit uses DebitAmountN (packs on combined tokens) — may undercharge vs summing
|
|
|
|
|
|
// per-item DebitAmount when token packs cross 1k boundaries; for exact parity sum
|
|
|
|
|
|
// DebitAmount per item and prefer N×ConsumeCredits (or a future debit-amount API).
|
|
|
|
|
|
func (s *Service) ConsumeCreditsBatch(ctx context.Context, companyID uuid.UUID, tokenCount, productCount int, featureName string) error {
|
|
|
|
|
|
if productCount < 1 {
|
|
|
|
|
|
productCount = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.consumeCredits(ctx, nil, companyID, tokenCount, featureName, productCount)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Service) consumeCredits(ctx context.Context, tx pgx.Tx, companyID uuid.UUID, tokenCount int, featureName string, productCount int) error {
|
|
|
|
|
|
if featureName == "" {
|
|
|
|
|
|
featureName = "product_processing"
|
|
|
|
|
|
}
|
|
|
|
|
|
if tokenCount < 0 {
|
|
|
|
|
|
tokenCount = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
if productCount < 1 {
|
|
|
|
|
|
productCount = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
// Entitlements gate only matters for flat (0-token) debits on Free — skip the
|
|
|
|
|
|
// two-query load on the AI hot path where tokenCount > 0.
|
|
|
|
|
|
if tokenCount == 0 {
|
|
|
|
|
|
ent, err := s.EntitlementsForCompany(ctx, companyID)
|
|
|
|
|
|
if err == nil && !ent.CanUseAI {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = s.EnsureDefaultCosts(ctx)
|
|
|
|
|
|
|
|
|
|
|
|
featureCost := s.lookupCost(ctx, featureName, 1)
|
|
|
|
|
|
tokenKCost := 1
|
|
|
|
|
|
if tokenCount > 0 {
|
|
|
|
|
|
tokenKCost = s.lookupCost(ctx, "openai_token_k", 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
var debit int
|
|
|
|
|
|
if productCount == 1 {
|
|
|
|
|
|
debit = DebitAmount(featureCost, tokenKCost, tokenCount)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
debit = DebitAmountN(featureCost, tokenKCost, tokenCount, productCount)
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.applyCreditDebit(ctx, tx, companyID, debit, productCount)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// applyCreditDebit atomically increments used_credits and open-cycle usage.
|
|
|
|
|
|
// Ensure-row + one CTE (balance + cycle) keeps the credit_balances row lock for two
|
|
|
|
|
|
// round-trips instead of an out-of-TX insert plus two separate UPDATEs.
|
|
|
|
|
|
// When tx is nil, begins and commits its own transaction; otherwise runs on tx
|
|
|
|
|
|
// without committing (caller owns the transaction).
|
|
|
|
|
|
func (s *Service) applyCreditDebit(ctx context.Context, tx pgx.Tx, companyID uuid.UUID, debit, productCount int) error {
|
|
|
|
|
|
if debit < 1 {
|
|
|
|
|
|
debit = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
if productCount < 1 {
|
|
|
|
|
|
productCount = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ownTx := tx == nil
|
|
|
|
|
|
if ownTx {
|
|
|
|
|
|
begun, err := s.Pool.Begin(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer begun.Rollback(ctx)
|
|
|
|
|
|
tx = begun
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Separate from the debit CTE: Postgres data-modifying CTEs share one snapshot
|
|
|
|
|
|
// and cannot see sibling INSERT effects in the same statement.
|
|
|
|
|
|
_, err := tx.Exec(ctx, `
|
|
|
|
|
|
INSERT INTO credit_balances (company_id) VALUES ($1)
|
|
|
|
|
|
ON CONFLICT (company_id) DO NOTHING`, companyID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var balRows, cycRows int
|
|
|
|
|
|
err = tx.QueryRow(ctx, `
|
|
|
|
|
|
WITH upd AS (
|
|
|
|
|
|
UPDATE credit_balances
|
|
|
|
|
|
SET used_credits = used_credits + $2, updated_at = now()
|
|
|
|
|
|
WHERE company_id = $1 AND (total_credits - used_credits) >= $2
|
|
|
|
|
|
RETURNING company_id
|
|
|
|
|
|
),
|
|
|
|
|
|
cyc AS (
|
|
|
|
|
|
UPDATE billing_cycles
|
|
|
|
|
|
SET credits_used = credits_used + $2,
|
|
|
|
|
|
products_processed = products_processed + $3,
|
|
|
|
|
|
updated_at = now()
|
|
|
|
|
|
WHERE id = (
|
|
|
|
|
|
SELECT id FROM billing_cycles
|
|
|
|
|
|
WHERE company_id = $1 AND end_date > now()
|
|
|
|
|
|
ORDER BY start_date DESC LIMIT 1
|
|
|
|
|
|
)
|
|
|
|
|
|
AND EXISTS (SELECT 1 FROM upd)
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
)
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
(SELECT count(*)::int FROM upd),
|
|
|
|
|
|
(SELECT count(*)::int FROM cyc)`, companyID, debit, productCount).
|
|
|
|
|
|
Scan(&balRows, &cycRows)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if balRows == 0 {
|
|
|
|
|
|
return ErrInsufficientCredits
|
|
|
|
|
|
}
|
|
|
|
|
|
if cycRows == 0 {
|
|
|
|
|
|
if err := s.ensureOpenBillingCycle(ctx, tx, companyID, debit, productCount); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if ownTx {
|
|
|
|
|
|
return tx.Commit(ctx)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ensureOpenBillingCycle opens a cycle from the active company_plan window when missing.
|
|
|
|
|
|
// When poolTx is nil, uses the service pool. initialUsed/products seed the new row (usually the debit just applied).
|
|
|
|
|
|
func (s *Service) ensureOpenBillingCycle(ctx context.Context, tx pgx.Tx, companyID uuid.UUID, initialUsed, products int) error {
|
|
|
|
|
|
exec := s.Pool.Exec
|
|
|
|
|
|
queryRow := s.Pool.QueryRow
|
|
|
|
|
|
if tx != nil {
|
|
|
|
|
|
exec = tx.Exec
|
|
|
|
|
|
queryRow = tx.QueryRow
|
|
|
|
|
|
}
|
|
|
|
|
|
var start, end time.Time
|
|
|
|
|
|
err := queryRow(ctx, `
|
|
|
|
|
|
SELECT billing_cycle_start, next_billing_date
|
|
|
|
|
|
FROM company_plans
|
|
|
|
|
|
WHERE company_id = $1 AND is_active = true
|
|
|
|
|
|
ORDER BY created_at DESC LIMIT 1`, companyID).Scan(&start, &end)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
now := time.Now().UTC()
|
|
|
|
|
|
start, end = now, now.AddDate(0, 1, 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
_, err = exec(ctx, `
|
|
|
|
|
|
INSERT INTO billing_cycles (company_id, start_date, end_date, credits_used, products_processed)
|
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5)`, companyID, start, end, initialUsed, products)
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Service) lookupCost(ctx context.Context, feature string, fallback int) int {
|
|
|
|
|
|
s.costMu.RLock()
|
|
|
|
|
|
if s.costCache != nil {
|
|
|
|
|
|
if cost, ok := s.costCache[feature]; ok {
|
|
|
|
|
|
s.costMu.RUnlock()
|
|
|
|
|
|
if cost <= 0 {
|
|
|
|
|
|
return fallback
|
|
|
|
|
|
}
|
|
|
|
|
|
return cost
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
s.costMu.RUnlock()
|
|
|
|
|
|
|
|
|
|
|
|
var cost int
|
|
|
|
|
|
err := s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT cost_per_unit FROM processing_costs
|
|
|
|
|
|
WHERE feature_name = $1 AND is_active = true`, feature).Scan(&cost)
|
|
|
|
|
|
if err != nil || cost <= 0 {
|
|
|
|
|
|
return fallback
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
s.costMu.Lock()
|
|
|
|
|
|
if s.costCache == nil {
|
|
|
|
|
|
s.costCache = make(map[string]int, 8)
|
|
|
|
|
|
}
|
|
|
|
|
|
s.costCache[feature] = cost
|
|
|
|
|
|
s.costMu.Unlock()
|
|
|
|
|
|
return cost
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// EnsureDefaultCosts seeds plan-aligned processing cost rows (idempotent).
|
|
|
|
|
|
// After a successful seed in this process, subsequent calls no-op (worker seeds at boot).
|
|
|
|
|
|
func (s *Service) EnsureDefaultCosts(ctx context.Context) error {
|
|
|
|
|
|
if s.costsSeeded.Load() {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
_, err := s.Pool.Exec(ctx, `
|
|
|
|
|
|
INSERT INTO processing_costs (feature_name, cost_per_unit, description, is_active)
|
|
|
|
|
|
VALUES
|
|
|
|
|
|
('product_processing', 1, 'Credits per processed product (base)', true),
|
|
|
|
|
|
('openai_token_k', 1, 'Credits per 1000 LLM tokens', true),
|
|
|
|
|
|
('seo_meta_ai', 1, 'Credits per SEO AI meta apply (base)', true),
|
|
|
|
|
|
('campaign_copy', 1, 'Credits per campaign AI generate (base)', true)
|
|
|
|
|
|
ON CONFLICT (feature_name) DO NOTHING`)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
s.costsSeeded.Store(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// EstimateDebit returns the credit cost for a feature + token pack (same math as ConsumeCredits).
|
|
|
|
|
|
func (s *Service) EstimateDebit(ctx context.Context, featureName string, tokenCount int) int {
|
|
|
|
|
|
if featureName == "" {
|
|
|
|
|
|
featureName = "product_processing"
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = s.EnsureDefaultCosts(ctx)
|
|
|
|
|
|
featureCost := s.lookupCost(ctx, featureName, 1)
|
|
|
|
|
|
tokenKCost := 1
|
|
|
|
|
|
if tokenCount > 0 {
|
|
|
|
|
|
tokenKCost = s.lookupCost(ctx, "openai_token_k", 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
return DebitAmount(featureCost, tokenKCost, tokenCount)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type Plan struct {
|
|
|
|
|
|
ID int64 `json:"id"`
|
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
|
Description *string `json:"description,omitempty"`
|
|
|
|
|
|
MonthlyCredits int `json:"monthly_credits"`
|
|
|
|
|
|
YearlyCredits *int `json:"yearly_credits,omitempty"`
|
|
|
|
|
|
MaxProducts *int `json:"max_products,omitempty"`
|
|
|
|
|
|
IsCustom bool `json:"is_custom"`
|
|
|
|
|
|
IsLegacy bool `json:"is_legacy,omitempty"`
|
|
|
|
|
|
Term string `json:"term"`
|
|
|
|
|
|
Features map[string]bool `json:"features,omitempty"`
|
|
|
|
|
|
// ResolvedFeatures is plan_allows only (globals ignored); populated on admin list/get.
|
|
|
|
|
|
ResolvedFeatures map[string]bool `json:"resolved_features,omitempty"`
|
|
|
|
|
|
// AiCoverPercent is derived from PlanAICoverPercent (not a DB column).
|
|
|
|
|
|
AiCoverPercent int `json:"ai_cover_percent,omitempty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// EnterpriseUnlimitedCredits is the managed AI credit pack for the public Enterprise plan.
|
|
|
|
|
|
// Marketing copy says "Unlimited"; the wallet still uses a finite high grant so debit accounting works.
|
|
|
|
|
|
// Demo seed assigns this plan to Platform Demo.
|
|
|
|
|
|
const EnterpriseUnlimitedCredits = 1_000_000
|
|
|
|
|
|
|
|
|
|
|
|
// defaultPublicPlans is the self-serve ladder (PRICING doc packaging).
|
|
|
|
|
|
// Free intentionally grants 0 AI credits — normalize/specs/fill only until upgrade.
|
|
|
|
|
|
// Client-specific deals (A1, Merkur trial, etc.) must never appear here or on public pricing.
|
|
|
|
|
|
func defaultPublicPlans() []Plan {
|
|
|
|
|
|
freeDesc := "Forever free — map a sample feed, normalize & fill specs (no AI credits)"
|
|
|
|
|
|
starterDesc := "Up to 100 SKUs, entry AI (~100 credits / ~50% cover), full WooCommerce sync"
|
|
|
|
|
|
plusDesc := "Up to 400 SKUs, Woo+Shopify, AI (~400 credits / ~50% of credit base)"
|
|
|
|
|
|
growthDesc := "Up to 1,200 SKUs, full stores, BYOK, AI (~1,200 credits / ~50% of credit base)"
|
|
|
|
|
|
businessDesc := "Up to 4,000 SKUs, BYOK, AI (~4,000 credits / ~50% of credit base)"
|
|
|
|
|
|
scaleDesc := "Up to 12,000 SKUs, AI (~12,000 credits / ~50% cover); packs/BYOK for more"
|
|
|
|
|
|
enterpriseDesc := "Unlimited SKUs & feeds — large managed AI grant or BYOK, SLA, account team"
|
|
|
|
|
|
return []Plan{
|
|
|
|
|
|
{Name: "Free", Description: &freeDesc, MonthlyCredits: 0, MaxProducts: PlanMaxProducts("Free"), Term: "monthly"},
|
|
|
|
|
|
{Name: "Starter", Description: &starterDesc, MonthlyCredits: MonthlyCreditsForPlan("Starter", 0), MaxProducts: PlanMaxProducts("Starter"), Term: "monthly"},
|
|
|
|
|
|
{Name: "Plus", Description: &plusDesc, MonthlyCredits: MonthlyCreditsForPlan("Plus", 0), MaxProducts: PlanMaxProducts("Plus"), Term: "monthly"},
|
|
|
|
|
|
{Name: "Growth", Description: &growthDesc, MonthlyCredits: MonthlyCreditsForPlan("Growth", 0), MaxProducts: PlanMaxProducts("Growth"), Term: "monthly"},
|
|
|
|
|
|
{Name: "Business", Description: &businessDesc, MonthlyCredits: MonthlyCreditsForPlan("Business", 0), MaxProducts: PlanMaxProducts("Business"), Term: "monthly"},
|
|
|
|
|
|
{Name: "Scale", Description: &scaleDesc, MonthlyCredits: MonthlyCreditsForPlan("Scale", 0), MaxProducts: PlanMaxProducts("Scale"), Term: "monthly"},
|
|
|
|
|
|
// MaxProducts nil = unlimited SKU cap in AssertCanStartProcessing.
|
|
|
|
|
|
{Name: "Enterprise", Description: &enterpriseDesc, MonthlyCredits: EnterpriseUnlimitedCredits, MaxProducts: PlanMaxProducts("Enterprise"), IsCustom: true, Term: "monthly"},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsPublicProductPlan reports whether name is on the public marketing ladder.
|
|
|
|
|
|
// Client deals (A1, Merkur trial, legacy Basic/Professional, …) return false.
|
|
|
|
|
|
func IsPublicProductPlan(name string) bool {
|
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
|
|
|
|
|
case "free", "starter", "plus", "growth", "business", "scale", "enterprise":
|
|
|
|
|
|
return true
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// EnsureDefaultPlans upserts Free / Starter / Plus / Growth / Business / Scale / Enterprise by name.
|
|
|
|
|
|
// Aligns with PRICING packaging; Free monthly_credits = 0 (no AI grant on signup).
|
|
|
|
|
|
func (s *Service) EnsureDefaultPlans(ctx context.Context) error {
|
|
|
|
|
|
for _, p := range defaultPublicPlans() {
|
|
|
|
|
|
var id int64
|
|
|
|
|
|
err := s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT id FROM plans WHERE lower(name) = lower($1) ORDER BY id LIMIT 1`, p.Name).Scan(&id)
|
|
|
|
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
|
|
|
|
_, err = s.Pool.Exec(ctx, `
|
|
|
|
|
|
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term)
|
|
|
|
|
|
VALUES ($1, $2, $3, NULL, $4, $5, $6)`,
|
|
|
|
|
|
p.Name, p.Description, p.MonthlyCredits, p.MaxProducts, p.IsCustom, p.Term)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
// Sync public ladder meters only — never clobber plans.features overrides.
|
|
|
|
|
|
// Named client deals (A1, Merkur, …) are not in defaultPublicPlans and stay untouched.
|
|
|
|
|
|
_, err = s.Pool.Exec(ctx, `
|
|
|
|
|
|
UPDATE plans SET description = $2, monthly_credits = $3, max_products = $4,
|
|
|
|
|
|
is_custom = $5, term = $6, updated_at = now()
|
|
|
|
|
|
WHERE id = $1`, id, p.Description, p.MonthlyCredits, p.MaxProducts, p.IsCustom, p.Term)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Global section gates default ON; empty plan.features stay unset (DefaultPlanFeatures).
|
|
|
|
|
|
// Then Legacy plan row + A1 cohort assignment + sparse legacy feature backfill.
|
|
|
|
|
|
if err := s.EnsureDefaultFeatureSeeds(ctx); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-08-24 04:31:49 +02:00
|
|
|
|
if err := s.EnsureSuperAdminPlan(ctx); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-08-09 22:47:43 +02:00
|
|
|
|
if err := s.EnsurePlanCatalogHygiene(ctx); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.EnsureLegacyDefaults(ctx)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ProvisionFreePlan assigns the Free plan (0 AI credits) to a new company.
|
|
|
|
|
|
// Best-effort: registration must not fail if Free is missing.
|
|
|
|
|
|
// Never falls back to another plan (Enterprise may have a lower id after seed-demo).
|
|
|
|
|
|
func (s *Service) ProvisionFreePlan(ctx context.Context, companyID uuid.UUID) error {
|
|
|
|
|
|
if err := s.EnsureDefaultPlans(ctx); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
var planID int64
|
|
|
|
|
|
err := s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT id FROM plans WHERE lower(name) = 'free' ORDER BY id LIMIT 1`).Scan(&planID)
|
|
|
|
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.AssignPlan(ctx, companyID, planID, false, 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Service) ListPlans(ctx context.Context) ([]Plan, error) {
|
|
|
|
|
|
rows, err := s.Pool.Query(ctx, `
|
|
|
|
|
|
SELECT id, name, description, monthly_credits, yearly_credits, max_products, is_custom, term,
|
|
|
|
|
|
COALESCE(features, '{}'::jsonb)
|
|
|
|
|
|
FROM plans ORDER BY id`)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if isUndefinedColumn(err) {
|
|
|
|
|
|
return s.listPlansWithoutFeatures(ctx)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
out := make([]Plan, 0)
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
|
var p Plan
|
|
|
|
|
|
var raw []byte
|
|
|
|
|
|
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.MonthlyCredits, &p.YearlyCredits, &p.MaxProducts, &p.IsCustom, &p.Term, &raw); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
overrides, derr := decodeFeaturesJSON(raw)
|
|
|
|
|
|
if derr != nil {
|
|
|
|
|
|
return nil, derr
|
|
|
|
|
|
}
|
|
|
|
|
|
p.Features = overrides
|
|
|
|
|
|
p.IsLegacy = IsLegacyPlanName(p.Name)
|
|
|
|
|
|
p.ResolvedFeatures = planFeaturesView(p.ID, p.Name, p.IsCustom, p.IsLegacy, overrides).ResolvedFeatures
|
|
|
|
|
|
out = append(out, p)
|
|
|
|
|
|
}
|
|
|
|
|
|
return out, rows.Err()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Service) listPlansWithoutFeatures(ctx context.Context) ([]Plan, error) {
|
|
|
|
|
|
rows, err := s.Pool.Query(ctx, `
|
|
|
|
|
|
SELECT id, name, description, monthly_credits, yearly_credits, max_products, is_custom, term
|
|
|
|
|
|
FROM plans ORDER BY id`)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
out := make([]Plan, 0)
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
|
var p Plan
|
|
|
|
|
|
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.MonthlyCredits, &p.YearlyCredits, &p.MaxProducts, &p.IsCustom, &p.Term); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
p.Features = map[string]bool{}
|
|
|
|
|
|
p.IsLegacy = IsLegacyPlanName(p.Name)
|
|
|
|
|
|
p.ResolvedFeatures = planFeaturesView(p.ID, p.Name, p.IsCustom, p.IsLegacy, nil).ResolvedFeatures
|
|
|
|
|
|
out = append(out, p)
|
|
|
|
|
|
}
|
|
|
|
|
|
return out, rows.Err()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ListPublicPlans returns only Free / Starter / Plus / Growth / Business / Scale / Enterprise.
|
|
|
|
|
|
// Keeps client-specific plans (A1, Merkur trial, …) assignable via admin ListPlans.
|
|
|
|
|
|
func (s *Service) ListPublicPlans(ctx context.Context) ([]Plan, error) {
|
|
|
|
|
|
all, err := s.ListPlans(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
order := map[string]int{
|
|
|
|
|
|
"free": 0, "starter": 1, "plus": 2, "growth": 3, "business": 4, "scale": 5, "enterprise": 6,
|
|
|
|
|
|
}
|
|
|
|
|
|
out := make([]Plan, 0, 7)
|
|
|
|
|
|
for _, p := range all {
|
|
|
|
|
|
if !IsPublicProductPlan(p.Name) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
p.AiCoverPercent = PlanAICoverPercent(p.Name)
|
|
|
|
|
|
out = append(out, p)
|
|
|
|
|
|
}
|
|
|
|
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
|
|
|
|
return order[strings.ToLower(out[i].Name)] < order[strings.ToLower(out[j].Name)]
|
|
|
|
|
|
})
|
|
|
|
|
|
return out, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Service) UpsertPlan(ctx context.Context, p Plan) (Plan, error) {
|
|
|
|
|
|
p.Name = strings.TrimSpace(p.Name)
|
|
|
|
|
|
if p.Name == "" {
|
|
|
|
|
|
return Plan{}, ErrPlanNameRequired
|
|
|
|
|
|
}
|
|
|
|
|
|
if p.Term == "" {
|
|
|
|
|
|
p.Term = "monthly"
|
|
|
|
|
|
}
|
|
|
|
|
|
creating := p.ID == 0
|
|
|
|
|
|
featuresProvided := p.Features != nil
|
|
|
|
|
|
prepareCustomPackageCreateFeatures(&p, creating, featuresProvided)
|
|
|
|
|
|
if IsLegacyPlan(p.Name, p.IsLegacy) {
|
|
|
|
|
|
p.IsLegacy = true
|
|
|
|
|
|
}
|
|
|
|
|
|
if p.Features != nil {
|
|
|
|
|
|
if err := validateFeatureOverrides(p.Features); err != nil {
|
|
|
|
|
|
return Plan{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
featuresJSON, err := encodeFeaturesJSON(p.Features)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return Plan{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if p.ID > 0 {
|
|
|
|
|
|
if p.Features != nil {
|
|
|
|
|
|
_, err = s.Pool.Exec(ctx, `
|
|
|
|
|
|
UPDATE plans SET name=$2, description=$3, monthly_credits=$4, yearly_credits=$5,
|
|
|
|
|
|
max_products=$6, is_custom=$7, term=$8, features=$9::jsonb, is_legacy=$10, updated_at=now()
|
|
|
|
|
|
WHERE id=$1`, p.ID, p.Name, p.Description, p.MonthlyCredits, p.YearlyCredits, p.MaxProducts, p.IsCustom, p.Term, featuresJSON, p.IsLegacy)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_, err = s.Pool.Exec(ctx, `
|
|
|
|
|
|
UPDATE plans SET name=$2, description=$3, monthly_credits=$4, yearly_credits=$5,
|
|
|
|
|
|
max_products=$6, is_custom=$7, term=$8, is_legacy=$9, updated_at=now()
|
|
|
|
|
|
WHERE id=$1`, p.ID, p.Name, p.Description, p.MonthlyCredits, p.YearlyCredits, p.MaxProducts, p.IsCustom, p.Term, p.IsLegacy)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if isUndefinedColumn(err) {
|
|
|
|
|
|
// Pre-is_legacy migration: fall back without the column.
|
|
|
|
|
|
if p.Features != nil {
|
|
|
|
|
|
_, err = s.Pool.Exec(ctx, `
|
|
|
|
|
|
UPDATE plans SET name=$2, description=$3, monthly_credits=$4, yearly_credits=$5,
|
|
|
|
|
|
max_products=$6, is_custom=$7, term=$8, features=$9::jsonb, updated_at=now()
|
|
|
|
|
|
WHERE id=$1`, p.ID, p.Name, p.Description, p.MonthlyCredits, p.YearlyCredits, p.MaxProducts, p.IsCustom, p.Term, featuresJSON)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_, err = s.Pool.Exec(ctx, `
|
|
|
|
|
|
UPDATE plans SET name=$2, description=$3, monthly_credits=$4, yearly_credits=$5,
|
|
|
|
|
|
max_products=$6, is_custom=$7, term=$8, updated_at=now()
|
|
|
|
|
|
WHERE id=$1`, p.ID, p.Name, p.Description, p.MonthlyCredits, p.YearlyCredits, p.MaxProducts, p.IsCustom, p.Term)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil && p.Features != nil && isUndefinedColumn(err) {
|
|
|
|
|
|
return Plan{}, errors.New("plans.features column missing — run migration 026_plan_features")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return Plan{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if p.Features != nil {
|
|
|
|
|
|
err = s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term, features, is_legacy)
|
|
|
|
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9) RETURNING id`,
|
|
|
|
|
|
p.Name, p.Description, p.MonthlyCredits, p.YearlyCredits, p.MaxProducts, p.IsCustom, p.Term, featuresJSON, p.IsLegacy,
|
|
|
|
|
|
).Scan(&p.ID)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
err = s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term, is_legacy)
|
|
|
|
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
|
|
|
|
|
|
p.Name, p.Description, p.MonthlyCredits, p.YearlyCredits, p.MaxProducts, p.IsCustom, p.Term, p.IsLegacy,
|
|
|
|
|
|
).Scan(&p.ID)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if isUndefinedColumn(err) {
|
|
|
|
|
|
if p.Features != nil {
|
|
|
|
|
|
err = s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term, features)
|
|
|
|
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb) RETURNING id`,
|
|
|
|
|
|
p.Name, p.Description, p.MonthlyCredits, p.YearlyCredits, p.MaxProducts, p.IsCustom, p.Term, featuresJSON,
|
|
|
|
|
|
).Scan(&p.ID)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
err = s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term)
|
|
|
|
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
|
|
|
|
|
|
p.Name, p.Description, p.MonthlyCredits, p.YearlyCredits, p.MaxProducts, p.IsCustom, p.Term,
|
|
|
|
|
|
).Scan(&p.ID)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil && p.Features != nil && isUndefinedColumn(err) {
|
|
|
|
|
|
return Plan{}, errors.New("plans.features column missing — run migration 026_plan_features")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return Plan{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
view, gerr := s.GetPlanFeatures(ctx, p.ID)
|
|
|
|
|
|
if gerr == nil {
|
|
|
|
|
|
p.Features = view.Features
|
|
|
|
|
|
p.ResolvedFeatures = view.ResolvedFeatures
|
|
|
|
|
|
p.IsCustom = view.IsCustom
|
|
|
|
|
|
p.IsLegacy = view.IsLegacy
|
|
|
|
|
|
p.Name = view.PlanName
|
|
|
|
|
|
}
|
|
|
|
|
|
return p, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Service) AssignPlan(ctx context.Context, companyID uuid.UUID, planID int64, isTrial bool, trialCredits int) error {
|
|
|
|
|
|
var monthly int
|
|
|
|
|
|
err := s.Pool.QueryRow(ctx, `SELECT monthly_credits FROM plans WHERE id = $1`, planID).Scan(&monthly)
|
|
|
|
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
|
|
|
|
return ErrPlanNotFound
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
tx, err := s.Pool.Begin(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer tx.Rollback(ctx)
|
|
|
|
|
|
|
|
|
|
|
|
_, err = tx.Exec(ctx, `UPDATE company_plans SET is_active = false, updated_at = now() WHERE company_id = $1 AND is_active = true`, companyID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
now := time.Now().UTC()
|
|
|
|
|
|
next := now.AddDate(0, 1, 0)
|
|
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
|
|
|
|
INSERT INTO company_plans (company_id, plan_id, is_active, billing_cycle_start, next_billing_date, is_trial, trial_credits)
|
|
|
|
|
|
VALUES ($1,$2,true,$3,$4,$5,$6)`, companyID, planID, now, next, isTrial, trialCredits)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
alloc := monthly
|
|
|
|
|
|
if isTrial && trialCredits > 0 {
|
|
|
|
|
|
alloc = trialCredits
|
|
|
|
|
|
}
|
|
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
|
|
|
|
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
|
|
|
|
|
|
VALUES ($1, $2, 0, now())
|
|
|
|
|
|
ON CONFLICT (company_id) DO UPDATE SET total_credits = EXCLUDED.total_credits, used_credits = 0, updated_at = now()`,
|
|
|
|
|
|
companyID, alloc)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
// Close any still-open cycles so UsageSummary / ConsumeCredits never read stale ended rows as "current".
|
|
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
|
|
|
|
UPDATE billing_cycles SET end_date = $2, updated_at = now()
|
|
|
|
|
|
WHERE company_id = $1 AND end_date > $2`, companyID, now)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
|
|
|
|
INSERT INTO billing_cycles (company_id, start_date, end_date, credits_used, products_processed)
|
|
|
|
|
|
VALUES ($1, $2, $3, 0, 0)`, companyID, now, next)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
return tx.Commit(ctx)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Service) AddCredits(ctx context.Context, companyID uuid.UUID, amount int) error {
|
|
|
|
|
|
if amount == 0 {
|
|
|
|
|
|
return ErrAmountRequired
|
|
|
|
|
|
}
|
|
|
|
|
|
_, _ = s.Pool.Exec(ctx, `INSERT INTO credit_balances (company_id) VALUES ($1) ON CONFLICT DO NOTHING`, companyID)
|
|
|
|
|
|
// Clawbacks (negative amount) must not push total below used_credits (no negative remaining).
|
|
|
|
|
|
_, err := s.Pool.Exec(ctx, `
|
|
|
|
|
|
UPDATE credit_balances
|
|
|
|
|
|
SET total_credits = GREATEST(used_credits, GREATEST(0, total_credits + $2)), updated_at = now()
|
|
|
|
|
|
WHERE company_id = $1`, companyID, amount)
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// UsageDayPoint is one UTC day of company product/token activity.
|
|
|
|
|
|
type UsageDayPoint struct {
|
|
|
|
|
|
Date string `json:"date"`
|
|
|
|
|
|
Products int64 `json:"products"`
|
|
|
|
|
|
Tokens int64 `json:"tokens"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// UsageSummary is company usage for the billing UI.
|
|
|
|
|
|
// Credits always come from live credit_balances (not stale billing_cycles rows).
|
|
|
|
|
|
// Products/tokens respect Range; cycle dates come from the active company_plans row.
|
|
|
|
|
|
type UsageSummary struct {
|
|
|
|
|
|
CompanyID uuid.UUID `json:"company_id"`
|
|
|
|
|
|
Range string `json:"range"`
|
|
|
|
|
|
CreditsUsed int `json:"credits_used"`
|
|
|
|
|
|
CreditsTotal int `json:"credits_total"`
|
|
|
|
|
|
CreditsRemaining int `json:"credits_remaining"`
|
|
|
|
|
|
ProductsProcessed int `json:"products_processed"`
|
|
|
|
|
|
ProductsTotal int `json:"products_total"`
|
|
|
|
|
|
Tokens int64 `json:"tokens"`
|
|
|
|
|
|
FeedsInput int `json:"feeds_input"`
|
|
|
|
|
|
FeedsExport int `json:"feeds_export"`
|
|
|
|
|
|
JobsTotal int `json:"jobs_total"`
|
|
|
|
|
|
CycleStart *time.Time `json:"cycle_start,omitempty"`
|
|
|
|
|
|
CycleEnd *time.Time `json:"cycle_end,omitempty"`
|
|
|
|
|
|
Series []UsageDayPoint `json:"series,omitempty"`
|
|
|
|
|
|
Notes []string `json:"notes,omitempty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ParseUsageRange normalizes billing usage range query values.
|
|
|
|
|
|
func ParseUsageRange(raw string) string {
|
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
|
|
|
|
case "7d", "30d", "cycle", "all":
|
|
|
|
|
|
return strings.ToLower(strings.TrimSpace(raw))
|
|
|
|
|
|
default:
|
|
|
|
|
|
return "30d"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Service) UsageSummary(ctx context.Context, companyID uuid.UUID, rangeRaw string) (UsageSummary, error) {
|
|
|
|
|
|
rangeKey := ParseUsageRange(rangeRaw)
|
|
|
|
|
|
out := UsageSummary{
|
|
|
|
|
|
CompanyID: companyID,
|
|
|
|
|
|
Range: rangeKey,
|
|
|
|
|
|
Notes: make([]string, 0, 3),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 out, err
|
|
|
|
|
|
}
|
|
|
|
|
|
out.CreditsTotal = total
|
|
|
|
|
|
out.CreditsUsed = used
|
|
|
|
|
|
out.CreditsRemaining = RemainingCreditsClamped(total, used)
|
|
|
|
|
|
out.Notes = append(out.Notes,
|
|
|
|
|
|
"Credits are the live wallet (credit_balances). Daily credit history is not ledgered yet — range filters apply to products and tokens only.")
|
|
|
|
|
|
|
|
|
|
|
|
var cycleStart, cycleEnd *time.Time
|
|
|
|
|
|
_ = s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT cp.billing_cycle_start, cp.next_billing_date
|
|
|
|
|
|
FROM company_plans cp
|
|
|
|
|
|
WHERE cp.company_id = $1 AND cp.is_active = true
|
|
|
|
|
|
ORDER BY cp.created_at DESC LIMIT 1`, companyID).Scan(&cycleStart, &cycleEnd)
|
|
|
|
|
|
out.CycleStart = cycleStart
|
|
|
|
|
|
out.CycleEnd = cycleEnd
|
|
|
|
|
|
|
|
|
|
|
|
now := time.Now().UTC()
|
|
|
|
|
|
var since *time.Time
|
|
|
|
|
|
var until *time.Time
|
|
|
|
|
|
switch rangeKey {
|
|
|
|
|
|
case "7d":
|
|
|
|
|
|
t := now.Truncate(24*time.Hour).AddDate(0, 0, -6)
|
|
|
|
|
|
since = &t
|
|
|
|
|
|
case "30d":
|
|
|
|
|
|
t := now.Truncate(24*time.Hour).AddDate(0, 0, -29)
|
|
|
|
|
|
since = &t
|
|
|
|
|
|
case "cycle":
|
|
|
|
|
|
if cycleStart != nil {
|
|
|
|
|
|
since = cycleStart
|
|
|
|
|
|
}
|
|
|
|
|
|
if cycleEnd != nil {
|
|
|
|
|
|
until = cycleEnd
|
|
|
|
|
|
}
|
|
|
|
|
|
if since == nil {
|
|
|
|
|
|
out.Notes = append(out.Notes, "No active billing cycle on company_plans — showing all-time products/tokens.")
|
|
|
|
|
|
rangeKey = "all"
|
|
|
|
|
|
out.Range = "all"
|
|
|
|
|
|
}
|
|
|
|
|
|
case "all":
|
|
|
|
|
|
// no time filter
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_ = s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT COUNT(*)::int FROM input_feeds WHERE company_id = $1`, companyID).Scan(&out.FeedsInput)
|
|
|
|
|
|
_ = s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT COUNT(*)::int FROM export_feeds WHERE company_id = $1`, companyID).Scan(&out.FeedsExport)
|
|
|
|
|
|
_ = s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT COUNT(*)::int FROM processing_jobs WHERE company_id = $1`, companyID).Scan(&out.JobsTotal)
|
|
|
|
|
|
|
|
|
|
|
|
// One scan of processed_products: all-time total plus optional range stats via FILTER.
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case since != nil && until != nil:
|
|
|
|
|
|
_ = s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT COUNT(*)::int,
|
|
|
|
|
|
COUNT(*) FILTER (WHERE created_at >= $2 AND created_at < $3)::int,
|
|
|
|
|
|
COALESCE(SUM(COALESCE(total_tokens, 0)) FILTER (WHERE created_at >= $2 AND created_at < $3), 0)::bigint
|
|
|
|
|
|
FROM processed_products
|
|
|
|
|
|
WHERE company_id = $1`,
|
|
|
|
|
|
companyID, *since, *until).Scan(&out.ProductsTotal, &out.ProductsProcessed, &out.Tokens)
|
|
|
|
|
|
case since != nil:
|
|
|
|
|
|
_ = s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT COUNT(*)::int,
|
|
|
|
|
|
COUNT(*) FILTER (WHERE created_at >= $2)::int,
|
|
|
|
|
|
COALESCE(SUM(COALESCE(total_tokens, 0)) FILTER (WHERE created_at >= $2), 0)::bigint
|
|
|
|
|
|
FROM processed_products
|
|
|
|
|
|
WHERE company_id = $1`,
|
|
|
|
|
|
companyID, *since).Scan(&out.ProductsTotal, &out.ProductsProcessed, &out.Tokens)
|
|
|
|
|
|
default:
|
|
|
|
|
|
_ = s.Pool.QueryRow(ctx, `
|
|
|
|
|
|
SELECT COUNT(*)::int, COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint
|
|
|
|
|
|
FROM processed_products
|
|
|
|
|
|
WHERE company_id = $1`, companyID).Scan(&out.ProductsTotal, &out.Tokens)
|
|
|
|
|
|
out.ProductsProcessed = out.ProductsTotal
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if rangeKey == "7d" || rangeKey == "30d" || (rangeKey == "cycle" && since != nil) {
|
|
|
|
|
|
seriesSince := now.Truncate(24*time.Hour).AddDate(0, 0, -29)
|
|
|
|
|
|
days := 30
|
|
|
|
|
|
if rangeKey == "7d" {
|
|
|
|
|
|
seriesSince = now.Truncate(24*time.Hour).AddDate(0, 0, -6)
|
|
|
|
|
|
days = 7
|
|
|
|
|
|
} else if rangeKey == "cycle" && since != nil {
|
|
|
|
|
|
seriesSince = since.UTC().Truncate(24 * time.Hour)
|
|
|
|
|
|
end := now.UTC().Truncate(24 * time.Hour)
|
|
|
|
|
|
if until != nil && until.Before(end) {
|
|
|
|
|
|
end = until.UTC().Truncate(24 * time.Hour)
|
|
|
|
|
|
}
|
|
|
|
|
|
days = int(end.Sub(seriesSince).Hours()/24) + 1
|
|
|
|
|
|
if days < 1 {
|
|
|
|
|
|
days = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
if days > 90 {
|
|
|
|
|
|
days = 90
|
|
|
|
|
|
seriesSince = end.AddDate(0, 0, -(days - 1))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
out.Series = s.usageDaySeries(ctx, companyID, seriesSince, days)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return out, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *Service) usageDaySeries(ctx context.Context, companyID uuid.UUID, since time.Time, days int) []UsageDayPoint {
|
|
|
|
|
|
byDay := map[string]UsageDayPoint{}
|
|
|
|
|
|
rows, err := s.Pool.Query(ctx, `
|
|
|
|
|
|
SELECT (created_at AT TIME ZONE 'UTC')::date AS d,
|
|
|
|
|
|
COUNT(*)::bigint,
|
|
|
|
|
|
COALESCE(SUM(COALESCE(total_tokens, 0)), 0)::bigint
|
|
|
|
|
|
FROM processed_products
|
|
|
|
|
|
WHERE company_id = $1 AND created_at >= $2
|
|
|
|
|
|
GROUP BY 1
|
|
|
|
|
|
ORDER BY 1`, companyID, since)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
|
var d time.Time
|
|
|
|
|
|
var products, tokens int64
|
|
|
|
|
|
if err := rows.Scan(&d, &products, &tokens); err != nil {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
key := d.UTC().Format("2006-01-02")
|
|
|
|
|
|
byDay[key] = UsageDayPoint{Date: key, Products: products, Tokens: tokens}
|
|
|
|
|
|
}
|
|
|
|
|
|
rows.Close()
|
|
|
|
|
|
}
|
|
|
|
|
|
out := make([]UsageDayPoint, 0, days)
|
|
|
|
|
|
for i := 0; i < days; i++ {
|
|
|
|
|
|
key := since.AddDate(0, 0, i).UTC().Format("2006-01-02")
|
|
|
|
|
|
if p, ok := byDay[key]; ok {
|
|
|
|
|
|
out = append(out, p)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
out = append(out, UsageDayPoint{Date: key})
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// DueBillingCyclesResult counts successful rolls and permanent per-row failures.
|
|
|
|
|
|
// Skipped claims (SKIP LOCKED / no longer due) are neither processed nor failed.
|
|
|
|
|
|
type DueBillingCyclesResult struct {
|
|
|
|
|
|
Processed int `json:"processed"`
|
|
|
|
|
|
Failed int `json:"failed"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// recordDueCycleAttempt updates counters for one claimAndRollDueCompanyPlan outcome.
|
|
|
|
|
|
// Permanent failures increment Failed and return a wrapped error for aggregation.
|
|
|
|
|
|
func recordDueCycleAttempt(res *DueBillingCyclesResult, rowID int64, ok bool, err error) error {
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
res.Failed++
|
|
|
|
|
|
return fmt.Errorf("company_plan %d: %w", rowID, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if ok {
|
|
|
|
|
|
res.Processed++
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// RunDueBillingCycles rolls due company_plans into a new cycle and refreshes monthly credits.
|
|
|
|
|
|
// Concurrent callers are safe: each due row is re-claimed with FOR UPDATE SKIP LOCKED inside
|
|
|
|
|
|
// its processing transaction (same pattern as processing.ClaimNext / shopify ClaimNextPendingJob).
|
|
|
|
|
|
// Per-row permanent failures are fail-closed (counted in Failed, aggregated into the returned
|
|
|
|
|
|
// error) without aborting the rest of the multi-company run.
|
|
|
|
|
|
func (s *Service) RunDueBillingCycles(ctx context.Context) (DueBillingCyclesResult, error) {
|
|
|
|
|
|
var res DueBillingCyclesResult
|
|
|
|
|
|
rows, err := s.Pool.Query(ctx, `
|
|
|
|
|
|
SELECT cp.id
|
|
|
|
|
|
FROM company_plans cp
|
|
|
|
|
|
WHERE cp.is_active = true AND cp.next_billing_date <= now()
|
|
|
|
|
|
ORDER BY cp.next_billing_date ASC, cp.id ASC`)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return res, err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
|
|
var ids []int64
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
|
var id int64
|
|
|
|
|
|
if err := rows.Scan(&id); err != nil {
|
|
|
|
|
|
return res, err
|
|
|
|
|
|
}
|
|
|
|
|
|
ids = append(ids, id)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
|
return res, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var errs []error
|
|
|
|
|
|
for _, rowID := range ids {
|
|
|
|
|
|
ok, rollErr := s.claimAndRollDueCompanyPlan(ctx, rowID)
|
|
|
|
|
|
if attemptErr := recordDueCycleAttempt(&res, rowID, ok, rollErr); attemptErr != nil {
|
|
|
|
|
|
slog.Error("billing_cycle_roll_failed", "company_plan_id", rowID, "err", rollErr)
|
|
|
|
|
|
errs = append(errs, attemptErr)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(errs) > 0 {
|
|
|
|
|
|
return res, errors.Join(errs...)
|
|
|
|
|
|
}
|
|
|
|
|
|
return res, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// claimAndRollDueCompanyPlan claims one company_plans row with FOR UPDATE SKIP LOCKED and
|
|
|
|
|
|
// rolls it when still due. ok=false, err=nil means another worker claimed it or it is no longer due.
|
|
|
|
|
|
// Insert/update/commit failures return ok=false with a non-nil error (fail-closed).
|
|
|
|
|
|
func (s *Service) claimAndRollDueCompanyPlan(ctx context.Context, rowID int64) (ok bool, err error) {
|
|
|
|
|
|
tx, err := s.Pool.Begin(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false, err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
|
companyID uuid.UUID
|
|
|
|
|
|
start time.Time
|
|
|
|
|
|
next time.Time
|
|
|
|
|
|
monthly int
|
|
|
|
|
|
)
|
|
|
|
|
|
// Claim still-due row; SKIP LOCKED yields no row when another worker holds the lock.
|
|
|
|
|
|
err = tx.QueryRow(ctx, `
|
|
|
|
|
|
SELECT cp.company_id, cp.billing_cycle_start, cp.next_billing_date, p.monthly_credits
|
|
|
|
|
|
FROM company_plans cp
|
|
|
|
|
|
JOIN plans p ON p.id = cp.plan_id
|
|
|
|
|
|
WHERE cp.id = $1
|
|
|
|
|
|
AND cp.is_active = true
|
|
|
|
|
|
AND cp.next_billing_date <= now()
|
|
|
|
|
|
FOR UPDATE OF cp SKIP LOCKED`, rowID).Scan(&companyID, &start, &next, &monthly)
|
|
|
|
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
|
|
|
|
return false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var used int
|
|
|
|
|
|
err = tx.QueryRow(ctx, `
|
|
|
|
|
|
SELECT used_credits FROM credit_balances WHERE company_id = $1 FOR UPDATE`, companyID).Scan(&used)
|
|
|
|
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
|
|
|
|
used = 0
|
|
|
|
|
|
} else if err != nil {
|
|
|
|
|
|
return false, err
|
|
|
|
|
|
}
|
|
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
|
|
|
|
INSERT INTO billing_cycles (company_id, start_date, end_date, credits_used, products_processed)
|
|
|
|
|
|
VALUES ($1, $2, $3, $4, 0)`, companyID, start, next, used)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false, err
|
|
|
|
|
|
}
|
|
|
|
|
|
newStart := next
|
|
|
|
|
|
newNext := next.AddDate(0, 1, 0)
|
|
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
|
|
|
|
UPDATE company_plans SET billing_cycle_start = $2, next_billing_date = $3, updated_at = now()
|
|
|
|
|
|
WHERE id = $1`, rowID, newStart, newNext)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false, err
|
|
|
|
|
|
}
|
|
|
|
|
|
// monthly_credits == 0 (Free / A1 PAYG): preserve wallet — no monthly grant to replace.
|
|
|
|
|
|
// Otherwise a cycle roll would wipe topped-up or migrated credits (A1 dump / packs).
|
|
|
|
|
|
if monthly > 0 {
|
|
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
|
|
|
|
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
|
|
|
|
|
|
VALUES ($1, $2, 0, now())
|
|
|
|
|
|
ON CONFLICT (company_id) DO UPDATE SET total_credits = EXCLUDED.total_credits, used_credits = 0, updated_at = now()`,
|
|
|
|
|
|
companyID, monthly)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
|
|
|
|
return false, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return true, nil
|
|
|
|
|
|
}
|