218 lines
6.0 KiB
TypeScript
218 lines
6.0 KiB
TypeScript
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",
|
|
});
|
|
}
|
|
}
|