diff --git a/app/api/billing-settings/route.ts b/app/api/billing-settings/route.ts new file mode 100644 index 0000000..389f3b3 --- /dev/null +++ b/app/api/billing-settings/route.ts @@ -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'; diff --git a/app/me/page.tsx b/app/me/page.tsx index ee65f51..4555e2b 100644 --- a/app/me/page.tsx +++ b/app/me/page.tsx @@ -4,6 +4,14 @@ import { useEffect, useState } from 'react'; 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 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() { const { t } = useI18n(); @@ -14,18 +22,41 @@ export default function ProfilePage() { const [password, setPassword] = useState(''); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(null); + const [billingSettings, setBillingSettings] = useState(null); + const [billingError, setBillingError] = useState(null); + const [billingMessage, setBillingMessage] = useState(null); + const [billingSaving, setBillingSaving] = useState(false); useEffect(() => { - fetch('/api/auth/me', { cache: 'no-store' }) - .then((res) => res.json()) - .then((data) => { + const load = async () => { + try { + const res = await fetch('/api/auth/me', { cache: 'no-store' }); + const data = await res.json(); if (data.user) { setUser(data.user); setName(data.user.name ?? ''); setPhone(data.user.phone ?? ''); - } else setError(t('notLoggedIn')); - }) - .catch(() => setError(t('notLoggedIn'))); + const billingRes = await fetch('/api/billing-settings', { cache: 'no-store' }); + const billingData = await billingRes.json(); + 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]); async function onSave(e: React.FormEvent) { @@ -47,10 +78,45 @@ export default function ProfilePage() { setPassword(''); 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) { - setError('Update failed'); + setBillingError('Update failed'); } finally { - setSaving(false); + setBillingSaving(false); } } @@ -109,6 +175,110 @@ export default function ProfilePage() { {saving ? t('saving') : t('save')} +
+

{t('billingAgentTitle')}

+ {billingMessage ?

{billingMessage}

: null} + {billingError ?

{billingError}

: null} + {billingSettings ? ( +
+ +

{t('billingAgentOptInHelp')}

+ + + + {billingSettings.agentBillingEnabled && billingSettings.agentUseListingOverrides ? ( +
+ {billingSettings.listingOverrides.length === 0 ? ( +

{t('billingNoListings')}

+ ) : ( + billingSettings.listingOverrides.map((listing) => ( +
+
{listing.label}
+ + +
+ )) + )} +
+ ) : null} + +
+ ) : ( +

{t('loading')}

+ )} +
) : (

{error ?? t('notLoggedIn')}

diff --git a/lib/i18n.ts b/lib/i18n.ts index d67b7aa..4b71f35 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -141,6 +141,17 @@ const baseMessages = { profileEmailVerified: 'Email verified', profileApproved: 'Approved', 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', save: 'Save', saving: 'Saving…', @@ -484,6 +495,17 @@ const baseMessages = { profileEmailVerified: 'Sähköposti vahvistettu', profileApproved: 'Hyväksytty', 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', save: 'Tallenna', saving: 'Tallennetaan…', @@ -672,6 +694,17 @@ const svMessages: Record = { monitorLoadFailed: 'Kunde inte hämta övervakningsdata.', monitorCreated: 'Skapad', 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).', slugPreview: 'Länk till annonsen: {url}', heroTitle: 'Hitta ditt nästa finska getaway', diff --git a/prisma/migrations/20251212_agent_billing/migration.sql b/prisma/migrations/20251212_agent_billing/migration.sql new file mode 100644 index 0000000..f6c52c6 --- /dev/null +++ b/prisma/migrations/20251212_agent_billing/migration.sql @@ -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") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0c6fe37..98fbbdd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,6 +35,10 @@ model User { passwordHash String @default("") name String? phone String? + agentBillingEnabled Boolean @default(false) + agentBankAccount String? + agentVatBreakdownRequired Boolean @default(false) + agentUseListingOverrides Boolean @default(false) role Role @default(USER) status UserStatus @default(PENDING) emailVerifiedAt DateTime? @@ -99,12 +103,23 @@ model Listing { externalUrl String? published Boolean @default(true) isSample Boolean @default(false) + billingSettings ListingBillingSettings? translations ListingTranslation[] images ListingImage[] createdAt DateTime @default(now()) 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 { id String @id @default(cuid()) listingId String