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));
|
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 MenuAi3dUsedThisMonth,
|
||||||
int MenuAi3dMonthlyLimit,
|
int MenuAi3dMonthlyLimit,
|
||||||
bool DiscoverProfileEnabled,
|
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);
|
public record BillingVerifyResult(bool Success, string RedirectUrl);
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ public interface IBillingService
|
|||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<BillingStatusDto?> GetStatusAsync(string cafeId, PlanTier currentTier, 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
|
public class BillingService : IBillingService
|
||||||
@@ -210,31 +215,161 @@ public class BillingService : IBillingService
|
|||||||
return new BillingVerifyResult(false, failUrl);
|
return new BillingVerifyResult(false, failUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
payment.Status = SubscriptionPaymentStatus.Completed;
|
|
||||||
payment.RefId = verify.RefId;
|
payment.RefId = verify.RefId;
|
||||||
|
|
||||||
var cafe = payment.Cafe;
|
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.PlanTier = payment.PlanTier;
|
||||||
var baseDate = cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt > DateTime.UtcNow
|
cafe.PlanExpiresAt = payment.EffectiveTo;
|
||||||
? cafe.PlanExpiresAt.Value
|
}
|
||||||
: DateTime.UtcNow;
|
|
||||||
cafe.PlanExpiresAt = baseDate.AddMonths(payment.Months);
|
|
||||||
|
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
await TrySendConfirmationSmsAsync(cafe, payment, cancellationToken);
|
await TrySendConfirmationSmsAsync(cafe, payment, queued, cancellationToken);
|
||||||
|
|
||||||
return new BillingVerifyResult(true, successUrl);
|
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(
|
public async Task<BillingStatusDto?> GetStatusAsync(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
PlanTier currentTier,
|
PlanTier currentTier,
|
||||||
CancellationToken cancellationToken = default)
|
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);
|
var cafe = await _db.Cafes.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||||
if (cafe is null) return null;
|
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 todayStart = DateTime.UtcNow.Date;
|
||||||
var ordersToday = await _db.Orders.CountAsync(
|
var ordersToday = await _db.Orders.CountAsync(
|
||||||
o => o.CafeId == cafeId && o.CreatedAt >= todayStart,
|
o => o.CafeId == cafeId && o.CreatedAt >= todayStart,
|
||||||
@@ -278,12 +413,14 @@ public class BillingService : IBillingService
|
|||||||
ai3dUsedCount,
|
ai3dUsedCount,
|
||||||
ai3dLimit,
|
ai3dLimit,
|
||||||
discoverProfile,
|
discoverProfile,
|
||||||
isExpired);
|
isExpired,
|
||||||
|
queuedPlans);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task TrySendConfirmationSmsAsync(
|
private async Task TrySendConfirmationSmsAsync(
|
||||||
Cafe cafe,
|
Cafe cafe,
|
||||||
SubscriptionPayment payment,
|
SubscriptionPayment payment,
|
||||||
|
bool queued,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var ownerPhone = await _db.Employees
|
var ownerPhone = await _db.Employees
|
||||||
@@ -293,8 +430,9 @@ public class BillingService : IBillingService
|
|||||||
|
|
||||||
if (string.IsNullOrEmpty(ownerPhone)) return;
|
if (string.IsNullOrEmpty(ownerPhone)) return;
|
||||||
|
|
||||||
var message =
|
var message = queued
|
||||||
$"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت";
|
? $"میزی: اشتراک {payment.PlanTier} ثبت شد و از {payment.EffectiveFrom:yyyy-MM-dd} (پس از پایان اشتراک فعلی) آغاز میشود. مبلغ: {payment.AmountToman:N0} ت"
|
||||||
|
: $"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _smsService.SendMessageAsync(ownerPhone, message, cancellationToken);
|
await _smsService.SendMessageAsync(ownerPhone, message, cancellationToken);
|
||||||
|
|||||||
@@ -13,5 +13,13 @@ public class SubscriptionPayment : TenantEntity
|
|||||||
public string? RefId { get; set; }
|
public string? RefId { get; set; }
|
||||||
public SubscriptionPaymentStatus Status { get; set; } = SubscriptionPaymentStatus.Pending;
|
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!;
|
public Cafe Cafe { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,9 @@ public enum SubscriptionPaymentStatus
|
|||||||
{
|
{
|
||||||
Pending = 0,
|
Pending = 0,
|
||||||
Completed = 1,
|
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")
|
b.Property<DateTime?>("DeletedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.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")
|
b.Property<int>("Months")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
|||||||
@@ -1047,7 +1047,18 @@
|
|||||||
"secureNote": "تتم المعالجة عبر بوابة دفع بنكية آمنة.",
|
"secureNote": "تتم المعالجة عبر بوابة دفع بنكية آمنة.",
|
||||||
"payTotal": "ادفع {total}",
|
"payTotal": "ادفع {total}",
|
||||||
"redirecting": "جارٍ التحويل إلى البوابة...",
|
"redirecting": "جارٍ التحويل إلى البوابة...",
|
||||||
"paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى."
|
"paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى.",
|
||||||
|
"queuedNotice": "لديك اشتراك نشط بالفعل. ستتم إضافة هذا الشراء إلى قائمة الانتظار وسيبدأ في {date}."
|
||||||
|
},
|
||||||
|
"queued": {
|
||||||
|
"title": "الاشتراكات في قائمة الانتظار",
|
||||||
|
"subtitle": "تبدأ تلقائيًا عند انتهاء اشتراكك الحالي.",
|
||||||
|
"months": "{count} أشهر",
|
||||||
|
"window": "من {from} إلى {to}",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"cancelled": "تم إلغاء الاشتراك في قائمة الانتظار",
|
||||||
|
"cancelConfirmTitle": "إلغاء الاشتراك المجدول",
|
||||||
|
"cancelConfirmDesc": "إلغاء اشتراك {plan} المقرر أن يبدأ في {from}؟ لن يتأثر اشتراكك الحالي."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
@@ -1119,7 +1119,18 @@
|
|||||||
"secureNote": "Payment is processed through a secure bank gateway.",
|
"secureNote": "Payment is processed through a secure bank gateway.",
|
||||||
"payTotal": "Pay {total}",
|
"payTotal": "Pay {total}",
|
||||||
"redirecting": "Redirecting to gateway...",
|
"redirecting": "Redirecting to gateway...",
|
||||||
"paymentFailed": "Payment failed. Please try again."
|
"paymentFailed": "Payment failed. Please try again.",
|
||||||
|
"queuedNotice": "You already have an active subscription. This purchase will be queued and start on {date}."
|
||||||
|
},
|
||||||
|
"queued": {
|
||||||
|
"title": "Queued subscriptions",
|
||||||
|
"subtitle": "These start automatically when your current subscription ends.",
|
||||||
|
"months": "{count} months",
|
||||||
|
"window": "From {from} to {to}",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"cancelled": "Queued subscription cancelled",
|
||||||
|
"cancelConfirmTitle": "Cancel queued subscription",
|
||||||
|
"cancelConfirmDesc": "Cancel the {plan} subscription scheduled to start on {from}? Your current subscription is unaffected."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
@@ -1120,7 +1120,18 @@
|
|||||||
"secureNote": "پرداخت از طریق درگاه امن بانکی انجام میشود.",
|
"secureNote": "پرداخت از طریق درگاه امن بانکی انجام میشود.",
|
||||||
"payTotal": "پرداخت {total}",
|
"payTotal": "پرداخت {total}",
|
||||||
"redirecting": "در حال انتقال به درگاه...",
|
"redirecting": "در حال انتقال به درگاه...",
|
||||||
"paymentFailed": "پرداخت ناموفق بود. لطفاً دوباره امتحان کنید."
|
"paymentFailed": "پرداخت ناموفق بود. لطفاً دوباره امتحان کنید.",
|
||||||
|
"queuedNotice": "شما اشتراک فعالی دارید. این خرید در صف قرار میگیرد و از {date} آغاز میشود."
|
||||||
|
},
|
||||||
|
"queued": {
|
||||||
|
"title": "اشتراکهای در صف",
|
||||||
|
"subtitle": "این اشتراکها پس از پایان اشتراک فعلی بهصورت خودکار فعال میشوند.",
|
||||||
|
"months": "{count} ماه",
|
||||||
|
"window": "از {from} تا {to}",
|
||||||
|
"cancel": "لغو",
|
||||||
|
"cancelled": "اشتراک در صف لغو شد",
|
||||||
|
"cancelConfirmTitle": "لغو اشتراک در صف",
|
||||||
|
"cancelConfirmDesc": "اشتراک {plan} که قرار بود از {from} آغاز شود لغو شود؟ اشتراک فعلی شما دستنخورده میماند."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
@@ -68,6 +68,37 @@ export function CheckoutScreen() {
|
|||||||
enabled: !!cafeId && isCafeOwner(role),
|
enabled: !!cafeId && isCafeOwner(role),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If the owner is still covered (active plan and/or queued plans), this purchase will be
|
||||||
|
// queued to start when the current coverage ends rather than activating immediately.
|
||||||
|
const { data: billingStatus } = useQuery({
|
||||||
|
queryKey: ["billing-status", cafeId],
|
||||||
|
queryFn: () =>
|
||||||
|
apiGet<{
|
||||||
|
planTier: string;
|
||||||
|
planExpiresAt: string | null;
|
||||||
|
isPlanExpired: boolean;
|
||||||
|
queuedPlans: { effectiveTo: string }[];
|
||||||
|
}>("/api/billing/status"),
|
||||||
|
enabled: !!cafeId && isCafeOwner(role),
|
||||||
|
});
|
||||||
|
|
||||||
|
const coverageEnd = useMemo(() => {
|
||||||
|
if (!billingStatus) return null;
|
||||||
|
const now = Date.now();
|
||||||
|
let end = 0;
|
||||||
|
if (
|
||||||
|
billingStatus.planTier !== "Free" &&
|
||||||
|
billingStatus.planExpiresAt &&
|
||||||
|
!billingStatus.isPlanExpired
|
||||||
|
) {
|
||||||
|
end = Math.max(end, new Date(billingStatus.planExpiresAt).getTime());
|
||||||
|
}
|
||||||
|
for (const q of billingStatus.queuedPlans ?? []) {
|
||||||
|
end = Math.max(end, new Date(q.effectiveTo).getTime());
|
||||||
|
}
|
||||||
|
return end > now ? new Date(end) : null;
|
||||||
|
}, [billingStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!paymentMethod && paymentMethods.length > 0) {
|
if (!paymentMethod && paymentMethods.length > 0) {
|
||||||
const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0];
|
const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0];
|
||||||
@@ -140,6 +171,13 @@ export function CheckoutScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{coverageEnd ? (
|
||||||
|
<div className="flex items-start gap-2 rounded-xl border border-[#0F6E56]/30 bg-[#E1F5EE]/50 px-4 py-3 text-sm text-[#0F6E56]">
|
||||||
|
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
||||||
|
<p>{tc("queuedNotice", { date: coverageEnd.toLocaleDateString(numberLocale) })}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Factor / invoice */}
|
{/* Factor / invoice */}
|
||||||
<Card className="overflow-hidden rounded-xl border border-border/80 shadow-sm">
|
<Card className="overflow-hidden rounded-xl border border-border/80 shadow-sm">
|
||||||
{/* Invoice header */}
|
{/* Invoice header */}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { CalendarClock, Trash2 } from "lucide-react";
|
||||||
import { useRouter } from "@/i18n/routing";
|
import { useRouter } from "@/i18n/routing";
|
||||||
import { apiGet, apiPost } from "@/lib/api/client";
|
import { apiDelete, apiGet, apiPost } from "@/lib/api/client";
|
||||||
import { isCafeOwner } from "@/lib/auth-permissions";
|
import { isCafeOwner } from "@/lib/auth-permissions";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
import { formatNumber } from "@/lib/format";
|
import { formatNumber } from "@/lib/format";
|
||||||
@@ -14,9 +15,20 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { PageHeader } from "@/components/layout/page-header";
|
import { PageHeader } from "@/components/layout/page-header";
|
||||||
import { PlanComparison } from "@/components/settings/plan-comparison";
|
import { PlanComparison } from "@/components/settings/plan-comparison";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import type { AuthTokenResponse } from "@/lib/api/types";
|
import type { AuthTokenResponse } from "@/lib/api/types";
|
||||||
import { Alert } from "@/components/ui/alert";
|
import { Alert } from "@/components/ui/alert";
|
||||||
import { notify } from "@/lib/notify";
|
import { notify } from "@/lib/notify";
|
||||||
|
import { useApiError } from "@/lib/use-api-error";
|
||||||
|
|
||||||
|
type QueuedPlan = {
|
||||||
|
paymentId: string;
|
||||||
|
planTier: string;
|
||||||
|
months: number;
|
||||||
|
effectiveFrom: string;
|
||||||
|
effectiveTo: string;
|
||||||
|
amountToman: number;
|
||||||
|
};
|
||||||
|
|
||||||
type BillingStatus = {
|
type BillingStatus = {
|
||||||
planTier: string;
|
planTier: string;
|
||||||
@@ -30,6 +42,7 @@ type BillingStatus = {
|
|||||||
menu3dEnabled: boolean;
|
menu3dEnabled: boolean;
|
||||||
discoverProfileEnabled: boolean;
|
discoverProfileEnabled: boolean;
|
||||||
isPlanExpired: boolean;
|
isPlanExpired: boolean;
|
||||||
|
queuedPlans: QueuedPlan[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SubscriptionScreen() {
|
export function SubscriptionScreen() {
|
||||||
@@ -40,8 +53,11 @@ export function SubscriptionScreen() {
|
|||||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||||
const role = useAuthStore((s) => s.user?.role);
|
const role = useAuthStore((s) => s.user?.role);
|
||||||
const setAuth = useAuthStore((s) => s.setAuth);
|
const setAuth = useAuthStore((s) => s.setAuth);
|
||||||
|
const apiError = useApiError();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const billingRefreshed = useRef(false);
|
const billingRefreshed = useRef(false);
|
||||||
const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null);
|
const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null);
|
||||||
|
const [cancelTarget, setCancelTarget] = useState<QueuedPlan | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const billing = searchParams.get("billing");
|
const billing = searchParams.get("billing");
|
||||||
@@ -61,6 +77,18 @@ export function SubscriptionScreen() {
|
|||||||
enabled: !!cafeId,
|
enabled: !!cafeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cancelQueued = useMutation({
|
||||||
|
mutationFn: (paymentId: string) => apiDelete(`/api/billing/queued/${paymentId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
setCancelTarget(null);
|
||||||
|
notify.success(t("queued.cancelled"));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["billing-status", cafeId] });
|
||||||
|
},
|
||||||
|
onError: (err) => notify.error(apiError(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fmtDate = (iso: string) => new Date(iso).toLocaleDateString("fa-IR");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchParams.get("billing") !== "success" || billingRefreshed.current) return;
|
if (searchParams.get("billing") !== "success" || billingRefreshed.current) return;
|
||||||
const refresh = localStorage.getItem("meezi_refresh_token");
|
const refresh = localStorage.getItem("meezi_refresh_token");
|
||||||
@@ -155,12 +183,72 @@ export function SubscriptionScreen() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{status?.queuedPlans && status.queuedPlans.length > 0 ? (
|
||||||
|
<Card className="rounded-xl border border-[#0F6E56]/30 bg-[#E1F5EE]/30 shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<CalendarClock className="size-4 text-[#0F6E56]" aria-hidden />
|
||||||
|
{t("queued.title")}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("queued.subtitle")}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{status.queuedPlans.map((q) => (
|
||||||
|
<div
|
||||||
|
key={q.paymentId}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border/70 bg-card px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge>{q.planTier}</Badge>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t("queued.months", { count: formatNumber(q.months) })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t("queued.window", { from: fmtDate(q.effectiveFrom), to: fmtDate(q.effectiveTo) })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
onClick={() => setCancelTarget(q)}
|
||||||
|
>
|
||||||
|
<Trash2 className="me-1.5 size-4" />
|
||||||
|
{t("queued.cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<PlanComparison
|
<PlanComparison
|
||||||
currentPlan={status?.planTier ?? "Free"}
|
currentPlan={status?.planTier ?? "Free"}
|
||||||
onSubscribe={(planTier) =>
|
onSubscribe={(planTier) =>
|
||||||
router.push(`/subscription/checkout?plan=${planTier}`)
|
router.push(`/subscription/checkout?plan=${planTier}`)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!cancelTarget}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setCancelTarget(null);
|
||||||
|
}}
|
||||||
|
title={t("queued.cancelConfirmTitle")}
|
||||||
|
description={
|
||||||
|
cancelTarget
|
||||||
|
? t("queued.cancelConfirmDesc", {
|
||||||
|
plan: cancelTarget.planTier,
|
||||||
|
from: fmtDate(cancelTarget.effectiveFrom),
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
confirmLabel={t("queued.cancel")}
|
||||||
|
busy={cancelQueued.isPending}
|
||||||
|
onConfirm={() => cancelTarget && cancelQueued.mutate(cancelTarget.paymentId)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user