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()