feat: add language preference for customers to support invoice locali… (#561)

* feat: add language preference for customers to support invoice localization

- Introduced language support for invoices, allowing customers to choose between Swedish and English.
- Updated invoice PDF generation to reflect the selected language for titles, labels, and messages.
- Enhanced email templates to generate content in the customer's preferred language.
- Added migration to include a language column in the customers table with a default value of Swedish.
- Updated tests to verify correct language usage in invoice emails and PDFs.

* fix: debounce API requests in InvoicePreviewCard and update F-skatt terminology in email templates
This commit is contained in:
Mattsson
2026-05-22 15:21:05 +02:00
committed by GitHub
parent 64bbeb4021
commit 78c91e00e4
21 changed files with 807 additions and 342 deletions
+2 -2
View File
@@ -385,8 +385,8 @@ Add new strings to both `messages/sv.json` and `messages/en.json` under the matc
| Surface | Reason |
|---|---|
| Invoice PDFs (`lib/invoices/pdf-template.tsx`) | Sent to the user's customers, who are typically Swedish |
| Customer email templates (`lib/email/invoice-templates.ts`, `reminder-templates.ts`) | Same — recipient is the customer, not the app user |
| Invoice PDFs (`lib/invoices/pdf-template.tsx`) | Customer-facing — driven by `customer.language` (`sv` default, `en` opt-in). The template's chrome translates; statutory chapter refs (ML 17 kap 24§, ML 3 kap.) stay intact in both locales. |
| Customer email templates (`lib/email/invoice-templates.ts`, `reminder-templates.ts`) | Same — `customer.language` drives the output. `reminder-templates.ts` is still Swedish-only; mirror the PDF/invoice-templates approach if you add English here. |
| Year-end wizard (`app/(dashboard)/bookkeeping/year-end/page.tsx`) | Statutory bokslut terminology; English would be misleading |
| Journal entry editor (`app/(dashboard)/bookkeeping/[id]/page.tsx`) | Deeply regulatory (verifikat, voucher numbers, BAS) |
| INK2 / NE-bilaga / SRU (`lib/reports/ink2/**`, `lib/reports/ne-bilaga/**`, `lib/reports/sru-*`) | Skatteverket forms — field codes and labels are statutory |
+51 -42
View File
@@ -1804,9 +1804,11 @@ export default function ImportPage() {
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">{t('title')}</h1>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
{view === 'export' ? t('export_title') : t('title')}
</h1>
<p className="text-muted-foreground">
{t('subtitle')}
{view === 'export' ? t('export_subtitle') : t('subtitle')}
</p>
</div>
@@ -2014,48 +2016,55 @@ export default function ImportPage() {
</TabsContent>
<TabsContent value="export" className="mt-6">
<div className="grid gap-4 md:grid-cols-2 items-start">
{/* SIE-export */}
<Card id="sie-export" className="scroll-mt-24">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4 text-muted-foreground" />
{t('export_sie_title')}
</CardTitle>
<CardDescription>{t('export_sie_description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FiscalYearSelector
value={exportPeriodId}
onChange={setExportPeriodId}
includeAllOption={false}
hideFuturePeriods
label={t('export_sie_period_label')}
/>
<Button
onClick={() => {
if (exportPeriodId) {
window.open(`/api/reports/sie-export?period_id=${exportPeriodId}`, '_blank')
}
}}
disabled={!exportPeriodId || isSandbox}
>
<Download className="mr-2 h-4 w-4" />
{t('export_sie_button')}
</Button>
{!exportPeriodId && (
<p className="text-xs text-muted-foreground">{t('export_sie_no_period')}</p>
)}
</CardContent>
</Card>
<div className="space-y-4">
{/* SIE-export */}
<div id="sie-export" className="scroll-mt-24 rounded-lg border border-border bg-card p-6">
<div className="grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
{/* Identity */}
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<FileSpreadsheet className="h-[18px] w-[18px] text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-[15px] font-semibold leading-tight">{t('export_sie_title')}</h3>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
{t('export_sie_description')}
</p>
</div>
</div>
{/* Molnsynkronisering (Google Drive) */}
{hasCloudBackup && (
<div id="cloud-backup" className="scroll-mt-24">
<CloudBackupCard />
{/* Controls */}
<div className="space-y-4">
<FiscalYearSelector
value={exportPeriodId}
onChange={setExportPeriodId}
includeAllOption={false}
hideFuturePeriods
label={t('export_sie_period_label')}
/>
<Button
onClick={() => {
if (exportPeriodId) {
window.open(`/api/reports/sie-export?period_id=${exportPeriodId}`, '_blank')
}
}}
disabled={!exportPeriodId || isSandbox}
className="w-full sm:w-auto"
>
<Download className="mr-2 h-4 w-4" />
{t('export_sie_button')}
</Button>
</div>
</div>
</div>
)}
</div>
{/* Molnsynkronisering (Google Drive) */}
{hasCloudBackup && (
<div id="cloud-backup" className="scroll-mt-24">
<CloudBackupCard />
</div>
)}
</div>
</TabsContent>
</Tabs>
</>
+12 -16
View File
@@ -50,25 +50,21 @@ export default function InvoicingSettingsPage() {
}
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-8">
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
<BankDetailsForm settings={settings} />
<div className="border-t border-border/8 pt-8">
<InvoiceSettingsForm settings={settings} />
</div>
</SettingsFormWrapper>
{/* PDF settings — saves individually via toggle switches */}
<div className="border-t border-border/8 pt-8">
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
</div>
<div className="space-y-8">
<div className="flex justify-end">
<InvoicePreviewCard settings={settings} />
</div>
<div className="lg:col-span-1">
<div className="lg:sticky lg:top-8">
<InvoicePreviewCard settings={settings} />
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
<BankDetailsForm settings={settings} />
<div className="border-t border-border/8 pt-8">
<InvoiceSettingsForm settings={settings} />
</div>
</SettingsFormWrapper>
{/* PDF settings — saves individually via toggle switches */}
<div className="border-t border-border/8 pt-8">
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
</div>
</div>
)
+1
View File
@@ -67,6 +67,7 @@ export const PATCH = withRouteContext(
if (body.country !== undefined) updateData.country = body.country
if (body.org_number !== undefined) updateData.org_number = body.org_number
if (body.vat_number !== undefined) updateData.vat_number = body.vat_number
if (body.language !== undefined) updateData.language = body.language
if (body.default_payment_terms !== undefined) updateData.default_payment_terms = body.default_payment_terms
if (body.notes !== undefined) updateData.notes = body.notes
+1
View File
@@ -58,6 +58,7 @@ export const POST = withRouteContext(
country: body.country || 'Sweden',
org_number: body.org_number,
vat_number: body.vat_number,
language: body.language || 'sv',
default_payment_terms: body.default_payment_terms || 30,
notes: body.notes,
})
+1 -1
View File
@@ -90,7 +90,7 @@ export async function POST(request: Request) {
id: 'preview',
user_id: user.id,
customer_id,
invoice_number: typeof invoice_number === 'string' && invoice_number.trim() ? invoice_number : 'FÖRHANDSGRANSKNING',
invoice_number: typeof invoice_number === 'string' && invoice_number.trim() ? invoice_number : null,
invoice_date: invoice_date || new Date().toISOString().split('T')[0],
due_date: due_date || new Date().toISOString().split('T')[0],
delivery_date: delivery_date || null,
@@ -303,6 +303,7 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
'country',
'org_number',
'vat_number',
'language',
'default_payment_terms',
'notes',
'archived_at',
@@ -396,6 +396,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
vat_number: body.vat_number ?? null,
vat_number_validated: vatValidated,
vat_number_validated_at: vatValidatedAt,
language: body.language ?? 'sv',
default_payment_terms: body.default_payment_terms ?? 30,
notes: body.notes ?? null,
})
+23
View File
@@ -52,6 +52,7 @@ export default function CustomerForm({
.regex(/^(\d{6}|\d{8})[-+]?\d{4}$/, t('personal_number_invalid'))
.optional()
.or(z.literal('')),
language: z.enum(['sv', 'en']).optional(),
default_payment_terms: z.number().min(1).optional(),
notes: z.string().optional(),
}), [t])
@@ -78,6 +79,7 @@ export default function CustomerForm({
org_number: initialData?.org_number || '',
vat_number: initialData?.vat_number || '',
personal_number: initialData?.personal_number || '',
language: initialData?.language || 'sv',
default_payment_terms: initialData?.default_payment_terms || 30,
notes: initialData?.notes || '',
},
@@ -318,6 +320,27 @@ export default function CustomerForm({
/>
</div>
{/* Invoice language */}
<div className="space-y-2">
<Label>{t('language_label')}</Label>
<Controller
name="language"
control={control}
render={({ field }) => (
<Select value={field.value ?? 'sv'} onValueChange={(v) => { if (v) field.onChange(v) }}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sv">{t('language_sv')}</SelectItem>
<SelectItem value="en">{t('language_en')}</SelectItem>
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">{t('language_hint')}</p>
</div>
{/* Notes */}
<div className="space-y-2">
<Label htmlFor="notes">{t('notes_label')}</Label>
+43 -35
View File
@@ -1,8 +1,16 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Eye } from 'lucide-react'
import { useLocale, useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
@@ -13,47 +21,44 @@ interface InvoicePreviewCardProps {
settings: CompanySettings
}
/**
* Live invoice PDF preview for the invoicing settings page.
*
* Re-fetches the preview PDF whenever the persisted `settings` change
* (debounced 500ms so rapid toggles don't hammer the endpoint). Reads
* the first customer in the company as a dummy recipient — the preview
* endpoint requires a real `customer_id` and `items` payload.
*/
export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
const t = useTranslations('settings_invoicing_preview')
const locale = useLocale() as ErrorLocale
const { company } = useCompany()
const [open, setOpen] = useState(false)
const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [noCustomers, setNoCustomers] = useState(false)
const currentUrlRef = useRef<string | null>(null)
// Resolve translated sample-line description once per render so the
// effect dependency stays referentially stable across renders.
const sampleItemDescription = t('sample_item_description')
// Debounced refresh whenever `settings` (identity) changes.
useEffect(() => {
if (!company?.id) return
if (!open || !company?.id) return
const companyId = company.id
let cancelled = false
const controller = new AbortController()
const timer = setTimeout(async () => {
// Debounce so rapid settings toggles (PDF print options on the invoicing
// page) don't burst-fire requests at /api/invoices/preview-pdf while the
// dialog is open. AbortController still cancels any in-flight fetch.
const timer = setTimeout(() => {
run()
}, 500)
async function run() {
setIsLoading(true)
setError(null)
setNoCustomers(false)
try {
// Pick any customer for the company — preview endpoint requires one.
const supabase = createClient()
const { data: customer, error: customerError } = await supabase
.from('customers')
.select('id')
.eq('company_id', company.id)
.eq('company_id', companyId)
.limit(1)
.maybeSingle()
@@ -95,9 +100,6 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
if (cancelled) return
const url = URL.createObjectURL(blob)
// Revoke the previous blob before swapping in the new one so we
// never leak object URLs.
if (currentUrlRef.current) URL.revokeObjectURL(currentUrlRef.current)
currentUrlRef.current = url
setBlobUrl(url)
@@ -108,16 +110,15 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
setError(getErrorMessage(err, { locale, context: 'invoice' }))
setIsLoading(false)
}
}, 500)
}
return () => {
cancelled = true
controller.abort()
clearTimeout(timer)
controller.abort()
}
}, [settings, company?.id, sampleItemDescription, locale])
}, [open, settings, company?.id, sampleItemDescription, locale])
// Final cleanup: revoke the in-flight blob URL when the component unmounts.
useEffect(() => {
return () => {
if (currentUrlRef.current) {
@@ -128,26 +129,33 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
}, [])
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Eye className="h-4 w-4" />
{t('preview_button')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
</DialogHeader>
{isLoading && (
<div className="space-y-2" aria-live="polite" aria-busy="true">
<Skeleton className="h-[600px] w-full rounded-lg" />
<Skeleton className="h-[70vh] w-full rounded-lg" />
<p className="text-xs text-muted-foreground">{t('loading')}</p>
</div>
)}
{!isLoading && noCustomers && (
<div className="flex h-[600px] w-full items-center justify-center rounded-lg border border-border bg-muted/30 px-6 text-center">
<div className="flex h-[70vh] w-full items-center justify-center rounded-lg border border-border bg-muted/30 px-6 text-center">
<p className="text-sm text-muted-foreground">{t('no_customers')}</p>
</div>
)}
{!isLoading && error && (
<div className="flex h-[600px] w-full items-center justify-center rounded-lg border border-border bg-muted/30 px-6 text-center">
<div className="flex h-[70vh] w-full items-center justify-center rounded-lg border border-border bg-muted/30 px-6 text-center">
<p className="text-sm text-destructive">{t('error')}: {error}</p>
</div>
)}
@@ -156,10 +164,10 @@ export function InvoicePreviewCard({ settings }: InvoicePreviewCardProps) {
<iframe
src={blobUrl}
title={t('iframe_title')}
className="w-full h-[600px] rounded-lg border border-border"
className="w-full h-[70vh] rounded-lg border border-border"
/>
)}
</CardContent>
</Card>
</DialogContent>
</Dialog>
)
}
@@ -3,7 +3,6 @@
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
@@ -141,115 +140,125 @@ export default function CloudBackupCard() {
}, [loadStatus, toast])
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Cloud className="h-4 w-4 text-muted-foreground" />
Google Drive
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<p className="text-sm text-muted-foreground">Laddar…</p>
) : status?.connected ? (
<>
<div className="text-sm">
<p>
Ansluten som <span className="font-medium">{status.account_email}</span>
</p>
{status.connected_at && (
<p className="text-xs text-muted-foreground">
Kopplat {formatDate(status.connected_at)}
</p>
)}
</div>
{status.last_sync ? (
<div className="rounded-md border border-border/60 bg-muted/30 p-3 text-sm">
<p>
Senaste synk: <span className="font-medium">{status.last_sync.file_name}</span>
</p>
<p className="text-xs text-muted-foreground">
{formatDate(status.last_sync.at)} · {formatMb(status.last_sync.file_size_bytes)}
</p>
<a
href={`https://drive.google.com/file/d/${status.last_sync.file_id}/view`}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
Öppna i Drive
<ExternalLink className="h-3 w-3" />
</a>
</div>
) : (
<p className="text-xs text-muted-foreground">
Ingen synk än — kör &ldquo;Synka nu&rdquo; för att ladda upp första arkivet.
</p>
)}
<ScheduleSection
schedule={status.schedule}
onUpdated={loadStatus}
/>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button onClick={handleSync} disabled={isSyncing}>
{isSyncing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Synkar…
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Synka nu
</>
)}
</Button>
<Button
variant="outline"
onClick={handleDisconnect}
disabled={isDisconnecting}
>
{isDisconnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Kopplar bort…
</>
) : (
<>
<Unplug className="mr-2 h-4 w-4" />
Koppla bort
</>
)}
</Button>
</div>
</>
) : (
<>
<p className="text-sm text-muted-foreground">
Koppla ditt Google-konto för att ladda upp säkerhetsbackupen till din egen
Drive. gnubok får bara tillgång till filer som appen själv skapar (scope
<span className="font-mono text-xs"> drive.file</span>).
<div className="rounded-lg border border-border bg-card p-6">
<div className="grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)]">
{/* Identity */}
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-foreground/[0.06]">
<Cloud className="h-[18px] w-[18px] text-foreground/60" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-[15px] font-semibold leading-tight">Google Drive</h3>
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
Säkerhetskopia till din egen Drive.
</p>
<Button onClick={handleConnect} disabled={isConnecting}>
{isConnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Omdirigerar…
</>
) : (
<>
<Cloud className="mr-2 h-4 w-4" />
Koppla Google Drive
</>
)}
</Button>
</>
)}
</CardContent>
</Card>
</div>
</div>
{/* Controls */}
<div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Laddar…</p>
) : status?.connected ? (
<>
<dl className="space-y-3 text-sm">
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-muted-foreground">Konto</dt>
<dd className="min-w-0 truncate font-medium">{status.account_email}</dd>
</div>
<div className="flex items-baseline justify-between gap-3">
<dt className="shrink-0 text-muted-foreground">Senaste synk</dt>
<dd className="min-w-0 text-right">
{status.last_sync ? (
<>
<a
href={`https://drive.google.com/file/d/${status.last_sync.file_id}/view`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium underline-offset-4 hover:underline tabular-nums"
>
{formatDate(status.last_sync.at)}
<ExternalLink className="h-3 w-3 text-muted-foreground" />
</a>
<p className="text-xs text-muted-foreground tabular-nums">
{formatMb(status.last_sync.file_size_bytes)}
</p>
</>
) : (
<span className="text-muted-foreground">Aldrig</span>
)}
</dd>
</div>
</dl>
<div className="mt-6 pt-6 border-t border-border">
<ScheduleSection
schedule={status.schedule}
onUpdated={loadStatus}
/>
</div>
<div className="mt-6 pt-6 border-t border-border flex flex-col gap-2 sm:flex-row sm:justify-between">
<Button onClick={handleSync} disabled={isSyncing} className="w-full sm:w-auto">
{isSyncing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Synkar…
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Synka nu
</>
)}
</Button>
<Button
variant="outline"
onClick={handleDisconnect}
disabled={isDisconnecting}
className="w-full sm:w-auto"
>
{isDisconnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Kopplar bort…
</>
) : (
<>
<Unplug className="mr-2 h-4 w-4" />
Koppla bort
</>
)}
</Button>
</div>
</>
) : (
<>
<p className="text-sm text-muted-foreground leading-relaxed">
Koppla ditt Google-konto för att ladda upp säkerhetsbackupen till din egen Drive.
gnubok får bara tillgång till filer som appen själv skapar (scope{' '}
<span className="font-mono text-xs">drive.file</span>).
</p>
<div className="mt-4">
<Button onClick={handleConnect} disabled={isConnecting} className="w-full sm:w-auto">
{isConnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Omdirigerar…
</>
) : (
<>
<Cloud className="mr-2 h-4 w-4" />
Koppla Google Drive
</>
)}
</Button>
</div>
</>
)}
</div>
</div>
</div>
)
}
@@ -343,14 +352,14 @@ function ScheduleSection({ schedule, onUpdated }: ScheduleSectionProps) {
)
return (
<div className="rounded-md border border-border/60 bg-background p-3 space-y-3">
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="min-w-0">
<Label htmlFor="auto-sync-toggle" className="text-sm font-medium">
Automatisk synkronisering
</Label>
<p className="text-xs text-muted-foreground">
Kör en daglig säkerhetsbackup till din Drive.
<p className="text-xs text-muted-foreground mt-0.5">
Daglig säkerhetsbackup till din Drive.
</p>
</div>
<Switch
@@ -364,7 +373,7 @@ function ScheduleSection({ schedule, onUpdated }: ScheduleSectionProps) {
{enabled && (
<div className="flex items-center gap-2">
<Label htmlFor="auto-sync-hour" className="text-xs text-muted-foreground">
Tid (din lokala tid)
Tid (lokal)
</Label>
<select
id="auto-sync-hour"
@@ -387,7 +396,7 @@ function ScheduleSection({ schedule, onUpdated }: ScheduleSectionProps) {
<p className="text-xs text-muted-foreground">
Senaste automatiska synk: {formatDate(schedule.last_auto_sync_at)}{' '}
{schedule.last_auto_sync_status === 'success' ? (
<span className="text-emerald-600">· lyckades</span>
<span className="text-success">· lyckades</span>
) : schedule.last_auto_sync_status === 'error' ? (
<span className="text-destructive">
· misslyckades
+1
View File
@@ -266,6 +266,7 @@ export const CreateCustomerSchema = z.object({
.regex(/^(\d{6}|\d{8})[-+]?\d{4}$/, 'Invalid personal number')
.optional()
.nullable(),
language: z.enum(['sv', 'en']).optional(),
default_payment_terms: z.number().int().positive().optional(),
notes: z.string().optional(),
})
@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest'
import {
generateInvoiceEmailHtml,
generateInvoiceEmailText,
generateInvoiceEmailSubject,
} from '../invoice-templates'
import { makeCustomer, makeInvoice, makeCompanySettings } from '@/tests/helpers'
const company = makeCompanySettings({
company_name: 'Acme AB',
bank_name: 'SEB',
clearing_number: '5000',
account_number: '1234567',
iban: 'SE45 5000 0000 0583 9825 7466',
bic: 'ESSESESS',
org_number: '556677-8899',
vat_number: 'SE556677889901',
f_skatt: true,
})
const invoice = makeInvoice({
invoice_number: '1042',
invoice_date: '2026-05-22',
due_date: '2026-06-21',
currency: 'SEK',
total: 12500,
})
describe('invoice email templates', () => {
describe('Swedish customer (default)', () => {
const customer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se', language: 'sv' })
const data = { invoice, customer, company }
it('uses Swedish chrome in HTML', () => {
const html = generateInvoiceEmailHtml(data)
expect(html).toContain('<html lang="sv">')
expect(html).toContain('Faktura från Acme AB')
expect(html).toContain('Att betala:')
expect(html).toContain('Betalningsinformation')
expect(html).toContain('Hej Erik,')
expect(html).toContain('Med vänliga hälsningar,')
expect(html).toContain('Innehar F-skattsedel')
})
it('renders the total with explicit SEK code, not "kr"', () => {
const html = generateInvoiceEmailHtml(data)
// sv-SE digit grouping: "12 500,00 SEK"
expect(html).toMatch(/12[\s\u00a0]500,00 SEK/)
expect(html).not.toContain('kr')
})
it('uses Swedish subject', () => {
expect(generateInvoiceEmailSubject(data)).toBe('Faktura 1042 från Acme AB')
})
it('uses Swedish plain text body', () => {
const text = generateInvoiceEmailText(data)
expect(text).toContain('Hej Erik,')
expect(text).toContain('Att betala:')
expect(text).toContain('Förfallodatum:')
expect(text).not.toContain('kr')
})
})
describe('English customer', () => {
const customer = makeCustomer({ name: 'Jane Doe', email: 'jane@example.com', language: 'en' })
const data = { invoice, customer, company }
it('uses English chrome in HTML', () => {
const html = generateInvoiceEmailHtml(data)
expect(html).toContain('<html lang="en">')
expect(html).toContain('Invoice from Acme AB')
expect(html).toContain('Total due:')
expect(html).toContain('Payment information')
expect(html).toContain('Hi Jane,')
expect(html).toContain('Kind regards,')
// F-skatt is statutory and stays Swedish in both locales.
expect(html).toContain('Innehar F-skattsedel')
})
it('renders the total with explicit SEK code in English digit grouping', () => {
const html = generateInvoiceEmailHtml(data)
// en-US digit grouping: "12,500.00 SEK"
expect(html).toContain('12,500.00 SEK')
expect(html).not.toContain('kr')
})
it('uses English subject', () => {
expect(generateInvoiceEmailSubject(data)).toBe('Invoice 1042 from Acme AB')
})
it('uses English plain text body', () => {
const text = generateInvoiceEmailText(data)
expect(text).toContain('Hi Jane,')
expect(text).toContain('Total due:')
expect(text).toContain('Due date:')
expect(text).toContain('Thank you for your business')
expect(text).not.toContain('kr')
})
})
describe('credit note', () => {
const creditInvoice = makeInvoice({
invoice_number: '1043',
invoice_date: '2026-05-22',
due_date: '2026-05-22',
currency: 'SEK',
total: -5000,
credited_invoice_id: 'inv-orig',
})
it('translates the credit-note body in English', () => {
const customer = makeCustomer({ language: 'en' })
const html = generateInvoiceEmailHtml({ invoice: creditInvoice, customer, company })
expect(html).toContain('Credit note')
expect(html).toContain('Attached you will find a credit note')
})
it('keeps the credit-note body in Swedish for sv customers', () => {
const customer = makeCustomer({ language: 'sv' })
const html = generateInvoiceEmailHtml({ invoice: creditInvoice, customer, company })
expect(html).toContain('Kreditfaktura')
expect(html).toContain('Bifogat hittar du en kreditfaktura')
})
})
describe('non-SEK currency', () => {
const eurInvoice = makeInvoice({
invoice_number: '1044',
currency: 'EUR',
total: 1000,
})
it('writes EUR code with the chosen locale grouping', () => {
const enCustomer = makeCustomer({ language: 'en' })
const enHtml = generateInvoiceEmailHtml({ invoice: eurInvoice, customer: enCustomer, company })
expect(enHtml).toContain('1,000.00 EUR')
const svCustomer = makeCustomer({ language: 'sv' })
const svHtml = generateInvoiceEmailHtml({ invoice: eurInvoice, customer: svCustomer, company })
expect(svHtml).toMatch(/1[\s\u00a0]000,00 EUR/)
})
})
})
+145 -59
View File
@@ -1,12 +1,97 @@
import type { Invoice, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
import { formatCurrency, formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils'
import { formatDate, getCompanyDisplayName, getCompanyPrimaryName } from '@/lib/utils'
function getDocumentLabel(invoice: Invoice): string {
if (invoice.credited_invoice_id) return 'Kreditfaktura'
type EmailLang = 'sv' | 'en'
// Customer-facing labels. Statutory chapter references stay intact in both
// locales. lib/utils.ts formatCurrency() keeps the Swedish "kr" symbol for
// in-app financial UI per the accounting standard; here we want the ISO code
// so a non-Swedish recipient understands the unit.
const LABELS = {
sv: {
docInvoice: 'Faktura',
docCreditNote: 'Kreditfaktura',
docProforma: 'Proformafaktura',
docDeliveryNote: 'Följesedel',
htmlLang: 'sv',
documentFrom: (doc: string, sender: string) => `${doc} från ${sender}`,
documentNumber: (doc: string) => `${doc}nummer:`,
documentDate: (doc: string) => `${doc}datum:`,
dueDate: 'Förfallodatum:',
greeting: (firstName: string) => `Hej${firstName ? ` ${firstName}` : ''},`,
bodyCreditNote: 'Bifogat hittar du en kreditfaktura som korrigerar en tidigare faktura.',
bodyInvoice: 'Tack för ditt förtroende! Bifogat hittar du din faktura.',
toPay: 'Att betala:',
paymentHeading: 'Betalningsinformation',
bank: 'Bank:',
account: 'Kontonummer:',
iban: 'IBAN:',
bic: 'BIC/SWIFT:',
message: 'Meddelande:',
questions: 'Har du frågor om fakturan? Svara direkt på detta mejl så hjälper vi dig.',
sincerely: 'Med vänliga hälsningar,',
orgNo: 'Org.nr:',
vat: 'VAT:',
fSkatt: 'Innehar F-skattsedel',
documentSummary: (doc: string) => `${doc.toLowerCase()}sammanfattning:`,
subjectFrom: (doc: string, num: string, sender: string) => `${doc} ${num} från ${sender}`,
},
en: {
docInvoice: 'Invoice',
docCreditNote: 'Credit note',
docProforma: 'Proforma invoice',
docDeliveryNote: 'Delivery note',
htmlLang: 'en',
documentFrom: (doc: string, sender: string) => `${doc} from ${sender}`,
documentNumber: (doc: string) => `${doc} number:`,
documentDate: (doc: string) => `${doc} date:`,
dueDate: 'Due date:',
greeting: (firstName: string) => `Hi${firstName ? ` ${firstName}` : ''},`,
bodyCreditNote: 'Attached you will find a credit note that corrects an earlier invoice.',
bodyInvoice: 'Thank you for your business. Attached you will find your invoice.',
toPay: 'Total due:',
paymentHeading: 'Payment information',
bank: 'Bank:',
account: 'Account number:',
iban: 'IBAN:',
bic: 'BIC/SWIFT:',
message: 'Reference:',
questions: 'Questions about the invoice? Reply directly to this email and we will help you.',
sincerely: 'Kind regards,',
orgNo: 'Reg. no.:',
vat: 'VAT:',
// Statutory Swedish phrase — kept verbatim in both locales. F-skatt is a
// Swedish tax-authority designation; translating it has no legal standing.
fSkatt: 'Innehar F-skattsedel',
documentSummary: (doc: string) => `${doc} summary:`,
subjectFrom: (doc: string, num: string, sender: string) => `${doc} ${num} from ${sender}`,
},
} as const
function resolveLang(customer: Customer): EmailLang {
return customer.language === 'en' ? 'en' : 'sv'
}
function getDocumentLabel(invoice: Invoice, lang: EmailLang): string {
const L = LABELS[lang]
if (invoice.credited_invoice_id) return L.docCreditNote
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
if (docType === 'proforma') return 'Proformafaktura'
if (docType === 'delivery_note') return 'Följesedel'
return 'Faktura'
if (docType === 'proforma') return L.docProforma
if (docType === 'delivery_note') return L.docDeliveryNote
return L.docInvoice
}
// Currency for the customer-facing total — explicit ISO code so a non-Swedish
// recipient reads "1 234,56 SEK" instead of the Swedish symbol "kr". Use the
// English locale for digit grouping when the email is in English so the comma
// thousands separator matches reader expectation.
function formatCurrencyForCustomer(amount: number, currency: string, lang: EmailLang): string {
const formatted = new Intl.NumberFormat(lang === 'en' ? 'en-US' : 'sv-SE', {
style: 'decimal',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
return `${formatted} ${currency}`
}
export interface InvoiceEmailData {
@@ -21,16 +106,19 @@ export interface InvoiceEmailData {
export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const documentType = getDocumentLabel(invoice)
const lang = resolveLang(customer)
const L = LABELS[lang]
const documentType = getDocumentLabel(invoice, lang)
const isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma'
const hidePayment = isCreditNote || isDeliveryNote || isProforma
const firstName = customer.name ? customer.name.split(' ')[0] : ''
return `
<!DOCTYPE html>
<html lang="sv">
<html lang="${L.htmlLang}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -41,23 +129,20 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
<!-- Header -->
<div style="margin-bottom: 30px;">
<h1 style="margin: 0 0 10px 0; font-size: 24px; font-weight: 600; color: #111;">
${documentType} från ${getCompanyPrimaryName(company)}
${L.documentFrom(documentType, getCompanyPrimaryName(company))}
</h1>
<p style="margin: 0; color: #666; font-size: 14px;">
${documentType}nummer: ${invoice.invoice_number}
${L.documentNumber(documentType)} ${invoice.invoice_number}
</p>
</div>
<!-- Greeting -->
<div style="margin-bottom: 30px;">
<p style="margin: 0 0 15px 0;">
Hej${customer.name ? ` ${customer.name.split(' ')[0]}` : ''},
${L.greeting(firstName)}
</p>
<p style="margin: 0;">
${isCreditNote
? `Bifogat hittar du en kreditfaktura som korrigerar en tidigare faktura.`
: `Tack för ditt förtroende! Bifogat hittar du din faktura.`
}
${isCreditNote ? L.bodyCreditNote : L.bodyInvoice}
</p>
</div>
@@ -65,15 +150,15 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
<div style="background: #f8f9fa; border-radius: 8px; padding: 25px; margin-bottom: 30px;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${documentType}nummer:</td>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.documentNumber(documentType)}</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500;">${invoice.invoice_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${documentType}datum:</td>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.documentDate(documentType)}</td>
<td style="padding: 8px 0; text-align: right;">${formatDate(invoice.invoice_date)}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666; font-size: 14px;">Förfallodatum:</td>
<td style="padding: 8px 0; color: #666; font-size: 14px;">${L.dueDate}</td>
<td style="padding: 8px 0; text-align: right; font-weight: 500; color: ${isCreditNote ? '#333' : '#e11d48'};">
${formatDate(invoice.due_date)}
</td>
@@ -82,9 +167,9 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
<td colspan="2" style="padding: 15px 0 8px 0; border-top: 1px solid #e5e7eb;"></td>
</tr>
<tr>
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">Att betala:</td>
<td style="padding: 8px 0; font-size: 18px; font-weight: 600;">${L.toPay}</td>
<td style="padding: 8px 0; text-align: right; font-size: 18px; font-weight: 600; color: ${isCreditNote ? '#059669' : '#111'};">
${formatCurrency(invoice.total, invoice.currency)}
${formatCurrencyForCustomer(invoice.total, invoice.currency, lang)}
</td>
</tr>
</table>
@@ -94,35 +179,35 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
${!hidePayment ? `
<div style="margin-bottom: 30px;">
<h2 style="margin: 0 0 15px 0; font-size: 16px; font-weight: 600; color: #111;">
Betalningsinformation
${L.paymentHeading}
</h2>
<table style="width: 100%; border-collapse: collapse;">
${company.bank_name ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px; width: 140px;">Bank:</td>
<td style="padding: 6px 0; color: #666; font-size: 14px; width: 140px;">${L.bank}</td>
<td style="padding: 6px 0;">${company.bank_name}</td>
</tr>
` : ''}
${company.clearing_number && company.account_number ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">Kontonummer:</td>
<td style="padding: 6px 0; color: #666; font-size: 14px;">${L.account}</td>
<td style="padding: 6px 0;">${company.clearing_number}-${company.account_number}</td>
</tr>
` : ''}
${company.iban ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">IBAN:</td>
<td style="padding: 6px 0; color: #666; font-size: 14px;">${L.iban}</td>
<td style="padding: 6px 0;">${company.iban}</td>
</tr>
` : ''}
${company.bic ? `
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">BIC/SWIFT:</td>
<td style="padding: 6px 0; color: #666; font-size: 14px;">${L.bic}</td>
<td style="padding: 6px 0;">${company.bic}</td>
</tr>
` : ''}
<tr>
<td style="padding: 6px 0; color: #666; font-size: 14px;">Meddelande:</td>
<td style="padding: 6px 0; color: #666; font-size: 14px;">${L.message}</td>
<td style="padding: 6px 0; font-weight: 500;">${invoice.invoice_number}</td>
</tr>
</table>
@@ -132,17 +217,17 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
<!-- Footer -->
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
<p style="margin: 0 0 10px 0; color: #666; font-size: 14px;">
Har du frågor om fakturan? Svara direkt på detta mejl så hjälper vi dig.
${L.questions}
</p>
<p style="margin: 0; color: #666; font-size: 14px;">
Med vänliga hälsningar,<br>
${L.sincerely}<br>
<strong>${getCompanyPrimaryName(company)}</strong>
</p>
${company.org_number ? `
<p style="margin: 10px 0 0 0; color: #999; font-size: 12px;">
Org.nr: ${company.org_number}
${company.vat_number ? ` | VAT: ${company.vat_number}` : ''}
${company.f_skatt ? ' | Innehar F-skattsedel' : ''}
${L.orgNo} ${company.org_number}
${company.vat_number ? ` | ${L.vat} ${company.vat_number}` : ''}
${company.f_skatt ? ` | ${L.fSkatt}` : ''}
</p>
` : ''}
</div>
@@ -158,51 +243,50 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
export function generateInvoiceEmailText(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const documentType = getDocumentLabel(invoice)
const lang = resolveLang(customer)
const L = LABELS[lang]
const documentType = getDocumentLabel(invoice, lang)
const isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma'
const hidePayment = isCreditNote || isDeliveryNote || isProforma
const firstName = customer.name ? customer.name.split(' ')[0] : ''
let text = `${documentType} från ${getCompanyPrimaryName(company)}\n`
text += `${documentType}nummer: ${invoice.invoice_number}\n\n`
let text = `${L.documentFrom(documentType, getCompanyPrimaryName(company))}\n`
text += `${L.documentNumber(documentType)} ${invoice.invoice_number}\n\n`
text += `Hej${customer.name ? ` ${customer.name.split(' ')[0]}` : ''},\n\n`
text += `${L.greeting(firstName)}\n\n`
if (isCreditNote) {
text += `Bifogat hittar du en kreditfaktura som korrigerar en tidigare faktura.\n\n`
} else {
text += `Tack för ditt förtroende! Bifogat hittar du din faktura.\n\n`
}
text += `${isCreditNote ? L.bodyCreditNote : L.bodyInvoice}\n\n`
text += `${documentType}sammanfattning:\n`
text += `${L.documentSummary(documentType)}\n`
text += `---\n`
text += `${documentType}nummer: ${invoice.invoice_number}\n`
text += `${documentType}datum: ${formatDate(invoice.invoice_date)}\n`
text += `Förfallodatum: ${formatDate(invoice.due_date)}\n`
text += `Att betala: ${formatCurrency(invoice.total, invoice.currency)}\n`
text += `${L.documentNumber(documentType)} ${invoice.invoice_number}\n`
text += `${L.documentDate(documentType)} ${formatDate(invoice.invoice_date)}\n`
text += `${L.dueDate} ${formatDate(invoice.due_date)}\n`
text += `${L.toPay} ${formatCurrencyForCustomer(invoice.total, invoice.currency, lang)}\n`
text += `---\n\n`
if (!hidePayment) {
text += `Betalningsinformation:\n`
if (company.bank_name) text += `Bank: ${company.bank_name}\n`
text += `${L.paymentHeading}:\n`
if (company.bank_name) text += `${L.bank} ${company.bank_name}\n`
if (company.clearing_number && company.account_number) {
text += `Kontonummer: ${company.clearing_number}-${company.account_number}\n`
text += `${L.account} ${company.clearing_number}-${company.account_number}\n`
}
if (company.iban) text += `IBAN: ${company.iban}\n`
if (company.bic) text += `BIC/SWIFT: ${company.bic}\n`
text += `Meddelande: ${invoice.invoice_number}\n\n`
if (company.iban) text += `${L.iban} ${company.iban}\n`
if (company.bic) text += `${L.bic} ${company.bic}\n`
text += `${L.message} ${invoice.invoice_number}\n\n`
}
text += `Har du frågor om fakturan? Svara direkt på detta mejl så hjälper vi dig.\n\n`
text += `Med vänliga hälsningar,\n`
text += `${L.questions}\n\n`
text += `${L.sincerely}\n`
text += `${getCompanyDisplayName(company)}\n`
if (company.org_number) {
text += `\nOrg.nr: ${company.org_number}`
if (company.vat_number) text += ` | VAT: ${company.vat_number}`
if (company.f_skatt) text += ` | Innehar F-skattsedel`
text += `\n${L.orgNo} ${company.org_number}`
if (company.vat_number) text += ` | ${L.vat} ${company.vat_number}`
if (company.f_skatt) text += ` | ${L.fSkatt}`
text += `\n`
}
@@ -213,8 +297,10 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string {
* Generate email subject for an invoice
*/
export function generateInvoiceEmailSubject(data: InvoiceEmailData): string {
const { invoice, company } = data
const documentType = getDocumentLabel(invoice)
const { invoice, customer, company } = data
const lang = resolveLang(customer)
const L = LABELS[lang]
const documentType = getDocumentLabel(invoice, lang)
return `${documentType} ${invoice.invoice_number} från ${getCompanyPrimaryName(company)}`
return L.subjectFrom(documentType, invoice.invoice_number ?? '', getCompanyPrimaryName(company))
}
+205 -71
View File
@@ -10,6 +10,131 @@ import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentTy
import { generateOcrReference } from '@/lib/bankgiro/luhn'
import { getDisplayTotal } from '@/lib/invoices/rounding'
type PdfLang = 'sv' | 'en'
// Customer-facing labels. Statutory chapter references (ML 17 kap 24§, ML 3 kap.)
// stay intact in both locales — they identify the law, not the language.
const LABELS = {
sv: {
// Document titles
titleInvoice: 'FAKTURA',
titleCreditNote: 'KREDITFAKTURA',
titleProforma: 'PROFORMAFAKTURA',
titleDeliveryNote: 'FÖLJESEDEL',
titlePreview: 'FÖRHANDSGRANSKNING',
// Status banners
cancelledTitle: 'MAKULERAD – inte en giltig faktura',
cancelledWithNumber: (n: string) => `Faktura ${n} har makulerats. Numret behålls i serien för att hålla nummerföljden obruten enligt ML 17 kap 24§, men dokumentet är inte ett giltigt fakturaunderlag.`,
cancelledNoNumber: 'Detta utkast har makulerats och är inte ett giltigt fakturaunderlag.',
draftTitle: 'UTKAST – inte en giltig faktura',
draftWithNumber: 'Detta är ett utkast. Markera fakturan som skickad eller skicka via systemet för att göra den giltig som fakturaunderlag.',
draftNoNumber: 'Denna faktura saknar löpnummer och kan inte användas som fakturaunderlag enligt ML 17 kap 24§. Skicka fakturan via systemet för att tilldela ett nummer.',
// Credit note reference
creditNoteRef: (n: string) => `Denna kreditfaktura avser och krediterar faktura nr ${n}`,
// Sections
invoiceInfoHeading: 'Fakturainformation',
billedToHeading: 'Faktureras till',
itemsHeading: 'Specifikation',
// Invoice details
invoiceDate: 'Fakturadatum:',
dueDate: 'Förfallodatum:',
deliveryDate: 'Leveransdatum:',
yourReference: 'Er referens:',
ourReference: 'Vår referens:',
// Customer box
orgNo: 'Org.nr:',
vat: 'VAT:',
// Table columns
colDescription: 'Beskrivning',
colQty: 'Antal',
colUnit: 'Enhet',
colUnitPrice: 'à-pris',
colVat: 'Moms',
colTotal: 'Summa',
// Totals
subtotal: 'Delsumma:',
net: (rate: number) => `Netto ${rate}%:`,
vatRow: (rate: number) => `Moms ${rate}%:`,
rounding: 'Öresavrundning:',
toCredit: 'Att kreditera:',
toPay: 'Att betala:',
vatInSek: (rate: number | string) => `Moms i SEK (kurs ${rate}):`,
totalInSek: 'Totalt i SEK:',
// Proforma / exempt
proformaNotice: 'Detta är en proformafaktura och utgör ingen betalningsanmodan.',
exemptNotice: 'Undantag från skatteplikt, ML 3 kap.',
// Payment
paymentHeading: 'Betalningsinformation',
bank: 'Bank:',
account: 'Kontonummer:',
bankgiro: 'Bankgiro:',
plusgiro: 'Plusgiro:',
swish: 'Swish:',
iban: 'IBAN:',
bic: 'BIC/SWIFT:',
ocr: 'OCR/Referens:',
// Footer
orgNoLong: 'Org.nr:',
vatRegNo: 'Momsreg.nr:',
fSkatt: 'Godkänd för F-skatt',
},
en: {
titleInvoice: 'INVOICE',
titleCreditNote: 'CREDIT NOTE',
titleProforma: 'PROFORMA INVOICE',
titleDeliveryNote: 'DELIVERY NOTE',
titlePreview: 'PREVIEW',
cancelledTitle: 'VOID — not a valid invoice',
cancelledWithNumber: (n: string) => `Invoice ${n} has been voided. The number is retained in the sequence to keep the numbering unbroken (ML 17 kap 24§ — Swedish VAT Act), but this document is not a valid invoice.`,
cancelledNoNumber: 'This draft has been voided and is not a valid invoice.',
draftTitle: 'DRAFT — not a valid invoice',
draftWithNumber: 'This is a draft. Mark the invoice as sent, or send it via the system, to make it a valid invoice.',
draftNoNumber: 'This invoice has no serial number and cannot be used as a valid invoice under ML 17 kap 24§ (Swedish VAT Act). Send the invoice via the system to assign a number.',
creditNoteRef: (n: string) => `This credit note credits invoice no. ${n}`,
invoiceInfoHeading: 'Invoice information',
billedToHeading: 'Billed to',
itemsHeading: 'Items',
invoiceDate: 'Invoice date:',
dueDate: 'Due date:',
deliveryDate: 'Delivery date:',
yourReference: 'Your reference:',
ourReference: 'Our reference:',
orgNo: 'Reg. no.:',
vat: 'VAT:',
colDescription: 'Description',
colQty: 'Qty',
colUnit: 'Unit',
colUnitPrice: 'Unit price',
colVat: 'VAT',
colTotal: 'Amount',
subtotal: 'Subtotal:',
net: (rate: number) => `Net ${rate}%:`,
vatRow: (rate: number) => `VAT ${rate}%:`,
rounding: 'Rounding:',
toCredit: 'To credit:',
toPay: 'Total due:',
vatInSek: (rate: number | string) => `VAT in SEK (rate ${rate}):`,
totalInSek: 'Total in SEK:',
proformaNotice: 'This is a proforma invoice and is not a request for payment.',
exemptNotice: 'Exempt from VAT (ML 3 kap. — Swedish VAT Act).',
paymentHeading: 'Payment information',
bank: 'Bank:',
account: 'Account number:',
bankgiro: 'Bankgiro:',
plusgiro: 'Plusgiro:',
swish: 'Swish:',
iban: 'IBAN:',
bic: 'BIC/SWIFT:',
ocr: 'Reference:',
orgNoLong: 'Reg. no.:',
vatRegNo: 'VAT reg. no.:',
// Statutory Swedish phrase — kept verbatim in both locales. Peppol SE-R-005
// and Skatteverket's F-skatt notation expect "Godkänd för F-skatt"; an
// English translation has no legal standing.
fSkatt: 'Godkänd för F-skatt',
},
} as const
// Create styles
const styles = StyleSheet.create({
page: {
@@ -278,19 +403,24 @@ const styles = StyleSheet.create({
},
})
// Format currency
function formatCurrency(amount: number, currency: string = 'SEK'): string {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency,
// Format currency with explicit ISO code so non-Swedish recipients see "1 234,56 SEK"
// instead of the Swedish symbol "kr". Decimal style + appended code works for any
// currency (SEK/EUR/USD) and avoids Intl's locale-specific symbol quirks.
function formatCurrency(amount: number, currency: string = 'SEK', language: PdfLang = 'sv'): string {
const formatted = new Intl.NumberFormat(language === 'en' ? 'en-US' : 'sv-SE', {
style: 'decimal',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
return `${formatted} ${currency}`
}
// Format date
// Format date as ISO yyyy-MM-dd in both locales — universally unambiguous and
// matches the project's formatDate() convention (lib/utils.ts).
// Input is already a YYYY-MM-DD string from the DB, so slice avoids the
// new Date() + local-getter timezone hazard.
function formatDate(date: string): string {
return new Date(date).toLocaleDateString('sv-SE')
return date.slice(0, 10)
}
// Format org number
@@ -302,12 +432,13 @@ function formatOrgNumber(orgNumber: string): string {
return orgNumber
}
function getDocumentTitle(invoice: Invoice): string {
if (invoice.credited_invoice_id) return 'KREDITFAKTURA'
function getDocumentTitle(invoice: Invoice, lang: PdfLang): string {
const L = LABELS[lang]
if (invoice.credited_invoice_id) return L.titleCreditNote
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
if (docType === 'proforma') return 'PROFORMAFAKTURA'
if (docType === 'delivery_note') return 'FÖLJESEDEL'
return 'FAKTURA'
if (docType === 'proforma') return L.titleProforma
if (docType === 'delivery_note') return L.titleDeliveryNote
return L.titleInvoice
}
interface InvoicePDFProps {
@@ -317,9 +448,12 @@ interface InvoicePDFProps {
company: CompanySettings
originalInvoiceNumber?: string
isPreview?: boolean
language?: PdfLang
}
export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber, isPreview }: InvoicePDFProps) {
export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber, isPreview, language }: InvoicePDFProps) {
const lang: PdfLang = language ?? customer.language ?? 'sv'
const L = LABELS[lang]
const isCreditNote = !!invoice.credited_invoice_id
// Check if items have mixed VAT rates
@@ -354,20 +488,20 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
of a non-cancelled invoice that somehow lacks a number. */}
{invoice.status === 'cancelled' ? (
<View style={styles.cancelledBanner}>
<Text style={styles.cancelledBannerTitle}>MAKULERAD – inte en giltig faktura</Text>
<Text style={styles.cancelledBannerTitle}>{L.cancelledTitle}</Text>
<Text style={styles.cancelledBannerText}>
{invoice.invoice_number
? `Faktura ${invoice.invoice_number} har makulerats. Numret behålls i serien för att hålla nummerföljden obruten enligt ML 17 kap 24§, men dokumentet är inte ett giltigt fakturaunderlag.`
: 'Detta utkast har makulerats och är inte ett giltigt fakturaunderlag.'}
? L.cancelledWithNumber(invoice.invoice_number)
: L.cancelledNoNumber}
</Text>
</View>
) : isPreview ? null : (invoice.status === 'draft' || !invoice.invoice_number) && (
<View style={styles.draftBanner}>
<Text style={styles.draftBannerTitle}>UTKAST – inte en giltig faktura</Text>
<Text style={styles.draftBannerTitle}>{L.draftTitle}</Text>
<Text style={styles.draftBannerText}>
{invoice.invoice_number
? 'Detta är ett utkast. Markera fakturan som skickad eller skicka via systemet för att göra den giltig som fakturaunderlag.'
: 'Denna faktura saknar löpnummer och kan inte användas som fakturaunderlag enligt ML 17 kap 24§. Skicka fakturan via systemet för att tilldela ett nummer.'}
? L.draftWithNumber
: L.draftNoNumber}
</Text>
</View>
)}
@@ -385,9 +519,9 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</View>
<View style={{ textAlign: 'right' }}>
<Text style={[styles.title, isCreditNote ? styles.creditNoteTitle : {}]}>
{getDocumentTitle(invoice)}
{getDocumentTitle(invoice, lang)}
</Text>
<Text style={{ marginTop: 5, color: '#666' }}>{invoice.invoice_number ?? 'FÖRHANDSGRANSKNING'}</Text>
<Text style={{ marginTop: 5, color: '#666' }}>{invoice.invoice_number ?? L.titlePreview}</Text>
</View>
</View>
@@ -395,7 +529,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{isCreditNote && originalInvoiceNumber && (
<View style={styles.creditNoteBox}>
<Text style={styles.creditNoteText}>
Denna kreditfaktura avser och krediterar faktura nr {originalInvoiceNumber}
{L.creditNoteRef(originalInvoiceNumber)}
</Text>
</View>
)}
@@ -404,24 +538,24 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<View style={styles.twoColumn}>
{/* Invoice details */}
<View style={styles.column}>
<Text style={styles.sectionTitle}>Fakturainformation</Text>
<Text style={styles.sectionTitle}>{L.invoiceInfoHeading}</Text>
<View style={styles.row}>
<Text style={styles.label}>Fakturadatum:</Text>
<Text style={styles.label}>{L.invoiceDate}</Text>
<Text style={styles.value}>{formatDate(invoice.invoice_date)}</Text>
</View>
<View style={styles.row}>
<Text style={styles.label}>Förfallodatum:</Text>
<Text style={styles.label}>{L.dueDate}</Text>
<Text style={styles.value}>{formatDate(invoice.due_date)}</Text>
</View>
{invoice.delivery_date && invoice.delivery_date !== invoice.invoice_date && (
<View style={styles.row}>
<Text style={styles.label}>Leveransdatum:</Text>
<Text style={styles.label}>{L.deliveryDate}</Text>
<Text style={styles.value}>{formatDate(invoice.delivery_date)}</Text>
</View>
)}
{invoice.your_reference && (
<View style={{ marginBottom: 4 }}>
<Text style={styles.label}>Er referens:</Text>
<Text style={styles.label}>{L.yourReference}</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 2 }}>
{invoice.your_reference.split(',').map((ref, i) => (
<Text key={i} style={{ backgroundColor: '#f0f0f0', borderRadius: 3, paddingHorizontal: 6, paddingVertical: 2, fontSize: 9, fontWeight: 'bold' }}>
@@ -433,7 +567,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
)}
{invoice.our_reference && (
<View style={{ marginBottom: 4 }}>
<Text style={styles.label}>Vår referens:</Text>
<Text style={styles.label}>{L.ourReference}</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 2 }}>
{invoice.our_reference.split(',').map((ref, i) => (
<Text key={i} style={{ backgroundColor: '#f0f0f0', borderRadius: 3, paddingHorizontal: 6, paddingVertical: 2, fontSize: 9, fontWeight: 'bold' }}>
@@ -447,7 +581,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{/* Customer */}
<View style={styles.column}>
<Text style={styles.sectionTitle}>Faktureras till</Text>
<Text style={styles.sectionTitle}>{L.billedToHeading}</Text>
<View style={styles.customerBox}>
<Text style={styles.customerName}>{customer.name}</Text>
{customer.address_line1 && <Text>{customer.address_line1}</Text>}
@@ -459,30 +593,30 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<Text>{customer.country}</Text>
)}
{customer.org_number && (
<Text style={{ marginTop: 6 }}>Org.nr: {customer.org_number}</Text>
<Text style={{ marginTop: 6 }}>{L.orgNo} {customer.org_number}</Text>
)}
{customer.vat_number && <Text>VAT: {customer.vat_number}</Text>}
{customer.vat_number && <Text>{L.vat} {customer.vat_number}</Text>}
</View>
</View>
</View>
{/* Items table */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Specifikation</Text>
<Text style={styles.sectionTitle}>{L.itemsHeading}</Text>
<View style={styles.table}>
{/* Table header */}
<View style={styles.tableHeader}>
<Text style={[styles.colDescription, styles.tableHeaderText]}>Beskrivning</Text>
<Text style={[styles.colQty, styles.tableHeaderText]}>Antal</Text>
<Text style={[styles.colUnit, styles.tableHeaderText]}>Enhet</Text>
<Text style={[styles.colDescription, styles.tableHeaderText]}>{L.colDescription}</Text>
<Text style={[styles.colQty, styles.tableHeaderText]}>{L.colQty}</Text>
<Text style={[styles.colUnit, styles.tableHeaderText]}>{L.colUnit}</Text>
{!isDeliveryNote && (
<Text style={[styles.colPrice, styles.tableHeaderText]}>à-pris</Text>
<Text style={[styles.colPrice, styles.tableHeaderText]}>{L.colUnitPrice}</Text>
)}
{!isDeliveryNote && showVatColumn && (
<Text style={[styles.colVat, styles.tableHeaderText]}>Moms</Text>
<Text style={[styles.colVat, styles.tableHeaderText]}>{L.colVat}</Text>
)}
{!isDeliveryNote && (
<Text style={[styles.colTotal, styles.tableHeaderText]}>Summa</Text>
<Text style={[styles.colTotal, styles.tableHeaderText]}>{L.colTotal}</Text>
)}
</View>
@@ -493,13 +627,13 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<Text style={styles.colQty}>{item.quantity}</Text>
<Text style={styles.colUnit}>{item.unit}</Text>
{!isDeliveryNote && (
<Text style={styles.colPrice}>{formatCurrency(item.unit_price, invoice.currency)}</Text>
<Text style={styles.colPrice}>{formatCurrency(item.unit_price, invoice.currency, lang)}</Text>
)}
{!isDeliveryNote && showVatColumn && (
<Text style={styles.colVat}>{item.vat_rate ?? 0}%</Text>
)}
{!isDeliveryNote && (
<Text style={styles.colTotal}>{formatCurrency(item.line_total, invoice.currency)}</Text>
<Text style={styles.colTotal}>{formatCurrency(item.line_total, invoice.currency, lang)}</Text>
)}
</View>
))}
@@ -510,8 +644,8 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{!isDeliveryNote && (
<View style={styles.totalsSection}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Delsumma:</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.subtotal, invoice.currency)}</Text>
<Text style={styles.totalLabel}>{L.subtotal}</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.subtotal, invoice.currency, lang)}</Text>
</View>
{vatByRate.size > 1 ? (
Array.from(vatByRate.entries())
@@ -519,21 +653,21 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
.map(([rate, group]) => (
<View key={rate}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Netto {rate}%:</Text>
<Text style={styles.totalValue}>{formatCurrency(group.base, invoice.currency)}</Text>
<Text style={styles.totalLabel}>{L.net(rate)}</Text>
<Text style={styles.totalValue}>{formatCurrency(group.base, invoice.currency, lang)}</Text>
</View>
{group.vat > 0 && (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Moms {rate}%:</Text>
<Text style={styles.totalValue}>{formatCurrency(group.vat, invoice.currency)}</Text>
<Text style={styles.totalLabel}>{L.vatRow(rate)}</Text>
<Text style={styles.totalValue}>{formatCurrency(group.vat, invoice.currency, lang)}</Text>
</View>
)}
</View>
))
) : (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Moms ({invoice.vat_rate ?? (vatByRate.size === 1 ? vatByRate.keys().next().value : 0)}%):</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency)}</Text>
<Text style={styles.totalLabel}>{L.vatRow(invoice.vat_rate ?? (vatByRate.size === 1 ? (vatByRate.keys().next().value ?? 0) : 0))}</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency, lang)}</Text>
</View>
)}
{(() => {
@@ -542,13 +676,13 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<>
{rounding.applies && (
<View style={styles.totalRow}>
<Text style={[styles.totalLabel, { fontSize: 8 }]}>Öresavrundning:</Text>
<Text style={[styles.totalValue, { fontSize: 8 }]}>{formatCurrency(rounding.roundingDelta, 'SEK')}</Text>
<Text style={[styles.totalLabel, { fontSize: 8 }]}>{L.rounding}</Text>
<Text style={[styles.totalValue, { fontSize: 8 }]}>{formatCurrency(rounding.roundingDelta, 'SEK', lang)}</Text>
</View>
)}
<View style={styles.grandTotal}>
<Text style={styles.grandTotalLabel}>{isCreditNote ? 'Att kreditera:' : 'Att betala:'}</Text>
<Text style={styles.grandTotalValue}>{formatCurrency(rounding.displayed, invoice.currency)}</Text>
<Text style={styles.grandTotalLabel}>{isCreditNote ? L.toCredit : L.toPay}</Text>
<Text style={styles.grandTotalValue}>{formatCurrency(rounding.displayed, invoice.currency, lang)}</Text>
</View>
</>
)
@@ -557,13 +691,13 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<View style={{ marginTop: 8 }}>
{invoice.vat_amount_sek != null && invoice.vat_amount_sek !== 0 && (
<View style={styles.totalRow}>
<Text style={[styles.totalLabel, { fontSize: 9 }]}>Moms i SEK (kurs {invoice.exchange_rate}):</Text>
<Text style={[styles.totalValue, { fontSize: 9 }]}>{formatCurrency(invoice.vat_amount_sek, 'SEK')}</Text>
<Text style={[styles.totalLabel, { fontSize: 9 }]}>{L.vatInSek(invoice.exchange_rate ?? '')}</Text>
<Text style={[styles.totalValue, { fontSize: 9 }]}>{formatCurrency(invoice.vat_amount_sek, 'SEK', lang)}</Text>
</View>
)}
<View style={styles.totalRow}>
<Text style={[styles.totalLabel, { fontSize: 9 }]}>Totalt i SEK:</Text>
<Text style={[styles.totalValue, { fontSize: 9 }]}>{formatCurrency(invoice.total_sek, 'SEK')}</Text>
<Text style={[styles.totalLabel, { fontSize: 9 }]}>{L.totalInSek}</Text>
<Text style={[styles.totalValue, { fontSize: 9 }]}>{formatCurrency(invoice.total_sek, 'SEK', lang)}</Text>
</View>
</View>
)}
@@ -574,7 +708,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{isProforma && (
<View style={[styles.reverseChargeBox, { backgroundColor: '#e8f4fd', borderColor: '#90cdf4' }]}>
<Text style={[styles.reverseChargeText, { color: '#2b6cb0' }]}>
Detta är en proformafaktura och utgör ingen betalningsanmodan.
{L.proformaNotice}
</Text>
</View>
)}
@@ -582,16 +716,16 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{/* Payment information - not shown for credit notes, proformas, or delivery notes */}
{!isCreditNote && !isProforma && !isDeliveryNote && (
<View style={styles.paymentSection}>
<Text style={styles.paymentTitle}>Betalningsinformation</Text>
<Text style={styles.paymentTitle}>{L.paymentHeading}</Text>
{company.bank_name && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>Bank:</Text>
<Text style={styles.paymentLabel}>{L.bank}</Text>
<Text style={styles.paymentValue}>{company.bank_name}</Text>
</View>
)}
{(company.clearing_number || company.account_number) && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>Kontonummer:</Text>
<Text style={styles.paymentLabel}>{L.account}</Text>
<Text style={styles.paymentValue}>
{company.clearing_number}-{company.account_number}
</Text>
@@ -599,41 +733,41 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
)}
{company.bankgiro && (company.invoice_show_bankgiro ?? true) && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>Bankgiro:</Text>
<Text style={styles.paymentLabel}>{L.bankgiro}</Text>
<Text style={styles.paymentValue}>{company.bankgiro}</Text>
</View>
)}
{company.plusgiro && (company.invoice_show_plusgiro ?? true) && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>Plusgiro:</Text>
<Text style={styles.paymentLabel}>{L.plusgiro}</Text>
<Text style={styles.paymentValue}>{company.plusgiro}</Text>
</View>
)}
{company.swish && (company.invoice_show_swish ?? true) && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>Swish:</Text>
<Text style={styles.paymentLabel}>{L.swish}</Text>
<Text style={styles.paymentValue}>{company.swish}</Text>
</View>
)}
{company.iban && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>IBAN:</Text>
<Text style={styles.paymentLabel}>{L.iban}</Text>
<Text style={styles.paymentValue}>{company.iban}</Text>
</View>
)}
{company.bic && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>BIC/SWIFT:</Text>
<Text style={styles.paymentLabel}>{L.bic}</Text>
<Text style={styles.paymentValue}>{company.bic}</Text>
</View>
)}
<View style={[styles.paymentRow, { marginTop: 8 }]}>
<Text style={styles.paymentLabel}>Förfallodatum:</Text>
<Text style={styles.paymentLabel}>{L.dueDate}</Text>
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{formatDate(invoice.due_date)}</Text>
</View>
{(company.invoice_show_ocr ?? true) && (
<View style={styles.paymentRow}>
<Text style={styles.paymentLabel}>OCR/Referens:</Text>
<Text style={styles.paymentLabel}>{L.ocr}</Text>
<Text style={[styles.paymentValue, { fontWeight: 'bold' }]}>{invoice.invoice_number ? generateOcrReference(invoice.invoice_number) : '—'}</Text>
</View>
)}
@@ -648,7 +782,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
)}
{invoice.vat_treatment === 'exempt' && !invoice.reverse_charge_text && (
<View style={styles.reverseChargeBox}>
<Text style={styles.reverseChargeText}>Undantag från skatteplikt, ML 3 kap.</Text>
<Text style={styles.reverseChargeText}>{L.exemptNotice}</Text>
</View>
)}
@@ -681,9 +815,9 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
: null,
company.address_line1,
(company.postal_code || company.city) ? `${company.postal_code ?? ''} ${company.city ?? ''}`.trim() : null,
company.org_number ? `Org.nr: ${formatOrgNumber(company.org_number)}` : null,
company.vat_number ? `Momsreg.nr: ${company.vat_number}` : null,
company.f_skatt ? 'Godkänd för F-skatt' : null,
company.org_number ? `${L.orgNoLong} ${formatOrgNumber(company.org_number)}` : null,
company.vat_number ? `${L.vatRegNo} ${company.vat_number}` : null,
company.f_skatt ? L.fSkatt : null,
].filter(Boolean).join(' · ')}
</Text>
</View>
+7
View File
@@ -522,6 +522,10 @@
"vat_failed_default": "The VAT number could not be verified",
"vat_error_title": "Could not verify VAT number",
"payment_terms_label": "Payment terms (days)",
"language_label": "Invoice language",
"language_sv": "Swedish",
"language_en": "English",
"language_hint": "Invoices and emails to this customer are sent in the chosen language. Does not affect how the invoice is booked.",
"notes_label": "Notes",
"notes_placeholder": "Internal notes about the customer...",
"submit_save": "Save customer",
@@ -945,6 +949,7 @@
},
"settings_invoicing_preview": {
"title": "Preview",
"preview_button": "Preview invoice",
"loading": "Generating preview...",
"error": "Could not load preview",
"no_customers": "Add a customer to see a preview of your invoice.",
@@ -3146,6 +3151,8 @@
"import": {
"title": "Import",
"subtitle": "Import bank transactions or bookkeeping data into your company",
"export_title": "Export",
"export_subtitle": "Download your bookkeeping as a SIE file or back it up to Google Drive",
"tab_import": "Import",
"tab_export": "Export",
"sandbox_disabled": "Import is not available in the sandbox. Create an account to import data.",
+7
View File
@@ -522,6 +522,10 @@
"vat_failed_default": "VAT-numret kunde inte verifieras",
"vat_error_title": "Kunde inte verifiera VAT-nummer",
"payment_terms_label": "Betalningsvillkor (dagar)",
"language_label": "Fakturaspråk",
"language_sv": "Svenska",
"language_en": "Engelska",
"language_hint": "Fakturor och e-post till denna kund skickas på det valda språket. Påverkar inte hur fakturan bokförs.",
"notes_label": "Anteckningar",
"notes_placeholder": "Interna anteckningar om kunden...",
"submit_save": "Spara kund",
@@ -945,6 +949,7 @@
},
"settings_invoicing_preview": {
"title": "Förhandsvisning",
"preview_button": "Förhandsvisa faktura",
"loading": "Genererar förhandsvisning...",
"error": "Kunde inte ladda förhandsvisning",
"no_customers": "Lägg till en kund för att se en förhandsvisning av din faktura.",
@@ -3146,6 +3151,8 @@
"import": {
"title": "Importera",
"subtitle": "Importera banktransaktioner eller bokföringsdata till ditt företag",
"export_title": "Exportera",
"export_subtitle": "Ladda ner bokföringen som SIE-fil eller säkerhetskopia till Google Drive",
"tab_import": "Importera",
"tab_export": "Exportera",
"sandbox_disabled": "Import är inte tillgängligt i sandlådemiljön. Skapa ett konto för att importera data.",
+17 -1
View File
@@ -17,7 +17,7 @@ const cspDirectives = [
"img-src 'self' data: blob: https:",
"font-src 'self'",
"worker-src 'self' blob:",
`frame-src 'self' ${supabaseUrl}${activepiecesUrl ? ` ${activepiecesUrl}` : ""}`,
`frame-src 'self' blob: ${supabaseUrl}${activepiecesUrl ? ` ${activepiecesUrl}` : ""}`,
"frame-ancestors 'none'",
].join("; ");
@@ -82,6 +82,22 @@ const nextConfig: NextConfig = {
},
],
},
// Document inline-preview proxy must be embeddable in same-origin
// iframes (used by the verifikat document preview Sheet).
// Overrides the strict catch-all above for this single endpoint.
{
source: "/api/documents/:id/inline",
headers: [
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
{
key: "Content-Security-Policy",
value: "frame-ancestors 'self'",
},
],
},
];
},
};
@@ -0,0 +1,16 @@
-- Add language preference per customer.
--
-- Drives the locale of the customer-facing invoice PDF and email when this
-- customer is invoiced. Defaults to Swedish, matching the existing behavior.
-- Adding more locales is a follow-up migration so we never end up with an
-- orphan value the templates don't have translations for.
--
-- Per-customer (not per-invoice) because the user shouldn't have to pick a
-- language every time they bill the same recipient — set it once on the
-- customer record and every future invoice + email honors it.
ALTER TABLE public.customers
ADD COLUMN language TEXT NOT NULL DEFAULT 'sv'
CHECK (language IN ('sv', 'en'));
NOTIFY pgrst, 'reload schema';
+1
View File
@@ -406,6 +406,7 @@ export function makeCustomer(overrides: Partial<Customer> = {}): Customer {
vat_number_validated: true,
vat_number_validated_at: '2024-01-01T00:00:00Z',
personal_number: null,
language: 'sv',
default_payment_terms: 30,
notes: null,
created_at: '2024-01-01T00:00:00Z',
+4
View File
@@ -479,6 +479,9 @@ export interface Customer {
vat_number_validated_at: string | null
personal_number: string | null
// Language for customer-facing invoice PDF and email
language: 'sv' | 'en'
// Payment
default_payment_terms: number // Days
@@ -830,6 +833,7 @@ export interface CreateCustomerInput {
org_number?: string
vat_number?: string
personal_number?: string
language?: 'sv' | 'en'
default_payment_terms?: number
notes?: string
}