feat(plans): Stage 1 — Starter tier + admin-editable limit model

Foundation for the configurable plan system (Free·Starter·Pro·Business·Enterprise).

- PlanTier: append Starter=4 (no renumber → no data migration; existing tier ints
  keep meaning). Ordering/display via PlanDefinition.SortOrder; gating uses explicit
  tier sets, never `tier >= X`.
- PlanLimits: locked 5-tier matrix — Free orders/day 50→30, tables (new) Free 6/
  Starter 15/Pro 40/∞, categories Free 3→10, menu items now unlimited, terminals/
  branches/report-history ladders incl. Starter; CRM/analytics = explicit Pro+; AI-3D
  = Business+. SMS quotas kept (Free/Starter 0, Pro 50, Business 200) until the
  pay-as-you-go credit system ships (don't break paid SMS).
- PlanLimitsData (LimitsJson shape): + MaxTables/MaxMenuCategories/MaxMenuItems/
  MaxMenuAi3dPerMonth; ForTier now derives from PlanLimits (single source of truth).

No migration. 86 tests pass. Next: S2 seed 5 plans + feature catalog (editable),
S3 wire enforcement to DB, S4 admin editor.
This commit is contained in:
soroush.asadi
2026-06-03 00:40:37 +03:30
parent 4c98c2cce1
commit 4cb640814a
3 changed files with 71 additions and 57 deletions
+41 -26
View File
@@ -2,30 +2,53 @@ using Meezi.Core.Enums;
namespace Meezi.Core.Constants;
/// <summary>
/// Code-level DEFAULTS for per-plan numeric limits. These are the fallback /
/// seed values; the source of truth at runtime is the admin-editable
/// PlatformPlanDefinition (LimitsJson) read via IPlatformCatalogService.
/// Gating uses explicit tier sets, never `tier >= X`, so the appended Starter
/// tier (enum value 4) is handled correctly.
/// </summary>
public static class PlanLimits
{
private static bool IsPaid(PlanTier t) => t is not PlanTier.Free;
private static bool IsProPlus(PlanTier t) =>
t is PlanTier.Pro or PlanTier.Business or PlanTier.Enterprise;
private static bool IsBusinessPlus(PlanTier t) =>
t is PlanTier.Business or PlanTier.Enterprise;
public static int MaxOrdersPerDay(PlanTier tier) => tier switch
{
PlanTier.Free => 50,
PlanTier.Free => 30,
_ => int.MaxValue
};
/// <summary>Maximum tables a café may define.</summary>
public static int MaxTables(PlanTier tier) => tier switch
{
PlanTier.Free => 6,
PlanTier.Starter => 15,
PlanTier.Pro => 40,
_ => int.MaxValue
};
public static int MaxTerminals(PlanTier tier) => tier switch
{
PlanTier.Free => 1,
PlanTier.Starter => 2,
PlanTier.Pro => 3,
_ => int.MaxValue
};
public static int MaxCustomers(PlanTier tier) => tier switch
{
PlanTier.Free => 50,
_ => int.MaxValue
};
public static int MaxCustomers(PlanTier tier) => int.MaxValue; // CRM module gated by CanAccessCrm
/// <summary>Monthly bundled SMS. The product direction is pay-as-you-go credits for
/// all tiers, but until the credit-purchase system ships we keep the existing bundled
/// quotas so paying cafés don't lose SMS. (Switch to 0 + credits in the SMS stage.)</summary>
public static int MaxSmsPerMonth(PlanTier tier) => tier switch
{
PlanTier.Free => 0,
PlanTier.Starter => 0,
PlanTier.Pro => 50,
PlanTier.Business => 200,
_ => int.MaxValue
@@ -34,8 +57,8 @@ public static class PlanLimits
public static int MaxBranches(PlanTier tier) => tier switch
{
PlanTier.Free => 1,
PlanTier.Starter => 1,
PlanTier.Pro => 3,
PlanTier.Business => int.MaxValue,
_ => int.MaxValue
};
@@ -43,35 +66,27 @@ public static class PlanLimits
public static int MaxReportHistoryDays(PlanTier tier) => tier switch
{
PlanTier.Free => 8,
PlanTier.Starter => 30,
PlanTier.Pro => 90,
_ => int.MaxValue
};
/// <summary>AI image-to-3D generations per calendar month (UTC).</summary>
public static int MaxMenuAi3dPerMonth(PlanTier tier) => tier switch
{
PlanTier.Business => 100,
PlanTier.Enterprise => 100,
_ => 0
};
/// <summary>AI image-to-3D generations per calendar month (UTC). Business+ only.</summary>
public static int MaxMenuAi3dPerMonth(PlanTier tier) => IsBusinessPlus(tier) ? 100 : 0;
/// <summary>Maximum active menu categories. Free tier is capped at 3; Pro+ is unlimited.</summary>
/// <summary>Maximum active menu categories. Free is capped at 10; paid tiers unlimited.</summary>
public static int MaxMenuCategories(PlanTier tier) => tier switch
{
PlanTier.Free => 3,
PlanTier.Free => 10,
_ => int.MaxValue
};
/// <summary>Maximum menu items. Free tier is capped at 30; Pro+ is unlimited.</summary>
public static int MaxMenuItems(PlanTier tier) => tier switch
{
PlanTier.Free => 30,
_ => int.MaxValue
};
/// <summary>Menu items are unlimited on every tier (Free can fully build the menu).</summary>
public static int MaxMenuItems(PlanTier tier) => int.MaxValue;
/// <summary>CRM (customers, loyalty) is only available on Pro and above.</summary>
public static bool CanAccessCrm(PlanTier tier) => tier >= PlanTier.Pro;
/// <summary>CRM (customers, loyalty) Pro and above.</summary>
public static bool CanAccessCrm(PlanTier tier) => IsProPlus(tier);
/// <summary>Statistics and analytics dashboards are only available on Pro and above.</summary>
public static bool CanAccessStatistics(PlanTier tier) => tier >= PlanTier.Pro;
/// <summary>Statistics / analytics dashboards Pro and above.</summary>
public static bool CanAccessStatistics(PlanTier tier) => IsProPlus(tier);
}
+6 -1
View File
@@ -5,5 +5,10 @@ public enum PlanTier
Free = 0,
Pro = 1,
Business = 2,
Enterprise = 3
Enterprise = 3,
// Appended (not inserted) so existing stored tier ints (Cafe / SubscriptionPayment /
// PlanDefinition) keep their meaning — no data migration needed. Display & upgrade
// ordering is driven by PlatformPlanDefinition.SortOrder, NOT this numeric value,
// and gating uses explicit tier checks (never `tier >= X`).
Starter = 4
}
+24 -30
View File
@@ -1,43 +1,37 @@
using Meezi.Core.Constants;
using Meezi.Core.Enums;
namespace Meezi.Core.Platform;
/// <summary>
/// Serializable per-plan numeric limits, stored as PlatformPlanDefinition.LimitsJson
/// and editable by admins. Missing fields default to "unlimited" (or 0 for opt-in
/// quotas) so older stored JSON stays safe. Defaults come from <see cref="PlanLimits"/>.
/// </summary>
public class PlanLimitsData
{
public int MaxOrdersPerDay { get; set; } = int.MaxValue;
public int MaxTables { get; set; } = int.MaxValue;
public int MaxTerminals { get; set; } = int.MaxValue;
public int MaxCustomers { get; set; } = int.MaxValue;
public int MaxSmsPerMonth { get; set; } = int.MaxValue;
public int MaxSmsPerMonth { get; set; } = 0;
public int MaxBranches { get; set; } = int.MaxValue;
public int MaxReportHistoryDays { get; set; } = int.MaxValue;
public int MaxMenuCategories { get; set; } = int.MaxValue;
public int MaxMenuItems { get; set; } = int.MaxValue;
public int MaxMenuAi3dPerMonth { get; set; } = 0;
public static PlanLimitsData ForTier(Enums.PlanTier tier) => tier switch
public static PlanLimitsData ForTier(PlanTier tier) => new()
{
Enums.PlanTier.Free => new PlanLimitsData
{
MaxOrdersPerDay = 50,
MaxTerminals = 1,
MaxCustomers = 50,
MaxSmsPerMonth = 0,
MaxBranches = 1,
MaxReportHistoryDays = 8
},
Enums.PlanTier.Pro => new PlanLimitsData
{
MaxOrdersPerDay = int.MaxValue,
MaxTerminals = 3,
MaxCustomers = int.MaxValue,
MaxSmsPerMonth = 50,
MaxBranches = 3,
MaxReportHistoryDays = 90
},
Enums.PlanTier.Business => new PlanLimitsData
{
MaxOrdersPerDay = int.MaxValue,
MaxTerminals = int.MaxValue,
MaxCustomers = int.MaxValue,
MaxSmsPerMonth = 200,
MaxBranches = int.MaxValue,
MaxReportHistoryDays = int.MaxValue
},
_ => new PlanLimitsData()
MaxOrdersPerDay = PlanLimits.MaxOrdersPerDay(tier),
MaxTables = PlanLimits.MaxTables(tier),
MaxTerminals = PlanLimits.MaxTerminals(tier),
MaxCustomers = PlanLimits.MaxCustomers(tier),
MaxSmsPerMonth = PlanLimits.MaxSmsPerMonth(tier),
MaxBranches = PlanLimits.MaxBranches(tier),
MaxReportHistoryDays = PlanLimits.MaxReportHistoryDays(tier),
MaxMenuCategories = PlanLimits.MaxMenuCategories(tier),
MaxMenuItems = PlanLimits.MaxMenuItems(tier),
MaxMenuAi3dPerMonth = PlanLimits.MaxMenuAi3dPerMonth(tier),
};
}