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:
@@ -2,30 +2,53 @@ using Meezi.Core.Enums;
|
|||||||
|
|
||||||
namespace Meezi.Core.Constants;
|
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
|
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
|
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
|
_ => int.MaxValue
|
||||||
};
|
};
|
||||||
|
|
||||||
public static int MaxTerminals(PlanTier tier) => tier switch
|
public static int MaxTerminals(PlanTier tier) => tier switch
|
||||||
{
|
{
|
||||||
PlanTier.Free => 1,
|
PlanTier.Free => 1,
|
||||||
|
PlanTier.Starter => 2,
|
||||||
PlanTier.Pro => 3,
|
PlanTier.Pro => 3,
|
||||||
_ => int.MaxValue
|
_ => int.MaxValue
|
||||||
};
|
};
|
||||||
|
|
||||||
public static int MaxCustomers(PlanTier tier) => tier switch
|
public static int MaxCustomers(PlanTier tier) => int.MaxValue; // CRM module gated by CanAccessCrm
|
||||||
{
|
|
||||||
PlanTier.Free => 50,
|
|
||||||
_ => int.MaxValue
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/// <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
|
public static int MaxSmsPerMonth(PlanTier tier) => tier switch
|
||||||
{
|
{
|
||||||
PlanTier.Free => 0,
|
PlanTier.Free => 0,
|
||||||
|
PlanTier.Starter => 0,
|
||||||
PlanTier.Pro => 50,
|
PlanTier.Pro => 50,
|
||||||
PlanTier.Business => 200,
|
PlanTier.Business => 200,
|
||||||
_ => int.MaxValue
|
_ => int.MaxValue
|
||||||
@@ -34,8 +57,8 @@ public static class PlanLimits
|
|||||||
public static int MaxBranches(PlanTier tier) => tier switch
|
public static int MaxBranches(PlanTier tier) => tier switch
|
||||||
{
|
{
|
||||||
PlanTier.Free => 1,
|
PlanTier.Free => 1,
|
||||||
|
PlanTier.Starter => 1,
|
||||||
PlanTier.Pro => 3,
|
PlanTier.Pro => 3,
|
||||||
PlanTier.Business => int.MaxValue,
|
|
||||||
_ => int.MaxValue
|
_ => int.MaxValue
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,35 +66,27 @@ public static class PlanLimits
|
|||||||
public static int MaxReportHistoryDays(PlanTier tier) => tier switch
|
public static int MaxReportHistoryDays(PlanTier tier) => tier switch
|
||||||
{
|
{
|
||||||
PlanTier.Free => 8,
|
PlanTier.Free => 8,
|
||||||
|
PlanTier.Starter => 30,
|
||||||
PlanTier.Pro => 90,
|
PlanTier.Pro => 90,
|
||||||
_ => int.MaxValue
|
_ => int.MaxValue
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>AI image-to-3D generations per calendar month (UTC).</summary>
|
/// <summary>AI image-to-3D generations per calendar month (UTC). Business+ only.</summary>
|
||||||
public static int MaxMenuAi3dPerMonth(PlanTier tier) => tier switch
|
public static int MaxMenuAi3dPerMonth(PlanTier tier) => IsBusinessPlus(tier) ? 100 : 0;
|
||||||
{
|
|
||||||
PlanTier.Business => 100,
|
|
||||||
PlanTier.Enterprise => 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
|
public static int MaxMenuCategories(PlanTier tier) => tier switch
|
||||||
{
|
{
|
||||||
PlanTier.Free => 3,
|
PlanTier.Free => 10,
|
||||||
_ => int.MaxValue
|
_ => int.MaxValue
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Maximum menu items. Free tier is capped at 30; Pro+ is unlimited.</summary>
|
/// <summary>Menu items are unlimited on every tier (Free can fully build the menu).</summary>
|
||||||
public static int MaxMenuItems(PlanTier tier) => tier switch
|
public static int MaxMenuItems(PlanTier tier) => int.MaxValue;
|
||||||
{
|
|
||||||
PlanTier.Free => 30,
|
|
||||||
_ => int.MaxValue
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>CRM (customers, loyalty) is only available on Pro and above.</summary>
|
/// <summary>CRM (customers, loyalty) — Pro and above.</summary>
|
||||||
public static bool CanAccessCrm(PlanTier tier) => tier >= PlanTier.Pro;
|
public static bool CanAccessCrm(PlanTier tier) => IsProPlus(tier);
|
||||||
|
|
||||||
/// <summary>Statistics and analytics dashboards are only available on Pro and above.</summary>
|
/// <summary>Statistics / analytics dashboards — Pro and above.</summary>
|
||||||
public static bool CanAccessStatistics(PlanTier tier) => tier >= PlanTier.Pro;
|
public static bool CanAccessStatistics(PlanTier tier) => IsProPlus(tier);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,10 @@ public enum PlanTier
|
|||||||
Free = 0,
|
Free = 0,
|
||||||
Pro = 1,
|
Pro = 1,
|
||||||
Business = 2,
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,37 @@
|
|||||||
|
using Meezi.Core.Constants;
|
||||||
|
using Meezi.Core.Enums;
|
||||||
|
|
||||||
namespace Meezi.Core.Platform;
|
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 class PlanLimitsData
|
||||||
{
|
{
|
||||||
public int MaxOrdersPerDay { get; set; } = int.MaxValue;
|
public int MaxOrdersPerDay { get; set; } = int.MaxValue;
|
||||||
|
public int MaxTables { get; set; } = int.MaxValue;
|
||||||
public int MaxTerminals { get; set; } = int.MaxValue;
|
public int MaxTerminals { get; set; } = int.MaxValue;
|
||||||
public int MaxCustomers { 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 MaxBranches { get; set; } = int.MaxValue;
|
||||||
public int MaxReportHistoryDays { 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 = PlanLimits.MaxOrdersPerDay(tier),
|
||||||
{
|
MaxTables = PlanLimits.MaxTables(tier),
|
||||||
MaxOrdersPerDay = 50,
|
MaxTerminals = PlanLimits.MaxTerminals(tier),
|
||||||
MaxTerminals = 1,
|
MaxCustomers = PlanLimits.MaxCustomers(tier),
|
||||||
MaxCustomers = 50,
|
MaxSmsPerMonth = PlanLimits.MaxSmsPerMonth(tier),
|
||||||
MaxSmsPerMonth = 0,
|
MaxBranches = PlanLimits.MaxBranches(tier),
|
||||||
MaxBranches = 1,
|
MaxReportHistoryDays = PlanLimits.MaxReportHistoryDays(tier),
|
||||||
MaxReportHistoryDays = 8
|
MaxMenuCategories = PlanLimits.MaxMenuCategories(tier),
|
||||||
},
|
MaxMenuItems = PlanLimits.MaxMenuItems(tier),
|
||||||
Enums.PlanTier.Pro => new PlanLimitsData
|
MaxMenuAi3dPerMonth = PlanLimits.MaxMenuAi3dPerMonth(tier),
|
||||||
{
|
|
||||||
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()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user