Add/user feedback (#679)

* 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) <noreply@anthropic.com>

* fix(ui): prevent mouse wheel from mutating number inputs

A focused <input type="number"> 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-05 17:26:40 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent bc61862e76
commit 0ca9c25aba
41 changed files with 2572 additions and 484 deletions
+214 -72
View File
@@ -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<string | null>(null)
const [oreRounding, setOreRounding] = useState<boolean>(true)
const [vatRegistered, setVatRegistered] = useState<boolean>(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
</Button>
<div>
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<h1 className={cn('font-display text-2xl sm:text-3xl font-medium tracking-tight', !invoice.invoice_number && !isSelfBilled && 'italic text-muted-foreground')}>{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : invoiceNumberDisplay(invoice.invoice_number)}</h1>
<h1 className={cn('font-display text-2xl sm:text-3xl font-medium tracking-tight', !invoice.invoice_number && !isSelfBilled && 'italic text-muted-foreground')}>{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : (invoice.invoice_number ?? '—')}</h1>
{isProforma && (
<Badge variant="secondary" className="bg-primary/10 text-primary">{t('badge_proforma')}</Badge>
)}
@@ -450,8 +531,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{isSelfBilled && (
<Badge variant="outline">{t('badge_self_billed')}</Badge>
)}
<Badge variant={statusVariant as 'default' | 'secondary' | 'destructive'}>
{statusLabel(invoice.status)}
<Badge variant={displayStatusVariant as 'default' | 'secondary' | 'destructive' | 'outline'}>
{displayStatusLabel}
</Badge>
</div>
<p className="text-muted-foreground">
@@ -479,7 +560,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{t('convert_to_invoice')}
</Button>
)}
{invoice.status === 'draft' && !isDeliveryNote && (
{isUnnumberedDraft && (
<Button
onClick={openFinalizeDialog}
disabled={isFinalizing || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <FileText className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('finalize_action')}
</Button>
)}
{invoice.status === 'draft' && !isDeliveryNote && invoice.invoice_number && (
customerHasEmail ? (
<Button
onClick={() => openSendDialog('email')}
@@ -716,7 +807,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<CardContent className="space-y-4">
<div className="flex justify-between">
<span className="text-muted-foreground">{isSelfBilled ? t('external_number_label') : t('invoice_number_label')}</span>
<span className={cn('font-medium', !invoice.invoice_number && !isSelfBilled && 'italic text-muted-foreground')}>{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : invoiceNumberDisplay(invoice.invoice_number)}</span>
<span className={cn('font-medium', !invoice.invoice_number && !isSelfBilled && 'italic text-muted-foreground')}>{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : (invoice.invoice_number ?? '—')}</span>
</div>
{isSelfBilled && (invoice as Invoice).self_billing_agreement_ref && (
<div className="flex justify-between">
@@ -1087,60 +1178,84 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</>
)}
{!isProforma && invoice.status === 'draft' && (
<>
{!isDeliveryNote && customerHasEmail ? (
<>
<Button
className="w-full"
onClick={() => openSendDialog('email')}
>
<Mail className="mr-2 h-4 w-4" />
{t('send_via_email')}
</Button>
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={() => openSendDialog('manual')}
>
<Send className="mr-2 h-4 w-4" />
{t('mark_sent_manually')}
</Button>
<p className="text-[11px] text-muted-foreground/60 px-1 -mt-1">
{t('send_manual_hint_with_email')}
</p>
</>
) : (
<>
{!isDeliveryNote && (
<div className="flex items-start gap-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg mb-2 dark:bg-yellow-950/30 dark:border-yellow-800">
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-700 dark:text-yellow-400">
{t('no_customer_email_warning')}
</p>
</div>
)}
<Button
className="w-full"
onClick={() => openSendDialog('manual')}
>
<Send className="mr-2 h-4 w-4" />
{t('mark_sent_manually')}
</Button>
<p className="text-[11px] text-muted-foreground/60 px-1 -mt-1">
{t('send_manual_hint_no_email')}
</p>
</>
)}
<Button
variant="outline"
className="w-full text-destructive hover:text-destructive"
onClick={() => setShowDeleteDialog(true)}
disabled={isDeleting}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('delete_draft')}
</Button>
</>
isUnnumberedDraft ? (
<>
{/* Unnumbered draft (saved via "Spara som utkast"): review
and create it, or remove it without a trace. */}
<Button
className="w-full"
onClick={openFinalizeDialog}
disabled={isFinalizing}
>
<FileText className="mr-2 h-4 w-4" />
{t('finalize_action')}
</Button>
<Button
variant="outline"
className="w-full text-destructive hover:text-destructive"
onClick={() => setShowDeleteDialog(true)}
disabled={isDeleting}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('remove_action')}
</Button>
</>
) : (
<>
{!isDeliveryNote && customerHasEmail ? (
<>
<Button
className="w-full"
onClick={() => openSendDialog('email')}
>
<Mail className="mr-2 h-4 w-4" />
{t('send_via_email')}
</Button>
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={() => openSendDialog('manual')}
>
<Send className="mr-2 h-4 w-4" />
{t('mark_sent_manually')}
</Button>
<p className="text-[11px] text-muted-foreground/60 px-1 -mt-1">
{t('send_manual_hint_with_email')}
</p>
</>
) : (
<>
{!isDeliveryNote && (
<div className="flex items-start gap-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg mb-2 dark:bg-yellow-950/30 dark:border-yellow-800">
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-700 dark:text-yellow-400">
{t('no_customer_email_warning')}
</p>
</div>
)}
<Button
className="w-full"
onClick={() => openSendDialog('manual')}
>
<Send className="mr-2 h-4 w-4" />
{t('mark_sent_manually')}
</Button>
<p className="text-[11px] text-muted-foreground/60 px-1 -mt-1">
{t('send_manual_hint_no_email')}
</p>
</>
)}
<Button
variant="outline"
className="w-full text-destructive hover:text-destructive"
onClick={() => setShowDeleteDialog(true)}
disabled={isDeleting}
>
<Trash2 className="mr-2 h-4 w-4" />
{t('delete_draft')}
</Button>
</>
)
)}
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && (
<>
@@ -1174,12 +1289,13 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</div>
</div>
{/* 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. */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('delete_dialog_title')}</DialogTitle>
<DialogTitle>{invoice.invoice_number ? t('delete_dialog_title') : t('remove_dialog_title')}</DialogTitle>
<DialogDescription>
{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')}
</>
)}
</DialogDescription>
@@ -1203,7 +1319,33 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</Button>
<Button variant="destructive" onClick={deleteInvoice} disabled={isDeleting}>
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('delete_dialog_confirm')}
{invoice.invoice_number ? t('delete_dialog_confirm') : t('remove_dialog_confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Finalize confirmation — "Granska & skapa". Allocates the F-number and
turns the unnumbered draft into a real, issued invoice. */}
<Dialog open={showFinalizeDialog} onOpenChange={setShowFinalizeDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('finalize_dialog_title')}</DialogTitle>
<DialogDescription>{t('finalize_dialog_desc')}</DialogDescription>
</DialogHeader>
{nextNumberPreview && (
<div className="flex items-center justify-between rounded-lg border border-border bg-secondary/40 px-4 py-3">
<span className="text-sm text-muted-foreground">{t('finalize_dialog_number_label')}</span>
<span className="text-base font-medium tabular-nums">{nextNumberPreview}</span>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setShowFinalizeDialog(false)} disabled={isFinalizing}>
{t('finalize_dialog_cancel')}
</Button>
<Button onClick={finalizeInvoice} disabled={isFinalizing}>
{isFinalizing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('finalize_dialog_confirm')}
</Button>
</DialogFooter>
</DialogContent>
+102 -18
View File
@@ -102,6 +102,7 @@ export default function NewInvoicePage() {
const [customers, setCustomers] = useState<Customer[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSavingDraft, setIsSavingDraft] = useState(false)
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null)
const [showReview, setShowReview] = useState(false)
const [pendingData, setPendingData] = useState<FormData | null>(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() {
</Card>
{/* Actions — desktop/tablet only */}
<Button
type="submit"
className="w-full hidden md:block"
size="lg"
disabled={isSubmitting || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
{isSelfBilled ? ts('register') : t('review_and_create')}
</Button>
<div className="hidden md:flex md:flex-col md:gap-2">
<Button
type="submit"
className="w-full"
size="lg"
disabled={isSubmitting || isSavingDraft || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
{isSelfBilled ? ts('register') : t('review_and_create')}
</Button>
{!isSelfBilled && watchDocumentType === 'invoice' && (
<Button
type="button"
variant="outline"
className="w-full"
size="lg"
disabled={isSubmitting || isSavingDraft || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : t('save_as_draft_tooltip')}
onClick={handleSubmit(saveDraftData)}
>
{isSavingDraft ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t('save_as_draft')}
</Button>
)}
</div>
</div>
</div>
@@ -1329,14 +1401,26 @@ export default function NewInvoicePage() {
{formatCurrency(hasAnyDeduction ? toPay : total, watchCurrency)}
</p>
</div>
<Button
type="submit"
disabled={isSubmitting || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
{isSelfBilled ? ts('register') : t('review_and_create')}
</Button>
<div className="flex items-center gap-2">
{!isSelfBilled && watchDocumentType === 'invoice' && (
<Button
type="button"
variant="outline"
disabled={isSubmitting || isSavingDraft || !canWrite}
onClick={handleSubmit(saveDraftData)}
>
{isSavingDraft ? <Loader2 className="h-4 w-4 animate-spin" /> : t('save_as_draft_short')}
</Button>
)}
<Button
type="submit"
disabled={isSubmitting || isSavingDraft || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
{isSelfBilled ? ts('register') : t('review_and_create')}
</Button>
</div>
</div>
</div>
</form>
+16 -4
View File
@@ -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() {
}
>
<DataListPrimary className={cn(!invoice.invoice_number && !invoice.external_invoice_number && 'italic text-muted-foreground')}>
{invoice.is_self_billed ? invoiceDisplayNumber(invoice) : invoiceNumberDisplay(invoice.invoice_number)}{' '}
{invoice.is_self_billed ? invoiceDisplayNumber(invoice) : (invoice.invoice_number ?? '—')}{' '}
<span className="font-normal text-muted-foreground">
· {(invoice.customer as { name: string })?.name}
</span>
@@ -316,10 +328,10 @@ export default function InvoicesPage() {
<span className="tabular-nums">{formatDate(invoice.invoice_date)}</span>
<DataListMetaSeparator />
<Badge
variant={status.variant as 'default' | 'secondary' | 'destructive'}
variant={statusVariant as 'default' | 'secondary' | 'destructive' | 'outline'}
className="h-4 px-1.5 py-0 text-[10px]"
>
{t(status.labelKey)}
{t(statusLabelKey)}
</Badge>
{isCreditNote && (
<>
+38 -93
View File
@@ -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<string, string> = {
employee: 'Anställd',
@@ -22,13 +23,6 @@ const EMPLOYMENT_LABELS: Record<string, string> = {
board_member: 'Styrelseledamot',
}
const F_SKATT_LABELS: Record<string, string> = {
a_skatt: 'A-skatt',
f_skatt: 'F-skatt',
fa_skatt: 'FA-skatt',
not_verified: 'Ej verifierad',
}
function RequiredMark() {
return <span className="text-destructive ml-0.5">*</span>
}
@@ -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<EmployeeTaxValue | null>(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
<Input id="employment_degree" name="employment_degree" type="number" defaultValue={employee.employment_degree} min="1" max="100" disabled={!canWrite} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="employment_start">Anställningsdatum<RequiredMark /></Label>
<Input id="employment_start" name="employment_start" type="date" defaultValue={employee.employment_start || ''} required disabled={!canWrite} />
<p className="text-xs text-muted-foreground">Lönen proportioneras automatiskt om anställningen börjar eller slutar mitt i en löneperiod.</p>
</div>
<div className="space-y-2">
<Label htmlFor="employment_end">Slutdatum</Label>
<Input id="employment_end" name="employment_end" type="date" defaultValue={employee.employment_end || ''} disabled={!canWrite} />
<p className="text-xs text-muted-foreground">Lämna tomt för pågående anställning.</p>
</div>
</div>
</CardContent>
</Card>
@@ -286,81 +289,23 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
</Card>
{/* Tax */}
<Card>
<CardHeader>
<CardTitle className="text-base">Skatt</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="f_skatt_status">Skatteform</Label>
<Select value={fSkattStatus} onValueChange={setFSkattStatus} disabled={!canWrite}>
<SelectTrigger id="f_skatt_status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="a_skatt">A-skatt</SelectItem>
<SelectItem value="f_skatt">F-skatt</SelectItem>
<SelectItem value="fa_skatt">FA-skatt</SelectItem>
<SelectItem value="not_verified">Ej verifierad</SelectItem>
</SelectContent>
</Select>
{employee.f_skatt_verified_at && (
<p className="text-xs text-muted-foreground">
Verifierad: {new Date(employee.f_skatt_verified_at).toLocaleDateString('sv-SE')}
</p>
)}
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isSidoinkomst}
onChange={(e) => setIsSidoinkomst(e.target.checked)}
disabled={!canWrite}
className="rounded border-border"
/>
Sidoinkomst (30% skatteavdrag)
</label>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="tax_table_number">
Skattetabell (29-42){requiresTaxTable && <RequiredMark />}
</Label>
<Input
id="tax_table_number"
name="tax_table_number"
type="number"
min="29"
max="42"
defaultValue={employee.tax_table_number || ''}
required={requiresTaxTable}
disabled={!canWrite}
/>
<p className="text-xs text-muted-foreground">Baseras på folkbokföringskommun</p>
</div>
<div className="space-y-2">
<Label htmlFor="tax_column">Kolumn (1-6)</Label>
<Input id="tax_column" name="tax_column" type="number" defaultValue={employee.tax_column} min="1" max="6" disabled={!canWrite} />
<p className="text-xs text-muted-foreground">1 = standard under 66 år</p>
</div>
<div className="space-y-2">
<Label htmlFor="tax_municipality">
Folkbokföringskommun{requiresTaxTable && <RequiredMark />}
</Label>
<Input
id="tax_municipality"
name="tax_municipality"
defaultValue={employee.tax_municipality || ''}
required={requiresTaxTable}
disabled={!canWrite}
/>
</div>
</div>
</CardContent>
</Card>
<EmployeeTaxCard
personnummer={employee.personnummer || ''}
disabled={!canWrite}
onChange={setTax}
initial={{
f_skatt_status: employee.f_skatt_status || 'a_skatt',
is_sidoinkomst: employee.is_sidoinkomst || false,
tax_table_number: employee.tax_table_number ?? null,
tax_column: employee.tax_column ?? 1,
tax_municipality: employee.tax_municipality || '',
}}
/>
{employee.f_skatt_verified_at && (
<p className="-mt-2 text-xs text-muted-foreground">
F-skatt verifierad: {new Date(employee.f_skatt_verified_at).toLocaleDateString('sv-SE')}
</p>
)}
{/* Vacation */}
<Card>
+30 -72
View File
@@ -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 <span className="text-destructive ml-0.5">*</span>
@@ -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<EmployeeTaxValue>({
f_skatt_status: 'a_skatt',
is_sidoinkomst: false,
tax_table_number: null,
tax_column: 1,
tax_municipality: '',
})
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
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() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="personnummer">Personnummer (12 siffror)<RequiredMark /></Label>
<Input id="personnummer" name="personnummer" placeholder="ÅÅÅÅMMDDNNNN" required maxLength={13} />
<Input
id="personnummer"
name="personnummer"
placeholder="ÅÅÅÅMMDDNNNN"
required
maxLength={13}
value={personnummer}
onChange={(e) => setPersonnummer(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Krypteras vid lagring</p>
</div>
<div className="space-y-2">
@@ -172,6 +186,10 @@ export default function NewEmployeePage() {
<Label htmlFor="employment_start">Anställningsdatum<RequiredMark /></Label>
<Input id="employment_start" name="employment_start" type="date" required />
</div>
<div className="space-y-2">
<Label htmlFor="employment_end">Slutdatum</Label>
<Input id="employment_end" name="employment_end" type="date" />
</div>
<div className="space-y-2">
<Label htmlFor="employment_degree">Sysselsättningsgrad (%)</Label>
<Input id="employment_degree" name="employment_degree" type="number" defaultValue="100" min="1" max="100" />
@@ -215,67 +233,7 @@ export default function NewEmployeePage() {
</Card>
{/* Tax */}
<Card>
<CardHeader>
<CardTitle className="text-base">Skatt</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="f_skatt_status">Skatteform</Label>
<Select value={fSkattStatus} onValueChange={setFSkattStatus}>
<SelectTrigger id="f_skatt_status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="a_skatt">A-skatt</SelectItem>
<SelectItem value="f_skatt">F-skatt</SelectItem>
<SelectItem value="fa_skatt">FA-skatt</SelectItem>
<SelectItem value="not_verified">Ej verifierad</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isSidoinkomst}
onChange={(e) => setIsSidoinkomst(e.target.checked)}
className="rounded border-border"
/>
Sidoinkomst (30% skatteavdrag)
</label>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="tax_table_number">
Skattetabell (29-42){requiresTaxTable && <RequiredMark />}
</Label>
<Input
id="tax_table_number"
name="tax_table_number"
type="number"
min="29"
max="42"
required={requiresTaxTable}
/>
<p className="text-xs text-muted-foreground">Baseras på folkbokföringskommun</p>
</div>
<div className="space-y-2">
<Label htmlFor="tax_column">Kolumn (1-6)</Label>
<Input id="tax_column" name="tax_column" type="number" defaultValue="1" min="1" max="6" />
<p className="text-xs text-muted-foreground">1 = standard under 66 år</p>
</div>
<div className="space-y-2">
<Label htmlFor="tax_municipality">
Folkbokföringskommun{requiresTaxTable && <RequiredMark />}
</Label>
<Input id="tax_municipality" name="tax_municipality" required={requiresTaxTable} />
</div>
</div>
</CardContent>
</Card>
<EmployeeTaxCard personnummer={personnummer} onChange={setTax} />
{/* Vacation */}
<Card>
+25 -1
View File
@@ -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:
<div className="flex flex-wrap gap-3 justify-end">
{run.status === 'draft' && (
<>
<Button variant="outline" onClick={handleDelete} disabled={!!actionLoading} className="text-destructive hover:text-destructive mr-auto">
{actionLoading === 'delete' ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Trash2 className="mr-2 h-4 w-4" />}
Radera utkast
</Button>
<Button variant="outline" onClick={handleCalculate} disabled={!!actionLoading || employees.length === 0}>
{actionLoading === 'calculate' ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Calculator className="mr-2 h-4 w-4" />}
Beräkna
@@ -31,7 +31,7 @@ type Period = { id: string; period_start: string; period_end: string; is_closed:
function buildMockSupabase(options: {
user?: { id: string } | null
allPeriods?: Period[]
openPeriods?: Array<{ name: string; period_start: string; period_end: string }>
openPeriods?: Array<{ id?: string; name: string; period_start: string; period_end: string }>
bookkeepingLockedThrough?: string | null
overlapping?: Array<{ id: string; name: string }>
insertResult?: { data: unknown; error: unknown }
@@ -78,7 +78,7 @@ function buildMockSupabase(options: {
// For update (call 5): .update(...).eq(...).eq(...)
chainable.select = vi.fn().mockImplementation((sel: string) => {
if (sel.includes('name') && sel.includes('period_start') && !sel.includes('id')) {
if (sel.includes('name') && sel.includes('period_start')) {
// openPeriods query: .eq(company_id).eq(is_closed=false).is(locked_at, null).order(...)
return {
eq: vi.fn().mockReturnValue({
@@ -178,17 +178,22 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
expect(body.error).toMatch(/must start on 2026-01-01/)
})
it('rejects forward period when an unlocked open period exists and lists its name', async () => {
it('rejects forward period when an unlocked open period exists and returns it as a blocking period', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }],
openPeriods: [{ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }],
openPeriods: [{ id: 'p1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }],
})
const req = createMockRequest({ name: 'FY 2026', period_start: '2026-01-01', period_end: '2026-12-31' })
const res = await POST(req)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error).toMatch(/unlocked period/)
expect(body.error).toMatch(/FY 2025 \(2025-01-01 – 2025-12-31\)/)
// Canonical envelope with a machine code + the blocking periods so the
// dialog can offer to lock them inline.
expect(body.error.code).toBe('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS')
expect(body.error.message).toMatch(/låsa föregående räkenskapsår/)
expect(body.error.details.blockingPeriods).toEqual([
{ id: 'p1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' },
])
})
// Regression: BFL 6 kap allows löpande bokföring of the new year in parallel
@@ -232,7 +237,7 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
it('rejects forward period creation when company-wide lock only partially covers prior period', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false }],
openPeriods: [{ name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' }],
openPeriods: [{ id: 'p1', name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' }],
bookkeepingLockedThrough: '2024-06-30',
overlapping: [],
})
@@ -240,7 +245,10 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
const res = await POST(req)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error).toMatch(/FY 2024/)
expect(body.error.code).toBe('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS')
expect(body.error.details.blockingPeriods).toEqual([
{ id: 'p1', name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' },
])
})
it('allows backward period creation', async () => {
@@ -337,7 +345,7 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
const callNum = fpCallIndex
return {
select: vi.fn().mockImplementation((sel: string) => {
if (sel.includes('name') && sel.includes('period_start') && !sel.includes('id')) {
if (sel.includes('name') && sel.includes('period_start')) {
return {
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
@@ -413,7 +421,7 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
const callNum = fpCallIndex
return {
select: vi.fn().mockImplementation((sel: string) => {
if (sel.includes('name') && sel.includes('period_start') && !sel.includes('id')) {
if (sel.includes('name') && sel.includes('period_start')) {
return {
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
+17 -10
View File
@@ -5,6 +5,10 @@ import { validateBody } from '@/lib/api/validate'
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createLogger } from '@/lib/logger'
const log = createLogger('api/bookkeeping/fiscal-periods')
export async function GET() {
const supabase = await createClient()
@@ -109,7 +113,7 @@ export async function POST(request: Request) {
// must not block creating the next räkenskapsår.
const { data: openPeriods } = await supabase
.from('fiscal_periods')
.select('name, period_start, period_end')
.select('id, name, period_start, period_end')
.eq('company_id', companyId)
.eq('is_closed', false)
.is('locked_at', null)
@@ -127,15 +131,18 @@ export async function POST(request: Request) {
)
if (trulyOpen.length > 0) {
const names = trulyOpen
.map((p) => `${p.name} (${p.period_start} – ${p.period_end})`)
.join(', ')
return NextResponse.json(
{
error: `Cannot create a new period while an unlocked period exists. Lock the following first: ${names}`,
},
{ status: 409 }
)
// Hand the blocking periods (id + name + dates) to the client so the
// "Skapa räkenskapsår" dialog can offer to lock them inline and retry,
// instead of dead-ending the user on a message they can't act on.
const blockingPeriods = trulyOpen.map((p) => ({
id: p.id,
name: p.name,
period_start: p.period_start,
period_end: p.period_end,
}))
return errorResponseFromCode('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS', log, {
details: { blockingPeriods },
})
}
}
}
@@ -114,6 +114,48 @@ describe('POST /api/company/[id]/delete', () => {
expect(body).toHaveProperty('error')
})
it('accepts the settings display name when companies.name is stale', async () => {
// Regression: the UI shows company_settings.company_name (the dialog,
// label and the enable-gate all use it), but companies.name can be stale.
// The server must accept the displayed name, not just the raw column.
mockAuth('user-1')
mockService({
companies: [{ data: { id: 'c1', name: 'Gammalt namn', archived_at: null }, error: null }],
company_members: [{ data: { role: 'owner' }, error: null }],
company_settings: [{ data: { company_name: 'Nytt namn' }, error: null }],
})
const req = createMockRequest('/api/company/c1/delete', {
method: 'POST',
body: { confirm_name: 'Nytt namn' },
})
const { status } = await parseJsonResponse(
await POST(req, createMockRouteParams({ id: 'c1' }))
)
expect(status).toBe(200)
})
it('rejects the stale companies.name when a settings name exists (only the displayed name is accepted)', async () => {
// Security: the UI only ever shows company_settings.company_name when set, so
// the server must not accept the stale companies.name as an alternative
// confirmation — that would be a delete path the user was never shown.
mockAuth('user-1')
mockService({
companies: [{ data: { id: 'c1', name: 'Gammalt namn', archived_at: null }, error: null }],
company_members: [{ data: { role: 'owner' }, error: null }],
company_settings: [{ data: { company_name: 'Nytt namn' }, error: null }],
})
const req = createMockRequest('/api/company/c1/delete', {
method: 'POST',
body: { confirm_name: 'Gammalt namn' },
})
const { status } = await parseJsonResponse(
await POST(req, createMockRouteParams({ id: 'c1' }))
)
expect(status).toBe(400)
})
it('returns 403 when caller is member but not owner', async () => {
mockAuth('user-1')
mockService({
+21 -3
View File
@@ -25,7 +25,10 @@ const DeleteCompanySchema = z.object({
*
* Rules:
* - Only callers with role='owner' in company_members may delete.
* - The body must include confirm_name matching companies.name exactly.
* - The body must include confirm_name matching the company's display name.
* The UI shows company_settings.company_name (companies.name may be stale),
* so we validate against that, falling back to companies.name. Either value
* is accepted so the confirm gate never blocks a legitimate deletion.
* - Already-archived companies return 404 (treated as not found).
*/
export async function POST(
@@ -89,8 +92,23 @@ export async function POST(
)
}
// 3. Confirm name matches exactly (case-sensitive trim)
if (confirm_name.trim() !== company.name.trim()) {
// 3. Confirm name matches the exact name the UI displays. The dashboard layout
// resolves the displayed name as `company_settings.company_name || companies.name`
// (companies.name may be stale) and CompanyDangerZone gates on that value, so
// the server must accept ONLY that single name. Accepting the stale
// companies.name as an alternative would open a confirmation path the user was
// never shown — weakening the gate on an irreversible action (ASVS V8.2.1).
// Case-sensitive trim, mirror of the client-side check.
const { data: companySettings } = await service
.from('company_settings')
.select('company_name')
.eq('company_id', companyId)
.maybeSingle()
const displayName = (companySettings?.company_name || company.name).trim()
const typed = confirm_name.trim()
if (typed !== displayName) {
return NextResponse.json(
{ error: 'Företagsnamnet stämmer inte överens.' },
{ status: 400 }
+35 -6
View File
@@ -5,12 +5,17 @@ import {
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
@@ -27,6 +32,7 @@ describe('DELETE /api/invoices/[id]', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
@@ -91,24 +97,47 @@ describe('DELETE /api/invoices/[id]', () => {
expect(body.data.invoice_number).toBe('F-2026001')
})
it('cancels an un-numbered draft (legacy null-number row)', async () => {
it('hard deletes an un-numbered draft (saved via "Spara som utkast") and emits an audit event', async () => {
enqueue({
data: { id: 'inv-1', status: 'draft', invoice_number: null, user_id: 'user-1' },
error: null,
})
// delete().select('id') returns the removed row
enqueue({ data: [{ id: 'inv-1' }], error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const response = await DELETE(
createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{
data: { cancelled: boolean; invoice_number: string | null }
}>(response)
const { status, body } = await parseJsonResponse<{ data: { deleted: boolean } }>(response)
expect(status).toBe(200)
expect(body.data.cancelled).toBe(true)
expect(body.data.invoice_number).toBeNull()
expect(body.data.deleted).toBe(true)
// The hard delete leaves no journal trace, so an audit event must record it.
expect(emitSpy).toHaveBeenCalledWith({
type: 'invoice.draft_deleted',
payload: { invoiceId: 'inv-1', companyId: 'company-1', userId: 'user-1' },
})
})
it('returns 409 INVOICE_CANCEL_RACE when an un-numbered draft is finalized concurrently', async () => {
enqueue({
data: { id: 'inv-1', status: 'draft', invoice_number: null, user_id: 'user-1' },
error: null,
})
// delete matched 0 rows — the draft was finalized (numbered) in the meantime.
enqueue({ data: [], error: null })
const response = await DELETE(
createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_CANCEL_RACE')
})
it('returns 409 INVOICE_CANCEL_RACE when status flipped between fetch and update', async () => {
@@ -0,0 +1,172 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
parseJsonResponse,
createQueuedMockSupabase,
makeInvoice,
makeCustomer,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { POST } from '../route'
describe('POST /api/invoices/[id]/finalize ("Granska & skapa")', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('finalizes an unnumbered draft: allocates a number and emits invoice.created', async () => {
const customer = makeCustomer({ id: 'cust-1' })
const draft = makeInvoice({
id: 'inv-1',
invoice_number: null,
status: 'draft',
document_type: 'invoice',
})
// Fetch the draft
enqueue({ data: draft, error: null })
// ensureInvoiceNumber → generate_invoice_number RPC
enqueue({ data: '2026001', error: null })
// Fetch the complete invoice
enqueue({ data: { ...draft, invoice_number: '2026001', customer, items: [] }, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const response = await POST(
createMockRequest('/api/invoices/inv-1/finalize', { method: 'POST' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{ data: { invoice_number: string | null } }>(response)
expect(status).toBe(200)
expect(body.data.invoice_number).toBe('2026001')
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'invoice.created' })
)
})
it('returns 404 INVOICE_NOT_FOUND when the invoice does not exist', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const response = await POST(
createMockRequest('/api/invoices/inv-1/finalize', { method: 'POST' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('INVOICE_NOT_FOUND')
})
it('rejects a draft that already has a number with INVOICE_FINALIZE_NOT_DRAFT', async () => {
const numbered = makeInvoice({
id: 'inv-1',
invoice_number: 'F-2026001',
status: 'draft',
document_type: 'invoice',
})
enqueue({ data: numbered, error: null })
const response = await POST(
createMockRequest('/api/invoices/inv-1/finalize', { method: 'POST' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_FINALIZE_NOT_DRAFT')
})
it('rejects a non-draft invoice with INVOICE_FINALIZE_NOT_DRAFT', async () => {
const sent = makeInvoice({
id: 'inv-1',
invoice_number: null,
status: 'sent',
document_type: 'invoice',
})
enqueue({ data: sent, error: null })
const response = await POST(
createMockRequest('/api/invoices/inv-1/finalize', { method: 'POST' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_FINALIZE_NOT_DRAFT')
})
it('rejects a self-billed draft (counterparty document, no F-number allocation)', async () => {
const selfBilled = makeInvoice({
id: 'inv-1',
invoice_number: null,
status: 'draft',
document_type: 'invoice',
is_self_billed: true,
})
enqueue({ data: selfBilled, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const response = await POST(
createMockRequest('/api/invoices/inv-1/finalize', { method: 'POST' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_FINALIZE_NOT_DRAFT')
expect(emitSpy).not.toHaveBeenCalled()
})
it('returns 500 INVOICE_FINALIZE_INCOMPLETE (and emits nothing) when the re-read fails after numbering', async () => {
const draft = makeInvoice({
id: 'inv-1',
invoice_number: null,
status: 'draft',
document_type: 'invoice',
})
enqueue({ data: draft, error: null }) // fetch draft
enqueue({ data: '2026001', error: null }) // number allocation
enqueue({ data: null, error: { message: 'transient db error' } }) // re-read fails
const emitSpy = vi.spyOn(eventBus, 'emit')
const response = await POST(
createMockRequest('/api/invoices/inv-1/finalize', { method: 'POST' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(500)
expect(body.error.code).toBe('INVOICE_FINALIZE_INCOMPLETE')
// No invoice.created emitted with a hollow payload.
expect(emitSpy).not.toHaveBeenCalled()
})
})
+93
View File
@@ -0,0 +1,93 @@
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Invoice } from '@/types'
ensureInitialized() // Module-level — loads extensions so invoice.created handlers are wired.
/**
* POST /api/invoices/[id]/finalize — "Granska och skapa".
*
* Turns an unnumbered draft (saved via "Spara som utkast") into a real, issued
* invoice: allocates the F-series number and emits invoice.created (which drives
* webhooks + the audit log). After this the invoice behaves exactly like one
* created directly — it can be sent or cancelled (makulerad), but no longer
* hard-deleted, because the number now belongs to the gap-free series
* (ML 17 kap 24§).
*
* Only an unnumbered draft (status='draft', invoice_number IS NULL,
* document_type='invoice') may be finalized. Numbering is idempotent inside the
* generate_invoice_number RPC, but the explicit guard keeps the contract clear.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.finalize',
async (_request, { supabase, companyId, user, log, requestId }, { params }) => {
const { id } = await params
const { data: invoice, error: fetchError } = await supabase
.from('invoices')
.select('id, status, invoice_number, document_type, is_self_billed')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !invoice) {
return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })
}
// Only an unnumbered draft can be finalized. Numbered drafts are already
// issued (cancel via makulering instead); sent/paid invoices are immutable.
// A null document_type means a plain invoice (older rows / default).
// Self-billed drafts are counterparty documents (självfakturering) and must
// not be allocated an F-series number through this flow even if a direct
// API call left one unnumbered.
const docType = invoice.document_type ?? 'invoice'
if (
invoice.status !== 'draft' ||
invoice.invoice_number ||
docType !== 'invoice' ||
invoice.is_self_billed
) {
return errorResponseFromCode('INVOICE_FINALIZE_NOT_DRAFT', log, { requestId })
}
try {
await ensureInvoiceNumber(supabase, companyId!, invoice as Invoice)
} catch (err) {
log.error('failed to assign invoice number on finalize', err as Error, { invoiceId: id })
return errorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', log, { requestId })
}
const { data: completeInvoice, error: refetchError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', id)
.single()
// The number was already allocated, so the invoice is finalized in the DB —
// but if the re-read fails we cannot emit invoice.created with a complete
// payload, which would silently drop the audit-log entry, webhooks, and any
// extension wired to the event. Surface it as a 500 rather than returning
// 200 with a null body and a hollow success toast. A reload shows the
// (correctly numbered) invoice; the failure is now visible in monitoring.
if (refetchError || !completeInvoice) {
log.error(
'finalize: number allocated but invoice re-read failed; invoice.created not emitted',
refetchError as Error,
{ invoiceId: id },
)
return errorResponseFromCode('INVOICE_FINALIZE_INCOMPLETE', log, { requestId })
}
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, companyId: companyId!, userId: user.id },
})
return NextResponse.json({ data: completeInvoice })
},
{ requireWrite: true },
)
+53 -8
View File
@@ -1,25 +1,30 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createLogger } from '@/lib/logger'
ensureInitialized() // Module-level — wires the audit-log handler for invoice.draft_deleted.
const log = createLogger('api.invoices.cancel')
/**
* DELETE /api/invoices/[id]
*
* Cancels (makulerar) a draft invoice. The row and its F-series number are
* retained — the invoice transitions to status='cancelled'. Keeping the row
* preserves the invoice-number sequence per ML 17 kap 24§ and BFNAR 2013:2,
* so the F-series stays gap-free without any voucher_gap_explanations entry.
* Removes a draft invoice. Behaviour depends on whether a number was issued:
*
* Only drafts may be cancelled this way. Sent / paid invoices are immutable
* per BFL and must be reversed via a credit note instead.
* - Unnumbered draft (saved via "Spara som utkast", never finalized): hard
* deleted. No F-series number was consumed, so there is no gap to document
* (ML 17 kap 24§). invoice_items cascade via the FK.
* - Numbered draft (created directly, or finalized via "Granska och skapa"):
* makulerad — the row and its number are retained and status flips to
* 'cancelled', keeping the F-series gap-free per ML 17 kap 24§ / BFNAR 2013:2.
*
* Old drafts predating allocate-on-save may have invoice_number = NULL; those
* still cancel (status flip) without consuming a number — no special-case path.
* Only drafts may be removed either way. Sent / paid invoices are immutable per
* BFL and must be reversed via a credit note instead.
*/
export async function DELETE(
request: Request,
@@ -54,6 +59,46 @@ export async function DELETE(
return errorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', log)
}
// Unnumbered drafts (saved via "Spara som utkast", never finalized) are not
// yet issued invoices — no F-series number was consumed — so they can be hard
// deleted with no gap in the sequence (ML 17 kap 24§). invoice_items cascade
// via the FK (ON DELETE CASCADE); an un-finalized draft has no journal entry
// or linked document. The status='draft' + invoice_number IS NULL guard makes
// the delete a no-op if the row was finalized (numbered) concurrently.
if (!invoice.invoice_number) {
const { data: removed, error: removeError } = await supabase
.from('invoices')
.delete()
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'draft')
.is('invoice_number', null)
.select('id')
if (removeError) {
return NextResponse.json({ error: removeError.message }, { status: 500 })
}
if (!removed || removed.length === 0) {
// Finalized between fetch and delete — refuse rather than fall through to
// makulering of a now-issued invoice.
return errorResponseFromCode('INVOICE_CANCEL_RACE', log)
}
// The row is gone, so there's no journal trace of the removal. Emit an
// audit event carrying the identifiers so the event log records who deleted
// which draft and when — the makulering path leaves a journal/status trail,
// a hard delete otherwise leaves none.
await eventBus.emit({
type: 'invoice.draft_deleted',
payload: { invoiceId: id, companyId, userId: user.id },
})
return NextResponse.json({ data: { deleted: true } })
}
// Numbered draft: retain the row and its number, flip to 'cancelled'
// (makulering) so the F-series stays gap-free.
// .select() returns the affected rows so we can detect a TOCTOU race where
// the status flipped between the fetch above and this update. With only the
// .eq('status','draft') guard, a 0-row update returns success and the user
+50
View File
@@ -219,6 +219,56 @@ describe('POST /api/invoices (create invoice)', () => {
)
})
it('saves an unnumbered draft without a number or event when save_as_draft is true', async () => {
const customer = makeCustomer({ id: VALID_UUID })
const createdInvoice = makeInvoice({ id: 'inv-1', invoice_number: null })
mockGetVatRules.mockReturnValue({
treatment: 'standard_25',
rate: 25,
momsRuta: '10',
reverseChargeText: null,
})
mockCalculateVat.mockReturnValue(2500)
mockGetAvailableVatRates.mockReturnValue([
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
])
// Fetch customer
enqueue({ data: customer, error: null })
// Insert invoice (stays unnumbered — the allocation step is skipped)
enqueue({ data: createdInvoice, error: null })
// Insert items
enqueue({ data: null, error: null })
// Fetch complete invoice (still unnumbered; no generate_invoice_number RPC)
enqueue({ data: { ...createdInvoice, invoice_number: null, customer, items: [] }, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: {
customer_id: VALID_UUID,
invoice_date: '2024-06-15',
due_date: '2024-07-15',
currency: 'SEK',
save_as_draft: true,
items: [{ description: 'Consulting', quantity: 10, unit: 'tim', unit_price: 1000 }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ data: { invoice_number: string | null } }>(response)
expect(status).toBe(200)
expect(body.data.invoice_number).toBeNull()
expect(emitSpy).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'invoice.created' })
)
})
it('rolls back invoice when items insertion fails', async () => {
const customer = makeCustomer({ id: VALID_UUID })
const createdInvoice = makeInvoice({ id: 'inv-1' })
+12 -8
View File
@@ -317,12 +317,14 @@ export const POST = withRouteContext(
})
}
// Allocate F-series number on save (Fortnox-style). The user gets a numbered
// draft they can download and send manually without first lying about
// having sent it. Discarded numbered drafts become 'cancelled' rather than
// deleted, so the F-series stays gap-free per ML 17 kap 24§.
// Delivery notes already have their number from the insert above.
if (documentType === 'invoice' || documentType === 'proforma') {
// Allocate the F-series number on save (Fortnox-style) — UNLESS the caller
// asked to save as an unnumbered draft. A direct create gives the user a
// numbered draft they can download and send manually; "Spara som utkast"
// (save_as_draft) defers numbering to the explicit "Granska och skapa" step
// (POST /invoices/{id}/finalize) so the draft can be hard-deleted with no
// gap in the F-series per ML 17 kap 24§. Delivery notes are always numbered
// at insert above and ignore the flag.
if (!invoiceInput.save_as_draft && (documentType === 'invoice' || documentType === 'proforma')) {
try {
await ensureInvoiceNumber(supabase, companyId!, invoice as Invoice)
} catch (err) {
@@ -373,8 +375,10 @@ export const POST = withRouteContext(
.eq('id', invoice.id)
.single()
// Emit event only for real invoices (proformas / delivery notes / quotes are informational).
if (completeInvoice && documentType === 'invoice') {
// Emit event only for real, issued invoices. Unnumbered drafts (save_as_draft)
// are not issued yet — the invoice.created event (which drives webhooks and the
// audit log) fires when the user finalizes via "Granska och skapa".
if (completeInvoice && documentType === 'invoice' && !invoiceInput.save_as_draft) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, companyId: companyId!, userId: user.id },
@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
createMockRouteParams,
} from '@/tests/helpers'
// ── Mocks ────────────────────────────────────────────────────
// The route is wrapped in withRouteContext, which resolves auth via
// requireAuth() (the only path that enforces MFA/AAL2 on hosted) and the active
// company via getActiveCompanyId(). Mock those, not createClient/getUser.
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { DELETE } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
// ── Test data ────────────────────────────────────────────────
const mockUser = { id: 'user-1', email: 'test@test.se' }
// ── Tests ────────────────────────────────────────────────────
describe('DELETE /api/salary/runs/[id]', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 401 when not authenticated', async () => {
vi.mocked(requireAuth).mockResolvedValue({
user: null as never,
supabase: {} as never,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const request = createMockRequest('/api/salary/runs/run-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 404 when salary run not found', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: supabase as never,
error: null,
})
enqueueMany([
{ data: null, error: { message: 'Not found' } }, // salary_runs lookup
])
const request = createMockRequest('/api/salary/runs/run-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(404)
expect(body.error).toContain('hittades inte')
})
it('returns 400 when the run is not a draft (booked must be storno-reversed)', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: supabase as never,
error: null,
})
enqueueMany([
{ data: { id: 'run-1', status: 'booked' } }, // salary_runs lookup
])
const request = createMockRequest('/api/salary/runs/run-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('utkast')
})
it('deletes a draft run and returns success', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: supabase as never,
error: null,
})
enqueueMany([
{ data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup
{ data: null }, // salary_runs delete (cascade handles children)
])
const request = createMockRequest('/api/salary/runs/run-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ data: { id: string; deleted: boolean } }>(response)
expect(status).toBe(200)
expect(body.data).toEqual({ id: 'run-1', deleted: true })
})
})
+142 -107
View File
@@ -1,123 +1,158 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
import { formatRedovisare } from '@/lib/skatteverket/format'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
ensureInitialized()
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.runs.get',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
const companyId = await requireCompanyId(supabase, user.id)
const { data: run, error } = await supabase
.from('salary_runs')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single()
const { data: run, error } = await supabase
.from('salary_runs')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (error || !run) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
// Load employees with line items
const { data: employees } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(id, first_name, last_name, personnummer, personnummer_last4, employment_type), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
.order('created_at')
// Resolve Skatteverket arbetsgivare ID for AGI submission. We surface this
// in the run payload so the client doesn't need a second round-trip just to
// build extension URLs. Quietly null when the org number isn't set yet.
let arbetsgivare: string | null = null
const { data: settings } = await supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', companyId)
.maybeSingle()
if (settings?.org_number && settings?.entity_type) {
try {
arbetsgivare = formatRedovisare(settings.org_number, settings.entity_type)
} catch {
arbetsgivare = null
if (error || !run) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
}
return NextResponse.json({
data: {
...run,
arbetsgivare,
employees: (employees || []).map(emp => ({
...emp,
employee: emp.employee ? {
...emp.employee,
personnummer: maskPersonnummer(decryptPersonnummer(emp.employee.personnummer)),
} : null,
})),
},
})
}
// Load employees with line items
const { data: employees } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(id, first_name, last_name, personnummer, personnummer_last4, employment_type), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
.order('created_at')
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Only allow updates on draft runs
const { data: run, error: fetchError } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !run) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
if (run.status !== 'draft') {
return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
}
const body = await request.json()
const allowedFields = ['payment_date', 'voucher_series', 'notes']
const updates: Record<string, unknown> = {}
for (const field of allowedFields) {
if (body[field] !== undefined) {
updates[field] = body[field]
// Resolve Skatteverket arbetsgivare ID for AGI submission. We surface this
// in the run payload so the client doesn't need a second round-trip just to
// build extension URLs. Quietly null when the org number isn't set yet.
let arbetsgivare: string | null = null
const { data: settings } = await supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', companyId)
.maybeSingle()
if (settings?.org_number && settings?.entity_type) {
try {
arbetsgivare = formatRedovisare(settings.org_number, settings.entity_type)
} catch {
arbetsgivare = null
}
}
}
const { data: updated, error } = await supabase
.from('salary_runs')
.update(updates)
.eq('id', id)
.select()
.single()
return NextResponse.json({
data: {
...run,
arbetsgivare,
employees: (employees || []).map(emp => ({
...emp,
employee: emp.employee ? {
...emp.employee,
personnummer: maskPersonnummer(decryptPersonnummer(emp.employee.personnummer)),
} : null,
})),
},
})
},
)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.runs.update',
async (request, { supabase, companyId }, { params }) => {
const { id } = await params
return NextResponse.json({ data: updated })
}
// Only allow updates on draft runs
const { data: run, error: fetchError } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !run) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
if (run.status !== 'draft') {
return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
}
const body = await request.json()
const allowedFields = ['payment_date', 'voucher_series', 'notes']
const updates: Record<string, unknown> = {}
for (const field of allowedFields) {
if (body[field] !== undefined) {
updates[field] = body[field]
}
}
const { data: updated, error } = await supabase
.from('salary_runs')
.update(updates)
.eq('id', id)
.eq('company_id', companyId)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: updated })
},
{ requireWrite: true },
)
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.runs.delete',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
// Only draft runs can be deleted. Once a run reaches review/approved/paid/
// booked it carries compliance weight — a booked run created immutable
// verifikat (storno to undo, never delete). A draft has produced no journal
// entries and no AGI (the arbetsgivardeklaration is filed monthly from the
// booked/paid run, never from a draft), so removing it touches no posted
// accounting data.
const { data: run, error: fetchError } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !run) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
if (run.status !== 'draft') {
return NextResponse.json(
{ error: 'Bara utkast kan raderas. En bokförd lönekörning måste vändas (storno).' },
{ status: 400 }
)
}
// salary_run_employees and their salary_line_items are removed via
// ON DELETE CASCADE. An agi_declarations row — never present on a draft —
// would block the delete via its RESTRICT FK, the safety net for the
// impossible case.
const { error } = await supabase
.from('salary_runs')
.delete()
.eq('id', id)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: { id, deleted: true } })
},
{ requireWrite: true },
)
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { parseJsonResponse } from '@/tests/helpers'
const mockSupabase = {
auth: { getUser: vi.fn() },
}
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
vi.mock('@/lib/salary/tax-tables', () => ({
fetchKommunTaxRates: vi.fn(),
}))
import { GET } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { fetchKommunTaxRates } from '@/lib/salary/tax-tables'
const mockUser = { id: 'user-1', email: 'test@test.se' }
describe('GET /api/salary/tax-tables/kommuner', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: mockSupabase as never,
error: null,
})
vi.mocked(fetchKommunTaxRates).mockResolvedValue([
{ kommun: 'Östersund', totalRate: 33.0, tableNumber: 33 },
{ kommun: 'Aronsjö', totalRate: 32.49, tableNumber: 32 },
{ kommun: 'Stockholm', totalRate: 29.82, tableNumber: 30 },
])
})
it('returns 401 when not authenticated', async () => {
vi.mocked(requireAuth).mockResolvedValue({
user: null as never,
supabase: mockSupabase as never,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const request = new Request('http://localhost/api/salary/tax-tables/kommuner?year=2030')
const response = await GET(request, { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(401)
})
it('returns the kommun list sorted by Swedish collation', async () => {
const request = new Request('http://localhost/api/salary/tax-tables/kommuner?year=2031')
const response = await GET(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{
data: { year: number; kommuner: { kommun: string; tableNumber: number }[] }
}>(response)
expect(status).toBe(200)
expect(body.data.year).toBe(2031)
expect(body.data.kommuner.map((k) => k.kommun)).toEqual(['Aronsjö', 'Stockholm', 'Östersund'])
expect(body.data.kommuner[1]).toMatchObject({ kommun: 'Stockholm', tableNumber: 30 })
})
it('falls back to the current year for a non-numeric year (no NaN reaches Skatteverket/cache)', async () => {
const currentYear = new Date().getFullYear()
const request = new Request('http://localhost/api/salary/tax-tables/kommuner?year=not-a-year')
const response = await GET(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ data: { year: number } }>(response)
expect(status).toBe(200)
expect(body.data.year).toBe(currentYear)
expect(fetchKommunTaxRates).toHaveBeenCalledWith(currentYear)
})
it('caches per year — a second request for the same year does not refetch', async () => {
const req1 = new Request('http://localhost/api/salary/tax-tables/kommuner?year=2032')
await GET(req1, { params: Promise.resolve({}) })
const req2 = new Request('http://localhost/api/salary/tax-tables/kommuner?year=2032')
await GET(req2, { params: Promise.resolve({}) })
expect(fetchKommunTaxRates).toHaveBeenCalledTimes(1)
expect(fetchKommunTaxRates).toHaveBeenCalledWith(2032)
})
})
@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { fetchKommunTaxRates } from '@/lib/salary/tax-tables'
/**
* GET /api/salary/tax-tables/kommuner?year=2026
*
* Returns every Swedish municipality mapped to its derived skattetabell number
* (29-42), sourced from Skatteverket's open-data API via fetchKommunTaxRates().
* Powers the folkbokföringskommun picker on the employee form so the user only
* has to choose their town — the tax table derives itself.
*
* The mapping changes once a year, so the result is cached in-process per year.
*/
interface KommunRate {
kommun: string
totalRate: number
tableNumber: number
}
const cache = new Map<number, KommunRate[]>()
export const GET = withRouteContext('salary.tax_tables.kommuner', async (request) => {
const { searchParams } = new URL(request.url)
// A non-numeric or out-of-range year would otherwise reach Skatteverket as
// 'år': 'NaN' and stick a NaN key in the module cache for the process
// lifetime. Clamp to a sane window and fall back to the current year.
const parsedYear = parseInt(searchParams.get('year') || '', 10)
const year =
Number.isFinite(parsedYear) && parsedYear >= 2000 && parsedYear <= 2100
? parsedYear
: new Date().getFullYear()
let kommuner = cache.get(year)
if (!kommuner) {
const list = await fetchKommunTaxRates(year)
list.sort((a, b) => a.kommun.localeCompare(b.kommun, 'sv'))
kommuner = list
cache.set(year, list)
}
return NextResponse.json({ data: { year, kommuner } })
})
+3 -3
View File
@@ -206,7 +206,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
{isOpen && flatList.length > 0 && (
<div
ref={listRef}
className="absolute z-50 top-full left-0 mt-1 min-w-[20rem] w-[max(100%,28rem)] max-h-[300px] overflow-y-auto rounded-md border border-input bg-card shadow-md"
className="absolute z-50 top-full left-0 mt-1 min-w-[24rem] w-[max(100%,34rem)] max-h-[300px] overflow-y-auto rounded-md border border-input bg-card shadow-md"
>
{groupedAccounts.map((group) => (
<div key={group.className}>
@@ -231,7 +231,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
onMouseEnter={() => setHighlightedIndex(flatIndex)}
>
<span className="font-mono shrink-0">{account.account_number}</span>
<span className="break-words">{account.account_name}</span>
<span className="flex-1 min-w-0 break-words">{account.account_name}</span>
</button>
)
})}
@@ -242,7 +242,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
{/* Empty state */}
{isOpen && search.trim() && flatList.length === 0 && (
<div className="absolute z-50 top-full left-0 mt-1 min-w-[20rem] w-[max(100%,28rem)] rounded-md border border-input bg-card shadow-md p-3">
<div className="absolute z-50 top-full left-0 mt-1 min-w-[24rem] w-[max(100%,34rem)] rounded-md border border-input bg-card shadow-md p-3">
<p className="text-sm text-muted-foreground">
Hittade inget konto som matchar.
</p>
+109 -7
View File
@@ -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<BlockingPeriod[]>([])
// 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
@@ -153,16 +225,46 @@ export default function CreatePeriodDialog({ open, onOpenChange, entryDate, peri
<Input type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} className="mt-1" />
</div>
</div>
{isBlocked && (
<div className="rounded-lg border border-warning/20 bg-warning/5 p-3 text-sm flex gap-2">
<Lock className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" />
<div className="space-y-2">
<div className="space-y-1">
<p className="font-medium">Föregående räkenskapsår är öppet</p>
<p className="text-muted-foreground">
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.
</p>
</div>
<ul className="space-y-0.5 text-muted-foreground">
{blockingPeriods.map((p) => (
<li key={p.id} className="tabular-nums">
{p.name} ({p.period_start} – {p.period_end})
</li>
))}
</ul>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Avbryt
</Button>
<Button onClick={handleCreate} disabled={isSubmitting || !name || !periodStart || !periodEnd}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Skapa
</Button>
{isBlocked ? (
<Button onClick={handleLockAndRetry} disabled={busy}>
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{blockingPeriods.length > 1 ? 'Lås åren och skapa' : 'Lås året och skapa'}
</Button>
) : (
<Button onClick={handleCreate} disabled={busy || !name || !periodStart || !periodEnd}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Skapa
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
@@ -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. */}
<Button
variant="default"
size="sm"
@@ -1644,19 +1647,19 @@ function FieldsRail({
>
Matcha mot transaktion
</Button>
<Link href={`/supplier-invoices/new?inbox_item_id=${item.id}`} className="block">
<Button variant="outline" size="sm" className="w-full">
Skapa leverantörsfaktura
</Button>
</Link>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={onBookDirect}
>
Bokför manuellt
Bokför som verifikat
</Button>
<Link href={`/supplier-invoices/new?inbox_item_id=${item.id}`} className="block">
<Button variant="outline" size="sm" className="w-full">
Skapa leverantörsfaktura
</Button>
</Link>
</>
)}
<Button
+11 -20
View File
@@ -22,7 +22,7 @@ import {
} from '@/components/ui/table'
import { ArrowLeft, ArrowRight, Columns3 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { getCSVPreview, normalizeMinusSign } from '@/lib/import/bank-file/formats/generic-csv'
import { getCSVPreview, normalizeMinusSign, suggestColumnMapping } from '@/lib/import/bank-file/formats/generic-csv'
import type { GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
const HEADER_KEYWORDS = [
@@ -137,29 +137,20 @@ export default function BankFileColumnMappingStep({
const dataRows = hasHeader ? parsedRows.slice(detectedHeaderRow + 1) : parsedRows
// Auto-guess date/description/amount columns from the first data row.
// Only used as initial defaults — user can override any pick.
// Match ASCII and Unicode minus; some banks (e.g. Northmill) use U+2212.
const AMOUNT_RE = /^[-\u2212\u2013\u2014\u2010]?\d+([.,]\d+)?$/
// Auto-guess date/description/amount/balance columns. Matches header labels
// first (so the trailing Saldo column is never picked as the amount), then
// falls back to value heuristics. Initial defaults only — user can override.
useEffect(() => {
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
+38 -1
View File
@@ -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<BankAccount[]>([])
const [selectedAccount, setSelectedAccount] = useState('1930')
@@ -102,6 +104,11 @@ export default function BankFileConfirmStep({
<span className="text-xs">Transaktioner</span>
</div>
<p className="text-xl font-display font-medium tabular-nums">{stats.parsed_rows}</p>
{stats.skipped_rows > 0 && (
<p className="text-xs text-muted-foreground mt-1">
{stats.skipped_rows} rader hoppades över
</p>
)}
</div>
<div className="p-4 bg-muted/50 rounded-lg">
@@ -172,6 +179,36 @@ export default function BankFileConfirmStep({
</CardContent>
</Card>
{/* Skipped rows — surfaced here because the manual-mapping path skips the
preview step where these warnings would otherwise be shown. */}
{warnings.length > 0 && (
<Card>
<CardHeader className="py-3">
<CardTitle className="text-sm flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-warning" />
{warnings.length} {warnings.length === 1 ? 'rad' : 'rader'} hoppades över
</CardTitle>
<CardDescription>
Dessa rader kunde inte läsas och importeras inte. Kontrollera att inga transaktioner saknas.
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-1 max-h-32 overflow-y-auto">
{warnings.slice(0, 10).map((issue, i) => (
<p key={i} className="text-xs text-muted-foreground">
Rad {issue.row}: {issue.message}
</p>
))}
{warnings.length > 10 && (
<p className="text-xs text-muted-foreground font-medium">
…och {warnings.length - 10} till
</p>
)}
</div>
</CardContent>
</Card>
)}
{/* Actions */}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
<Button variant="outline" className="min-h-11" onClick={onBack} disabled={isLoading}>
+256
View File
@@ -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<EmployeeTaxValue>
/** Income year the table/column applies to. Defaults to the current year. */
year?: number
disabled?: boolean
onChange: (value: EmployeeTaxValue) => void
}
function RequiredMark() {
return <span className="text-destructive ml-0.5">*</span>
}
/**
* 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<number | null>(initial?.tax_table_number ?? null)
const [rate, setRate] = useState<number | null>(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 (
<Card>
<CardHeader>
<CardTitle className="text-base">Skatt</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="f_skatt_status">
<InfoTooltip content="A-skatt: du drar preliminärskatt enligt skattetabell. F-skatt/FA-skatt: personen sköter sin egen skatt — inget skatteavdrag görs.">
Skatteform
</InfoTooltip>
</Label>
<Select value={fSkatt} onValueChange={setFSkatt} disabled={disabled}>
<SelectTrigger id="f_skatt_status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="a_skatt">A-skatt</SelectItem>
<SelectItem value="f_skatt">F-skatt</SelectItem>
<SelectItem value="fa_skatt">FA-skatt</SelectItem>
<SelectItem value="not_verified">Ej verifierad</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={sido}
onChange={(e) => setSido(e.target.checked)}
disabled={disabled}
className="rounded border-border"
/>
<InfoTooltip content="Kryssa i om detta INTE är personens huvudarbetsgivare. Då dras en fast skatt på 30 % istället för enligt tabell.">
Sidoinkomst (30 % skatteavdrag)
</InfoTooltip>
</label>
</div>
</div>
{requiresTable ? (
<>
<div className="space-y-2">
<Label htmlFor="tax_municipality">
<InfoTooltip content="Kommunen där personen är folkbokförd (per 1 november föregående år). Den avgör skattetabellen — välj kommun så fylls tabellen i automatiskt.">
Folkbokföringskommun
</InfoTooltip>
<RequiredMark />
</Label>
<MunicipalityCombobox
id="tax_municipality"
value={municipality}
year={incomeYear}
disabled={disabled}
onChange={(value) => {
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)
}}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tax_table_number">
<InfoTooltip content="Skatteverkets tabell 29–42, baserad på kommunens totala skattesats. Härleds automatiskt från folkbokföringskommunen.">
Skattetabell
</InfoTooltip>
<RequiredMark />
</Label>
{tableManual ? (
<Input
id="tax_table_number"
type="number"
min="29"
max="42"
value={tableNumber ?? ''}
onChange={(e) => setTableNumber(parseInt(e.target.value) || null)}
disabled={disabled}
/>
) : tableNumber ? (
<div className="flex items-baseline gap-2 rounded-md border border-input px-3 py-2">
<span className="font-sans text-xl tabular-nums">{tableNumber}</span>
{(municipality || rate != null) && (
<span className="text-xs text-muted-foreground">
{municipality}
{rate != null ? ` · ${rate.toLocaleString('sv-SE')} %` : ''}
</span>
)}
</div>
) : (
<p className="rounded-md border border-dashed border-input px-3 py-2 text-sm text-muted-foreground">
Välj kommun ovan
</p>
)}
{!disabled && (
<button
type="button"
onClick={() => setTableManual((v) => !v)}
className="text-xs text-primary hover:underline underline-offset-4"
>
{tableManual ? 'Använd kommunens tabell' : 'Ange tabell manuellt'}
</button>
)}
</div>
<div className="space-y-2">
<Label htmlFor="tax_column">
<InfoTooltip content="Kolumnen i skattetabellen avgör hur avdraget beräknas, främst utifrån ålder och inkomsttyp. Föreslås automatiskt från personnumret.">
Kolumn
</InfoTooltip>
</Label>
<Select
value={String(effectiveColumn)}
onValueChange={(v) => {
setColumn(parseInt(v))
setColumnTouched(true)
}}
disabled={disabled}
>
<SelectTrigger id="tax_column">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TAX_COLUMN_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>
{opt.value}. {opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{!columnTouched && derivedColumn != null ? (
<p className="text-xs text-muted-foreground">
Föreslås automatiskt: anställd under 66 år.
</p>
) : isSenior && !columnTouched ? (
<p className="text-xs text-warning-foreground">
Personen har fyllt 66 år — välj kolumn manuellt (lön = kolumn 3, pension = kolumn 2).
</p>
) : null}
</div>
</div>
</>
) : (
<p className="rounded-md border border-dashed border-input px-3 py-3 text-sm text-muted-foreground">
{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.'}
</p>
)}
</CardContent>
</Card>
)
}
+201
View File
@@ -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<KommunRate[]>([])
const [loadFailed, setLoadFailed] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
const next = e.target.value
setSearch(next)
onChange?.(next)
if (!isOpen) setIsOpen(true)
}
return (
<div ref={containerRef} className="relative">
<Input
id={id}
value={search}
onChange={handleInputChange}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder="Sök kommun…"
autoComplete="off"
disabled={disabled}
className={className}
/>
{isOpen && filtered.length > 0 && (
<div
ref={listRef}
className="absolute z-50 top-full left-0 mt-1 w-full max-h-[300px] overflow-y-auto rounded-md border border-input bg-card shadow-md"
>
{filtered.map((k, i) => {
const isHighlighted = i === highlightedIndex
return (
<button
key={k.kommun}
type="button"
data-highlighted={isHighlighted}
className={`w-full text-left px-3 py-1.5 text-sm cursor-pointer flex items-baseline justify-between gap-2 ${
isHighlighted ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
}`}
onMouseDown={(e) => {
e.preventDefault()
select(k)
}}
onMouseEnter={() => setHighlightedIndex(i)}
>
<span className="min-w-0 break-words">{k.kommun}</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
Tabell {k.tableNumber}
</span>
</button>
)
})}
</div>
)}
{loadFailed && (
<p className="mt-1 text-xs text-muted-foreground">
Kunde inte hämta kommunlistan — skriv kommunnamnet och ange skattetabellen manuellt.
</p>
)}
</div>
)
}
+15 -1
View File
@@ -4,7 +4,20 @@ import { cn } from "@/lib/utils"
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ 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<HTMLInputElement>) => {
if (type === 'number') {
e.currentTarget.blur()
}
onWheel?.(e)
},
[type, onWheel]
)
return (
<input
type={type}
@@ -13,6 +26,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
className
)}
ref={ref}
onWheel={handleWheel}
{...props}
/>
)
+5
View File
@@ -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'),
})
+29
View File
@@ -738,6 +738,21 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
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<string, StructuredErrorEntry> = {
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<string, StructuredErrorEntry> = {
+6 -1
View File
@@ -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, unknown>): 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) {
+5
View File
@@ -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 } }
@@ -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 })
})
})
+147
View File
@@ -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
+57
View File
@@ -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()
})
})
+65
View File
@@ -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/)
})
})
+57
View File
@@ -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
}
+63 -25
View File
@@ -327,34 +327,53 @@ export async function fetchKommunTaxRates(year: number): Promise<Array<{
totalRate: number
tableNumber: number
}>> {
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<string, number>()
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<Array<{
}))
}
/**
* Skatteverket returns kommun names in uppercase ("UPPLANDS VÄSBY"). Title-case
* them for display and storage, preserving hyphenated parts ("Höör-...") and the
* common "i"/"och" connectors lowercase.
*/
function normalizeKommunName(raw: string): string {
const lowerWords = new Set(['i', 'och'])
return raw
.trim()
.toLowerCase()
.split(/(\s+|-)/) // keep separators (spaces, hyphens) as tokens
.map((token) => {
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) ──
/**
+23 -1
View File
@@ -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",
+23 -1
View File
@@ -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",
+3
View File
@@ -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[]
}