fix(subscription): recover stepper when a pending checkout is abandoned
Some checks failed
Deploy / Deploy to Production (push) Has been cancelled

A user who picked a plan + brands, clicked the checkout button to
create the pending subscription, then closed the tab without paying
came back to a frozen "Mevcut Plan · Bekliyor" card with no payment
button and no way to edit. Status was "pending", which my conditional
treated as an active subscription and routed away from the stepper.

Frontend
- hasActiveSub narrowed to active|cancelled only. Pending users now
  see the stepper again.
- New effect pre-fills selectedPlanKey, selectedBrandIds,
  billingPeriod from the pending row on first render and jumps
  straight to the payment step.
- New PendingPaymentBanner at the top of the stepper with
  "Odemeye devam et" (scrolls + sets step=payment) and "Vazgec"
  (calls cancel-pending then resets selection).

Backend
- subscriptions.service.create() auto-expires existing pending
  rows for the user before inserting a new one — abandoned checkouts
  no longer accumulate and a fresh attempt with a different plan
  doesn't 409.
- New PATCH /subscriptions/cancel-pending + service method for
  the banner's explicit cancel action.

i18n: subscription.pendingBanner.* (tr/en).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-05-13 22:57:13 +00:00
parent 9c05a05a7a
commit 02bf7342a1
5 changed files with 142 additions and 2 deletions

View File

