Add billing agent opt-in settings and per-listing overrides
Some checks are pending
CI / checks (push) Waiting to run

This commit is contained in:
Tero Halla-aho 2025-12-13 22:23:47 +02:00
parent 6392120b3a
commit 49a1096152
5 changed files with 385 additions and 8 deletions

View file

@ -0,0 +1,140 @@
import { NextResponse } from 'next/server';
import { prisma } from '../../../lib/prisma';
import { requireAuth } from '../../../lib/jwt';
import { UserStatus } from '@prisma/client';
const selectListingLabel = (translations: { title: string; locale: string; slug: string }[]) => {
if (!translations || translations.length === 0) return 'Listing';
const sorted = [...translations].sort((a, b) => a.locale.localeCompare(b.locale));
return sorted[0].title || sorted[0].slug || 'Listing';
};
export async function GET(req: Request) {
try {
const session = await requireAuth(req);
const user = await prisma.user.findUnique({
where: { id: session.userId },
select: {
status: true,
agentBillingEnabled: true,
agentBankAccount: true,
agentVatBreakdownRequired: true,
agentUseListingOverrides: true,
},
});
if (!user || user.status !== UserStatus.ACTIVE) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const listings = await prisma.listing.findMany({
where: { ownerId: session.userId },
select: {
id: true,
translations: { select: { title: true, slug: true, locale: true } },
billingSettings: { select: { bankAccount: true, vatBreakdownRequired: true } },
},
orderBy: { createdAt: 'desc' },
});
const listingOverrides = listings.map((listing) => ({
id: listing.id,
label: selectListingLabel(listing.translations),
bankAccount: listing.billingSettings?.bankAccount ?? '',
vatBreakdownRequired: listing.billingSettings?.vatBreakdownRequired ?? false,
}));
return NextResponse.json({
settings: {
agentBillingEnabled: user.agentBillingEnabled,
agentBankAccount: user.agentBankAccount ?? '',
agentVatBreakdownRequired: user.agentVatBreakdownRequired,
agentUseListingOverrides: user.agentUseListingOverrides,
},
listingOverrides,
});
} catch (error) {
console.error('Billing settings fetch error', error);
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function PATCH(req: Request) {
try {
const session = await requireAuth(req);
const body = await req.json();
const user = await prisma.user.findUnique({ where: { id: session.userId }, select: { status: true } });
if (!user || user.status !== UserStatus.ACTIVE) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const agentBillingEnabled = Boolean(body.agentBillingEnabled);
const agentVatBreakdownRequired = Boolean(body.agentVatBreakdownRequired);
const agentUseListingOverrides = Boolean(body.agentUseListingOverrides);
const agentBankAccountRaw = typeof body.agentBankAccount === 'string' ? body.agentBankAccount.trim() : '';
const agentBankAccount = agentBankAccountRaw.length ? agentBankAccountRaw : null;
const updates = await prisma.$transaction(async (tx) => {
const updatedUser = await tx.user.update({
where: { id: session.userId },
data: {
agentBillingEnabled,
agentVatBreakdownRequired,
agentUseListingOverrides,
agentBankAccount,
},
select: {
agentBillingEnabled: true,
agentBankAccount: true,
agentVatBreakdownRequired: true,
agentUseListingOverrides: true,
},
});
const listingOverrides: any[] = Array.isArray(body.listingOverrides) ? body.listingOverrides : [];
const sanitizedOverrides = listingOverrides
.map((o) => ({
listingId: typeof o.listingId === 'string' ? o.listingId : '',
bankAccount: typeof o.bankAccount === 'string' ? o.bankAccount.trim() : '',
vatBreakdownRequired: Boolean(o.vatBreakdownRequired),
}))
.filter((o) => o.listingId);
for (const override of sanitizedOverrides) {
const listing = await tx.listing.findFirst({ where: { id: override.listingId, ownerId: session.userId }, select: { id: true } });
if (!listing) continue;
await tx.listingBillingSettings.upsert({
where: { listingId: override.listingId },
update: {
bankAccount: override.bankAccount || null,
vatBreakdownRequired: override.vatBreakdownRequired,
},
create: {
listingId: override.listingId,
bankAccount: override.bankAccount || null,
vatBreakdownRequired: override.vatBreakdownRequired,
},
});
}
return updatedUser;
});
return NextResponse.json({
ok: true,
settings: {
agentBillingEnabled: updates.agentBillingEnabled,
agentBankAccount: updates.agentBankAccount ?? '',
agentVatBreakdownRequired: updates.agentVatBreakdownRequired,
agentUseListingOverrides: updates.agentUseListingOverrides,
},
});
} catch (error) {
console.error('Billing settings update error', error);
return NextResponse.json({ error: 'Failed to update billing settings' }, { status: 500 });
}
}
export const dynamic = 'force-dynamic';

View file

@ -4,6 +4,14 @@ import { useEffect, useState } from 'react';
import { useI18n } from '../components/I18nProvider'; import { useI18n } from '../components/I18nProvider';
type User = { id: string; email: string; role: string; status: string; emailVerifiedAt: string | null; approvedAt: string | null; name: string | null; phone: string | null }; type User = { id: string; email: string; role: string; status: string; emailVerifiedAt: string | null; approvedAt: string | null; name: string | null; phone: string | null };
type ListingOverride = { id: string; label: string; bankAccount: string; vatBreakdownRequired: boolean };
type BillingSettings = {
agentBillingEnabled: boolean;
agentBankAccount: string;
agentVatBreakdownRequired: boolean;
agentUseListingOverrides: boolean;
listingOverrides: ListingOverride[];
};
export default function ProfilePage() { export default function ProfilePage() {
const { t } = useI18n(); const { t } = useI18n();
@ -14,18 +22,41 @@ export default function ProfilePage() {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [billingSettings, setBillingSettings] = useState<BillingSettings | null>(null);
const [billingError, setBillingError] = useState<string | null>(null);
const [billingMessage, setBillingMessage] = useState<string | null>(null);
const [billingSaving, setBillingSaving] = useState(false);
useEffect(() => { useEffect(() => {
fetch('/api/auth/me', { cache: 'no-store' }) const load = async () => {
.then((res) => res.json()) try {
.then((data) => { const res = await fetch('/api/auth/me', { cache: 'no-store' });
const data = await res.json();
if (data.user) { if (data.user) {
setUser(data.user); setUser(data.user);
setName(data.user.name ?? ''); setName(data.user.name ?? '');
setPhone(data.user.phone ?? ''); setPhone(data.user.phone ?? '');
} else setError(t('notLoggedIn')); const billingRes = await fetch('/api/billing-settings', { cache: 'no-store' });
}) const billingData = await billingRes.json();
.catch(() => setError(t('notLoggedIn'))); if (billingRes.ok && billingData.settings) {
setBillingSettings({
agentBillingEnabled: billingData.settings.agentBillingEnabled,
agentBankAccount: billingData.settings.agentBankAccount ?? '',
agentVatBreakdownRequired: billingData.settings.agentVatBreakdownRequired,
agentUseListingOverrides: billingData.settings.agentUseListingOverrides,
listingOverrides: billingData.listingOverrides ?? [],
});
} else {
setBillingError(billingData.error || 'Failed to load billing settings');
}
} else {
setError(t('notLoggedIn'));
}
} catch (err) {
setError(t('notLoggedIn'));
}
};
load();
}, [t]); }, [t]);
async function onSave(e: React.FormEvent) { async function onSave(e: React.FormEvent) {
@ -47,10 +78,45 @@ export default function ProfilePage() {
setPassword(''); setPassword('');
setMessage(t('profileUpdated')); setMessage(t('profileUpdated'));
} }
} catch (err) {
setError('Update failed');
} finally {
setSaving(false);
}
}
async function onSaveBilling(e: React.FormEvent) {
e.preventDefault();
if (!billingSettings) return;
setBillingSaving(true);
setBillingError(null);
setBillingMessage(null);
try {
const res = await fetch('/api/billing-settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentBillingEnabled: billingSettings.agentBillingEnabled,
agentBankAccount: billingSettings.agentBankAccount,
agentVatBreakdownRequired: billingSettings.agentVatBreakdownRequired,
agentUseListingOverrides: billingSettings.agentUseListingOverrides,
listingOverrides: billingSettings.listingOverrides.map((o) => ({
listingId: o.id,
bankAccount: o.bankAccount,
vatBreakdownRequired: o.vatBreakdownRequired,
})),
}),
});
const data = await res.json();
if (!res.ok) {
setBillingError(data.error || 'Update failed');
} else {
setBillingMessage(t('billingSettingsSaved'));
}
} catch (err) { } catch (err) {
setError('Update failed'); setBillingError('Update failed');
} finally { } finally {
setSaving(false); setBillingSaving(false);
} }
} }
@ -109,6 +175,110 @@ export default function ProfilePage() {
{saving ? t('saving') : t('save')} {saving ? t('saving') : t('save')}
</button> </button>
</form> </form>
<section style={{ marginTop: 32, paddingTop: 20, borderTop: '1px solid #e5e7eb' }}>
<h2 style={{ marginBottom: 12 }}>{t('billingAgentTitle')}</h2>
{billingMessage ? <p style={{ color: 'green' }}>{billingMessage}</p> : null}
{billingError ? <p style={{ color: 'red' }}>{billingError}</p> : null}
{billingSettings ? (
<form onSubmit={onSaveBilling} style={{ display: 'grid', gap: 12 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={billingSettings.agentBillingEnabled}
onChange={(e) =>
setBillingSettings({ ...billingSettings, agentBillingEnabled: e.target.checked })
}
/>
<span>{t('billingAgentOptIn')}</span>
</label>
<p style={{ margin: 0, color: '#4b5563', fontSize: 14 }}>{t('billingAgentOptInHelp')}</p>
<label style={{ opacity: billingSettings.agentBillingEnabled ? 1 : 0.6 }}>
{t('billingDefaultBank')}
<input
value={billingSettings.agentBankAccount}
onChange={(e) =>
setBillingSettings({ ...billingSettings, agentBankAccount: e.target.value })
}
placeholder="FI.. / payment instructions"
disabled={!billingSettings.agentBillingEnabled}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, opacity: billingSettings.agentBillingEnabled ? 1 : 0.6 }}>
<input
type="checkbox"
checked={billingSettings.agentVatBreakdownRequired}
onChange={(e) =>
setBillingSettings({ ...billingSettings, agentVatBreakdownRequired: e.target.checked })
}
disabled={!billingSettings.agentBillingEnabled}
/>
<span>{t('billingVatBreakdown')}</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, opacity: billingSettings.agentBillingEnabled ? 1 : 0.6 }}>
<input
type="checkbox"
checked={billingSettings.agentUseListingOverrides}
onChange={(e) =>
setBillingSettings({ ...billingSettings, agentUseListingOverrides: e.target.checked })
}
disabled={!billingSettings.agentBillingEnabled}
/>
<span>{t('billingUseListingOverrides')}</span>
</label>
{billingSettings.agentBillingEnabled && billingSettings.agentUseListingOverrides ? (
<div style={{ display: 'grid', gap: 10 }}>
{billingSettings.listingOverrides.length === 0 ? (
<p style={{ color: '#4b5563', margin: 0 }}>{t('billingNoListings')}</p>
) : (
billingSettings.listingOverrides.map((listing) => (
<div
key={listing.id}
style={{ border: '1px solid #e5e7eb', borderRadius: 6, padding: 12, display: 'grid', gap: 8 }}
>
<div style={{ fontWeight: 600 }}>{listing.label}</div>
<label>
{t('billingListingBank')}
<input
value={listing.bankAccount}
onChange={(e) =>
setBillingSettings({
...billingSettings,
listingOverrides: billingSettings.listingOverrides.map((l) =>
l.id === listing.id ? { ...l, bankAccount: e.target.value } : l
),
})
}
placeholder={t('billingListingBankPlaceholder')}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={listing.vatBreakdownRequired}
onChange={(e) =>
setBillingSettings({
...billingSettings,
listingOverrides: billingSettings.listingOverrides.map((l) =>
l.id === listing.id ? { ...l, vatBreakdownRequired: e.target.checked } : l
),
})
}
/>
<span>{t('billingListingVat')}</span>
</label>
</div>
))
)}
</div>
) : null}
<button className="button" type="submit" disabled={billingSaving}>
{billingSaving ? t('saving') : t('save')}
</button>
</form>
) : (
<p style={{ color: '#4b5563' }}>{t('loading')}</p>
)}
</section>
</> </>
) : ( ) : (
<p style={{ color: 'red' }}>{error ?? t('notLoggedIn')}</p> <p style={{ color: 'red' }}>{error ?? t('notLoggedIn')}</p>

View file

@ -141,6 +141,17 @@ const baseMessages = {
profileEmailVerified: 'Email verified', profileEmailVerified: 'Email verified',
profileApproved: 'Approved', profileApproved: 'Approved',
profileUpdated: 'Profile updated', profileUpdated: 'Profile updated',
billingAgentTitle: 'Billing agent',
billingAgentOptIn: 'Enable billing agent for automatic invoice creation',
billingAgentOptInHelp: 'Lets the n8n billing agent generate bills using your defaults and customer message details.',
billingDefaultBank: 'Default bank account / payment instructions',
billingVatBreakdown: 'Include VAT breakdown in invoices',
billingUseListingOverrides: 'Use listing-specific billing details',
billingNoListings: 'No listings to override yet.',
billingListingBank: 'Listing bank account / payment instructions',
billingListingBankPlaceholder: 'IBAN or payment instructions for this listing',
billingListingVat: 'Include VAT breakdown for this listing',
billingSettingsSaved: 'Billing settings saved',
emailLocked: 'Email cannot be changed', emailLocked: 'Email cannot be changed',
save: 'Save', save: 'Save',
saving: 'Saving…', saving: 'Saving…',
@ -484,6 +495,17 @@ const baseMessages = {
profileEmailVerified: 'Sähköposti vahvistettu', profileEmailVerified: 'Sähköposti vahvistettu',
profileApproved: 'Hyväksytty', profileApproved: 'Hyväksytty',
profileUpdated: 'Profiili päivitetty', profileUpdated: 'Profiili päivitetty',
billingAgentTitle: 'Laskutusagentti',
billingAgentOptIn: 'Ota laskutusagentti käyttöön automaattiseen laskutukseen',
billingAgentOptInHelp: 'Sallii n8n-agentin luoda laskuja viestien perusteella käyttäen oletus- ja asiakastietoja.',
billingDefaultBank: 'Oletus tilinumero / maksutiedot',
billingVatBreakdown: 'Näytä ALV-erittely laskuissa',
billingUseListingOverrides: 'Käytä kohdekohtaisia laskutustietoja',
billingNoListings: 'Ei kohteita, joille ylikirjoittaa tietoja.',
billingListingBank: 'Kohteen tilinumero / maksutiedot',
billingListingBankPlaceholder: 'IBAN tai maksutiedot tälle kohteelle',
billingListingVat: 'Näytä ALV-erittely tälle kohteelle',
billingSettingsSaved: 'Laskutusasetukset tallennettu',
emailLocked: 'Sähköpostia ei voi vaihtaa', emailLocked: 'Sähköpostia ei voi vaihtaa',
save: 'Tallenna', save: 'Tallenna',
saving: 'Tallennetaan…', saving: 'Tallennetaan…',
@ -672,6 +694,17 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
monitorLoadFailed: 'Kunde inte hämta övervakningsdata.', monitorLoadFailed: 'Kunde inte hämta övervakningsdata.',
monitorCreated: 'Skapad', monitorCreated: 'Skapad',
monitorLastReady: 'Senaste Ready-ändring', monitorLastReady: 'Senaste Ready-ändring',
billingAgentTitle: 'Faktureringsagent',
billingAgentOptIn: 'Aktivera faktureringsagenten för automatisk fakturering',
billingAgentOptInHelp: 'Låter n8n-agenten skapa fakturor med dina standardinställningar och kundens meddelande.',
billingDefaultBank: 'Standard bankkonto / betalningsinfo',
billingVatBreakdown: 'Visa momsuppdelning på fakturor',
billingUseListingOverrides: 'Använd objektspecifika faktureringsuppgifter',
billingNoListings: 'Inga annonser att åsidosätta ännu.',
billingListingBank: 'Annonsens bankkonto / betalningsinfo',
billingListingBankPlaceholder: 'IBAN eller betalningsinfo för denna annons',
billingListingVat: 'Visa momsuppdelning för denna annons',
billingSettingsSaved: 'Faktureringsinställningar sparade',
slugHelp: 'Hitta på en kort och enkel länk; använd små bokstäver och bindestreck (t.ex. sjo-stuga).', slugHelp: 'Hitta på en kort och enkel länk; använd små bokstäver och bindestreck (t.ex. sjo-stuga).',
slugPreview: 'Länk till annonsen: {url}', slugPreview: 'Länk till annonsen: {url}',
heroTitle: 'Hitta ditt nästa finska getaway', heroTitle: 'Hitta ditt nästa finska getaway',

View file

@ -0,0 +1,19 @@
-- Add agent billing settings to users
ALTER TABLE "User"
ADD COLUMN "agentBillingEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "agentBankAccount" TEXT,
ADD COLUMN "agentVatBreakdownRequired" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "agentUseListingOverrides" BOOLEAN NOT NULL DEFAULT false;
-- Listing-specific billing overrides
CREATE TABLE "ListingBillingSettings" (
"id" TEXT NOT NULL,
"listingId" TEXT NOT NULL,
"bankAccount" TEXT,
"vatBreakdownRequired" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ListingBillingSettings_pkey" PRIMARY KEY ("id"),
CONSTRAINT "ListingBillingSettings_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "Listing"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ListingBillingSettings_listingId_key" UNIQUE ("listingId")
);

View file

@ -35,6 +35,10 @@ model User {
passwordHash String @default("") passwordHash String @default("")
name String? name String?
phone String? phone String?
agentBillingEnabled Boolean @default(false)
agentBankAccount String?
agentVatBreakdownRequired Boolean @default(false)
agentUseListingOverrides Boolean @default(false)
role Role @default(USER) role Role @default(USER)
status UserStatus @default(PENDING) status UserStatus @default(PENDING)
emailVerifiedAt DateTime? emailVerifiedAt DateTime?
@ -99,12 +103,23 @@ model Listing {
externalUrl String? externalUrl String?
published Boolean @default(true) published Boolean @default(true)
isSample Boolean @default(false) isSample Boolean @default(false)
billingSettings ListingBillingSettings?
translations ListingTranslation[] translations ListingTranslation[]
images ListingImage[] images ListingImage[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model ListingBillingSettings {
id String @id @default(cuid())
listing Listing @relation(fields: [listingId], references: [id])
listingId String @unique
bankAccount String?
vatBreakdownRequired Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ListingTranslation { model ListingTranslation {
id String @id @default(cuid()) id String @id @default(cuid())
listingId String listingId String