diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 20380e7c..7c6930c2 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -12,7 +12,7 @@ import { Separator } from '@/components/ui/separator' import { useToast } from '@/components/ui/use-toast' import { formatCurrency, formatDate, cn } from '@/lib/utils' import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules' -import { invoiceNumberDisplay, invoiceDisplayNumber } from '@/lib/invoices/display' +import { invoiceDisplayNumber } from '@/lib/invoices/display' import { getDisplayTotal } from '@/lib/invoices/rounding' import { Loader2, @@ -101,6 +101,9 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st const [isDownloading, setIsDownloading] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isDeleting, setIsDeleting] = useState(false) + const [showFinalizeDialog, setShowFinalizeDialog] = useState(false) + const [isFinalizing, setIsFinalizing] = useState(false) + const [nextNumberPreview, setNextNumberPreview] = useState(null) const [oreRounding, setOreRounding] = useState(true) const [vatRegistered, setVatRegistered] = useState(true) @@ -373,6 +376,68 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st setIsDownloading(false) } + // Open the finalize dialog and peek the next F-number so the user can see + // which number they'll get before committing. Read-only (peek_next_invoice_number); + // the real number is allocated atomically on confirm and may differ by one if + // another invoice is created in between. + async function openFinalizeDialog() { + setNextNumberPreview(null) + setShowFinalizeDialog(true) + try { + const r = await fetch('/api/invoices/next-number?document_type=invoice') + if (r.ok) { + const json = await r.json() + const preview = json?.data?.preview + // Only show a value that looks like a real invoice number. Guards the + // preview against an unexpected/oversized API response being rendered + // verbatim — a short alphanumeric token (optional series prefix), never + // free-form text. + setNextNumberPreview( + typeof preview === 'string' && /^[A-Za-z0-9-]{1,32}$/.test(preview) ? preview : null + ) + } + } catch { + // Best-effort preview; the dialog still works without it. + } + } + + // "Granska & skapa" — finalize an unnumbered draft into a real invoice: + // allocate the F-number and emit invoice.created. After this the invoice + // behaves like any draft (send / makulera), no longer hard-deletable. + async function finalizeInvoice() { + if (!invoice) return + + setIsFinalizing(true) + + try { + const response = await fetch(`/api/invoices/${invoice.id}/finalize`, { + method: 'POST', + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || t('fallback_try_again')) + } + + toast({ + title: t('finalized_toast_title'), + description: t('finalized_toast_description', { number: data.data?.invoice_number ?? '' }), + }) + + setShowFinalizeDialog(false) + fetchInvoice() + } catch (error) { + toast({ + title: t('finalize_failed_title'), + description: error instanceof Error ? error.message : t('fallback_try_again'), + variant: 'destructive', + }) + } finally { + setIsFinalizing(false) + } + } + async function deleteInvoice() { if (!invoice) return @@ -385,15 +450,22 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st if (!response.ok) { const data = await response.json() - throw new Error(data.error || t('cancel_failed_fallback')) + throw new Error(data.error?.message || t('cancel_failed_fallback')) } - toast({ - title: t('cancelled_toast_title'), - description: invoice.invoice_number - ? t('cancelled_with_number', { number: invoice.invoice_number }) - : t('cancelled_draft'), - }) + // Unnumbered drafts are hard deleted ("Ta bort"); numbered drafts are + // makulerade and keep their number in the series. + toast( + invoice.invoice_number + ? { + title: t('cancelled_toast_title'), + description: t('cancelled_with_number', { number: invoice.invoice_number }), + } + : { + title: t('removed_toast_title'), + description: t('removed_toast_description'), + } + ) router.push('/invoices') } catch (error) { @@ -427,6 +499,15 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st const isProforma = docType === 'proforma' const isDeliveryNote = docType === 'delivery_note' const isRealInvoice = docType === 'invoice' + // An unnumbered draft is one saved via "Spara som utkast" that hasn't been + // finalized — no F-number yet, so it can still be reviewed-and-created or + // hard-deleted. Once finalized it gets a number and behaves like any draft. + const isUnnumberedDraft = invoice.status === 'draft' && !invoice.invoice_number && isRealInvoice + // A numbered draft is issued-but-unsent ("Ej skickad"), distinct from an + // unnumbered draft ("Utkast"). Display-only — the DB status stays 'draft'. + const isUnsentNumberedInvoice = invoice.status === 'draft' && !!invoice.invoice_number && isRealInvoice + const displayStatusVariant = isUnsentNumberedInvoice ? 'outline' : statusVariant + const displayStatusLabel = isUnsentNumberedInvoice ? t('status_unsent') : statusLabel(invoice.status) // Self-billing invoices we received: the document is the counterparty's, so // there is no own PDF to render and no send step — it arrives already booked. const isSelfBilled = !!invoice.is_self_billed @@ -440,7 +521,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
-

{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : invoiceNumberDisplay(invoice.invoice_number)}

+

{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : (invoice.invoice_number ?? '—')}

{isProforma && ( {t('badge_proforma')} )} @@ -450,8 +531,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {isSelfBilled && ( {t('badge_self_billed')} )} - - {statusLabel(invoice.status)} + + {displayStatusLabel}

@@ -479,7 +560,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {t('convert_to_invoice')} )} - {invoice.status === 'draft' && !isDeliveryNote && ( + {isUnnumberedDraft && ( + + )} + {invoice.status === 'draft' && !isDeliveryNote && invoice.invoice_number && ( customerHasEmail ? ( - -

- {t('send_manual_hint_with_email')} -

- - ) : ( - <> - {!isDeliveryNote && ( -
- -

- {t('no_customer_email_warning')} -

-
- )} - -

- {t('send_manual_hint_no_email')} -

- - )} - - + isUnnumberedDraft ? ( + <> + {/* Unnumbered draft (saved via "Spara som utkast"): review + and create it, or remove it without a trace. */} + + + + ) : ( + <> + {!isDeliveryNote && customerHasEmail ? ( + <> + + +

+ {t('send_manual_hint_with_email')} +

+ + ) : ( + <> + {!isDeliveryNote && ( +
+ +

+ {t('no_customer_email_warning')} +

+
+ )} + +

+ {t('send_manual_hint_no_email')} +

+ + )} + + + ) )} {(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && ( <> @@ -1174,12 +1289,13 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
- {/* Cancel confirmation dialog. The invoice transitions to status='cancelled' - and the F-series number is retained so the sequence stays gap-free. */} + {/* Remove/cancel confirmation. A numbered draft is makulerad (status flips + to 'cancelled', number retained for a gap-free series); an unnumbered + draft is hard deleted since it never entered the number series. */} - {t('delete_dialog_title')} + {invoice.invoice_number ? t('delete_dialog_title') : t('remove_dialog_title')} {invoice.invoice_number ? ( <> @@ -1192,7 +1308,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st ) : ( <> - {t('delete_dialog_desc_no_number')} + {t('remove_dialog_desc')} )} @@ -1203,7 +1319,33 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st + + + + + {/* Finalize confirmation — "Granska & skapa". Allocates the F-number and + turns the unnumbered draft into a real, issued invoice. */} + + + + {t('finalize_dialog_title')} + {t('finalize_dialog_desc')} + + {nextNumberPreview && ( +
+ {t('finalize_dialog_number_label')} + {nextNumberPreview} +
+ )} + + +
diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index 4de077c9..605256c0 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -102,6 +102,7 @@ export default function NewInvoicePage() { const [customers, setCustomers] = useState([]) const [isLoading, setIsLoading] = useState(true) const [isSubmitting, setIsSubmitting] = useState(false) + const [isSavingDraft, setIsSavingDraft] = useState(false) const [selectedCustomer, setSelectedCustomer] = useState(null) const [showReview, setShowReview] = useState(false) const [pendingData, setPendingData] = useState(null) @@ -582,6 +583,61 @@ export default function NewInvoicePage() { } } + // "Spara som utkast" — save an unnumbered draft (save_as_draft) without the + // review dialog. The invoice gets no F-number and fires no invoice.created + // until the user opens it and clicks "Granska & skapa" (finalize). Same + // ROT/RUT privacy sanitization as handleConfirm. + async function saveDraftData(data: FormData) { + setIsSavingDraft(true) + + const anyDeduction = data.items.some((i) => i.deduction_type) + const sanitizedItems = data.items.map((item) => { + if (item.deduction_type) return item + const { + deduction_type: _dt, + labor_hours: _lh, + work_type: _wt, + housing_designation: _hd, + apartment_number: _an, + ...rest + } = item + return rest + }) + const payload: CreateInvoiceInput = { + ...(data as CreateInvoiceInput), + save_as_draft: true, + items: sanitizedItems as CreateInvoiceInput['items'], + ...(anyDeduction + ? {} + : { deduction_personnummer: undefined, deduction_housing_designation: undefined }), + } + + try { + const response = await fetch('/api/invoices', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + const result = await response.json() + if (!response.ok) { + throw new Error(getErrorMessage(result, { context: 'invoice', statusCode: response.status })) + } + toast({ + title: t('toast_draft_saved_title'), + description: t('toast_draft_saved_description'), + }) + router.push(`/invoices/${result.data.id}`) + } catch (error) { + toast({ + title: t('save_draft_failed_title'), + description: getErrorMessage(error, { context: 'invoice' }), + variant: 'destructive', + }) + } finally { + setIsSavingDraft(false) + } + } + async function handleSendNow() { if (!createdInvoiceId) return setIsSending(true) @@ -1305,16 +1361,32 @@ export default function NewInvoicePage() { {/* Actions — desktop/tablet only */} - +
+ + {!isSelfBilled && watchDocumentType === 'invoice' && ( + + )} +
@@ -1329,14 +1401,26 @@ export default function NewInvoicePage() { {formatCurrency(hasAnyDeduction ? toPay : total, watchCurrency)}

- +
+ {!isSelfBilled && watchDocumentType === 'invoice' && ( + + )} + +
diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index e058bfe0..89972ec9 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -21,7 +21,7 @@ import { import { useToast } from '@/components/ui/use-toast' import { formatCurrency, formatDate } from '@/lib/utils' import { cn } from '@/lib/utils' -import { invoiceNumberDisplay, invoiceDisplayNumber } from '@/lib/invoices/display' +import { invoiceDisplayNumber } from '@/lib/invoices/display' import { getDisplayTotal } from '@/lib/invoices/rounding' import { Plus, Search, Receipt, Lock, Repeat } from 'lucide-react' import { EmptyInvoices } from '@/components/ui/empty-state' @@ -275,6 +275,18 @@ export default function InvoicesPage() { const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice' const isProforma = docType === 'proforma' const isDeliveryNote = docType === 'delivery_note' + // A draft that already has a number is issued-but-unsent ("Granska & + // skapa" done, "Skicka" pending) — distinct from a true unnumbered + // draft. Show "Ej skickad" so the two don't look alike. Display-only. + const isUnsentInvoice = + invoice.status === 'draft' && + !!invoice.invoice_number && + !isProforma && + !isDeliveryNote && + !isCreditNote && + !invoice.is_self_billed + const statusLabelKey = isUnsentInvoice ? 'status_unsent' : status.labelKey + const statusVariant: InvoiceStatusVariant | 'outline' = isUnsentInvoice ? 'outline' : status.variant const relativeTime = invoice.due_date ? getRelativeTimeLabel(invoice.due_date, invoice.status) : null const displayedTotal = getDisplayTotal( { total: Number(invoice.total), currency: invoice.currency }, @@ -307,7 +319,7 @@ export default function InvoicesPage() { } > - {invoice.is_self_billed ? invoiceDisplayNumber(invoice) : invoiceNumberDisplay(invoice.invoice_number)}{' '} + {invoice.is_self_billed ? invoiceDisplayNumber(invoice) : (invoice.invoice_number ?? '—')}{' '} · {(invoice.customer as { name: string })?.name} @@ -316,10 +328,10 @@ export default function InvoicesPage() { {formatDate(invoice.invoice_date)} - {t(status.labelKey)} + {t(statusLabelKey)} {isCreditNote && ( <> diff --git a/app/(dashboard)/salary/employees/[id]/page.tsx b/app/(dashboard)/salary/employees/[id]/page.tsx index 9c0b110f..e73d074a 100644 --- a/app/(dashboard)/salary/employees/[id]/page.tsx +++ b/app/(dashboard)/salary/employees/[id]/page.tsx @@ -15,6 +15,7 @@ import { useCanWrite } from '@/lib/hooks/use-can-write' import { getErrorMessage } from '@/lib/errors/get-error-message' import type { Employee } from '@/types' import { EmployeeBenefitsPanel } from '@/components/salary/EmployeeBenefitsPanel' +import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard' const EMPLOYMENT_LABELS: Record = { employee: 'Anställd', @@ -22,13 +23,6 @@ const EMPLOYMENT_LABELS: Record = { board_member: 'Styrelseledamot', } -const F_SKATT_LABELS: Record = { - a_skatt: 'A-skatt', - f_skatt: 'F-skatt', - fa_skatt: 'FA-skatt', - not_verified: 'Ej verifierad', -} - function RequiredMark() { return * } @@ -43,11 +37,8 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s const [saving, setSaving] = useState(false) const [employmentType, setEmploymentType] = useState('employee') const [salaryType, setSalaryType] = useState('monthly') - const [fSkattStatus, setFSkattStatus] = useState('a_skatt') - const [isSidoinkomst, setIsSidoinkomst] = useState(false) const [vacationRule, setVacationRule] = useState('procentregeln') - - const requiresTaxTable = fSkattStatus === 'a_skatt' && !isSidoinkomst + const [tax, setTax] = useState(null) useEffect(() => { async function load() { @@ -57,8 +48,6 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s setEmployee(data) setEmploymentType(data.employment_type) setSalaryType(data.salary_type || 'monthly') - setFSkattStatus(data.f_skatt_status || 'a_skatt') - setIsSidoinkomst(data.is_sidoinkomst || false) setVacationRule(data.vacation_rule || 'procentregeln') } setLoading(false) @@ -75,13 +64,15 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s first_name: form.get('first_name') as string, last_name: form.get('last_name') as string, employment_type: employmentType, + employment_start: form.get('employment_start') as string || undefined, + employment_end: form.get('employment_end') as string || undefined, employment_degree: parseFloat(form.get('employment_degree') as string) || 100, salary_type: salaryType, - f_skatt_status: fSkattStatus, - is_sidoinkomst: isSidoinkomst, - tax_table_number: parseInt(form.get('tax_table_number') as string) || undefined, - tax_column: parseInt(form.get('tax_column') as string) || 1, - tax_municipality: form.get('tax_municipality') as string || undefined, + f_skatt_status: tax?.f_skatt_status, + is_sidoinkomst: tax?.is_sidoinkomst, + tax_table_number: tax?.tax_table_number ?? undefined, + tax_column: tax?.tax_column ?? 1, + tax_municipality: tax?.tax_municipality || undefined, email: form.get('email') as string || undefined, phone: form.get('phone') as string || undefined, address_line1: form.get('address_line1') as string || undefined, @@ -248,6 +239,18 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s +
+
+ + +

Lönen proportioneras automatiskt om anställningen börjar eller slutar mitt i en löneperiod.

+
+
+ + +

Lämna tomt för pågående anställning.

+
+
@@ -286,81 +289,23 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s {/* Tax */} - - - Skatt - - -
-
- - - {employee.f_skatt_verified_at && ( -

- Verifierad: {new Date(employee.f_skatt_verified_at).toLocaleDateString('sv-SE')} -

- )} -
-
- -
-
-
-
- - -

Baseras på folkbokföringskommun

-
-
- - -

1 = standard under 66 år

-
-
- - -
-
-
-
+ + {employee.f_skatt_verified_at && ( +

+ F-skatt verifierad: {new Date(employee.f_skatt_verified_at).toLocaleDateString('sv-SE')} +

+ )} {/* Vacation */} diff --git a/app/(dashboard)/salary/employees/new/page.tsx b/app/(dashboard)/salary/employees/new/page.tsx index f1f1e8d2..00018c35 100644 --- a/app/(dashboard)/salary/employees/new/page.tsx +++ b/app/(dashboard)/salary/employees/new/page.tsx @@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { ArrowLeft, Save } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' +import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard' function RequiredMark() { return * @@ -22,11 +23,15 @@ export default function NewEmployeePage() { const [saving, setSaving] = useState(false) const [employmentType, setEmploymentType] = useState('employee') const [salaryType, setSalaryType] = useState('monthly') - const [fSkattStatus, setFSkattStatus] = useState('a_skatt') - const [isSidoinkomst, setIsSidoinkomst] = useState(false) + const [personnummer, setPersonnummer] = useState('') const [vacationRule, setVacationRule] = useState('procentregeln') - - const requiresTaxTable = fSkattStatus === 'a_skatt' && !isSidoinkomst + const [tax, setTax] = useState({ + f_skatt_status: 'a_skatt', + is_sidoinkomst: false, + tax_table_number: null, + tax_column: 1, + tax_municipality: '', + }) async function handleSubmit(e: React.FormEvent) { e.preventDefault() @@ -36,18 +41,19 @@ export default function NewEmployeePage() { const body = { first_name: form.get('first_name') as string, last_name: form.get('last_name') as string, - personnummer: (form.get('personnummer') as string).replace(/\D/g, ''), + personnummer: personnummer.replace(/\D/g, ''), employment_type: employmentType, employment_start: form.get('employment_start') as string, + employment_end: form.get('employment_end') as string || undefined, employment_degree: parseFloat(form.get('employment_degree') as string) || 100, salary_type: salaryType, monthly_salary: salaryType === 'monthly' ? (parseFloat(form.get('monthly_salary') as string) || undefined) : undefined, hourly_rate: salaryType === 'hourly' ? (parseFloat(form.get('hourly_rate') as string) || undefined) : undefined, - f_skatt_status: fSkattStatus, - is_sidoinkomst: isSidoinkomst, - tax_table_number: parseInt(form.get('tax_table_number') as string) || undefined, - tax_column: parseInt(form.get('tax_column') as string) || 1, - tax_municipality: form.get('tax_municipality') as string || undefined, + f_skatt_status: tax.f_skatt_status, + is_sidoinkomst: tax.is_sidoinkomst, + tax_table_number: tax.tax_table_number ?? undefined, + tax_column: tax.tax_column, + tax_municipality: tax.tax_municipality || undefined, email: form.get('email') as string || undefined, phone: form.get('phone') as string || undefined, address_line1: form.get('address_line1') as string || undefined, @@ -109,7 +115,15 @@ export default function NewEmployeePage() {
- + setPersonnummer(e.target.value)} + />

Krypteras vid lagring

@@ -172,6 +186,10 @@ export default function NewEmployeePage() {
+
+ + +
@@ -215,67 +233,7 @@ export default function NewEmployeePage() { {/* Tax */} - - - Skatt - - -
-
- - -
-
- -
-
-
-
- - -

Baseras på folkbokföringskommun

-
-
- - -

1 = standard under 66 år

-
-
- - -
-
-
-
+ {/* Vacation */} diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index 70e31616..07ba06fb 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -11,7 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { ArrowLeft, Calculator, Eye, Check, CreditCard, BookOpen, - ArrowLeftCircle, Loader2, Download, FileDown, + ArrowLeftCircle, Loader2, Download, FileDown, Trash2, } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' import { useCanWrite } from '@/lib/hooks/use-can-write' @@ -125,6 +125,26 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: setActionLoading(null) } + async function handleDelete() { + if (!run) return + const period = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + if (!confirm(`Radera utkastet för ${period}? Alla anställda och beräkningar i körningen tas bort. Detta kan inte ångras.`)) return + setActionLoading('delete') + const res = await fetch(`/api/salary/runs/${id}`, { method: 'DELETE' }) + if (res.ok) { + toast({ title: 'Utkast raderat' }) + router.push('/salary') + return + } + const result = await res.json() + toast({ + title: 'Kunde inte radera utkast', + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + setActionLoading(null) + } + async function handleAddEmployee(employeeId: string) { setActionLoading('add-employee') const res = await fetch(`/api/salary/runs/${id}/employees`, { @@ -573,6 +593,10 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
{run.status === 'draft' && ( <> + ) })} @@ -242,7 +242,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o {/* Empty state */} {isOpen && search.trim() && flatList.length === 0 && ( -
+

Hittade inget konto som matchar.

diff --git a/components/bookkeeping/CreatePeriodDialog.tsx b/components/bookkeeping/CreatePeriodDialog.tsx index f26d327c..777f6ac4 100644 --- a/components/bookkeeping/CreatePeriodDialog.tsx +++ b/components/bookkeeping/CreatePeriodDialog.tsx @@ -13,7 +13,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { useToast } from '@/components/ui/use-toast' -import { Loader2 } from 'lucide-react' +import { Loader2, Lock } from 'lucide-react' import type { FiscalPeriod } from '@/types' interface Props { @@ -24,6 +24,24 @@ interface Props { onCreated: () => void } +/** A prior period that must be locked before a new fiscal year can be created. */ +interface BlockingPeriod { + id: string + name: string + period_start: string + period_end: string +} + +/** Read a user-facing message from either a legacy string error or the + * canonical { code, message } envelope. */ +function errorMessage(err: unknown, fallback = 'Ett oväntat fel uppstod.'): string { + if (typeof err === 'string') return err + if (err && typeof err === 'object' && typeof (err as { message?: unknown }).message === 'string') { + return (err as { message: string }).message + } + return fallback +} + function computeSuggestedPeriod(entryDate: string, periods: FiscalPeriod[]) { if (periods.length === 0) { // No periods at all — suggest a calendar year period around the entry date @@ -84,6 +102,10 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri const [periodStart, setPeriodStart] = useState(suggested.period_start) const [periodEnd, setPeriodEnd] = useState(suggested.period_end) const [isSubmitting, setIsSubmitting] = useState(false) + const [isLocking, setIsLocking] = useState(false) + // Set when creation is blocked because a prior räkenskapsår is still open. + // The user can lock these inline and retry without leaving the dialog. + const [blockingPeriods, setBlockingPeriods] = useState([]) // Reset form when suggested values change (dialog reopened with new date) const [lastSuggested, setLastSuggested] = useState(suggested) @@ -92,6 +114,7 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri setPeriodStart(suggested.period_start) setPeriodEnd(suggested.period_end) setLastSuggested(suggested) + setBlockingPeriods([]) } const handleCreate = async () => { @@ -106,15 +129,28 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri const result = await res.json() if (!res.ok) { + const err = result?.error + // Blocked by an open prior year — surface an inline "lås och försök + // igen" path instead of a dead-end toast. + if ( + err && + typeof err === 'object' && + err.code === 'PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS' + ) { + const blocking = (err.details?.blockingPeriods ?? []) as BlockingPeriod[] + setBlockingPeriods(blocking) + return + } toast({ title: 'Kunde inte skapa räkenskapsår', - description: result.error || 'Ett oväntat fel uppstod.', + description: errorMessage(err), variant: 'destructive', }) return } toast({ title: 'Räkenskapsår skapat', description: `${name} har skapats.` }) + setBlockingPeriods([]) onOpenChange(false) onCreated() } catch { @@ -128,6 +164,42 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri } } + // Lock each blocking prior year (reversible locked_at), then retry creation. + const handleLockAndRetry = async () => { + setIsLocking(true) + try { + for (const p of blockingPeriods) { + const res = await fetch(`/api/bookkeeping/fiscal-periods/${p.id}/lock`, { + method: 'POST', + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + // An already-locked period is fine — keep going. + if (body?.error?.code === 'PERIOD_LOCK_ALREADY_LOCKED') continue + toast({ + title: `Kunde inte låsa ${p.name}`, + description: errorMessage(body?.error), + variant: 'destructive', + }) + return + } + } + setBlockingPeriods([]) + await handleCreate() + } catch { + toast({ + title: 'Kunde inte låsa räkenskapsåret', + description: 'Ett nätverksfel uppstod. Försök igen.', + variant: 'destructive', + }) + } finally { + setIsLocking(false) + } + } + + const isBlocked = blockingPeriods.length > 0 + const busy = isSubmitting || isLocking + return ( @@ -153,16 +225,46 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri setPeriodEnd(e.target.value)} className="mt-1" />
+ + {isBlocked && ( +
+ +
+
+

Föregående räkenskapsår är öppet

+

+ Du måste låsa föregående räkenskapsår innan du kan skapa ett nytt. + Låsningen är vändbar — du kan låsa upp året igen för att bokföra + bokslutsposter. +

+
+
    + {blockingPeriods.map((p) => ( +
  • + {p.name} ({p.period_start} – {p.period_end}) +
  • + ))} +
+
+
+ )}
- - + {isBlocked ? ( + + ) : ( + + )}
diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index b78e406c..aebca843 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -1630,12 +1630,15 @@ function FieldsRail({ ) : ( <> {/* Unmatched state: the canonical next step is to find the bank - transaction this underlag belongs to. "Bokför manuellt" opens - BookDirectlyDialog without a transaction — for underlag with - no bank movement (cash expenses, private outlays); the dialog - still offers optional transaction selection inside. "Skapa - leverantörsfaktura" stays as an escape hatch for users who - want supplier-invoice tracking (accrual flow). */} + transaction this underlag belongs to. Two escape hatches sit + below it — "Skapa leverantörsfaktura" for users who want + supplier-invoice tracking (accrual flow), and "Bokför som + verifikat" for underlag that aren't a supplier invoice at all + (bank fees, owner expenses, the underlag for a correction). The + latter opens the same BookDirectlyDialog as the matched state, + which works without a bank transaction and lets the user attach + one if they want. Per BFL 5 kap 6-7 § the underlag must be + bookable as a verifikat, not forced into a supplier invoice. */} + + + - - - )} + )} + + +
+ + + {!columnTouched && derivedColumn != null ? ( +

+ Föreslås automatiskt: anställd under 66 år. +

+ ) : isSenior && !columnTouched ? ( +

+ Personen har fyllt 66 år — välj kolumn manuellt (lön = kolumn 3, pension = kolumn 2). +

+ ) : null} +
+ + + ) : ( +

+ {sido + ? 'Sidoinkomst: ett fast skatteavdrag på 30 % görs — ingen skattetabell behövs.' + : 'Med F-skatt eller FA-skatt sköter personen sin egen skatt — inget skatteavdrag görs och ingen skattetabell behövs.'} +

+ )} + + + ) +} diff --git a/components/salary/MunicipalityCombobox.tsx b/components/salary/MunicipalityCombobox.tsx new file mode 100644 index 00000000..9c57bc41 --- /dev/null +++ b/components/salary/MunicipalityCombobox.tsx @@ -0,0 +1,201 @@ +'use client' + +import { useState, useRef, useEffect, useMemo, useCallback } from 'react' +import { Input } from '@/components/ui/input' + +export interface KommunRate { + kommun: string + totalRate: number + tableNumber: number +} + +interface MunicipalityComboboxProps { + value: string + /** Fired when a municipality is committed from the list (with its derived table). */ + onSelect: (kommun: string, tableNumber: number, totalRate: number) => void + /** Fired on free-text edits that don't match a known municipality. */ + onChange?: (kommun: string) => void + /** Income year — drives which year's municipal rates are fetched. */ + year: number + disabled?: boolean + id?: string + className?: string +} + +const MAX_RESULTS = 50 + +/** + * Searchable folkbokföringskommun picker. Loads the kommun → skattetabell map + * from /api/salary/tax-tables/kommuner once, so picking a town auto-fills the + * tax table. Degrades to a plain free-text field if the list can't be fetched. + */ +export default function MunicipalityCombobox({ + value, + onSelect, + onChange, + year, + disabled, + id, + className, +}: MunicipalityComboboxProps) { + const [search, setSearch] = useState(value) + const [isOpen, setIsOpen] = useState(false) + const [highlightedIndex, setHighlightedIndex] = useState(0) + const [kommuner, setKommuner] = useState([]) + const [loadFailed, setLoadFailed] = useState(false) + const containerRef = useRef(null) + const listRef = useRef(null) + + // Sync external value changes into the search field + useEffect(() => { + setSearch(value) + }, [value]) + + // Load the municipality list once per year + useEffect(() => { + let cancelled = false + async function load() { + try { + const res = await fetch(`/api/salary/tax-tables/kommuner?year=${year}`) + if (!res.ok) throw new Error(`status ${res.status}`) + const { data } = await res.json() + if (!cancelled) setKommuner(data.kommuner ?? []) + } catch { + if (!cancelled) setLoadFailed(true) + } + } + load() + return () => { + cancelled = true + } + }, [year]) + + const filtered = useMemo(() => { + const trimmed = search.trim().toLowerCase() + if (!trimmed) return kommuner.slice(0, MAX_RESULTS) + return kommuner.filter((k) => k.kommun.toLowerCase().includes(trimmed)).slice(0, MAX_RESULTS) + }, [kommuner, search]) + + useEffect(() => { + setHighlightedIndex(0) + }, [filtered]) + + useEffect(() => { + if (!isOpen || !listRef.current) return + const el = listRef.current.querySelector('[data-highlighted="true"]') + if (el) el.scrollIntoView({ block: 'nearest' }) + }, [highlightedIndex, isOpen]) + + useEffect(() => { + function handleClickOutside(e: MouseEvent | TouchEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('touchstart', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('touchstart', handleClickOutside) + } + }, []) + + const select = useCallback( + (k: KommunRate) => { + setSearch(k.kommun) + setIsOpen(false) + onSelect(k.kommun, k.tableNumber, k.totalRate) + }, + [onSelect] + ) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + setIsOpen(true) + e.preventDefault() + } + return + } + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setHighlightedIndex((p) => Math.min(p + 1, filtered.length - 1)) + break + case 'ArrowUp': + e.preventDefault() + setHighlightedIndex((p) => Math.max(p - 1, 0)) + break + case 'Enter': + if (filtered[highlightedIndex]) { + e.preventDefault() + select(filtered[highlightedIndex]) + } + break + case 'Escape': + e.preventDefault() + setIsOpen(false) + break + } + } + + const handleInputChange = (e: React.ChangeEvent) => { + const next = e.target.value + setSearch(next) + onChange?.(next) + if (!isOpen) setIsOpen(true) + } + + return ( +
+ setIsOpen(true)} + onKeyDown={handleKeyDown} + placeholder="Sök kommun…" + autoComplete="off" + disabled={disabled} + className={className} + /> + + {isOpen && filtered.length > 0 && ( +
+ {filtered.map((k, i) => { + const isHighlighted = i === highlightedIndex + return ( + + ) + })} +
+ )} + + {loadFailed && ( +

+ Kunde inte hämta kommunlistan — skriv kommunnamnet och ange skattetabellen manuellt. +

+ )} +
+ ) +} diff --git a/components/ui/input.tsx b/components/ui/input.tsx index d45e180b..85dfa2ce 100644 --- a/components/ui/input.tsx +++ b/components/ui/input.tsx @@ -4,7 +4,20 @@ import { cn } from "@/lib/utils" export type InputProps = React.InputHTMLAttributes const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { + ({ className, type, onWheel, ...props }, ref) => { + // Prevent the mouse wheel from silently mutating a focused number input + // (e.g. scrolling the page over a salary field turning 20000 into 19998). + // Blurring drops focus so the wheel scrolls the page instead of the value. + const handleWheel = React.useCallback( + (e: React.WheelEvent) => { + if (type === 'number') { + e.currentTarget.blur() + } + onWheel?.(e) + }, + [type, onWheel] + ) + return ( ( className )} ref={ref} + onWheel={handleWheel} {...props} /> ) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 89790dc7..512bd362 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -219,6 +219,11 @@ export const CreateInvoiceSchema = z.object({ // present (enforced via rot-rut-rules.validateInvoice in the API). deduction_personnummer: z.string().max(20).optional(), deduction_housing_designation: z.string().max(128).optional(), + // When true, save as an unnumbered draft: skip F-series allocation and the + // invoice.created event until the user finalizes via POST /invoices/{id}/finalize + // ("Granska och skapa"). An unnumbered draft is not yet an issued faktura + // (ML 17 kap 24§), so it can be hard-deleted with no gap in the number series. + save_as_draft: z.boolean().optional(), items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required'), }) diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index dc653018..977d7284 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -738,6 +738,21 @@ const INVOICE: Record = { message_sv: 'Fakturan ändrades samtidigt och kunde inte makuleras. Ladda om och försök igen.', message_en: 'Invoice was modified concurrently and could not be cancelled. Reload and retry.', }, + INVOICE_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Fakturan kunde inte hittas.', + message_en: 'Invoice not found.', + }, + INVOICE_FINALIZE_NOT_DRAFT: { + httpStatus: 409, + message_sv: 'Endast onumrerade utkast kan skapas. Fakturan har redan ett nummer eller är inte ett utkast.', + message_en: 'Only unnumbered drafts can be finalized; this invoice already has a number or is not a draft.', + }, + INVOICE_FINALIZE_INCOMPLETE: { + httpStatus: 500, + message_sv: 'Fakturanumret tilldelades men fakturan kunde inte läsas tillbaka. Ladda om sidan och kontrollera fakturan.', + message_en: 'The invoice number was assigned but the invoice could not be re-read. Reload the page and verify the invoice.', + }, // Quotes / Offerter QUOTE_NOT_FOUND: { httpStatus: 404, @@ -833,6 +848,20 @@ const PERIOD: Record = { message_sv: 'Perioden är redan låst.', message_en: 'Period is already locked.', }, + // Forward-chaining a new räkenskapsår is blocked while a prior period is + // still fully open (not locked, not closed, not covered by the company-wide + // lock-through date). BFL 6 kap allows löpande bokföring of the new year in + // parallel with bokslut, but the prior year must at least be locked so + // nothing is back-posted into a year you've moved on from. The blocking + // periods (id + name + dates) are attached to the response `details` so the + // UI can offer to lock them inline. See app/api/bookkeeping/fiscal-periods. + PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS: { + httpStatus: 409, + message_sv: + 'Du måste låsa föregående räkenskapsår innan du kan skapa ett nytt.', + message_en: + 'Cannot create a new fiscal year while a prior period is still open; lock it first.', + }, } const YEAR_END: Record = { diff --git a/lib/events/handlers/event-log-handler.ts b/lib/events/handlers/event-log-handler.ts index 651cad29..0ea8c2c5 100644 --- a/lib/events/handlers/event-log-handler.ts +++ b/lib/events/handlers/event-log-handler.ts @@ -17,6 +17,7 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [ 'document.accessed', 'document.deleted', 'invoice.created', + 'invoice.draft_deleted', 'invoice.sent', 'credit_note.created', 'transaction.synced', @@ -88,10 +89,14 @@ function extractEntityId(payload: Record): string | null { // Flat-string ID fields on events that don't carry a full entity object. // Bank connection events fall into this category — the connection lives in - // an extension table, so we record its id directly. + // an extension table, so we record its id directly. invoice.draft_deleted + // carries only invoiceId because the row is already gone. if (typeof payload.connectionId === 'string') { return payload.connectionId } + if (typeof payload.invoiceId === 'string') { + return payload.invoiceId + } // For journal_entry.corrected: use the corrected entry's ID if ('corrected' in payload) { diff --git a/lib/events/types.ts b/lib/events/types.ts index 2bc600d1..91434acb 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -30,6 +30,11 @@ export type CoreEvent = | { type: 'document.deleted'; payload: { document: { id: string; file_name: string }; userId: string; companyId: string } } // Invoicing | { type: 'invoice.created'; payload: { invoice: Invoice; userId: string; companyId: string } } + // Hard delete of an un-finalized, unnumbered draft (no F-series number was + // consumed). Carries only the identifiers — the row is gone — so the audit + // log can record who removed which draft and when. Numbered drafts are + // makulerade instead and surface via the journal, not this event. + | { type: 'invoice.draft_deleted'; payload: { invoiceId: string; userId: string; companyId: string } } | { type: 'invoice.sent'; payload: { invoice: Invoice; userId: string; companyId: string } } | { type: 'invoice.paid'; payload: { invoice: Invoice; paymentAmount: number; paymentDate: string; userId: string; companyId: string } } | { type: 'credit_note.created'; payload: { creditNote: CreditNote; userId: string; companyId: string } } diff --git a/lib/import/bank-file/__tests__/suggest-column-mapping.test.ts b/lib/import/bank-file/__tests__/suggest-column-mapping.test.ts new file mode 100644 index 00000000..9f328e98 --- /dev/null +++ b/lib/import/bank-file/__tests__/suggest-column-mapping.test.ts @@ -0,0 +1,106 @@ +/** + * Tests for suggestColumnMapping — the auto-guess that seeds the manual CSV + * column-mapping UI. + * + * Regression context: the previous heuristic walked each data row right-to-left + * and picked the first numeric cell as the amount. Because virtually every + * Swedish bank export ends with `…;Belopp;Saldo`, that grabbed the trailing + * running-balance column as the amount, so the live preview showed the balance + * instead of the transaction amount. The fix matches header labels first. + */ + +import { describe, it, expect } from 'vitest' +import { suggestColumnMapping } from '../formats/generic-csv' + +describe('suggestColumnMapping', () => { + it('REGRESSION: picks Belopp (not the trailing Saldo) as amount when a header is present', () => { + // Handelsbanken Företag-style layout: Bokföringsdatum;Referens;Belopp;Saldo. + // The old reverse-scan heuristic would have returned amount = 3 (Saldo). + const headers = ['Bokföringsdatum', 'Referens', 'Belopp', 'Saldo'] + const dataRows = [ + ['2024-01-15', 'Swish Anna Svensson', '-99,00', '12345,67'], + ['2024-01-14', 'HEMKÖP', '-432,50', '12444,67'], + ['2024-01-13', 'LÖNEUTBETALNING', '25000,00', '12877,17'], + ] + + const result = suggestColumnMapping(headers, dataRows) + + expect(result.amount).toBe(2) // Belopp — NOT 3 (Saldo) + expect(result.balance).toBe(3) // Saldo auto-filled + expect(result.date).toBe(0) + expect(result.description).toBe(1) + }) + + it('without a header, prefers the column with negative values as amount and the trailing column as balance', () => { + // date ; text ; belopp ; saldo — no header row at all. + const dataRows = [ + ['2024-01-15', 'Swish Anna Svensson', '-99,00', '12345,67'], + ['2024-01-14', 'HEMKÖP', '-432,50', '12444,67'], + ['2024-01-13', 'LÖNEUTBETALNING', '25000,00', '12877,17'], + ] + + const result = suggestColumnMapping(null, dataRows) + + expect(result.date).toBe(0) + expect(result.description).toBe(1) + expect(result.amount).toBe(2) // has negative values + expect(result.balance).toBe(3) // remaining (all-positive) numeric column + }) + + it('handles the Handelsbanken private layout (Reskontra + Transaktionsdatum + Text)', () => { + const headers = ['Reskontradatum', 'Transaktionsdatum', 'Text', 'Belopp', 'Saldo'] + const dataRows = [ + ['2024-01-15', '2024-01-15', 'SPOTIFY AB', '-99,00', '12345,67'], + ['2024-01-14', '2024-01-14', 'HEMKÖP', '-432,50', '12444,67'], + ] + + const result = suggestColumnMapping(headers, dataRows) + + expect(result.date).toBe(1) // prefers transaktionsdatum over reskontradatum + expect(result.description).toBe(2) // Text + expect(result.amount).toBe(3) // Belopp — NOT 4 (Saldo) + expect(result.balance).toBe(4) + }) + + it('does not invent a balance column when the file has none', () => { + const headers = ['Datum', 'Text', 'Belopp'] + const dataRows = [ + ['2024-01-15', 'SPOTIFY', '-99,00'], + ['2024-01-14', 'HEMKÖP', '-432,50'], + ] + + const result = suggestColumnMapping(headers, dataRows) + + expect(result.date).toBe(0) + expect(result.description).toBe(1) + expect(result.amount).toBe(2) + expect(result.balance).toBe(-1) + }) + + it('matches the amount by label even when every amount is positive', () => { + // No negative values to fall back on — the label match must still win. + const headers = ['Bokföringsdatum', 'Referens', 'Belopp', 'Saldo'] + const dataRows = [['2024-01-15', 'Inbetalning kund', '5000,00', '12345,67']] + + const result = suggestColumnMapping(headers, dataRows) + + expect(result.amount).toBe(2) // Belopp by label, not Saldo + expect(result.balance).toBe(3) + }) + + it('tolerates space-grouped thousands and Unicode minus signs', () => { + const headers = ['Datum', 'Text', 'Belopp', 'Saldo'] + const dataRows = [ + ['2024-01-15', 'STOR BETALNING', '−1 432,50', '120 000,00'], + ] + + const result = suggestColumnMapping(headers, dataRows) + + expect(result.amount).toBe(2) + expect(result.balance).toBe(3) + }) + + it('returns all -1 for empty input', () => { + expect(suggestColumnMapping(null, [])).toEqual({ date: -1, description: -1, amount: -1, balance: -1 }) + }) +}) diff --git a/lib/import/bank-file/formats/generic-csv.ts b/lib/import/bank-file/formats/generic-csv.ts index 6c54861f..96d853ad 100644 --- a/lib/import/bank-file/formats/generic-csv.ts +++ b/lib/import/bank-file/formats/generic-csv.ts @@ -186,6 +186,153 @@ export function getCSVPreview(content: string, delimiter: string = ',', rows: nu ) } +/** Column indices suggested for the manual mapping UI. -1 = not resolved. */ +export interface SuggestedColumnMapping { + date: number + description: number + amount: number + balance: number +} + +// A cell that looks like a date in one of the formats the importer accepts. +const SUGGEST_DATE_PATTERNS = [ + /^\d{4}-\d{2}-\d{2}$/, + /^\d{2}[./]\d{2}[./]\d{4}$/, + /^\d{4}\/\d{2}\/\d{2}$/, + /^\d{8}$/, +] + +/** Pick the best date column by header label, preferring transaction date over value date. */ +function pickDateHeader(headers: string[]): number { + const tiers: Array<(h: string) => boolean> = [ + (h) => h.includes('transaktionsdatum'), + (h) => h.includes('reskontradatum'), + (h) => /bokf(ö|o)ringsda(g|tum)/.test(h), + (h) => h === 'datum' || h === 'date', + ] + for (const match of tiers) { + const idx = headers.findIndex((h) => match(h) && !h.includes('valuta')) + if (idx >= 0) return idx + } + return headers.findIndex((h) => (h.includes('datum') || h.includes('date')) && !h.includes('valuta')) +} + +/** Pick the best description column by header label. */ +function pickDescriptionHeader(headers: string[]): number { + const keywords = ['text', 'beskrivning', 'description', 'rubrik', 'meddelande', 'referens', 'mottagare', 'namn'] + for (const kw of keywords) { + const idx = headers.findIndex((h) => h === kw || h.includes(kw)) + if (idx >= 0) return idx + } + return -1 +} + +/** Per-column value statistics across the sampled data rows. */ +function analyzeColumns(dataRows: string[][], colCount: number) { + const acc = Array.from({ length: colCount }, () => ({ numeric: 0, date: 0, negative: 0, nonEmpty: 0 })) + for (const row of dataRows.slice(0, 20)) { + for (let i = 0; i < colCount; i++) { + const raw = (row[i] ?? '').trim() + if (!raw) continue + acc[i].nonEmpty++ + if (SUGGEST_DATE_PATTERNS.some((re) => re.test(raw))) acc[i].date++ + const cleaned = normalizeMinusSign(raw).replace(/\s/g, '') + if (/^-?\d+([.,]\d+)?$/.test(cleaned)) { + acc[i].numeric++ + if (cleaned.startsWith('-')) acc[i].negative++ + } + } + } + return acc.map((s) => ({ + isDate: s.nonEmpty > 0 && s.date / s.nonEmpty >= 0.5, + isNumeric: s.nonEmpty > 0 && s.numeric / s.nonEmpty >= 0.5, + hasNegative: s.negative > 0, + })) +} + +/** + * Suggest date / description / amount / balance column indices for the manual + * CSV mapping UI. + * + * When a header row is available, columns are matched by their label first + * (belopp → amount, saldo → balance, …) so a trailing running-balance column is + * never mistaken for the amount — the original positional heuristic walked the + * row right-to-left and grabbed Saldo as the amount on the common + * `…;Belopp;Saldo` layout. Falls back to value-based heuristics on the sample + * data for any column the labels don't resolve; there the amount guess + * explicitly skips the balance column and prefers a column carrying negative + * values (real transaction amounts swing negative, a running balance usually + * does not). + * + * @param headers Header labels, or null when the file has no header row. + * @param dataRows Sample data rows already split into cells. + */ +export function suggestColumnMapping( + headers: string[] | null, + dataRows: string[][] +): SuggestedColumnMapping { + const result: SuggestedColumnMapping = { date: -1, description: -1, amount: -1, balance: -1 } + + const colCount = Math.max( + headers?.length ?? 0, + ...dataRows.slice(0, 10).map((r) => r.length), + 0 + ) + if (colCount === 0) return result + + // 1. Label-based matching when we have a header row. + if (headers) { + const hdr = headers.map((h) => h.trim().toLowerCase().replace(/"/g, '')) + result.date = pickDateHeader(hdr) + result.balance = hdr.findIndex((h) => h.includes('saldo') || h.includes('balance')) + result.amount = hdr.findIndex( + (h, i) => i !== result.balance && (h === 'belopp' || h.includes('belopp') || h.includes('amount')) + ) + result.description = pickDescriptionHeader(hdr) + } + + // 2. Value-based fallback for anything the labels didn't resolve. + const stats = analyzeColumns(dataRows, colCount) + + if (result.date === -1) { + result.date = stats.findIndex((s) => s.isDate) + } + + if (result.amount === -1 || result.balance === -1) { + const numericCols = stats + .map((s, i) => ({ i, s })) + .filter(({ i, s }) => i !== result.date && s.isNumeric) + + if (result.amount === -1) { + const candidates = numericCols.filter(({ i }) => i !== result.balance) + const negative = candidates.find(({ s }) => s.hasNegative) + result.amount = (negative ?? candidates[0])?.i ?? -1 + } + + if (result.balance === -1) { + // Remaining numeric column (typically the trailing running balance). + const remaining = numericCols.filter(({ i }) => i !== result.amount) + result.balance = remaining.length ? remaining[remaining.length - 1].i : -1 + } + } + + if (result.description === -1) { + result.description = stats.findIndex( + (s, i) => i !== result.date && i !== result.amount && i !== result.balance && !s.isNumeric && !s.isDate + ) + if (result.description === -1) { + for (let i = 0; i < colCount; i++) { + if (i !== result.date && i !== result.amount && i !== result.balance) { + result.description = i + break + } + } + } + } + + return result +} + /** * Generic CSV format definition (used for format detection) * Always returns false for detect() since it's a fallback requiring user mapping diff --git a/lib/salary/__tests__/tax-column.test.ts b/lib/salary/__tests__/tax-column.test.ts new file mode 100644 index 00000000..800d6418 --- /dev/null +++ b/lib/salary/__tests__/tax-column.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest' +import { deriveTaxColumn, getTaxColumnOption, TAX_COLUMN_OPTIONS } from '../tax-column' + +describe('deriveTaxColumn', () => { + it('returns column 1 for an employee comfortably under 66', () => { + // Born 1990 → 36 in 2026 + expect(deriveTaxColumn('199003151234', 2026)).toBe(1) + }) + + it('returns column 1 at the exact under-66 boundary (born 1960 for 2026)', () => { + // "född 1960 eller senare" = kolumn 1 för inkomståret 2026 + expect(deriveTaxColumn('196006151234', 2026)).toBe(1) + }) + + it('returns null (manual) for the 66+ group (born 1959 for 2026)', () => { + // Column 2 (pension) vs 3 (working senior) can't be inferred from age. + expect(deriveTaxColumn('195906151234', 2026)).toBeNull() + }) + + it('returns null for a clearly senior employee', () => { + expect(deriveTaxColumn('194001011234', 2026)).toBeNull() + }) + + it('tracks the boundary with the income year', () => { + // Born 1959: under-66 group for 2025 (year - 66 = 1959), 66+ group for 2026. + expect(deriveTaxColumn('195906151234', 2025)).toBe(1) + expect(deriveTaxColumn('195906151234', 2026)).toBeNull() + }) + + it('works from a masked personnummer (YYYYMMDD-XXXX)', () => { + expect(deriveTaxColumn('19900315-XXXX', 2026)).toBe(1) + }) + + it('returns null when there is not enough of a birthdate to decide', () => { + expect(deriveTaxColumn('', 2026)).toBeNull() + expect(deriveTaxColumn('1990', 2026)).toBeNull() + expect(deriveTaxColumn('199003', 2026)).toBeNull() + }) + + it('returns null for an implausible birth year', () => { + expect(deriveTaxColumn('180003151234', 2026)).toBeNull() + // birth year after the income year + expect(deriveTaxColumn('203003151234', 2026)).toBeNull() + }) +}) + +describe('TAX_COLUMN_OPTIONS', () => { + it('covers all six Skatteverket columns in order', () => { + expect(TAX_COLUMN_OPTIONS.map((o) => o.value)).toEqual([1, 2, 3, 4, 5, 6]) + }) + + it('looks up an option by value', () => { + expect(getTaxColumnOption(1)?.label).toContain('under 66') + expect(getTaxColumnOption(3)?.label).toContain('66+') + expect(getTaxColumnOption(99)).toBeUndefined() + }) +}) diff --git a/lib/salary/__tests__/tax-tables.test.ts b/lib/salary/__tests__/tax-tables.test.ts index bebf2244..691611f7 100644 --- a/lib/salary/__tests__/tax-tables.test.ts +++ b/lib/salary/__tests__/tax-tables.test.ts @@ -4,6 +4,7 @@ import { calculateJamkningTax, calculateSidoinkomstTax, fetchTaxTableRates, + fetchKommunTaxRates, clearTaxTableCache, TaxTableUnavailableError, } from '../tax-tables' @@ -145,3 +146,67 @@ describe('fetchTaxTableRates fallback behavior', () => { expect(fetchSpy).toHaveBeenCalledTimes(1) }) }) + +describe('fetchKommunTaxRates', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + function row(kommun: string, rate: string) { + return { kommun, 'summa, exkl. kyrkoavgift': rate } + } + + it('pages through every församling row and dedupes to one entry per kommun', async () => { + // The dataset returns one row per församling. Page 1 is full (so the loop + // continues); page 2 is short (so it stops). Göteborg only appears on page 2 + // — the bug this guards against was a single 500-row page dropping it. + const page1 = Array.from({ length: 500 }, () => row('STOCKHOLM', '30.62')) + const page2 = [ + row('GÖTEBORG', '32.892'), + row('GÖTEBORG', '33.50'), // duplicate församling — must not override the first + row('UPPLANDS VÄSBY', '32.042'), + ] + + const fetchSpy = vi + .fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ resultCount: 503, results: page1 }) } as Response) + .mockResolvedValueOnce({ ok: true, json: async () => ({ resultCount: 503, results: page2 }) } as Response) + globalThis.fetch = fetchSpy + + const result = await fetchKommunTaxRates(2026) + + expect(fetchSpy).toHaveBeenCalledTimes(2) + // Second call must advance the offset past the first page. + expect((fetchSpy.mock.calls[1][0] as string)).toContain('_offset=500') + + const goteborg = result.find((r) => r.kommun === 'Göteborg') + expect(goteborg).toBeDefined() + expect(goteborg!.totalRate).toBe(32.892) + expect(goteborg!.tableNumber).toBe(33) // 32.892 → round → 33 + + // Deduped: Stockholm appears once despite 500 rows. + expect(result.filter((r) => r.kommun === 'Stockholm')).toHaveLength(1) + expect(result.find((r) => r.kommun === 'Stockholm')!.tableNumber).toBe(31) // 30.62 → 31 + }) + + it('normalizes Skatteverket uppercase names to title case', async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ resultCount: 2, results: [row('UPPLANDS VÄSBY', '32.042'), row('MALMÖ', '32.10')] }), + } as Response) + globalThis.fetch = fetchSpy + + const result = await fetchKommunTaxRates(2026) + const names = result.map((r) => r.kommun) + + expect(names).toContain('Upplands Väsby') + expect(names).toContain('Malmö') + }) + + it('throws when the API returns a non-ok status', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 502 } as Response) + await expect(fetchKommunTaxRates(2026)).rejects.toThrow(/502/) + }) +}) diff --git a/lib/salary/tax-column.ts b/lib/salary/tax-column.ts new file mode 100644 index 00000000..90bf7aab --- /dev/null +++ b/lib/salary/tax-column.ts @@ -0,0 +1,57 @@ +import { extractBirthDate } from './personnummer' + +/** + * Skattetabell columns (1-6) per Skatteverket. The numbering matches the + * column order in the imported tax-table data (lib/salary/tax-tables-fallback.ts) + * and the project payroll reference (.claude/skills/swedish-payroll/references/tax-tables.md). + */ +export interface TaxColumnOption { + value: number + /** Short label for the select option. */ + label: string + /** One-line clarification shown under the select / in the option. */ + description: string +} + +export const TAX_COLUMN_OPTIONS: TaxColumnOption[] = [ + { value: 1, label: 'Anställd under 66 år', description: 'Standard — det vanligaste valet' }, + { value: 2, label: 'Pensionär 66+ år', description: 'Pension till den som fyllt 66 år vid årets ingång' }, + { value: 3, label: 'Anställd 66+ år', description: 'Lön med förhöjt jobbskatteavdrag' }, + { value: 4, label: 'Sjuk- eller aktivitetsersättning, under 66 år', description: 'Ersättning från Försäkringskassan' }, + { value: 5, label: 'Kolumn 5 (särskilda fall)', description: 'Varierar per år enligt SKVFS' }, + { value: 6, label: 'Pension före 65 år', description: 'Född 1951 eller senare' }, +] + +export function getTaxColumnOption(value: number): TaxColumnOption | undefined { + return TAX_COLUMN_OPTIONS.find((o) => o.value === value) +} + +/** + * Derive the tax column for a salaried EMPLOYEE from their birth year. + * + * Only the unambiguous, dominant case is auto-derived: an employee who has NOT + * turned 66 by the start of the income year → column 1. Skatteverket draws this + * line by birth year ("född 1960 eller senare" = kolumn 1 för inkomståret 2026), + * so we compare birth year, not exact date. + * + * For 66+ the column is genuinely ambiguous from age alone — column 2 (pension) + * vs column 3 (working senior with förhöjt jobbskatteavdrag) depends on the + * income type, which the system can't infer. In that case this returns null and + * the UI asks the user to pick from the named column list. + * + * @param personnummer Full (YYYYMMDDNNNN) or masked (YYYYMMDD-XXXX) — only the + * leading 8 birthdate digits are used. + * @param year The income/payment year the column applies to. + * @returns 1 for a confidently-under-66 employee, otherwise null. + */ +export function deriveTaxColumn(personnummer: string, year: number): number | null { + const digits = personnummer.replace(/\D/g, '') + if (digits.length < 8) return null + + const { year: birthYear } = extractBirthDate(personnummer) + if (!birthYear || birthYear < 1900 || birthYear > year) return null + + // "fyllt 66 år vid årets ingång" → 66+ group. Born in (year - 66) or later + // means they have not turned 66 by Jan 1 of `year` → column 1. + return birthYear >= year - 66 ? 1 : null +} diff --git a/lib/salary/tax-tables.ts b/lib/salary/tax-tables.ts index a6a2b8d2..4bed9ed4 100644 --- a/lib/salary/tax-tables.ts +++ b/lib/salary/tax-tables.ts @@ -327,34 +327,53 @@ export async function fetchKommunTaxRates(year: number): Promise> { - const params = new URLSearchParams({ - 'år': year.toString(), - '_limit': '500', - }) + // The dataset has one row per församling (~1300/year), not per kommun (~290), + // so a single page would silently drop most municipalities. Page through all + // rows via _offset until the result set is exhausted. + const PAGE_SIZE = 500 + const MAX_ROWS = 5000 // safety cap (~4x the real row count) - const response = await fetch(`${KOMMUN_RATES_API}?${params.toString()}`, { - headers: { 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000), - }) - - if (!response.ok) { - throw new Error(`Kommun rates API returned ${response.status}`) - } - - const data = await response.json() as { - results: Array<{ - 'kommun': string - 'summa, exkl. kyrkoavgift': string - }> - } - - // Deduplicate by kommun (multiple församlingar per kommun) + // Deduplicate by kommun (multiple församlingar per kommun share the same + // kommunal + landstingsskatt, so the first row's rate is representative). const byKommun = new Map() - for (const r of data.results) { - const rate = parseFloat(r['summa, exkl. kyrkoavgift']) - if (!byKommun.has(r.kommun)) { - byKommun.set(r.kommun, rate) + let offset = 0 + + while (offset < MAX_ROWS) { + const params = new URLSearchParams({ + 'år': year.toString(), + '_limit': String(PAGE_SIZE), + '_offset': String(offset), + }) + + const response = await fetch(`${KOMMUN_RATES_API}?${params.toString()}`, { + headers: { 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000), + }) + + if (!response.ok) { + throw new Error(`Kommun rates API returned ${response.status}`) } + + const data = await response.json() as { + resultCount?: number + results: Array<{ + 'kommun': string + 'summa, exkl. kyrkoavgift': string + }> + } + + for (const r of data.results) { + const kommun = normalizeKommunName(r.kommun) + const rate = parseFloat(r['summa, exkl. kyrkoavgift']) + if (kommun && !byKommun.has(kommun)) { + byKommun.set(kommun, rate) + } + } + + offset += data.results.length + // Stop when the page came back short or we've consumed the whole dataset. + if (data.results.length < PAGE_SIZE) break + if (typeof data.resultCount === 'number' && offset >= data.resultCount) break } return Array.from(byKommun.entries()).map(([kommun, rate]) => ({ @@ -365,6 +384,25 @@ export async function fetchKommunTaxRates(year: number): Promise { + if (token.trim() === '' || token === '-') return token + if (lowerWords.has(token)) return token + return token.charAt(0).toUpperCase() + token.slice(1) + }) + .join('') +} + // ── Legacy compatibility (used by calculation-engine.ts) ── /** diff --git a/messages/en.json b/messages/en.json index 37e88c09..81732e98 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2099,6 +2099,12 @@ "vat_label_short": "VAT", "total_label": "Total", "review_and_create": "Review & create", + "save_as_draft": "Save as draft", + "save_as_draft_short": "Draft", + "save_as_draft_tooltip": "Saves without an invoice number — can be deleted later", + "toast_draft_saved_title": "Draft saved", + "toast_draft_saved_description": "The draft is in your invoice list. Open it to review and create the invoice.", + "save_draft_failed_title": "Could not save draft", "mode_invoice": "Invoice", "mode_self_billed": "Self-billing", "viewer_disabled_tooltip": "You only have read-only access to this company", @@ -2201,6 +2207,7 @@ "load_failed_title": "Could not load invoice", "load_failed_description": "The invoice was not found.", "status_draft": "Draft", + "status_unsent": "Not sent", "status_sent": "Sent", "status_paid": "Paid", "status_partially_paid": "Partially paid", @@ -2290,9 +2297,23 @@ "delete_dialog_status_makulerad": "Cancelled", "delete_dialog_desc_with_number_2": ".", "delete_dialog_number_kept": "Invoice number {number} is kept so the number series stays unbroken per ML 17 kap 24§.", - "delete_dialog_desc_no_number": "The draft is marked as cancelled. This cannot be undone.", "delete_dialog_cancel": "Cancel", "delete_dialog_confirm": "Cancel invoice", + "finalize_action": "Review & create", + "finalize_dialog_title": "Create invoice?", + "finalize_dialog_number_label": "Invoice number", + "finalize_dialog_desc": "An invoice number is assigned and the draft becomes an invoice. You can send or cancel it afterwards, but can no longer delete it.", + "finalize_dialog_cancel": "Cancel", + "finalize_dialog_confirm": "Review & create", + "finalized_toast_title": "Invoice created", + "finalized_toast_description": "Invoice {number} has been created.", + "finalize_failed_title": "Could not create the invoice", + "remove_action": "Delete", + "remove_dialog_title": "Delete draft?", + "remove_dialog_desc": "The draft and its rows are permanently deleted. Since no invoice number was assigned, the number series is unaffected. This cannot be undone.", + "remove_dialog_confirm": "Delete", + "removed_toast_title": "Draft deleted", + "removed_toast_description": "The draft has been removed.", "status_update_toast_title": "Updated", "status_update_toast_description": "The invoice is now marked as {status}", "status_update_failed_title": "Status update failed", @@ -3664,6 +3685,7 @@ "badge_delivery_note": "Delivery note", "badge_self_billed": "Self-billing", "status_draft": "Draft", + "status_unsent": "Not sent", "status_sent": "Sent", "status_paid": "Paid", "status_partially_paid": "Partly paid", diff --git a/messages/sv.json b/messages/sv.json index 4a9a0f00..ed74c7c6 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -2099,6 +2099,12 @@ "vat_label_short": "Moms", "total_label": "Totalt", "review_and_create": "Granska & skapa", + "save_as_draft": "Spara som utkast", + "save_as_draft_short": "Utkast", + "save_as_draft_tooltip": "Sparar utan fakturanummer — kan tas bort senare", + "toast_draft_saved_title": "Utkast sparat", + "toast_draft_saved_description": "Utkastet finns i fakturalistan. Öppna det för att granska och skapa fakturan.", + "save_draft_failed_title": "Kunde inte spara utkast", "mode_invoice": "Faktura", "mode_self_billed": "Självfaktura", "viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag", @@ -2201,6 +2207,7 @@ "load_failed_title": "Kunde inte ladda faktura", "load_failed_description": "Fakturan hittades inte.", "status_draft": "Utkast", + "status_unsent": "Ej skickad", "status_sent": "Skickad", "status_paid": "Betald", "status_partially_paid": "Delbetalad", @@ -2290,9 +2297,23 @@ "delete_dialog_status_makulerad": "Makulerad", "delete_dialog_desc_with_number_2": ".", "delete_dialog_number_kept": "Fakturanumret {number} behålls för att hålla nummerserien obruten enligt ML 17 kap 24§.", - "delete_dialog_desc_no_number": "Utkastet markeras som makulerat. Detta kan inte ångras.", "delete_dialog_cancel": "Avbryt", "delete_dialog_confirm": "Makulera", + "finalize_action": "Granska & skapa", + "finalize_dialog_title": "Skapa faktura?", + "finalize_dialog_number_label": "Fakturanummer", + "finalize_dialog_desc": "Ett fakturanummer tilldelas och utkastet blir en faktura. Du kan skicka eller makulera den efteråt, men inte längre ta bort den.", + "finalize_dialog_cancel": "Avbryt", + "finalize_dialog_confirm": "Granska & skapa", + "finalized_toast_title": "Faktura skapad", + "finalized_toast_description": "Faktura {number} har skapats.", + "finalize_failed_title": "Kunde inte skapa fakturan", + "remove_action": "Ta bort", + "remove_dialog_title": "Ta bort utkast?", + "remove_dialog_desc": "Utkastet och dess rader tas bort permanent. Eftersom inget fakturanummer tilldelats påverkas inte nummerserien. Detta kan inte ångras.", + "remove_dialog_confirm": "Ta bort", + "removed_toast_title": "Utkast borttaget", + "removed_toast_description": "Utkastet har tagits bort.", "status_update_toast_title": "Uppdaterad", "status_update_toast_description": "Fakturan är nu markerad som {status}", "status_update_failed_title": "Statusuppdatering misslyckades", @@ -3664,6 +3685,7 @@ "badge_delivery_note": "Följesedel", "badge_self_billed": "Självfaktura", "status_draft": "Utkast", + "status_unsent": "Ej skickad", "status_sent": "Skickad", "status_paid": "Betald", "status_partially_paid": "Delbetalad", diff --git a/types/index.ts b/types/index.ts index 92bba3cd..6857ce0a 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1007,6 +1007,9 @@ export interface CreateInvoiceInput { deduction_personnummer?: string /** Fastighetsbeteckning. Required when any item carries deduction_type === 'rot'. */ deduction_housing_designation?: string + /** Save as an unnumbered draft (no F-number, no invoice.created) until the + * user finalizes via "Granska & skapa". Lets the draft be hard-deleted. */ + save_as_draft?: boolean items: CreateInvoiceItemInput[] }