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>
256 lines
8.7 KiB
TypeScript
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 });
|
|
}
|