* fix(bookkeeping): send each missing-underlag lookup chunk once per URL The "verifikat utan underlag" filter (/bookkeeping?missingUnderlag=true) failed with "Verifikaten kunde inte hamtas" on a self-hosted instance as soon as the candidate set passed one chunk of 150 ids. Why it occurred: resolveMissingUnderlagEntries issues four lookups per chunk. Three carry the id list once; the supplier_invoices lookup interpolated the same chunk twice into a single .or() over registration_journal_entry_id and payment_journal_entry_id. That URL alone crossed the 8 KB header buffer nginx/Kong ship with, so the gateway answered 414 before PostgREST saw the request. Hosted sits at roughly half of Cloudflare's 16 KB ceiling on the same query. The LOOKUP_CHUNK docblock acknowledged the doubling without sizing for it. What was removed: the runtime-built .or() string, the chunkInList helper and its uuid guard (the .in() array filter is injection-safe on its own). The supplier-invoice lookup is now two .in() queries, one per FK column, merged into the same set, so every request carries the chunk exactly once and the proxy limit stops being a dependency rather than moving. LOOKUP_CHUNK stays 150 and its comment is now true. The literal filter also leaves the phantom-column scanner's unresolvable budget. Two secondary defects from the same report: MissingUnderlagQueryError now carries the raw driver error as `cause` and the journal-entries route logs it, while the response keeps the Swedish text (the log used to say only "Nagot gick fel", hiding the 414). The "Visa saknade underlag" badge renders the total of the last successful filtered fetch and hides on failure, instead of borrowing the list count (0 on a failed first load, the whole ledger after a toggle). Alternatives: halving LOOKUP_CHUNK moves the wall instead of removing it. Pushing the list filters into the verifikat_without_documents RPC and deleting the TS mirror leaves one predicate instead of two, but moves search, series, date and sort into SQL; recorded in DECISIONS.md as the intended next step for the surface owner. Fixes #2395 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4kpVfYooLp8CWeUguRVpQ * fix(bookkeeping): hide the underlag badge on network failure, log the bulk route's cause Skeptic findings on aacb17634. The badge contract is "no honest source, no badge": the non-OK branch cleared missingCount but the network-level catch (offline, aborted body, JSON parse rejection) did not, so a period or series change that failed at that level kept the previous filtered total next to the toggle. The bulk "Inget underlag kravs" route had the same log gap as the list route: it returned the mapped text without logging the driver error, so a gateway 414 on that path stayed invisible. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4kpVfYooLp8CWeUguRVpQ --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
424 lines
18 KiB
TypeScript
424 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) {
|
|
// err.message is the user-facing Swedish text; the driver error is
|
|
// what an operator needs (a gateway 414 hid behind it in #2395).
|
|
log.error('failed to resolve missing-underlag entries', err, { cause: err.cause })
|
|
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 },
|
|
)
|