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:
@@ -1188,4 +1188,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-24] Declared currency/voucher_series nullable in three MCP listing schemas on column-nullability alone (no traced null producer): loosening an output schema can only stop false validation failures, never cause one, and legacy rows predate the columns' defaults. Declined (for now) a full Ajv execute-vs-schema round-trip harness in output-schema.test.ts: right long-term answer to this bug class, but a session-sized project of its own; the audit's seven confirmed sites are pinned by a targeted declaration test instead.
|
||||
[2026-08-24] Manual matching (PR 6b) ships N:1 only (many outside rows -> one verifikat): bank links are independent per transaction (the engine allows it by design), skattekonto groups are all-or-nothing with the sum settling the verifikat (one guarded UPDATE, partial hit rolled back). 1:M (one row over several verifikat) and residual booking wait for a link table in 6c: the single journal_entry_id pointer on both row kinds cannot express them, and faking it (pointing the row at the residual verifikat) would break the bridge. The worksheet therefore enables Koppla only when the selection nets to zero and says so otherwise.
|
||||
[2026-08-24] Skattekonto payment file gets pain.001 through the supplier-payment generator (generateSupplierPain001), not the salary pain001 generator: the payment is a plain BG+OCR giro transfer (no SALA CtgyPurp), and the supplier dialect is the Validex-validated shape for exactly that; the LB path stays the default so nothing changes for banks still on LB.
|
||||
[2026-08-24] Detach-duplicate underlag ships as a SECURITY DEFINER RPC (detach_underlag_duplicate) instead of loosening the document triggers: the WORM guards stay intact for every other path, the carve-out is transaction-local (gnubok.allow_delete) and audit-logged first, and detach is refused unless another anchored underlag remains on the verifikat (BFL 5 kap 7 par) AND a remaining sibling has an identical sha256_hash (only byte-identical duplicates detach; skeptic-hardened 2026-08-24, along with an enforced posted-status guard and company_id on the audit row). Pinned docs (transactions.document_id / supplier_invoices.document_id) stay replace-only.
|
||||
[2026-08-24] Single-call chat console (general.help, AskConsole → /api/agent/ask) now carries the thread's earlier turns into every model call, via a new optional `history` on the provider-agnostic GenerateTextRequest (real message turns before the prompt in BOTH adapters: Anthropic-family messages array, OpenAI-compatible via AI SDK `messages`; an absent/empty history leaves the request byte-identical to the single-turn call, so hosted extraction and every other caller are untouched). The 08-20 RIP-3 cutover made each turn stateless (conversationId was only the tool actor id), so a follow-up in a resumed thread was answered blind (user report: "frågar vad jag refererar till"). History is loaded server-side from agent_messages (loadChatHistory: text only, hidden + tool rows dropped, alternation repaired, newest 16 rows / 10k chars) rather than sent by the client, so the client cannot forge earlier turns and old streaming threads replay cleanly. Rejected: inlining a transcript into the prompt (works everywhere but weaker turn semantics and blurs data vs instructions) and loading history in AskConsole (client-trusted history). Separately: the docked assistant panel now remembers its open thread per tab in sessionStorage (lib/agent-panel/session-restore) and reopens it after a full reload (the deploy prompt's "Ladda om" wiped it); sessionStorage, not user_preferences, because this is this-tab-this-session state that must not follow the user to other devices or tabs. And DeployReloadPrompt's full-width wrapper gets pointer-events-none: at z-[60] after the panel in DOM order it swallowed clicks on the panel's composer ("går ej att skriva").
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Tests for POST /api/documents/[id]/detach (detach a duplicate underlag from
|
||||
* a posted verifikat via the audited detach_underlag_duplicate RPC).
|
||||
*
|
||||
* Covers: 401, rule-violation 409 passthrough (Swedish RPC messages verbatim),
|
||||
* tenant guard 403, unexpected RPC failure 500, and the happy path.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase, reset } = createQueuedMockSupabase()
|
||||
|
||||
const rpcMock = vi.fn()
|
||||
;(supabase as { rpc?: unknown }).rpc = rpcMock
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const params = () => createMockRouteParams({ id: 'doc-1' })
|
||||
|
||||
function makeRequest() {
|
||||
return createMockRequest('/api/documents/doc-1/detach', { method: 'POST' })
|
||||
}
|
||||
|
||||
describe('POST /api/documents/[id]/detach', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await POST(makeRequest(), params())
|
||||
expect(response.status).toBe(401)
|
||||
expect(rpcMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes rule violations through as 409 with the Swedish message', async () => {
|
||||
rpcMock.mockResolvedValue({
|
||||
data: null,
|
||||
error: {
|
||||
code: 'P0001',
|
||||
message:
|
||||
'Verifikationen skulle stå utan underlag: det sista underlaget kan inte kopplas bort. Ersätt det med en ny version i stället.',
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(makeRequest(), params())
|
||||
const { body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(body.error).toContain('sista underlaget')
|
||||
})
|
||||
|
||||
it('maps the tenant guard (42501) to 403', async () => {
|
||||
rpcMock.mockResolvedValue({
|
||||
data: null,
|
||||
error: { code: '42501', message: 'unauthorized: caller is not a member of company company-1' },
|
||||
})
|
||||
|
||||
const response = await POST(makeRequest(), params())
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 500 on unexpected RPC failure', async () => {
|
||||
rpcMock.mockResolvedValue({
|
||||
data: null,
|
||||
error: { code: '57014', message: 'canceling statement due to statement timeout' },
|
||||
})
|
||||
|
||||
const response = await POST(makeRequest(), params())
|
||||
expect(response.status).toBe(500)
|
||||
})
|
||||
|
||||
it('detaches the duplicate and returns the RPC result', async () => {
|
||||
rpcMock.mockResolvedValue({
|
||||
data: {
|
||||
detached: true,
|
||||
document_id: 'doc-1',
|
||||
journal_entry_id: 'je-1',
|
||||
remaining_documents: 1,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const response = await POST(makeRequest(), params())
|
||||
const { body } = await parseJsonResponse<{
|
||||
data: { detached: boolean; journal_entry_id: string; remaining_documents: number }
|
||||
}>(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data.detached).toBe(true)
|
||||
expect(body.data.remaining_documents).toBe(1)
|
||||
expect(rpcMock).toHaveBeenCalledWith('detach_underlag_duplicate', {
|
||||
p_company_id: 'company-1',
|
||||
p_document_id: 'doc-1',
|
||||
p_user_id: 'user-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
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 },
|
||||
)
|
||||
@@ -39,6 +39,11 @@ vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({
|
||||
cancelOrphanedPaymentEntry: (...args: unknown[]) => mockCancelOrphan(...args),
|
||||
}))
|
||||
|
||||
const mockAnchorDocument = vi.fn()
|
||||
vi.mock('@/lib/core/documents/supplier-invoice-underlag', () => ({
|
||||
anchorSupplierInvoiceDocument: (...args: unknown[]) => mockAnchorDocument(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
@@ -152,6 +157,7 @@ describe('POST /api/supplier-invoices/[id]/book', () => {
|
||||
'je-1',
|
||||
expect.any(String),
|
||||
)
|
||||
expect(mockAnchorDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('books the registration entry and links it', async () => {
|
||||
@@ -172,6 +178,9 @@ describe('POST /api/supplier-invoices/[id]/book', () => {
|
||||
expect(mockCreateRegistrationEntry).toHaveBeenCalled()
|
||||
// No accrual items on the fixture, so no schedule creation.
|
||||
expect(mockCreateSchedules).not.toHaveBeenCalled()
|
||||
// The invoice's retained source document is anchored to the fresh
|
||||
// registration verifikat (no-op inside the helper when there is none).
|
||||
expect(mockAnchorDocument).toHaveBeenCalledWith(mockSupabase, 'company-1', 'si-1')
|
||||
})
|
||||
|
||||
it('creates accrual schedules and surfaces failures as warnings', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { anchorSupplierInvoiceDocument } from '@/lib/core/documents/supplier-invoice-underlag'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
// Statuses where the registration entry can still be created afterwards.
|
||||
@@ -122,6 +123,14 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode('SI_BOOK_CONFLICT', log, { requestId })
|
||||
}
|
||||
|
||||
// The invoice's retained source document (attached at registration) has
|
||||
// been floating until now: the deferred flow books later, and every
|
||||
// missing-underlag surface only accepts an ANCHORED doc
|
||||
// (document_attachments.journal_entry_id set). Anchor it to the fresh
|
||||
// registration verifikat, same as the create route does when it books
|
||||
// immediately. Never throws; a no-op when the invoice has no document.
|
||||
await anchorSupplierInvoiceDocument(supabase, companyId!, id)
|
||||
|
||||
// Periodiseringar ride on the registration entry, so they can only be
|
||||
// created now. Non-blocking: the entry is committed (immutable); a
|
||||
// schedule failure is surfaced as a warning and retried from the
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5040,6 +5040,10 @@
|
||||
"remove_blocked_hint": "If the document needs correction, upload a new version. The existing one is preserved in the version history.",
|
||||
"remove_blocked_replace_cta": "Upload new version",
|
||||
"remove_blocked_cancel_cta": "Close",
|
||||
"detach_hint": "The same file is uploaded more than once on this verifikation. The duplicate can be detached: the file is not deleted, it returns to your documents.",
|
||||
"detach_cta": "Detach duplicate",
|
||||
"detaching": "Detaching...",
|
||||
"detach_failed": "Could not detach the document.",
|
||||
"replace_uploading": "Replacing...",
|
||||
"remove_failed": "Could not remove the document.",
|
||||
"replace_failed": "Could not upload new version.",
|
||||
|
||||
@@ -5040,6 +5040,10 @@
|
||||
"remove_blocked_hint": "Behöver underlaget korrigeras: ladda upp en ny version. Den befintliga bevaras då i versionshistoriken.",
|
||||
"remove_blocked_replace_cta": "Ladda upp ny version",
|
||||
"remove_blocked_cancel_cta": "Stäng",
|
||||
"detach_hint": "Samma fil finns uppladdad flera gånger på verifikationen. Dubbletten kan kopplas bort: filen raderas inte utan hamnar bland dina dokument.",
|
||||
"detach_cta": "Koppla bort dubblett",
|
||||
"detaching": "Kopplar bort...",
|
||||
"detach_failed": "Underlaget kunde inte kopplas bort.",
|
||||
"replace_uploading": "Ersätter...",
|
||||
"remove_failed": "Kunde inte ta bort underlaget.",
|
||||
"replace_failed": "Kunde inte ladda upp ny version.",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
-- Re-anchor floating supplier-invoice source documents, round 2.
|
||||
--
|
||||
-- Same repair as 20260727180000 (support case 2026-07-27, MGS Sweden), re-run
|
||||
-- because one booking surface kept producing floating documents after that
|
||||
-- sweep: POST /api/supplier-invoices/[id]/book (the deferred #967 flow) set
|
||||
-- registration_journal_entry_id without ever anchoring the invoice's retained
|
||||
-- source document. Support case 2026-08-24 (verifikat booked from a
|
||||
-- leverantorsfaktura/utlagg stuck under "Saknar underlag" with the PDF
|
||||
-- plainly attached) is this exact state. The code half of the fix adds
|
||||
-- anchorSupplierInvoiceDocument() to that route in the same change; this
|
||||
-- migration repairs the rows created before it ships.
|
||||
--
|
||||
-- The sweep is idempotent and strictly protective: only currently-unlinked
|
||||
-- current-version documents (never steal a doc that already serves a
|
||||
-- verifikat), only into posted verifikat in open, unlocked periods
|
||||
-- (enforce_period_lock_documents raises otherwise), NULL -> uuid is the
|
||||
-- explicitly permitted direction in enforce_document_journal_entry_immutability.
|
||||
--
|
||||
-- Preference order matches lib/core/documents/supplier-invoice-underlag.ts:
|
||||
-- registration booking first (the primary booking of the affarshandelse),
|
||||
-- payment booking second (the only booking under kontantmetoden), then
|
||||
-- partial-payment verifikat, oldest first.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_updated integer;
|
||||
BEGIN
|
||||
WITH candidate AS (
|
||||
SELECT
|
||||
si.document_id,
|
||||
si.company_id,
|
||||
je.id AS journal_entry_id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY si.document_id
|
||||
ORDER BY rank_source, coalesce(sip.payment_date, je.entry_date), je.id
|
||||
) AS pick
|
||||
FROM supplier_invoices si
|
||||
JOIN document_attachments d
|
||||
ON d.id = si.document_id
|
||||
AND d.company_id = si.company_id
|
||||
AND d.journal_entry_id IS NULL
|
||||
AND d.is_current_version = true
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT si.registration_journal_entry_id AS entry_id, 1 AS rank_source, NULL::uuid AS payment_id
|
||||
UNION ALL
|
||||
SELECT si.payment_journal_entry_id, 2, NULL::uuid
|
||||
UNION ALL
|
||||
SELECT p.journal_entry_id, 3, p.id
|
||||
FROM supplier_invoice_payments p
|
||||
WHERE p.supplier_invoice_id = si.id
|
||||
AND p.company_id = si.company_id
|
||||
AND p.journal_entry_id IS NOT NULL
|
||||
) AS src(entry_id, rank_source, payment_id)
|
||||
LEFT JOIN supplier_invoice_payments sip ON sip.id = src.payment_id
|
||||
JOIN journal_entries je
|
||||
ON je.id = src.entry_id
|
||||
AND je.company_id = si.company_id
|
||||
AND je.status = 'posted'
|
||||
JOIN fiscal_periods fp
|
||||
ON fp.id = je.fiscal_period_id
|
||||
AND fp.is_closed = false
|
||||
AND fp.locked_at IS NULL
|
||||
)
|
||||
UPDATE document_attachments d
|
||||
SET journal_entry_id = candidate.journal_entry_id
|
||||
FROM candidate
|
||||
WHERE candidate.pick = 1
|
||||
AND d.id = candidate.document_id
|
||||
AND d.company_id = candidate.company_id
|
||||
AND d.journal_entry_id IS NULL
|
||||
AND d.is_current_version = true;
|
||||
|
||||
GET DIAGNOSTICS v_updated = ROW_COUNT;
|
||||
RAISE NOTICE 're-anchored % floating supplier-invoice documents to a posted verifikat', v_updated;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,210 @@
|
||||
-- detach_underlag_duplicate: the ONE sanctioned path for removing a redundant
|
||||
-- duplicate underlag from a posted verifikation.
|
||||
--
|
||||
-- Problem (support case 2026-08-24): a user uploaded the same underlag twice
|
||||
-- to one verifikat. Both uploads anchored (document_attachments.journal_entry_id
|
||||
-- set), which puts them behind the WORM guards: block_document_deletion blocks
|
||||
-- DELETE and enforce_document_journal_entry_immutability blocks clearing the
|
||||
-- anchor. The UI could therefore only offer "Ersatt med ny version", a dead
|
||||
-- end for a plain duplicate.
|
||||
--
|
||||
-- Legal analysis: BFL 5 kap 7 par requires the verifikation to reference its
|
||||
-- underlag, and BFL 7 kap 2 par protects rakenskapsinformation for 7 years.
|
||||
-- Neither requires TWO copies of the SAME underlag to stay bound to the
|
||||
-- verifikat. That identity condition is enforced, not assumed: the detached
|
||||
-- document must have a remaining anchored sibling with an identical
|
||||
-- sha256_hash (the hash is immutable per 20260506150000), so only a
|
||||
-- byte-identical duplicate ever leaves the verifikat. Two DIFFERENT
|
||||
-- handlingar on one verifikation (faktura + betalkvitto) are each
|
||||
-- rakenskapsinformation and both stay behind the WORM guards. The detached
|
||||
-- file itself is NOT deleted: it returns to the company's unlinked document
|
||||
-- pool (storage object and version chain untouched), where the ordinary
|
||||
-- deleteDocument() rules apply. The operation is recorded in the append-only
|
||||
-- audit_log before the write, mirroring correct_entry_metadata's log-first
|
||||
-- ordering.
|
||||
--
|
||||
-- Guards, in order:
|
||||
-- 1. caller must be an owner/admin/member of the company (JWT paths verify
|
||||
-- membership via caller_is_company_member; p_user_id is only honored for
|
||||
-- service-role callers, which authenticate the user application-side);
|
||||
-- 2. the document must belong to the company, be the current version, and
|
||||
-- be anchored to a POSTED journal entry of the same company (a reversed
|
||||
-- or cancelled verifikat is corrected through storno, never edited);
|
||||
-- 3. the entry's fiscal period must be open and unlocked, and the entry
|
||||
-- date must be after the company lock date (same rattelse window as
|
||||
-- inline rattelse: past a lock, storno is the only path);
|
||||
-- 4. at least one OTHER current-version document must remain anchored to
|
||||
-- the same journal entry (the verifikat never loses its last underlag),
|
||||
-- and at least one of those siblings must carry the SAME sha256_hash
|
||||
-- (only true duplicates are detachable);
|
||||
-- 5. the document must not be pinned as transactions.document_id or
|
||||
-- supplier_invoices.document_id: those pins have their own immutability
|
||||
-- rules and consumers, so a pinned doc is replaced, never detached.
|
||||
--
|
||||
-- The gnubok.allow_delete carve-out is transaction-local and only set after
|
||||
-- every guard has passed and the audit row is written, identical in spirit to
|
||||
-- delete_last_voucher (20260506140000).
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.detach_underlag_duplicate(
|
||||
p_company_id uuid,
|
||||
p_document_id uuid,
|
||||
p_user_id uuid DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
|
||||
v_actor uuid := COALESCE(p_user_id, auth.uid());
|
||||
v_caller_role text;
|
||||
v_doc record;
|
||||
v_entry record;
|
||||
v_is_closed boolean;
|
||||
v_locked_at timestamptz;
|
||||
v_lock_date date;
|
||||
v_siblings integer;
|
||||
v_duplicates integer;
|
||||
v_pinned_tx uuid;
|
||||
v_pinned_si uuid;
|
||||
BEGIN
|
||||
IF v_jwt_role IN ('anon', 'authenticated') THEN
|
||||
IF NOT public.caller_is_company_member(p_company_id) THEN
|
||||
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
-- A JWT caller can never act as someone else: p_user_id is only for
|
||||
-- service-role paths, which authenticate the user application-side.
|
||||
v_actor := auth.uid();
|
||||
END IF;
|
||||
|
||||
SELECT cm.role INTO v_caller_role
|
||||
FROM company_members cm
|
||||
WHERE cm.company_id = p_company_id AND cm.user_id = v_actor;
|
||||
|
||||
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin', 'member') THEN
|
||||
RAISE EXCEPTION 'Endast användare med skrivbehörighet kan koppla bort underlag.';
|
||||
END IF;
|
||||
|
||||
SELECT d.id, d.company_id, d.journal_entry_id, d.file_name, d.is_current_version, d.sha256_hash
|
||||
INTO v_doc
|
||||
FROM public.document_attachments d
|
||||
WHERE d.id = p_document_id
|
||||
FOR UPDATE OF d;
|
||||
|
||||
IF NOT FOUND OR v_doc.company_id <> p_company_id THEN
|
||||
RAISE EXCEPTION 'Underlaget hittades inte.';
|
||||
END IF;
|
||||
IF v_doc.journal_entry_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Underlaget är inte kopplat till någon verifikation.';
|
||||
END IF;
|
||||
IF v_doc.is_current_version IS DISTINCT FROM true THEN
|
||||
RAISE EXCEPTION 'Endast den aktuella versionen av ett underlag kan kopplas bort.';
|
||||
END IF;
|
||||
|
||||
SELECT je.id, je.entry_date, je.status, je.fiscal_period_id, je.company_id AS entry_company_id
|
||||
INTO v_entry
|
||||
FROM public.journal_entries je
|
||||
WHERE je.id = v_doc.journal_entry_id
|
||||
FOR UPDATE OF je;
|
||||
|
||||
IF NOT FOUND OR v_entry.entry_company_id <> p_company_id THEN
|
||||
RAISE EXCEPTION 'Verifikationen hittades inte.';
|
||||
END IF;
|
||||
|
||||
IF v_entry.status <> 'posted' THEN
|
||||
RAISE EXCEPTION 'Underlag kan bara kopplas bort från bokförda verifikat.';
|
||||
END IF;
|
||||
|
||||
SELECT fp.is_closed, fp.locked_at
|
||||
INTO v_is_closed, v_locked_at
|
||||
FROM public.fiscal_periods fp
|
||||
WHERE fp.id = v_entry.fiscal_period_id;
|
||||
|
||||
IF v_is_closed OR v_locked_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Perioden är stängd eller låst: underlaget kan inte kopplas bort.';
|
||||
END IF;
|
||||
|
||||
SELECT cs.bookkeeping_locked_through INTO v_lock_date
|
||||
FROM public.company_settings cs
|
||||
WHERE cs.company_id = p_company_id;
|
||||
|
||||
IF v_lock_date IS NOT NULL AND v_entry.entry_date <= v_lock_date THEN
|
||||
RAISE EXCEPTION 'Bokföringen är låst t.o.m. %: underlaget kan inte kopplas bort.', v_lock_date;
|
||||
END IF;
|
||||
|
||||
-- The verifikat must keep at least one anchored underlag (BFL 5 kap 7 par),
|
||||
-- and only a byte-identical duplicate may leave: a remaining sibling must
|
||||
-- carry the same immutable sha256_hash. Two different handlingar on one
|
||||
-- verifikation are each rakenskapsinformation and both stay.
|
||||
SELECT count(*),
|
||||
count(*) FILTER (WHERE d.sha256_hash = v_doc.sha256_hash)
|
||||
INTO v_siblings, v_duplicates
|
||||
FROM public.document_attachments d
|
||||
WHERE d.journal_entry_id = v_doc.journal_entry_id
|
||||
AND d.company_id = p_company_id
|
||||
AND d.is_current_version = true
|
||||
AND d.id <> v_doc.id;
|
||||
|
||||
IF v_siblings = 0 THEN
|
||||
RAISE EXCEPTION 'Verifikationen skulle stå utan underlag: det sista underlaget kan inte kopplas bort. Ersätt det med en ny version i stället.';
|
||||
END IF;
|
||||
|
||||
IF v_duplicates = 0 THEN
|
||||
RAISE EXCEPTION 'Underlaget är inte en dubblett: ingen identisk kopia finns kvar på verifikationen. Bara dubbletter kan kopplas bort.';
|
||||
END IF;
|
||||
|
||||
-- A doc pinned to a bank transaction or serving as a supplier invoice's
|
||||
-- retained source document is replaced through those flows, never detached.
|
||||
SELECT t.id INTO v_pinned_tx
|
||||
FROM public.transactions t
|
||||
WHERE t.document_id = v_doc.id AND t.company_id = p_company_id
|
||||
LIMIT 1;
|
||||
IF v_pinned_tx IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Underlaget är kopplat till en banktransaktion och kan inte kopplas bort här. Byt transaktionens underlag i stället.';
|
||||
END IF;
|
||||
|
||||
SELECT si.id INTO v_pinned_si
|
||||
FROM public.supplier_invoices si
|
||||
WHERE si.document_id = v_doc.id AND si.company_id = p_company_id
|
||||
LIMIT 1;
|
||||
IF v_pinned_si IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Underlaget är leverantörsfakturans originalunderlag och kan inte kopplas bort. Ersätt det med en ny version i stället.';
|
||||
END IF;
|
||||
|
||||
-- Append-only audit FIRST: the carve-out below is only ever exercised in a
|
||||
-- transaction that has already recorded who detached what from where.
|
||||
-- company_id must be set explicitly: the audit_log SELECT policy filters on
|
||||
-- company_id IN user_company_ids(), so a NULL row is invisible to every
|
||||
-- reader (the exact delete_last_voucher defect 20260528120600 fixed).
|
||||
INSERT INTO public.audit_log
|
||||
(user_id, company_id, action, table_name, record_id, actor_id, old_state, new_state, description)
|
||||
VALUES
|
||||
(v_actor, p_company_id, 'UPDATE', 'document_attachments', v_doc.id, v_actor,
|
||||
jsonb_build_object('journal_entry_id', v_doc.journal_entry_id, 'company_id', p_company_id, 'file_name', v_doc.file_name),
|
||||
jsonb_build_object('journal_entry_id', NULL, 'company_id', p_company_id, 'file_name', v_doc.file_name),
|
||||
'Dubblett-underlag frånkopplat från verifikation (annat underlag kvarstår)');
|
||||
|
||||
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
||||
|
||||
UPDATE public.document_attachments
|
||||
SET journal_entry_id = NULL,
|
||||
journal_entry_line_id = NULL
|
||||
WHERE id = v_doc.id;
|
||||
|
||||
PERFORM set_config('gnubok.allow_delete', 'false', true);
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'detached', true,
|
||||
'document_id', v_doc.id,
|
||||
'journal_entry_id', v_doc.journal_entry_id,
|
||||
'remaining_documents', v_siblings
|
||||
);
|
||||
END;
|
||||
$function$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.detach_underlag_duplicate(uuid, uuid, uuid) FROM PUBLIC, anon;
|
||||
GRANT EXECUTE ON FUNCTION public.detach_underlag_duplicate(uuid, uuid, uuid) TO authenticated, service_role;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,260 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import { getPool, withUserContext } from './setup'
|
||||
import {
|
||||
seedCompany,
|
||||
insertAuthUser,
|
||||
insertPostedJournalEntry,
|
||||
insertTransaction,
|
||||
} from './fixtures'
|
||||
|
||||
/**
|
||||
* Invariants for detach_underlag_duplicate (support case 2026-08-24): the ONE
|
||||
* sanctioned path for removing a redundant duplicate underlag from a posted
|
||||
* verifikat. The RPC must only detach a byte-identical duplicate (sha256
|
||||
* equality with a remaining anchored sibling), must refuse pinned/last/
|
||||
* non-duplicate/unposted/locked-period docs, must write a READABLE audit row
|
||||
* (company_id set: the RLS policy filters on it), and must remain the only
|
||||
* way past enforce_document_journal_entry_immutability (the direct UPDATE
|
||||
* stays blocked).
|
||||
*/
|
||||
|
||||
function makeHash(): string {
|
||||
return randomUUID().replace(/-/g, '').padEnd(64, '0')
|
||||
}
|
||||
|
||||
async function attachDocument(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
journalEntryId: string | null
|
||||
fileName?: string
|
||||
sha256?: string
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.document_attachments
|
||||
(id, user_id, company_id, journal_entry_id, file_name, mime_type,
|
||||
file_size_bytes, storage_path, sha256_hash, upload_source)
|
||||
VALUES ($1, $2, $3, $4, $5, 'application/pdf', 1024, $6, $7, 'file_upload')`,
|
||||
[
|
||||
id,
|
||||
params.userId,
|
||||
params.companyId,
|
||||
params.journalEntryId,
|
||||
params.fileName ?? 'underlag.pdf',
|
||||
`documents/${params.companyId}/${id}.pdf`,
|
||||
params.sha256 ?? makeHash(),
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertEntry(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
voucherNumber: number
|
||||
}): Promise<string> {
|
||||
return insertPostedJournalEntry({
|
||||
userId: params.userId,
|
||||
companyId: params.companyId,
|
||||
fiscalPeriodId: params.fiscalPeriodId,
|
||||
voucherNumber: params.voucherNumber,
|
||||
entryDate: '2026-06-10',
|
||||
description: `detach test ${params.voucherNumber}`,
|
||||
lines: [
|
||||
{ accountNumber: '1930', debitAmount: 100, creditAmount: 0 },
|
||||
{ accountNumber: '3001', debitAmount: 0, creditAmount: 100 },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/** Entry + an anchored duplicate pair (same sha256). Returns [entryId, keptId, dupId]. */
|
||||
async function seedDuplicatePair(s: {
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
voucherNumber: number
|
||||
}): Promise<[string, string, string]> {
|
||||
const entryId = await insertEntry(s)
|
||||
const hash = makeHash()
|
||||
const keptId = await attachDocument({
|
||||
userId: s.userId, companyId: s.companyId, journalEntryId: entryId,
|
||||
fileName: 'kept.pdf', sha256: hash,
|
||||
})
|
||||
const dupId = await attachDocument({
|
||||
userId: s.userId, companyId: s.companyId, journalEntryId: entryId,
|
||||
fileName: 'dup.pdf', sha256: hash,
|
||||
})
|
||||
return [entryId, keptId, dupId]
|
||||
}
|
||||
|
||||
describe('detach_underlag_duplicate RPC', () => {
|
||||
let userId: string
|
||||
let companyId: string
|
||||
let fiscalPeriodId: string
|
||||
let voucherNumber = 0
|
||||
|
||||
beforeAll(async () => {
|
||||
const s = await seedCompany()
|
||||
userId = s.userId
|
||||
companyId = s.companyId
|
||||
fiscalPeriodId = s.fiscalPeriodId
|
||||
})
|
||||
|
||||
it('detaches a duplicate and writes a reader-visible audit row', async () => {
|
||||
const [entryId, keptId, dupId] = await seedDuplicatePair({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: ++voucherNumber,
|
||||
})
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
const { rows } = await client.query<{ result: { detached: boolean; remaining_documents: number } }>(
|
||||
`SELECT public.detach_underlag_duplicate($1, $2) AS result`,
|
||||
[companyId, dupId],
|
||||
)
|
||||
expect(rows[0].result.detached).toBe(true)
|
||||
expect(rows[0].result.remaining_documents).toBe(1)
|
||||
|
||||
const after = await client.query<{ journal_entry_id: string | null }>(
|
||||
`SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`,
|
||||
[dupId],
|
||||
)
|
||||
expect(after.rows[0].journal_entry_id).toBeNull()
|
||||
|
||||
const kept = await client.query<{ journal_entry_id: string | null }>(
|
||||
`SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`,
|
||||
[keptId],
|
||||
)
|
||||
expect(kept.rows[0].journal_entry_id).toBe(entryId)
|
||||
|
||||
// The RPC's explicit provenance row must be visible to a company member
|
||||
// under RLS: that requires company_id set (the SELECT policy filters on
|
||||
// it), the actor recorded, and the detach description. Matching on the
|
||||
// description distinguishes it from the generic write_audit_log trigger
|
||||
// row, which must not be the row this assertion passes on.
|
||||
const audit = await client.query<{ company_id: string; actor_id: string }>(
|
||||
`SELECT company_id, actor_id FROM public.audit_log
|
||||
WHERE table_name = 'document_attachments' AND record_id = $1
|
||||
AND action = 'UPDATE' AND description LIKE 'Dubblett-underlag%'`,
|
||||
[dupId],
|
||||
)
|
||||
expect(audit.rowCount).toBe(1)
|
||||
expect(audit.rows[0].company_id).toBe(companyId)
|
||||
expect(audit.rows[0].actor_id).toBe(userId)
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses to detach the last anchored underlag', async () => {
|
||||
const entryId = await insertEntry({ userId, companyId, fiscalPeriodId, voucherNumber: ++voucherNumber })
|
||||
const onlyId = await attachDocument({ userId, companyId, journalEntryId: entryId })
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
await expect(
|
||||
client.query(`SELECT public.detach_underlag_duplicate($1, $2)`, [companyId, onlyId]),
|
||||
).rejects.toThrow(/sista underlaget/)
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses to detach a non-duplicate (different sha256) even with siblings present', async () => {
|
||||
const entryId = await insertEntry({ userId, companyId, fiscalPeriodId, voucherNumber: ++voucherNumber })
|
||||
await attachDocument({ userId, companyId, journalEntryId: entryId, fileName: 'faktura.pdf' })
|
||||
const receiptId = await attachDocument({ userId, companyId, journalEntryId: entryId, fileName: 'betalkvitto.pdf' })
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
await expect(
|
||||
client.query(`SELECT public.detach_underlag_duplicate($1, $2)`, [companyId, receiptId]),
|
||||
).rejects.toThrow(/inte en dubblett/)
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses to detach from a reversed (storno) verifikat', async () => {
|
||||
const [entryId, , dupId] = await seedDuplicatePair({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: ++voucherNumber,
|
||||
})
|
||||
// posted -> reversed is the transition the immutability trigger permits.
|
||||
await getPool().query(
|
||||
`UPDATE public.journal_entries SET status = 'reversed' WHERE id = $1`,
|
||||
[entryId],
|
||||
)
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
await expect(
|
||||
client.query(`SELECT public.detach_underlag_duplicate($1, $2)`, [companyId, dupId]),
|
||||
).rejects.toThrow(/bokförda verifikat/)
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses to detach a document pinned to a bank transaction', async () => {
|
||||
const entryId = await insertEntry({ userId, companyId, fiscalPeriodId, voucherNumber: ++voucherNumber })
|
||||
const hash = makeHash()
|
||||
await attachDocument({ userId, companyId, journalEntryId: entryId, fileName: 'other.pdf', sha256: hash })
|
||||
const pinnedId = await attachDocument({ userId, companyId, journalEntryId: entryId, fileName: 'pinned.pdf', sha256: hash })
|
||||
const txId = await insertTransaction({
|
||||
userId,
|
||||
companyId,
|
||||
amount: -100,
|
||||
description: 'pinned tx',
|
||||
})
|
||||
// NULL -> doc pin is the allowed direction on the transactions side.
|
||||
await getPool().query(
|
||||
`UPDATE public.transactions SET document_id = $1 WHERE id = $2`,
|
||||
[pinnedId, txId],
|
||||
)
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
await expect(
|
||||
client.query(`SELECT public.detach_underlag_duplicate($1, $2)`, [companyId, pinnedId]),
|
||||
).rejects.toThrow(/banktransaktion/)
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a caller who is not a member of the company', async () => {
|
||||
const [, , dupId] = await seedDuplicatePair({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: ++voucherNumber,
|
||||
})
|
||||
const outsiderId = await insertAuthUser()
|
||||
|
||||
await withUserContext(outsiderId, async (client) => {
|
||||
await expect(
|
||||
client.query(`SELECT public.detach_underlag_duplicate($1, $2)`, [companyId, dupId]),
|
||||
).rejects.toThrow(/not a member/)
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses when the fiscal period is closed', async () => {
|
||||
// Seed open (the period-lock triggers block inserting posted entries and
|
||||
// anchored docs into an already-closed period), then close the period.
|
||||
const s = await seedCompany()
|
||||
const [, , dupId] = await seedDuplicatePair({
|
||||
userId: s.userId,
|
||||
companyId: s.companyId,
|
||||
fiscalPeriodId: s.fiscalPeriodId,
|
||||
voucherNumber: 1,
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods SET is_closed = true, closed_at = now() WHERE id = $1`,
|
||||
[s.fiscalPeriodId],
|
||||
)
|
||||
|
||||
await withUserContext(s.userId, async (client) => {
|
||||
await expect(
|
||||
client.query(`SELECT public.detach_underlag_duplicate($1, $2)`, [s.companyId, dupId]),
|
||||
).rejects.toThrow(/stängd eller låst/)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the direct UPDATE path blocked by the immutability trigger', async () => {
|
||||
const [, , dupId] = await seedDuplicatePair({
|
||||
userId, companyId, fiscalPeriodId, voucherNumber: ++voucherNumber,
|
||||
})
|
||||
|
||||
await withUserContext(userId, async (client) => {
|
||||
await expect(
|
||||
client.query(
|
||||
`UPDATE public.document_attachments SET journal_entry_id = NULL WHERE id = $1`,
|
||||
[dupId],
|
||||
),
|
||||
).rejects.toThrow(/BFL_DOCUMENT_IMMUTABILITY/)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user