Files
accounted/tests/pg/document-surfaces-unification.pg.test.ts
T
Mattsson cd344b6dbb fix(db): enforce balance check on directly inserted posted journal entries (v2) (#1439)
* fix(db): enforce balance check on directly inserted posted journal entries

check_balance_on_post only fires on the draft-to-posted UPDATE transition,
so any code path that INSERTs a row with status 'posted' directly skipped
balance validation entirely. The invariant sum(debit) = sum(credit) on
every posted entry was DB-enforced only for the engine's commit lifecycle.

Add check_balance_on_posted_insert, a deferred constraint trigger on
AFTER INSERT WHEN (NEW.status = 'posted') reusing the existing
check_journal_entry_balance() function, which already handles the
journal_entries INSERT context via NEW.id/NEW.status. Deferred semantics
let an atomic transaction insert header and lines together; zero-line and
unbalanced posted inserts are rejected at constraint evaluation. All
existing checks stay intact; this only adds coverage.

The one first-party posted-INSERT path outside an RPC, the sandbox seed,
now books through the bookkeeping engine (createJournalEntry) instead of
raw inserts. SIE import already inserts header and lines in a single
transaction via its structured RPC and passes unchanged.

pg tests cover the new path (zero-line rejected, unbalanced rejected at
SET CONSTRAINTS IMMEDIATE, balanced same-transaction insert accepted) and
existing posted-entry fixtures move to a transactional
insertPostedJournalEntry helper so they stay valid setup.

Fixes #327

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

* fix(tests): insert list-filters pg fixtures in one transaction

The list-filters suite (landed via a sibling merge) inserted posted
headers with getPool().query, where each query autocommits: the deferred
check_balance_on_posted_insert constraint fired at the header's own
commit with zero lines and correctly rejected the fixture. Header and
balanced lines now share one BEGIN/COMMIT so the constraint evaluates
the complete entry, mirroring the insertPostedJournalEntry helper.

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

* fix(seed): insert journal headers as drafts, post after lines land

check_balance_on_posted_insert (renamed to apply-time version
20260806130000) rejects a posted header whose transaction has no lines.
PostgREST autocommits each request, so every seed path that inserted
posted headers first would die with "has zero total": the sandbox seed
(ledger history, invoice vouchers, salary vouchers), seed-demo-account
and seed-export-data. All now insert draft headers, insert lines, then
flip to posted so check_balance_on_post validates the finished
verifikat. The sandbox seed keeps its documented no-events design.

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

* fix(db): preserve a preset committed_at on draft-to-posted transition

set_committed_at() stamped now() unconditionally, so the seed flows that
post backdated drafts lost their historical booking timestamps and every
demo verifikat read as booked today (CodeRabbit finding on PR 1439).
Stamp only when committed_at is NULL: the engine path (drafts carry no
committed_at) behaves exactly as before and a posted entry still always
has a committed_at; an explicitly supplied value now survives posting.

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

* fix(db): preserve preset committed_at only for trusted roles

The IS NULL guard alone (20260806150000, never shipped; replaced by
20260806160000) let any RLS-permitted member backdate committed_at
through PostgREST by presetting it on a draft and posting, which the
Swedish accounting review flagged: committed_at is what the BFL 5 kap
timeliness checks and behandlingshistorik treat as the genuine
transition time. Preset values now survive posting only for
service_role/postgres/supabase_admin; authenticated and anon writers
always get the now() stamp. Consequence: the sandbox seed (runs as the
requesting user) gets committed_at = posting time, accepted and
documented in the route; the demo scripts run as service_role and keep
their backdated history. pg tests cover all four paths, with the upper
timestamp bound CodeRabbit asked for.

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

* fix(db): restore superseded migration so the preview tracker stays consistent

The preview branch had already applied 20260806150000 when the previous
commit deleted the file, orphaning the preview's migration tracker
("Remote migration versions not found in local migrations directory").
Restored with a header explaining it is superseded in the same deploy by
20260806160000, so the unguarded semantics are never live on their own.

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

* fix(db): decide committed_at trust by JWT claims, not current_user

The Swedish review found the current_user guard bypassable:
commit_journal_entry is SECURITY DEFINER and granted to authenticated,
so inside it current_user is the function owner and a member could
preset a backdated committed_at on a direct-inserted draft and launder
it through the RPC. The guard now reads the JWT claims role (same
primitive as the RPC's own tenant guard): preset values survive only
for service_role or claim-less backend connections; authenticated and
anon callers are always stamped now(), on both the direct UPDATE and
the RPC path (new pg test). Both migration files now carry the
identical final body so no unguarded intermediate exists as a
standalone applyable unit. Behandlingshistorik logging of trusted
overrides is follow-up #1444.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 23:00:05 +02:00

700 lines
26 KiB
TypeScript

import { randomUUID } from 'crypto'
import { beforeAll, describe, expect, it } from 'vitest'
import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
import { getPool } from './setup'
import {
seedCompany,
insertDraftJournalEntry,
insertPostedJournalEntry,
insertBalancedLines,
insertTransaction,
} from './fixtures'
/**
* P1-3 (mcp_optimization_plan): both missing-document surfaces implement ONE
* predicate: posted, needs-doc source type, no CURRENT-version
* document_attachments row, no journal_entry_no_doc_required waiver, no
* supplier-invoice reference carrying a retained document: and the
* transactions surface is a strict subset of the verifikat surface.
*
* The supplier-invoice arm (migration 20260724090000) implements BFL 5 kap
* 7 §: a verifikation may satisfy the underlag requirement by hänvisning till
* underlag. An entry referenced by a supplier invoice whose document_id is
* set (registration/payment FK or a supplier_invoice_payments row) is
* covered by that retained document even though the doc row hangs on the
* invoice's other verifikat.
*
* Also pins the SQL needs-doc source-type list to the TS constant
* NEEDS_DOC_SOURCE_TYPES (lib/worklist/categories.ts): a divergence between
* the two lists fails the per-source-type probe below.
*/
type VerifikatResult = {
ok: boolean
total_count?: number
verifikat?: Array<{ journal_entry_id: string; source_type: string }>
}
type TransactionsResult = {
ok: boolean
code?: string
total_count?: number
transactions?: Array<{ id: string; transaction_id: string; journal_entry_id: string }>
}
async function verifikatSurface(companyId: string): Promise<VerifikatResult> {
const { rows } = await getPool().query<{ r: VerifikatResult }>(
`SELECT public.verifikat_without_documents($1, NULL, 0, 100, 0) AS r`,
[companyId],
)
return rows[0].r
}
async function transactionsSurface(companyId: string): Promise<TransactionsResult> {
const { rows } = await getPool().query<{ r: TransactionsResult }>(
`SELECT public.transactions_without_documents($1, NULL, 100, 0) AS r`,
[companyId],
)
return rows[0].r
}
async function attachDocument(params: {
userId: string
companyId: string
journalEntryId: string | null
isCurrentVersion?: boolean
}): 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, is_current_version)
VALUES ($1, $2, $3, $4, 'underlag.pdf', 'application/pdf', 1024, $5, $6, 'file_upload', $7)`,
[
id,
params.userId,
params.companyId,
params.journalEntryId,
`documents/${params.companyId}/${id}.pdf`,
randomUUID().replace(/-/g, '').padEnd(64, '0'),
params.isCurrentVersion ?? true,
],
)
return id
}
async function waive(params: { userId: string; companyId: string; journalEntryId: string }) {
await getPool().query(
`INSERT INTO public.journal_entry_no_doc_required (journal_entry_id, company_id, user_id, reason)
VALUES ($1, $2, $3, 'internal transfer: no underlag required')`,
[params.journalEntryId, params.companyId, params.userId],
)
}
async function insertSupplier(params: { userId: string; companyId: string }): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.suppliers (id, user_id, company_id, name)
VALUES ($1, $2, $3, 'Test Leverantör AB')`,
[id, params.userId, params.companyId],
)
return id
}
async function insertSupplierInvoice(params: {
userId: string
companyId: string
supplierId: string
arrivalNumber: number
registrationJournalEntryId?: string | null
paymentJournalEntryId?: string | null
documentId?: string | null
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.supplier_invoices
(id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number,
invoice_date, due_date, total, remaining_amount,
registration_journal_entry_id, payment_journal_entry_id, document_id)
VALUES ($1, $2, $3, $4, $5, $6, '2026-06-01', '2026-06-30', 1000, 1000, $7, $8, $9)`,
[
id,
params.userId,
params.companyId,
params.supplierId,
params.arrivalNumber,
`SI-${params.arrivalNumber}`,
params.registrationJournalEntryId ?? null,
params.paymentJournalEntryId ?? null,
params.documentId ?? null,
],
)
return id
}
async function insertSupplierInvoicePayment(params: {
userId: string
companyId: string
supplierInvoiceId: string
journalEntryId: string
}): Promise<void> {
await getPool().query(
`INSERT INTO public.supplier_invoice_payments
(user_id, company_id, supplier_invoice_id, payment_date, amount, journal_entry_id)
VALUES ($1, $2, $3, '2026-06-10', 500, $4)`,
[params.userId, params.companyId, params.supplierInvoiceId, params.journalEntryId],
)
}
describe('document surfaces unification', () => {
let userId: string
let companyId: string
let fiscalPeriodId: string
// Fixture matrix ids
let jeBankNoDoc: string // bank tx JE, no doc → BOTH surfaces
let jeBankWithDoc: string // bank tx JE, current doc → NEITHER
let jeBankWaived: string // bank tx JE, waived → NEITHER
let jeBankStaleDoc: string // bank tx JE, only superseded doc version → BOTH
let jeInvoiceCreated: string // doc-exempt source type → NEITHER
let jeImportNoDoc: string // import JE, no tx → verifikat surface only
let jeSiRegWithDoc: string // SI registration JE holding the invoice doc directly → NEITHER
let jeSiPaymentCovered: string // SI payment JE, doc on the SI (registration side) → NEITHER (BFL 5:7 hänvisning)
let jeSiRegNoDoc: string // SI registration JE, SI has NO doc → verifikat surface
let jeSiPartialCovered: string // SI payment JE referenced only via supplier_invoice_payments, SI doc anchored → NEITHER
let jeSiPayUnanchored: string // SI payment JE whose SI doc is UNANCHORED (deletable) → verifikat surface
beforeAll(async () => {
const s = await seedCompany()
userId = s.userId
companyId = s.companyId
fiscalPeriodId = s.fiscalPeriodId
const mkJe = async (n: number, sourceType: string) => {
return insertPostedJournalEntry({
userId,
companyId,
fiscalPeriodId,
voucherNumber: n,
entryDate: `2026-06-${String(n).padStart(2, '0')}`,
description: `${sourceType} ${n}`,
sourceType,
lines: [
{ accountNumber: '1930', debitAmount: n * 100, creditAmount: 0 },
{ accountNumber: '3001', debitAmount: 0, creditAmount: n * 100 },
],
})
}
jeBankNoDoc = await mkJe(1, 'bank_transaction')
jeBankWithDoc = await mkJe(2, 'bank_transaction')
jeBankWaived = await mkJe(3, 'bank_transaction')
jeBankStaleDoc = await mkJe(4, 'bank_transaction')
jeInvoiceCreated = await mkJe(5, 'invoice_created')
jeImportNoDoc = await mkJe(6, 'import')
jeSiRegWithDoc = await mkJe(7, 'supplier_invoice_registered')
jeSiPaymentCovered = await mkJe(8, 'supplier_invoice_paid')
jeSiRegNoDoc = await mkJe(9, 'supplier_invoice_registered')
jeSiPartialCovered = await mkJe(10, 'supplier_invoice_paid')
jeSiPayUnanchored = await mkJe(11, 'supplier_invoice_paid')
// Bank transactions pointing at the bank-driven entries. The with-doc tx
// deliberately keeps document_id NULL (the 1,100-row reverse gap on
// prod): the surface must key on document_attachments, not
// transactions.document_id. jeSiPaymentCovered also gets a tx so the
// transactions surface exercises the reference arm.
for (const [jeId, date] of [
[jeBankNoDoc, '2026-06-01'],
[jeBankWithDoc, '2026-06-02'],
[jeBankWaived, '2026-06-03'],
[jeBankStaleDoc, '2026-06-04'],
[jeSiPaymentCovered, '2026-06-08'],
] as const) {
await insertTransaction({ userId, companyId, journalEntryId: jeId, date })
}
await attachDocument({ userId, companyId, journalEntryId: jeBankWithDoc })
await attachDocument({
userId,
companyId,
journalEntryId: jeBankStaleDoc,
isCurrentVersion: false,
})
await waive({ userId, companyId, journalEntryId: jeBankWaived })
// Supplier-invoice reference matrix (BFL 5 kap 7 § hänvisning):
// - siWithDoc: document hangs on the registration JE; its payment JE is
// covered by reference through payment_journal_entry_id.
// - siNoDoc: no retained document → its registration JE stays flagged.
// - siPartial: anchored document; its payment JE is linked only through
// a supplier_invoice_payments row (partial payment path).
// - siUnanchored: document referenced but journal_entry_id NULL: outside
// the WORM deletion guards, so it must NOT silence the warning.
const supplierId = await insertSupplier({ userId, companyId })
const siDoc = await attachDocument({ userId, companyId, journalEntryId: jeSiRegWithDoc })
await insertSupplierInvoice({
userId,
companyId,
supplierId,
arrivalNumber: 1,
registrationJournalEntryId: jeSiRegWithDoc,
paymentJournalEntryId: jeSiPaymentCovered,
documentId: siDoc,
})
await insertSupplierInvoice({
userId,
companyId,
supplierId,
arrivalNumber: 2,
registrationJournalEntryId: jeSiRegNoDoc,
documentId: null,
})
// Anchor the partial invoice's doc on the covered registration JE so the
// partial arm is exercised in isolation (the doc's own anchor is a
// different entry than the one being silenced).
const siPartialDoc = await attachDocument({ userId, companyId, journalEntryId: jeSiRegWithDoc })
const siPartial = await insertSupplierInvoice({
userId,
companyId,
supplierId,
arrivalNumber: 3,
documentId: siPartialDoc,
})
await insertSupplierInvoicePayment({
userId,
companyId,
supplierInvoiceId: siPartial,
journalEntryId: jeSiPartialCovered,
})
const siUnanchoredDoc = await attachDocument({ userId, companyId, journalEntryId: null })
await insertSupplierInvoice({
userId,
companyId,
supplierId,
arrivalNumber: 4,
paymentJournalEntryId: jeSiPayUnanchored,
documentId: siUnanchoredDoc,
})
})
it('verifikat surface: needs-doc entries without current docs, waivers, or covering references', async () => {
const res = await verifikatSurface(companyId)
expect(res.ok).toBe(true)
const ids = (res.verifikat ?? []).map((v) => v.journal_entry_id).sort()
// jeSiRegNoDoc appears: its supplier invoice retains no document, so the
// reference alone is not underlag. jeSiPayUnanchored appears: the SI's
// doc is not anchored to any entry, so it is deletable and cannot back a
// posted verifikat. The covered SI entries do not appear.
expect(ids).toEqual(
[jeBankNoDoc, jeBankStaleDoc, jeImportNoDoc, jeSiRegNoDoc, jeSiPayUnanchored].sort(),
)
expect(res.total_count).toBe(5)
// Doc-exempt source type never appears even when undocumented.
expect(ids).not.toContain(jeInvoiceCreated)
})
it('supplier-invoice references with an anchored doc silence both FK paths and the partial-payment path', async () => {
const res = await verifikatSurface(companyId)
const ids = (res.verifikat ?? []).map((v) => v.journal_entry_id)
// Registration JE holds the doc directly.
expect(ids).not.toContain(jeSiRegWithDoc)
// Payment JE covered by the SI's retained doc via payment_journal_entry_id.
expect(ids).not.toContain(jeSiPaymentCovered)
// Payment JE covered via a supplier_invoice_payments row only.
expect(ids).not.toContain(jeSiPartialCovered)
// Unanchored SI doc does NOT cover its payment JE.
expect(ids).toContain(jeSiPayUnanchored)
})
it('transactions surface: the bank-driven rows of the same set, keyed on document_attachments', async () => {
const res = await transactionsSurface(companyId)
expect(res.ok).toBe(true)
const jeIds = (res.transactions ?? []).map((t) => t.journal_entry_id).sort()
// jeBankWithDoc excluded even though its tx.document_id is NULL: the
// doc truth is document_attachments. jeImportNoDoc has no tx row.
// jeSiPaymentCovered excluded: covered by the SI's retained doc.
expect(jeIds).toEqual([jeBankNoDoc, jeBankStaleDoc].sort())
// P1-2 forward-compat: rows expose the qualified id.
expect(res.transactions![0].transaction_id).toBe(res.transactions![0].id)
})
it('transactions surface is a strict subset of the verifikat surface', async () => {
const [ver, tx] = await Promise.all([verifikatSurface(companyId), transactionsSurface(companyId)])
const verIds = new Set((ver.verifikat ?? []).map((v) => v.journal_entry_id))
for (const row of tx.transactions ?? []) {
expect(verIds.has(row.journal_entry_id), `tx surface row ${row.journal_entry_id} missing from verifikat surface`).toBe(true)
}
})
it('pins the SQL needs-doc list to NEEDS_DOC_SOURCE_TYPES per source type', async () => {
// Each needs-doc source type must appear when undocumented; a canary
// non-needs-doc type must not. Uses a fresh company per probe set to
// keep assertions exact.
const s = await seedCompany()
let voucher = 1
const expected: string[] = []
for (const sourceType of NEEDS_DOC_SOURCE_TYPES) {
const id = await insertPostedJournalEntry({
userId: s.userId,
companyId: s.companyId,
fiscalPeriodId: s.fiscalPeriodId,
voucherNumber: voucher,
entryDate: '2026-06-15',
description: sourceType,
sourceType,
lines: [
{ accountNumber: '1930', debitAmount: 100 * voucher, creditAmount: 0 },
{ accountNumber: '3001', debitAmount: 0, creditAmount: 100 * voucher },
],
})
expected.push(id)
voucher++
}
const res = await verifikatSurface(s.companyId)
expect((res.verifikat ?? []).map((v) => v.journal_entry_id).sort()).toEqual(expected.sort())
})
it('tenant guard on the transactions surface (NULL + foreign company)', async () => {
const { rows } = await getPool().query<{ r: TransactionsResult }>(
`SELECT public.transactions_without_documents(NULL, NULL, 20, 0) AS r`,
)
// Superuser pool bypasses the guard by role; assert the NULL-company path
// simply returns an empty ok result rather than leaking cross-tenant rows.
expect((rows[0].r.transactions ?? []).length).toBe(0)
})
})
describe('transaction-pinned document backfill (migration 20260724090000 §4)', () => {
// The DO-block body, verbatim from the migration: docs pinned to a booked
// transaction whose verifikat never received the link. Only unlinked
// current-version docs, only into open unlocked periods.
const BACKFILL_SQL = `
WITH gap AS (
SELECT t.document_id, t.journal_entry_id
FROM transactions t
JOIN journal_entries je ON je.id = t.journal_entry_id
JOIN fiscal_periods fp ON fp.id = je.fiscal_period_id
WHERE t.document_id IS NOT NULL
AND je.status = 'posted'
AND fp.is_closed = false
AND fp.locked_at IS NULL
)
UPDATE document_attachments d
SET journal_entry_id = gap.journal_entry_id
FROM gap
WHERE d.id = gap.document_id
AND d.journal_entry_id IS NULL
AND d.is_current_version = true`
it('propagates unlinked pinned docs, never steals linked docs, skips closed periods', async () => {
const s = await seedCompany()
const mkPostedJe = async (n: number, fiscalPeriodId: string) => {
return insertPostedJournalEntry({
userId: s.userId,
companyId: s.companyId,
fiscalPeriodId,
voucherNumber: n,
entryDate: '2026-06-15',
description: `backfill ${n}`,
sourceType: 'supplier_invoice_paid',
lines: [
{ accountNumber: '1930', debitAmount: 100 * n, creditAmount: 0 },
{ accountNumber: '3001', debitAmount: 0, creditAmount: 100 * n },
],
})
}
// Case A (Emil's flow): doc pinned to the tx, never propagated.
const jeA = await mkPostedJe(1, s.fiscalPeriodId)
const docA = await attachDocument({ userId: s.userId, companyId: s.companyId, journalEntryId: null })
const txA = await insertTransaction({
userId: s.userId,
companyId: s.companyId,
journalEntryId: jeA,
date: '2026-06-15',
})
await getPool().query(`UPDATE public.transactions SET document_id = $1 WHERE id = $2`, [docA, txA])
// Case B: pinned doc already serves ANOTHER verifikat: must not move.
const jeB = await mkPostedJe(2, s.fiscalPeriodId)
const jeBOther = await mkPostedJe(3, s.fiscalPeriodId)
const docB = await attachDocument({ userId: s.userId, companyId: s.companyId, journalEntryId: jeBOther })
const txB = await insertTransaction({
userId: s.userId,
companyId: s.companyId,
journalEntryId: jeB,
date: '2026-06-16',
})
await getPool().query(`UPDATE public.transactions SET document_id = $1 WHERE id = $2`, [docB, txB])
await getPool().query(BACKFILL_SQL)
const { rows: aRows } = await getPool().query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`,
[docA],
)
expect(aRows[0].journal_entry_id).toBe(jeA)
const { rows: bRows } = await getPool().query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`,
[docB],
)
expect(bRows[0].journal_entry_id).toBe(jeBOther)
// Case A no longer surfaces as missing underlag.
const res = await verifikatSurface(s.companyId)
const flagged = (res.verifikat ?? []).map((v) => v.journal_entry_id)
expect(flagged).not.toContain(jeA)
// Case C: closed period: the gap row is filtered out, so the doc stays
// unlinked and no period-lock trigger fires. Closing happens AFTER the
// entries exist (inserting into a closed period would itself be blocked).
const jeC = await mkPostedJe(4, s.fiscalPeriodId)
const docC = await attachDocument({ userId: s.userId, companyId: s.companyId, journalEntryId: null })
const txC = await insertTransaction({
userId: s.userId,
companyId: s.companyId,
journalEntryId: jeC,
date: '2026-06-17',
})
await getPool().query(`UPDATE public.transactions SET document_id = $1 WHERE id = $2`, [docC, txC])
await getPool().query(
`UPDATE public.fiscal_periods SET is_closed = true, closed_at = now() WHERE id = $1`,
[s.fiscalPeriodId],
)
await getPool().query(BACKFILL_SQL)
const { rows: cRows } = await getPool().query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`,
[docC],
)
expect(cRows[0].journal_entry_id).toBeNull()
})
})
describe('floating supplier-invoice document backfill (migration 20260727180000)', () => {
// The DO-block body, verbatim from the migration: re-anchor a supplier
// invoice's retained document when it is floating (journal_entry_id NULL)
// even though the invoice still has a posted verifikat to hang on. Preference
// order: registration booking, payment booking, then partial payments.
const BACKFILL_SQL = `
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`
const anchorOf = async (documentId: string): Promise<string | null> => {
const { rows } = await getPool().query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`,
[documentId],
)
return rows[0].journal_entry_id
}
it('anchors a floating doc to the payment verifikat when registration was reversed', async () => {
// The reported shape: the invoice PDF was orphaned when the rättelse it
// had been relinked onto was deleted (delete_last_voucher has to clear
// journal_entry_id), leaving the posted payment verifikat flagged while
// the verifikat view still displayed the PDF.
const s = await seedCompany()
const mkJe = async (n: number, status: 'posted' | 'reversed', sourceType: string) => {
if (status === 'posted') {
return insertPostedJournalEntry({
userId: s.userId,
companyId: s.companyId,
fiscalPeriodId: s.fiscalPeriodId,
voucherNumber: n,
entryDate: '2026-06-15',
description: `anchor ${n}`,
sourceType,
lines: [
{ accountNumber: '1930', debitAmount: 100 * n, creditAmount: 0 },
{ accountNumber: '3001', debitAmount: 0, creditAmount: 100 * n },
],
})
}
const id = await insertDraftJournalEntry({
userId: s.userId,
companyId: s.companyId,
fiscalPeriodId: s.fiscalPeriodId,
status,
voucherNumber: n,
entryDate: '2026-06-15',
description: `anchor ${n}`,
sourceType,
})
await insertBalancedLines(id, 100 * n)
return id
}
const jeReg = await mkJe(1, 'reversed', 'supplier_invoice_registered')
const jePay = await mkJe(2, 'posted', 'supplier_invoice_paid')
const supplierId = await insertSupplier({ userId: s.userId, companyId: s.companyId })
const doc = await attachDocument({
userId: s.userId,
companyId: s.companyId,
journalEntryId: null,
})
await insertSupplierInvoice({
userId: s.userId,
companyId: s.companyId,
supplierId,
arrivalNumber: 1,
registrationJournalEntryId: jeReg,
paymentJournalEntryId: jePay,
documentId: doc,
})
// Before: the payment verifikat is flagged even though the PDF is retained.
const before = await verifikatSurface(s.companyId)
expect((before.verifikat ?? []).map((v) => v.journal_entry_id)).toContain(jePay)
await getPool().query(BACKFILL_SQL)
expect(await anchorOf(doc)).toBe(jePay)
const after = await verifikatSurface(s.companyId)
expect((after.verifikat ?? []).map((v) => v.journal_entry_id)).not.toContain(jePay)
})
it('prefers the registration verifikat and never steals an anchored doc', async () => {
const s = await seedCompany()
const mkJe = async (n: number, sourceType: string) => {
return insertPostedJournalEntry({
userId: s.userId,
companyId: s.companyId,
fiscalPeriodId: s.fiscalPeriodId,
voucherNumber: n,
entryDate: '2026-06-15',
description: `prefer ${n}`,
sourceType,
lines: [
{ accountNumber: '1930', debitAmount: 100 * n, creditAmount: 0 },
{ accountNumber: '3001', debitAmount: 0, creditAmount: 100 * n },
],
})
}
const jeReg = await mkJe(1, 'supplier_invoice_registered')
const jePay = await mkJe(2, 'supplier_invoice_paid')
const jeOther = await mkJe(3, 'manual')
const supplierId = await insertSupplier({ userId: s.userId, companyId: s.companyId })
const floating = await attachDocument({
userId: s.userId,
companyId: s.companyId,
journalEntryId: null,
})
await insertSupplierInvoice({
userId: s.userId,
companyId: s.companyId,
supplierId,
arrivalNumber: 1,
registrationJournalEntryId: jeReg,
paymentJournalEntryId: jePay,
documentId: floating,
})
// Already serving another verifikat: must stay put (BFL 5 kap 6 §).
const anchored = await attachDocument({
userId: s.userId,
companyId: s.companyId,
journalEntryId: jeOther,
})
await insertSupplierInvoice({
userId: s.userId,
companyId: s.companyId,
supplierId,
arrivalNumber: 2,
paymentJournalEntryId: jePay,
documentId: anchored,
})
await getPool().query(BACKFILL_SQL)
expect(await anchorOf(floating)).toBe(jeReg)
expect(await anchorOf(anchored)).toBe(jeOther)
})
it('skips closed periods: the period-lock trigger would reject the write anyway', async () => {
const s = await seedCompany()
const je = await insertPostedJournalEntry({
userId: s.userId,
companyId: s.companyId,
fiscalPeriodId: s.fiscalPeriodId,
voucherNumber: 1,
entryDate: '2026-06-15',
description: 'closed period',
sourceType: 'supplier_invoice_paid',
lines: [
{ accountNumber: '1930', debitAmount: 100, creditAmount: 0 },
{ accountNumber: '3001', debitAmount: 0, creditAmount: 100 },
],
})
const supplierId = await insertSupplier({ userId: s.userId, companyId: s.companyId })
const doc = await attachDocument({
userId: s.userId,
companyId: s.companyId,
journalEntryId: null,
})
await insertSupplierInvoice({
userId: s.userId,
companyId: s.companyId,
supplierId,
arrivalNumber: 1,
paymentJournalEntryId: je,
documentId: doc,
})
// Close AFTER the fixtures exist: inserting into a closed period is itself
// blocked by enforce_period_lock.
await getPool().query(
`UPDATE public.fiscal_periods SET is_closed = true, closed_at = now() WHERE id = $1`,
[s.fiscalPeriodId],
)
await getPool().query(BACKFILL_SQL)
expect(await anchorOf(doc)).toBeNull()
})
})