Files
accounted/app/api/bookkeeping/journal-entries/route.ts
T
MattssonandClaude Fable 5 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

422 lines
18 KiB
TypeScript

import { NextResponse } from 'next/server'
import { createDraftEntry, createJournalEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
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()
// Query params are hand-parsed with per-param clamping/regex validation (see
// each param's comment) rather than a Zod schema; response shapes are legacy
// `{ data, count }` / `{ error: string }` for the verifikat list UI.
export const GET = withRouteContext('bookkeeping.journal_entries.list', async (request, ctx) => {
const { supabase, companyId, log } = ctx
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const status = searchParams.get('status')
// Drafts get their own surface in the UI; the committed list excludes them.
const excludeDraft = searchParams.get('exclude_draft') === 'true'
// Collapse a correction group to the live correction (hide the storno and the
// reversed original it replaced). The full chain stays reachable.
const collapseCorrections = searchParams.get('collapse_corrections') === 'true'
// Clamp pagination to bound DB work against oversized/pathological inputs
// (compliance A.8.28 / ASVS V1.2.5). The UI page-size selector offers
// 20/50/100/Alla; "Alla" sends a large limit which is capped at MAX_LIMIT.
const MAX_LIMIT = 100000
const rawLimit = parseInt(searchParams.get('limit') || '50', 10)
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), MAX_LIMIT) : 50
const rawOffset = parseInt(searchParams.get('offset') || '0', 10)
const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0
const dateFrom = searchParams.get('date_from')
const dateTo = searchParams.get('date_to')
const sortDate = searchParams.get('sort_date') // 'asc' | 'desc'
// 'series' optional filter: single uppercase letter A-Z. Ignored if any
// other value is passed (defense against trivial injection / typos).
const seriesRaw = searchParams.get('series')
const seriesFilter = seriesRaw && /^[A-Z]$/.test(seriesRaw) ? seriesRaw : null
// Free-text search over the voucher description (verifikationstext). When set,
// we take the direct-query path below (the include_related RPC can't search),
// which filters strictly by fiscal_period_id. So search is scoped to the
// selected fiscal period / company and (like voucher sort) does NOT surface
// cross-period follow-up entries: every result stays inside the selected
// year's series (the BFL-compliant per-year view). It narrows the period, it
// never widens it.
const search = searchParams.get('search')?.trim() || null
// sort_by is a comma-separated priority list of `${column}_${direction}`
// tokens over date | voucher | total | description (single tokens, the old
// format, stay valid). Unknown tokens are ignored, repeated columns are
// deduped, and the list is capped at 3 keys to bound DB work. Amount keys
// order by the total_amount computed column (sum of debit lines, migration
// 20260811100000): PostgREST evaluates it per row, which the RPC path
// below cannot express. sort_by overrides sort_date when present;
// sort_date is kept for backwards compatibility with older clients.
const SORT_TOKEN_RE = /^(date|voucher|total|description)_(asc|desc)$/
const MAX_SORT_KEYS = 3
const sortKeys: { column: 'date' | 'voucher' | 'total' | 'description'; ascending: boolean }[] = []
for (const token of (searchParams.get('sort_by') ?? '').split(',')) {
const m = SORT_TOKEN_RE.exec(token.trim())
if (!m) continue
const column = m[1] as (typeof sortKeys)[number]['column']
if (sortKeys.some((k) => k.column === column)) continue
sortKeys.push({ column, ascending: m[2] === 'asc' })
if (sortKeys.length === MAX_SORT_KEYS) break
}
// A single date key (or no keys at all) is the only shape the RPC path can
// serve, via p_sort_date. Everything else falls through to the direct query.
const soloDateKey = sortKeys.length === 1 && sortKeys[0].column === 'date' ? sortKeys[0] : null
// Default on: when a fiscal period is selected, include follow-up entries
// booked in later periods whose source aggregate (invoice, supplier invoice)
// 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<string, unknown>()
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
// that the RPC normally surfaces under date sort are excluded.
// That's intentional: voucher numbers are series-scoped within a fiscal
// year (BFL 5 kap 6-7 §§), so showing series A1, A2 … alongside entries
// belonging to a different year's series would be misleading. The trade-off
// is that the visible row count may differ between sort modes for the same
// period; the strict count is the BFL-compliant view of that year.
if (periodId && includeRelated && (sortKeys.length === 0 || soloDateKey) && !search) {
const { data, error } = await supabase.rpc('list_fiscal_period_entries_with_related', {
p_company_id: companyId,
p_period_id: periodId,
p_include_related: true,
p_status: status,
p_date_from: dateFrom,
p_date_to: dateTo,
p_sort_date: sortDateParam,
p_limit: limit,
p_offset: offset,
p_exclude_draft: excludeDraft,
p_collapse_corrections: collapseCorrections,
// Series filter lives in the RPC now (#798): filtering here after the RPC
// paged would recompute count from one page only, breaking pagination.
p_series: seriesFilter,
})
if (error) {
log.error('failed to list fiscal period entries via RPC', error)
return NextResponse.json(
{ error: 'Verifikationerna kunde inte hämtas. Försök igen.' },
{ status: 500 }
)
}
const rows = data ?? []
const entries = rows.map((r: { entry: unknown }) => r.entry)
const count = rows.length > 0 ? Number((rows[0] as { total_count: number | string }).total_count) : 0
return NextResponse.json({ data: entries, count })
}
let query = supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)', { count: 'exact' })
.eq('company_id', companyId)
if (sortKeys.length > 0) {
// Apply the priority list in order. total_amount is a computed column (a
// function on the row type), which PostgREST accepts in order=.
for (const key of sortKeys) {
switch (key.column) {
case 'voucher':
query = query
.order('voucher_series', { ascending: key.ascending })
.order('voucher_number', { ascending: key.ascending })
break
case 'date':
query = query.order('entry_date', { ascending: key.ascending })
break
case 'total':
query = query.order('total_amount', { ascending: key.ascending })
break
case 'description':
query = query.order('description', { ascending: key.ascending })
break
}
}
// Stable pagination needs a total order: unless voucher is already a key,
// tiebreak by series+number in the LAST key's direction, so a plain date
// sort keeps same-date vouchers running the same way as the RPC (#972).
if (!sortKeys.some((k) => k.column === 'voucher')) {
const tiebreakAscending = sortKeys[sortKeys.length - 1].ascending
query = query
.order('voucher_series', { ascending: tiebreakAscending })
.order('voucher_number', { ascending: tiebreakAscending })
}
// Final id tiebreak: series+number repeat across fiscal years, so on an
// all-years scope equal sort keys could reshuffle between page requests
// and duplicate or drop rows at page boundaries.
query = query.order('id', { ascending: sortKeys[sortKeys.length - 1].ascending })
} else if (sortDate === 'asc' || sortDate === 'desc') {
// Legacy sort_date param (older clients). Tiebreak same-date vouchers in
// the SAME direction as the date sort (#972).
query = query
.order('entry_date', { ascending: dateAscending })
.order('voucher_series', { ascending: dateAscending })
.order('voucher_number', { ascending: dateAscending })
.order('id', { ascending: dateAscending })
} else {
query = query
.order('voucher_series', { ascending: true })
.order('voucher_number', { ascending: true })
.order('id', { ascending: true })
}
query = query.range(offset, offset + limit - 1)
if (periodId) {
query = query.eq('fiscal_period_id', periodId)
}
if (status) {
query = query.eq('status', status)
} else {
query = query.neq('status', 'cancelled')
if (excludeDraft) {
query = query.neq('status', 'draft')
}
}
if (dateFrom) {
query = query.gte('entry_date', dateFrom)
}
if (dateTo) {
query = query.lte('entry_date', dateTo)
}
if (seriesFilter) {
query = query.eq('voucher_series', seriesFilter)
}
if (search) {
// Escape LIKE wildcards (\ % _) so they match literally, and cap the needle
// length (≤200 chars): both handled by the shared escapeLikePattern helper.
// The cap bounds DB work against oversized/pathological inputs (compliance
// A.8.28 / ASVS V1.2.5); escaping prevents silent over-matching on values
// like "50%". Supabase parameterises the value, so this is not about SQLi.
const needle = `%${escapeLikePattern(search)}%`
// The first thing a user searches for is the voucher's own label ("A209").
// A description-only match never finds it (only OTHER vouchers that
// mention A209 in their text), so a label-shaped needle also matches
// voucher_series + voucher_number. The OR is a PostgREST filter list, so
// the needle is double-quoted to keep commas/parentheses literal.
const voucher = parseVoucher(search)
if (voucher) {
const quotedNeedle = `"${needle.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
query = query.or(
`description.ilike.${quotedNeedle},and(voucher_series.eq.${voucher.series},voucher_number.eq.${voucher.number})`,
)
} else {
query = query.ilike('description', needle)
}
}
// Collapse correction groups (voucher-sort / search path): hide the storno
// and the reversed originals a posted correction replaced, leaving the live
// correction. Pagination/count stay correct because these are query filters.
if (collapseCorrections) {
query = query.neq('source_type', 'storno')
const { data: corrections } = await supabase
.from('journal_entries')
.select('correction_of_id')
.eq('company_id', companyId)
.eq('source_type', 'correction')
.eq('status', 'posted')
.not('correction_of_id', 'is', null)
const correctedOriginalIds = Array.from(
new Set((corrections ?? []).map((r) => r.correction_of_id).filter(Boolean) as string[])
)
if (correctedOriginalIds.length > 0) {
query = query.not('id', 'in', `(${correctedOriginalIds.join(',')})`)
}
}
const { data, error, count } = await query
if (error) {
log.error('failed to list journal entries', error)
return NextResponse.json(
{ error: 'Verifikationerna kunde inte hämtas. Försök igen.' },
{ status: 500 }
)
}
return NextResponse.json({ data, count })
})
export const POST = withRouteContext(
'bookkeeping.journal_entries.create',
async (request, ctx) => {
const { supabase, companyId, user, log } = ctx
const validation = await validateBody(request, CreateJournalEntrySchema)
if (!validation.success) return validation.response
const body = validation.data
const { searchParams } = new URL(request.url)
const asDraft = searchParams.get('as_draft') === 'true'
try {
const entry = asDraft
? await createDraftEntry(supabase, companyId, user.id, body)
: await createJournalEntry(supabase, companyId, user.id, body)
return NextResponse.json({ data: entry })
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
// Untyped errors map to Swedish via getErrorMessage: the raw message is
// logged here and must never reach the user verbatim (issue #337).
log.error('failed to create journal entry', err as Error)
return NextResponse.json(
{ error: getErrorMessage(err, { context: 'journal_entry' }) },
{ status: 400 }
)
}
},
{ requireWrite: true },
)