feat(plans): Stage 2 — seed 5-tier matrix + feature catalog (editable defaults)
- CanonicalPlans(): single source for Free·Starter·Pro·Business·Enterprise with the locked feature sets (Free is broad: KDS/queue/Koja/offline/reviews/reservations/ coupons/employees; Starter +watermark-removal/custom-styling/review-reply; Pro +CRM/ reports/taxes/HR/delivery/expenses/branches; Business +3D/AI-3D; Enterprise *). - Feature catalog: + offline, employees, watermark_removed, custom_menu_styling, review_reply, api, white_label. - New Starter plan (690k Toman default, billable, sort 1). - One-time, version-guarded matrix upgrade (catalog.planMatrixVersion=2): brings the existing (never-yet-admin-edited) prod plans to the canonical limits/features/order/ price and inserts Starter. Runs once; won't clobber later admin edits. - Replaced the additive feature-merge (which would wrongly re-add menu_3d to Pro). Defaults only — admins will be able to change everything in S4. 86 tests pass.
This commit is contained in:
@@ -274,50 +274,47 @@ public static class PlatformDataSeeder
|
||||
logger.LogInformation("Platform upgrade: added {Count} features", newFeatures.Count);
|
||||
}
|
||||
|
||||
var plans = await db.PlatformPlanDefinitions.ToListAsync();
|
||||
var changed = 0;
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
if (plan.Tier is PlanTier.Free or PlanTier.Enterprise)
|
||||
continue;
|
||||
|
||||
var keys = plan.Tier == PlanTier.Business || plan.Tier == PlanTier.Enterprise
|
||||
? new[] { "menu_3d", "menu_3d_ai", "discover_profile" }
|
||||
: new[] { "menu_3d", "discover_profile" };
|
||||
var merged = MergeFeaturesJson(plan.FeaturesJson ?? "[]", keys);
|
||||
if (merged == plan.FeaturesJson) continue;
|
||||
plan.FeaturesJson = merged;
|
||||
changed++;
|
||||
}
|
||||
|
||||
if (changed > 0)
|
||||
// One-time: bring plan definitions to the current matrix (Free·Starter·Pro·
|
||||
// Business·Enterprise). Existing plans were never admin-editable before this, so
|
||||
// updating their limits/features/order/price to the canonical defaults is safe.
|
||||
// Version-guarded so it runs once and never clobbers later admin edits.
|
||||
const string matrixVersionKey = "catalog.planMatrixVersion";
|
||||
const string matrixVersion = "2";
|
||||
var verSetting = await db.PlatformSettings.FirstOrDefaultAsync(s => s.Key == matrixVersionKey);
|
||||
if (verSetting?.Value != matrixVersion)
|
||||
{
|
||||
// Tier is unique across all rows (incl. soft-deleted), so at most one row per tier.
|
||||
var byTier = (await db.PlatformPlanDefinitions.IgnoreQueryFilters().ToListAsync())
|
||||
.ToDictionary(p => p.Tier);
|
||||
foreach (var def in CanonicalPlans())
|
||||
{
|
||||
if (byTier.TryGetValue(def.Tier, out var ex))
|
||||
{
|
||||
ex.DisplayNameFa = def.DisplayNameFa;
|
||||
ex.DisplayNameEn = def.DisplayNameEn;
|
||||
ex.MonthlyPriceToman = def.MonthlyPriceToman;
|
||||
ex.IsBillableOnline = def.IsBillableOnline;
|
||||
ex.SortOrder = def.SortOrder;
|
||||
ex.LimitsJson = def.LimitsJson;
|
||||
ex.FeaturesJson = def.FeaturesJson;
|
||||
ex.DeletedAt = null; // ensure all five plans are active
|
||||
}
|
||||
else
|
||||
{
|
||||
db.PlatformPlanDefinitions.Add(def);
|
||||
}
|
||||
}
|
||||
if (verSetting is null)
|
||||
db.PlatformSettings.Add(S(matrixVersionKey, matrixVersion, "catalog", "نسخه ماتریس پلنها"));
|
||||
else
|
||||
verSetting.Value = matrixVersion;
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("Platform upgrade: updated features on {Count} plans", changed);
|
||||
logger.LogInformation("Platform upgrade: applied plan matrix v{Version}", matrixVersion);
|
||||
}
|
||||
|
||||
await EnsureIntegrationSettingsAsync(db, logger);
|
||||
}
|
||||
|
||||
private static string MergeFeaturesJson(string json, params string[] keys)
|
||||
{
|
||||
var list = JsonSerializer.Deserialize<List<string>>(json, JsonOpts) ?? [];
|
||||
if (list.Contains("*"))
|
||||
return json;
|
||||
|
||||
var updated = false;
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!list.Contains(key))
|
||||
{
|
||||
list.Add(key);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return updated ? JsonSerializer.Serialize(list, JsonOpts) : json;
|
||||
}
|
||||
|
||||
private static async Task EnsureIntegrationSettingsAsync(AppDbContext db, ILogger logger)
|
||||
{
|
||||
var defaults = new[]
|
||||
@@ -368,68 +365,47 @@ public static class PlatformDataSeeder
|
||||
logger.LogInformation("Platform seed: system admin phone {Phone}", phone);
|
||||
}
|
||||
|
||||
// ── Canonical plan matrix (Free·Starter·Pro·Business·Enterprise) ─────────────
|
||||
// Single source of plan DEFAULTS. Free features are broad (KDS, queue, Koja,
|
||||
// offline, reviews, reservations, coupons, employees); paid tiers add the rest.
|
||||
private static readonly string[] FreeFeatures =
|
||||
{
|
||||
"pos", "menu", "tables", "qr_menu", "kds", "queue", "inventory",
|
||||
"reservations", "reviews", "coupons", "discover_profile", "offline", "employees"
|
||||
};
|
||||
private static readonly string[] StarterFeatures =
|
||||
FreeFeatures.Concat(new[] { "watermark_removed", "custom_menu_styling", "review_reply" }).ToArray();
|
||||
private static readonly string[] ProFeatures =
|
||||
StarterFeatures.Concat(new[] { "crm", "reports", "taxes", "hr", "delivery", "expenses", "branches" }).ToArray();
|
||||
private static readonly string[] BusinessFeatures =
|
||||
ProFeatures.Concat(new[] { "menu_3d", "menu_3d_ai" }).ToArray();
|
||||
|
||||
private static PlatformPlanDefinition Plan(
|
||||
string id, PlanTier tier, string fa, string en, decimal price, bool billable, int sort, string[] features) => new()
|
||||
{
|
||||
Id = id,
|
||||
Tier = tier,
|
||||
DisplayNameFa = fa,
|
||||
DisplayNameEn = en,
|
||||
MonthlyPriceToman = price,
|
||||
IsBillableOnline = billable,
|
||||
SortOrder = sort,
|
||||
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(tier), JsonOpts),
|
||||
FeaturesJson = JsonSerializer.Serialize(features, JsonOpts)
|
||||
};
|
||||
|
||||
private static PlatformPlanDefinition[] CanonicalPlans() =>
|
||||
[
|
||||
Plan("plan_free", PlanTier.Free, "رایگان", "Free", 0, false, 0, FreeFeatures),
|
||||
Plan("plan_starter", PlanTier.Starter, "پایه", "Starter", 690_000, true, 1, StarterFeatures),
|
||||
Plan("plan_pro", PlanTier.Pro, "حرفهای", "Pro", 1_490_000, true, 2, ProFeatures),
|
||||
Plan("plan_business", PlanTier.Business, "کسبوکار", "Business", 3_490_000, true, 3, BusinessFeatures),
|
||||
Plan("plan_enterprise", PlanTier.Enterprise, "سازمانی", "Enterprise", 0, false, 4, new[] { "*" }),
|
||||
];
|
||||
|
||||
private static async Task SeedPlansAsync(AppDbContext db, ILogger logger)
|
||||
{
|
||||
var plans = new[]
|
||||
{
|
||||
new PlatformPlanDefinition
|
||||
{
|
||||
Id = "plan_free",
|
||||
Tier = PlanTier.Free,
|
||||
DisplayNameFa = "رایگان",
|
||||
DisplayNameEn = "Free",
|
||||
MonthlyPriceToman = 0,
|
||||
IsBillableOnline = false,
|
||||
SortOrder = 0,
|
||||
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Free), JsonOpts),
|
||||
FeaturesJson = JsonSerializer.Serialize(new[] { "pos", "menu", "tables", "qr_menu" }, JsonOpts)
|
||||
},
|
||||
new PlatformPlanDefinition
|
||||
{
|
||||
Id = "plan_pro",
|
||||
Tier = PlanTier.Pro,
|
||||
DisplayNameFa = "حرفهای",
|
||||
DisplayNameEn = "Pro",
|
||||
MonthlyPriceToman = PlanPricing.MonthlyToman(PlanTier.Pro),
|
||||
IsBillableOnline = true,
|
||||
SortOrder = 1,
|
||||
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Pro), JsonOpts),
|
||||
FeaturesJson = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
"pos", "menu", "tables", "qr_menu", "crm", "coupons", "reports", "kds", "inventory",
|
||||
"menu_3d", "discover_profile"
|
||||
}, JsonOpts)
|
||||
},
|
||||
new PlatformPlanDefinition
|
||||
{
|
||||
Id = "plan_business",
|
||||
Tier = PlanTier.Business,
|
||||
DisplayNameFa = "کسبوکار",
|
||||
DisplayNameEn = "Business",
|
||||
MonthlyPriceToman = PlanPricing.MonthlyToman(PlanTier.Business),
|
||||
IsBillableOnline = true,
|
||||
SortOrder = 2,
|
||||
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Business), JsonOpts),
|
||||
FeaturesJson = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
"pos", "menu", "tables", "qr_menu", "crm", "coupons", "reports", "kds", "inventory",
|
||||
"hr", "sms", "reservations", "delivery", "expenses", "branches",
|
||||
"menu_3d", "menu_3d_ai", "discover_profile"
|
||||
}, JsonOpts)
|
||||
},
|
||||
new PlatformPlanDefinition
|
||||
{
|
||||
Id = "plan_enterprise",
|
||||
Tier = PlanTier.Enterprise,
|
||||
DisplayNameFa = "سازمانی",
|
||||
DisplayNameEn = "Enterprise",
|
||||
MonthlyPriceToman = 0,
|
||||
IsBillableOnline = false,
|
||||
SortOrder = 3,
|
||||
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Enterprise), JsonOpts),
|
||||
FeaturesJson = JsonSerializer.Serialize(new[] { "*" }, JsonOpts)
|
||||
}
|
||||
};
|
||||
var plans = CanonicalPlans();
|
||||
|
||||
// Tier (not Id) carries the unique constraint, so dedupe on Tier — an
|
||||
// existing Free plan may have a different Id, and inserting another
|
||||
@@ -473,7 +449,14 @@ public static class PlatformDataSeeder
|
||||
F("queue", "صف", "Queue", "operations"),
|
||||
F("menu_3d", "منوی سهبعدی", "3D menu", "growth"),
|
||||
F("menu_3d_ai", "تولید ۳D با هوش مصنوعی", "AI 3D menu", "growth"),
|
||||
F("discover_profile", "پروفایل کشف", "Discover profile", "growth")
|
||||
F("discover_profile", "پروفایل کشف", "Discover profile", "growth"),
|
||||
F("offline", "حالت آفلاین", "Offline mode", "core"),
|
||||
F("employees", "کارکنان", "Employees", "operations"),
|
||||
F("watermark_removed", "حذف واترمارک منو", "Remove menu watermark", "growth"),
|
||||
F("custom_menu_styling", "طراحی اختصاصی منو", "Custom menu styling", "growth"),
|
||||
F("review_reply", "پاسخ به نظرات", "Reply to reviews", "growth"),
|
||||
F("api", "API عمومی", "Public API", "integrations"),
|
||||
F("white_label", "وایتلیبل", "White-label", "integrations")
|
||||
};
|
||||
|
||||
// Key carries the unique constraint, so dedupe on Key (not Id).
|
||||
|
||||
Reference in New Issue
Block a user