feat(billing): queue subscriptions bought while active + cancel queued
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
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>
This commit is contained in:
@@ -103,4 +103,25 @@ public class BillingController : ControllerBase
|
||||
|
||||
return Ok(new ApiResponse<BillingStatusDto>(true, data));
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpDelete("api/billing/queued/{paymentId}")]
|
||||
public async Task<IActionResult> CancelQueued(string paymentId, ITenantContext tenant, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenant.CafeId))
|
||||
return Unauthorized();
|
||||
if (tenant.Role != Core.Enums.EmployeeRole.Owner)
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("OWNER_REQUIRED", "Only the cafe owner can manage subscription billing.")));
|
||||
|
||||
var (ok, code, message) = await _billing.CancelQueuedAsync(tenant.CafeId, paymentId, ct);
|
||||
if (!ok)
|
||||
{
|
||||
return code == "NOT_FOUND"
|
||||
? NotFound(new ApiResponse<object>(false, null, new ApiError(code, message ?? "Not found.")))
|
||||
: BadRequest(new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
|
||||
}
|
||||
|
||||
return Ok(new ApiResponse<object>(true, new { id = paymentId }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,15 @@ public record BillingStatusDto(
|
||||
int MenuAi3dUsedThisMonth,
|
||||
int MenuAi3dMonthlyLimit,
|
||||
bool DiscoverProfileEnabled,
|
||||
bool IsPlanExpired);
|
||||
bool IsPlanExpired,
|
||||
IReadOnlyList<QueuedPlanDto> QueuedPlans);
|
||||
|
||||
public record QueuedPlanDto(
|
||||
string PaymentId,
|
||||
PlanTier PlanTier,
|
||||
int Months,
|
||||
DateTime EffectiveFrom,
|
||||
DateTime EffectiveTo,
|
||||
decimal AmountToman);
|
||||
|
||||
public record BillingVerifyResult(bool Success, string RedirectUrl);
|
||||
|
||||
@@ -35,6 +35,11 @@ public interface IBillingService
|
||||
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
|
||||
@@ -210,31 +215,161 @@ public class BillingService : IBillingService
|
||||
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);
|
||||
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, 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,
|
||||
@@ -278,12 +413,14 @@ public class BillingService : IBillingService
|
||||
ai3dUsedCount,
|
||||
ai3dLimit,
|
||||
discoverProfile,
|
||||
isExpired);
|
||||
isExpired,
|
||||
queuedPlans);
|
||||
}
|
||||
|
||||
private async Task TrySendConfirmationSmsAsync(
|
||||
Cafe cafe,
|
||||
SubscriptionPayment payment,
|
||||
bool queued,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerPhone = await _db.Employees
|
||||
@@ -293,8 +430,9 @@ public class BillingService : IBillingService
|
||||
|
||||
if (string.IsNullOrEmpty(ownerPhone)) return;
|
||||
|
||||
var message =
|
||||
$"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت";
|
||||
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);
|
||||
|
||||
@@ -13,5 +13,13 @@ public class SubscriptionPayment : TenantEntity
|
||||
public string? RefId { get; set; }
|
||||
public SubscriptionPaymentStatus Status { get; set; } = SubscriptionPaymentStatus.Pending;
|
||||
|
||||
/// <summary>When this paid period starts. For an immediately-activated purchase this is
|
||||
/// (around) the payment time; for a queued (Scheduled) purchase it is the end of the
|
||||
/// current coverage. Null until the payment completes.</summary>
|
||||
public DateTime? EffectiveFrom { get; set; }
|
||||
|
||||
/// <summary>When this paid period ends (EffectiveFrom + Months). Null until completed.</summary>
|
||||
public DateTime? EffectiveTo { get; set; }
|
||||
|
||||
public Cafe Cafe { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -4,5 +4,9 @@ public enum SubscriptionPaymentStatus
|
||||
{
|
||||
Pending = 0,
|
||||
Completed = 1,
|
||||
Failed = 2
|
||||
Failed = 2,
|
||||
/// <summary>Paid, but queued to start after the current coverage ends.</summary>
|
||||
Scheduled = 3,
|
||||
/// <summary>A queued (Scheduled) subscription the owner cancelled before it started.</summary>
|
||||
Cancelled = 4
|
||||
}
|
||||
|
||||
Generated
+3316
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Meezi.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSubscriptionScheduling : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "EffectiveFrom",
|
||||
table: "SubscriptionPayments",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "EffectiveTo",
|
||||
table: "SubscriptionPayments",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EffectiveFrom",
|
||||
table: "SubscriptionPayments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EffectiveTo",
|
||||
table: "SubscriptionPayments");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2011,6 +2011,12 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("EffectiveFrom")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("EffectiveTo")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Months")
|
||||
.HasColumnType("integer");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user