import { NextResponse } from "next/server"; import { UserStatus } from "@prisma/client"; import { prisma } from "../../../../lib/prisma"; import { requireAuth } from "../../../../lib/jwt"; import { normalizeIban, normalizeNullableBoolean, normalizeOptionalString, } from "../../../../lib/billing"; function pickTranslation( translations: { title: string; slug: string; locale: string }[], ) { return ( translations.find((t) => t.locale === "en") || translations.find((t) => t.locale === "fi") || translations[0] || null ); } async function loadState(userId: string) { const [user, listings] = await Promise.all([ prisma.user.findUnique({ where: { id: userId }, select: { id: true, status: true, billingEmailsEnabled: true, billingAccountName: true, billingIban: true, billingIncludeVatLine: true, }, }), prisma.listing.findMany({ where: { ownerId: userId, removedAt: null }, select: { id: true, billingAccountName: true, billingIban: true, billingIncludeVatLine: true, translations: { select: { title: true, slug: true, locale: true } }, }, orderBy: { createdAt: "desc" }, }), ]); return { user, listings }; } function validateAccountName(name: string | null | undefined) { if (name && name.length > 120) { return "Account owner name is too long"; } return null; } function validateIban(iban: string | null | undefined) { if (iban && !/^[A-Z0-9]{8,34}$/.test(iban)) { return "IBAN must be 8-34 alphanumeric characters"; } return null; } function normalizeListingOverrides(body: any) { type OverrideInput = { id: string | null; accountName: string | null | undefined; iban: string | null | undefined; includeVatLine: boolean | null | undefined; }; const overridesRaw: any[] = Array.isArray(body?.listings) ? body.listings : []; const mapped: OverrideInput[] = overridesRaw.map((item: any) => ({ id: typeof item.id === "string" ? item.id : null, accountName: normalizeOptionalString(item.accountName), iban: normalizeIban(item.iban), includeVatLine: normalizeNullableBoolean(item.includeVatLine), })); return mapped.filter((o): o is OverrideInput & { id: string } => Boolean(o.id), ); } function buildResponsePayload( user: NonNullable>["user"]>, listings: Awaited>["listings"], ) { return { settings: { enabled: user.billingEmailsEnabled, accountName: user.billingAccountName, iban: user.billingIban, includeVatLine: user.billingIncludeVatLine, }, listings: listings.map((listing) => { const translation = pickTranslation(listing.translations); return { id: listing.id, title: translation?.title ?? "Listing", slug: translation?.slug ?? "", locale: translation?.locale ?? "en", billingAccountName: listing.billingAccountName, billingIban: listing.billingIban, billingIncludeVatLine: listing.billingIncludeVatLine, }; }), }; } export async function GET(req: Request) { try { const session = await requireAuth(req); const { user, listings } = await loadState(session.userId); if (!user || user.status !== UserStatus.ACTIVE) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } return NextResponse.json(buildResponsePayload(user, listings)); } catch (error) { console.error("Billing settings fetch failed", error); return NextResponse.json( { error: "Failed to load billing settings" }, { status: 500 }, ); } } export async function PATCH(req: Request) { let body: any; try { body = await req.json(); } catch { return NextResponse.json( { error: "Invalid request body" }, { status: 400 }, ); } try { const session = await requireAuth(req); const { user, listings } = await loadState(session.userId); if (!user || user.status !== UserStatus.ACTIVE) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const enabled = body.enabled === undefined ? undefined : Boolean(body.enabled); const accountName = normalizeOptionalString(body.accountName); const iban = normalizeIban(body.iban); const includeVatLine = body.includeVatLine === undefined ? undefined : Boolean(body.includeVatLine); const listingOverrides = normalizeListingOverrides(body); const errors = [ validateAccountName(accountName), validateIban(iban), ].filter(Boolean) as string[]; for (const override of listingOverrides) { const nameError = validateAccountName(override.accountName); const ibanError = validateIban(override.iban); if (nameError) errors.push(`${nameError} for listing ${override.id}`); if (ibanError) errors.push(`${ibanError} for listing ${override.id}`); } if (errors.length) { return NextResponse.json({ error: errors.join("; ") }, { status: 400 }); } const userUpdates: any = {}; if (enabled !== undefined) userUpdates.billingEmailsEnabled = enabled; if (accountName !== undefined) userUpdates.billingAccountName = accountName; if (iban !== undefined) userUpdates.billingIban = iban; if (includeVatLine !== undefined) userUpdates.billingIncludeVatLine = includeVatLine; const listingMap = new Map(listings.map((l) => [l.id, l])); const listingUpdates: { id: string; data: Record }[] = []; listingOverrides.forEach( (override: { id: string; accountName: string | null | undefined; iban: string | null | undefined; includeVatLine: boolean | null | undefined; }) => { if (!listingMap.has(override.id!)) return; const data: Record = {}; if (override.accountName !== undefined) data.billingAccountName = override.accountName; if (override.iban !== undefined) data.billingIban = override.iban; if (override.includeVatLine !== undefined) data.billingIncludeVatLine = override.includeVatLine; if (Object.keys(data).length) { listingUpdates.push({ id: override.id!, data }); } }, ); const targetEnabled = enabled ?? user.billingEmailsEnabled; if (targetEnabled) { const targetAccountName = accountName !== undefined ? accountName : user.billingAccountName; const targetIban = iban !== undefined ? iban : user.billingIban; const hasGlobalDetails = Boolean(targetAccountName && targetIban); const missingFor: string[] = []; listings.forEach((listing) => { const override = listingUpdates.find((o) => o.id === listing.id); const effectiveAccountName = override?.data.billingAccountName !== undefined ? override.data.billingAccountName : (listing.billingAccountName ?? targetAccountName); const effectiveIban = override?.data.billingIban !== undefined ? override.data.billingIban : (listing.billingIban ?? targetIban); if (!effectiveAccountName || !effectiveIban) { const t = pickTranslation(listing.translations); missingFor.push(t?.slug || listing.id); } }); if (!hasGlobalDetails && missingFor.length) { return NextResponse.json( { error: `Provide billing account name and IBAN globally or per listing (missing for: ${missingFor.join(", ")})`, }, { status: 400 }, ); } if (!hasGlobalDetails && listings.length === 0) { return NextResponse.json( { error: "Add a billing account name and IBAN before enabling the billing assistant.", }, { status: 400 }, ); } } const tx: any[] = []; if (Object.keys(userUpdates).length) { tx.push( prisma.user.update({ where: { id: user.id }, data: userUpdates }), ); } listingUpdates.forEach((update) => { tx.push( prisma.listing.update({ where: { id: update.id }, data: update.data }), ); }); if (tx.length) { await prisma.$transaction(tx); } else { return NextResponse.json( { error: "No updates provided" }, { status: 400 }, ); } const { user: updatedUser, listings: updatedListings } = await loadState( session.userId, ); if (!updatedUser) { return NextResponse.json({ error: "User not found" }, { status: 404 }); } return NextResponse.json( buildResponsePayload(updatedUser, updatedListings), ); } catch (error) { console.error("Billing settings update failed", error); const status = (error as any)?.message === "Unauthorized" ? 401 : 500; return NextResponse.json( { error: "Failed to update billing settings" }, { status }, ); } } export const dynamic = "force-dynamic";