1205 lines
44 KiB
Go
1205 lines
44 KiB
Go
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"`
|
||
// 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"`
|
||
}
|
||
|
||
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
|
||
}
|
||
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
|
||
}
|