Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
358 lines
11 KiB
Go
358 lines
11 KiB
Go
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
|
|
}
|