Files
descrybe/apps/api/internal/billing/legacy_plan_seed.go
T

358 lines
11 KiB
Go
Raw Normal View History

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
}