0676f5a564
* 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>
240 lines
10 KiB
TypeScript
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))
|
|
}
|