Files
accounted/lib/bookkeeping/missing-underlag.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

259 lines
11 KiB
TypeScript

import type { PostgrestError, SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories'
import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard'
import { parseVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { getInvoiceReferencesForJournalEntries } from '@/lib/core/bookkeeping/journal-entry-references'
/**
* Shared resolution of "posted verifikat that lack underlag", scoped by the
* journal list's filters. Single TS mirror of the verifikat_without_documents
* RPC predicate (posted + document-requiring source type, no current-version
* document, no BFL 5 kap 7 § hänvisning via a supplier invoice whose retained
* document is anchored to a journal entry or via a customer invoice that
* points at the entry, no journal_entry_no_doc_required exemption). Used by
* the bulk "Inget underlag krävs" route and the journal list's
* missing_underlag filter so the two can never disagree.
*/
export interface MissingUnderlagFilters {
periodId?: string | null
/** Single uppercase verifikationsserie (A-Z); null/undefined = all. */
series?: string | null
dateFrom?: string | null
dateTo?: string | null
/** Free-text ilike over the voucher description. */
search?: string | null
}
/**
* The candidate columns carried through resolution: enough for the caller to
* sort the full missing set without a second round-trip. total_amount is the
* computed column from migration 20260811100000 (sum of debit lines).
*/
export interface MissingUnderlagEntry {
id: string
/** Sort columns; absent when the caller asked for ids only. */
entry_date?: string
voucher_series?: string | null
voucher_number?: number | null
description?: string | null
total_amount?: number | null
}
/**
* Sub-query failure. `userMessage` is already mapped through getErrorMessage()
* (user-facing Swedish), never a raw driver message. `cause` is the raw
* PostgREST error for the server log: the mapped text alone hid a gateway 414
* behind "Något gick fel" for a whole evening of proxy-log reading (#2395).
*/
export class MissingUnderlagQueryError extends Error {
constructor(
public readonly userMessage: string,
public readonly cause: PostgrestError | unknown,
) {
super(userMessage)
}
}
/**
* Ids per PostgREST .in() filter. Ids travel in the GET query string; 150
* UUIDs is about 5.6 KB, under the 8 KB header buffer that nginx/Kong ship
* with and that self-hosted Supabase inherits. Every lookup below carries the
* chunk exactly ONCE: a filter that repeats it (one .or() over two FK columns)
* doubles the URL and is answered 414 before PostgREST ever sees it (#2395).
*/
const LOOKUP_CHUNK = 150
/**
* Resolve every posted, document-requiring journal entry matching the filters
* that currently has neither an underlag nor an exemption. Returns the full
* missing set (bounded by the tenant's ledger size), ordered by id for
* stability; callers sort/page as needed.
*
* `idOnly` skips the sort columns, notably total_amount, a computed column
* evaluated per candidate row. The bulk-exempt route (built for post-import
* floods of thousands of entries) doesn't sort, so it must not pay that
* per-row aggregate on its full candidate scan.
*
* @throws MissingUnderlagQueryError when a sub-query fails.
*/
export async function resolveMissingUnderlagEntries(
supabase: SupabaseClient,
companyId: string,
filters: MissingUnderlagFilters = {},
{ idOnly = false }: { idOnly?: boolean } = {},
): Promise<MissingUnderlagEntry[]> {
const periodId = filters.periodId ?? null
const series = filters.series ?? null
const dateFrom = filters.dateFrom ?? null
const dateTo = filters.dateTo ?? null
const search = filters.search?.trim() || null
// Candidate entries: posted, document-requiring, matching the active
// filters. Built from LITERAL select strings and literal column filters
// only: tests/schema/no-phantom-columns.test.ts statically resolves every
// query expression against the schema, and a runtime-built select() or
// .or() string counts against its unresolvable ceiling. That is also why a
// voucher-label search runs as two separate queries below instead of one
// .or().
// Named only for its return TYPE below; called solely in the idOnly branch
// so exactly one query is ever built per invocation.
const idSelect = () => supabase.from('journal_entries').select('id')
const buildCandidateQuery = () => {
// Two separate literal select() calls (never one call with a computed
// string). The cast unifies the two builder generics: supabase-js types
// the select string at the type level, and rows are typed by
// fetchAllRows<MissingUnderlagEntry> either way.
let q = idOnly
? idSelect()
: (supabase
.from('journal_entries')
.select(
'id, entry_date, voucher_series, voucher_number, description, total_amount',
) as unknown as ReturnType<typeof idSelect>)
q = q
.eq('company_id', companyId)
.eq('status', 'posted')
.in('source_type', [...NEEDS_DOC_SOURCE_TYPES])
if (periodId) q = q.eq('fiscal_period_id', periodId)
if (series) q = q.eq('voucher_series', series)
if (dateFrom) q = q.gte('entry_date', dateFrom)
if (dateTo) q = q.lte('entry_date', dateTo)
return q
}
type CandidateQuery = ReturnType<typeof buildCandidateQuery>
const fetchCandidates = (refine: (q: CandidateQuery) => CandidateQuery) =>
fetchAllRows<MissingUnderlagEntry>(({ from, to }) =>
refine(buildCandidateQuery()).order('id').range(from, to),
)
let candidates: MissingUnderlagEntry[]
if (search) {
// Same search semantics as the journal list's direct query: a
// voucher-label-shaped needle ("A209") also matches series+number, so
// searching for a voucher by its own label works with the filter on.
const needle = `%${escapeLikePattern(search)}%`
const voucher = parseVoucher(search)
if (voucher) {
const [byDescription, byLabel] = await Promise.all([
fetchCandidates((q) => q.ilike('description', needle)),
fetchCandidates((q) =>
q.eq('voucher_series', voucher.series).eq('voucher_number', voucher.number),
),
])
// Union, deduped by id, restored to the id order fetchAllRows pages by.
const seen = new Set<string>()
const merged: MissingUnderlagEntry[] = []
for (const entry of [...byDescription, ...byLabel]) {
if (seen.has(entry.id)) continue
seen.add(entry.id)
merged.push(entry)
}
merged.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
candidates = merged
} else {
candidates = await fetchCandidates((q) => q.ilike('description', needle))
}
} else {
candidates = await fetchCandidates((q) => q)
}
if (candidates.length === 0) return []
// Resolve which candidates already have a document or an exemption by
// querying ONLY for the candidate ids (chunked), rather than loading the
// company's full document_attachments + journal_entry_no_doc_required tables
// into memory. Data minimisation + bounded memory for large migrations.
const candidateIds = candidates.map((e) => e.id)
const withDoc = new Set<string>()
const exempt = new Set<string>()
for (let i = 0; i < candidateIds.length; i += LOOKUP_CHUNK) {
const chunk = candidateIds.slice(i, i + LOOKUP_CHUNK)
// BFL 5 kap 7 § hänvisning: an entry referenced by a supplier invoice
// whose source document is retained AND anchored to a journal entry
// is NOT missing underlag (only anchored docs sit behind the WORM
// deletion guards). Mirrors the verifikat_without_documents RPC.
// One query per FK column, never one .or() over both: the chunk must
// appear once per URL (see LOOKUP_CHUNK), and a literal .in() keeps the
// filter resolvable for tests/schema/no-phantom-columns.test.ts.
const supplierInvoiceRefs = () =>
supabase
.from('supplier_invoices')
.select(
'registration_journal_entry_id, payment_journal_entry_id, document:document_attachments(journal_entry_id)',
)
.eq('company_id', companyId)
.not('document_id', 'is', null)
const [docRes, siRegRes, siPayRes, sipRefRes, exemptRes] = await Promise.all([
supabase
.from('document_attachments')
.select('journal_entry_id')
.eq('company_id', companyId)
.eq('is_current_version', true)
.in('journal_entry_id', chunk),
supplierInvoiceRefs().in('registration_journal_entry_id', chunk),
supplierInvoiceRefs().in('payment_journal_entry_id', chunk),
supabase
.from('supplier_invoice_payments')
.select(
'journal_entry_id, supplier_invoice:supplier_invoices(document_id, document:document_attachments(journal_entry_id))',
)
.eq('company_id', companyId)
.in('journal_entry_id', chunk),
supabase
.from('journal_entry_no_doc_required')
.select('journal_entry_id')
.eq('company_id', companyId)
.in('journal_entry_id', chunk),
])
for (const res of [docRes, siRegRes, siPayRes, sipRefRes, exemptRes]) {
if (res.error) {
throw new MissingUnderlagQueryError(getUserErrorMessage(res.error), res.error)
}
}
for (const r of (docRes.data ?? []) as { journal_entry_id: string }[]) {
withDoc.add(r.journal_entry_id)
}
// Both lookups return the same row shape; an invoice matched by both
// columns lands twice, harmlessly, in the set.
for (const r of [...(siRegRes.data ?? []), ...(siPayRes.data ?? [])] as unknown as {
registration_journal_entry_id: string | null
payment_journal_entry_id: string | null
document: { journal_entry_id: string | null } | null
}[]) {
if (!r.document?.journal_entry_id) continue // unanchored: not underlag
if (r.registration_journal_entry_id) withDoc.add(r.registration_journal_entry_id)
if (r.payment_journal_entry_id) withDoc.add(r.payment_journal_entry_id)
}
for (const r of (sipRefRes.data ?? []) as unknown as {
journal_entry_id: string | null
supplier_invoice: {
document_id: string | null
document: { journal_entry_id: string | null } | null
} | null
}[]) {
if (r.journal_entry_id && r.supplier_invoice?.document?.journal_entry_id) {
withDoc.add(r.journal_entry_id)
}
}
for (const r of (exemptRes.data ?? []) as { journal_entry_id: string }[]) {
exempt.add(r.journal_entry_id)
}
// BFL 5 kap 7 § hänvisning, customer side (#2298): an entry a register
// invoice points at (registration link or invoice_payments row, e.g. a
// SIE-imported sale matched to its invoice afterwards) is backed by that
// invoice. Mirrors the verifikat_without_documents RPC's customer arm.
let invoiceRefs: Map<string, string[]>
try {
invoiceRefs = await getInvoiceReferencesForJournalEntries(supabase, companyId, chunk)
} catch (err) {
throw new MissingUnderlagQueryError(getUserErrorMessage(err), err)
}
for (const journalEntryId of invoiceRefs.keys()) withDoc.add(journalEntryId)
}
return candidates.filter((e) => !withDoc.has(e.id) && !exempt.has(e.id))
}