Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user