diff --git a/.env.example b/.env.example index 04280f9..0248296 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,7 @@ OPENAI_TRANSLATIONS_KEY= HETZNER_API_TOKEN= HCLOUD_TOKEN= HETZNER_TOKEN= +N8N_BILLING_API_KEY= JOKER_DYNDNS_USERNAME= JOKER_DYNDNS_PASSWORD= REGISTRY_USERNAME= diff --git a/app/api/integrations/billing/verify/route.ts b/app/api/integrations/billing/verify/route.ts new file mode 100644 index 0000000..a53b76e --- /dev/null +++ b/app/api/integrations/billing/verify/route.ts @@ -0,0 +1,137 @@ +import { NextResponse } from 'next/server'; +import { ListingStatus, UserStatus } from '@prisma/client'; +import { prisma } from '../../../../../lib/prisma'; +import { loadN8nBillingApiKey } from '../../../../../lib/apiKeys'; +import { resolveBillingDetails } from '../../../../../lib/billing'; + +function pickTranslation(translations: { slug: string; locale: string }[]) { + return translations.find((t) => t.locale === 'en') || translations.find((t) => t.locale === 'fi') || translations[0] || null; +} + +function extractApiKey(req: Request) { + const headerKey = req.headers.get('x-api-key'); + if (headerKey) return headerKey.trim(); + const auth = req.headers.get('authorization'); + if (auth && auth.toLowerCase().startsWith('bearer ')) { + return auth.slice(7).trim(); + } + return null; +} + +export async function POST(req: Request) { + const expectedKey = loadN8nBillingApiKey(); + if (!expectedKey) { + return NextResponse.json({ error: 'Billing API key missing' }, { status: 500 }); + } + const providedKey = extractApiKey(req); + if (!providedKey || providedKey !== expectedKey) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: any; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }); + } + + const listingId = typeof body.listingId === 'string' ? body.listingId : undefined; + const listingSlug = typeof body.listingSlug === 'string' ? body.listingSlug.trim() : undefined; + const ownerEmailRaw = typeof body.ownerEmail === 'string' ? body.ownerEmail.trim() : undefined; + const ownerEmail = ownerEmailRaw ? ownerEmailRaw.toLowerCase() : undefined; + + if (!listingId && !listingSlug && !ownerEmail) { + return NextResponse.json({ error: 'Provide listingId, listingSlug, or ownerEmail' }, { status: 400 }); + } + + let listing: any = null; + if (listingId || listingSlug) { + const listings = await prisma.listing.findMany({ + where: { + removedAt: null, + status: { not: ListingStatus.REMOVED }, + id: listingId ?? undefined, + translations: listingSlug ? { some: { slug: listingSlug } } : undefined, + }, + include: { + owner: { + select: { + id: true, + email: true, + status: true, + approvedAt: true, + emailVerifiedAt: true, + billingEmailsEnabled: true, + billingAccountName: true, + billingIban: true, + billingIncludeVatLine: true, + }, + }, + translations: { select: { slug: true, locale: true } }, + }, + take: listingSlug ? 2 : 1, + }); + + if (listingSlug && listings.length > 1) { + return NextResponse.json({ error: 'Listing slug is ambiguous; provide listingId' }, { status: 400 }); + } + listing = listings[0] ?? null; + if (!listing && listingId) { + return NextResponse.json({ error: 'Listing not found' }, { status: 404 }); + } + if (!listing && listingSlug) { + return NextResponse.json({ error: 'Listing not found' }, { status: 404 }); + } + } + + let owner = listing?.owner ?? null; + if (!owner && ownerEmail) { + owner = await prisma.user.findFirst({ + where: { email: { equals: ownerEmail, mode: 'insensitive' } }, + select: { + id: true, + email: true, + status: true, + approvedAt: true, + emailVerifiedAt: true, + billingEmailsEnabled: true, + billingAccountName: true, + billingIban: true, + billingIncludeVatLine: true, + }, + }); + } + + if (!owner) { + return NextResponse.json({ enabled: false }); + } + + const ownerReady = owner.status === UserStatus.ACTIVE && owner.approvedAt && owner.emailVerifiedAt; + if (!ownerReady || !owner.billingEmailsEnabled) { + return NextResponse.json({ enabled: false, owner: { id: owner.id, email: owner.email } }); + } + + const billing = resolveBillingDetails(owner, listing ?? undefined); + const enabled = Boolean(billing.accountName && billing.iban); + const listingHasOverride = + listing && + (listing.billingAccountName !== null && listing.billingAccountName !== undefined || + listing.billingIban !== null && listing.billingIban !== undefined || + listing.billingIncludeVatLine !== null && listing.billingIncludeVatLine !== undefined); + const source = listingHasOverride ? 'listing' : 'user'; + + return NextResponse.json({ + enabled, + owner: { id: owner.id, email: owner.email }, + listing: listing + ? { + id: listing.id, + slug: pickTranslation(listing.translations)?.slug ?? listing.translations[0]?.slug ?? null, + status: listing.status, + } + : null, + billing: enabled ? { ...billing, source } : null, + }); +} + +export const dynamic = 'force-dynamic'; diff --git a/app/api/me/billing/route.ts b/app/api/me/billing/route.ts new file mode 100644 index 0000000..c6805de --- /dev/null +++ b/app/api/me/billing/route.ts @@ -0,0 +1,215 @@ +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>['user']>, listings: Awaited>['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 }[] = []; + listingOverrides.forEach((override) => { + if (!listingMap.has(override.id!)) return; + const data: Record = {}; + 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'; diff --git a/app/me/page.tsx b/app/me/page.tsx index ee65f51..05fb5bc 100644 --- a/app/me/page.tsx +++ b/app/me/page.tsx @@ -4,6 +4,15 @@ 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 BillingListing = { + id: string; + title: string; + slug: string; + locale: string; + billingAccountName: string | null; + billingIban: string | null; + billingIncludeVatLine: boolean | null; +}; export default function ProfilePage() { const { t } = useI18n(); @@ -14,6 +23,15 @@ export default function ProfilePage() { const [password, setPassword] = useState(''); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(null); + const [billingEnabled, setBillingEnabled] = useState(false); + const [billingAccountName, setBillingAccountName] = useState(''); + const [billingIban, setBillingIban] = useState(''); + const [billingIncludeVatLine, setBillingIncludeVatLine] = useState(false); + const [billingListings, setBillingListings] = useState([]); + const [billingLoading, setBillingLoading] = useState(false); + const [billingSaving, setBillingSaving] = useState(false); + const [billingMessage, setBillingMessage] = useState(null); + const [billingError, setBillingError] = useState(null); useEffect(() => { fetch('/api/auth/me', { cache: 'no-store' }) @@ -28,6 +46,38 @@ export default function ProfilePage() { .catch(() => setError(t('notLoggedIn'))); }, [t]); + useEffect(() => { + setBillingLoading(true); + fetch('/api/me/billing', { cache: 'no-store' }) + .then(async (res) => { + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed'); + return data; + }) + .then((data) => { + setBillingEnabled(Boolean(data.settings?.enabled)); + setBillingAccountName(data.settings?.accountName ?? ''); + setBillingIban(data.settings?.iban ?? ''); + setBillingIncludeVatLine(Boolean(data.settings?.includeVatLine)); + setBillingListings( + Array.isArray(data.listings) + ? data.listings.map((l: any) => ({ + id: l.id, + title: l.title, + slug: l.slug, + locale: l.locale, + billingAccountName: l.billingAccountName ?? '', + billingIban: l.billingIban ?? '', + billingIncludeVatLine: l.billingIncludeVatLine ?? null, + })) + : [], + ); + setBillingError(null); + }) + .catch(() => setBillingError(t('billingLoadFailed'))) + .finally(() => setBillingLoading(false)); + }, [t]); + async function onSave(e: React.FormEvent) { e.preventDefault(); setSaving(true); @@ -54,6 +104,58 @@ export default function ProfilePage() { } } + async function onSaveBilling(e: React.FormEvent) { + e.preventDefault(); + setBillingSaving(true); + setBillingError(null); + setBillingMessage(null); + try { + const res = await fetch('/api/me/billing', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: billingEnabled, + accountName: billingAccountName, + iban: billingIban, + includeVatLine: billingIncludeVatLine, + listings: billingListings.map((l) => ({ + id: l.id, + accountName: l.billingAccountName, + iban: l.billingIban, + includeVatLine: l.billingIncludeVatLine, + })), + }), + }); + const data = await res.json(); + if (!res.ok) { + setBillingError(data.error || t('billingSaveFailed')); + return; + } + setBillingEnabled(Boolean(data.settings?.enabled)); + setBillingAccountName(data.settings?.accountName ?? ''); + setBillingIban(data.settings?.iban ?? ''); + setBillingIncludeVatLine(Boolean(data.settings?.includeVatLine)); + setBillingListings( + Array.isArray(data.listings) + ? data.listings.map((l: any) => ({ + id: l.id, + title: l.title, + slug: l.slug, + locale: l.locale, + billingAccountName: l.billingAccountName ?? '', + billingIban: l.billingIban ?? '', + billingIncludeVatLine: l.billingIncludeVatLine ?? null, + })) + : [], + ); + setBillingMessage(t('billingSaved')); + } catch (err) { + setBillingError(t('billingSaveFailed')); + } finally { + setBillingSaving(false); + } + } + return (

{t('myProfileTitle')}

@@ -109,6 +211,125 @@ export default function ProfilePage() { {saving ? t('saving') : t('save')} +
+

{t('billingSettingsTitle')}

+

{t('billingSettingsLead')}

+ {billingMessage ?

{billingMessage}

: null} + {billingError ?

{billingError}

: null} +
+ + {billingEnabled ? ( + <> +
+ + + +
+
+
+ {t('billingListingsTitle')} +
{t('billingListingsLead')}
+
+ {billingLoading ? ( +

{t('loading')}

+ ) : billingListings.length === 0 ? ( +

{t('billingNoListings')}

+ ) : ( +
+ {billingListings.map((listing) => { + const vatValue = + listing.billingIncludeVatLine === null || listing.billingIncludeVatLine === undefined + ? 'inherit' + : listing.billingIncludeVatLine + ? 'yes' + : 'no'; + return ( +
+
+ {listing.title} ({listing.slug}) +
+
+ + + +
+
+ ); + })} +
+ )} +
+ + ) : ( +

{t('billingDisabledHint')}

+ )} +
+ +
+
+
) : (

{error ?? t('notLoggedIn')}

diff --git a/docs/build.html b/docs/build.html index ceaefa7..a5b1800 100644 --- a/docs/build.html +++ b/docs/build.html @@ -96,6 +96,7 @@ flowchart LR
  • From ConfigMap (public): NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_API_BASE, APP_ENV.
  • From Secret: DB URL, AUTH_SECRET, SMTP, DKIM, etc. (materialize from creds/secrets.env).
  • App env resolution: process.env.* in Next server code.
  • +
  • n8n billing assistant: N8N_BILLING_API_KEY or file creds/n8n-billing.key protects /api/integrations/billing/verify.
  • diff --git a/docs/secrets.md b/docs/secrets.md index 194056d..41c7190 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -5,6 +5,7 @@ - `creds/secrets.enc.env`: encrypted dotenv managed by sops/age (committable). - `creds/secrets.env`: decrypted dotenv (git-ignored) produced when loading secrets; not committed. - Legacy plaintext secrets moved to `creds/deprecated/` for reference. +- `creds/n8n-billing.key`: API key for the billing verification endpoint (git-ignored). Can also be provided via `N8N_BILLING_API_KEY`. ## Editing secrets ```bash @@ -31,6 +32,11 @@ This decrypts `creds/secrets.enc.env` to `creds/secrets.env` if needed (requires - Update `.sops.yaml` recipient to the new public key. - Re-encrypt: `SOPS_AGE_KEY_FILE=creds/age-key.txt sops --encrypt --in-place creds/secrets.enc.env`. +## n8n billing API key +- The billing assistant verification endpoint (`/api/integrations/billing/verify`) requires an API key. +- Store it in `creds/n8n-billing.key` (git-ignored) or export `N8N_BILLING_API_KEY` via `creds/secrets.env`. +- Rotate by replacing the file/env value and restarting the app/n8n caller with the new key. + ## Per-user age keys - Keys live under `creds/age/.key` (git-ignored) and carry a public key in the header. - Helper: `./scripts/manage-age-key.sh add alice` generates a key and appends the recipient to `.sops.yaml`. diff --git a/lib/apiKeys.ts b/lib/apiKeys.ts new file mode 100644 index 0000000..07c5b82 --- /dev/null +++ b/lib/apiKeys.ts @@ -0,0 +1,12 @@ +import fs from 'fs'; +import path from 'path'; + +export function loadN8nBillingApiKey() { + if (process.env.N8N_BILLING_API_KEY) return process.env.N8N_BILLING_API_KEY; + const keyPath = path.join(process.cwd(), 'creds', 'n8n-billing.key'); + try { + return fs.readFileSync(keyPath, 'utf8').trim(); + } catch { + return null; + } +} diff --git a/lib/billing.ts b/lib/billing.ts new file mode 100644 index 0000000..9bd4537 --- /dev/null +++ b/lib/billing.ts @@ -0,0 +1,41 @@ +import type { Listing, User } from '@prisma/client'; + +export type BillingConfig = { + accountName: string | null; + iban: string | null; + includeVatLine: boolean; +}; + +type BillingUser = Pick; +type BillingListing = Pick; + +export function normalizeOptionalString(input: unknown): string | null | undefined { + if (input === undefined) return undefined; + if (input === null) return null; + const value = String(input).trim(); + return value ? value : null; +} + +export function normalizeIban(input: unknown): string | null | undefined { + if (input === undefined) return undefined; + if (input === null) return null; + const value = String(input).replace(/\s+/g, '').toUpperCase(); + return value ? value : null; +} + +export function normalizeNullableBoolean(input: unknown): boolean | null | undefined { + if (input === undefined) return undefined; + if (input === null) return null; + return Boolean(input); +} + +export function resolveBillingDetails(user: BillingUser, listing?: BillingListing | null): BillingConfig { + const accountName = listing?.billingAccountName ?? user.billingAccountName ?? null; + const iban = listing?.billingIban ?? user.billingIban ?? null; + const includeVatLine = + listing?.billingIncludeVatLine !== null && listing?.billingIncludeVatLine !== undefined + ? Boolean(listing.billingIncludeVatLine) + : Boolean(user.billingIncludeVatLine); + + return { accountName, iban, includeVatLine }; +} diff --git a/lib/i18n.ts b/lib/i18n.ts index 6ac8f21..3107014 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -145,6 +145,25 @@ const baseMessages = { profileEmailVerified: 'Email verified', profileApproved: 'Approved', profileUpdated: 'Profile updated', + billingSettingsTitle: 'Billing assistant', + billingSettingsLead: 'Opt into automated billing emails via the n8n agent. Set defaults and per-listing overrides.', + billingEnableLabel: 'Enable billing assistant for my listings', + billingAccountNameLabel: 'Billing account owner name', + billingAccountPlaceholder: 'Use profile default', + billingIbanLabel: 'IBAN for payouts', + billingIbanPlaceholder: 'Use profile default', + billingIncludeVat: 'Include a VAT line on invoices', + billingListingsTitle: 'Per-listing billing', + billingListingsLead: 'Override billing details per listing; blank fields inherit your profile defaults.', + billingNoListings: 'No listings available yet.', + billingVatChoice: 'VAT line preference', + billingVatInherit: 'Use profile choice', + billingVatYes: 'Include VAT line', + billingVatNo: 'Do not include VAT line', + billingDisabledHint: 'Enable the billing assistant to manage invoice details and VAT preferences.', + billingLoadFailed: 'Failed to load billing settings.', + billingSaveFailed: 'Could not save billing settings.', + billingSaved: 'Billing settings saved.', settingsSaved: 'Settings saved.', emailLocked: 'Email cannot be changed', save: 'Save', @@ -504,6 +523,25 @@ const baseMessages = { profileEmailVerified: 'Sähköposti vahvistettu', profileApproved: 'Hyväksytty', profileUpdated: 'Profiili päivitetty', + billingSettingsTitle: 'Laskutusavustin', + billingSettingsLead: 'Ota n8n-laskutus käyttöön halutessasi ja määritä oletus- ja kohdekohtaiset tiedot.', + billingEnableLabel: 'Ota laskutusavustin käyttöön kohteilleni', + billingAccountNameLabel: 'Tilinomistajan nimi', + billingAccountPlaceholder: 'Käytä profiilin oletusta', + billingIbanLabel: 'IBAN-maksuissa', + billingIbanPlaceholder: 'Käytä profiilin oletusta', + billingIncludeVat: 'Lisää laskulle ALV-rivi', + billingListingsTitle: 'Kohdekohtaiset asetukset', + billingListingsLead: 'Ylikirjoita tiedot kohteittain; tyhjä kenttä käyttää profiilin arvoa.', + billingNoListings: 'Ei kohteita vielä.', + billingVatChoice: 'ALV-rivin valinta', + billingVatInherit: 'Käytä profiilin asetusta', + billingVatYes: 'Lisää ALV-rivi', + billingVatNo: 'Älä lisää ALV-riviä', + billingDisabledHint: 'Ota laskutusavustin käyttöön lisätäksesi laskutustiedot ja ALV-valinnat.', + billingLoadFailed: 'Laskutusasetusten lataus epäonnistui.', + billingSaveFailed: 'Laskutusasetusten tallennus epäonnistui.', + billingSaved: 'Laskutusasetukset tallennettu.', settingsSaved: 'Asetukset tallennettu.', emailLocked: 'Sähköpostia ei voi vaihtaa', save: 'Tallenna', @@ -735,6 +773,25 @@ const svMessages: Record = { settingContactVisibilityTitle: 'Synlighet för kontaktuppgifter', settingContactVisibilityHelp: 'Dölj värdens kontaktuppgifter för anonyma besökare så att bara inloggade ser dem.', settingRequireLoginForContact: 'Kräv inloggning för att se kontaktuppgifter', + billingSettingsTitle: 'Faktureringsassistent', + billingSettingsLead: 'Aktivera n8n-fakturor om du vill och ange standard- samt objektspecifika uppgifter.', + billingEnableLabel: 'Aktivera faktureringsassistent för mina annonser', + billingAccountNameLabel: 'Kontoinnehavarens namn', + billingAccountPlaceholder: 'Använd profilens standard', + billingIbanLabel: 'IBAN för utbetalningar', + billingIbanPlaceholder: 'Använd profilens standard', + billingIncludeVat: 'Ta med momsrad på fakturor', + billingListingsTitle: 'Objektvisa inställningar', + billingListingsLead: 'Åsidosätt uppgifter per annons; tomma fält använder profilens värden.', + billingNoListings: 'Inga annonser ännu.', + billingVatChoice: 'Momsrad', + billingVatInherit: 'Använd profilinställning', + billingVatYes: 'Lägg till momsrad', + billingVatNo: 'Lägg inte till momsrad', + billingDisabledHint: 'Aktivera assistenten för att hantera fakturauppgifter och momsval.', + billingLoadFailed: 'Kunde inte ladda faktureringsinställningar.', + billingSaveFailed: 'Kunde inte spara faktureringsinställningar.', + billingSaved: 'Faktureringsinställningar sparade.', settingsSaved: 'Inställningar sparade.', translationMissing: 'Lägg till minst ett språk med titel och beskrivning.', saveDraft: 'Spara utkast', diff --git a/prisma/migrations/20260311_billing_preferences/migration.sql b/prisma/migrations/20260311_billing_preferences/migration.sql new file mode 100644 index 0000000..8ddf17e --- /dev/null +++ b/prisma/migrations/20260311_billing_preferences/migration.sql @@ -0,0 +1,11 @@ +-- Billing preferences for automated invoices + per-listing overrides +ALTER TABLE "User" +ADD COLUMN "billingEmailsEnabled" BOOLEAN NOT NULL DEFAULT FALSE, +ADD COLUMN "billingAccountName" TEXT, +ADD COLUMN "billingIban" TEXT, +ADD COLUMN "billingIncludeVatLine" BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE "Listing" +ADD COLUMN "billingAccountName" TEXT, +ADD COLUMN "billingIban" TEXT, +ADD COLUMN "billingIncludeVatLine" BOOLEAN; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 91bfa04..f3f1755 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,6 +49,10 @@ model User { verificationTokens VerificationToken[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + billingEmailsEnabled Boolean @default(false) + billingAccountName String? + billingIban String? + billingIncludeVatLine Boolean @default(false) } model Listing { @@ -95,6 +99,9 @@ model Listing { calendarUrls String[] @db.Text @default([]) priceWeekdayEuros Int? priceWeekendEuros Int? + billingAccountName String? + billingIban String? + billingIncludeVatLine Boolean? contactName String? contactEmail String? contactPhone String?