Files

127 lines
3.9 KiB
Go
Raw Permalink Normal View History

package billing
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// CompanyWithoutActivePlan is a tenant with no is_active company_plans row.
type CompanyWithoutActivePlan struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Language string `json:"language"`
LegacyCompanyID string `json:"legacy_company_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// PlanIDByName resolves a plan by case-insensitive name (lowest id wins).
func (s *Service) PlanIDByName(ctx context.Context, name string) (int64, error) {
name = strings.TrimSpace(name)
if name == "" {
return 0, ErrPlanNameRequired
}
var id int64
err := s.Pool.QueryRow(ctx, `
SELECT id FROM plans WHERE lower(name) = lower($1) ORDER BY id LIMIT 1`, name).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrPlanNotFound
}
if err != nil {
return 0, err
}
return id, nil
}
// HasActivePlan reports whether the company has an is_active company_plans row.
func (s *Service) HasActivePlan(ctx context.Context, companyID uuid.UUID) (bool, error) {
var has bool
err := s.Pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM company_plans WHERE company_id = $1 AND is_active = true
)`, companyID).Scan(&has)
return has, err
}
// ListCompaniesWithoutActivePlan returns companies with no active plan assignment.
// Safe read-only operator / cutover helper (never mutates).
// Excludes the A1 cohort (legacy_company_id) — A1 plans are managed separately.
func (s *Service) ListCompaniesWithoutActivePlan(ctx context.Context, limit, offset int) ([]CompanyWithoutActivePlan, error) {
if limit <= 0 {
limit = 50
}
if limit > 500 {
limit = 500
}
if offset < 0 {
offset = 0
}
rows, err := s.Pool.Query(ctx, `
SELECT c.id, c.name, c.language, COALESCE(c.legacy_company_id, ''), c.created_at
FROM companies c
WHERE NOT EXISTS (
SELECT 1 FROM company_plans cp
WHERE cp.company_id = c.id AND cp.is_active = true
)
AND lower(COALESCE(c.legacy_company_id, '')) <> lower($3)
ORDER BY c.created_at DESC
LIMIT $1 OFFSET $2`, limit, offset, A1LegacyCompanyID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]CompanyWithoutActivePlan, 0)
for rows.Next() {
var c CompanyWithoutActivePlan
if err := rows.Scan(&c.ID, &c.Name, &c.Language, &c.LegacyCompanyID, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// CountCompaniesWithoutActivePlan returns how many companies lack an active plan.
// Excludes the A1 cohort (same filter as ListCompaniesWithoutActivePlan).
func (s *Service) CountCompaniesWithoutActivePlan(ctx context.Context) (int64, error) {
var n int64
err := s.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM companies c
WHERE NOT EXISTS (
SELECT 1 FROM company_plans cp
WHERE cp.company_id = c.id AND cp.is_active = true
)
AND lower(COALESCE(c.legacy_company_id, '')) <> lower($1)`, A1LegacyCompanyID).Scan(&n)
return n, err
}
// AssignPlanIfMissing assigns planID only when the company has no active plan.
// Does not deactivate or replace an existing active plan (safe cutover repair).
// Returns assigned=false when the company already has an active plan.
func (s *Service) AssignPlanIfMissing(ctx context.Context, companyID uuid.UUID, planID int64) (assigned bool, err error) {
has, err := s.HasActivePlan(ctx, companyID)
if err != nil {
return false, err
}
if has {
return false, nil
}
if err := s.AssignPlan(ctx, companyID, planID, false, 0); err != nil {
return false, err
}
return true, nil
}
// AssignPlanByNameIfMissing resolves planName then AssignPlanIfMissing.
func (s *Service) AssignPlanByNameIfMissing(ctx context.Context, companyID uuid.UUID, planName string) (assigned bool, err error) {
planID, err := s.PlanIDByName(ctx, planName)
if err != nil {
return false, err
}
return s.AssignPlanIfMissing(ctx, companyID, planID)
}