127 lines
3.9 KiB
Go
127 lines
3.9 KiB
Go
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)
|
||
|
|
}
|