feat(api): .NET 10 multi-tenant REST API
Full backend implementation: - Multi-tenant cafe/restaurant management (menus, orders, tables, staff) - POS order flow with ZarinPal and Snappfood payment integration - OTP authentication via Kavenegar SMS - QR digital menu with public discover/finder endpoints - Customer loyalty, coupons, CRM - PostgreSQL via EF Core, Redis for caching/sessions - Background jobs, webhook handlers - Full migration history Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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 smsCampaignPath = $"/api/cafes/{cafeId}/sms/campaign";
|
||||
if (path.Equals(smsCampaignPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Equals($"{smsCampaignPath}/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var limitsSms = await _platformCatalog.GetLimitsAsync(tier, cancellationToken);
|
||||
var maxSms = limitsSms.MaxSmsPerMonth;
|
||||
if (maxSms == 0)
|
||||
return (false, "PLAN_LIMIT_REACHED", "SMS is not available on the Free plan. Please upgrade.");
|
||||
|
||||
if (maxSms == int.MaxValue)
|
||||
return (true, null, null);
|
||||
|
||||
var monthKey = $"sms:usage:{cafeId}:{DateTime.UtcNow:yyyy-MM}";
|
||||
var redis = _redis.GetDatabase();
|
||||
var used = await redis.StringGetAsync(monthKey);
|
||||
var usedCount = used.HasValue ? (int)used : 0;
|
||||
|
||||
if (usedCount >= maxSms)
|
||||
return (false, "PLAN_LIMIT_REACHED", "Monthly SMS limit reached for your plan. Please upgrade.");
|
||||
}
|
||||
|
||||
return (true, null, null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user