Files
accounted/app/api/v1/companies/[companyId]/journal-entries/route.ts
T
Jakob Wennberg f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

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

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

362 lines
16 KiB
TypeScript

/**
* /api/v1/companies/{companyId}/journal-entries: list + create draft.
*
* GET : cursor-paginated list with filters (fiscal_period_id, status, date range).
* Cursor on (created_at DESC, id ASC).
* POST : create a draft verifikation. Idempotent (mandatory Idempotency-Key).
* Dry-runnable. The draft has no voucher number until you call
* /commit, so a draft that's never committed produces no löpnummer gap
* (BFL 5 kap 6-7 §§).
*
* Strict-mode v1: any engine failure aborts before any state change. The
* `createDraftEntry` engine call is itself atomic (rollbacks the row on
* line-insert failure); the route surface just propagates structured errors.
*/
import { z } from 'zod'
import { created, paginated } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import {
decodeDefaultCursor,
encodeDefaultCursor,
parsePaginationParams,
} from '@/lib/api/v1/pagination'
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode, v1ValidationError } from '@/lib/api/v1/errors'
import { readV1JsonBody } from '@/lib/api/v1/body'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period'
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
import { createDraftEntry, validateBalance } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
const JE_LINE_COLUMNS =
'id, account_number, debit_amount, credit_amount, line_description, currency, amount_in_currency, exchange_rate, tax_code, cost_center, project, sort_order'
const JE_COLUMNS =
'id, fiscal_period_id, voucher_series, voucher_number, entry_date, description, status, source_type, source_id, notes, reverses_id, reversed_by_id, correction_of_id, created_at, updated_at'
const JournalEntryStatus = z.enum(['draft', 'posted', 'cancelled'])
const JournalEntrySummary = z.object({
id: z.string().uuid(),
fiscal_period_id: z.string().uuid(),
voucher_series: z.string(),
voucher_number: z.number().int(),
entry_date: z.string(),
description: z.string(),
status: JournalEntryStatus,
source_type: z.string(),
created_at: z.string(),
})
const JournalEntriesListResponse = listEnvelope(JournalEntrySummary)
const JournalEntryLine = z.object({
id: z.string().uuid(),
account_number: z.string(),
debit_amount: z.number(),
credit_amount: z.number(),
line_description: z.string().nullable(),
currency: z.string().nullable(),
amount_in_currency: z.number().nullable(),
exchange_rate: z.number().nullable(),
tax_code: z.string().nullable(),
cost_center: z.string().nullable(),
project: z.string().nullable(),
})
const JournalEntryDetail = JournalEntrySummary.extend({
notes: z.string().nullable(),
reverses_id: z.string().uuid().nullable(),
reversed_by_id: z.string().uuid().nullable(),
correction_of_id: z.string().uuid().nullable(),
lines: z.array(JournalEntryLine),
})
registerEndpoint({
operation: 'journal-entries.list',
method: 'GET',
path: '/api/v1/companies/:companyId/journal-entries',
summary: 'List journal entries (verifikationer).',
description:
'Cursor-paginated list of journal entries ordered by created_at DESC, id ASC (newest-booked first; the `entry_date` column is the verifikationsdatum and is filterable via ?date_from / ?date_to but is not the sort key). Filters: fiscal_period_id, status, date_from, date_to. Excludes status=cancelled by default; pass status=cancelled to inspect storno-cancelled drafts.',
useWhen:
'You need to walk the verifikationsserie for a period (audit, SIE export, gap detection) or list recent activity for a UI.',
doNotUseFor:
'Reading a single verifikation (use GET /{id}). Reading lines without the header (no separate endpoint: they ride in /{id}).',
pitfalls: [
'Cancelled drafts are hidden by default. They are NOT a löpnummer gap (no voucher_number is allocated for drafts); the filter is for noise reduction.',
'voucher_number=0 indicates a draft that has not been committed. Posted entries always have voucher_number > 0.',
'Ordering is by created_at (when the verifikat was booked), not entry_date. A backdated verifikat appears where it was booked: filter on ?date_from / ?date_to when you need entry_date ranges, and walk the whole cursor chain when you need a full period.',
'Cursor pagination: pass ?cursor=<next_cursor> from the previous response. A stale or tampered cursor is ignored and the first page is returned again.',
],
example: {
response: {
data: [
{
id: '0e9c…',
fiscal_period_id: 'a8f1…',
voucher_series: 'A',
voucher_number: 142,
entry_date: '2026-05-12',
description: 'Levfaktura 2026-1234, Office Depot AB (ankomstnr 42)',
status: 'posted',
source_type: 'supplier_invoice_registered',
created_at: '2026-05-13T15:00:00Z',
},
],
meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null },
},
},
scope: 'reports:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: JournalEntriesListResponse },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'journal-entries.list',
async (request, ctx) => {
const url = new URL(request.url)
const { limit, cursor } = parsePaginationParams(url)
const decoded = decodeDefaultCursor(cursor)
const FiltersSchema = z.object({
fiscal_period_id: z.string().uuid().optional(),
status: JournalEntryStatus.optional(),
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
})
const fr = FiltersSchema.safeParse({
fiscal_period_id: url.searchParams.get('fiscal_period_id') ?? undefined,
status: url.searchParams.get('status') ?? undefined,
date_from: url.searchParams.get('date_from') ?? undefined,
date_to: url.searchParams.get('date_to') ?? undefined,
})
if (!fr.success) return v1ValidationError(ctx, fr.error)
const filters = fr.data
// Sort by (created_at DESC, id ASC). created_at is the stable cursor
// anchor: it's a real timestamp (passes ISO-8601 validation in
// decodeDefaultCursor), NOT NULL, and total-orderable once id breaks
// ties. Sorting by `entry_date` directly broke the cursor: a Postgres
// `date` serializes as YYYY-MM-DD, the decoder rejected it, the keyset
// predicate was never applied, and an integrator syncing verifikat
// looped on the newest page forever. entry_date is still on every row
// and ?date_from / ?date_to filter on it. Same anchor as the
// transactions list.
let query = ctx.supabase
.from('journal_entries')
.select(JE_COLUMNS)
.eq('company_id', ctx.companyId!)
.order('created_at', { ascending: false })
.order('id', { ascending: true })
.limit(limit + 1)
if (filters.fiscal_period_id) query = query.eq('fiscal_period_id', filters.fiscal_period_id)
if (filters.status) {
query = query.eq('status', filters.status)
} else {
query = query.neq('status', 'cancelled')
}
if (filters.date_from) query = query.gte('entry_date', filters.date_from)
if (filters.date_to) query = query.lte('entry_date', filters.date_to)
if (decoded) {
// Keyset on (created_at DESC, id ASC): created_at moves backward,
// id breaks ties within the same timestamp.
query = query.or(
`created_at.lt.${decoded.ts},and(created_at.eq.${decoded.ts},id.gt.${decoded.id})`,
)
}
const { data, error } = await query
if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
type Row = {
id: string
fiscal_period_id: string
voucher_series: string
voucher_number: number
entry_date: string
description: string
status: string
source_type: string
created_at: string
} & Record<string, unknown>
const rows = ((data ?? []) as unknown) as Row[]
const trimmed = rows.slice(0, limit)
const hasMore = rows.length > limit
const last = trimmed[trimmed.length - 1]
const nextCursor = hasMore && last
? encodeDefaultCursor({ id: last.id, created_at: last.created_at })
: null
return paginated(
trimmed.map((r) => ({
id: r.id,
fiscal_period_id: r.fiscal_period_id,
voucher_series: r.voucher_series,
voucher_number: r.voucher_number,
entry_date: r.entry_date,
description: r.description,
status: r.status,
source_type: r.source_type,
created_at: r.created_at,
})),
{ requestId: ctx.requestId, nextCursor: nextCursor ?? undefined },
)
},
)
// ──────────────────────────────────────────────────────────────────
// POST: create draft verifikation
// ──────────────────────────────────────────────────────────────────
registerEndpoint({
operation: 'journal-entries.create-draft',
method: 'POST',
path: '/api/v1/companies/:companyId/journal-entries',
summary: 'Create a draft journal entry (verifikation).',
description:
'Creates a draft journal entry via the engine\'s createDraftEntry(). The draft has no voucher_number until /commit is called. Idempotent (mandatory Idempotency-Key). Dry-runnable: a dry-run validates balance + account-chart membership + period date constraints without inserting any row.',
useWhen:
'You\'re posting an arbitrary verifikation (manual journal entries, accrual reversals, period closing adjustments) outside the invoicing / supplier-invoice / transaction flows.',
doNotUseFor:
'Bookkeeping flows that have a dedicated endpoint (invoices, supplier-invoices, transactions). Editing an existing posted entry: use /correct instead.',
pitfalls: [
'Idempotency-Key is mandatory.',
'Lines must sum to zero (Σ debit = Σ credit). Engine rejects with JOURNAL_ENTRY_NOT_BALANCED on imbalance.',
'entry_date must fall within fiscal_period_id\'s [period_start, period_end]; otherwise ENTRY_DATE_OUTSIDE_FISCAL_PERIOD.',
'All account_numbers must be active in the chart_of_accounts; otherwise ACCOUNTS_NOT_IN_CHART.',
'voucher_series defaults to "A" if omitted. Must be a single uppercase letter.',
'This creates a DRAFT only: call POST /{id}/commit to assign the voucher_number and post atomically.',
],
example: {
request: {
fiscal_period_id: 'a8f1…',
entry_date: '2026-05-12',
description: 'Bankavgift maj 2026',
lines: [
{ account_number: '6570', debit_amount: 50, credit_amount: 0, line_description: 'Bankavgift' },
{ account_number: '1930', debit_amount: 0, credit_amount: 50, line_description: 'Företagskonto' },
],
},
response: {
data: { id: '0e9c…', status: 'draft', voucher_series: 'A', voucher_number: 0 },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'bookkeeping:write',
risk: 'high',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: CreateJournalEntrySchema },
response: { success: dataEnvelope(JournalEntryDetail) },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'journal-entries.create-draft',
async (request, ctx) => {
const rawBodyResult = await readV1JsonBody(request, ctx)
if (!rawBodyResult.ok) return rawBodyResult.response
const rawBody = rawBodyResult.body
const parsed = CreateJournalEntrySchema.safeParse(rawBody)
if (!parsed.success) return v1ValidationError(ctx, parsed.error)
const input = parsed.data
// Ownership pre-check: the caller-supplied fiscal_period_id must belong
// to ctx.companyId. The engine scopes by company_id internally but the
// engine throws a Swedish error string on mismatch; the route returns
// a structured envelope before the engine call.
if (!(await ownsFiscalPeriod(ctx.supabase, ctx.companyId!, input.fiscal_period_id))) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'fiscal_period', field: 'fiscal_period_id' },
})
}
// Balance pre-check: same logic the engine runs, but cheap to fail fast.
const balance = validateBalance(input.lines)
if (!balance.valid) {
return v1ErrorResponseFromCode('JOURNAL_ENTRY_NOT_BALANCED', ctx.log, {
requestId: ctx.requestId,
details: { total_debit: balance.totalDebit, total_credit: balance.totalCredit },
})
}
// Period-lock pre-check: drafts CAN technically be inserted into locked
// periods (no JE-trigger fires until commit), but rejecting up front is
// cleaner UX and avoids leaving an undeletable draft behind.
const lockVerdict = await checkPeriodLock(ctx.supabase, ctx.companyId!, input.entry_date)
if (lockVerdict.locked) {
return v1ErrorResponseFromCode('PERIOD_LOCKED', ctx.log, {
requestId: ctx.requestId,
details: { reason: lockVerdict.reason, fiscal_period_id: lockVerdict.fiscal_period_id },
})
}
if (ctx.dryRun) {
// Dry-run preview: report the balanced lines + would-be header. No row
// is inserted, so the engine's per-line account-id resolution doesn't
// happen: chart-lookup failures will only be reported on live commit.
return dryRunPreview(
{
status: 'draft' as const,
voucher_series: input.voucher_series ?? 'A',
voucher_number: 0,
fiscal_period_id: input.fiscal_period_id,
entry_date: input.entry_date,
description: input.description,
source_type: input.source_type ?? 'manual',
source_id: input.source_id ?? null,
notes: input.notes ?? null,
lines: input.lines.map((l, i) => ({
sort_order: i,
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
line_description: l.line_description ?? null,
currency: l.currency ?? null,
amount_in_currency: l.amount_in_currency ?? null,
exchange_rate: l.exchange_rate ?? null,
tax_code: l.tax_code ?? null,
cost_center: l.cost_center ?? null,
project: l.project ?? null,
})),
totals: { debit: balance.totalDebit, credit: balance.totalCredit },
},
{ requestId: ctx.requestId, log: ctx.log },
)
}
try {
const entry = await createDraftEntry(ctx.supabase, ctx.companyId!, ctx.userId, input)
// Refetch with lines to return the full detail shape.
const { data: complete } = await ctx.supabase
.from('journal_entries')
.select(`${JE_COLUMNS}, lines:journal_entry_lines(${JE_LINE_COLUMNS})`)
.eq('company_id', ctx.companyId!)
.eq('id', entry.id)
.maybeSingle()
return created(complete ?? entry, { requestId: ctx.requestId })
} catch (err) {
if (isBookkeepingError(err)) {
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
}
ctx.log.error('journal-entries.create-draft failed', err as Error)
return v1ErrorResponseFromCode('BOOKKEEPING_DATABASE_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { step: 'create_draft' },
})
}
},
{ requireIdempotencyKey: true },
)