"Sök verifikationstext" only matched journal_entries.description, so typing A209 never returned voucher A209 itself: only other vouchers whose text mentioned it. Users read that as "the voucher is missing". A label-shaped needle (A209, a 209, A-209) now also matches voucher_series + voucher_number via a PostgREST OR (needle double-quoted so commas/parentheses stay literal); other needles keep the plain description ILIKE. parseVoucher accepts one space or hyphen between series and number. The search box shows a spinner while a server-side search is in flight; before, the only signal was the list dimming. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
292 lines
13 KiB
TypeScript
292 lines
13 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'
|
|
|
|
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'
|
|
|
|
const dateAscending = sortDate === 'asc'
|
|
const sortDateParam = soloDateKey ? (soloDateKey.ascending ? 'asc' : 'desc') : sortDate === 'asc' ? 'asc' : 'desc'
|
|
|
|
// 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 },
|
|
)
|