bb0be19dac
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m1s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 49s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m3s
CI/CD / CI · Admin Web (tsc) (push) Successful in 34s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Successful in 3m9s
Before, buying a plan immediately switched the tier and stacked the duration.
Now a purchase made while the café still has paid coverage is QUEUED to start
when the current coverage ends, and the owner can cancel a queued one.
Model:
- SubscriptionPayment gains EffectiveFrom/EffectiveTo; status gains Scheduled
(paid, queued) and Cancelled. EF migration AddSubscriptionScheduling (nullable).
BillingService:
- On payment completion, compute coverage end (latest of active expiry + furthest
queued period). If it is in the future → Scheduled (queued, café tier/expiry
untouched); else activate immediately as before. Periods chain correctly.
- GetStatusAsync lazily promotes any due queued period to active, and returns the
queue (QueuedPlans).
- CancelQueuedAsync cancels a Scheduled period (owner-only) and re-packs the queue
so later periods slide earlier. Active prepaid plan is never cut short; no
automatic refund (manual, per product decision).
- Confirmation SMS distinguishes "activated until X" vs "queued, starts X".
API: BillingStatusDto.QueuedPlans + DELETE /api/billing/queued/{paymentId}.
Dashboard:
- Subscription screen shows a "Queued subscriptions" card (tier, window, cancel
with confirm).
- Checkout shows "you already have an active subscription — this will start on
{date}" when the café is still covered.
- i18n fa/en/ar.
81 API tests pass; dashboard typechecks.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
446 lines
18 KiB
C#
446 lines
18 KiB
C#
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);
|
|
|
|
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<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;
|
|
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);
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
public async Task<BillingStatusDto?> 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);
|
|
}
|
|
}
|
|
}
|