Files
accounted/components/bookkeeping/AddAccountDialog.tsx
T
Jakob Wennberg 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

235 lines
8.1 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Loader2, AlertTriangle } from 'lucide-react'
import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
import type { BASAccount } from '@/types'
interface AddAccountDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: (account: BASAccount) => void
initialAccountNumber?: string
initialAccountName?: string
}
export function AddAccountDialog({
open,
onOpenChange,
onCreated,
initialAccountNumber,
initialAccountName,
}: AddAccountDialogProps) {
const [accountNumber, setAccountNumber] = useState('')
const [accountName, setAccountName] = useState('')
const [description, setDescription] = useState('')
const [defaultVatCode, setDefaultVatCode] = useState('')
const [sruCode, setSruCode] = useState('')
const [normalBalance, setNormalBalance] = useState<'debit' | 'credit'>('debit')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
// Apply prefill values whenever the dialog opens. Resetting on close happens
// implicitly after a successful create; here we only need to seed inputs so
// the user doesn't retype what the combobox already captured.
useEffect(() => {
if (!open) return
const num = (initialAccountNumber ?? '').replace(/\D/g, '').slice(0, 4)
setAccountNumber(num)
setAccountName(initialAccountName ?? '')
setError('')
if (num.length === 4) {
setNormalBalance(classifyAccount(num).normal_balance)
}
}, [open, initialAccountNumber, initialAccountName])
const isBASMatch = accountNumber.length === 4 && isStandardBASAccount(accountNumber)
const derived = accountNumber.length === 4 ? classifyAccount(accountNumber) : null
async function handleCreate() {
setError('')
if (!/^\d{4}$/.test(accountNumber)) {
setError('Kontonumret måste vara exakt 4 siffror')
return
}
if (!accountName.trim()) {
setError('Kontonamn krävs')
return
}
setIsSaving(true)
try {
const response = await fetch('/api/bookkeeping/accounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
account_number: accountNumber,
account_name: accountName.trim(),
account_type: derived?.account_type || 'expense',
normal_balance: normalBalance,
description: description || null,
default_vat_code: defaultVatCode || null,
sru_code: sruCode || null,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Kunde inte skapa kontot')
}
const { data: createdAccount } = await response.json() as { data: BASAccount }
// Reset form
setAccountNumber('')
setAccountName('')
setDescription('')
setDefaultVatCode('')
setSruCode('')
onCreated(createdAccount)
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Något gick fel')
} finally {
setIsSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Lägg till eget konto</DialogTitle>
<DialogDescription>
Skapa ett eget konto utanför BAS-standarden
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{isBASMatch && (
<div className="flex items-start gap-2 rounded-lg bg-warning/10 border border-warning/30 p-3">
<AlertTriangle className="h-4 w-4 text-warning-foreground mt-0.5 shrink-0" />
<p className="text-sm text-warning-foreground">
Kontonummer {accountNumber} finns i BAS-standarden. Använd &quot;BAS-katalog&quot;-fliken för att aktivera standardkonton istället.
</p>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Kontonummer</Label>
<Input
value={accountNumber}
onChange={(e) => {
const v = e.target.value.replace(/\D/g, '').slice(0, 4)
setAccountNumber(v)
if (v.length === 4) {
setNormalBalance(classifyAccount(v).normal_balance)
}
}}
placeholder="T.ex. 1935"
maxLength={4}
className="font-mono"
/>
</div>
<div className="space-y-2">
<Label>Normal saldo</Label>
<Select value={normalBalance} onValueChange={(v) => { if (v) setNormalBalance(v as 'debit' | 'credit') }}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="debit">Debet</SelectItem>
<SelectItem value="credit">Kredit</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{derived && (
<p className="text-xs text-muted-foreground">
Auto-detekterad typ:{' '}
<span className="font-medium">
{derived.account_type === 'asset' ? 'Tillgång'
: derived.account_type === 'liability' ? 'Skuld'
: derived.account_type === 'equity' ? 'Eget kapital'
: derived.account_type === 'untaxed_reserves' ? 'Obeskattade reserver'
: derived.account_type === 'revenue' ? 'Intäkt'
: 'Kostnad'}
</span>
</p>
)}
<div className="space-y-2">
<Label>Kontonamn</Label>
<Input
value={accountName}
onChange={(e) => setAccountName(e.target.value)}
placeholder="T.ex. Sparkonto företag"
/>
</div>
<div className="space-y-2">
<Label>Beskrivning <span className="text-muted-foreground">(valfritt)</span></Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Kort beskrivning av kontots användning"
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Momskod <span className="text-muted-foreground">(valfritt)</span></Label>
<Input
value={defaultVatCode}
onChange={(e) => setDefaultVatCode(e.target.value)}
placeholder="T.ex. MP1"
/>
</div>
<div className="space-y-2">
<Label>SRU-kod <span className="text-muted-foreground">(valfritt)</span></Label>
<Input
value={sruCode}
onChange={(e) => setSruCode(e.target.value)}
placeholder="T.ex. 7201"
/>
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button onClick={handleCreate} disabled={isSaving || accountNumber.length !== 4 || !accountName.trim()}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Skapa konto
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}