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

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:
soroush.asadi
2026-06-02 16:44:32 +03:30
parent 15def7ff1c
commit bb0be19dac
13 changed files with 3717 additions and 17 deletions
+12 -1
View File
@@ -1047,7 +1047,18 @@
"secureNote": "تتم المعالجة عبر بوابة دفع بنكية آمنة.",
"payTotal": "ادفع {total}",
"redirecting": "جارٍ التحويل إلى البوابة...",
"paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى."
"paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى.",
"queuedNotice": "لديك اشتراك نشط بالفعل. ستتم إضافة هذا الشراء إلى قائمة الانتظار وسيبدأ في {date}."
},
"queued": {
"title": "الاشتراكات في قائمة الانتظار",
"subtitle": "تبدأ تلقائيًا عند انتهاء اشتراكك الحالي.",
"months": "{count} أشهر",
"window": "من {from} إلى {to}",
"cancel": "إلغاء",
"cancelled": "تم إلغاء الاشتراك في قائمة الانتظار",
"cancelConfirmTitle": "إلغاء الاشتراك المجدول",
"cancelConfirmDesc": "إلغاء اشتراك {plan} المقرر أن يبدأ في {from}؟ لن يتأثر اشتراكك الحالي."
}
},
"settings": {
+12 -1
View File
@@ -1119,7 +1119,18 @@
"secureNote": "Payment is processed through a secure bank gateway.",
"payTotal": "Pay {total}",
"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": {
+12 -1
View File
@@ -1120,7 +1120,18 @@
"secureNote": "پرداخت از طریق درگاه امن بانکی انجام می‌شود.",
"payTotal": "پرداخت {total}",
"redirecting": "در حال انتقال به درگاه...",
"paymentFailed": "پرداخت ناموفق بود. لطفاً دوباره امتحان کنید."
"paymentFailed": "پرداخت ناموفق بود. لطفاً دوباره امتحان کنید.",
"queuedNotice": "شما اشتراک فعالی دارید. این خرید در صف قرار می‌گیرد و از {date} آغاز می‌شود."
},
"queued": {
"title": "اشتراک‌های در صف",
"subtitle": "این اشتراک‌ها پس از پایان اشتراک فعلی به‌صورت خودکار فعال می‌شوند.",
"months": "{count} ماه",
"window": "از {from} تا {to}",
"cancel": "لغو",
"cancelled": "اشتراک در صف لغو شد",
"cancelConfirmTitle": "لغو اشتراک در صف",
"cancelConfirmDesc": "اشتراک {plan} که قرار بود از {from} آغاز شود لغو شود؟ اشتراک فعلی شما دست‌نخورده می‌ماند."
}
},
"settings": {
@@ -68,6 +68,37 @@ export function CheckoutScreen() {
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(() => {
if (!paymentMethod && paymentMethods.length > 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 */}
<Card className="overflow-hidden rounded-xl border border-border/80 shadow-sm">
{/* Invoice header */}
@@ -2,10 +2,11 @@
import { useEffect, useRef, useState } from "react";
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 { CalendarClock, Trash2 } from "lucide-react";
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 { useAuthStore } from "@/lib/stores/auth.store";
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 { PageHeader } from "@/components/layout/page-header";
import { PlanComparison } from "@/components/settings/plan-comparison";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import type { AuthTokenResponse } from "@/lib/api/types";
import { Alert } from "@/components/ui/alert";
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 = {
planTier: string;
@@ -30,6 +42,7 @@ type BillingStatus = {
menu3dEnabled: boolean;
discoverProfileEnabled: boolean;
isPlanExpired: boolean;
queuedPlans: QueuedPlan[];
};
export function SubscriptionScreen() {
@@ -40,8 +53,11 @@ export function SubscriptionScreen() {
const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role);
const setAuth = useAuthStore((s) => s.setAuth);
const apiError = useApiError();
const queryClient = useQueryClient();
const billingRefreshed = useRef(false);
const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null);
const [cancelTarget, setCancelTarget] = useState<QueuedPlan | null>(null);
useEffect(() => {
const billing = searchParams.get("billing");
@@ -61,6 +77,18 @@ export function SubscriptionScreen() {
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(() => {
if (searchParams.get("billing") !== "success" || billingRefreshed.current) return;
const refresh = localStorage.getItem("meezi_refresh_token");
@@ -155,12 +183,72 @@ export function SubscriptionScreen() {
</CardContent>
</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
currentPlan={status?.planTier ?? "Free"}
onSubscribe={(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>
);
}