Files
accounted/app/api/documents/[id]/detach/route.ts
T
MattssonandClaude Fable 5 174f315d9c 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>
2026-08-24 17:05:00 +02:00

49 lines
2.0 KiB
TypeScript

import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/documents/[id]/detach
*
* Detach a redundant duplicate underlag from its posted verifikation. The
* detach_underlag_duplicate RPC enforces everything (writer role, open and
* unlocked period, company lock date, at least one other anchored underlag
* remaining, not pinned to a transaction or supplier invoice) and writes an
* append-only audit_log row before the carve-out UPDATE. The document itself
* is never deleted: it returns to the unlinked document pool, where the
* ordinary deletion rules apply (an unlinked doc may be deleted).
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'documents.detach',
async (_request, { supabase, companyId, user, log }, { params }) => {
const { id } = await params
const { data, error } = await supabase.rpc('detach_underlag_duplicate', {
p_company_id: companyId,
p_document_id: id,
p_user_id: user.id,
})
if (error) {
// Rule violations are plain RAISE EXCEPTION (P0001) with user-facing
// Swedish messages: surface verbatim as 409. Tenant guard raises 42501.
if (error.code === 'P0001') {
return NextResponse.json({ error: getErrorMessage(error) }, { status: 409 })
}
if (error.code === '42501') {
// The RPC's tenant-guard message is English (log/diagnostic text);
// getErrorMessage would pass it through verbatim, so map it here.
return NextResponse.json(
{ error: 'Du saknar behörighet att ändra underlag i det här företaget.' },
{ status: 403 },
)
}
log.error('detach_underlag_duplicate failed', new Error(error.message), { documentId: id })
return NextResponse.json({ error: 'Underlaget kunde inte kopplas bort' }, { status: 500 })
}
return NextResponse.json({ data })
},
{ requireWrite: true },
)