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 { getErrorMessage } from '@/lib/errors/get-error-message' 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 // 'date_desc' (default) | 'date_asc' | 'voucher_asc' | 'voucher_desc' // sort_by overrides sort_date when present. sort_date is kept for backwards // compatibility with older clients. const sortBy = searchParams.get('sort_by') const isVoucherSort = sortBy === 'voucher_asc' || sortBy === 'voucher_desc' // 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' const dateAscending = sortDate === 'asc' || sortBy === 'date_asc' const sortDateParam = sortBy === 'date_asc' || sortDate === 'asc' ? 'asc' : 'desc' // Voucher-sort path: include_related RPC doesn't support voucher ordering, // so fall through to the direct query below. This means voucher sort is // *strict by fiscal_period_id*: cross-period follow-up entries that the // RPC normally surfaces under date sort are excluded under voucher sort. // 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 && !isVoucherSort && !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 (isVoucherSort) { const voucherAscending = sortBy === 'voucher_asc' query = query .order('voucher_series', { ascending: voucherAscending }) .order('voucher_number', { ascending: voucherAscending }) } else if (sortDate === 'asc' || sortDate === 'desc' || sortBy === 'date_asc' || sortBy === 'date_desc') { // Tiebreak same-date vouchers in the SAME direction as the date sort, and // by series before number so the order matches the RPC path (#972). query = query .order('entry_date', { ascending: dateAscending }) .order('voucher_series', { ascending: dateAscending }) .order('voucher_number', { ascending: dateAscending }) } else { query = query .order('voucher_series', { ascending: true }) .order('voucher_number', { 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. query = query.ilike('description', `%${escapeLikePattern(search)}%`) } // 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 }, )