5fc0be9ed7
* feat(webshop): generate orderunderlag PDF and attach it to the verifikat at booking Booked webshop orders only carried the VAT split; the verifikat showed no product lines, customer or payment method although the sync already stores all of it in webshop_orders.line_items (#1881). - lib/webshop-orders/order-underlag.tsx: pure model builder + react-pdf template (order lines, customer, payment method, per-rate VAT summary, SEK conversion facts) + archiveWebshopOrderUnderlag, which renders and archives the PDF on the committed verifikat through uploadDocument (upload_source system, extraction none), mirroring archiveIssuedInvoicePdf. Never throws: the booking is immutable by then. - book route: archive after commitEntry; response gains underlag_archived. FX-retry now also syncs the in-memory row so the underlag shows the resolved SEK facts. - webshop_order added to NEEDS_DOC_SOURCE_TYPES and (new migration 20260825140000) to the verifikat_without_documents needs-doc list, so a failed attach or a historical booking surfaces on the saknar-underlag worklist. transactions_without_documents is deliberately unchanged. - tests: underlag model/render/archive unit tests, book-route archive and failure-isolation cases, pg test extended (per-source-type probe now covers webshop_order; explicit flagged/silenced pair). Fixes #1881 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(migrations): move webshop needs-doc migration after main's 20260825150000 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(webshop): add manually_booked fields to the underlag order fixture Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(webshop): skeptic findings on the orderunderlag (#1881) Two refutations from the skeptic pass on PR #1899, both fixed: 1. Correctness: sv-SE Intl emits U+2212 MINUS SIGN for negatives, which Helvetica/WinAnsi PDF fonts drop silently, so refund and discount amounts on the archived underlag rendered as POSITIVE. formatAmount now replaces U+2212 with an ASCII hyphen (same guard as formatPdfCurrency), is exported, and is pinned by a regression test. 2. Regression: NEEDS_DOC_SOURCE_TYPES had two hardcoded copies that missed webshop_order, so flagged rows rendered without the "Underlag saknas" chip, waiver toggle, or batch-exempt selection, and the weekly missing-underlag push cron disagreed with the badge. The constant now lives in dependency-free lib/worklist/types.ts (client-safe), is re-exported from categories.ts, and both JournalEntryList.tsx and push-notifications/notification-scheduler.ts consume it instead of their own copies. Also: "Bokfört i SEK" reworded to "Motsvarande i SEK" (compliance skeptic observation: the dialog's lines are user-editable, so the underlag must state the order's conversion, not claim a booking fact). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
778 lines
30 KiB
TypeScript
778 lines
30 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,
|
|
insertCashAccount,
|
|
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('webshop_order: flagged without underlag, silenced by the archived orderunderlag (#1881)', async () => {
|
|
// The book route archives a generated orderunderlag on the verifikat; a
|
|
// historical booking (or a failed attach) has no doc and must surface.
|
|
const s = await seedCompany()
|
|
const mkWebshopJe = (n: number) =>
|
|
insertPostedJournalEntry({
|
|
userId: s.userId,
|
|
companyId: s.companyId,
|
|
fiscalPeriodId: s.fiscalPeriodId,
|
|
voucherNumber: n,
|
|
entryDate: '2026-06-15',
|
|
description: `webshop order ${n}`,
|
|
sourceType: 'webshop_order',
|
|
lines: [
|
|
{ accountNumber: '1930', debitAmount: 100 * n, creditAmount: 0 },
|
|
{ accountNumber: '3001', debitAmount: 0, creditAmount: 100 * n },
|
|
],
|
|
})
|
|
const jeWithUnderlag = await mkWebshopJe(1)
|
|
const jeWithoutUnderlag = await mkWebshopJe(2)
|
|
await attachDocument({
|
|
userId: s.userId,
|
|
companyId: s.companyId,
|
|
journalEntryId: jeWithUnderlag,
|
|
})
|
|
|
|
const res = await verifikatSurface(s.companyId)
|
|
expect(res.ok).toBe(true)
|
|
const ids = (res.verifikat ?? []).map((v) => v.journal_entry_id)
|
|
expect(ids).toContain(jeWithoutUnderlag)
|
|
expect(ids).not.toContain(jeWithUnderlag)
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|
|
|
|
describe('transactions_without_documents: bank account on each row (A4)', () => {
|
|
it('returns cash_account_id and the cash account ledger, null when unbackfilled', async () => {
|
|
const s = await seedCompany()
|
|
const cashAccountId = await insertCashAccount({ companyId: s.companyId, ledgerAccount: '1940' })
|
|
const mkJe = (n: number) =>
|
|
insertPostedJournalEntry({
|
|
userId: s.userId,
|
|
companyId: s.companyId,
|
|
fiscalPeriodId: s.fiscalPeriodId,
|
|
voucherNumber: n,
|
|
entryDate: `2026-06-${String(n).padStart(2, '0')}`,
|
|
description: `bank ${n}`,
|
|
sourceType: 'bank_transaction',
|
|
lines: [
|
|
{ accountNumber: '1940', debitAmount: 100, creditAmount: 0 },
|
|
{ accountNumber: '3001', debitAmount: 0, creditAmount: 100 },
|
|
],
|
|
})
|
|
const jeWithAccount = await mkJe(1)
|
|
const jeWithoutAccount = await mkJe(2)
|
|
const txWith = await insertTransaction({
|
|
companyId: s.companyId,
|
|
userId: s.userId,
|
|
journalEntryId: jeWithAccount,
|
|
cashAccountId,
|
|
date: '2026-06-01',
|
|
})
|
|
const txWithout = await insertTransaction({
|
|
companyId: s.companyId,
|
|
userId: s.userId,
|
|
journalEntryId: jeWithoutAccount,
|
|
cashAccountId: null,
|
|
date: '2026-06-02',
|
|
})
|
|
|
|
const { rows } = await getPool().query<{
|
|
r: { ok: boolean; transactions: Array<{ id: string; cash_account_id: string | null; cash_account_ledger: string | null }> }
|
|
}>(`SELECT public.transactions_without_documents($1, NULL, 100, 0) AS r`, [s.companyId])
|
|
const byId = new Map(rows[0].r.transactions.map((t) => [t.id, t]))
|
|
expect(byId.get(txWith)).toMatchObject({ cash_account_id: cashAccountId, cash_account_ledger: '1940' })
|
|
expect(byId.get(txWithout)).toMatchObject({ cash_account_id: null, cash_account_ledger: null })
|
|
})
|
|
})
|