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.
193 lines
5.4 KiB
TypeScript
193 lines
5.4 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
||
import {
|
||
CREDIT_PRICE_CENTS,
|
||
FREE_EXTRA_CREDITS_MAX,
|
||
FREE_TOP_UP_MAX,
|
||
FREE_TOP_UP_MIN,
|
||
formatCreditPrice,
|
||
validateFreeTopUpAmount,
|
||
} from "@/lib/credits";
|
||
import { prisma } from "@/lib/db";
|
||
import { getSessionUser } from "@/lib/session";
|
||
import {
|
||
checkoutTaxAndReferenceOptions,
|
||
ensureStripeCustomer,
|
||
priceDataTaxFields,
|
||
productDataWithTaxCode,
|
||
} from "@/lib/stripe-checkout";
|
||
import { getStripe, isBillingDevMock, isStripeConfigured } from "@/lib/stripe";
|
||
import { grantExtraCredits } from "@/lib/billing";
|
||
|
||
export async function GET() {
|
||
const user = await getSessionUser();
|
||
if (!user) {
|
||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||
}
|
||
|
||
const dbUser = await prisma.user.findUniqueOrThrow({
|
||
where: { id: user.id },
|
||
select: {
|
||
extraCredits: true,
|
||
plan: true,
|
||
monthlyCredits: true,
|
||
videosUsed: true,
|
||
},
|
||
});
|
||
|
||
const purchases = await prisma.creditPurchase.findMany({
|
||
where: { userId: user.id, status: "COMPLETED" },
|
||
orderBy: { completedAt: "desc" },
|
||
take: 10,
|
||
});
|
||
|
||
const cap = FREE_EXTRA_CREDITS_MAX;
|
||
const room = Math.max(0, cap - dbUser.extraCredits);
|
||
|
||
return NextResponse.json({
|
||
extraCredits: dbUser.extraCredits,
|
||
videoCredits: dbUser.extraCredits,
|
||
topUpMin: FREE_TOP_UP_MIN,
|
||
topUpMax: FREE_TOP_UP_MAX,
|
||
priceCentsPerCredit: CREDIT_PRICE_CENTS,
|
||
priceLabelPerCredit: formatCreditPrice(1),
|
||
creditBalanceMax: cap,
|
||
monthlyCredits: dbUser.monthlyCredits,
|
||
creditsUsed: dbUser.videosUsed,
|
||
plan: dbUser.plan,
|
||
stripeConfigured: isStripeConfigured() || isBillingDevMock(),
|
||
room,
|
||
atExtraCap: room === 0,
|
||
purchases: purchases.map((p) => ({
|
||
id: p.id,
|
||
credits: p.credits,
|
||
amountCents: p.amountCents,
|
||
completedAt: p.completedAt?.toISOString() ?? null,
|
||
})),
|
||
});
|
||
}
|
||
|
||
/** Free tier: top up 1–15 extra credits (price = credits × €0.25). */
|
||
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:
|
||
"Credit top-ups are for the Free plan. Pro includes 50 monthly credits; existing extras never expire.",
|
||
},
|
||
{ status: 403 },
|
||
);
|
||
}
|
||
|
||
let credits = 0;
|
||
try {
|
||
const body = await req.json();
|
||
credits = Math.floor(Number(body.credits));
|
||
} catch {
|
||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||
}
|
||
|
||
const dbUser = await prisma.user.findUniqueOrThrow({
|
||
where: { id: user.id },
|
||
select: { extraCredits: true, email: true, monthlyCredits: true, videosUsed: true, bonusQuota: true },
|
||
});
|
||
|
||
const monthlyRemaining = Math.max(
|
||
0,
|
||
dbUser.monthlyCredits + dbUser.bonusQuota - dbUser.videosUsed,
|
||
);
|
||
const validation = validateFreeTopUpAmount(credits, dbUser.extraCredits, monthlyRemaining);
|
||
if (!validation.ok) {
|
||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||
}
|
||
|
||
const { credits: amount, amountCents } = validation;
|
||
|
||
if (isBillingDevMock()) {
|
||
const purchase = await prisma.creditPurchase.create({
|
||
data: {
|
||
userId: user.id,
|
||
credits: amount,
|
||
amountCents,
|
||
status: "COMPLETED",
|
||
completedAt: new Date(),
|
||
stripeSessionId: `dev_topup_${Date.now()}`,
|
||
},
|
||
});
|
||
await grantExtraCredits(user.id, amount);
|
||
return NextResponse.json({
|
||
mocked: true,
|
||
granted: amount,
|
||
amountCents,
|
||
purchaseId: purchase.id,
|
||
});
|
||
}
|
||
|
||
if (!isStripeConfigured()) {
|
||
return NextResponse.json(
|
||
{ error: "Payments are not configured yet. Set STRIPE_SECRET_KEY." },
|
||
{ status: 503 },
|
||
);
|
||
}
|
||
|
||
const origin = process.env.NEXTAUTH_URL || req.nextUrl.origin;
|
||
const purchase = await prisma.creditPurchase.create({
|
||
data: {
|
||
userId: user.id,
|
||
credits: amount,
|
||
amountCents,
|
||
status: "PENDING",
|
||
},
|
||
});
|
||
|
||
try {
|
||
const stripe = getStripe();
|
||
const customerId = await ensureStripeCustomer(user.id, dbUser.email);
|
||
const session = await stripe.checkout.sessions.create({
|
||
mode: "payment",
|
||
customer: customerId,
|
||
line_items: [
|
||
{
|
||
quantity: amount,
|
||
price_data: {
|
||
currency: "eur",
|
||
unit_amount: CREDIT_PRICE_CENTS,
|
||
...priceDataTaxFields(),
|
||
product_data: productDataWithTaxCode(
|
||
"Songs2VID video credit",
|
||
`1 credit = 1 video upload (${formatCreditPrice(1)} each)`,
|
||
),
|
||
},
|
||
},
|
||
],
|
||
metadata: {
|
||
type: "free_top_up",
|
||
userId: user.id,
|
||
purchaseId: purchase.id,
|
||
credits: String(amount),
|
||
},
|
||
...checkoutTaxAndReferenceOptions(user.id),
|
||
success_url: `${origin}/dashboard/settings?credits=success`,
|
||
cancel_url: `${origin}/dashboard/settings?credits=cancelled`,
|
||
});
|
||
|
||
await prisma.creditPurchase.update({
|
||
where: { id: purchase.id },
|
||
data: { stripeSessionId: session.id },
|
||
});
|
||
|
||
return NextResponse.json({ url: session.url, sessionId: session.id });
|
||
} catch (err) {
|
||
await prisma.creditPurchase.update({
|
||
where: { id: purchase.id },
|
||
data: { status: "FAILED" },
|
||
});
|
||
const message = err instanceof Error ? err.message : "Checkout failed";
|
||
return NextResponse.json({ error: message }, { status: 500 });
|
||
}
|
||
}
|