Files
songs2vid/app/api/stripe/webhook/route.ts
T
Atakan Doğan ÖzbanandCursor c8015937f9 Align preview typography with FFmpeg output, add video/song title split, and ship OSS updates.
Separate YouTube video titles from on-video song/artist fields with Pro gating, serve curated fonts and watermark assets for 1:1 preview parity, and include billing/API/docs/deploy stack for self-hosted release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 16:26:19 +02:00

256 lines
8.7 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import type Stripe from "stripe";
import {
activateProPlan,
applyMonthlyCreditRenewal,
downgradeToFreePlan,
grantExtraCredits,
} from "@/lib/billing";
import { monthlyCreditsForPlan } from "@/lib/credits";
import { prisma } from "@/lib/db";
import { getNextMonthlyQuotaReset } from "@/lib/plans";
import { getStripe } from "@/lib/stripe";
export const runtime = "nodejs";
/** Stripe API 2025+: subscription lives on parent.subscription_details, not invoice.subscription. */
function subscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
const legacy = (invoice as Stripe.Invoice & {
subscription?: string | Stripe.Subscription | null;
}).subscription;
if (typeof legacy === "string" && legacy) return legacy;
if (legacy && typeof legacy === "object" && "id" in legacy && legacy.id) {
return String(legacy.id);
}
const parent = (
invoice as Stripe.Invoice & {
parent?: {
type?: string | null;
subscription_details?: { subscription?: string | Stripe.Subscription | null } | null;
} | null;
}
).parent;
const fromParent = parent?.subscription_details?.subscription;
if (typeof fromParent === "string" && fromParent) return fromParent;
if (fromParent && typeof fromParent === "object" && "id" in fromParent && fromParent.id) {
return String(fromParent.id);
}
return null;
}
async function fulfillFreeTopUp(session: Stripe.Checkout.Session) {
const purchaseId = session.metadata?.purchaseId;
const userId = session.metadata?.userId;
if (!purchaseId || !userId) {
console.error("[stripe] free_top_up missing metadata", session.id);
return;
}
const purchase = await prisma.creditPurchase.findUnique({ where: { id: purchaseId } });
if (!purchase || purchase.userId !== userId) {
throw new Error(`Purchase not found: ${purchaseId}`);
}
if (purchase.status === "COMPLETED") return;
await grantExtraCredits(userId, purchase.credits);
await prisma.creditPurchase.update({
where: { id: purchaseId },
data: {
status: "COMPLETED",
completedAt: new Date(),
stripeSessionId: session.id,
stripePaymentIntentId:
typeof session.payment_intent === "string"
? session.payment_intent
: session.payment_intent?.id ?? null,
},
});
}
async function fulfillProCheckout(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId;
if (!userId) {
console.error("[stripe] pro_subscription missing userId", session.id);
return;
}
const subscriptionId =
typeof session.subscription === "string"
? session.subscription
: session.subscription?.id ?? null;
const customerId =
typeof session.customer === "string" ? session.customer : session.customer?.id ?? null;
await activateProPlan(userId, {
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
});
}
async function handleInvoicePaymentSucceeded(invoice: Stripe.Invoice) {
const subscriptionId = subscriptionIdFromInvoice(invoice);
if (!subscriptionId) {
console.warn("[stripe] invoice.payment_succeeded without subscription id", invoice.id);
return;
}
// Skip the first invoice if checkout already activated Pro (billing_reason subscription_create)
const user = await prisma.user.findFirst({
where: { stripeSubscriptionId: subscriptionId },
});
if (!user) {
// Fallback: metadata on subscription via Stripe retrieve is optional; try customer
const customerId =
typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id;
if (!customerId) return;
const byCustomer = await prisma.user.findFirst({
where: { stripeCustomerId: customerId },
});
if (!byCustomer) return;
await applyMonthlyCreditRenewal(byCustomer.id, {
plan: "PREMIUM",
newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"),
quotaResetAt: getNextMonthlyQuotaReset(),
});
await prisma.user.update({
where: { id: byCustomer.id },
data: {
stripeSubscriptionId: subscriptionId,
subscribedAt: byCustomer.subscribedAt ?? new Date(),
},
});
return;
}
// Renewal: rollover unused + new monthly, trim to MAX_CREDIT_CAP (30)
await applyMonthlyCreditRenewal(user.id, {
plan: "PREMIUM",
newMonthlyCredits: monthlyCreditsForPlan("PREMIUM"),
quotaResetAt: getNextMonthlyQuotaReset(),
});
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const user = await prisma.user.findFirst({
where: { stripeSubscriptionId: subscription.id },
});
if (!user) return;
await downgradeToFreePlan(user.id);
}
/** Stripe retries failed invoices; when status is unpaid/canceled, revoke Pro access. */
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const terminal = new Set(["canceled", "unpaid", "incomplete_expired"]);
if (!terminal.has(subscription.status)) return;
const user = await prisma.user.findFirst({
where: { stripeSubscriptionId: subscription.id },
});
if (!user || user.plan !== "PREMIUM") return;
await downgradeToFreePlan(user.id);
}
async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
const subscriptionId = subscriptionIdFromInvoice(invoice);
if (!subscriptionId) return;
const user = await prisma.user.findFirst({
where: {
OR: [
{ stripeSubscriptionId: subscriptionId },
...(typeof invoice.customer === "string"
? [{ stripeCustomerId: invoice.customer }]
: invoice.customer?.id
? [{ stripeCustomerId: invoice.customer.id }]
: []),
],
},
});
if (!user) return;
// Soft signal only Stripe Smart Retries continue. Do not downgrade on first failure.
console.warn(
"[stripe] invoice.payment_failed",
invoice.id,
"user",
user.id,
"attempt",
invoice.attempt_count,
);
}
export async function POST(req: NextRequest) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
return NextResponse.json({ error: "Webhook not configured" }, { status: 503 });
}
const signature = req.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
const rawBody = await req.text();
let event: Stripe.Event;
try {
const stripe = getStripe();
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
} catch (err) {
const message = err instanceof Error ? err.message : "Invalid signature";
return NextResponse.json({ error: message }, { status: 400 });
}
try {
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
if (session.payment_status === "paid" || session.status === "complete") {
const type = session.metadata?.type;
if (type === "pro_subscription" || session.mode === "subscription") {
await fulfillProCheckout(session);
} else if (type === "free_top_up") {
await fulfillFreeTopUp(session);
} else if (session.metadata?.purchaseId) {
// Legacy PAYG purchases: grant metadata credits as extras
const userId = session.metadata.userId;
const credits = Number(session.metadata.credits);
const purchaseId = session.metadata.purchaseId;
if (userId && purchaseId && Number.isFinite(credits) && credits > 0) {
const purchase = await prisma.creditPurchase.findUnique({
where: { id: purchaseId },
});
if (purchase && purchase.status !== "COMPLETED") {
await grantExtraCredits(userId, credits);
await prisma.creditPurchase.update({
where: { id: purchaseId },
data: {
status: "COMPLETED",
completedAt: new Date(),
stripeSessionId: session.id,
},
});
}
}
}
}
} else if (event.type === "invoice.payment_succeeded") {
await handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice);
} else if (event.type === "invoice.payment_failed") {
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
} else if (event.type === "customer.subscription.updated") {
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
} else if (event.type === "customer.subscription.deleted") {
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
}
} catch (err) {
console.error("[stripe] webhook handler error", event.type, event.id, err);
return NextResponse.json({ error: "Handler failed" }, { status: 500 });
}
return NextResponse.json({ received: true });
}