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:
co-authored by
Claude Opus 4.8
parent
bc61862e76
commit
0ca9c25aba
@@ -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,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>
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
@@ -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 } })
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user