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) }