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
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>
124 lines
5.1 KiB
C#
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);
|
|
}
|
|
}
|