lomavuokraus/app/api/me/billing/route.ts
2025-12-20 17:46:01 +02:00

215 lines
8.1 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) {
const overridesRaw = Array.isArray(body?.listings) ? body.listings : [];
return overridesRaw
.map((item: any) => ({
id: typeof item.id === 'string' ? item.id : null,
accountName: normalizeOptionalString(item.accountName),
iban: normalizeIban(item.iban),
includeVatLine: normalizeNullableBoolean(item.includeVatLine),
}))
.filter((o) => 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) => {
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';