package billing import ( "context" "errors" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // EnsureLegacyDefaults idempotently: // 1. Upserts the Legacy plan row (meters aligned with Enterprise for migrated catalogs) // 2. Repairs prior enable-all feature maps on legacy-named plans // 3. Delegates empty/flagged sparse backfill to EnsureLegacyPlanFeatureSeeds // 4. Assigns the Legacy plan to A1 cohort companies when missing or on a non-legacy profile // 5. Repairs dump-faithful A1 PAYG plan rows (is_custom, clear mistaken is_legacy) func (s *Service) EnsureLegacyDefaults(ctx context.Context) error { if s == nil || s.Pool == nil { return nil } if err := s.ensureLegacyPlanRow(ctx); err != nil { return err } if err := s.repairLegacyEnableAllFeatures(ctx); err != nil { return err } if err := s.EnsureLegacyPlanFeatureSeeds(ctx); err != nil { return err } if err := s.assignLegacyPlanToA1Companies(ctx); err != nil { return err } return s.ensureA1PaygPlanSemantics(ctx) } // repairLegacyEnableAllFeatures rewrites full all-true maps on legacy-named plans // (left over from prior custom enable-all create) to SparseLegacyOverrides. func (s *Service) repairLegacyEnableAllFeatures(ctx context.Context) error { rows, err := s.Pool.Query(ctx, ` SELECT id, name, COALESCE(is_legacy, false), COALESCE(features, '{}'::jsonb) FROM plans`) if err != nil { if isUndefinedColumn(err) { rows, err = s.Pool.Query(ctx, ` SELECT id, name, false, COALESCE(features, '{}'::jsonb) FROM plans`) } if err != nil { if isUndefinedRelation(err) || isUndefinedColumn(err) { return nil } return err } } defer rows.Close() for rows.Next() { var id int64 var name string var isLegacy bool var raw []byte if err := rows.Scan(&id, &name, &isLegacy, &raw); err != nil { return err } // Only the explicit Legacy package is rewritten; A1 PAYG / other A1* deals keep features. if !strings.EqualFold(strings.TrimSpace(name), LegacyPlanName) { continue } if !IsLegacyPlan(name, isLegacy) { continue } overrides, err := decodeFeaturesJSON(raw) if err != nil { return err } if featuresMapEmpty(overrides) || !isEnableAllOverrides(overrides) { continue } if _, err := s.SetPlanFeatures(ctx, id, SparseLegacyOverrides()); err != nil { return err } } return rows.Err() } func (s *Service) ensureLegacyPlanRow(ctx context.Context) error { desc := "Migrated legacy package — catalog, feeds, billing & settings (no Background Tasks / stores / marketing)" var id int64 err := s.Pool.QueryRow(ctx, ` SELECT id FROM plans WHERE lower(name) = lower($1) ORDER BY id LIMIT 1`, LegacyPlanName).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, is_legacy, term) VALUES ($1, $2, $3, NULL, NULL, false, true, 'monthly')`, LegacyPlanName, desc, EnterpriseUnlimitedCredits) if err != nil { if isUndefinedColumn(err) { _, err = s.Pool.Exec(ctx, ` INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term) VALUES ($1, $2, $3, NULL, NULL, false, 'monthly')`, LegacyPlanName, desc, EnterpriseUnlimitedCredits) } if err != nil { return err } } return s.seedLegacyFeaturesIfEmpty(ctx, 0, LegacyPlanName) } if err != nil { return err } _, err = s.Pool.Exec(ctx, ` UPDATE plans SET description = $2, monthly_credits = $3, max_products = NULL, is_custom = false, is_legacy = true, term = 'monthly', updated_at = now() WHERE id = $1`, id, desc, EnterpriseUnlimitedCredits) if err != nil { if isUndefinedColumn(err) { _, err = s.Pool.Exec(ctx, ` UPDATE plans SET description = $2, monthly_credits = $3, max_products = NULL, is_custom = false, term = 'monthly', updated_at = now() WHERE id = $1`, id, desc, EnterpriseUnlimitedCredits) } if err != nil { return err } } return s.seedLegacyFeaturesIfEmpty(ctx, id, LegacyPlanName) } func isEnableAllOverrides(overrides map[string]bool) bool { if len(overrides) < len(FeatureCatalogKeys) { return false } for _, k := range FeatureCatalogKeys { v, ok := overrides[k] if !ok || !v { return false } } return true } func shouldWriteLegacySparse(overrides map[string]bool) bool { return featuresMapEmpty(overrides) || isEnableAllOverrides(overrides) } func (s *Service) seedLegacyFeaturesIfEmpty(ctx context.Context, planID int64, name string) error { if planID == 0 { 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 err != nil { return err } planID = id } _, _, _, overrides, err := s.loadPlanFeaturesRow(ctx, planID) if err != nil { return err } if !shouldWriteLegacySparse(overrides) { return nil } _, err = s.SetPlanFeatures(ctx, planID, SparseLegacyOverrides()) return err } func (s *Service) assignLegacyPlanToA1Companies(ctx context.Context) error { legacyID, err := s.PlanIDByName(ctx, LegacyPlanName) if err != nil { return err } // One row per company using the active plan only — joining all company_plans rows // previously re-AssignPlan'd when an inactive Enterprise row appeared and wiped // migrated credit_balances (A1 dump 2500/216 → fake pack). // Privilege-sensitive: match ONLY immutable legacy_company_id. Mutable names // ("A1", "Local Demo Co", …) must never auto-AssignPlan (register/rename IDOR). rows, err := s.Pool.Query(ctx, ` SELECT c.id::text, COALESCE(c.legacy_company_id, ''), COALESCE(c.name, ''), COALESCE(p.name, ''), COALESCE(p.is_legacy, false), COALESCE(cb.total_credits, 0), COALESCE(cb.used_credits, 0) FROM companies c LEFT JOIN company_plans cp ON cp.company_id = c.id AND cp.is_active = true LEFT JOIN plans p ON p.id = cp.plan_id LEFT JOIN credit_balances cb ON cb.company_id = c.id WHERE lower(COALESCE(c.legacy_company_id, '')) = lower($1)`, A1LegacyCompanyID) if err != nil { // Fail closed when legacy_company_id is unavailable — never fall back to name match. if isUndefinedColumn(err) { return nil } return err } defer rows.Close() for rows.Next() { var ( cid, legacyCID, cname, planName string planLegacy bool total, used int ) if err := rows.Scan(&cid, &legacyCID, &cname, &planName, &planLegacy, &total, &used); err != nil { return err } if !IsA1CohortCompany(legacyCID, cname) { continue } if IsLegacyPlan(planName, planLegacy) { continue } // Dump-faithful A1 PAYG / other custom deals keep their plan + wallet. if strings.EqualFold(strings.TrimSpace(planName), "A1") || strings.Contains(strings.ToLower(planName), "a1") { continue } // Preserve migrated wallets — AssignPlan resets used_credits and total from plan monthly. if total > 0 || used > 0 { continue } companyUUID, err := uuid.Parse(cid) if err != nil { continue } if err := s.AssignPlan(ctx, companyUUID, legacyID, false, 0); err != nil { return err } } return rows.Err() } // ensureA1PaygPlanSemantics repairs dump-faithful A1 plans: // is_custom=true, is_legacy=false, PAYG description, A1PaygPlanFeatures // (Stores + Marketing + Integrations OFF), and documents open-ended contract dates on // company_plans.notes when dates are null. func (s *Service) ensureA1PaygPlanSemantics(ctx context.Context) error { desc := "A1 pay-as-you-go — credits wallet, unlimited SKUs, catalog/feeds/processing/billing (Stores, Marketing, Integrations off). EPREL included on all plans." _, err := s.Pool.Exec(ctx, ` UPDATE plans SET is_custom = true, is_legacy = false, description = $1, updated_at = now() WHERE lower(name) = 'a1' OR lower(name) LIKE 'a1 %' OR lower(name) LIKE 'a1-%' OR lower(name) LIKE 'a1_%' OR lower(name) LIKE '%a1 slovenija%'`, desc) if err != nil { if isUndefinedColumn(err) { _, err = s.Pool.Exec(ctx, ` UPDATE plans SET is_custom = true, description = $1, updated_at = now() WHERE lower(name) = 'a1' OR lower(name) LIKE 'a1 %' OR lower(name) LIKE 'a1-%' OR lower(name) LIKE 'a1_%' OR lower(name) LIKE '%a1 slovenija%'`, desc) } if err != nil { return err } } rows, err := s.Pool.Query(ctx, ` SELECT id, name, COALESCE(features, '{}'::jsonb) FROM plans WHERE lower(name) = 'a1' OR lower(name) LIKE 'a1 %' OR lower(name) LIKE 'a1-%' OR lower(name) LIKE 'a1_%' OR lower(name) LIKE '%a1 slovenija%'`) if err != nil { if isUndefinedRelation(err) || isUndefinedColumn(err) { return nil } return err } defer rows.Close() for rows.Next() { var id int64 var name string var raw []byte if err := rows.Scan(&id, &name, &raw); err != nil { return err } overrides, err := decodeFeaturesJSON(raw) if err != nil { return err } if !shouldWriteA1PaygFeatures(overrides) { continue } if _, err := s.SetPlanFeatures(ctx, id, A1PaygPlanFeatures()); err != nil { return err } } if err := rows.Err(); err != nil { return err } const paygNote = "PAYG: open-ended contract (no end date). Credits are consumed as used; yearly packaging is advisory." // Privilege-sensitive notes: match A1* plan names and/or immutable legacy_company_id only. // Never match mutable company display names (same isolation as assignLegacyPlanToA1Companies). _, err = s.Pool.Exec(ctx, ` UPDATE company_plans cp SET notes = CASE WHEN COALESCE(cp.notes, '') = '' THEN $1 WHEN cp.notes LIKE '%' || $1 || '%' THEN cp.notes ELSE cp.notes || E'\n' || $1 END, updated_at = now() FROM plans p, companies c WHERE cp.plan_id = p.id AND cp.company_id = c.id AND cp.is_active = true AND cp.contract_end_date IS NULL AND ( lower(p.name) = 'a1' OR lower(p.name) LIKE 'a1 %' OR lower(p.name) LIKE 'a1-%' OR lower(p.name) LIKE 'a1_%' OR lower(p.name) LIKE '%a1 slovenija%' OR lower(COALESCE(c.legacy_company_id, '')) = lower($2) )`, paygNote, A1LegacyCompanyID) if err != nil && !isUndefinedColumn(err) && !isUndefinedRelation(err) { return err } return nil } func looksLikeLegacySparse(overrides map[string]bool) bool { if len(overrides) == 0 { return false } for _, v := range overrides { if v { return false } } return true } // shouldWriteA1PaygFeatures reports whether A1 plan features need hygiene to the // tailored PAYG matrix (Stores + Marketing + Integrations explicitly OFF). func shouldWriteA1PaygFeatures(overrides map[string]bool) bool { if featuresMapEmpty(overrides) || looksLikeLegacySparse(overrides) || isEnableAllOverrides(overrides) { return true } for _, k := range FeatureCatalogKeys { if !A1PaygFeatureDenied(k) { continue } v, ok := overrides[k] if !ok || v { return true } } return false }