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,357 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user