Files
meezi/src/Meezi.API/Services/PlanLimitChecker.cs
T
soroush.asadi 00649d0248
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 5m16s
feat(sms): bring-your-own-provider — cafés use their own SMS account
The platform no longer sells SMS. Each café saves its OWN Kavenegar API
key + sender line (new Cafes columns + migration) and campaigns are sent
and billed through that account.

Backend:
- GET/PUT /sms/settings (Manager/Owner; key echoed masked, verified
  against the provider before saving)
- campaign + balance use the café's credentials; SMS_NOT_CONFIGURED
  error when missing; plan-tier SMS gating removed everywhere
  (PlanLimitChecker, SmsMarketingService, billing status)
- platform Kavenegar config stays ONLY for login OTPs (env/DB)
- design-time DbContext factory so `dotnet ef migrations add` works
  without booting the host

Dashboard:
- SMS screen: provider-settings card, not-configured callout, campaign
  form disabled until configured; quota bar removed (usage stays as info)
- subscription screen + plan comparison no longer show SMS limits

Admin panel:
- Kavenegar/SMS section removed from integrations (request field now
  optional; stored OTP config untouched)
- SMS limit field removed from the plan editor
- nav label "درگاه و پیامک" → "درگاه پرداخت و AI"

fa/en/ar translations. 86 tests pass; all tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:23:50 +03:30

124 lines
5.1 KiB
C#

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);
}
}