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:
co-authored by
Claude Fable 5
parent
3edbf0a2e3
commit
174f315d9c
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user