Files
accounted/components/salary/SalaryOverridePanel.tsx
T
ccdfed5fea feat: voucher linking, recovery ops, and salary overrides (#591)
* feat: voucher linking, recovery ops, and salary overrides

Adds reversible/correction-style write paths that customers and agents have
been asking for, plus per-run salary employee overrides.

Invoice → voucher linking
- POST /api/invoices/[id]/link-to-voucher and
  GET /api/invoices/[id]/voucher-candidates
- lib/invoices/voucher-matching.ts with full + pg test coverage
- LinkVoucherPicker UI in PaymentBookingDialog
- pending_operations.operation_type expanded with link_invoice_voucher
  (medium risk) and a (journal_entry_id, invoice_id) unique guard
- MCP: gnubok_find_voucher_candidates_for_invoice and
  gnubok_link_invoice_to_voucher tools

SIE undo
- POST /api/import/sie/[id]/undo + undo_sie_import RPC
- sie_imports.status gains 'undone'
- ImportResultStep surfaces the action; structured error SIE_UNDO_FAILED

Edit-recreate journal entries
- POST /api/bookkeeping/journal-entries/[id]/edit-recreate
- Bookkeeping detail page wires it into the existing edit flow

Delete-last-voucher clears IB link
- Trigger + pg test ensure deleting the last voucher of a period nulls the
  opening_balance_journal_entry_id link so a re-import lands cleanly

Salary employee overrides
- salary_run_employees gains per-run override fields + migration
- lib/salary/effective-values.ts centralises resolved values; all payslip,
  payment, AGI, KU, and booking routes read through it
- SalaryOverridePanel on the employee detail page

Account classifier
- lib/bookkeeping/account-classifier.ts + tests; AddAccountDialog uses it
- backfill-import-accounts script updated

Misc
- toast: minor styling tweak
- AGI generate-declaration: respect effective values
- structured-errors: new LINK_INVOICE_VOUCHER namespace

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: add link_invoice_voucher operation type to pending_operations

* feat: refactor salary run calculations and update error handling for SIE imports

* fix: PR review feedback on voucher linking and SIE recovery

pg-real (blocking):
- tests/pg/delete-last-voucher-ib: drop posted_at = now() from the seed
  UPDATE — journal_entries has no posted_at column.
- lib/invoices/__tests__/voucher-matching.pg: seed the posted voucher
  before closing the fiscal period so enforce_period_lock doesn't block
  the INSERT during setup.

voucher-matching error codes and rollback:
- Add LINK_VOUCHER_DB_ERROR (HTTP 500) and return it on real invoice
  UPDATE / payment INSERT failures. Previously these returned
  LINK_VOUCHER_VOUCHER_NOT_FOUND (404) which the pending-op dispatcher
  auto-rejects on transient DB errors.
- Log rollback failures explicitly so an invoice left in a half-linked
  state (advanced status, no payment row) surfaces for manual
  reconciliation instead of disappearing silently.

resyncNextPeriodOpeningBalance ordering:
- Create the new IB first, relink the period FK, then storno the old IB.
  Previously the storno ran first; if createJournalEntry failed the next
  period was left with a reversed IB and nothing to replace it, and
  executeSIEImport swallows the error as a non-fatal warning.

replace_period_opening_balance_link:
- Tighten role check to owner/admin (was owner/admin/member). Matches
  delete_last_voucher and undo_sie_import.

Data minimisation:
- /api/invoices/[id]/voucher-candidates and the matching MCP tools now
  project only the invoice and customer fields the matcher reads, instead
  of returning the full customer row.

Schema bounds:
- SalaryEmployeeOverrideSchema caps each numeric override at 10 MSEK to
  catch typos before they reach the ledger or AGI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): supply user_id when seeding voucher_sequences

voucher_sequences.user_id is NOT NULL (per the multi-tenant refactor in
20260330130000). The previous test seed only set company_id /
fiscal_period_id / voucher_series, which made the seed fail with a
constraint violation on the latest pg-real run. Pass the same userId
used elsewhere in the seed helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): scope delete-last-voucher RPC assertions inside the tx

withUserContext always ROLLBACKs, so any DELETE the RPC performs is
discarded when the callback returns. The previous test then queried
journal_entries via a fresh getPool() connection that only saw the
pre-RPC committed seed state — hence "expected '1' to be '0'".

Move every post-RPC assertion (entry count, period FK clear,
opening_balances_set flip, audit log entry, sie_imports clear) inside
the same withUserContext callback so they observe the uncommitted state
before ROLLBACK fires.

Also fix the sie_imports INSERT: the column is `filename`, not
`file_name`, and `sie_type` is NOT NULL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): assert against the IB-marker audit row directly

DELETE on journal_entries fires two audit_log writes: the generic
write_audit_log() trigger row ("Deleted journal_entries record") and the
delete_last_voucher RPC's explicit "(was period IB)" entry. Both land
at the same statement_timestamp(), so ORDER BY created_at DESC LIMIT 1
returned the trigger row non-deterministically in CI.

Switch to a presence check with a LIKE filter on the IB marker so the
test verifies what it actually cares about — that the RPC's IB-aware
audit row exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(db): set company_id on delete_last_voucher audit_log rows

20260528120000_delete_last_voucher_clears_ib_link.sql inserts directly
into audit_log without setting company_id. audit_log's SELECT policy
filters company_id IN user_company_ids(), so those rows landed with
company_id=NULL and were invisible to every reader — only the generic
write_audit_log() trigger row remained visible. That broke BFL audit-
trail intent: the "(was period IB)" provenance row was never readable.