@@ -39,6 +39,11 @@ export class SubscriptionsController {
return this.subscriptionsService.startFullTrial(userId);
}
@Patch("cancel-pending")
async cancelPending(@CurrentUser("id") userId: string) {
return this.subscriptionsService.cancelPending(userId);
}
@Patch("cancel")
async cancel(@CurrentUser("id") userId: string) {
return this.subscriptionsService.cancel(userId);

View File

@@ -34,6 +34,13 @@ export class SubscriptionsService {
.set({ status: "expired", updatedAt: new Date() })
.where(and(eq(userSubscriptions.userId, userId), eq(userSubscriptions.status, "trial")));
// Expire any existing pending subscription so an abandoned checkout
// doesn't block the user from starting a new one with different choices.
await this.db
.update(userSubscriptions)
.set({ status: "expired", updatedAt: new Date() })
.where(and(eq(userSubscriptions.userId, userId), eq(userSubscriptions.status, "pending")));
// Validate plan
const plan = await this.db.select().from(plans).where(eq(plans.id, data.planId)).limit(1);
if (plan.length === 0) throw new NotFoundException("Plan bulunamadı");
@@ -157,6 +164,15 @@ export class SubscriptionsService {
return { ...sub, plan: plan[0], brands: subBrands };
}
async cancelPending(userId: string) {
const result = await this.db
.update(userSubscriptions)
.set({ status: "expired", updatedAt: new Date() })
.where(and(eq(userSubscriptions.userId, userId), eq(userSubscriptions.status, "pending")))
.returning({ id: userSubscriptions.id });
return { cancelled: result.length };
}
async cancel(userId: string) {
const [sub] = await this.db
.select()

View File

@@ -256,6 +256,13 @@
"title": "Your trial is ending soon!",
"description": "{days} days left. Upgrade now to continue accessing all features.",
"cta": "Upgrade Plan"
},
"pendingBanner": {
"title": "You have a pending payment",
"description": "Checkout for {plan} ({brands}) was left incomplete. Resume it or cancel to pick a different plan.",
"resumeCta": "Resume payment",
"cancelCta": "Cancel",
"cancelled": "Pending subscription cancelled. You can pick a new plan."
}
},
"payment": {

View File

@@ -256,6 +256,13 @@
"title": "Deneme süreniz dolmak üzere!",
"description": "{days} gününüz kaldı. Tüm özelliklere erişmeye devam etmek için hemen yükseltin.",
"cta": "Plana Yükselt"
},
"pendingBanner": {
"title": "Bekleyen ödemen var",
"description": "{plan} planı, {brands} için ödeme yarıda kaldı. Devam edebilir veya farklı plan seçmek için vazgeçebilirsin.",
"resumeCta": "Ödemeye devam et",
"cancelCta": "Vazgeç",
"cancelled": "Bekleyen abonelik iptal edildi. Yeni plan seçebilirsin."
}
},
"payment": {

View File

@@ -396,6 +396,35 @@ export function SubscriptionPage() {
subscription?.billingPeriod,
]);
// ─── Effect: pre-fill stepper from pending subscription ────────────────────
const hasPrefilledFromPendingRef = useRef(false);
useEffect(() => {
if (!subscription || subscription.status !== "pending") return;
if (hasPrefilledFromPendingRef.current) return;
if (selectedPlanKey) return; // user already started fresh selection — don't overwrite
const planKey = subscription.plan?.name ? planNameToKey(subscription.plan.name) : null;
if (!planKey) return;
hasPrefilledFromPendingRef.current = true;
setSelectedPlanKey(planKey);
setSelectedBrandIds((subscription.brands ?? []).map((b) => b.brandId));
setBillingPeriod(subscription.billingPeriod === "yearly" ? "yearly" : "monthly");
setStep("payment");
}, [subscription, selectedPlanKey]);
const cancelPendingMutation = useMutation({
mutationFn: () => api.patch("/subscriptions/cancel-pending"),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["subscription"] });
hasPrefilledFromPendingRef.current = false;
setSelectedPlanKey(null);
setSelectedBrandIds([]);
setStep("plan");
toast.success(t("subscription.pendingBanner.cancelled"));
capture("pending_subscription_cancelled");
},
onError: () => toast.error(t("errors.generic")),
});
// ─── Handlers ──────────────────────────────────────────────────────────────
function handleSelectPlan(planKey: string) {
if (planKey === selectedPlanKey) return; // idempotent — no brand wipe
@@ -505,8 +534,11 @@ export function SubscriptionPage() {
);
}
// Stepper visible for: no sub, expired, trial, and pending (abandoned checkout).
// Active subscription card shown only for active/cancelled.
const hasActiveSub =
subscription && subscription.status !== "expired" && subscription.status !== "trial";
!!subscription && (subscription.status === "active" || subscription.status === "cancelled");
const isPending = subscription?.status === "pending";
const showStepper = !hasActiveSub || subscription?.status === "trial";
return (
@@ -516,7 +548,7 @@ export function SubscriptionPage() {
<p className="text-sm text-muted-foreground">{t("subscription.subtitle")}</p>
</header>
{subscription && !(eligibleForTrial && subscription.status === "trial") && (
{subscription && hasActiveSub && (
<ActiveSubscriptionCard
subscription={subscription}
statusVariants={statusVariants}
@@ -549,6 +581,23 @@ export function SubscriptionPage() {
/>
)}
{isPending && subscription && (
<PendingPaymentBanner
planName={subscription.plan?.name ?? ""}
brandCount={subscription.brands?.length ?? 0}
isFullPlan={
subscription.plan?.name ? planNameToKey(subscription.plan.name) === "full" : false
}
onResume={() => {
capture("pending_subscription_resumed");
document.getElementById("checkout-flow")?.scrollIntoView({ behavior: "smooth" });
setStep("payment");
}}
onCancel={() => cancelPendingMutation.mutate()}
cancelling={cancelPendingMutation.isPending}
/>
)}
{showStepper && (
<div id="checkout-flow" className="space-y-6">
<StepIndicator
@@ -1375,6 +1424,62 @@ function TrialCtaCard({ onStartTrial, pending }: { onStartTrial: () => void; pen
);
}
function PendingPaymentBanner({
planName,
brandCount,
isFullPlan,
onResume,
onCancel,
cancelling,
}: {
planName: string;
brandCount: number;
isFullPlan: boolean;
onResume: () => void;
onCancel: () => void;
cancelling: boolean;
}) {
const { t } = useTranslation();
const brandsLabel = isFullPlan
? t("subscription.allBrandsSelected")
: `${brandCount} ${t("subscription.brandsSelected")}`;
return (
<div
role="alert"
className="animate-fade-in-up flex flex-col gap-3 rounded-2xl border border-blue-200 bg-blue-50 px-5 py-4 dark:border-blue-900/60 dark:bg-blue-950/30 sm:flex-row sm:items-center"
>
<div className="flex flex-1 items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-blue-500/15 text-blue-600 dark:text-blue-300">
<Clock className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-semibold text-blue-900 dark:text-blue-100">
{t("subscription.pendingBanner.title")}
</p>
<p className="text-xs text-blue-700 dark:text-blue-300">
{t("subscription.pendingBanner.description", { plan: planName, brands: brandsLabel })}
</p>
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button
variant="outline"
size="sm"
onClick={onCancel}
disabled={cancelling}
className="border-blue-300 text-blue-700 hover:bg-blue-100 dark:border-blue-800 dark:text-blue-300 dark:hover:bg-blue-900/50"
>
{cancelling ? t("common.loading") : t("subscription.pendingBanner.cancelCta")}
</Button>
<Button size="sm" onClick={onResume}>
{t("subscription.pendingBanner.resumeCta")}
<ArrowRight className="ml-1.5 h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
function TrialUrgencyBanner({
daysRemaining,
onCta,