Files
songs2vid/app/api/account/credits/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

193 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 115 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 });
}
}