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> GetPaymentMethodsAsync(CancellationToken cancellationToken = default); Task<(SubscribeResponse? Data, string? ErrorCode, string? Message)> InitiateSubscriptionAsync( string cafeId, SubscribeRequest request, CancellationToken cancellationToken = default); Task VerifyZarinPalAsync( string authority, string? status, CancellationToken cancellationToken = default); Task VerifySnappPayAsync( string? paymentToken, string? state, CancellationToken cancellationToken = default); Task VerifyTaraAsync( string? traceNumber, string? status, CancellationToken cancellationToken = default); Task GetStatusAsync(string cafeId, PlanTier currentTier, CancellationToken cancellationToken = default); Task<(bool Ok, string? ErrorCode, string? Message)> CancelQueuedAsync( string cafeId, string paymentId, 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 _logger; public BillingService( AppDbContext db, IBillingPaymentOrchestrator payments, ISmsService smsService, IConnectionMultiplexer redis, IConfiguration configuration, IPlatformCatalogService platformCatalog, ILogger 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> GetPaymentMethodsAsync(CancellationToken cancellationToken = default) => _payments.GetEnabledMethodsAsync(cancellationToken); public Task VerifyZarinPalAsync( string authority, string? status, CancellationToken cancellationToken = default) => CompletePaymentAsync( PaymentProvider.ZarinPal, authority, status is null or "" or "OK", cancellationToken); public Task 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 VerifyTaraAsync( string? traceNumber, string? status, CancellationToken cancellationToken = default) => CompletePaymentAsync( PaymentProvider.Tara, traceNumber, status is null or "" or "OK", cancellationToken); private async Task CompletePaymentAsync( PaymentProvider provider, string? externalId, bool callbackOk, CancellationToken cancellationToken) { var dashboardBase = _configuration["Billing:DashboardBaseUrl"]?.TrimEnd('/') ?? _configuration.GetSection("Cors:Origins").Get()?.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.RefId = verify.RefId; var cafe = payment.Cafe; var now = DateTime.UtcNow; // Where does the current paid coverage end? = the latest of the active plan's expiry // and the furthest-out already-queued period. A new purchase is appended to that. var coverageEnd = await ComputeCoverageEndAsync(cafe, payment.Id, now, cancellationToken); payment.EffectiveFrom = coverageEnd; payment.EffectiveTo = coverageEnd.AddMonths(payment.Months); var queued = coverageEnd > now; if (queued) { // The owner already has active/queued coverage → book this one after it. payment.Status = SubscriptionPaymentStatus.Scheduled; } else { // No active coverage → activate immediately. payment.Status = SubscriptionPaymentStatus.Completed; cafe.PlanTier = payment.PlanTier; cafe.PlanExpiresAt = payment.EffectiveTo; } await _db.SaveChangesAsync(cancellationToken); await TrySendConfirmationSmsAsync(cafe, payment, queued, cancellationToken); return new BillingVerifyResult(true, successUrl); } /// End of the cafe's current paid coverage: the later of its active plan expiry /// and the furthest-out scheduled (queued) period. Returns if neither /// extends past now (i.e. nothing active/queued). private async Task ComputeCoverageEndAsync( Cafe cafe, string? excludePaymentId, DateTime now, CancellationToken ct) { var end = now; if (cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > end) end = cafe.PlanExpiresAt.Value; var lastScheduledEnd = await _db.SubscriptionPayments .Where(p => p.CafeId == cafe.Id && p.Status == SubscriptionPaymentStatus.Scheduled && (excludePaymentId == null || p.Id != excludePaymentId) && p.EffectiveTo != null) .OrderByDescending(p => p.EffectiveTo) .Select(p => p.EffectiveTo) .FirstOrDefaultAsync(ct); if (lastScheduledEnd.HasValue && lastScheduledEnd.Value > end) end = lastScheduledEnd.Value; return end; } /// When the active plan has lapsed, promote due queued periods to active. /// Loops so a fully-elapsed short queued period doesn't strand the next one. private async Task PromoteDueScheduledAsync(string cafeId, CancellationToken ct) { var now = DateTime.UtcNow; var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct); if (cafe is null) return; var changed = false; while (!(cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > now)) { var next = await _db.SubscriptionPayments .Where(p => p.CafeId == cafeId && p.Status == SubscriptionPaymentStatus.Scheduled && p.EffectiveFrom != null && p.EffectiveFrom <= now) .OrderBy(p => p.EffectiveFrom) .FirstOrDefaultAsync(ct); if (next is null) break; cafe.PlanTier = next.PlanTier; cafe.PlanExpiresAt = next.EffectiveTo; next.Status = SubscriptionPaymentStatus.Completed; changed = true; } if (changed) await _db.SaveChangesAsync(ct); } public async Task<(bool Ok, string? ErrorCode, string? Message)> CancelQueuedAsync( string cafeId, string paymentId, CancellationToken cancellationToken = default) { var payment = await _db.SubscriptionPayments .FirstOrDefaultAsync(p => p.Id == paymentId && p.CafeId == cafeId, cancellationToken); if (payment is null) return (false, "NOT_FOUND", "Subscription not found."); // Only a queued (not-yet-started) subscription can be cancelled. The active prepaid // plan keeps running until its paid time ends. if (payment.Status != SubscriptionPaymentStatus.Scheduled) return (false, "NOT_CANCELLABLE", "Only a queued subscription can be cancelled."); payment.Status = SubscriptionPaymentStatus.Cancelled; await _db.SaveChangesAsync(cancellationToken); // Re-pack the remaining queue so later periods slide earlier to fill the gap. await RecomputeQueueAsync(cafeId, cancellationToken); return (true, null, null); } /// Re-sequences the remaining queued periods contiguously after the active plan /// (purchase order preserved), so cancelling one in the middle doesn't leave a gap. private async Task RecomputeQueueAsync(string cafeId, CancellationToken ct) { var now = DateTime.UtcNow; var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct); if (cafe is null) return; var anchor = (cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > now) ? cafe.PlanExpiresAt.Value : now; var scheduled = await _db.SubscriptionPayments .Where(p => p.CafeId == cafeId && p.Status == SubscriptionPaymentStatus.Scheduled) .OrderBy(p => p.CreatedAt) .ToListAsync(ct); foreach (var s in scheduled) { s.EffectiveFrom = anchor; s.EffectiveTo = anchor.AddMonths(s.Months); anchor = s.EffectiveTo.Value; } if (scheduled.Count > 0) await _db.SaveChangesAsync(ct); } public async Task GetStatusAsync( string cafeId, PlanTier currentTier, CancellationToken cancellationToken = default) { // Lazily activate any queued plan whose start date has passed before reading status. await PromoteDueScheduledAsync(cafeId, cancellationToken); var cafe = await _db.Cafes.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken); if (cafe is null) return null; var queuedPlans = await _db.SubscriptionPayments.AsNoTracking() .Where(p => p.CafeId == cafeId && p.Status == SubscriptionPaymentStatus.Scheduled && p.EffectiveFrom != null && p.EffectiveTo != null) .OrderBy(p => p.EffectiveFrom) .Select(p => new QueuedPlanDto( p.Id, p.PlanTier, p.Months, p.EffectiveFrom!.Value, p.EffectiveTo!.Value, p.AmountToman)) .ToListAsync(cancellationToken); 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, queuedPlans); } private async Task TrySendConfirmationSmsAsync( Cafe cafe, SubscriptionPayment payment, bool queued, 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 = queued ? $"میزی: اشتراک {payment.PlanTier} ثبت شد و از {payment.EffectiveFrom:yyyy-MM-dd} (پس از پایان اشتراک فعلی) آغاز می‌شود. مبلغ: {payment.AmountToman:N0} ت" : $"میزی: اشتراک {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); } } }