Republish delete_last_voucher with p_company_id populated on both
audit_log INSERTs (draft path and posted path). Behavior is otherwise
unchanged; the pg-real test for the IB-clear flow now sees the
RPC-written marker row as expected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Emil <emilmattsson14@gmail.com>
2026-05-28 21:09:43 +02:00

241 lines
8.1 KiB
TypeScript

'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Settings2, Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
interface SalaryOverridePanelProps {
runId: string
employeeId: string
taxWithheld: number
taxOverride: number | null
avgifterAmount: number
avgifterOverride: number | null
avgifterBasis: number
avgifterBasisOverride: number | null
reason: string | null
onSaved: () => void
disabled?: boolean
}
function num(v: string): number | null {
const trimmed = v.trim()
if (!trimmed) return null
const n = Number(trimmed.replace(',', '.'))
return Number.isFinite(n) ? n : null
}
export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
const { toast } = useToast()
const [expanded, setExpanded] = useState(
props.taxOverride !== null ||
props.avgifterOverride !== null ||
props.avgifterBasisOverride !== null,
)
const [taxStr, setTaxStr] = useState(props.taxOverride !== null ? String(props.taxOverride) : '')
const [avgStr, setAvgStr] = useState(
props.avgifterOverride !== null ? String(props.avgifterOverride) : '',
)
const [basisStr, setBasisStr] = useState(
props.avgifterBasisOverride !== null ? String(props.avgifterBasisOverride) : '',
)
const [reason, setReason] = useState(props.reason ?? '')
const [saving, setSaving] = useState(false)
const hasOverride =
props.taxOverride !== null ||
props.avgifterOverride !== null ||
props.avgifterBasisOverride !== null
async function handleSave() {
setSaving(true)
try {
const body = {
tax_withheld_override: num(taxStr),
avgifter_amount_override: num(avgStr),
avgifter_basis_override: num(basisStr),
reason: reason.trim() || null,
}
const res = await fetch(`/api/salary/runs/${props.runId}/employees/${props.employeeId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const data = await res.json()
if (!res.ok) {
toast({
title: 'Kunde inte spara justering',
description: typeof data?.error === 'string' ? data.error : 'Okänt fel',
variant: 'destructive',
})
return
}
toast({ title: 'Justering sparad' })
props.onSaved()
} catch (err) {
toast({
title: 'Kunde inte spara justering',
description: err instanceof Error ? err.message : 'Okänt fel',
variant: 'destructive',
})
} finally {
setSaving(false)
}
}
async function handleClear() {
setSaving(true)
try {
const res = await fetch(`/api/salary/runs/${props.runId}/employees/${props.employeeId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tax_withheld_override: null,
avgifter_amount_override: null,
avgifter_basis_override: null,
reason: null,
}),
})
if (!res.ok) {
const data = await res.json()
toast({
title: 'Kunde inte rensa justering',
description: typeof data?.error === 'string' ? data.error : 'Okänt fel',
variant: 'destructive',
})
return
}
setTaxStr('')
setAvgStr('')
setBasisStr('')
setReason('')
toast({ title: 'Justering rensad' })
props.onSaved()
} finally {
setSaving(false)
}
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<CardTitle className="text-base">Avancerat läge</CardTitle>
{hasOverride && <Badge variant="warning">Justerat</Badge>}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setExpanded((v) => !v)}
disabled={props.disabled}
>
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
{expanded ? 'Dölj' : 'Visa'}
</Button>
</CardHeader>
{expanded && (
<CardContent className="space-y-4">
<p className="text-xs text-muted-foreground">
Justera skatteavdrag eller arbetsgivaravgift för den här anställde — t.ex. för FoU-avdrag eller
jämkning. Justerade värden används vid bokföring och AGI-rapportering. Endast tillåtet i
granskningsläge.
</p>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="tax_override" className="text-xs">
Skatteavdrag (kr)
</Label>
<Input
id="tax_override"
inputMode="decimal"
placeholder={String(props.taxWithheld)}
value={taxStr}
onChange={(e) => setTaxStr(e.target.value)}
disabled={props.disabled || saving}
className="tabular-nums"
/>
<p className="text-[11px] text-muted-foreground">
Beräknat: <span className="tabular-nums">{formatCurrency(props.taxWithheld)}</span>
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="avgifter_override" className="text-xs">
Arbetsgivaravgifter (kr)
</Label>
<Input
id="avgifter_override"
inputMode="decimal"
placeholder={String(props.avgifterAmount)}
value={avgStr}
onChange={(e) => setAvgStr(e.target.value)}
disabled={props.disabled || saving}
className="tabular-nums"
/>
<p className="text-[11px] text-muted-foreground">
Beräknat: <span className="tabular-nums">{formatCurrency(props.avgifterAmount)}</span>
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="avgifter_basis_override" className="text-xs">
Avgiftsunderlag (kr)
</Label>
<Input
id="avgifter_basis_override"
inputMode="decimal"
placeholder={String(props.avgifterBasis)}
value={basisStr}
onChange={(e) => setBasisStr(e.target.value)}
disabled={props.disabled || saving}
className="tabular-nums"
/>
<p className="text-[11px] text-muted-foreground">
Beräknat: <span className="tabular-nums">{formatCurrency(props.avgifterBasis)}</span>
</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="override_reason" className="text-xs">
Anledning (krävs vid justering)
</Label>
<Textarea
id="override_reason"
rows={2}
placeholder="T.ex. FoU-avdrag 10 % på arbetsgivaravgifter, jämkning enligt beslut från Skatteverket"
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={props.disabled || saving}
/>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={handleSave} disabled={props.disabled || saving}>
{saving && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
Spara justering
</Button>
{hasOverride && (
<Button
variant="outline"
onClick={handleClear}
disabled={props.disabled || saving}
>
Rensa justering
</Button>
)}
</div>
</CardContent>
)}
</Card>
)
}