Files
accounted/components/transactions/MatchVoucherDialog.tsx
T
Jakob WennbergandClaude Opus 4.8 b5c3c3ec04 fix(reconciliation): surface bank tx + match to existing verifikat, delete UX, DPA links (#623)
* fix(reconciliation): surface bank transactions via robust cash_account_id scoping

The per-account reconciliation scoping silently returned zero transactions for
companies whose rows were NULL or mis-assigned mid-backfill (e.g. Arcim: 138
transactions, 101 unbooked, yet Bankavstämning showed "0 kr" while the 1930 GL
movement and a large difference still displayed). Two causes, both fixed:

- scopeTransactionsToAccount used a fragile nested or(...,and(is.null,...))
  PostgREST filter. Replace it with a flat, reliable
  `currency = X AND (cash_account_id = id OR cash_account_id IS NULL)` and share
  the one implementation with /api/transactions so the status card and the lists
  can never drift.
- The original best-effort backfill only touched NULL rows and an earlier
  revision mis-assigned cash_account_id (the since-fixed min(uuid) bug), which
  migrations cannot self-correct. Add an idempotent repair migration that
  re-seeds the default 1930 account and re-derives cash_account_id (correcting
  non-NULL mis-assignments) for booked rows and single-account companies.

Also localise manualLink's user-facing errors to Swedish. Adds unit coverage for
the new filter shape and pg-real coverage for the repair (incl. the Arcim
single-account reproduction).

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

* feat(transactions): match a bank transaction to an existing verifikat

Adds a "Matcha mot befintlig verifikation" action to the Transactions inbox so a
bank line that is already booked elsewhere (a salary run, a Fortnox/manual
voucher, an invoice paid from the invoice page) can be linked to that existing
verifikat with no new bokföring — the capability previously lived only in
Reports → Bankavstämning.

- Extract the searchable MatchVerifikationPicker into a shared client component.
- New MatchVoucherDialog: resolves the tx's cash account, fetches candidates
  ranked server-side by reconciliation confidence, links via
  /api/reconciliation/bank/link (so the link is undoable in Bankavstämning).
- unmatched-entries route gains an optional transaction_id that ranks candidates
  (ranking stays server-side; the recon lib is not client-safe).
- Inbox row's overflow (⋯) menu gains the new action.

Also fixes the Bankavstämning view: editing the date no longer auto-reloads
(applies on Filtrera / account change only) and Datum till defaults to today.

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

* fix(transactions): actionable Swedish errors when a bank tx cannot be deleted

The delete route returned a hardcoded English 409 that getErrorMessage mapped to
the misleading generic "En konflikt uppstod. Ladda om sidan...". Return
structured bilingual envelopes instead:

- TRANSACTION_DELETE_BOOKED (409) for a booked/linked row — steers the user to
  unlink in Bankavstämning or storna the voucher.
- TRANSACTION_DELETE_HAS_AUDIT_TRAIL (409) for the real, common case where an
  unbooked row carries payment_match_log rows: the cascade hits the
  audit-immutability trigger (P0001), previously surfaced as a bare 500. Steers
  the user to match-to-voucher or ignore instead.

Updates the DELETE test suite and adds the P0001 case.

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

* feat(settings): surface DPA and privacy policy links

The /dpa page (personuppgiftsbiträdesavtal, GDPR Art. 28) was complete but
linked from nowhere. Add a "Sekretess och avtal" card on Inställningar → Konto
linking to /privacy and /dpa, and a reciprocal link to the DPA from the privacy
policy's sub-processor section. (The DPA already links back to /privacy.)

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

* fix(review): address PR #623 feedback

- unmatched-entries: when transaction_id is supplied but resolves to no row in
  the caller's company, return an empty candidate list instead of silently
  falling back to the full unranked list (Compliance Swarm V8.2.1, high).
- MatchVoucherDialog: preserve a manually-picked voucher when the candidate
  list reloads (e.g. "Visa alla datum") instead of discarding it (Greptile P2).
- DELETE /api/transactions/[id]: return the 404 as the structured
  { error: { code, message, message_en } } envelope like the handler's other
  errors, for a uniform contract (Greptile P2). Test updated.

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

* feat(bookkeeping): allow editing notes on a committed journal entry

Saving a note on a posted verifikation failed with "Committed entries are
immutable": enforce_journal_entry_immutability() had no posted→posted path, so a
notes-only UPDATE fell through to the final RAISE. `notes` is internal annotation
metadata (not verifikation content under BFL 5 kap. / BFNAR 2013:2), so add a
narrow carve-out that permits a notes-only change on a committed entry — verified
with a whole-row to_jsonb() diff so any other field change still raises, and only
when status is unchanged. Period-lock enforcement is unaffected.

CREATE OR REPLACE in a new migration (same pattern as
20260428160000_fix_journal_entry_immutability_delete_bypass); the migration-017
protections are extended, never weakened. Covered by a pg-real test asserting a
notes edit succeeds while amount/description/account edits still fail.

(Already applied to production; committing the file + test for repo consistency.)

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

* fix(sandbox): make pre-staged pending operations executor-complete

The seeded pending_operations existed only as display previews — approving them
failed because commit executors in lib/pending-operations/commit.ts validate
required fields on "Godkänn". Seed a backing invoice_inbox_items row and fill the
supplier-invoice and categorize params with every field the executors require
(inbox_item_id, full items array; real uncategorized transaction_id + category),
so the sandbox approval queue is actually approvable end to end.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:08:06 +02:00

276 lines
10 KiB
TypeScript

'use client'
import { useCallback, useEffect, useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
MatchVerifikationPicker,
type UnlinkedGLLine,
} from '@/components/reconciliation/MatchVerifikationPicker'
import { formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { useToast } from '@/components/ui/use-toast'
import { ArrowUpRight, ArrowDownRight, Loader2 } from 'lucide-react'
import type { TransactionWithInvoice } from './transaction-types'
import type { CashAccount } from '@/types'
interface MatchVoucherDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
/** Called after a successful link. voucherLabel is the picked verifikat's label (e.g. "A-42"). */
onLinked: (transactionId: string, journalEntryId: string, voucherLabel: string) => void
}
// ±30 days around the transaction date — wide enough to catch a salary or
// supplier voucher booked a few days off the bank value date, narrow enough to
// keep the candidate list short. "Visa alla" drops the window entirely.
const WINDOW_DAYS = 30
function shiftDate(isoDate: string, deltaDays: number): string {
const d = new Date(isoDate)
if (Number.isNaN(d.getTime())) return isoDate
d.setDate(d.getDate() + deltaDays)
return d.toISOString().slice(0, 10)
}
/** Resolve which cash account (BAS ledger number) this transaction reconciles against. */
function resolveAccount(
cashAccounts: CashAccount[],
tx: TransactionWithInvoice,
): { account: string; fallback: boolean } {
// 1. Bound row → its own account.
if (tx.cash_account_id) {
const bound = cashAccounts.find((a) => a.id === tx.cash_account_id)
if (bound) return { account: bound.ledger_account, fallback: false }
}
// 2. Legacy NULL → the sole enabled account of the transaction's currency.
const sameCurrency = cashAccounts.filter((a) => a.enabled && a.currency === tx.currency)
if (sameCurrency.length === 1) return { account: sameCurrency[0].ledger_account, fallback: false }
// 3. Give up gracefully on 1930 (the default SEK företagskonto).
return { account: '1930', fallback: true }
}
export function MatchVoucherDialog({
open,
onOpenChange,
transaction,
onLinked,
}: MatchVoucherDialogProps) {
const { toast } = useToast()
const [glLines, setGlLines] = useState<UnlinkedGLLine[]>([])
const [selected, setSelected] = useState('')
const [accountNumber, setAccountNumber] = useState('1930')
const [accountFallback, setAccountFallback] = useState(false)
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [wideRange, setWideRange] = useState(false)
const loadCandidates = useCallback(
async (tx: TransactionWithInvoice, wide: boolean) => {
setLoading(true)
try {
// Resolve the settlement account from the company's cash accounts.
let account = '1930'
let fallback = true
try {
const caRes = await fetch('/api/cash-accounts')
const caJson = await caRes.json()
const accounts = (caJson.data ?? []) as CashAccount[]
const resolved = resolveAccount(accounts, tx)
account = resolved.account
fallback = resolved.fallback
} catch {
// Network hiccup — fall back to 1930 and let the user see the note.
}
setAccountNumber(account)
setAccountFallback(fallback)
const params = new URLSearchParams()
params.set('account_number', account)
params.set('transaction_id', tx.id)
if (!wide) {
params.set('date_from', shiftDate(tx.date, -WINDOW_DAYS))
params.set('date_to', shiftDate(tx.date, WINDOW_DAYS))
}
const res = await fetch(`/api/reconciliation/bank/unmatched-entries?${params}`)
const json = await res.json()
const lines = (json.data ?? []) as UnlinkedGLLine[]
setGlLines(lines)
// Pre-select a strong auto-match (exact/reference/date-range) so the
// common case is one click. Fuzzy (<0.85) is left for the user to confirm.
// Auto-select a strong match only when nothing is chosen yet. Toggling
// "Visa alla datum" reloads with a wider set — it must NOT discard a
// voucher the user already picked. (selected resets to '' on close.)
const top = lines[0]
setSelected((prev) =>
prev ? prev : top && (top.confidence ?? 0) >= 0.85 ? top.journal_entry_id : '',
)
} finally {
setLoading(false)
}
},
[],
)
// (Re)load whenever the dialog opens for a transaction, or the range widens.
useEffect(() => {
if (!open || !transaction) return
void loadCandidates(transaction, wideRange)
}, [open, transaction, wideRange, loadCandidates])
// Reset transient state when the dialog closes so the next open starts clean.
useEffect(() => {
if (open) return
setGlLines([])
setSelected('')
setWideRange(false)
setAccountFallback(false)
}, [open])
if (!transaction) return null
const isIncome = transaction.amount > 0
const selectedLine = glLines.find((l) => l.journal_entry_id === selected) ?? null
async function handleConfirm() {
if (!transaction || !selected) return
setSubmitting(true)
try {
const res = await fetch('/api/reconciliation/bank/link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transaction_id: transaction.id,
journal_entry_id: selected,
account_number: accountNumber,
}),
})
const result = await res.json()
if (!res.ok || result.error) {
toast({
title: 'Kunde inte koppla',
description: getErrorMessage(result, { context: 'transaction', statusCode: res.status }),
variant: 'destructive',
})
return
}
const label = selectedLine ? formatVoucher(selectedLine) : ''
onLinked(transaction.id, selected, label)
} catch {
toast({
title: 'Kunde inte koppla',
description: 'Ett fel uppstod. Försök igen.',
variant: 'destructive',
})
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Matcha mot befintlig verifikation</DialogTitle>
<DialogDescription>
Koppla bankhändelsen till en verifikation som redan är bokförd (t.ex. en
lön eller en post importerad från Fortnox). Ingen ny bokföring skapas.
</DialogDescription>
</DialogHeader>
{/* Transaction summary */}
<div className="flex items-center gap-3 rounded-lg border border-border bg-secondary/30 px-3 py-2.5 text-sm">
<span
className={isIncome ? 'text-success' : 'text-foreground/60'}
aria-hidden
>
{isIncome ? <ArrowUpRight className="h-4 w-4" /> : <ArrowDownRight className="h-4 w-4" />}
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{transaction.description}</p>
<p className="text-xs text-muted-foreground tabular-nums">{formatDate(transaction.date)}</p>
</div>
<span className={`font-medium tabular-nums ${isIncome ? 'text-success' : ''}`}>
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</span>
</div>
{accountFallback && (
<p className="text-xs text-muted-foreground">
Avstämning mot 1930. Hör transaktionen till ett annat bankkonto? Stäm av
det under Rapporter → Bankavstämning.
</p>
)}
{/* Candidate picker */}
<div className="space-y-2">
{loading ? (
<div className="flex items-center justify-center gap-2 rounded-lg border border-border py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Söker verifikationer…
</div>
) : glLines.length === 0 ? (
<div className="rounded-lg border border-border px-3 py-6 text-center text-sm text-muted-foreground">
<p>Inga omatchade verifikationer på {accountNumber} i perioden.</p>
{!wideRange && (
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={() => setWideRange(true)}
>
Visa alla datum
</Button>
)}
</div>
) : (
<>
{selectedLine && (selectedLine.confidence ?? 0) >= 0.85 && (
<Badge variant="success" className="mb-1">Föreslagen träff</Badge>
)}
<MatchVerifikationPicker glLines={glLines} value={selected} onChange={setSelected} />
{!wideRange && (
<button
type="button"
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
onClick={() => setWideRange(true)}
>
Hittar du inte verifikationen? Visa alla datum
</button>
)}
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
Avbryt
</Button>
<Button onClick={handleConfirm} disabled={!selected || submitting}>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Kopplar…
</>
) : (
'Koppla'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}