2026-05-27 21:33:48 +03:30
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 ) ;
2026-06-02 16:44:32 +03:30
Task < ( bool Ok , string? ErrorCode , string? Message ) > CancelQueuedAsync (
string cafeId ,
string paymentId ,
CancellationToken cancellationToken = default ) ;
2026-05-27 21:33:48 +03:30
}
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 . RefId = verify . RefId ;
var cafe = payment . Cafe ;
2026-06-02 16:44:32 +03:30
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 ;
}
2026-05-27 21:33:48 +03:30
await _db . SaveChangesAsync ( cancellationToken ) ;
2026-06-02 16:44:32 +03:30
await TrySendConfirmationSmsAsync ( cafe , payment , queued , cancellationToken ) ;
2026-05-27 21:33:48 +03:30
return new BillingVerifyResult ( true , successUrl ) ;
}
2026-06-02 16:44:32 +03:30
/// <summary>End of the cafe's current paid coverage: the later of its active plan expiry
/// and the furthest-out scheduled (queued) period. Returns <paramref name="now"/> if neither
/// extends past now (i.e. nothing active/queued).</summary>
private async Task < DateTime > 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 ;
}
/// <summary>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.</summary>
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 ) ;
}
/// <summary>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.</summary>
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 ) ;
}
2026-05-27 21:33:48 +03:30
public async Task < BillingStatusDto ? > GetStatusAsync (
string cafeId ,
PlanTier currentTier ,
CancellationToken cancellationToken = default )
{
2026-06-02 16:44:32 +03:30
// Lazily activate any queued plan whose start date has passed before reading status.
await PromoteDueScheduledAsync ( cafeId , cancellationToken ) ;
2026-05-27 21:33:48 +03:30
var cafe = await _db . Cafes . AsNoTracking ( ) . FirstOrDefaultAsync ( c = > c . Id = = cafeId , cancellationToken ) ;
if ( cafe is null ) return null ;
2026-06-02 16:44:32 +03:30
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 ) ;
2026-05-27 21:33:48 +03:30
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 ,
2026-06-02 16:44:32 +03:30
isExpired ,
queuedPlans ) ;
2026-05-27 21:33:48 +03:30
}
private async Task TrySendConfirmationSmsAsync (
Cafe cafe ,
SubscriptionPayment payment ,
2026-06-02 16:44:32 +03:30
bool queued ,
2026-05-27 21:33:48 +03:30
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 ;
2026-06-02 16:44:32 +03:30
var message = queued
? $"میزی: اشتراک {payment.PlanTier} ثبت شد و از {payment.EffectiveFrom:yyyy-MM-dd} (پس از پایان اشتراک فعلی) آغاز میشود. مبلغ: {payment.AmountToman:N0} ت"
: $"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت" ;
2026-05-27 21:33:48 +03:30
try
{
await _smsService . SendMessageAsync ( ownerPhone , message , cancellationToken ) ;
}
catch ( Exception ex )
{
_logger . LogWarning ( ex , "Failed to send subscription confirmation SMS for cafe {CafeId}" , cafe . Id ) ;
}
}
}