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:
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user