Files
accounted/lib/bookkeeping/missing-underlag.ts
T
Mattsson 0676f5a564 feat(bookkeeping): dashboard deep link filters verifikat utan underlag server-side (#1840)
* feat(bookkeeping): dashboard deep link filters verifikat utan underlag server-side

The dashboard "Verifikat utan underlag" card (and the push-notification
link) pointed at /bookkeeping?missingUnderlag=true, but nothing read the
param: the user landed on the plain unfiltered ledger. The existing
"Visa saknade underlag" toggle also only filtered the already-fetched
page, so it could not represent the badge count across pages.

- lib/bookkeeping/missing-underlag.ts: shared resolver of "posted
  verifikat lacking underlag" (document-requiring source types, no
  current-version document, no anchored supplier-invoice reference per
  BFL 5 kap 7 §, no exemption), extracted from the bulk "Inget underlag
  krävs" route so list, bulk remedy and dashboard badge share one
  predicate.
- GET /api/bookkeeping/journal-entries?missing_underlag=true: resolves
  the full missing set server-side, applies the active sort stack, pages
  it, and returns the full-set count, fetching page rows in id chunks so
  the "Alla" page size cannot blow the PostgREST URL limit.
- JournalEntryList: the toggle is now server-backed (refetch on change,
  honest count in the dialog badge); client-side re-filtering against
  late-arriving attachment counts removed. Deep-link arrival turns the
  filter on and scopes the visit to all fiscal years in memory only,
  matching the all-years badge count without touching the saved
  preference.

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

* fix(bookkeeping): harden the saknade-underlag filter after skeptic review

Three skeptic subagents refuted the first cut; this fixes every confirmed
finding in one pass:

- FyPicker: new suppressAutoRestore prop. The deep-link visit opens as
  "Alla räkenskapsår" in memory, and FyPicker's on-load restore of the
  persisted year (value === null) snapped the scope back right after
  load, desyncing the list from the all-years badge that launched it.
  Manual picks still persist as usual.
- Voucher-label search: the resolver now carries the same parseVoucher
  OR-branch as the direct list path, so searching "A209" with the filter
  on finds verifikat A209 instead of silently returning 0 rows.
- Staleness while the filter is on: batch exempt, the single-row "Inget
  underlag krävs" toggle, and a row gaining its first underlag now
  refetch in place so fixed rows leave the filtered list and the count
  stays honest (the pre-server-filter behavior). The attachment-driven
  refetch is guarded per entry id against predicate-disagreement loops.
- Drafts view: the filter switch is disabled there; the predicate is
  posted-only and the badge would mislabel the draft count.
- Perf: the bulk-exempt route resolves ids only, skipping the per-row
  total_amount computed column on its full post-import candidate scan.

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

* fix(bookkeeping): keep the underlag resolver statically checkable

The skeptic-fix commit tripped the phantom-column scanner ceiling
(tests/schema/no-phantom-columns.test.ts, 382 > 380): a computed
select() string and a runtime-built .or() are expressions the scanner
cannot resolve against the schema. Restructured instead of raising the
ceiling: the idOnly/full column choice is two literal select() calls
behind a lazy branch, and a voucher-label search fans out to two
statically-checkable candidate queries (description ilike, series+number
eq) unioned by id, same semantics as before.

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

---------

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

240 lines
10 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { z } from 'zod'
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'
/**
* 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, 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.
*/
export class MissingUnderlagQueryError extends Error {
constructor(public readonly userMessage: string) {
super(userMessage)
}
}
// Journal-entry ids are interpolated into the supplier-invoice .or() filter
// string below, so they must be UUIDs. They originate from journal_entries.id
// (DB-sourced, never request input), but this guard keeps the injection-safety
// contract identical to /api/documents/counts.
const uuidSchema = z.string().uuid()
// 150 keeps the embedded id lists well under PostgREST's URL-length limit:
// the supplier-invoice .or() below repeats the chunk twice (registration +
// payment FK), so a larger chunk would risk truncating the GET filter.
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)
// Only UUIDs reach the interpolated .or() string (the .in() array filters
// are already injection-safe); mirrors the guard in documents/counts.
const chunkInList = `(${chunk.filter((id) => uuidSchema.safeParse(id).success).join(',')})`
const [docRes, siRefRes, 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),
// 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.
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)
.or(
`registration_journal_entry_id.in.${chunkInList},payment_journal_entry_id.in.${chunkInList}`,
),
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, siRefRes, sipRefRes, exemptRes]) {
if (res.error) throw new MissingUnderlagQueryError(getUserErrorMessage(res.error))
}
for (const r of (docRes.data ?? []) as { journal_entry_id: string }[]) {
withDoc.add(r.journal_entry_id)
}
for (const r of (siRefRes.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)
}
}
return candidates.filter((e) => !withDoc.has(e.id) && !exempt.has(e.id))
}