lomavuokraus/app/api/me/billing/route.ts
Tero Halla-aho 0bb709d9c5
Some checks failed
CI / checks (push) Has been cancelled
chore: fix audit alerts and formatting
2026-02-04 12:43:03 +02:00

283 lines
8.9 KiB
TypeScript

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<Awaited<ReturnType<typeof loadState>>["user"]>,
listings: Awaited<ReturnType<typeof loadState>>["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<string, any> }[] = [];
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<string, any> = {};
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";