283 lines
8.9 KiB
TypeScript
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";
|