Files
accounted/app/api/bookkeeping/journal-entries/route.ts
T
MattssonandClaude Fable 5 9dbaebcc50 fix(invoices): ROT/RUT credit notes; verifikat amount sort, HTML underlag, source chip (#1523)
* feat(invoice-inbox): store HTML mails as underlag, expandable field editor

Body-only mails and .html attachments (including forwarded .eml bodies) no
longer dead-end as "Fel vid bearbetning": the mail body is wrapped into a
self-contained text/html document, stored through the normal upload/extract
pipeline, and extracted via a new HTML-to-text Bedrock path, so the mail
itself can serve as bookable underlag. Empty mails keep the error row,
unsupported types are still rejected, and webhook retries dedupe on
resend_email_id.

Mail HTML is attacker-controlled, so rendering is fully sandboxed: iframe
sandbox in the workspace preview and a CSP sandbox header on
/api/documents/:id/inline for text/html. The type is accepted only from the
email pipeline (EMAIL_ALLOWED_MIME_TYPES), never from manual upload.

The "Extraherade falt" rail gains an expand button opening a centered
dialog with the same autosaving field editor at a readable size (two
columns), which also gives every failed or skipped extraction a manual
fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): sortable verifikat list headers with amount sort

- clickable sort toggles on the verifikat list headers (asc -> desc -> default)
- total_amount computed column + sort_by total/description on the list route
- failed list loads render an error card with retry, never the empty-ledger state

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(import): decode bank CSV as Windows-1252 fallback in column mapping

The client read the uploaded file with file.text(), which is UTF-8-only,
so Windows-1252 exports (e.g. Handelsbanken) rendered and re-parsed with
U+FFFD in place of Swedish characters. Decode from bytes with the shared
decodeFileContent() helper, matching what the server parse route does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): stackable sort keys on verifikat list headers

- shift-click adds a column as secondary/tertiary sort key (max 3), plain
  click keeps the single-key tri-state cycle
- sort_by accepts a comma-separated priority list; single tokens stay valid
- voucher tiebreak follows the last key's direction (#972 parity)
- priority numbers on stacked headers; hint text in the filter dialog

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): keep ROT/RUT deduction fields positive on credit notes

Crediting an invoice with a ROT/RUT deduction failed 100% of the time:
the credit-note path negated deduction_total (and per-item
deduction_amount) like the other amounts, but both columns carry
CHECK (>= 0), so Postgres rejected the insert and the user only saw
'Kunde inte skapa kreditfaktura'.

Store the deduction fields as positive magnitudes, matching the
convention everywhere else. The stored sign is inert on credit notes:
the reversing verifikat recomputes the ROT/RUT split from the items,
and the PDF and amount-to-pay logic skip deductions on credit notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(transactions): share the source chip across inbox and history modes

Move SourceFilter to transaction-types.ts (widened with 'bank:other' and
'acct:<id>'), render the one toolbar ContextPicker in both view modes,
and drop the narrower duplicate chip inside TransactionHistoryList. The
history list now applies the acct:/bank:other narrowing itself and hides
skattekonto rows under any bank-side selection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(deps): bump js-yaml to 4.3.1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(schema): recognize PostgREST computed columns in the migration parser

The verifikat amount sort orders by total_amount, a PostgREST computed
column (a function on the journal_entries row type, migration
20260811100000). The schema guard only modeled real columns, so
no-phantom-columns flagged the order as a phantom.

Teach the parser that a function whose only argument is a table's row
type joins that table's column set, with DROP FUNCTION retraction when
the signature names the row type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: resolve PR #1523 review findings

- journal-entries route: append the globally unique id tiebreak to every
  direct-query sort; voucher series+number repeat across fiscal years, so
  the all-years scope could duplicate or drop rows at page boundaries.
  Existing order assertions updated, new all-years tiebreak test.
- documents inline route: CSP source policy on HTML previews; sandbox
  alone still loads remote resources, letting a tracking pixel notify the
  sender on open. New route test asserts the full header.
- JournalEntryList: catch rejected list requests so loading cannot stick
  forever, and gate every post-await state write behind a request
  generation so a slow earlier request cannot overwrite the current sort.
- TransactionHistoryList: pagination follows the selected source scope
  (reachable with zero matches on the current page, hidden for the
  skattekonto scope it cannot affect).
- transactions page: bank:other picker availability derives from history
  rows too, not only the pending inbox dataset.
- DECISIONS.md: mark the superseded single-sort decision; record the
  credit-note deduction positive-magnitude invariant and its verified
  reader inventory (Swedish review flag).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: guard metadata refetches behind the list request generation

fetchAttachmentCounts and fetchRattelseFlags write state after their own
awaits; a stale list request's late completion could overwrite attachment
counts and rattelse flags for rows a newer request just rendered, showing
false missing-underlag warnings. Both helpers now take the caller's
generation guard and discard stale completions, including the
attachment-counts loaded flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 23:12:18 +02:00

277 lines
12 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 { 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.
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 },
)