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,307 @@
|
||||
using Meezi.API.Models.Billing;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IBillingService
|
||||
{
|
||||
Task<IReadOnlyList<PaymentMethodDto>> GetPaymentMethodsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(SubscribeResponse? Data, string? ErrorCode, string? Message)> InitiateSubscriptionAsync(
|
||||
string cafeId,
|
||||
SubscribeRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BillingVerifyResult> VerifyZarinPalAsync(
|
||||
string authority,
|
||||
string? status,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BillingVerifyResult> VerifySnappPayAsync(
|
||||
string? paymentToken,
|
||||
string? state,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BillingVerifyResult> VerifyTaraAsync(
|
||||
string? traceNumber,
|
||||
string? status,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<BillingStatusDto?> GetStatusAsync(string cafeId, PlanTier currentTier, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class BillingService : IBillingService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IBillingPaymentOrchestrator _payments;
|
||||
private readonly ISmsService _smsService;
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IPlatformCatalogService _platformCatalog;
|
||||
private readonly ILogger<BillingService> _logger;
|
||||
|
||||
public BillingService(
|
||||
AppDbContext db,
|
||||
IBillingPaymentOrchestrator payments,
|
||||
ISmsService smsService,
|
||||
IConnectionMultiplexer redis,
|
||||
IConfiguration configuration,
|
||||
IPlatformCatalogService platformCatalog,
|
||||
ILogger<BillingService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_payments = payments;
|
||||
_smsService = smsService;
|
||||
_redis = redis;
|
||||
_configuration = configuration;
|
||||
_platformCatalog = platformCatalog;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private const string FeatureMenu3d = "menu_3d";
|
||||
private const string FeatureMenu3dAi = "menu_3d_ai";
|
||||
private const string FeatureDiscoverProfile = "discover_profile";
|
||||
|
||||
public async Task<(SubscribeResponse? Data, string? ErrorCode, string? Message)> InitiateSubscriptionAsync(
|
||||
string cafeId,
|
||||
SubscribeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||
if (cafe is null)
|
||||
return (null, "NOT_FOUND", "Cafe not found.");
|
||||
|
||||
if (!await _platformCatalog.IsBillableOnlineAsync(request.PlanTier, cancellationToken))
|
||||
return (null, "NOT_BILLABLE", "This plan requires contacting sales.");
|
||||
|
||||
var monthly = await _platformCatalog.GetMonthlyPriceTomanAsync(request.PlanTier, cancellationToken);
|
||||
if (monthly <= 0)
|
||||
return (null, "NOT_BILLABLE", "This plan has no online price.");
|
||||
|
||||
var amountToman = monthly * request.Months;
|
||||
var amountRials = PlanPricing.ToRials(amountToman);
|
||||
|
||||
var methods = await _payments.GetEnabledMethodsAsync(cancellationToken);
|
||||
var methodId = string.IsNullOrWhiteSpace(request.PaymentMethod)
|
||||
? methods.FirstOrDefault(m => m.IsDefault)?.Id ?? PaymentProviderIds.ZarinPal
|
||||
: request.PaymentMethod.Trim().ToLowerInvariant();
|
||||
|
||||
var provider = PaymentProviderIds.Parse(methodId);
|
||||
if (provider is null || methods.All(m => m.Id != methodId))
|
||||
return (null, "PAYMENT_METHOD_DISABLED", "Selected payment method is not available.");
|
||||
|
||||
var payment = new SubscriptionPayment
|
||||
{
|
||||
CafeId = cafeId,
|
||||
PlanTier = request.PlanTier,
|
||||
Months = request.Months,
|
||||
AmountToman = amountToman,
|
||||
AmountRials = amountRials,
|
||||
Provider = provider.Value,
|
||||
Status = SubscriptionPaymentStatus.Pending
|
||||
};
|
||||
|
||||
_db.SubscriptionPayments.Add(payment);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var apiBase = _configuration["App:PublicBaseUrl"]?.TrimEnd('/') ?? "http://localhost:5080";
|
||||
var callbackUrl = provider.Value switch
|
||||
{
|
||||
PaymentProvider.SnappPay => $"{apiBase}/api/billing/verify/snapppay",
|
||||
PaymentProvider.Tara => $"{apiBase}/api/billing/verify/tara",
|
||||
_ => $"{apiBase}/api/billing/verify"
|
||||
};
|
||||
|
||||
var description = $"میزی — اشتراک {request.PlanTier} ({request.Months} ماه)";
|
||||
var init = await _payments.InitiateAsync(
|
||||
provider.Value,
|
||||
amountRials,
|
||||
payment.Id,
|
||||
description,
|
||||
callbackUrl,
|
||||
cancellationToken);
|
||||
|
||||
if (!init.Success || string.IsNullOrEmpty(init.Authority) || string.IsNullOrEmpty(init.PaymentUrl))
|
||||
{
|
||||
payment.Status = SubscriptionPaymentStatus.Failed;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return (null, "PAYMENT_FAILED", init.ErrorMessage ?? "Could not start payment.");
|
||||
}
|
||||
|
||||
payment.Authority = init.Authority;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (new SubscribeResponse(payment.Id, init.PaymentUrl), null, null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PaymentMethodDto>> GetPaymentMethodsAsync(CancellationToken cancellationToken = default) =>
|
||||
_payments.GetEnabledMethodsAsync(cancellationToken);
|
||||
|
||||
public Task<BillingVerifyResult> VerifyZarinPalAsync(
|
||||
string authority,
|
||||
string? status,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
CompletePaymentAsync(
|
||||
PaymentProvider.ZarinPal,
|
||||
authority,
|
||||
status is null or "" or "OK",
|
||||
cancellationToken);
|
||||
|
||||
public Task<BillingVerifyResult> VerifySnappPayAsync(
|
||||
string? paymentToken,
|
||||
string? state,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
CompletePaymentAsync(
|
||||
PaymentProvider.SnappPay,
|
||||
paymentToken,
|
||||
!string.IsNullOrWhiteSpace(paymentToken)
|
||||
&& (string.IsNullOrWhiteSpace(state) || state.Equals("OK", StringComparison.OrdinalIgnoreCase)),
|
||||
cancellationToken);
|
||||
|
||||
public Task<BillingVerifyResult> VerifyTaraAsync(
|
||||
string? traceNumber,
|
||||
string? status,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
CompletePaymentAsync(
|
||||
PaymentProvider.Tara,
|
||||
traceNumber,
|
||||
status is null or "" or "OK",
|
||||
cancellationToken);
|
||||
|
||||
private async Task<BillingVerifyResult> CompletePaymentAsync(
|
||||
PaymentProvider provider,
|
||||
string? externalId,
|
||||
bool callbackOk,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var dashboardBase = _configuration["Billing:DashboardBaseUrl"]?.TrimEnd('/')
|
||||
?? _configuration.GetSection("Cors:Origins").Get<string[]>()?.FirstOrDefault()
|
||||
?? "http://localhost:3101";
|
||||
var failUrl = $"{dashboardBase}/fa/subscription?billing=failed";
|
||||
var successUrl = $"{dashboardBase}/fa/subscription?billing=success";
|
||||
|
||||
if (!callbackOk || string.IsNullOrWhiteSpace(externalId))
|
||||
return new BillingVerifyResult(false, failUrl);
|
||||
|
||||
var payment = await _db.SubscriptionPayments
|
||||
.Include(p => p.Cafe)
|
||||
.FirstOrDefaultAsync(
|
||||
p => p.Authority == externalId && p.Provider == provider,
|
||||
cancellationToken);
|
||||
|
||||
if (payment is null)
|
||||
return new BillingVerifyResult(false, failUrl);
|
||||
|
||||
if (payment.Status == SubscriptionPaymentStatus.Completed)
|
||||
return new BillingVerifyResult(true, successUrl);
|
||||
|
||||
var verify = await _payments.VerifyAsync(provider, externalId, payment.AmountRials, cancellationToken);
|
||||
if (!verify.Success)
|
||||
{
|
||||
payment.Status = SubscriptionPaymentStatus.Failed;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return new BillingVerifyResult(false, failUrl);
|
||||
}
|
||||
|
||||
payment.Status = SubscriptionPaymentStatus.Completed;
|
||||
payment.RefId = verify.RefId;
|
||||
|
||||
var cafe = payment.Cafe;
|
||||
cafe.PlanTier = payment.PlanTier;
|
||||
var baseDate = cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt > DateTime.UtcNow
|
||||
? cafe.PlanExpiresAt.Value
|
||||
: DateTime.UtcNow;
|
||||
cafe.PlanExpiresAt = baseDate.AddMonths(payment.Months);
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await TrySendConfirmationSmsAsync(cafe, payment, cancellationToken);
|
||||
|
||||
return new BillingVerifyResult(true, successUrl);
|
||||
}
|
||||
|
||||
public async Task<BillingStatusDto?> GetStatusAsync(
|
||||
string cafeId,
|
||||
PlanTier currentTier,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cafe = await _db.Cafes.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||
if (cafe is null) return null;
|
||||
|
||||
var todayStart = DateTime.UtcNow.Date;
|
||||
var ordersToday = await _db.Orders.CountAsync(
|
||||
o => o.CafeId == cafeId && o.CreatedAt >= todayStart,
|
||||
cancellationToken);
|
||||
|
||||
var customersCount = await _db.Customers.CountAsync(c => c.CafeId == cafeId, cancellationToken);
|
||||
|
||||
var maxOrders = PlanLimits.MaxOrdersPerDay(cafe.PlanTier);
|
||||
var maxCustomers = PlanLimits.MaxCustomers(cafe.PlanTier);
|
||||
var maxSms = PlanLimits.MaxSmsPerMonth(cafe.PlanTier);
|
||||
|
||||
var monthKey = $"sms:usage:{cafeId}:{DateTime.UtcNow:yyyy-MM}";
|
||||
var redis = _redis.GetDatabase();
|
||||
var smsUsed = await redis.StringGetAsync(monthKey);
|
||||
var smsUsedCount = smsUsed.HasValue ? (int)smsUsed : 0;
|
||||
|
||||
var menu3d = await _platformCatalog.IsFeatureEnabledForCafeAsync(
|
||||
cafeId, cafe.PlanTier, FeatureMenu3d, cancellationToken);
|
||||
var menuAi3d = await _platformCatalog.IsFeatureEnabledForCafeAsync(
|
||||
cafeId, cafe.PlanTier, FeatureMenu3dAi, cancellationToken);
|
||||
var discoverProfile = await _platformCatalog.IsFeatureEnabledForCafeAsync(
|
||||
cafeId, cafe.PlanTier, FeatureDiscoverProfile, cancellationToken);
|
||||
var isExpired = cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value < DateTime.UtcNow;
|
||||
|
||||
var ai3dKey = $"ai3d:usage:{cafeId}:{DateTime.UtcNow:yyyy-MM}";
|
||||
var ai3dUsed = await redis.StringGetAsync(ai3dKey);
|
||||
var ai3dUsedCount = ai3dUsed.HasValue && int.TryParse(ai3dUsed.ToString(), out var aiN) ? aiN : 0;
|
||||
var ai3dLimit = menuAi3d ? PlanLimits.MaxMenuAi3dPerMonth(cafe.PlanTier) : 0;
|
||||
|
||||
return new BillingStatusDto(
|
||||
cafe.PlanTier,
|
||||
cafe.PlanExpiresAt,
|
||||
ordersToday,
|
||||
maxOrders == int.MaxValue ? null : maxOrders,
|
||||
customersCount,
|
||||
maxCustomers == int.MaxValue ? null : maxCustomers,
|
||||
smsUsedCount,
|
||||
maxSms == int.MaxValue ? -1 : maxSms,
|
||||
menu3d,
|
||||
menuAi3d,
|
||||
ai3dUsedCount,
|
||||
ai3dLimit,
|
||||
discoverProfile,
|
||||
isExpired);
|
||||
}
|
||||
|
||||
private async Task TrySendConfirmationSmsAsync(
|
||||
Cafe cafe,
|
||||
SubscriptionPayment payment,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerPhone = await _db.Employees
|
||||
.Where(e => e.CafeId == cafe.Id && e.Role == EmployeeRole.Owner)
|
||||
.Select(e => e.Phone)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (string.IsNullOrEmpty(ownerPhone)) return;
|
||||
|
||||
var message =
|
||||
$"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت";
|
||||
try
|
||||
{
|
||||
await _smsService.SendMessageAsync(ownerPhone, message, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send subscription confirmation SMS for cafe {CafeId}", cafe.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user