using Meezi.Infrastructure.Services.Platform; using Meezi.Core.Enums; using Meezi.Core.Interfaces; using Meezi.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using StackExchange.Redis; namespace Meezi.API.Services; public interface IPlanLimitChecker { Task<(bool Allowed, string? ErrorCode, string? Message)> CheckAsync( HttpContext context, ITenantContext tenant, CancellationToken cancellationToken = default); } public class PlanLimitChecker : IPlanLimitChecker { private readonly AppDbContext _db; private readonly IConnectionMultiplexer _redis; private readonly IPlatformCatalogService _platformCatalog; public PlanLimitChecker( AppDbContext db, IConnectionMultiplexer redis, IPlatformCatalogService platformCatalog) { _db = db; _redis = redis; _platformCatalog = platformCatalog; } public async Task<(bool Allowed, string? ErrorCode, string? Message)> CheckAsync( HttpContext context, ITenantContext tenant, CancellationToken cancellationToken = default) { if (tenant.IsSystemAdmin || !tenant.IsAuthenticated || tenant.PlanTier is null || string.IsNullOrEmpty(tenant.CafeId)) return (true, null, null); var method = context.Request.Method; var path = context.Request.Path.Value ?? string.Empty; if (method != HttpMethods.Post) return (true, null, null); var cafeId = tenant.CafeId; var tier = tenant.PlanTier.Value; var ordersPath = $"/api/cafes/{cafeId}/orders"; if (method == HttpMethods.Post && path.StartsWith(ordersPath, StringComparison.OrdinalIgnoreCase) && (path.Equals(ordersPath, StringComparison.OrdinalIgnoreCase) || path.Equals($"{ordersPath}/", StringComparison.OrdinalIgnoreCase))) { var limits = await _platformCatalog.GetLimitsAsync(tier, cancellationToken); var maxOrders = limits.MaxOrdersPerDay; if (maxOrders == int.MaxValue) return (true, null, null); var todayStart = DateTime.UtcNow.Date; var count = await _db.Orders .CountAsync(o => o.CafeId == cafeId && o.CreatedAt >= todayStart, cancellationToken); if (count >= maxOrders) return (false, "PLAN_LIMIT_REACHED", "Daily order limit reached for your plan. Please upgrade."); } var customersPath = $"/api/cafes/{cafeId}/customers"; if (path.StartsWith(customersPath, StringComparison.OrdinalIgnoreCase) && (path.Equals(customersPath, StringComparison.OrdinalIgnoreCase) || path.Equals($"{customersPath}/", StringComparison.OrdinalIgnoreCase))) { var limitsCustomers = await _platformCatalog.GetLimitsAsync(tier, cancellationToken); var maxCustomers = limitsCustomers.MaxCustomers; if (maxCustomers == int.MaxValue) return (true, null, null); var count = await _db.Customers .CountAsync(c => c.CafeId == cafeId, cancellationToken); if (count >= maxCustomers) return (false, "PLAN_LIMIT_REACHED", "Customer limit reached for your plan. Please upgrade."); } var branchesPath = $"/api/cafes/{cafeId}/branches"; if (path.StartsWith(branchesPath, StringComparison.OrdinalIgnoreCase) && (path.Equals(branchesPath, StringComparison.OrdinalIgnoreCase) || path.Equals($"{branchesPath}/", StringComparison.OrdinalIgnoreCase))) { var limitsBranches = await _platformCatalog.GetLimitsAsync(tier, cancellationToken); var maxBranches = limitsBranches.MaxBranches; if (maxBranches == int.MaxValue) return (true, null, null); var branchCount = await _db.Branches.CountAsync(b => b.CafeId == cafeId, cancellationToken); if (branchCount >= maxBranches) return (false, "PLAN_LIMIT_REACHED", "Branch limit reached for your plan. Please upgrade."); } var tablesPath = $"/api/cafes/{cafeId}/tables"; if (path.StartsWith(tablesPath, StringComparison.OrdinalIgnoreCase) && (path.Equals(tablesPath, StringComparison.OrdinalIgnoreCase) || path.Equals($"{tablesPath}/", StringComparison.OrdinalIgnoreCase))) { var limitsTables = await _platformCatalog.GetLimitsAsync(tier, cancellationToken); var maxTables = limitsTables.MaxTables; if (maxTables != int.MaxValue) { var tableCount = await _db.Tables.CountAsync(t => t.CafeId == cafeId, cancellationToken); if (tableCount >= maxTables) return (false, "PLAN_LIMIT_REACHED", "Table limit reached for your plan. Please upgrade."); } } // NOTE: SMS is deliberately NOT plan-gated — marketing SMS is // bring-your-own-provider (the café's own API key + sender line), so the // café's provider account is the only limit. return (true, null, null); } }