fix(documents): anchor underlag at deferred SI booking, sanctioned duplicate detach (#1860)

* fix(documents): anchor underlag at deferred SI booking, sanctioned duplicate detach

Support case 2026-08-24: a verifikat booked from a leverantorsfaktura/utlagg
stayed under 'Saknar underlag' with the PDF attached, and a twice-uploaded
underlag could only be replaced, never removed.

- POST /api/supplier-invoices/[id]/book now calls
  anchorSupplierInvoiceDocument() after the CAS link: the deferred (#967)
  flow was the last booking surface that never anchored the invoice's
  retained source document, so every missing-underlag surface kept flagging
  the registration verifikat until payment.
- Repair migration 20260824150000 re-runs the 20260727180000 sweep for rows
  created since (idempotent, open unlocked periods only).
- New detach_underlag_duplicate RPC (migration 20260824151000): the one
  sanctioned path to detach a redundant duplicate underlag from a posted
  verifikat. Guarded: writer role, open unlocked period, company lock date,
  at least one other anchored underlag must remain (BFL 5 kap 7 par), pinned
  docs (transactions/supplier_invoices.document_id) stay replace-only.
  Audit-logged first, transaction-local gnubok.allow_delete carve-out. The
  file is never deleted: it returns to the unlinked pool.
- POST /api/documents/[id]/detach + 'Koppla bort dubblett' in the verifikat
  attachments blocked-dialog when the entry keeps 2+ direct docs (sv+en).
- Tests: book-route anchor assertions, detach route unit tests, pg-real
  suite for the RPC incl. the direct-UPDATE-stays-blocked invariant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(documents): harden detach_underlag_duplicate per skeptic findings

- Require sha256 identity: detach only when a remaining anchored sibling
  carries the same immutable sha256_hash, so only byte-identical duplicates
  ever leave a verifikat (two different handlingar both stay behind the WORM
  guards). UI gates the button on the same condition.
- Enforce the documented posted-status guard (reversed/cancelled verifikat
  refuse detach).
- Set company_id on the RPC's audit_log row: the SELECT policy filters on
  company_id, so the provenance row was invisible to every reader (same
  defect 20260528120600 fixed for delete_last_voucher).
- Swedish 403 message on the tenant guard (CodeRabbit).
- pg tests: closed-period case now seeds open and closes via UPDATE (the
  period-lock trigger blocks seeding into a closed period), duplicate pairs
  share a hash, added non-duplicate and reversed-entry refusals, audit
  assertion pins the RPC's own row (description + company_id + actor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-24 17:05:00 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 3edbf0a2e3
commit 174f315d9c
11 changed files with 804 additions and 1 deletions
@@ -40,6 +40,7 @@ interface DocumentRecord {
mime_type: string | null
storage_path: string
created_at: string
sha256_hash?: string | null
download_url?: string
referenced?: boolean
}
@@ -97,6 +98,7 @@ export default function JournalEntryAttachments({
// so the original stays in the version chain.
const [blockedDoc, setBlockedDoc] = useState<DocumentRecord | null>(null)
const [replacingDocId, setReplacingDocId] = useState<string | null>(null)
const [detachingDocId, setDetachingDocId] = useState<string | null>(null)
const replaceFileInputRef = useRef<HTMLInputElement | null>(null)
const replaceTargetIdRef = useRef<string | null>(null)
@@ -214,6 +216,39 @@ export default function JournalEntryAttachments({
setBlockedDoc(doc)
}
// A duplicate may be detached (not deleted) only when another directly
// anchored doc with the SAME content hash remains on the verifikat: the
// detach_underlag_duplicate RPC enforces sha256 equality, so the button is
// gated on the same condition. Referenced docs (via a supplier invoice)
// don't count: they are not anchored to this entry.
const hasDuplicateSibling = (doc: DocumentRecord) =>
Boolean(doc.sha256_hash) &&
documents.some(
(d) => !d.referenced && d.id !== doc.id && d.sha256_hash === doc.sha256_hash,
)
const handleDetach = async (doc: DocumentRecord) => {
setDetachingDocId(doc.id)
try {
const res = await fetch(`/api/documents/${doc.id}/detach`, { method: 'POST' })
if (!res.ok) {
const { error } = await res.json().catch(() => ({ error: undefined }))
toast({
title: t('detach_failed'),
description: typeof error === 'string' ? error : undefined,
variant: 'destructive',
})
} else {
await fetchDocuments()
setBlockedDoc(null)
}
} catch {
toast({ title: t('detach_failed'), variant: 'destructive' })
} finally {
setDetachingDocId(null)
}
}
const handleOpenReplacePicker = (docId: string) => {
replaceTargetIdRef.current = docId
replaceFileInputRef.current?.click()
@@ -460,7 +495,11 @@ export default function JournalEntryAttachments({
<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-muted-foreground">{t('remove_blocked_hint')}</p>
<p className="text-muted-foreground">
{blockedDoc !== null && hasDuplicateSibling(blockedDoc)
? t('detach_hint')
: t('remove_blocked_hint')}
</p>
</div>
</div>
@@ -468,6 +507,24 @@ export default function JournalEntryAttachments({
<Button variant="outline" onClick={() => setBlockedDoc(null)}>
{t('remove_blocked_cancel_cta')}
</Button>
{blockedDoc !== null && hasDuplicateSibling(blockedDoc) && (
<Button
variant="outline"
onClick={() => {
if (blockedDoc) handleDetach(blockedDoc)
}}
disabled={blockedDoc !== null && detachingDocId === blockedDoc.id}
>
{blockedDoc !== null && detachingDocId === blockedDoc.id ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('detaching')}
</>
) : (
t('detach_cta')
)}
</Button>
)}
<Button
onClick={() => {
if (blockedDoc) handleOpenReplacePicker(blockedDoc.id)