From 0676f5a56495cabc400084d58cb698fc279142b3 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:06:30 +0200 Subject: [PATCH] feat(bookkeeping): dashboard deep link filters verifikat utan underlag server-side (#1840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + app/(dashboard)/bookkeeping/page.tsx | 8 + .../journal-entries/__tests__/route.test.ts | 154 +++++++++++ app/api/bookkeeping/journal-entries/route.ts | 130 ++++++++++ .../no-doc-required/bulk-missing/route.ts | 162 +++--------- components/bookkeeping/JournalEntryList.tsx | 109 ++++++-- components/common/FyPicker.tsx | 16 +- lib/bookkeeping/missing-underlag.ts | 239 ++++++++++++++++++ 8 files changed, 673 insertions(+), 146 deletions(-) create mode 100644 lib/bookkeeping/missing-underlag.ts diff --git a/DECISIONS.md b/DECISIONS.md index e59dd9c1..6dcfbe18 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1176,6 +1176,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-24] Assistant empty answer is a typed failure (EmptyModelAnswerError, 502 'Assistenten gav inget svar. Försök igen.'), one manual retry, NO auto-retry: the silent 200 with an empty answer was the 'Tänker then nothing' bug after the RIP-3 cutover (1500-token cap on /api/agent/ask), and auto-retry would double model spend while hiding the regression. Related deliberate request-shape change: anthropic-family's step-exhausted fallback now keeps tools with tool_choice none, because replaying a transcript containing tool_use/tool_result without declaring tools is rejected by the Messages API (the old shape 400ed every step-exhausted answer). general.help stays on the single-call runtime (founder decision); the fix raises headroom to 5400 tokens and surfaces the failure instead. [2026-08-24] Issue #1820 self-billed credit fix: creditConfirmNumber()/originalRef fall back invoice_number -> external_invoice_number (typed 400 INVOICE_CREDIT_NO_NUMBER if both null) instead of relaxing the DB numbering constraint or dropping the type-the-number confirm step; the confirm step stays (dropping it is a founder call). The invoice-date Forval chip surfaces in ALL editor modes, not only self-billed: the silent today-default exists in every mode and the chip line already carries the due date. In self-billed mode fakturadatum + mottagningsdatum render uncollapsed next to the external number (transcription fields, not defaults); the panel rows are hidden there because registering the same RHF field twice desyncs the inputs. The v1 credit route's existing id-slice fallback was left unchanged (public API behavior). [2026-08-24] No-IBAN reconnect pairing (issue #1709) uses only per-currency exactly-one-each-side elimination, deliberately WITHOUT name equality: ASPSPs reformat product names between consents, so requiring it would silently disable the fix for the banks that need it, while the one-per-currency guard already bounds a mis-pair to skipping rows whose account+date+amount+occurrence all collide. upsertFromPsd2 needed no change: its explicit reuse_cash_account_id promote path already covers a same-connection holder, so the fix only names the paired row from the callback. +[2026-08-24] Dashboard "Verifikat utan underlag" deep link filters server-side via a TS resolver (lib/bookkeeping/missing-underlag.ts, extracted from the bulk-missing route) with a two-step ids-then-rows fetch, NOT by extending list_fiscal_period_entries_with_related or the verifikat_without_documents RPC: no migration, and the predicate stays byte-identical to the badge count's bulk-remedy path. Three deliberate sub-choices: collapse_corrections is ignored while the filter is on (the badge has no collapse notion, hiding corrected originals would desync list from count); the deep link scopes the visit to all fiscal years in memory only (badge counts all years; the saved per-company year preference is not overwritten); client-side re-filtering against attachmentCounts was removed when the filter is on (late-arriving counts could hide server-included rows and desync rows from count). [2026-08-23] Reconciliation engine (PR 1): the skattekonto status engine lives in core lib/reconciliation (reads the core table + the extension snapshot row in extension_data directly) instead of in the skatteverket extension: core must never import @/extensions/*, and the reconciliation facade must work with zero extensions; the matcher stays in the extension and writes its proposals to the row at sync time. Proposals are propose-only (suggested_journal_entry_id is never a link); the same per-entry one-to-one assignment replaces per-row "exactly one candidate". Ledger balances everywhere in reconciliation use the trial-balance predicate status IN (posted, reversed): the drift check summed posted only, which misstated 1630 for every company with a storno on the account. [2026-08-23] Reconciliation doors (PR 2): dashboard routes, the v1 API and the MCP tools all call lib/reconciliation/{service,items,actions}.ts; no door re-implements a link. Policy lives in the door: page + REST apply directly, MCP stages (reconciliation_match / reconciliation_unmatch pending operations, executors in commit.ts). The MCP write tools are catalogVisibility search (and gnubok_link_transaction_to_journal_entry moved to search) because the tools/list payload ceiling (59 900 tokens) left no room for them in the default catalog; the reads (status with account_key, items) stay default and the items description points at the write. The skattekonto link now has its canonical implementation in core lib/skatteverket/skattekonto-link.ts (needed by core doors; core must not import the extension); the extension route still uses its own matchSkattekontoToEntry until its queued-mock tests are ported, then it delegates. New scopes reconciliation:read/write; gnubok_get_reconciliation_status keeps reports:read and the legacy bank routes keep transactions:* so no existing key is cut off. [2026-08-23] Avstämning page (PR 3) ships without the period picker, the manual two-pane match mode and the sign-off button: the page renders the approved 'Vald riktning' layout (rail + tiles + bridge + actions + banded table) over the PR 2 dashboard routes only, so that it is verifiable on its own; period + sign-off arrive together in PR 4 (both are period-bound), manual N:M matching with residual booking in PR 5. Bank accounts get the same generic body plus links to the existing bank view for the matcher run rather than embedding the 1900-line BankReconciliationView: one body for every account kind is the point of the page, and embedding would have doubled the header. diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx index e6974c22..e2cde481 100644 --- a/app/(dashboard)/bookkeeping/page.tsx +++ b/app/(dashboard)/bookkeeping/page.tsx @@ -54,6 +54,13 @@ export default function BookkeepingPage() { const raw = searchParams.get('copy_from') return raw && UUID_RE.test(raw) ? raw : null }, [searchParams]) + // Deep link from the dashboard "Verifikat utan underlag" card (and the + // push-notification link): open the list with the saknade-underlag filter + // already on. Read once on mount: the param stays in the URL (shareable), + // and later in-page filter changes must not be fought by the URL. + const [initialShowMissingOnly] = useState( + () => searchParams.get('missingUnderlag') === 'true', + ) const [refreshKey, setRefreshKey] = useState(0) const [showNewEntry, setShowNewEntry] = useState(false) @@ -270,6 +277,7 @@ export default function BookkeepingPage() { losing expansion/selection/scroll. */} { // Raw Supabase messages never reach the response field (issue #337). expect(body.error).toBe('Verifikationerna kunde inte hämtas. Försök igen.') }) + + describe('missing_underlag=true (the dashboard deep-link filter)', () => { + // Candidate ids must be UUID-shaped: the resolver interpolates them into + // the supplier-invoice .or() filter behind a UUID guard. + const E1 = '11111111-1111-4111-8111-111111111111' + const E2 = '22222222-2222-4222-8222-222222222222' + const E3 = '33333333-3333-4333-8333-333333333333' + const candidate = (id: string, voucherNumber: number) => ({ + id, + entry_date: '2026-06-08', + voucher_series: 'A', + voucher_number: voucherNumber, + description: 'test', + total_amount: 100, + }) + + it('returns only entries without documents, exemption-aware, with the full-set count', async () => { + enqueue({ data: [candidate(E1, 1), candidate(E2, 2), candidate(E3, 3)], error: null }) // candidates + enqueue({ data: [{ journal_entry_id: E1 }], error: null }) // E1 has a document + enqueue({ data: [], error: null }) // no SI references + enqueue({ data: [], error: null }) // no SI payment-row references + enqueue({ data: [{ journal_entry_id: E3 }], error: null }) // E3 exempt + const fullRow = makeJournalEntry({ id: E2 }) + enqueue({ data: [fullRow], error: null }) // page rows + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { missing_underlag: 'true', exclude_draft: 'true' }, + }) + const { status, body } = await parseJsonResponse<{ data: { id: string }[]; count: number }>( + await GET(request) + ) + + expect(status).toBe(200) + expect(body.data.map((e) => e.id)).toEqual([E2]) + expect(body.count).toBe(1) + }) + + it('takes this path instead of the include_related RPC when a period is selected', async () => { + enqueue({ data: [], error: null }) // candidates: none + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { missing_underlag: 'true', period_id: 'period-1', exclude_draft: 'true' }, + }) + const { status, body } = await parseJsonResponse<{ data: unknown[]; count: number }>( + await GET(request) + ) + + expect(status).toBe(200) + expect(body.data).toEqual([]) + expect(body.count).toBe(0) + expect(mockSupabase.rpc).not.toHaveBeenCalled() + }) + + it('paginates over the missing set with count spanning all pages', async () => { + // Out of voucher order on purpose: default sort is series+number asc. + enqueue({ data: [candidate(E3, 3), candidate(E1, 1), candidate(E2, 2)], error: null }) + enqueue({ data: [], error: null }) // no documents + enqueue({ data: [], error: null }) // no SI references + enqueue({ data: [], error: null }) // no SI payment-row references + enqueue({ data: [], error: null }) // no exemptions + enqueue({ data: [makeJournalEntry({ id: E2 })], error: null }) // page rows + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { + missing_underlag: 'true', + exclude_draft: 'true', + limit: '1', + offset: '1', + }, + }) + const { status, body } = await parseJsonResponse<{ data: { id: string }[]; count: number }>( + await GET(request) + ) + + expect(status).toBe(200) + // Page 2 of size 1 under voucher-asc = A2. + expect(body.data.map((e) => e.id)).toEqual([E2]) + expect(body.count).toBe(3) + }) + + it('treats an anchored supplier-invoice reference as underlag (BFL 5 kap 7 §)', async () => { + enqueue({ data: [candidate(E1, 1), candidate(E2, 2)], error: null }) + enqueue({ data: [], error: null }) // no direct documents + enqueue({ + data: [ + { + registration_journal_entry_id: E1, + payment_journal_entry_id: null, + document: { journal_entry_id: E1 }, // anchored → counts as underlag + }, + ], + error: null, + }) + enqueue({ data: [], error: null }) // no SI payment-row references + enqueue({ data: [], error: null }) // no exemptions + enqueue({ data: [makeJournalEntry({ id: E2 })], error: null }) // page rows + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { missing_underlag: 'true', exclude_draft: 'true' }, + }) + const { status, body } = await parseJsonResponse<{ data: { id: string }[]; count: number }>( + await GET(request) + ) + + expect(status).toBe(200) + expect(body.data.map((e) => e.id)).toEqual([E2]) + expect(body.count).toBe(1) + }) + + it('matches a voucher-label search against series+number, like the direct path', async () => { + // A voucher-shaped needle fans out to TWO candidate queries (description + // ilike, then series+number), unioned by id: a runtime-built .or() + // would count against the phantom-column scanner's ceiling. + enqueue({ data: [], error: null }) // candidates by description: none + enqueue({ data: [candidate(E1, 209)], error: null }) // candidates by voucher label + enqueue({ data: [], error: null }) // no documents + enqueue({ data: [], error: null }) // no SI references + enqueue({ data: [], error: null }) // no SI payment-row references + enqueue({ data: [], error: null }) // no exemptions + enqueue({ data: [makeJournalEntry({ id: E1 })], error: null }) // page rows + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { missing_underlag: 'true', exclude_draft: 'true', search: 'A209' }, + }) + const { status, body } = await parseJsonResponse<{ data: { id: string }[]; count: number }>( + await GET(request) + ) + + expect(status).toBe(200) + // Searching "A209" with the filter on must find verifikat A209 even + // when its description never mentions it. + expect(body.data.map((e) => e.id)).toEqual([E1]) + expect(body.count).toBe(1) + const eqCalls = findCalls('journal_entries', 'eq') + expect(eqCalls).toContainEqual(['voucher_series', 'A']) + expect(eqCalls).toContainEqual(['voucher_number', 209]) + }) + + it('is ignored for the drafts view', async () => { + enqueue({ data: [], error: null, count: 0 }) + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { missing_underlag: 'true', status: 'draft' }, + }) + const { status } = await parseJsonResponse(await GET(request)) + + expect(status).toBe(200) + // The drafts view goes through the normal direct query, not the + // resolver: no lookup-table queries. + expect(mockSupabase.from).toHaveBeenCalledWith('journal_entries') + expect(mockSupabase.from).not.toHaveBeenCalledWith('document_attachments') + expect(mockSupabase.from).not.toHaveBeenCalledWith('journal_entry_no_doc_required') + }) + }) }) const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000' diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts index 4feb6835..89dc6f13 100644 --- a/app/api/bookkeeping/journal-entries/route.ts +++ b/app/api/bookkeeping/journal-entries/route.ts @@ -8,6 +8,11 @@ import { CreateJournalEntrySchema } from '@/lib/api/schemas' import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard' import { parseVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { getErrorMessage } from '@/lib/errors/get-error-message' +import { + MissingUnderlagQueryError, + resolveMissingUnderlagEntries, + type MissingUnderlagEntry, +} from '@/lib/bookkeeping/missing-underlag' ensureInitialized() @@ -75,10 +80,135 @@ export const GET = withRouteContext('bookkeeping.journal_entries.list', async (r // is dated inside the selected period. Pass include_related=false to // restore strict fiscal_period_id filtering. const includeRelated = searchParams.get('include_related') !== 'false' + // Server-side "saknar underlag" filter (dashboard deep link + the list + // dialog's "Visa saknade underlag" toggle). Committed view only: the + // predicate is posted-only, so it is meaningless for the drafts view. + const missingUnderlag = searchParams.get('missing_underlag') === 'true' && status !== 'draft' const dateAscending = sortDate === 'asc' const sortDateParam = soloDateKey ? (soloDateKey.ascending ? 'asc' : 'desc') : sortDate === 'asc' ? 'asc' : 'desc' + if (missingUnderlag) { + // The missing-underlag predicate spans three tables (current-version + // documents, anchored supplier-invoice references per BFL 5 kap 7 §, and + // journal_entry_no_doc_required exemptions), which PostgREST cannot + // express as a row filter. So this path resolves the FULL missing set via + // the shared helper (the same code the bulk "Inget underlag krävs" route + // uses, mirroring the verifikat_without_documents RPC that feeds the + // dashboard badge), sorts it with the active sort stack, pages it, and + // fetches only the page's rows. count is the full filtered total, so + // pagination and the dialog badge stay honest. + // + // collapse_corrections is deliberately NOT applied here: the dashboard + // badge has no collapse notion, and hiding corrected originals would make + // the filtered list disagree with the count that led the user here. + let missing + try { + missing = await resolveMissingUnderlagEntries(supabase, companyId, { + periodId, + series: seriesFilter, + dateFrom, + dateTo, + search, + }) + } catch (err) { + if (err instanceof MissingUnderlagQueryError) { + log.error('failed to resolve missing-underlag entries', err) + return NextResponse.json( + { error: 'Verifikationerna kunde inte hämtas. Försök igen.' }, + { status: 500 } + ) + } + throw err + } + + // Sort the full set with the same key semantics as the direct query + // below: the sort stack in priority order, a voucher tiebreak in the last + // key's direction unless voucher is already a key, and a final id + // tiebreak for a stable total order across page requests. + const keys = + sortKeys.length > 0 + ? sortKeys + : sortDate === 'asc' || sortDate === 'desc' + ? [{ column: 'date' as const, ascending: dateAscending }] + : [] + const compareVoucher = (a: MissingUnderlagEntry, b: MissingUnderlagEntry) => { + const seriesA = a.voucher_series ?? '' + const seriesB = b.voucher_series ?? '' + if (seriesA !== seriesB) return seriesA < seriesB ? -1 : 1 + return (a.voucher_number ?? 0) - (b.voucher_number ?? 0) + } + const compareBy = ( + column: 'date' | 'voucher' | 'total' | 'description', + a: MissingUnderlagEntry, + b: MissingUnderlagEntry, + ) => { + switch (column) { + case 'date': { + const dateA = a.entry_date ?? '' + const dateB = b.entry_date ?? '' + return dateA < dateB ? -1 : dateA > dateB ? 1 : 0 + } + case 'voucher': + return compareVoucher(a, b) + case 'total': + return (a.total_amount ?? 0) - (b.total_amount ?? 0) + case 'description': { + const descA = a.description ?? '' + const descB = b.description ?? '' + return descA < descB ? -1 : descA > descB ? 1 : 0 + } + } + } + const lastAscending = keys.length > 0 ? keys[keys.length - 1].ascending : true + const sorted = [...missing].sort((a, b) => { + for (const key of keys) { + const cmp = compareBy(key.column, a, b) + if (cmp !== 0) return key.ascending ? cmp : -cmp + } + if (!keys.some((k) => k.column === 'voucher')) { + const cmp = compareVoucher(a, b) + if (cmp !== 0) return lastAscending ? cmp : -cmp + } + const cmp = a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + return lastAscending ? cmp : -cmp + }) + + const total = sorted.length + const pageIds = sorted.slice(offset, offset + limit).map((e) => e.id) + if (pageIds.length === 0) { + return NextResponse.json({ data: [], count: total }) + } + + // Fetch the page's full rows in id chunks: "Alla" as page size can put + // thousands of ids on this page, and a single .in() with that many ids + // would blow PostgREST's URL length limit. + const ROW_CHUNK = 100 + const rowsById = new Map() + for (let i = 0; i < pageIds.length; i += ROW_CHUNK) { + const chunk = pageIds.slice(i, i + ROW_CHUNK) + const { data: chunkRows, error } = await supabase + .from('journal_entries') + .select('*, lines:journal_entry_lines(*)') + .eq('company_id', companyId) + .in('id', chunk) + if (error) { + log.error('failed to fetch missing-underlag page rows', error) + return NextResponse.json( + { error: 'Verifikationerna kunde inte hämtas. Försök igen.' }, + { status: 500 } + ) + } + for (const row of (chunkRows ?? []) as { id: string }[]) { + rowsById.set(row.id, row) + } + } + // Reassemble in the sorted page order (the .in() fetch has no order). + const data = pageIds.map((id) => rowsById.get(id)).filter(Boolean) + + return NextResponse.json({ data, count: total }) + } + // Non-date sorts (and stacked sorts): the include_related RPC only orders // by date, so fall through to the direct query below. This means these // sorts are *strict by fiscal_period_id*: cross-period follow-up entries diff --git a/app/api/bookkeeping/no-doc-required/bulk-missing/route.ts b/app/api/bookkeeping/no-doc-required/bulk-missing/route.ts index b70b385d..d483031f 100644 --- a/app/api/bookkeeping/no-doc-required/bulk-missing/route.ts +++ b/app/api/bookkeeping/no-doc-required/bulk-missing/route.ts @@ -2,11 +2,11 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' -import { fetchAllRows } from '@/lib/supabase/fetch-all' import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required' -import { NEEDS_DOC_SOURCE_TYPES } from '@/lib/worklist/categories' -import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard' -import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { + MissingUnderlagQueryError, + resolveMissingUnderlagEntries, +} from '@/lib/bookkeeping/missing-underlag' // A real calendar date in YYYY-MM-DD form. Rejects shaped-but-invalid values // (e.g. 9999-99-99 or 2026-02-30) that a bare /^\d{4}-\d{2}-\d{2}$/ regex would @@ -25,12 +25,6 @@ const isoDate = z.string().refine( { message: 'Ogiltigt datum (förväntat YYYY-MM-DD)' }, ) -// 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() - const BulkMissingSchema = z.object({ period_id: z.string().uuid().nullable().optional(), // Single uppercase verifikationsserie (A-Z); the list sends null for "all". @@ -51,9 +45,11 @@ const BulkMissingSchema = z.object({ * scalable remedy for the "thousands of saknade underlag after a migration" * problem; the per-entry batch route handles selective marking. * - * The missing-doc predicate mirrors countVerifikatMissingDocument: posted + - * NEEDS_DOC source type, no current-version document_attachment, not already - * exempt. + * The missing-doc predicate lives in resolveMissingUnderlagEntries (shared + * with the journal list's missing_underlag filter) and mirrors + * countVerifikatMissingDocument: posted + NEEDS_DOC source type, no + * current-version document_attachment, no anchored supplier-invoice reference, + * not already exempt. */ export const POST = withRouteContext( 'journal_entry.bulk_missing_no_document_required', @@ -64,124 +60,32 @@ export const POST = withRouteContext( // All formats are enforced by the schema above, so these are already valid // (or null). No re-validation needed before they reach the query layer. const { period_id, reason, dry_run } = validation.data - const series = validation.data.series ?? null - const dateFrom = validation.data.date_from ?? null - const dateTo = validation.data.date_to ?? null - const search = validation.data.search?.trim() || null - // Candidate entries: posted, document-requiring, matching the active filters. - const candidates = await fetchAllRows<{ id: string }>(({ from, to }) => { - let q = supabase - .from('journal_entries') - .select('id') - .eq('company_id', companyId) - .eq('status', 'posted') - .in('source_type', [...NEEDS_DOC_SOURCE_TYPES]) - if (period_id) q = q.eq('fiscal_period_id', period_id) - if (series) q = q.eq('voucher_series', series) - if (dateFrom) q = q.gte('entry_date', dateFrom) - if (dateTo) q = q.lte('entry_date', dateTo) - if (search) q = q.ilike('description', `%${escapeLikePattern(search)}%`) - return q.order('id').range(from, to) - }) - - if (candidates.length === 0) { - return NextResponse.json({ data: dry_run ? { count: 0 } : { exempted: 0 } }) + let missing + try { + missing = await resolveMissingUnderlagEntries( + supabase, + companyId, + { + periodId: period_id ?? null, + series: validation.data.series ?? null, + dateFrom: validation.data.date_from ?? null, + dateTo: validation.data.date_to ?? null, + search: validation.data.search ?? null, + }, + // No sorting here, so skip the per-row total_amount computed column + // on what can be a full post-import candidate scan. + { idOnly: true }, + ) + } catch (err) { + if (err instanceof MissingUnderlagQueryError) { + // userMessage is already mapped through getErrorMessage() in the + // resolver: user-facing Swedish, never a raw driver message. + return NextResponse.json({ error: err.userMessage }, { status: 400 }) + } + throw err } - - // 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() - const exempt = new Set() - // 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 - 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; - // without this the bulk action would waive entries the warning no - // longer counts. - 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), - ]) - if (docRes.error) { - return NextResponse.json({ error: getUserErrorMessage(docRes.error) }, { status: 400 }) - } - if (siRefRes.error) { - return NextResponse.json({ error: getUserErrorMessage(siRefRes.error) }, { status: 400 }) - } - if (sipRefRes.error) { - return NextResponse.json({ error: getUserErrorMessage(sipRefRes.error) }, { status: 400 }) - } - if (exemptRes.error) { - return NextResponse.json({ error: getUserErrorMessage(exemptRes.error) }, { status: 400 }) - } - 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) - } - } - - const missingIds = candidateIds.filter((id) => !withDoc.has(id) && !exempt.has(id)) + const missingIds = missing.map((e) => e.id) if (dry_run) { return NextResponse.json({ data: { count: missingIds.length } }) diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 483b7eb9..71145a9e 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -194,6 +194,7 @@ const ALL_PAGE_SIZE = 100000 export default function JournalEntryList({ pristineSlot, refreshToken, + initialShowMissingOnly = false, }: { pristineSlot?: ReactNode /** @@ -204,6 +205,14 @@ export default function JournalEntryList({ * selection, pagination and scroll position. */ refreshToken?: number + /** + * Deep-link arrival (dashboard "Verifikat utan underlag" card, + * push-notification link): start with the "Visa saknade underlag" filter on + * and, for this visit only, scope to all fiscal years so the visible set + * matches the all-years dashboard count. The saved fiscal-year preference + * is not overwritten. + */ + initialShowMissingOnly?: boolean } = {}) { const router = useRouter() const { toast } = useToast() @@ -241,7 +250,7 @@ export default function JournalEntryList({ // (BFL 5 kap 5 §), not only on the detail page. const [rattelseFlags, setRattelseFlags] = useState>(new Set()) const [noDocRequired, setNoDocRequired] = useState>(new Map()) - const [showMissingOnly, setShowMissingOnly] = useState(false) + const [showMissingOnly, setShowMissingOnly] = useState(initialShowMissingOnly) const [selectedIds, setSelectedIds] = useState>(new Set()) const [batchReason, setBatchReason] = useState('') const [batchSubmitting, setBatchSubmitting] = useState(false) @@ -257,6 +266,9 @@ export default function JournalEntryList({ const [sortHydrated, setSortHydrated] = useState(false) const [periodId, setPeriodId] = useState(null) const [periodHydrated, setPeriodHydrated] = useState(false) + // One-shot: a deep-link arrival scopes the first period resolution to all + // years (see the period effect below) without touching the saved preference. + const deepLinkAllYearsRef = useRef(initialShowMissingOnly) const [filterOpen, setFilterOpen] = useState(false) const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') @@ -450,6 +462,17 @@ export default function JournalEntryList({ return } + // Deep-link arrival with the missing-underlag filter: scope this visit to + // all fiscal years (in memory only) so the list can show the same set the + // all-years dashboard badge counted. Consumed once; picking a year in the + // FyPicker afterwards works and persists as usual. + if (deepLinkAllYearsRef.current) { + deepLinkAllYearsRef.current = false + setPeriodId(null) + setPeriodHydrated(true) + return + } + const stored = typeof window !== 'undefined' ? window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id) @@ -536,6 +559,11 @@ export default function JournalEntryList({ if (dateFrom) params.set('date_from', dateFrom) if (dateTo) params.set('date_to', dateTo) if (seriesFilter !== 'all') params.set('series', seriesFilter) + // Server-side filter: the missing-underlag predicate spans documents, + // supplier-invoice references and exemptions, so the server resolves it + // across ALL pages (count included). Filtering the fetched page here + // could only ever show this page's missing rows. + if (showMissingOnly) params.set('missing_underlag', 'true') } if (search) params.set('search', search) @@ -572,7 +600,10 @@ export default function JournalEntryList({ // count BEFORE clearing loading so the toggle doesn't flash out for a frame on // a stale count of 0. Every other case refreshes the badge in the background. if (loadedEntries.length === 0 && listMode === 'committed') { - const unscopedQuery = !periodId && !dateFrom && !dateTo && seriesFilter === 'all' && !search + // A missing-underlag-filtered total of 0 says nothing about whether + // the ledger has entries, so that mode never short-circuits the probe. + const unscopedQuery = + !periodId && !dateFrom && !dateTo && seriesFilter === 'all' && !search && !showMissingOnly await Promise.all([ fetchDraftCount(), unscopedQuery @@ -642,7 +673,7 @@ export default function JournalEntryList({ useEffect(() => { if (!sortHydrated || !periodHydrated || !pageSizeHydrated) return fetchEntries() - }, [periodId, page, pageSize, sortParam, dateFrom, dateTo, seriesFilter, search, listMode, collapseCorrections, sortHydrated, periodHydrated, pageSizeHydrated]) + }, [periodId, page, pageSize, sortParam, dateFrom, dateTo, seriesFilter, search, listMode, collapseCorrections, showMissingOnly, sortHydrated, periodHydrated, pageSizeHydrated]) // Parent-driven in-place refresh (see the refreshToken prop). Skips the // mount value: the main effect above owns the initial fetch, and a token @@ -658,8 +689,33 @@ export default function JournalEntryList({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [refreshToken, sortHydrated, periodHydrated, pageSizeHydrated]) + // Render-time ref mirrors so the stable [] callback below can read current + // state and call the current fetchEntries (same pattern as + // JournalEntryAttachments' onCountChangeRef). + const showMissingOnlyRef = useRef(showMissingOnly) + showMissingOnlyRef.current = showMissingOnly + const fetchEntriesRef = useRef(fetchEntries) + fetchEntriesRef.current = fetchEntries + // Entry ids that already triggered a filter refetch after gaining underlag: + // if the server still included the row after that refetch (predicate + // disagreement), a remount would re-fire the callback and loop forever. + const refetchedAfterAttachRef = useRef>(new Set()) + const handleAttachmentCountChange = useCallback((entryId: string, count: number) => { setAttachmentCounts((prev) => ({ ...prev, [entryId]: count })) + // With the server-side saknade-underlag filter on, a listed row that just + // received its first underlag no longer belongs to the filtered set: + // refetch in place so it leaves the list, as the old client-side filter + // did. Listed rows arrive with zero underlag by definition, so a count + // above zero here can only follow a user action. + if ( + count > 0 && + showMissingOnlyRef.current && + !refetchedAfterAttachRef.current.has(entryId) + ) { + refetchedAfterAttachRef.current.add(entryId) + void fetchEntriesRef.current({ preserveSelection: true }) + } }, []) const toggleExpand = (id: string) => { @@ -797,6 +853,10 @@ export default function JournalEntryList({ title: t('batch_no_doc_done_title'), description: t('batch_no_doc_done_description', { count: body.data?.exempted ?? ids.length }), }) + // With the server-side saknade-underlag filter on, the exempted rows no + // longer belong to the filtered set: refetch so they leave the list and + // the count updates (the pre-server-filter behavior). + if (showMissingOnly) await fetchEntries() } catch { toast({ title: t('no_doc_required_save_failed'), variant: 'destructive' }) } finally { @@ -866,15 +926,13 @@ export default function JournalEntryList({ } } - const filteredEntries = showMissingOnly - ? entries.filter( - (e) => - NEEDS_ATTACHMENT.has(e.source_type) && - !attachmentCounts[e.id] && - e.status === 'posted' && - !noDocRequired.has(e.id) - ) - : entries + // The missing-underlag filter is applied SERVER-side (missing_underlag=true + // in fetchEntries): the server's set spans all pages and includes reference + // -aware document checks the client can't see. Re-filtering here against the + // late-arriving attachmentCounts could hide rows the server included and + // desync the visible rows from the returned count, so the fetched page is + // rendered as-is. + const filteredEntries = entries // Detail-pager context: the loaded page as rendered, written when the user // opens a verifikat. Server-paginated, so prev/next spans this page only. @@ -1199,17 +1257,23 @@ export default function JournalEntryList({ {/* Visa saknade underlag */}
+ {/* Committed view only: the predicate is posted-only, so in the + drafts view the toggle would just mislabel the draft count. */} { + setShowMissingOnly(on) + setPage(0) + }} /> {showMissingOnly && ( - {filteredEntries.length} + {count} )}
@@ -1250,7 +1314,15 @@ export default function JournalEntryList({ authoritatively in the period effect. */} {periodHydrated && (
- + {/* suppressAutoRestore on deep-link visits: the arrival scope is a + deliberate in-memory "Alla räkenskapsår" (matches the all-years + dashboard badge); FyPicker's on-load restore of the persisted + year would otherwise snap the scope back right after load. */} +
)} @@ -1690,6 +1762,13 @@ export default function JournalEntryList({ else next.delete(entry.id) return next }) + // Server-side saknade-underlag filter on: an + // exempted row leaves the filtered set, so + // refetch in place (keeps expansion state + // semantics of a background refresh). + if (exempted && showMissingOnly) { + void fetchEntries({ preserveSelection: true }) + } }} /> )} diff --git a/components/common/FyPicker.tsx b/components/common/FyPicker.tsx index 4f669402..708bf97f 100644 --- a/components/common/FyPicker.tsx +++ b/components/common/FyPicker.tsx @@ -47,6 +47,17 @@ interface FyPickerProps { * without any cross-session hazard. */ requireExplicitChoice?: boolean + /** + * Skip ONLY the on-load restore of a persisted selection (and its + * newest-period fallback) while keeping manual picks persisted as usual. + * For deep-link visits that arrive with a deliberate transient scope (e.g. + * /bookkeeping?missingUnderlag=true opens as "Alla räkenskapsår" to match + * the all-years dashboard count): without this, the restore fires on + * `value === null` and snaps the scope back to the stored year right after + * load. Unlike requireExplicitChoice this does not change labels or + * persistence semantics. + */ + suppressAutoRestore?: boolean /** Fires once after the initial period load completes. */ onReady?: () => void /** Server-loaded periods for the first render, scoped to initialCompanyId. */ @@ -83,6 +94,7 @@ export function FyPicker({ hideFuturePeriods = false, preferLatestEnded = false, requireExplicitChoice = false, + suppressAutoRestore = false, onReady, initialPeriods, initialCompanyId, @@ -132,7 +144,7 @@ export function FyPicker({ // ALL_YEARS-stored fallback, newest-period, preferLatestEnded), and a // per-branch gate already missed one of them once. Nothing auto-fires; // the picker stays empty until a human picks. - if (value === null && !requireExplicitChoice && typeof window !== 'undefined') { + if (value === null && !requireExplicitChoice && !suppressAutoRestore && typeof window !== 'undefined') { if (preferLatestEnded) { // Filing surfaces: ignore the shared scope memory and open on the // most recently ended period (fetched is sorted newest-first). @@ -160,7 +172,7 @@ export function FyPicker({ // onReady is a lifecycle callback: fire once per load, not on parent // re-renders that re-create it. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [company?.id, hideFuturePeriods, includeAllOption, preferLatestEnded, requireExplicitChoice, initialCompanyId, initialPeriods, storageKeyPrefix]) + }, [company?.id, hideFuturePeriods, includeAllOption, preferLatestEnded, requireExplicitChoice, suppressAutoRestore, initialCompanyId, initialPeriods, storageKeyPrefix]) const handleChange = (id: string) => { const nextId = id === ALL_YEARS_VALUE ? null : id diff --git a/lib/bookkeeping/missing-underlag.ts b/lib/bookkeeping/missing-underlag.ts new file mode 100644 index 00000000..74572949 --- /dev/null +++ b/lib/bookkeeping/missing-underlag.ts @@ -0,0 +1,239 @@ +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 { + 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 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) + 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 + const fetchCandidates = (refine: (q: CandidateQuery) => CandidateQuery) => + fetchAllRows(({ 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() + 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() + const exempt = new Set() + 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)) +}