215 lines
8.1 KiB
TypeScript
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';
|