import { NextRequest, NextResponse } from "next/server"; import { downgradeToFreePlan } from "@/lib/billing"; import { prisma } from "@/lib/db"; import { getSessionUser } from "@/lib/session"; import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe"; type CancelWhen = "immediate" | "period_end"; type CancelAction = "cancel" | "resume"; function periodEndIso(sub: { cancel_at?: number | null; current_period_end?: number; items?: { data?: Array<{ current_period_end?: number }> }; }): string | null { const fromItem = sub.items?.data?.[0]?.current_period_end; const ts = sub.cancel_at ?? fromItem ?? sub.current_period_end ?? null; return ts ? new Date(ts * 1000).toISOString() : null; } /** * Cancel Pro subscription via Stripe. * Body: { when: "immediate" | "period_end" } or { action: "resume" } */ export async function POST(req: NextRequest) { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } if (user.plan !== "PREMIUM") { return NextResponse.json({ error: "No active subscription to cancel" }, { status: 400 }); } let action: CancelAction = "cancel"; let when: CancelWhen = "immediate"; try { const body = await req.json(); if (body?.action === "resume") action = "resume"; if (body?.when === "period_end" || body?.when === "immediate") when = body.when; } catch { // empty body → default immediate cancel (legacy) } const dbUser = await prisma.user.findUniqueOrThrow({ where: { id: user.id }, select: { stripeSubscriptionId: true }, }); const subId = dbUser.stripeSubscriptionId; const isMockSub = !subId || subId.startsWith("dev_sub_"); const useStripe = Boolean(subId) && !isMockSub && isStripeConfigured() && !isBillingDevMock(); // Resume (undo cancel-at-period-end) if (action === "resume") { if (useStripe && subId) { try { const stripe = getStripe(); const sub = await stripe.subscriptions.update(subId, { cancel_at_period_end: false, }); return NextResponse.json({ ok: true, action: "resume", cancelAtPeriodEnd: false, currentPeriodEnd: periodEndIso(sub as { current_period_end?: number }), }); } catch (err) { console.error("[stripe] resume subscription failed", err); return NextResponse.json( { error: err instanceof Error ? err.message : "Failed to keep subscription" }, { status: 502 }, ); } } return NextResponse.json({ ok: true, action: "resume", cancelAtPeriodEnd: false, mocked: true, }); } // Cancel if (when === "period_end") { if (useStripe && subId) { try { const stripe = getStripe(); const sub = await stripe.subscriptions.update(subId, { cancel_at_period_end: true, }); const endsAt = periodEndIso( sub as { cancel_at?: number | null; current_period_end?: number; items?: { data?: Array<{ current_period_end?: number }> }; }, ); return NextResponse.json({ ok: true, when: "period_end", cancelAtPeriodEnd: true, endsAt, // Keep Pro until Stripe sends customer.subscription.deleted }); } catch (err) { console.error("[stripe] cancel at period end failed", err); return NextResponse.json( { error: err instanceof Error ? err.message : "Failed to schedule cancellation" }, { status: 502 }, ); } } // Mock / no Stripe id: schedule locally by leaving Pro and reporting next month const endsAt = new Date(); endsAt.setMonth(endsAt.getMonth() + 1); return NextResponse.json({ ok: true, when: "period_end", cancelAtPeriodEnd: true, endsAt: endsAt.toISOString(), mocked: true, }); } // immediate if (useStripe && subId) { try { const stripe = getStripe(); await stripe.subscriptions.cancel(subId); } catch (err) { console.error("[stripe] cancel subscription failed", err); return NextResponse.json( { error: err instanceof Error ? err.message : "Stripe could not cancel the subscription. Your plan was not changed.", }, { status: 502 }, ); } } await downgradeToFreePlan(user.id); return NextResponse.json({ ok: true, when: "immediate", cancelAtPeriodEnd: false, plan: "FREE", }); } /** Current Stripe subscription cancel status for settings UI. */ export async function GET() { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } if (user.plan !== "PREMIUM") { return NextResponse.json({ plan: user.plan, cancelAtPeriodEnd: false, endsAt: null, }); } const dbUser = await prisma.user.findUniqueOrThrow({ where: { id: user.id }, select: { stripeSubscriptionId: true }, }); const subId = dbUser.stripeSubscriptionId; if ( !subId || subId.startsWith("dev_sub_") || !isStripeConfigured() || isBillingDevMock() ) { return NextResponse.json({ plan: "PREMIUM", cancelAtPeriodEnd: false, endsAt: null, mocked: true, }); } try { const stripe = getStripe(); const sub = await stripe.subscriptions.retrieve(subId); return NextResponse.json({ plan: "PREMIUM", status: sub.status, cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end), endsAt: periodEndIso( sub as { cancel_at?: number | null; current_period_end?: number; items?: { data?: Array<{ current_period_end?: number }> }; }, ), }); } catch (err) { console.error("[stripe] retrieve subscription failed", err); return NextResponse.json({ plan: "PREMIUM", cancelAtPeriodEnd: false, endsAt: null, error: "Could not load subscription status from Stripe", }); } }