f266c386f3
* 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>
362 lines
16 KiB
TypeScript
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 },
|
|
)
|