Add billing agent opt-in settings and per-listing overrides
Some checks are pending
CI / checks (push) Waiting to run
Some checks are pending
CI / checks (push) Waiting to run
This commit is contained in:
parent
6392120b3a
commit
49a1096152
5 changed files with 385 additions and 8 deletions
140
app/api/billing-settings/route.ts
Normal file
140
app/api/billing-settings/route.ts
Normal 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';
|
||||
182
app/me/page.tsx
182
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<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(() => {
|
||||
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) {
|
||||
|
|
@ -52,6 +83,41 @@ export default function ProfilePage() {
|
|||
} 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) {
|
||||
setBillingError('Update failed');
|
||||
} finally {
|
||||
setBillingSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -109,6 +175,110 @@ export default function ProfilePage() {
|
|||
{saving ? t('saving') : t('save')}
|
||||
</button>
|
||||
</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>
|
||||
|
|
|
|||
33
lib/i18n.ts
33
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<keyof typeof baseMessages.en, string> = {
|
|||
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',
|
||||
|
|
|
|||
19
prisma/migrations/20251212_agent_billing/migration.sql
Normal file
19
prisma/migrations/20251212_agent_billing/migration.sql
Normal 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")
|
||||
);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue