From 0ca9c25aba11fbdd95353c6d999e3f81c7afde08 Mon Sep 17 00:00:00 2001
From: Mattsson <111893710+mattssonn@users.noreply.github.com>
Date: Fri, 5 Jun 2026 17:26:40 +0200
Subject: [PATCH] Add/user feedback (#679)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(bookkeeping): make blocked fiscal-year creation actionable
When creating a new räkenskapsår is blocked because a prior period is
still open, the "Skapa räkenskapsår" dialog no longer dead-ends on an
English toast. The API now returns the canonical bilingual error envelope
with the blocking periods (id/name/dates) under details, and the dialog
renders a Swedish panel that locks them inline (reversible locked_at) via
the existing /lock endpoint and retries creation.
The guard rule is unchanged and remains BFL-compliant: BFL 6 kap allows
löpande bokföring of the new year in parallel with the prior year's
bokslut, so a lock (not a full close) is sufficient and reversible.
- Add PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS structured error code
- Return envelope + details.blockingPeriods from the 409 (was English string)
- CreatePeriodDialog: inline "lås och skapa" panel + lock-and-retry
- Update route tests for the new envelope shape
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(ui): prevent mouse wheel from mutating number inputs
A focused would change its value on scroll,
silently turning e.g. a 20000 salary into 19998. Blur number inputs
on wheel so the page scrolls instead of editing the value. Applied
at the Input primitive so all number fields are protected.
Co-Authored-By: Claude Opus 4.8 (1M context)
* feat(salary): auto-derive skattetabell and kolumn for employees
Replace the opaque manual "Skattetabell (29-42)" and "Kolumn (1-6)" inputs
on the employee form with a self-deriving flow: the user picks their
folkbokföringskommun from a searchable dropdown and the tax table fills
itself in, while the column derives from the personnummer we already collect.
- Add a searchable municipality picker (MunicipalityCombobox) backed by a
new cached GET /api/salary/tax-tables/kommuner endpoint.
- Wrap the whole "Skatt" card in a self-contained EmployeeTaxCard used by
both the create and edit pages, with InfoTooltips and named column options.
- deriveTaxColumn(): auto-select column 1 for under-66 employees; leave the
ambiguous 66+ case (pension vs working senior) to a clearly-named manual
choice.
- Fix fetchKommunTaxRates() to page through all ~1300 församling rows instead
of a single 500-row page (which silently dropped ~200 kommuner, incl.
Göteborg) and normalize the uppercase names to title case.
Co-Authored-By: Claude Opus 4.8 (1M context)
* fix(import): correct CSV amount-column guess and surface skipped rows
Manual CSV column-mapping auto-guess walked each data row right-to-left
and picked the first numeric cell as the amount, so on the common
...;Belopp;Saldo layout it grabbed the trailing running-balance column.
Extract the guess into a pure, tested suggestColumnMapping(): match
header labels first (belopp/amount -> amount, saldo/balance -> balance),
auto-fill the balance field, and fall back to value heuristics that skip
the balance column and prefer a column carrying negative values.
Also surface stats.skipped_rows + parse warnings in BankFileConfirmStep -
the manual-mapping path skips the preview step that was the only place
they showed, so skipped rows were silently dropped from view.
Add a unit test reproducing the Saldo-as-amount regression.
Co-Authored-By: Claude Opus 4.8 (1M context)
* feat: add "Save as draft" functionality for invoices
- Implemented a new feature to allow users to save invoices as unnumbered drafts without generating an invoice number until finalized.
- Added a `save_as_draft` flag to the CreateInvoiceInput schema to handle draft saving logic.
- Updated the invoice creation API to skip number allocation when saving as a draft.
- Introduced a new endpoint for finalizing drafts, which allocates an invoice number and emits an `invoice.created` event.
- Enhanced the UI to include a "Save as draft" button, with loading states and tooltips.
- Updated tests to cover the new draft saving and finalization logic, including race conditions for concurrent modifications.
- Added relevant error handling for draft finalization and deletion scenarios.
* feat(employee): add employment start and end date fields to employee forms
* feat: enhance invoice and salary run handling with improved validation and event logging
---------
Co-authored-by: Claude Opus 4.8 (1M context)
---
app/(dashboard)/invoices/[id]/page.tsx | 286 +++++++++++++-----
app/(dashboard)/invoices/new/page.tsx | 120 ++++++--
app/(dashboard)/invoices/page.tsx | 20 +-
.../salary/employees/[id]/page.tsx | 131 +++-----
app/(dashboard)/salary/employees/new/page.tsx | 102 ++-----
app/(dashboard)/salary/runs/[id]/page.tsx | 26 +-
.../fiscal-periods/__tests__/route.test.ts | 28 +-
app/api/bookkeeping/fiscal-periods/route.ts | 27 +-
.../[id]/delete/__tests__/route.test.ts | 42 +++
app/api/company/[id]/delete/route.ts | 24 +-
app/api/invoices/[id]/__tests__/route.test.ts | 41 ++-
.../[id]/finalize/__tests__/route.test.ts | 172 +++++++++++
app/api/invoices/[id]/finalize/route.ts | 93 ++++++
app/api/invoices/[id]/route.ts | 61 +++-
app/api/invoices/__tests__/route.test.ts | 50 +++
app/api/invoices/route.ts | 20 +-
.../salary/runs/[id]/__tests__/route.test.ts | 120 ++++++++
app/api/salary/runs/[id]/route.ts | 249 ++++++++-------
.../kommuner/__tests__/route.test.ts | 91 ++++++
app/api/salary/tax-tables/kommuner/route.ts | 44 +++
components/bookkeeping/AccountCombobox.tsx | 6 +-
components/bookkeeping/CreatePeriodDialog.tsx | 116 ++++++-
.../general/InvoiceInboxWorkspace.tsx | 27 +-
.../import/BankFileColumnMappingStep.tsx | 31 +-
components/import/BankFileConfirmStep.tsx | 39 ++-
components/salary/EmployeeTaxCard.tsx | 256 ++++++++++++++++
components/salary/MunicipalityCombobox.tsx | 201 ++++++++++++
components/ui/input.tsx | 16 +-
lib/api/schemas.ts | 5 +
lib/errors/structured-errors.ts | 29 ++
lib/events/handlers/event-log-handler.ts | 7 +-
lib/events/types.ts | 5 +
.../__tests__/suggest-column-mapping.test.ts | 106 +++++++
lib/import/bank-file/formats/generic-csv.ts | 147 +++++++++
lib/salary/__tests__/tax-column.test.ts | 57 ++++
lib/salary/__tests__/tax-tables.test.ts | 65 ++++
lib/salary/tax-column.ts | 57 ++++
lib/salary/tax-tables.ts | 88 ++++--
messages/en.json | 24 +-
messages/sv.json | 24 +-
types/index.ts | 3 +
41 files changed, 2572 insertions(+), 484 deletions(-)
create mode 100644 app/api/invoices/[id]/finalize/__tests__/route.test.ts
create mode 100644 app/api/invoices/[id]/finalize/route.ts
create mode 100644 app/api/salary/runs/[id]/__tests__/route.test.ts
create mode 100644 app/api/salary/tax-tables/kommuner/__tests__/route.test.ts
create mode 100644 app/api/salary/tax-tables/kommuner/route.ts
create mode 100644 components/salary/EmployeeTaxCard.tsx
create mode 100644 components/salary/MunicipalityCombobox.tsx
create mode 100644 lib/import/bank-file/__tests__/suggest-column-mapping.test.ts
create mode 100644 lib/salary/__tests__/tax-column.test.ts
create mode 100644 lib/salary/tax-column.ts
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 ?? '—')}
- {/* 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. */}
+
+ {/* Finalize confirmation — "Granska & skapa". Allocates the F-number and
+ turns the unnumbered draft into a real, issued invoice. */}
+
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 (
+
+ {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})
+
+ ))}
+
+
+
+ )}
- onOpenChange(false)} disabled={isSubmitting}>
+ onOpenChange(false)} disabled={busy}>
Avbryt
-
- {isSubmitting && }
- Skapa
-
+ {isBlocked ? (
+
+ {busy && }
+ {blockingPeriods.length > 1 ? 'Lås åren och skapa' : 'Lås året och skapa'}
+
+ ) : (
+
+ {isSubmitting && }
+ Skapa
+
+ )}
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. */}
Matcha mot transaktion
+
+
+ Skapa leverantörsfaktura
+
+
- Bokför manuellt
+ Bokför som verifikat
-
-
- Skapa leverantörsfaktura
-
-
>
)}
{
if (dateCol !== -1 || descCol !== -1 || amountCol !== -1) return
- const sample = dataRows[0]
- if (!sample || sample.length === 0) return
+ if (dataRows.length === 0) return
- const dateIdx = sample.findIndex((cell) =>
- DATE_PATTERNS.some((re) => re.test(cell.trim()))
- )
- const amountIdx = sample
- .map((cell, i) => ({ i, cell: cell.trim().replace(/\s/g, '') }))
- .reverse()
- .find(({ cell, i }) => AMOUNT_RE.test(cell) && i !== dateIdx)?.i ?? -1
- const descIdx = sample.findIndex((_, i) => i !== dateIdx && i !== amountIdx)
-
- if (dateIdx >= 0) setDateCol(dateIdx)
- if (descIdx >= 0) setDescCol(descIdx)
- if (amountIdx >= 0) setAmountCol(amountIdx)
+ const suggestion = suggestColumnMapping(hasHeader ? columnHeaders : null, dataRows)
+ if (suggestion.date >= 0) setDateCol(suggestion.date)
+ if (suggestion.description >= 0) setDescCol(suggestion.description)
+ if (suggestion.amount >= 0) setAmountCol(suggestion.amount)
+ if (suggestion.balance >= 0) setBalanceCol(suggestion.balance)
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [dataRows])
+ }, [dataRows, hasHeader, columnHeaders])
const isValid = dateCol >= 0 && descCol >= 0 && amountCol >= 0
diff --git a/components/import/BankFileConfirmStep.tsx b/components/import/BankFileConfirmStep.tsx
index e8466e76..8b3559ca 100644
--- a/components/import/BankFileConfirmStep.tsx
+++ b/components/import/BankFileConfirmStep.tsx
@@ -14,6 +14,7 @@ import {
Link2,
Calendar,
Landmark,
+ AlertTriangle,
} from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
@@ -37,8 +38,9 @@ export default function BankFileConfirmStep({
onBack,
isLoading,
}: BankFileConfirmStepProps) {
- const { transactions, stats, date_from, date_to } = parseResult
+ const { transactions, stats, date_from, date_to, issues } = parseResult
const refsCount = transactions.filter((t) => t.reference).length
+ const warnings = issues.filter((i) => i.severity === 'warning')
const [bankAccounts, setBankAccounts] = useState([])
const [selectedAccount, setSelectedAccount] = useState('1930')
@@ -102,6 +104,11 @@ export default function BankFileConfirmStep({
Transaktioner
{stats.parsed_rows}
+ {stats.skipped_rows > 0 && (
+
+ {stats.skipped_rows} rader hoppades över
+
+ )}
@@ -172,6 +179,36 @@ export default function BankFileConfirmStep({
+ {/* Skipped rows — surfaced here because the manual-mapping path skips the
+ preview step where these warnings would otherwise be shown. */}
+ {warnings.length > 0 && (
+
+
+
+
+ {warnings.length} {warnings.length === 1 ? 'rad' : 'rader'} hoppades över
+
+
+ Dessa rader kunde inte läsas och importeras inte. Kontrollera att inga transaktioner saknas.
+
+
+
+
+ {warnings.slice(0, 10).map((issue, i) => (
+
+ Rad {issue.row}: {issue.message}
+
+ ))}
+ {warnings.length > 10 && (
+
+ …och {warnings.length - 10} till
+
+ )}
+
+
+
+ )}
+
{/* Actions */}
diff --git a/components/salary/EmployeeTaxCard.tsx b/components/salary/EmployeeTaxCard.tsx
new file mode 100644
index 00000000..a9c73ba6
--- /dev/null
+++ b/components/salary/EmployeeTaxCard.tsx
@@ -0,0 +1,256 @@
+'use client'
+
+import { useState, useEffect, useMemo, useRef } from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { InfoTooltip } from '@/components/ui/info-tooltip'
+import MunicipalityCombobox from './MunicipalityCombobox'
+import { TAX_COLUMN_OPTIONS, deriveTaxColumn } from '@/lib/salary/tax-column'
+
+export interface EmployeeTaxValue {
+ f_skatt_status: string
+ is_sidoinkomst: boolean
+ tax_table_number: number | null
+ tax_column: number
+ tax_municipality: string
+}
+
+interface EmployeeTaxCardProps {
+ /** Live personnummer (full or masked) — drives the column suggestion. */
+ personnummer: string
+ initial?: Partial
+ /** Income year the table/column applies to. Defaults to the current year. */
+ year?: number
+ disabled?: boolean
+ onChange: (value: EmployeeTaxValue) => void
+}
+
+function RequiredMark() {
+ return *
+}
+
+/**
+ * The "Skatt" card on the employee form. Instead of asking the user to look up
+ * an opaque skattetabell (29-42) and kolumn (1-6), it derives both from data we
+ * already have: the folkbokföringskommun fills the tax table, and the
+ * personnummer fills the column. Manual overrides remain for edge cases.
+ */
+export default function EmployeeTaxCard({
+ personnummer,
+ initial,
+ year,
+ disabled,
+ onChange,
+}: EmployeeTaxCardProps) {
+ const incomeYear = year ?? new Date().getFullYear()
+
+ const [fSkatt, setFSkatt] = useState(initial?.f_skatt_status ?? 'a_skatt')
+ const [sido, setSido] = useState(initial?.is_sidoinkomst ?? false)
+ const [municipality, setMunicipality] = useState(initial?.tax_municipality ?? '')
+ const [tableNumber, setTableNumber] = useState(initial?.tax_table_number ?? null)
+ const [rate, setRate] = useState(null)
+ const [tableManual, setTableManual] = useState(false)
+ const [column, setColumn] = useState(initial?.tax_column ?? 1)
+ // Editing an existing employee: respect their saved column. New employee:
+ // let the personnummer drive it until the user picks one.
+ const [columnTouched, setColumnTouched] = useState(initial?.tax_column != null)
+
+ const requiresTable = fSkatt === 'a_skatt' && !sido
+
+ const derivedColumn = useMemo(
+ () => deriveTaxColumn(personnummer, incomeYear),
+ [personnummer, incomeYear]
+ )
+ const isSenior = personnummer.replace(/\D/g, '').length >= 8 && derivedColumn === null
+
+ // The effective column is the user's explicit choice once they've made one,
+ // otherwise the value suggested from the personnummer (falling back to 1).
+ // Derived in render — no setState-in-effect needed.
+ const effectiveColumn = columnTouched ? column : (derivedColumn ?? 1)
+
+ // Report the current value up. onChange via ref so an unstable parent callback
+ // doesn't retrigger the effect (deps are the primitive values only).
+ const onChangeRef = useRef(onChange)
+ useEffect(() => {
+ onChangeRef.current = onChange
+ })
+ useEffect(() => {
+ onChangeRef.current({
+ f_skatt_status: fSkatt,
+ is_sidoinkomst: sido,
+ tax_table_number: requiresTable ? tableNumber : null,
+ tax_column: effectiveColumn,
+ tax_municipality: municipality.trim(),
+ })
+ }, [fSkatt, sido, tableNumber, effectiveColumn, municipality, requiresTable])
+
+ return (
+
+
+ Skatt
+
+
+
+
+
+
+
+
+
+
+
+
+ {requiresTable ? (
+ <>
+
+
+ {
+ setMunicipality(value)
+ // Clearing the field must clear the derived table/rate too —
+ // otherwise we'd report an empty kommun alongside a stale
+ // table number (an inconsistent pair). Manual entry keeps its
+ // own value.
+ if (!value && !tableManual) {
+ setTableNumber(null)
+ setRate(null)
+ }
+ }}
+ onSelect={(kommun, table, totalRate) => {
+ setMunicipality(kommun)
+ setRate(totalRate)
+ if (!tableManual) setTableNumber(table)
+ }}
+ />
+
+ 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 (
+
+ 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[]
}