From c5d5a4006aef32b883a84465dd4fc62678b48ec5 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 3 Jun 2026 00:53:02 +0330 Subject: [PATCH] =?UTF-8?q?feat(plans):=20Stage=202=20=E2=80=94=20seed=205?= =?UTF-8?q?-tier=20matrix=20+=20feature=20catalog=20(editable=20defaults)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../Data/PlatformDataSeeder.cs | 179 ++++++++---------- 1 file changed, 81 insertions(+), 98 deletions(-) diff --git a/src/Meezi.Infrastructure/Data/PlatformDataSeeder.cs b/src/Meezi.Infrastructure/Data/PlatformDataSeeder.cs index a2ecb1e..ea63c4b 100644 --- a/src/Meezi.Infrastructure/Data/PlatformDataSeeder.cs +++ b/src/Meezi.Infrastructure/Data/PlatformDataSeeder.cs @@ -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>(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).