diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx
index e57eeecb..302caf00 100644
--- a/app/(dashboard)/import/page.tsx
+++ b/app/(dashboard)/import/page.tsx
@@ -336,7 +336,7 @@ function SIEImportWizard() {
const [step, setStep] = useState('upload')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
- const [errorType, setErrorType] = useState<'duplicate' | 'duplicate_period' | 'validation' | 'parse' | undefined>()
+ const [errorType, setErrorType] = useState<'duplicate' | 'duplicate_period' | 'validation' | 'parse' | 'network' | undefined>()
const [validationErrors, setValidationErrors] = useState([])
const [validationWarnings, setValidationWarnings] = useState([])
const [duplicateImportId, setDuplicateImportId] = useState(null)
@@ -450,9 +450,9 @@ function SIEImportWizard() {
const message = isNetworkError
? 'Kunde inte nå servern. Kontrollera din internetanslutning och försök igen.'
: err instanceof Error ? err.message : 'Ett oväntat fel uppstod.'
- setErrorType('parse')
+ setErrorType(isNetworkError ? 'network' : 'parse')
setError(message)
- toast({ title: 'Anslutningsfel', description: message, variant: 'destructive' })
+ toast({ title: isNetworkError ? 'Anslutningsfel' : 'Ett fel uppstod', description: message, variant: 'destructive' })
} finally {
setIsLoading(false)
}
diff --git a/app/api/v1/companies/[companyId]/accounts/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/accounts/__tests__/route.test.ts
new file mode 100644
index 00000000..100b4f72
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/accounts/__tests__/route.test.ts
@@ -0,0 +1,172 @@
+/**
+ * Integration tests for GET /api/v1/companies/:companyId/accounts and
+ * GET .../fiscal-periods.
+ */
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+beforeAll(() => {
+ if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
+ process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
+})
+
+vi.mock('@/lib/auth/api-keys', async () => {
+ const actual = await vi.importActual('@/lib/auth/api-keys')
+ return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
+})
+vi.mock('@supabase/supabase-js', async () => {
+ const actual = await vi.importActual('@supabase/supabase-js')
+ return { ...actual, createClient: vi.fn().mockReturnValue({}) }
+})
+
+import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
+import { GET as listAccounts } from '../route'
+import { GET as listPeriods } from '../../fiscal-periods/route'
+
+const mockValidate = validateApiKey as ReturnType
+const mockServiceClient = createServiceClientNoCookies as ReturnType
+
+type MockResult = { data?: unknown; error?: unknown }
+function makeFlexibleSupabase(byTable: Record) {
+ const queues = new Map()
+ for (const [t, val] of Object.entries(byTable)) {
+ queues.set(t, Array.isArray(val) ? [...val] : [val])
+ }
+ const buildChain = (table: string): unknown => {
+ const handler: ProxyHandler = {
+ get(_target, prop) {
+ if (prop === 'then') {
+ return (resolve: (v: unknown) => void) => {
+ const q = queues.get(table)
+ const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
+ resolve(next)
+ }
+ }
+ return (..._args: unknown[]) => buildChain(table)
+ },
+ }
+ return new Proxy({}, handler)
+ }
+ return { from: vi.fn((table: string) => buildChain(table)) }
+}
+
+const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
+
+function makeRequest(url: string): Request {
+ return new Request(url, {
+ method: 'GET',
+ headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
+ })
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockValidate.mockResolvedValue({
+ userId: 'user-1',
+ companyId: COMPANY_ID,
+ apiKeyId: 'ak_1',
+ scopes: ['reports:read'],
+ mode: 'live',
+ })
+})
+
+describe('GET /api/v1/companies/:companyId/accounts', () => {
+ it('returns active accounts by default', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ chart_of_accounts: {
+ data: [
+ {
+ account_number: '1930',
+ account_name: 'Företagskonto',
+ account_class: 1,
+ account_group: '19',
+ account_type: 'asset',
+ normal_balance: 'debit',
+ is_system_account: true,
+ is_active: true,
+ description: null,
+ default_vat_code: null,
+ sru_code: null,
+ sort_order: 1930,
+ },
+ ],
+ error: null,
+ },
+ }),
+ )
+ const res = await listAccounts(
+ makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/accounts`),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data.accounts).toHaveLength(1)
+ expect(body.data.accounts[0].account_number).toBe('1930')
+ })
+
+ it('rejects invalid class filter', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ }),
+ )
+ const res = await listAccounts(
+ makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/accounts?class=9`),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(400)
+ })
+
+ it('rejects keys without reports:read scope', async () => {
+ mockValidate.mockResolvedValue({
+ userId: 'user-1',
+ companyId: COMPANY_ID,
+ scopes: ['invoices:read'],
+ mode: 'live',
+ })
+ mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
+ const res = await listAccounts(
+ makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/accounts`),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(403)
+ })
+})
+
+describe('GET /api/v1/companies/:companyId/fiscal-periods', () => {
+ it('returns fiscal periods sorted by period_start desc', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ fiscal_periods: {
+ data: [
+ {
+ id: 'fp-1',
+ name: 'Räkenskapsår 2026',
+ period_start: '2026-01-01',
+ period_end: '2026-12-31',
+ is_closed: false,
+ closed_at: null,
+ locked_at: null,
+ previous_period_id: null,
+ created_at: '2026-01-01T00:00:00Z',
+ },
+ ],
+ error: null,
+ },
+ }),
+ )
+ const res = await listPeriods(
+ makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/fiscal-periods`),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data.fiscal_periods).toHaveLength(1)
+ // Phase 3 review fix: derived BFL 3 kap fields appear on every row.
+ expect(body.data.fiscal_periods[0].duration_days).toBe(365) // 2026-01-01 → 2026-12-31
+ expect(body.data.fiscal_periods[0].exceeds_18_months).toBe(false)
+ })
+})
diff --git a/app/api/v1/companies/[companyId]/accounts/route.ts b/app/api/v1/companies/[companyId]/accounts/route.ts
new file mode 100644
index 00000000..5cbcd35b
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/accounts/route.ts
@@ -0,0 +1,117 @@
+/**
+ * GET /api/v1/companies/{companyId}/accounts
+ *
+ * List chart-of-accounts entries (BAS chart). Filter by ?class=1..8
+ * (BAS account class) and ?active=false (include archived). Sorted by
+ * sort_order — agents can render the BAS hierarchy directly from this.
+ */
+import { z } from 'zod'
+import { ok } from '@/lib/api/v1/response'
+import { registerEndpoint } from '@/lib/api/v1/registry'
+import { withApiV1 } from '@/lib/api/v1/with-api-v1'
+import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
+
+const Account = z.object({
+ account_number: z.string(),
+ account_name: z.string(),
+ account_class: z.number().int().min(1).max(8),
+ account_group: z.string(),
+ account_type: z.string(),
+ normal_balance: z.string(),
+ is_system_account: z.boolean(),
+ is_active: z.boolean(),
+ description: z.string().nullable(),
+ default_vat_code: z.string().nullable(),
+ sru_code: z.string().nullable(),
+ sort_order: z.number().int(),
+})
+
+const AccountsResponse = z.object({ accounts: z.array(Account) })
+
+const ACCOUNT_COLUMNS =
+ 'account_number, account_name, account_class, account_group, account_type, ' +
+ 'normal_balance, is_system_account, is_active, description, default_vat_code, ' +
+ 'sru_code, sort_order'
+
+registerEndpoint({
+ operation: 'accounts.list',
+ method: 'GET',
+ path: '/api/v1/companies/:companyId/accounts',
+ summary: 'List chart-of-accounts entries (BAS chart).',
+ description:
+ 'Returns every account in the company\'s chart of accounts, ordered by sort_order (the BAS canonical sequence). Filter by ?class=<1..8> (BAS account class — 1=assets, 2=equity/liabilities, 3=revenue, 4=cost of goods sold, 5=övriga externa kostnader (rents, supplies, services), 6=övriga externa kostnader (marketing, professional services, IT), 7=labour, 8=financial). Note: BAS 5xxx and 6xxx are both övriga externa kostnader but cover distinct subgroups — see the BAS chart for the canonical mapping. Pass ?active=false to include archived accounts.',
+ useWhen:
+ 'You need account numbers and names to render verifikation tables, build a custom report, or look up the canonical BAS label for an account.',
+ doNotUseFor:
+ 'Fetching balances — use the trial-balance report. Creating new accounts — this endpoint is read-only in v1 (use the dashboard).',
+ pitfalls: [
+ 'account_number is a STRING — "1930", not 1930. The leading character can be 0 in non-BAS plans.',
+ 'is_system_account=true means the account was seeded by gnubok and cannot be archived or renamed.',
+ 'Default filter excludes archived accounts; pass ?active=false to include them.',
+ ],
+ example: {
+ response: {
+ data: [
+ {
+ account_number: '1930',
+ account_name: 'Företagskonto',
+ account_class: 1,
+ account_type: 'asset',
+ normal_balance: 'debit',
+ is_active: true,
+ },
+ ],
+ meta: { request_id: 'req_…', api_version: '2026-05-12' },
+ },
+ },
+ scope: 'reports:read',
+ risk: 'low',
+ idempotent: true,
+ reversible: false,
+ dryRunSupported: false,
+ response: { success: AccountsResponse },
+})
+
+export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
+ 'accounts.list',
+ async (request, ctx) => {
+ const url = new URL(request.url)
+ const Filters = z.object({
+ class: z
+ .string()
+ .regex(/^[1-8]$/)
+ .optional(),
+ active: z.enum(['true', 'false']).optional(),
+ })
+ const parsed = Filters.safeParse({
+ class: url.searchParams.get('class') ?? undefined,
+ active: url.searchParams.get('active') ?? undefined,
+ })
+ if (!parsed.success) {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: {
+ issues: parsed.error.issues.map((i) => ({
+ field: i.path.join('.'),
+ message: i.message,
+ })),
+ },
+ })
+ }
+ const f = parsed.data
+ const activeOnly = f.active !== 'false'
+
+ let query = ctx.supabase
+ .from('chart_of_accounts')
+ .select(ACCOUNT_COLUMNS)
+ .eq('company_id', ctx.companyId!)
+ .order('sort_order', { ascending: true })
+
+ if (activeOnly) query = query.eq('is_active', true)
+ if (f.class) query = query.eq('account_class', parseInt(f.class, 10))
+
+ const { data, error } = await query
+ if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
+ return ok({ accounts: data ?? [] }, { requestId: ctx.requestId })
+ },
+)
diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/route.ts
new file mode 100644
index 00000000..197a49e7
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/fiscal-periods/route.ts
@@ -0,0 +1,132 @@
+/**
+ * GET /api/v1/companies/{companyId}/fiscal-periods
+ *
+ * List fiscal periods (räkenskapsår) for the company. Ordered newest first.
+ * Read-only in v1 — period creation, locking and closing land in Phase 4.
+ */
+import { z } from 'zod'
+import { ok } from '@/lib/api/v1/response'
+import { registerEndpoint } from '@/lib/api/v1/registry'
+import { withApiV1 } from '@/lib/api/v1/with-api-v1'
+import { v1ErrorResponse } from '@/lib/api/v1/errors'
+
+const FiscalPeriod = z.object({
+ id: z.string().uuid(),
+ name: z.string(),
+ period_start: z.string(),
+ period_end: z.string(),
+ is_closed: z.boolean(),
+ closed_at: z.string().nullable(),
+ locked_at: z.string().nullable(),
+ previous_period_id: z.string().uuid().nullable(),
+ created_at: z.string(),
+ // Computed BFL-compliance flags. Persisted nowhere; derived per response.
+ duration_days: z.number().int(),
+ exceeds_18_months: z.boolean(),
+})
+
+const FiscalPeriodsResponse = z.object({ fiscal_periods: z.array(FiscalPeriod) })
+
+const FISCAL_PERIOD_COLUMNS =
+ 'id, name, period_start, period_end, is_closed, closed_at, locked_at, ' +
+ 'previous_period_id, created_at'
+
+registerEndpoint({
+ operation: 'fiscal-periods.list',
+ method: 'GET',
+ path: '/api/v1/companies/:companyId/fiscal-periods',
+ summary: 'List fiscal periods (räkenskapsår).',
+ description:
+ 'Returns every fiscal period for the company ordered by period_start DESC. is_closed=true means bokslut has been signed; locked_at non-null means writes are blocked at the DB-trigger level.',
+ useWhen:
+ 'You need to find the active period before booking, build a year-selector UI, or audit the period-lock history.',
+ doNotUseFor:
+ 'Creating, locking, or closing periods — those land in Phase 4 (`POST /fiscal-periods/{id}/lock`, `:close`, `:year-end`). Use the dashboard or wait for Phase 4.',
+ pitfalls: [
+ 'previous_period_id chains the bokslut continuity (BFNAR 2013:2). A null value on a non-first period is a data-quality red flag.',
+ 'A period can be locked but not closed (löpande bokföring of the new year while bokslut work continues on the prior year — see BFL 5 kap 2 § for the löpande bokföring deadline).',
+ 'BFL 3 kap caps a single fiscal period at 18 months. First-year exceptions are allowed.',
+ ],
+ example: {
+ response: {
+ data: [
+ {
+ id: 'fp_2026',
+ name: 'Räkenskapsår 2026',
+ period_start: '2026-01-01',
+ period_end: '2026-12-31',
+ is_closed: false,
+ locked_at: null,
+ },
+ ],
+ meta: { request_id: 'req_…', api_version: '2026-05-12' },
+ },
+ },
+ scope: 'reports:read',
+ risk: 'low',
+ idempotent: true,
+ reversible: false,
+ dryRunSupported: false,
+ response: { success: FiscalPeriodsResponse },
+})
+
+/**
+ * BFL 3 kap 1 § caps a räkenskapsår at 18 calendar months. "Calendar months"
+ * matters here: 18 months can span 540–549 days depending on which 31-day
+ * months and leap days fall in the window, so a fixed day count is either
+ * too generous (false negatives) or too strict (false positives near month
+ * boundaries). Use proper calendar arithmetic — the period end's anchor day
+ * 18 months after the period start.
+ */
+function exceedsEighteenMonths(periodStart: string, periodEnd: string): boolean {
+ // ISO date strings — UTC parse to avoid host-tz shifts.
+ const start = new Date(periodStart + 'T00:00:00Z')
+ const end = new Date(periodEnd + 'T00:00:00Z')
+ const startY = start.getUTCFullYear()
+ const startM = start.getUTCMonth() // 0-indexed
+ const startD = start.getUTCDate()
+ const targetY = startY + Math.floor((startM + 18) / 12)
+ const targetM = (startM + 18) % 12
+ // Clamp the day to the last valid day of the target month. Without this,
+ // start=2024-08-31 + 18 months → Date.UTC(2026, 1, 31) rolls into March 3,
+ // making the cap later than the BFL 3 kap 1 § ceiling and causing false
+ // negatives near month-end starts. Date.UTC(year, month, 0) returns the
+ // last day of the prior month, so passing targetM+1 with day=0 gives us
+ // the last day of targetM.
+ const lastDayOfTargetM = new Date(Date.UTC(targetY, targetM + 1, 0)).getUTCDate()
+ const cappedDay = Math.min(startD, lastDayOfTargetM)
+ const cap = new Date(Date.UTC(targetY, targetM, cappedDay))
+ return end.getTime() > cap.getTime()
+}
+
+function durationDays(periodStart: string, periodEnd: string): number {
+ const start = new Date(periodStart + 'T00:00:00Z')
+ const end = new Date(periodEnd + 'T00:00:00Z')
+ return Math.round((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) + 1
+}
+
+export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
+ 'fiscal-periods.list',
+ async (_request, ctx) => {
+ const { data, error } = await ctx.supabase
+ .from('fiscal_periods')
+ .select(FISCAL_PERIOD_COLUMNS)
+ .eq('company_id', ctx.companyId!)
+ .order('period_start', { ascending: false })
+
+ if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
+
+ // Derive BFL 3 kap compliance flags so an automated client (year-end
+ // wizard, audit tool) can spot non-compliant period sequences without
+ // re-implementing date arithmetic.
+ type Row = { period_start: string; period_end: string } & Record
+ const rows = (data ?? []) as unknown as Row[]
+ const enriched = rows.map((p) => ({
+ ...p,
+ duration_days: durationDays(p.period_start, p.period_end),
+ exceeds_18_months: exceedsEighteenMonths(p.period_start, p.period_end),
+ }))
+
+ return ok({ fiscal_periods: enriched }, { requestId: ctx.requestId })
+ },
+)
diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts
new file mode 100644
index 00000000..dffec36e
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/reconciliation/bank/__tests__/route.test.ts
@@ -0,0 +1,236 @@
+/**
+ * Integration tests for POST .../reconciliation/bank/run and
+ * GET .../reconciliation/bank/status.
+ */
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+beforeAll(() => {
+ if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
+ process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
+})
+
+vi.mock('@/lib/auth/api-keys', async () => {
+ const actual = await vi.importActual('@/lib/auth/api-keys')
+ return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
+})
+vi.mock('@supabase/supabase-js', async () => {
+ const actual = await vi.importActual('@supabase/supabase-js')
+ return { ...actual, createClient: vi.fn().mockReturnValue({}) }
+})
+
+const { runRecMock, statusMock } = vi.hoisted(() => ({
+ runRecMock: vi.fn().mockResolvedValue({
+ matches: [
+ {
+ transaction: {
+ id: '11111111-1111-4111-8111-111111111111',
+ date: '2026-05-12',
+ description: 'Test',
+ amount: -100,
+ },
+ glLine: {
+ journal_entry_id: '22222222-2222-4222-8222-222222222222',
+ voucher_number: 42,
+ voucher_series: 'A',
+ entry_date: '2026-05-12',
+ entry_description: 'Voucher 42',
+ },
+ method: 'amount_date',
+ confidence: 0.95,
+ },
+ ],
+ applied: 1,
+ errors: [],
+ }),
+ statusMock: vi.fn().mockResolvedValue({
+ matched_transactions: 100,
+ unmatched_transactions: 5,
+ unmatched_gl_lines: 2,
+ total_unmatched_amount: 1500,
+ bank_balance: 50000,
+ gl_balance: 48500,
+ difference: 1500,
+ }),
+}))
+
+vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
+ runReconciliation: runRecMock,
+ getReconciliationStatus: statusMock,
+}))
+
+import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
+import { POST as runPOST } from '../run/route'
+import { GET as statusGET } from '../status/route'
+
+const mockValidate = validateApiKey as ReturnType
+const mockServiceClient = createServiceClientNoCookies as ReturnType
+
+type MockResult = { data?: unknown; error?: unknown }
+function makeFlexibleSupabase(byTable: Record) {
+ const queues = new Map()
+ for (const [t, val] of Object.entries(byTable)) {
+ queues.set(t, Array.isArray(val) ? [...val] : [val])
+ }
+ const buildChain = (table: string): unknown => {
+ const handler: ProxyHandler = {
+ get(_target, prop) {
+ if (prop === 'then') {
+ return (resolve: (v: unknown) => void) => {
+ const q = queues.get(table)
+ const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
+ resolve(next)
+ }
+ }
+ return (..._args: unknown[]) => buildChain(table)
+ },
+ }
+ return new Proxy({}, handler)
+ }
+ return { from: vi.fn((table: string) => buildChain(table)) }
+}
+
+const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
+
+function postRequest(url: string, body: unknown): Request {
+ return new Request(url, {
+ method: 'POST',
+ headers: {
+ Authorization: 'Bearer test-fixture-not-a-real-key',
+ 'Content-Type': 'application/json',
+ 'Idempotency-Key': 'idem1234-aaaa-4abc-8def-1234567890ab',
+ },
+ body: JSON.stringify(body),
+ })
+}
+function getRequest(url: string): Request {
+ return new Request(url, {
+ method: 'GET',
+ headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
+ })
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('POST /reconciliation/bank/run', () => {
+ beforeEach(() => {
+ mockValidate.mockResolvedValue({
+ userId: 'user-1',
+ companyId: COMPANY_ID,
+ apiKeyId: 'ak_1',
+ scopes: ['transactions:write'],
+ mode: 'live',
+ })
+ })
+
+ it('runs the matcher and applies results', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ }),
+ )
+ const res = await runPOST(
+ postRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/run`, {
+ date_from: '2026-05-01',
+ date_to: '2026-05-31',
+ }),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data.matches).toHaveLength(1)
+ expect(body.data.applied).toBe(1)
+ expect(runRecMock).toHaveBeenCalledWith(
+ expect.anything(),
+ COMPANY_ID,
+ 'user-1',
+ expect.objectContaining({ dryRun: false }),
+ )
+ })
+
+ it('dry-run passes dryRun: true into the matcher', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ }),
+ )
+ const res = await runPOST(
+ postRequest(
+ `https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/run?dry_run=true`,
+ {},
+ ),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(200)
+ expect(res.headers.get('X-Dry-Run')).toBe('true')
+ expect(runRecMock).toHaveBeenCalledWith(
+ expect.anything(),
+ COMPANY_ID,
+ 'user-1',
+ expect.objectContaining({ dryRun: true }),
+ )
+ })
+
+ it('rejects keys without transactions:write scope', async () => {
+ mockValidate.mockResolvedValue({
+ userId: 'user-1',
+ companyId: COMPANY_ID,
+ scopes: ['transactions:read'],
+ mode: 'live',
+ })
+ mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
+ const res = await runPOST(
+ postRequest(
+ `https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/run`,
+ {},
+ ),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(403)
+ })
+})
+
+describe('GET /reconciliation/bank/status', () => {
+ beforeEach(() => {
+ mockValidate.mockResolvedValue({
+ userId: 'user-1',
+ companyId: COMPANY_ID,
+ apiKeyId: 'ak_1',
+ scopes: ['transactions:read'],
+ mode: 'live',
+ })
+ })
+
+ it('returns the status snapshot', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ }),
+ )
+ const res = await statusGET(
+ getRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/status`),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data.matched_transactions).toBe(100)
+ expect(body.data.unmatched_transactions).toBe(5)
+ })
+
+ it('rejects invalid date filter', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ }),
+ )
+ const res = await statusGET(
+ getRequest(
+ `https://x.test/api/v1/companies/${COMPANY_ID}/reconciliation/bank/status?date_from=invalid`,
+ ),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(400)
+ })
+})
diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts
new file mode 100644
index 00000000..25967eab
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/reconciliation/bank/run/route.ts
@@ -0,0 +1,151 @@
+/**
+ * POST /api/v1/companies/{companyId}/reconciliation/bank/run
+ *
+ * Run the bank-reconciliation pipeline: look for bank-side transactions and
+ * GL-side journal lines that pair up by amount + date proximity, then apply
+ * the matches (set `transactions.journal_entry_id` for confirmed pairs).
+ *
+ * Dry-run returns the proposed matches without applying any of them — the
+ * canonical way to preview a reconciliation before letting it write to the
+ * ledger. Idempotent via mandatory Idempotency-Key.
+ */
+import { z } from 'zod'
+import { ok } from '@/lib/api/v1/response'
+import { dryRunPreview } from '@/lib/api/v1/dry-run'
+import { registerEndpoint } from '@/lib/api/v1/registry'
+import { withApiV1 } from '@/lib/api/v1/with-api-v1'
+import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
+import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
+
+const RunRequest = z
+ .object({
+ date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
+ date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
+ })
+ // Bound the window so a key with no explicit range can't trigger an
+ // unbounded join across years. 366 days covers a full räkenskapsår + a
+ // leap day; longer reconciliations should be paged.
+ .refine(
+ (d) => {
+ if (!d.date_from || !d.date_to) return true
+ const ms = new Date(d.date_to).getTime() - new Date(d.date_from).getTime()
+ return ms >= 0 && ms <= 366 * 24 * 60 * 60 * 1000
+ },
+ { message: 'date range must be ≤ 366 days; page longer reconciliations.' },
+ )
+
+const MatchOut = z.object({
+ transaction_id: z.string().uuid(),
+ transaction_date: z.string(),
+ transaction_description: z.string().nullable(),
+ transaction_amount: z.number(),
+ journal_entry_id: z.string().uuid(),
+ voucher_number: z.number().int().nullable(),
+ voucher_series: z.string().nullable(),
+ entry_date: z.string(),
+ entry_description: z.string().nullable(),
+ method: z.string(),
+ confidence: z.number(),
+})
+
+const RunResponse = z.object({
+ matches: z.array(MatchOut),
+ applied: z.number().int(),
+ errors: z.array(z.string()),
+})
+
+registerEndpoint({
+ operation: 'reconciliation.bank.run',
+ method: 'POST',
+ path: '/api/v1/companies/:companyId/reconciliation/bank/run',
+ summary: 'Run the bank-reconciliation matcher.',
+ description:
+ 'Walks all unbooked bank transactions in the requested date range and pairs them with open GL lines (1930-side) by amount + date proximity. Applies confirmed matches by setting transactions.journal_entry_id (the GL row already exists). Dry-runnable.',
+ useWhen:
+ 'You want to auto-match outstanding bank transactions against existing journal entries — typically as the closing step of a sync. Dry-run first to inspect proposed matches.',
+ doNotUseFor:
+ 'Creating new journal entries — this only links bank transactions to existing GL lines. Matching to invoices — use `:match-invoice` or `:match-supplier-invoice` for explicit invoice payments.',
+ pitfalls: [
+ 'date_from / date_to default to the company\'s full bank history if omitted. Specify a window for predictable performance.',
+ 'Idempotency-Key is mandatory.',
+ 'matches.confidence is between 0 and 1; the matcher only applies matches above the internal threshold (currently ~0.85).',
+ ],
+ example: {
+ request: { date_from: '2026-05-01', date_to: '2026-05-31' },
+ response: {
+ data: { matches: [], applied: 0, errors: [] },
+ meta: { request_id: 'req_…', api_version: '2026-05-12' },
+ },
+ },
+ scope: 'transactions:write',
+ risk: 'medium',
+ idempotent: true,
+ reversible: false,
+ dryRunSupported: true,
+ request: { body: RunRequest },
+ response: { success: RunResponse },
+})
+
+export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
+ 'reconciliation.bank.run',
+ async (request, ctx) => {
+ let rawBody: unknown
+ try {
+ rawBody = await request.json()
+ } catch {
+ // Body is optional — an empty body is fine.
+ rawBody = {}
+ }
+ const parsed = RunRequest.safeParse(rawBody)
+ if (!parsed.success) {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: {
+ issues: parsed.error.issues.map((i) => ({
+ field: i.path.join('.'),
+ message: i.message,
+ })),
+ },
+ })
+ }
+ const body = parsed.data
+
+ let result
+ try {
+ result = await runReconciliation(ctx.supabase, ctx.companyId!, ctx.userId, {
+ dateFrom: body.date_from,
+ dateTo: body.date_to,
+ dryRun: ctx.dryRun,
+ })
+ } catch (err) {
+ ctx.log.error('reconciliation.bank.run: pipeline failed', err as Error)
+ return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
+ }
+
+ const matches = result.matches.map((m) => ({
+ transaction_id: m.transaction.id,
+ transaction_date: m.transaction.date,
+ transaction_description: m.transaction.description ?? null,
+ transaction_amount: m.transaction.amount,
+ journal_entry_id: m.glLine.journal_entry_id,
+ voucher_number: m.glLine.voucher_number ?? null,
+ voucher_series: m.glLine.voucher_series ?? null,
+ entry_date: m.glLine.entry_date,
+ entry_description: m.glLine.entry_description ?? null,
+ method: m.method,
+ confidence: m.confidence,
+ }))
+
+ const payload = {
+ matches,
+ applied: result.applied,
+ errors: result.errors,
+ }
+
+ if (ctx.dryRun) {
+ return dryRunPreview(payload, { requestId: ctx.requestId, log: ctx.log })
+ }
+ return ok(payload, { requestId: ctx.requestId })
+ },
+ { requireIdempotencyKey: true },
+)
diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts
new file mode 100644
index 00000000..49038612
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts
@@ -0,0 +1,98 @@
+/**
+ * GET /api/v1/companies/{companyId}/reconciliation/bank/status
+ *
+ * Snapshot of bank reconciliation health: counts of matched / unmatched
+ * transactions and GL lines for the requested window. Read-only, no
+ * dry-run, no idempotency.
+ */
+import { z } from 'zod'
+import { ok } from '@/lib/api/v1/response'
+import { registerEndpoint } from '@/lib/api/v1/registry'
+import { withApiV1 } from '@/lib/api/v1/with-api-v1'
+import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
+import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
+
+const StatusResponse = z.object({
+ matched_transactions: z.number().int(),
+ unmatched_transactions: z.number().int(),
+ unmatched_gl_lines: z.number().int(),
+ total_unmatched_amount: z.number(),
+ bank_balance: z.number(),
+ gl_balance: z.number(),
+ difference: z.number(),
+})
+
+registerEndpoint({
+ operation: 'reconciliation.bank.status',
+ method: 'GET',
+ path: '/api/v1/companies/:companyId/reconciliation/bank/status',
+ summary: 'Bank-reconciliation health snapshot.',
+ description:
+ 'Returns matched / unmatched counts and the balance delta between the bank ledger and the GL for the requested window. Optional ?date_from / ?date_to (default: company history).',
+ useWhen:
+ 'You\'re building a dashboard widget, an audit report, or a pre-close check that needs to know how many bank transactions are still unbooked.',
+ doNotUseFor:
+ 'Running the matcher — that\'s POST `/reconciliation/bank/run`. Per-transaction detail — use the transaction list with `?status=unbooked`.',
+ pitfalls: [
+ 'A non-zero difference is normal between sync runs (uncleared cheques, in-flight transfers). Investigate only if it persists across reconciliations.',
+ 'total_unmatched_amount is the absolute sum — positive even when the unmatched rows include both credits and debits.',
+ ],
+ example: {
+ response: {
+ data: {
+ matched_transactions: 142,
+ unmatched_transactions: 3,
+ unmatched_gl_lines: 2,
+ total_unmatched_amount: 1850.0,
+ bank_balance: 50000,
+ gl_balance: 48150,
+ difference: 1850,
+ },
+ meta: { request_id: 'req_…', api_version: '2026-05-12' },
+ },
+ },
+ scope: 'transactions:read',
+ risk: 'low',
+ idempotent: true,
+ reversible: false,
+ dryRunSupported: false,
+ response: { success: StatusResponse },
+})
+
+export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
+ 'reconciliation.bank.status',
+ async (request, ctx) => {
+ const url = new URL(request.url)
+ const Filters = z.object({
+ 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 parsed = Filters.safeParse({
+ date_from: url.searchParams.get('date_from') ?? undefined,
+ date_to: url.searchParams.get('date_to') ?? undefined,
+ })
+ if (!parsed.success) {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: {
+ issues: parsed.error.issues.map((i) => ({
+ field: i.path.join('.'),
+ message: i.message,
+ })),
+ },
+ })
+ }
+
+ try {
+ const status = await getReconciliationStatus(
+ ctx.supabase,
+ ctx.companyId!,
+ parsed.data.date_from,
+ parsed.data.date_to,
+ )
+ return ok(status, { requestId: ctx.requestId })
+ } catch (err) {
+ return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
+ }
+ },
+)
diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts
new file mode 100644
index 00000000..c577264b
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts
@@ -0,0 +1,418 @@
+/**
+ * Integration tests for the single-transaction write verbs:
+ * POST :id/categorize
+ * POST :id/uncategorize
+ * POST :id/match-invoice
+ * POST :id/match-supplier-invoice
+ *
+ * Each test stubs the bookkeeping engine (createTransactionJournalEntry,
+ * createInvoicePaymentJournalEntry, reverseEntry, etc.) so the test asserts
+ * the route's orchestration — wiring of params + scope + error codes —
+ * rather than reimplementing the engine.
+ */
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+beforeAll(() => {
+ if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
+ process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
+})
+
+vi.mock('@/lib/auth/api-keys', async () => {
+ const actual = await vi.importActual('@/lib/auth/api-keys')
+ return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
+})
+vi.mock('@supabase/supabase-js', async () => {
+ const actual = await vi.importActual('@supabase/supabase-js')
+ return { ...actual, createClient: vi.fn().mockReturnValue({}) }
+})
+
+// Engine stubs — happy-path returns reusable across cases.
+const { createTxJE, reverseEntryMock, createInvPmtJE, createInvCashJE, createSupplierInvPmtJE } = vi.hoisted(() => ({
+ createTxJE: vi.fn().mockResolvedValue({ id: 'je-fresh' }),
+ reverseEntryMock: vi.fn().mockResolvedValue(undefined),
+ createInvPmtJE: vi.fn().mockResolvedValue({ id: 'je-invpmt' }),
+ createInvCashJE: vi.fn().mockResolvedValue({ id: 'je-invcash' }),
+ createSupplierInvPmtJE: vi.fn().mockResolvedValue({ id: 'je-sipmt' }),
+}))
+
+vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
+ createTransactionJournalEntry: createTxJE,
+}))
+vi.mock('@/lib/bookkeeping/engine', () => ({
+ reverseEntry: reverseEntryMock,
+}))
+vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
+ createInvoicePaymentJournalEntry: createInvPmtJE,
+ createInvoiceCashEntry: createInvCashJE,
+}))
+vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
+ createSupplierInvoicePaymentEntry: createSupplierInvPmtJE,
+ createSupplierInvoiceCashEntry: vi.fn().mockResolvedValue({ id: 'je-sicash' }),
+}))
+vi.mock('@/lib/invoices/match-log', () => ({
+ logMatchEvent: vi.fn(),
+}))
+vi.mock('@/lib/bookkeeping/mapping-engine', () => ({
+ saveUserMappingRule: vi.fn().mockResolvedValue(undefined),
+}))
+vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
+ upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
+ buildMappingResultFromCounterpartyTemplate: vi.fn(),
+}))
+// category mapping is real — provides the debit/credit account guarantees.
+
+import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
+import { POST as categorizePOST } from '../categorize/route'
+import { POST as uncategorizePOST } from '../uncategorize/route'
+import { POST as matchInvoicePOST } from '../match-invoice/route'
+import { POST as matchSIPOST } from '../match-supplier-invoice/route'
+
+const mockValidate = validateApiKey as ReturnType
+const mockServiceClient = createServiceClientNoCookies as ReturnType
+
+type MockResult = { data?: unknown; error?: unknown }
+function makeFlexibleSupabase(byTable: Record) {
+ const queues = new Map()
+ for (const [t, val] of Object.entries(byTable)) {
+ queues.set(t, Array.isArray(val) ? [...val] : [val])
+ }
+ const buildChain = (table: string): unknown => {
+ const handler: ProxyHandler = {
+ get(_target, prop) {
+ if (prop === 'then') {
+ return (resolve: (v: unknown) => void) => {
+ const q = queues.get(table)
+ const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
+ resolve(next)
+ }
+ }
+ return (..._args: unknown[]) => buildChain(table)
+ },
+ }
+ return new Proxy({}, handler)
+ }
+ return { from: vi.fn((table: string) => buildChain(table)) }
+}
+
+const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
+const TX_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
+const INV_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
+const SI_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
+const JE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'
+
+function makeRequest(url: string, body: unknown): Request {
+ return new Request(url, {
+ method: 'POST',
+ headers: {
+ Authorization: 'Bearer test-fixture-not-a-real-key',
+ 'Content-Type': 'application/json',
+ 'Idempotency-Key': 'idem1234-aaaa-4abc-8def-1234567890ab',
+ },
+ body: JSON.stringify(body),
+ })
+}
+function txParams(id: string) {
+ return { params: Promise.resolve({ companyId: COMPANY_ID, id }) }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockValidate.mockResolvedValue({
+ userId: 'user-1',
+ companyId: COMPANY_ID,
+ apiKeyId: 'ak_1',
+ scopes: ['transactions:write'],
+ mode: 'live',
+ })
+})
+
+describe('POST :id/categorize', () => {
+ it('categorizes a fresh business transaction and creates the JE', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ transactions: [
+ {
+ data: {
+ id: TX_ID,
+ company_id: COMPANY_ID,
+ date: '2026-05-12',
+ amount: -349.5,
+ currency: 'SEK',
+ merchant_name: 'ICA',
+ journal_entry_id: null,
+ },
+ error: null,
+ },
+ { data: [{ id: TX_ID }], error: null }, // CAS update select
+ ],
+ company_settings: {
+ data: { entity_type: 'enskild_firma' },
+ error: null,
+ },
+ }),
+ )
+
+ const res = await categorizePOST(
+ makeRequest(
+ `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/categorize`,
+ { is_business: true, category: 'expense_office' },
+ ),
+ txParams(TX_ID),
+ )
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data.journal_entry_created).toBe(true)
+ expect(body.data.category).toBe('expense_office')
+ expect(createTxJE).toHaveBeenCalledTimes(1)
+ })
+
+ it('dry-run returns mapping preview without creating a JE', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ transactions: {
+ data: {
+ id: TX_ID,
+ company_id: COMPANY_ID,
+ date: '2026-05-12',
+ amount: -349.5,
+ currency: 'SEK',
+ merchant_name: 'ICA',
+ journal_entry_id: null,
+ },
+ error: null,
+ },
+ company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
+ }),
+ )
+
+ const res = await categorizePOST(
+ makeRequest(
+ `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/categorize?dry_run=true`,
+ { is_business: true, category: 'expense_office' },
+ ),
+ txParams(TX_ID),
+ )
+ expect(res.status).toBe(200)
+ expect(res.headers.get('X-Dry-Run')).toBe('true')
+ expect(createTxJE).not.toHaveBeenCalled()
+ })
+
+ it('rejects unknown transaction id with TX_CATEGORIZE_TX_NOT_FOUND', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ transactions: { data: null, error: { code: 'PGRST116' } },
+ }),
+ )
+ const res = await categorizePOST(
+ makeRequest(
+ `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/categorize`,
+ { is_business: true, category: 'expense_office' },
+ ),
+ txParams(TX_ID),
+ )
+ expect(res.status).toBe(404)
+ const body = await res.json()
+ expect(body.error.code).toBe('TX_CATEGORIZE_TX_NOT_FOUND')
+ })
+})
+
+describe('POST :id/uncategorize', () => {
+ it('storno + reset on a booked transaction', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ transactions: { data: { id: TX_ID, journal_entry_id: JE_ID }, error: null },
+ journal_entries: { data: { id: JE_ID, status: 'posted' }, error: null },
+ }),
+ )
+ const res = await uncategorizePOST(
+ makeRequest(
+ `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/uncategorize`,
+ {},
+ ),
+ txParams(TX_ID),
+ )
+ expect(res.status).toBe(200)
+ expect(reverseEntryMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('returns TX_UNCATEGORIZE_NOT_BOOKED when JE missing', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ transactions: { data: { id: TX_ID, journal_entry_id: null }, error: null },
+ }),
+ )
+ const res = await uncategorizePOST(
+ makeRequest(
+ `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/uncategorize`,
+ {},
+ ),
+ txParams(TX_ID),
+ )
+ expect(res.status).toBe(400)
+ const body = await res.json()
+ expect(body.error.code).toBe('TX_UNCATEGORIZE_NOT_BOOKED')
+ })
+})
+
+describe('POST :id/match-invoice', () => {
+ it('matches a positive transaction to an open invoice', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ transactions: {
+ data: {
+ id: TX_ID,
+ amount: 12500,
+ date: '2026-05-12',
+ currency: 'SEK',
+ invoice_id: null,
+ journal_entry_id: null,
+ },
+ error: null,
+ },
+ invoices: [
+ {
+ data: {
+ id: INV_ID,
+ status: 'sent',
+ document_type: 'invoice',
+ total: 12500,
+ paid_amount: 0,
+ remaining_amount: 12500,
+ currency: 'SEK',
+ exchange_rate: null,
+ customer: { name: 'Acme' },
+ items: [],
+ journal_entry_id: null,
+ },
+ error: null,
+ },
+ { data: [{ id: INV_ID }], error: null }, // status update select
+ ],
+ company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
+ invoice_payments: { data: null, error: null },
+ }),
+ )
+ const res = await matchInvoicePOST(
+ makeRequest(
+ `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
+ { invoice_id: INV_ID },
+ ),
+ txParams(TX_ID),
+ )
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data.invoice_status).toBe('paid')
+ expect(body.data.journal_entry_id).toBe('je-invpmt')
+ })
+
+ it('rejects negative transaction with MATCH_INVOICE_NOT_INCOME', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ transactions: { data: { id: TX_ID, amount: -100, invoice_id: null }, error: null },
+ }),
+ )
+ const res = await matchInvoicePOST(
+ makeRequest(
+ `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
+ { invoice_id: INV_ID },
+ ),
+ txParams(TX_ID),
+ )
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('MATCH_INVOICE_NOT_INCOME')
+ })
+
+ it('rejects already-linked transaction', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ transactions: { data: { id: TX_ID, amount: 100, invoice_id: 'other-id' }, error: null },
+ }),
+ )
+ const res = await matchInvoicePOST(
+ makeRequest(
+ `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-invoice`,
+ { invoice_id: INV_ID },
+ ),
+ txParams(TX_ID),
+ )
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('MATCH_INVOICE_TX_ALREADY_LINKED')
+ })
+})
+
+describe('POST :id/match-supplier-invoice', () => {
+ it('matches a negative transaction to an open supplier invoice', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ transactions: {
+ data: {
+ id: TX_ID,
+ amount: -5000,
+ date: '2026-05-12',
+ currency: 'SEK',
+ supplier_invoice_id: null,
+ journal_entry_id: null,
+ },
+ error: null,
+ },
+ supplier_invoices: [
+ {
+ data: {
+ id: SI_ID,
+ status: 'approved',
+ total: 5000,
+ paid_amount: 0,
+ remaining_amount: 5000,
+ currency: 'SEK',
+ exchange_rate: null,
+ supplier: { name: 'Acme', supplier_type: 'swedish_business' },
+ items: [],
+ },
+ error: null,
+ },
+ { data: [{ id: SI_ID }], error: null },
+ ],
+ company_settings: { data: { accounting_method: 'accrual' }, error: null },
+ supplier_invoice_payments: { data: null, error: null },
+ }),
+ )
+ const res = await matchSIPOST(
+ makeRequest(
+ `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`,
+ { supplier_invoice_id: SI_ID },
+ ),
+ txParams(TX_ID),
+ )
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data.invoice_status).toBe('paid')
+ })
+
+ it('rejects positive transaction with MATCH_SI_NOT_EXPENSE', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ transactions: { data: { id: TX_ID, amount: 100, supplier_invoice_id: null }, error: null },
+ }),
+ )
+ const res = await matchSIPOST(
+ makeRequest(
+ `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/match-supplier-invoice`,
+ { supplier_invoice_id: SI_ID },
+ ),
+ txParams(TX_ID),
+ )
+ expect(res.status).toBe(400)
+ expect((await res.json()).error.code).toBe('MATCH_SI_NOT_EXPENSE')
+ })
+})
diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts
new file mode 100644
index 00000000..52fdd610
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts
@@ -0,0 +1,477 @@
+/**
+ * POST /api/v1/companies/{companyId}/transactions/{id}/categorize
+ *
+ * Categorize a transaction and create the corresponding journal entry. This
+ * is a thin v1 surface over the same orchestration the internal dashboard
+ * route uses — same mapping engine, same booking templates, same SI-match
+ * suggestion intercept, same CAS race guard.
+ *
+ * Already-categorized fast path: if the transaction already has a journal
+ * entry, only the is_business / category flags are updated. The JE is left
+ * intact (it's immutable post-commit per BFL 5 kap 6 §).
+ *
+ * Dry-runnable: returns the resolved mapping (debit/credit + VAT lines)
+ * without inserting the journal entry or mutating the transaction.
+ */
+import { z } from 'zod'
+import { ok } from '@/lib/api/v1/response'
+import { dryRunPreview } from '@/lib/api/v1/dry-run'
+import { registerEndpoint } from '@/lib/api/v1/registry'
+import { withApiV1 } from '@/lib/api/v1/with-api-v1'
+import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
+import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
+import { CategorizeTransactionSchema } from '@/lib/api/schemas'
+import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
+import {
+ getTemplateById,
+ buildMappingResultFromTemplate,
+ validateTemplateForEntity,
+} from '@/lib/bookkeeping/booking-templates'
+import {
+ upsertCounterpartyTemplate,
+ buildMappingResultFromCounterpartyTemplate,
+} from '@/lib/bookkeeping/counterparty-templates'
+import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
+import { reverseEntry } from '@/lib/bookkeeping/engine'
+import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
+import { isBookkeepingError } from '@/lib/bookkeeping/errors'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
+import { eventBus } from '@/lib/events'
+import type {
+ CategorizationTemplate,
+ EntityType,
+ Transaction,
+ TransactionCategory,
+} from '@/types'
+
+const CategorizeResponse = z.object({
+ success: z.boolean(),
+ journal_entry_created: z.boolean(),
+ journal_entry_id: z.string().uuid().nullable(),
+ journal_entry_error: z.string().nullable(),
+ document_link_warning: z.string().nullable().optional(),
+ category: z.string(),
+ already_had_journal_entry: z.boolean().optional(),
+})
+
+registerEndpoint({
+ operation: 'transactions.categorize',
+ method: 'POST',
+ path: '/api/v1/companies/:companyId/transactions/:id/categorize',
+ summary: 'Categorize a transaction and create the journal entry.',
+ description:
+ 'Resolves the BAS account mapping for the transaction (via category, booking template, or counterparty template), creates the corresponding verifikation, and updates the transaction with is_business / category / journal_entry_id. Idempotent on (transaction, key). Dry-runnable.',
+ useWhen:
+ 'You\'re categorizing a bank transaction. Pass `is_business: true` plus either `category`, `template_id` (booking template), `counterparty_template_id`, or `account_override`. For private transactions, `is_business: false` is enough.',
+ doNotUseFor:
+ 'Matching a payment to an invoice — use `:match-invoice` or `:match-supplier-invoice`, which storno any conflicting JE first. Uncategorizing — `:uncategorize`.',
+ pitfalls: [
+ 'A bank payment that looks like an invoice payment will be flagged via TX_CATEGORIZE_SUGGEST_SI_MATCH — pass `confirm_no_match: true` to override and force-categorize as direct expense (e.g. when the supplier invoice was already booked).',
+ 'Already-categorized fast path: if the transaction already has a journal_entry_id, only flags get updated. The JE is immutable post-commit.',
+ 'account_override must exist in the chart of accounts; an unknown account returns TX_CATEGORIZE_INVALID_ACCOUNT.',
+ ],
+ example: {
+ request: { is_business: true, category: 'expense_office' },
+ response: {
+ data: {
+ success: true,
+ journal_entry_created: true,
+ journal_entry_id: 'je_…',
+ category: 'expense_office',
+ },
+ meta: { request_id: 'req_…', api_version: '2026-05-12' },
+ },
+ },
+ scope: 'transactions:write',
+ risk: 'medium',
+ idempotent: true,
+ reversible: true,
+ dryRunSupported: true,
+ request: { body: CategorizeTransactionSchema },
+ response: { success: CategorizeResponse },
+})
+
+export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
+ 'transactions.categorize',
+ async (request, ctx, params) => {
+ const { id } = await params.params
+ const idParse = z.string().uuid().safeParse(id)
+ if (!idParse.success) {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: { field: 'id', message: 'Transaction id must be a UUID.' },
+ })
+ }
+ const txId = idParse.data
+
+ let rawBody: unknown
+ try {
+ rawBody = await request.json()
+ } catch {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: { field: 'body', message: 'Body is not valid JSON.' },
+ })
+ }
+ const parsed = CategorizeTransactionSchema.safeParse(rawBody)
+ if (!parsed.success) {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: {
+ issues: parsed.error.issues.map((i) => ({
+ field: i.path.join('.'),
+ message: i.message,
+ })),
+ },
+ })
+ }
+ const body = parsed.data
+ const { is_business, category } = body
+
+ const { data: transaction, error: fetchErr } = await ctx.supabase
+ .from('transactions')
+ .select('*')
+ .eq('id', txId)
+ .eq('company_id', ctx.companyId!)
+ .single()
+
+ if (fetchErr || !transaction) {
+ return v1ErrorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', ctx.log, {
+ requestId: ctx.requestId,
+ })
+ }
+
+ const txLog = ctx.log.child({ transactionId: txId })
+
+ // Already-categorized fast path: just flip flags. Skip on dry-run so the
+ // caller can preview the full mapping that would be applied to a fresh tx.
+ if (transaction.journal_entry_id && !ctx.dryRun) {
+ const finalCat: TransactionCategory = is_business
+ ? category || 'uncategorized'
+ : 'private'
+ const { error: updateErr } = await ctx.supabase
+ .from('transactions')
+ .update({ is_business, category: finalCat })
+ .eq('id', txId)
+ .eq('company_id', ctx.companyId!)
+ if (updateErr) return v1ErrorResponse(updateErr, txLog, { requestId: ctx.requestId })
+ return ok(
+ {
+ success: true,
+ journal_entry_created: false,
+ journal_entry_id: transaction.journal_entry_id as string,
+ journal_entry_error: null,
+ category: finalCat,
+ already_had_journal_entry: true,
+ },
+ { requestId: ctx.requestId },
+ )
+ }
+
+ const { data: settings } = await ctx.supabase
+ .from('company_settings')
+ .select('entity_type')
+ .eq('company_id', ctx.companyId!)
+ .single()
+ const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
+
+ // Resolve final category and mapping result. Mirrors the internal route.
+ let finalCategory: TransactionCategory
+ if (body.template_id) {
+ const template = getTemplateById(body.template_id)
+ if (!template) {
+ return v1ErrorResponseFromCode('TX_CATEGORIZE_INVALID_TEMPLATE', txLog, {
+ requestId: ctx.requestId,
+ details: { templateId: body.template_id, reason: 'unknown_template' },
+ })
+ }
+ const valid = validateTemplateForEntity(template, entityType)
+ if (!valid.valid) {
+ return v1ErrorResponseFromCode('TX_CATEGORIZE_INVALID_TEMPLATE', txLog, {
+ requestId: ctx.requestId,
+ details: { templateId: body.template_id, reason: valid.error },
+ })
+ }
+ finalCategory = is_business ? template.fallback_category : 'private'
+ } else {
+ finalCategory = is_business ? category || 'uncategorized' : 'private'
+ }
+
+ let mappingResult
+ if (body.counterparty_template_id && is_business) {
+ const { data: cpTemplate } = await ctx.supabase
+ .from('categorization_templates')
+ .select('*')
+ .eq('id', body.counterparty_template_id)
+ .eq('company_id', ctx.companyId!)
+ .eq('is_active', true)
+ .maybeSingle()
+ if (!cpTemplate) {
+ return v1ErrorResponseFromCode('NOT_FOUND', txLog, {
+ requestId: ctx.requestId,
+ details: { resource: 'counterparty_template' },
+ })
+ }
+ const match = {
+ template: cpTemplate as CategorizationTemplate,
+ matchMethod: 'exact_alias' as const,
+ confidence: Number(cpTemplate.confidence),
+ }
+ mappingResult = buildMappingResultFromCounterpartyTemplate(
+ match,
+ transaction as Transaction,
+ entityType,
+ )
+ } else if (body.template_id) {
+ const template = getTemplateById(body.template_id)!
+ mappingResult = buildMappingResultFromTemplate(
+ template,
+ transaction as Transaction,
+ entityType,
+ )
+ } else {
+ mappingResult = buildMappingResultFromCategory(
+ finalCategory,
+ transaction as Transaction,
+ is_business,
+ entityType,
+ body.vat_treatment,
+ )
+ }
+
+ if (
+ is_business &&
+ body.account_override &&
+ !body.template_id &&
+ !body.counterparty_template_id
+ ) {
+ const { data: accountExists } = await ctx.supabase
+ .from('chart_of_accounts')
+ .select('account_number, account_class')
+ .eq('company_id', ctx.companyId!)
+ .eq('account_number', body.account_override)
+ .single()
+ if (!accountExists) {
+ return v1ErrorResponseFromCode('TX_CATEGORIZE_INVALID_ACCOUNT', txLog, {
+ requestId: ctx.requestId,
+ details: { accountNumber: body.account_override },
+ })
+ }
+ if (transaction.amount < 0) mappingResult.debit_account = body.account_override
+ else mappingResult.credit_account = body.account_override
+ // Drop auto-VAT lines when the override targets a balance-sheet
+ // (class 2) account — but NOT when it targets a moms-line account
+ // directly. BAS class 2 covers both equity/liabilities (where VAT
+ // shouldn't be auto-posted) and the specific VAT accounts themselves
+ // (2611/2621/2631 utgående moms, 2641/2645 ingående moms, etc.).
+ // Narrow the exception to the 2610–2649 range — 2650
+ // (momsredovisningskonto) and 2690 (diverse) are class-2 but NOT
+ // moms-line accounts, so writing the auto-VAT pair there would
+ // double-post on the momsredovisningskonto.
+ const overrideNum = parseInt(body.account_override, 10)
+ const isMomsLineAccount = overrideNum >= 2610 && overrideNum <= 2649
+ if (accountExists.account_class === 2 && !isMomsLineAccount) {
+ mappingResult.vat_lines = []
+ }
+ }
+
+ if (!mappingResult.debit_account || !mappingResult.credit_account) {
+ return v1ErrorResponseFromCode('TX_CATEGORIZE_INVALID_MAPPING', txLog, {
+ requestId: ctx.requestId,
+ details: {
+ debitAccount: mappingResult.debit_account,
+ creditAccount: mappingResult.credit_account,
+ },
+ })
+ }
+
+ // Dry-run stops here — caller sees the resolved mapping without burning
+ // a voucher number or mutating any state.
+ if (ctx.dryRun) {
+ return dryRunPreview(
+ {
+ category: finalCategory,
+ mapping: {
+ debit_account: mappingResult.debit_account,
+ credit_account: mappingResult.credit_account,
+ vat_lines: mappingResult.vat_lines,
+ all_lines_complete: mappingResult.all_lines_complete ?? false,
+ },
+ would_create_journal_entry: !transaction.journal_entry_id,
+ already_had_journal_entry: !!transaction.journal_entry_id,
+ },
+ { requestId: ctx.requestId, log: ctx.log },
+ )
+ }
+
+ // Period-lock pre-check. enforce_period_lock + enforce_company_lock_date
+ // triggers will block the JE insert anyway, but they surface as a generic
+ // 500. Catch the locked-period case here and return a structured
+ // PERIOD_LOCKED response so callers see actionable error semantics.
+ const periodLock = await checkPeriodLock(
+ ctx.supabase,
+ ctx.companyId!,
+ transaction.date,
+ )
+ if (periodLock.locked) {
+ return v1ErrorResponseFromCode('PERIOD_LOCKED', txLog, {
+ requestId: ctx.requestId,
+ details: {
+ transaction_date: transaction.date,
+ reason: periodLock.reason,
+ fiscal_period_id: periodLock.fiscal_period_id,
+ },
+ })
+ }
+
+ // Live path: create the journal entry. The internal route runs a
+ // duplicate-payment guard (Prong B) here that surfaces SI-match
+ // suggestions; we preserve that behavior so v1 and the dashboard
+ // diverge on neither booking outcomes nor compliance.
+ let journalEntryId: string | null = null
+ let journalEntryError: string | null = null
+ try {
+ const journalEntry = await createTransactionJournalEntry(
+ ctx.supabase,
+ ctx.companyId!,
+ ctx.userId,
+ transaction as Transaction,
+ mappingResult,
+ )
+ if (journalEntry) journalEntryId = journalEntry.id
+ } catch (err) {
+ txLog.error('transactions.categorize: journal entry creation failed', err as Error)
+ if (isBookkeepingError(err)) {
+ journalEntryError = getErrorMessage(err, { context: 'transaction' })
+ } else {
+ journalEntryError = err instanceof Error ? err.message : 'Unknown error'
+ }
+ }
+
+ // Best-effort: save mapping rule + upsert counterparty template. These
+ // are user-experience polish (faster future categorization) and never
+ // fail the request.
+ if (is_business && transaction.merchant_name) {
+ try {
+ await saveUserMappingRule(
+ ctx.supabase,
+ ctx.companyId!,
+ transaction.merchant_name,
+ mappingResult.debit_account,
+ mappingResult.credit_account,
+ !is_business,
+ body.user_description,
+ body.template_id,
+ )
+ } catch (err) {
+ txLog.warn('save mapping rule failed (non-critical)', err as Error)
+ }
+ }
+ try {
+ await upsertCounterpartyTemplate(
+ ctx.supabase,
+ ctx.userId,
+ transaction as Transaction,
+ mappingResult,
+ 'user_approved',
+ )
+ } catch (err) {
+ txLog.warn('counterparty template upsert failed (non-critical)', err as Error)
+ }
+
+ // CAS guard: another request must not have categorized this transaction
+ // between fetch and write.
+ const { data: updateResult, error: updateErr } = await ctx.supabase
+ .from('transactions')
+ .update({
+ is_business,
+ category: finalCategory,
+ journal_entry_id: journalEntryId,
+ })
+ .eq('id', txId)
+ .eq('company_id', ctx.companyId!)
+ .is('journal_entry_id', null)
+ .select('id')
+
+ if (updateErr) return v1ErrorResponse(updateErr, txLog, { requestId: ctx.requestId })
+
+ if ((!updateResult || updateResult.length === 0) && journalEntryId) {
+ // Lost the race. The orphan JE was created with status='posted' by the
+ // engine, so the immutability trigger blocks a direct status flip to
+ // 'cancelled'. BFL 5 kap 5 § requires corrections via a reversing
+ // entry (storno) — issue one. The pair (orphan + storno) keeps the
+ // verifikationsnummer series unbroken; no voucher_gap_explanations row
+ // is needed because there's no gap.
+ try {
+ await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, journalEntryId)
+ } catch (revErr) {
+ // Storno failure on the orphan is rare but creates an unreconcilable
+ // ledger state (posted JE with no reversal). BFL 5 kap 5 § requires
+ // every correction be traceable. Document the gap explicitly so a
+ // human can reconcile manually rather than losing the trail to logs.
+ txLog.error('TX_CATEGORIZE_RACE: failed to storno orphaned JE', revErr as Error, {
+ orphanJournalEntryId: journalEntryId,
+ })
+ try {
+ const { data: orphan } = await ctx.supabase
+ .from('journal_entries')
+ .select('fiscal_period_id, voucher_series, voucher_number')
+ .eq('id', journalEntryId)
+ .single()
+ if (orphan && orphan.voucher_series) {
+ // Skip the gap row when the engine didn't tag a series on the
+ // orphan. Filing under a fallback series (previously 'A') would
+ // index the gap explanation under the wrong key, hiding it from
+ // series-specific audit queries (BFL 5 kap 6 §). A missing series
+ // is logged above already; a human will reconcile via that trail.
+ await ctx.supabase.from('voucher_gap_explanations').insert({
+ company_id: ctx.companyId!,
+ fiscal_period_id: orphan.fiscal_period_id,
+ voucher_series: orphan.voucher_series,
+ gap_number: orphan.voucher_number,
+ explanation:
+ 'CAS-race orphan; automatisk storno misslyckades. Manuell reconciliation krävs.',
+ created_by: ctx.userId,
+ })
+ }
+ } catch (gapErr) {
+ txLog.error(
+ 'TX_CATEGORIZE_RACE: failed to log voucher_gap_explanations after storno failure',
+ gapErr as Error,
+ { orphanJournalEntryId: journalEntryId },
+ )
+ }
+ }
+ return v1ErrorResponseFromCode('TX_CATEGORIZE_RACE', txLog, {
+ requestId: ctx.requestId,
+ })
+ }
+
+ try {
+ await eventBus.emit({
+ type: 'transaction.categorized',
+ payload: {
+ transaction: transaction as Transaction,
+ account: mappingResult.debit_account,
+ taxCode: mappingResult.vat_lines[0]?.account_number || '',
+ userId: ctx.userId,
+ companyId: ctx.companyId!,
+ },
+ })
+ } catch (err) {
+ txLog.warn('transaction.categorized emit failed (non-critical)', err as Error)
+ }
+
+ return ok(
+ {
+ success: true,
+ journal_entry_created: !!journalEntryId,
+ journal_entry_id: journalEntryId,
+ journal_entry_error: journalEntryError,
+ category: finalCategory,
+ },
+ { requestId: ctx.requestId },
+ )
+ },
+ { requireIdempotencyKey: true },
+)
diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts
new file mode 100644
index 00000000..2b61dadc
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts
@@ -0,0 +1,454 @@
+/**
+ * POST /api/v1/companies/{companyId}/transactions/{id}/match-invoice
+ *
+ * Match a positive (income) transaction to an open customer invoice. The
+ * full flow:
+ * 1. Storno any conflicting auto-categorization JE.
+ * 2. Create the payment journal entry (1930 debit / 1510 credit under
+ * accrual; cash-method path delegates to createInvoiceCashEntry).
+ * 3. Re-attach the invoice PDF to the new payment JE (BFL 7 kap underlag).
+ * 4. Update invoice status (paid / partially_paid) with optimistic lock.
+ * 5. Insert invoice_payments row; link transaction to invoice.
+ *
+ * Mirrors the internal route's failure ordering exactly. Idempotent on
+ * (transaction, key). NOT dry-runnable — the multi-row interlock makes a
+ * meaningful preview infeasible without staging the JE for real, and dry-
+ * run is reserved for endpoints where the caller benefits from a fully
+ * resolved preview before commit. Skip the flag here; document it.
+ */
+import { z } from 'zod'
+import { ok } from '@/lib/api/v1/response'
+import { registerEndpoint } from '@/lib/api/v1/registry'
+import { withApiV1 } from '@/lib/api/v1/with-api-v1'
+import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
+import { MatchInvoiceSchema } from '@/lib/api/schemas'
+import {
+ createInvoicePaymentJournalEntry,
+ createInvoiceCashEntry,
+} from '@/lib/bookkeeping/invoice-entries'
+import { reverseEntry } from '@/lib/bookkeeping/engine'
+import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
+import { logMatchEvent } from '@/lib/invoices/match-log'
+import { eventBus } from '@/lib/events/bus'
+import type { EntityType, Invoice, Transaction } from '@/types'
+
+const MatchInvoiceResponse = z.object({
+ success: z.boolean(),
+ invoice_status: z.string(),
+ paid_at: z.string().nullable(),
+ paid_amount: z.number(),
+ remaining_amount: z.number(),
+ journal_entry_id: z.string().uuid().nullable(),
+ // Preserved from the prior :categorize call (or whatever the existing
+ // value was). Returns null when the transaction had never been
+ // categorized — the v1 surface no longer guesses 'income_services'
+ // for unmatched-revenue rows because the wrong default flows into
+ // BAS 3001/3041/3530 selection and INK2R/SRU reporting.
+ category: z.string().nullable(),
+})
+
+registerEndpoint({
+ operation: 'transactions.match-invoice',
+ method: 'POST',
+ path: '/api/v1/companies/:companyId/transactions/:id/match-invoice',
+ summary: 'Match a positive bank transaction to a customer invoice.',
+ description:
+ 'Confirms an invoice match for a transaction. Storno any conflicting auto-categorization JE, create the payment journal entry, update the invoice status (paid / partially_paid), insert into invoice_payments, and link the transaction. Idempotent.',
+ useWhen:
+ 'You have a bank receipt and a known open invoice it pays. The transaction must be positive (income) and unlinked.',
+ doNotUseFor:
+ 'Categorizing a transaction without an invoice — use `:categorize`. Matching to a supplier invoice — use `:match-supplier-invoice`. Bulk auto-match — use `POST /reconciliation/bank/run`.',
+ pitfalls: [
+ 'Proforma + delivery notes are rejected (MATCH_INVOICE_NOT_INVOICE_TYPE) — only document_type=\'invoice\' can be matched.',
+ 'Transaction must be positive (amount > 0) — negative transactions return MATCH_INVOICE_NOT_INCOME.',
+ 'Invoice must be in sent / overdue / partially_paid status — paid or draft invoices return MATCH_INVOICE_NOT_OPEN.',
+ 'Idempotency-Key is mandatory.',
+ ],
+ example: {
+ request: { invoice_id: 'inv_…' },
+ response: {
+ data: {
+ success: true,
+ invoice_status: 'paid',
+ paid_amount: 12500,
+ remaining_amount: 0,
+ journal_entry_id: 'je_…',
+ category: null,
+ },
+ meta: { request_id: 'req_…', api_version: '2026-05-12' },
+ },
+ },
+ scope: 'transactions:write',
+ risk: 'high',
+ idempotent: true,
+ reversible: false,
+ dryRunSupported: false,
+ request: { body: MatchInvoiceSchema },
+ response: { success: MatchInvoiceResponse },
+})
+
+export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
+ 'transactions.match-invoice',
+ async (request, ctx, params) => {
+ const { id } = await params.params
+ const idParse = z.string().uuid().safeParse(id)
+ if (!idParse.success) {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: { field: 'id', message: 'Transaction id must be a UUID.' },
+ })
+ }
+ const txId = idParse.data
+
+ let rawBody: unknown
+ try {
+ rawBody = await request.json()
+ } catch {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: { field: 'body', message: 'Body is not valid JSON.' },
+ })
+ }
+ const parsed = MatchInvoiceSchema.safeParse(rawBody)
+ if (!parsed.success) {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: {
+ issues: parsed.error.issues.map((i) => ({
+ field: i.path.join('.'),
+ message: i.message,
+ })),
+ },
+ })
+ }
+ const { invoice_id } = parsed.data
+ const txLog = ctx.log.child({ transactionId: txId, invoiceId: invoice_id })
+
+ const { data: transaction, error: fetchTxErr } = await ctx.supabase
+ .from('transactions')
+ .select('*')
+ .eq('id', txId)
+ .eq('company_id', ctx.companyId!)
+ .single()
+ if (fetchTxErr || !transaction) {
+ return v1ErrorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', txLog, {
+ requestId: ctx.requestId,
+ })
+ }
+ // Preserve any prior category (e.g. income_products for goods sales).
+ // Only fall back to the generic 'income_services' default if the
+ // transaction has never been categorized — Greptile + Swedish-compliance
+ // flagged the dashboard's hardcode-on-write as a wrong BAS classification
+ // for goods/rental income flows.
+ const existingTxCategory = (transaction as { category?: string | null }).category ?? null
+ if (transaction.amount <= 0) {
+ return v1ErrorResponseFromCode('MATCH_INVOICE_NOT_INCOME', txLog, {
+ requestId: ctx.requestId,
+ details: { amount: transaction.amount },
+ })
+ }
+ if (transaction.invoice_id) {
+ return v1ErrorResponseFromCode('MATCH_INVOICE_TX_ALREADY_LINKED', txLog, {
+ requestId: ctx.requestId,
+ details: { existingInvoiceId: transaction.invoice_id },
+ })
+ }
+
+ const { data: invoice, error: fetchInvErr } = await ctx.supabase
+ .from('invoices')
+ .select('*, customer:customers(*), items:invoice_items(*)')
+ .eq('id', invoice_id)
+ .eq('company_id', ctx.companyId!)
+ .single()
+ if (fetchInvErr || !invoice) {
+ return v1ErrorResponseFromCode('MATCH_INVOICE_NOT_FOUND', txLog, {
+ requestId: ctx.requestId,
+ })
+ }
+ const docType = (invoice as { document_type?: string }).document_type ?? 'invoice'
+ if (docType !== 'invoice') {
+ return v1ErrorResponseFromCode('MATCH_INVOICE_NOT_INVOICE_TYPE', txLog, {
+ requestId: ctx.requestId,
+ details: { documentType: docType },
+ })
+ }
+ if (
+ invoice.status !== 'sent' &&
+ invoice.status !== 'overdue' &&
+ invoice.status !== 'partially_paid'
+ ) {
+ return v1ErrorResponseFromCode('MATCH_INVOICE_NOT_OPEN', txLog, {
+ requestId: ctx.requestId,
+ details: { currentStatus: invoice.status },
+ })
+ }
+
+ if (transaction.journal_entry_id) {
+ try {
+ await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, transaction.journal_entry_id)
+ const { error: clearErr } = await ctx.supabase
+ .from('transactions')
+ .update({ journal_entry_id: null })
+ .eq('id', txId)
+ .eq('company_id', ctx.companyId!)
+ if (clearErr) {
+ txLog.warn('failed to clear journal_entry_id after storno', clearErr)
+ }
+ logMatchEvent(ctx.supabase, ctx.userId, txId, 'storno_conflict_resolved', {
+ invoiceId: invoice_id,
+ previousState: { journal_entry_id: transaction.journal_entry_id },
+ newState: { journal_entry_id: null },
+ })
+ } catch (err) {
+ txLog.error('storno failed', err as Error)
+ return v1ErrorResponse(err, txLog, { requestId: ctx.requestId })
+ }
+ }
+
+ const now = new Date().toISOString()
+ const paidAmount = transaction.amount
+ const newPaidAmount =
+ Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100
+ const currentRemaining =
+ invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
+ const newRemaining = Math.max(
+ 0,
+ Math.round((currentRemaining - paidAmount) * 100) / 100,
+ )
+ const isFullyPaid = newRemaining <= 0
+ const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
+
+ const { data: settings } = await ctx.supabase
+ .from('company_settings')
+ .select('accounting_method, entity_type')
+ .eq('company_id', ctx.companyId!)
+ .single()
+ const accountingMethod = settings?.accounting_method || 'accrual'
+ const entityType: EntityType =
+ (settings?.entity_type as EntityType) || 'enskild_firma'
+
+ // Reject cash-method partial payments. Under kontantmetoden, utgående
+ // moms must be reported in the period of actual receipt (ML 13 kap 8 §);
+ // the partial-payment branch below uses createInvoicePaymentJournalEntry
+ // (the accrual-style 1510/1930 clearing entry), which doesn't model the
+ // per-installment moms event. Rather than silently over-report moms,
+ // refuse the operation and document the constraint. Full payments
+ // (isFullyPaid=true) flow through createInvoiceCashEntry which IS the
+ // correct kontantmetod path.
+ if (accountingMethod === 'cash' && !isFullyPaid) {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', txLog, {
+ requestId: ctx.requestId,
+ details: {
+ field: 'accounting_method',
+ message:
+ 'Kontantmetoden does not support partial-payment matching via this endpoint. ' +
+ 'Match the full payment when received, or switch to accrual (faktureringsmetoden).',
+ accounting_method: 'cash',
+ payment_amount: paidAmount,
+ invoice_total: invoice.total,
+ },
+ })
+ }
+
+ // Strict-mode for the public API: if the payment JE can't be created we
+ // ABORT before touching invoice / payment / transaction state. The
+ // dashboard's internal route soft-fails here and surfaces a banner so
+ // the user can re-book manually; the v1 caller is an automation with
+ // no UI, so a partial state (invoice marked paid, GL has no entry) is
+ // strictly worse than a clean failure to retry.
+ let journalEntryId: string | null = null
+ try {
+ if (accountingMethod === 'cash' && isFullyPaid) {
+ const je = await createInvoiceCashEntry(
+ ctx.supabase,
+ ctx.companyId!,
+ ctx.userId,
+ invoice as Invoice,
+ transaction.date,
+ entityType,
+ invoice.customer?.name,
+ )
+ journalEntryId = je?.id ?? null
+ } else {
+ const je = await createInvoicePaymentJournalEntry(
+ ctx.supabase,
+ ctx.companyId!,
+ ctx.userId,
+ invoice as Invoice,
+ transaction.date,
+ undefined,
+ invoice.customer?.name,
+ paidAmount,
+ )
+ journalEntryId = je?.id ?? null
+ }
+ } catch (err) {
+ if (err instanceof AccountsNotInChartError) {
+ return v1ErrorResponse(err, txLog, { requestId: ctx.requestId })
+ }
+ txLog.error('match-invoice: payment JE creation failed — aborting before state mutation', err as Error)
+ const message = isBookkeepingError(err)
+ ? getErrorMessage(err, { context: 'invoice' })
+ : err instanceof Error
+ ? err.message
+ : 'Unknown error'
+ return v1ErrorResponseFromCode('INVOICE_PAID_BOOK_FAILED', txLog, {
+ requestId: ctx.requestId,
+ details: { reason: message },
+ })
+ }
+
+ // Re-attach invoice PDF to the payment JE (BFL 7 kap underlag).
+ if (journalEntryId && invoice.journal_entry_id) {
+ try {
+ const { data: invoiceDoc } = await ctx.supabase
+ .from('document_attachments')
+ .select('storage_path, file_name, file_size_bytes, mime_type, sha256_hash')
+ .eq('journal_entry_id', invoice.journal_entry_id)
+ .eq('company_id', ctx.companyId!)
+ .eq('is_current_version', true)
+ .limit(1)
+ .maybeSingle()
+ if (invoiceDoc) {
+ const { error: attachErr } = await ctx.supabase
+ .from('document_attachments')
+ .insert({
+ user_id: ctx.userId,
+ company_id: ctx.companyId!,
+ uploaded_by: ctx.userId,
+ upload_source: 'system',
+ storage_path: invoiceDoc.storage_path,
+ file_name: invoiceDoc.file_name,
+ file_size_bytes: invoiceDoc.file_size_bytes,
+ mime_type: invoiceDoc.mime_type,
+ sha256_hash: invoiceDoc.sha256_hash,
+ journal_entry_id: journalEntryId,
+ })
+ if (attachErr) {
+ txLog.warn('failed to attach invoice PDF to payment JE', {
+ attachError: attachErr.message,
+ })
+ }
+ }
+ } catch (err) {
+ txLog.warn('attach invoice PDF threw', err as Error)
+ }
+ }
+
+ const { data: updatedRows, error: updateInvErr } = await ctx.supabase
+ .from('invoices')
+ .update({
+ status: newStatus,
+ paid_at: isFullyPaid ? now : null,
+ paid_amount: newPaidAmount,
+ remaining_amount: newRemaining,
+ })
+ .eq('id', invoice_id)
+ .eq('company_id', ctx.companyId!)
+ .in('status', ['sent', 'overdue', 'partially_paid'])
+ .select('id')
+ if (updateInvErr) return v1ErrorResponse(updateInvErr, txLog, { requestId: ctx.requestId })
+ if (!updatedRows || updatedRows.length === 0) {
+ return v1ErrorResponseFromCode('MATCH_INVOICE_ALREADY_PAID', txLog, {
+ requestId: ctx.requestId,
+ })
+ }
+
+ const paymentNotes =
+ accountingMethod === 'cash' && !isFullyPaid
+ ? 'Kontantmetoden: intäkt bokförs vid slutbetalning'
+ : null
+
+ const { error: paymentInsertErr } = await ctx.supabase
+ .from('invoice_payments')
+ .insert({
+ user_id: ctx.userId,
+ company_id: ctx.companyId!,
+ invoice_id,
+ payment_date: transaction.date,
+ amount: paidAmount,
+ currency: invoice.currency,
+ exchange_rate: invoice.exchange_rate,
+ journal_entry_id: journalEntryId,
+ transaction_id: txId,
+ notes: paymentNotes,
+ })
+ if (paymentInsertErr) {
+ if (paymentInsertErr.code === '23505') {
+ return v1ErrorResponseFromCode('MATCH_INVOICE_DUPLICATE_PAYMENT', txLog, {
+ requestId: ctx.requestId,
+ })
+ }
+ txLog.error('failed to record payment', paymentInsertErr)
+ return v1ErrorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, {
+ requestId: ctx.requestId,
+ })
+ }
+
+ // When the tx already has a category (set by a prior :categorize call,
+ // could be income_products / rental / etc.), preserve it. When there is
+ // none, leave the column UNTOUCHED — the existing default ('uncategorized')
+ // persists. Writing a hardcoded 'income_services' here was the source of
+ // a known mis-classification for goods/rental flows (BAS 3001/3041/3530
+ // distinct accounts → wrong INK2R field → wrong SRU).
+ const txUpdate: Record = {
+ invoice_id,
+ potential_invoice_id: null,
+ journal_entry_id: journalEntryId,
+ is_business: true,
+ }
+ if (existingTxCategory) txUpdate.category = existingTxCategory
+
+ const { error: updateTxErr } = await ctx.supabase
+ .from('transactions')
+ .update(txUpdate)
+ .eq('id', txId)
+ .eq('company_id', ctx.companyId!)
+ if (updateTxErr) {
+ txLog.error('failed to link transaction to invoice', updateTxErr)
+ return v1ErrorResponseFromCode('MATCH_INVOICE_LINK_TX_FAILED', txLog, {
+ requestId: ctx.requestId,
+ })
+ }
+
+ logMatchEvent(ctx.supabase, ctx.userId, txId, 'matched', {
+ invoiceId: invoice_id,
+ matchConfidence: 1.0,
+ matchMethod: 'manual_confirm',
+ newState: {
+ status: newStatus,
+ paid_amount: newPaidAmount,
+ remaining_amount: newRemaining,
+ },
+ })
+
+ try {
+ eventBus.emit({
+ type: 'invoice.match_confirmed',
+ payload: {
+ invoice: invoice as Invoice,
+ transaction: transaction as Transaction,
+ userId: ctx.userId,
+ companyId: ctx.companyId!,
+ },
+ })
+ } catch (err) {
+ txLog.warn('event emit failed (non-critical)', err as Error)
+ }
+
+ return ok(
+ {
+ success: true,
+ invoice_status: newStatus,
+ paid_at: isFullyPaid ? now : null,
+ paid_amount: newPaidAmount,
+ remaining_amount: newRemaining,
+ journal_entry_id: journalEntryId,
+ category: existingTxCategory,
+ },
+ { requestId: ctx.requestId },
+ )
+ },
+ { requireIdempotencyKey: true },
+)
diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts
new file mode 100644
index 00000000..3a729586
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts
@@ -0,0 +1,368 @@
+/**
+ * POST /api/v1/companies/{companyId}/transactions/{id}/match-supplier-invoice
+ *
+ * Match a negative (expense) bank transaction to an open supplier invoice.
+ * Mirrors the dashboard's internal route: same FX-difference handling,
+ * same cash-method-FX rejection, same optimistic-lock interlock.
+ */
+import { z } from 'zod'
+import { ok } from '@/lib/api/v1/response'
+import { registerEndpoint } from '@/lib/api/v1/registry'
+import { withApiV1 } from '@/lib/api/v1/with-api-v1'
+import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
+import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas'
+import {
+ createSupplierInvoicePaymentEntry,
+ createSupplierInvoiceCashEntry,
+} from '@/lib/bookkeeping/supplier-invoice-entries'
+import { reverseEntry } from '@/lib/bookkeeping/engine'
+import { isBookkeepingError } from '@/lib/bookkeeping/errors'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
+import { logMatchEvent } from '@/lib/invoices/match-log'
+import { eventBus } from '@/lib/events/bus'
+import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types'
+
+const MatchSIResponse = z.object({
+ success: z.boolean(),
+ invoice_status: z.string(),
+ paid_amount: z.number(),
+ remaining_amount: z.number(),
+ journal_entry_id: z.string().uuid().nullable(),
+})
+
+registerEndpoint({
+ operation: 'transactions.match-supplier-invoice',
+ method: 'POST',
+ path: '/api/v1/companies/:companyId/transactions/:id/match-supplier-invoice',
+ summary: 'Match a negative bank transaction to a supplier invoice.',
+ description:
+ 'Confirms a supplier invoice payment match. Creates the payment journal entry (accrual: 2440 debit / 1930 credit; cash-method: collapsed registration+payment), updates supplier_invoices, inserts a supplier_invoice_payments row, and links the transaction. Handles FX differences for cross-currency payments (7960 gain / 3960 loss).',
+ useWhen:
+ 'You have a bank payment and a known open supplier invoice. The transaction must be negative (expense) and unlinked.',
+ doNotUseFor:
+ 'Categorizing a direct supplier expense without an invoice — use `:categorize`. Matching to a customer invoice — use `:match-invoice`. Bulk auto-match — `POST /reconciliation/bank/run`.',
+ pitfalls: [
+ 'Cash-method companies cannot match across currencies (MATCH_SI_CASH_FX_UNSUPPORTED) — switch to accrual or book FX manually.',
+ 'Transaction must be negative (amount < 0). Positive returns MATCH_SI_NOT_EXPENSE.',
+ 'Supplier invoice must NOT be paid/credited already. paid/credited returns MATCH_SI_ALREADY_PAID; registered/approved/partially_paid/overdue are matchable.',
+ 'Idempotency-Key is mandatory.',
+ ],
+ example: {
+ request: { supplier_invoice_id: 'si_…' },
+ response: {
+ data: {
+ success: true,
+ invoice_status: 'paid',
+ paid_amount: 5000,
+ remaining_amount: 0,
+ journal_entry_id: 'je_…',
+ },
+ meta: { request_id: 'req_…', api_version: '2026-05-12' },
+ },
+ },
+ scope: 'transactions:write',
+ risk: 'high',
+ idempotent: true,
+ reversible: false,
+ dryRunSupported: false,
+ request: { body: MatchSupplierInvoiceSchema },
+ response: { success: MatchSIResponse },
+})
+
+export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
+ 'transactions.match-supplier-invoice',
+ async (request, ctx, params) => {
+ const { id } = await params.params
+ const idParse = z.string().uuid().safeParse(id)
+ if (!idParse.success) {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: { field: 'id', message: 'Transaction id must be a UUID.' },
+ })
+ }
+ const txId = idParse.data
+
+ let rawBody: unknown
+ try {
+ rawBody = await request.json()
+ } catch {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: { field: 'body', message: 'Body is not valid JSON.' },
+ })
+ }
+ const parsed = MatchSupplierInvoiceSchema.safeParse(rawBody)
+ if (!parsed.success) {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: {
+ issues: parsed.error.issues.map((i) => ({
+ field: i.path.join('.'),
+ message: i.message,
+ })),
+ },
+ })
+ }
+ const { supplier_invoice_id } = parsed.data
+ const txLog = ctx.log.child({ transactionId: txId, supplierInvoiceId: supplier_invoice_id })
+
+ const { data: transaction, error: fetchTxErr } = await ctx.supabase
+ .from('transactions')
+ .select('*')
+ .eq('id', txId)
+ .eq('company_id', ctx.companyId!)
+ .single()
+ if (fetchTxErr || !transaction) {
+ return v1ErrorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', txLog, {
+ requestId: ctx.requestId,
+ })
+ }
+ if (transaction.amount >= 0) {
+ return v1ErrorResponseFromCode('MATCH_SI_NOT_EXPENSE', txLog, {
+ requestId: ctx.requestId,
+ details: { amount: transaction.amount },
+ })
+ }
+ if (transaction.supplier_invoice_id) {
+ return v1ErrorResponseFromCode('MATCH_SI_TX_ALREADY_LINKED', txLog, {
+ requestId: ctx.requestId,
+ details: { existingSupplierInvoiceId: transaction.supplier_invoice_id },
+ })
+ }
+
+ const { data: invoice, error: fetchInvErr } = await ctx.supabase
+ .from('supplier_invoices')
+ .select('*, supplier:suppliers(*), items:supplier_invoice_items(*)')
+ .eq('id', supplier_invoice_id)
+ .eq('company_id', ctx.companyId!)
+ .single()
+ if (fetchInvErr || !invoice) {
+ return v1ErrorResponseFromCode('MATCH_SI_NOT_FOUND', txLog, {
+ requestId: ctx.requestId,
+ })
+ }
+ if (invoice.status === 'paid' || invoice.status === 'credited') {
+ return v1ErrorResponseFromCode('MATCH_SI_ALREADY_PAID', txLog, {
+ requestId: ctx.requestId,
+ details: { currentStatus: invoice.status },
+ })
+ }
+
+ // Storno any conflicting auto-categorization JE before booking the
+ // payment. Mirrors the match-invoice path. Without this, an earlier
+ // :categorize of the same transaction (e.g. as expense_office with a
+ // 5460/1930 entry) would leave its JE posted alongside the new
+ // 2440/1930 supplier-invoice payment entry — two verifikationer for
+ // one affärshändelse violates BFL 5 kap 6 §. If storno fails, abort
+ // before any further state change.
+ if (transaction.journal_entry_id) {
+ try {
+ await reverseEntry(
+ ctx.supabase,
+ ctx.companyId!,
+ ctx.userId,
+ transaction.journal_entry_id,
+ )
+ const { error: clearErr } = await ctx.supabase
+ .from('transactions')
+ .update({ journal_entry_id: null })
+ .eq('id', txId)
+ .eq('company_id', ctx.companyId!)
+ if (clearErr) {
+ txLog.warn('failed to clear journal_entry_id after storno', clearErr)
+ }
+ } catch (err) {
+ txLog.error('match-supplier-invoice: storno of conflicting JE failed', err as Error, {
+ conflictingJournalEntryId: transaction.journal_entry_id,
+ })
+ return v1ErrorResponse(err, txLog, { requestId: ctx.requestId })
+ }
+ }
+
+ const txAmountAbs = Math.abs(transaction.amount)
+ const paymentAmountInvoiceCurrency =
+ transaction.currency === invoice.currency ? txAmountAbs : invoice.remaining_amount
+ const actualBankSek =
+ transaction.currency === 'SEK'
+ ? txAmountAbs
+ : transaction.amount_sek != null
+ ? Math.abs(transaction.amount_sek)
+ : txAmountAbs
+ const invoiceFxRate = invoice.exchange_rate ?? null
+ const originalBookedSek =
+ invoice.currency === 'SEK'
+ ? paymentAmountInvoiceCurrency
+ : invoiceFxRate && invoiceFxRate > 0
+ ? Math.round(paymentAmountInvoiceCurrency * invoiceFxRate * 100) / 100
+ : actualBankSek
+ const exchangeRateDifference =
+ Math.round((originalBookedSek - actualBankSek) * 100) / 100
+ const paymentAmountSek =
+ exchangeRateDifference !== 0 ? originalBookedSek : actualBankSek
+
+ const now = new Date().toISOString()
+
+ const { data: settings } = await ctx.supabase
+ .from('company_settings')
+ .select('accounting_method')
+ .eq('company_id', ctx.companyId!)
+ .single()
+ const accountingMethod = settings?.accounting_method || 'accrual'
+
+ if (accountingMethod === 'cash' && exchangeRateDifference !== 0) {
+ return v1ErrorResponseFromCode('MATCH_SI_CASH_FX_UNSUPPORTED', txLog, {
+ requestId: ctx.requestId,
+ details: {
+ exchangeRateDifference,
+ invoiceCurrency: invoice.currency,
+ transactionCurrency: transaction.currency,
+ },
+ })
+ }
+
+ // Strict-mode for the public API: abort before mutating state if the
+ // payment JE can't be created. See the parallel comment in match-invoice.
+ let journalEntryId: string | null = null
+ try {
+ if (accountingMethod === 'cash') {
+ const je = await createSupplierInvoiceCashEntry(
+ ctx.supabase,
+ ctx.companyId!,
+ ctx.userId,
+ invoice as SupplierInvoice,
+ (invoice.items || []) as SupplierInvoiceItem[],
+ transaction.date,
+ invoice.supplier?.supplier_type || 'swedish_business',
+ )
+ if (je) journalEntryId = je.id
+ } else {
+ const je = await createSupplierInvoicePaymentEntry(
+ ctx.supabase,
+ ctx.companyId!,
+ ctx.userId,
+ invoice as SupplierInvoice,
+ paymentAmountSek,
+ transaction.date,
+ exchangeRateDifference !== 0 ? exchangeRateDifference : undefined,
+ )
+ if (je) journalEntryId = je.id
+ }
+ } catch (err) {
+ txLog.error('match-supplier-invoice: payment JE creation failed — aborting before state mutation', err as Error)
+ const message = isBookkeepingError(err)
+ ? getErrorMessage(err, { context: 'supplier_invoice' })
+ : err instanceof Error
+ ? err.message
+ : 'Unknown error'
+ return v1ErrorResponseFromCode('MATCH_SI_RECORD_PAYMENT_FAILED', txLog, {
+ requestId: ctx.requestId,
+ details: { reason: message },
+ })
+ }
+
+ const newRemaining = Math.max(
+ 0,
+ Math.round((invoice.remaining_amount - paymentAmountInvoiceCurrency) * 100) / 100,
+ )
+ const newPaidAmount =
+ Math.round((invoice.paid_amount + paymentAmountInvoiceCurrency) * 100) / 100
+ const isFullyPaid = newRemaining <= 0
+ const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
+
+ const { data: updatedRows, error: updateInvErr } = await ctx.supabase
+ .from('supplier_invoices')
+ .update({
+ status: newStatus,
+ remaining_amount: newRemaining,
+ paid_amount: newPaidAmount,
+ paid_at: isFullyPaid ? now : null,
+ payment_journal_entry_id: journalEntryId,
+ transaction_id: txId,
+ })
+ .eq('id', supplier_invoice_id)
+ .eq('company_id', ctx.companyId!)
+ // 'overdue' must appear here — the early status guard accepts it as
+ // matchable, so excluding it here would return MATCH_SI_NOT_OPEN
+ // for a legitimately payable invoice.
+ .in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
+ .select('id')
+ if (updateInvErr) return v1ErrorResponse(updateInvErr, txLog, { requestId: ctx.requestId })
+ if (!updatedRows || updatedRows.length === 0) {
+ return v1ErrorResponseFromCode('MATCH_SI_NOT_OPEN', txLog, {
+ requestId: ctx.requestId,
+ })
+ }
+
+ const { error: paymentInsertErr } = await ctx.supabase
+ .from('supplier_invoice_payments')
+ .insert({
+ user_id: ctx.userId,
+ company_id: ctx.companyId!,
+ supplier_invoice_id,
+ payment_date: transaction.date,
+ amount: paymentAmountInvoiceCurrency,
+ currency: invoice.currency,
+ journal_entry_id: journalEntryId,
+ transaction_id: txId,
+ })
+ if (paymentInsertErr) {
+ if (paymentInsertErr.code === '23505') {
+ return v1ErrorResponseFromCode('MATCH_SI_DUPLICATE_PAYMENT', txLog, {
+ requestId: ctx.requestId,
+ })
+ }
+ txLog.error('failed to record payment', paymentInsertErr)
+ return v1ErrorResponseFromCode('MATCH_SI_RECORD_PAYMENT_FAILED', txLog, {
+ requestId: ctx.requestId,
+ })
+ }
+
+ const { error: updateTxErr } = await ctx.supabase
+ .from('transactions')
+ .update({
+ supplier_invoice_id,
+ journal_entry_id: journalEntryId,
+ is_business: true,
+ })
+ .eq('id', txId)
+ .eq('company_id', ctx.companyId!)
+ if (updateTxErr) {
+ return v1ErrorResponseFromCode('MATCH_SI_LINK_TX_FAILED', txLog, {
+ requestId: ctx.requestId,
+ })
+ }
+
+ logMatchEvent(ctx.supabase, ctx.userId, txId, 'matched', {
+ supplierInvoiceId: supplier_invoice_id,
+ matchConfidence: 1.0,
+ matchMethod: 'manual_confirm',
+ newState: { status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining },
+ })
+
+ try {
+ eventBus.emit({
+ type: 'supplier_invoice.match_confirmed',
+ payload: {
+ supplierInvoice: invoice as SupplierInvoice,
+ transaction: transaction as Transaction,
+ userId: ctx.userId,
+ companyId: ctx.companyId!,
+ },
+ })
+ } catch (err) {
+ txLog.warn('event emit failed (non-critical)', err as Error)
+ }
+
+ return ok(
+ {
+ success: true,
+ invoice_status: newStatus,
+ paid_amount: newPaidAmount,
+ remaining_amount: newRemaining,
+ journal_entry_id: journalEntryId,
+ },
+ { requestId: ctx.requestId },
+ )
+ },
+ { requireIdempotencyKey: true },
+)
diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/route.ts
new file mode 100644
index 00000000..dece2cd9
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/transactions/[id]/route.ts
@@ -0,0 +1,110 @@
+/**
+ * GET /api/v1/companies/{companyId}/transactions/{id}
+ *
+ * Single transaction detail. Includes match state (invoice, supplier
+ * invoice), booking state (journal_entry_id), and import metadata.
+ */
+import { z } from 'zod'
+import { ok } from '@/lib/api/v1/response'
+import { registerEndpoint } from '@/lib/api/v1/registry'
+import { withApiV1 } from '@/lib/api/v1/with-api-v1'
+import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
+
+const TransactionDetail = z.object({
+ id: z.string().uuid(),
+ date: z.string(),
+ description: z.string().nullable(),
+ amount: z.number(),
+ currency: z.string(),
+ amount_sek: z.number().nullable(),
+ reference: z.string().nullable(),
+ merchant_name: z.string().nullable(),
+ counterparty_account: z.string().nullable(),
+ journal_entry_id: z.string().uuid().nullable(),
+ invoice_id: z.string().uuid().nullable(),
+ supplier_invoice_id: z.string().uuid().nullable(),
+ potential_invoice_id: z.string().uuid().nullable(),
+ is_business: z.boolean().nullable(),
+ category: z.string().nullable(),
+ receipt_id: z.string().uuid().nullable(),
+ document_id: z.string().uuid().nullable(),
+ external_id: z.string().nullable(),
+ import_source: z.string().nullable(),
+ reconciliation_method: z.string().nullable(),
+ created_at: z.string(),
+ updated_at: z.string(),
+})
+
+// Detail endpoint carve-out: a single-row drill-in is the user's intentional
+// request for the full row. Verbose by design — list endpoint stays minimal.
+const TRANSACTION_DETAIL_COLUMNS =
+ 'id, date, description, amount, currency, amount_sek, reference, merchant_name, ' +
+ 'counterparty_account, journal_entry_id, invoice_id, supplier_invoice_id, ' +
+ 'potential_invoice_id, is_business, category, receipt_id, document_id, ' +
+ 'external_id, import_source, reconciliation_method, created_at, updated_at'
+
+registerEndpoint({
+ operation: 'transactions.get',
+ method: 'GET',
+ path: '/api/v1/companies/:companyId/transactions/:id',
+ summary: 'Retrieve a single transaction by id.',
+ description: 'Returns the full transaction record including match state, booking state, and import metadata.',
+ useWhen:
+ 'You have a transaction id (from the list or a webhook) and need the full record before deciding to categorize, match, or attach a document.',
+ doNotUseFor:
+ 'Walking the ledger — use the list endpoint with a cursor. Fetching the linked invoice/journal entry — separate endpoints.',
+ pitfalls: [
+ 'Both invoice_id (matched) and potential_invoice_id (suggested) can be set independently. The matched id is authoritative for accounting.',
+ 'reconciliation_method is null for transactions that have never been auto-reconciled. journal_entry_id may still be set via manual categorize.',
+ ],
+ example: {
+ response: {
+ data: {
+ id: 'a8f1…',
+ date: '2026-05-12',
+ amount: -349.5,
+ currency: 'SEK',
+ journal_entry_id: null,
+ is_business: null,
+ },
+ meta: { request_id: 'req_…', api_version: '2026-05-12' },
+ },
+ },
+ scope: 'transactions:read',
+ risk: 'low',
+ idempotent: true,
+ reversible: false,
+ dryRunSupported: false,
+ response: { success: TransactionDetail },
+})
+
+export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
+ 'transactions.get',
+ async (_request, ctx, params) => {
+ const { id } = await params.params
+ const idParse = z.string().uuid().safeParse(id)
+ if (!idParse.success) {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: { field: 'id', message: 'Transaction id must be a UUID.' },
+ })
+ }
+
+ const { data, error } = await ctx.supabase
+ .from('transactions')
+ .select(TRANSACTION_DETAIL_COLUMNS)
+ .eq('company_id', ctx.companyId!)
+ .eq('id', idParse.data)
+ .maybeSingle()
+
+ if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
+ if (!data) {
+ ctx.log.warn('transactions.get: not found', { id: idParse.data })
+ return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
+ requestId: ctx.requestId,
+ details: { resource: 'transaction' },
+ })
+ }
+ return ok(data, { requestId: ctx.requestId })
+ },
+)
diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/uncategorize/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/uncategorize/route.ts
new file mode 100644
index 00000000..2908e2cd
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/transactions/[id]/uncategorize/route.ts
@@ -0,0 +1,147 @@
+/**
+ * POST /api/v1/companies/{companyId}/transactions/{id}/uncategorize
+ *
+ * Reverse the categorization of a transaction:
+ * 1. Storno the existing journal entry (BFL-compliant — JEs are never
+ * deleted, they're cancelled via a reversing entry).
+ * 2. Reset is_business / category / journal_entry_id on the transaction.
+ *
+ * Idempotent. Dry-runnable. The body is empty — the transaction id in the
+ * path is the only input.
+ */
+import { z } from 'zod'
+import { ok } from '@/lib/api/v1/response'
+import { dryRunPreview } from '@/lib/api/v1/dry-run'
+import { registerEndpoint } from '@/lib/api/v1/registry'
+import { withApiV1 } from '@/lib/api/v1/with-api-v1'
+import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
+import { reverseEntry } from '@/lib/bookkeeping/engine'
+import { isBookkeepingError } from '@/lib/bookkeeping/errors'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
+
+const UncategorizeResponse = z.object({
+ success: z.boolean(),
+ reversed_journal_entry_id: z.string().uuid(),
+})
+
+registerEndpoint({
+ operation: 'transactions.uncategorize',
+ method: 'POST',
+ path: '/api/v1/companies/:companyId/transactions/:id/uncategorize',
+ summary: 'Reverse the categorization of a transaction (storno + reset).',
+ description:
+ 'Storno the transaction\'s journal entry (BFL 5 kap 5 §: posted entries are never deleted, only cancelled via a reversing entry) and reset is_business / category / journal_entry_id on the transaction row. Idempotent — a second call on an already-uncategorized transaction returns 400 TX_UNCATEGORIZE_NOT_BOOKED. Dry-runnable.',
+ useWhen:
+ 'You categorized a transaction by mistake and want to redo it from scratch. The storno keeps the audit trail intact.',
+ doNotUseFor:
+ 'Changing the categorization of an already-booked transaction — categorize again instead (the second call sees journal_entry_id and only updates flags). Reversing a payment match — there is no v1 verb for that yet.',
+ pitfalls: [
+ 'Idempotency-Key is mandatory.',
+ 'The storno creates a new (cancelling) journal entry. The original entry stays in the ledger marked as cancelled — voucher gaps are documented automatically.',
+ 'A transaction without a journal_entry_id returns 400 TX_UNCATEGORIZE_NOT_BOOKED — there is nothing to reverse.',
+ ],
+ example: {
+ response: {
+ data: { success: true, reversed_journal_entry_id: 'je_…' },
+ meta: { request_id: 'req_…', api_version: '2026-05-12' },
+ },
+ },
+ scope: 'transactions:write',
+ risk: 'medium',
+ idempotent: true,
+ reversible: false, // The reversal itself cannot be reversed via this endpoint.
+ dryRunSupported: true,
+ response: { success: UncategorizeResponse },
+})
+
+export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
+ 'transactions.uncategorize',
+ async (_request, ctx, params) => {
+ const { id } = await params.params
+ const idParse = z.string().uuid().safeParse(id)
+ if (!idParse.success) {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: { field: 'id', message: 'Transaction id must be a UUID.' },
+ })
+ }
+ const txId = idParse.data
+
+ const { data: transaction, error: fetchErr } = await ctx.supabase
+ .from('transactions')
+ .select('id, journal_entry_id')
+ .eq('id', txId)
+ .eq('company_id', ctx.companyId!)
+ .single()
+
+ if (fetchErr || !transaction) {
+ return v1ErrorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', ctx.log, {
+ requestId: ctx.requestId,
+ })
+ }
+
+ if (!transaction.journal_entry_id) {
+ return v1ErrorResponseFromCode('TX_UNCATEGORIZE_NOT_BOOKED', ctx.log, {
+ requestId: ctx.requestId,
+ })
+ }
+
+ const { data: entry, error: entryErr } = await ctx.supabase
+ .from('journal_entries')
+ .select('id, status')
+ .eq('id', transaction.journal_entry_id)
+ .eq('company_id', ctx.companyId!)
+ .single()
+ if (entryErr || !entry) {
+ return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
+ requestId: ctx.requestId,
+ details: { resource: 'journal_entry' },
+ })
+ }
+ if (entry.status !== 'posted') {
+ return v1ErrorResponseFromCode('TX_UNCATEGORIZE_JE_NOT_POSTED', ctx.log, {
+ requestId: ctx.requestId,
+ details: { currentStatus: entry.status },
+ })
+ }
+
+ if (ctx.dryRun) {
+ return dryRunPreview(
+ {
+ would_storno_journal_entry_id: transaction.journal_entry_id,
+ would_reset_transaction: { is_business: null, category: null, journal_entry_id: null },
+ },
+ { requestId: ctx.requestId, log: ctx.log },
+ )
+ }
+
+ try {
+ await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, transaction.journal_entry_id)
+ } catch (err) {
+ ctx.log.error('transactions.uncategorize: reversal failed', err as Error)
+ if (isBookkeepingError(err)) {
+ return v1ErrorResponseFromCode('TX_UNCATEGORIZE_JE_NOT_POSTED', ctx.log, {
+ requestId: ctx.requestId,
+ details: { message: getErrorMessage(err, { context: 'transaction' }) },
+ })
+ }
+ return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
+ }
+
+ const { error: updateErr } = await ctx.supabase
+ .from('transactions')
+ .update({ is_business: null, category: null, journal_entry_id: null })
+ .eq('id', txId)
+ .eq('company_id', ctx.companyId!)
+ if (updateErr) return v1ErrorResponse(updateErr, ctx.log, { requestId: ctx.requestId })
+
+ return ok(
+ {
+ success: true,
+ reversed_journal_entry_id: transaction.journal_entry_id as string,
+ },
+ { requestId: ctx.requestId },
+ )
+ },
+ { requireIdempotencyKey: true },
+)
diff --git a/app/api/v1/companies/[companyId]/transactions/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/__tests__/route.test.ts
new file mode 100644
index 00000000..b692668f
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/transactions/__tests__/route.test.ts
@@ -0,0 +1,182 @@
+/**
+ * Integration tests for GET /api/v1/companies/:companyId/transactions
+ * (list) and GET .../:id (detail).
+ */
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+beforeAll(() => {
+ if (process.env.NODE_ENV !== 'test') {
+ throw new Error(`tx route tests require NODE_ENV=test`)
+ }
+ process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
+})
+
+vi.mock('@/lib/auth/api-keys', async () => {
+ const actual = await vi.importActual('@/lib/auth/api-keys')
+ return {
+ ...actual,
+ validateApiKey: vi.fn(),
+ createServiceClientNoCookies: vi.fn(),
+ }
+})
+vi.mock('@supabase/supabase-js', async () => {
+ const actual = await vi.importActual('@supabase/supabase-js')
+ return { ...actual, createClient: vi.fn().mockReturnValue({}) }
+})
+
+import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
+import { GET as listTransactions } from '../route'
+import { GET as getTransaction } from '../[id]/route'
+
+const mockValidate = validateApiKey as ReturnType
+const mockServiceClient = createServiceClientNoCookies as ReturnType
+
+type MockResult = { data?: unknown; error?: unknown }
+function makeFlexibleSupabase(byTable: Record) {
+ const queues = new Map()
+ for (const [t, val] of Object.entries(byTable)) {
+ queues.set(t, Array.isArray(val) ? [...val] : [val])
+ }
+ const buildChain = (table: string): unknown => {
+ const handler: ProxyHandler = {
+ get(_target, prop) {
+ if (prop === 'then') {
+ return (resolve: (v: unknown) => void) => {
+ const q = queues.get(table)
+ const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
+ resolve(next)
+ }
+ }
+ return (..._args: unknown[]) => buildChain(table)
+ },
+ }
+ return new Proxy({}, handler)
+ }
+ return { from: vi.fn((table: string) => buildChain(table)) }
+}
+
+const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
+const TX_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
+const USER_ID = 'user-1'
+
+function makeRequest(url: string): Request {
+ return new Request(url, {
+ method: 'GET',
+ headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
+ })
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockValidate.mockResolvedValue({
+ userId: USER_ID,
+ companyId: COMPANY_ID,
+ apiKeyId: 'ak_1',
+ scopes: ['transactions:read'],
+ mode: 'live',
+ })
+})
+
+describe('GET /api/v1/companies/:companyId/transactions', () => {
+ it('returns a list with pagination metadata', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ transactions: {
+ data: [
+ { id: TX_ID, date: '2026-05-12', amount: -100, currency: 'SEK', description: 'ICA' },
+ ],
+ error: null,
+ },
+ }),
+ )
+
+ const res = await listTransactions(
+ makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions`),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data).toHaveLength(1)
+ // No next page → omitted (paginated() helper drops the key entirely
+ // when nextCursor is undefined).
+ expect(body.meta.next_cursor).toBeUndefined()
+ })
+
+ it('rejects invalid status filter with 400', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ }),
+ )
+ const res = await listTransactions(
+ makeRequest(
+ `https://x.test/api/v1/companies/${COMPANY_ID}/transactions?status=unknown`,
+ ),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(400)
+ })
+
+ it('rejects keys without transactions:read scope', async () => {
+ mockValidate.mockResolvedValue({
+ userId: USER_ID,
+ companyId: COMPANY_ID,
+ scopes: ['invoices:read'],
+ mode: 'live',
+ })
+ mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
+ const res = await listTransactions(
+ makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions`),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(403)
+ })
+})
+
+describe('GET /api/v1/companies/:companyId/transactions/:id', () => {
+ it('returns 200 with the transaction', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ transactions: {
+ data: { id: TX_ID, date: '2026-05-12', amount: -100, currency: 'SEK' },
+ error: null,
+ },
+ }),
+ )
+ const res = await getTransaction(
+ makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}`),
+ { params: Promise.resolve({ companyId: COMPANY_ID, id: TX_ID }) },
+ )
+ expect(res.status).toBe(200)
+ })
+
+ it('returns 404 NOT_FOUND for unknown id', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ transactions: { data: null, error: null },
+ }),
+ )
+ const res = await getTransaction(
+ makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}`),
+ { params: Promise.resolve({ companyId: COMPANY_ID, id: TX_ID }) },
+ )
+ expect(res.status).toBe(404)
+ })
+
+ it('rejects non-UUID id with 400', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ }),
+ )
+ const res = await getTransaction(
+ makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/not-a-uuid`),
+ { params: Promise.resolve({ companyId: COMPANY_ID, id: 'not-a-uuid' }) },
+ )
+ expect(res.status).toBe(400)
+ })
+})
diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts
new file mode 100644
index 00000000..cd32d041
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts
@@ -0,0 +1,457 @@
+/**
+ * POST /api/v1/companies/{companyId}/transactions/batch-categorize
+ *
+ * Apply a single categorization to up to 100 transactions in one call.
+ * Partial-success semantics — per-item failure does not roll back items
+ * that succeeded. Each item is processed through the same orchestration
+ * as the single :categorize endpoint, so it can fail individually for any
+ * of the same reasons (invalid template, invalid mapping, race, etc.).
+ *
+ * Idempotent over the whole batch. Dry-runnable.
+ */
+import { z } from 'zod'
+import { ok } from '@/lib/api/v1/response'
+import { dryRunPreview } from '@/lib/api/v1/dry-run'
+import { registerEndpoint } from '@/lib/api/v1/registry'
+import { withApiV1 } from '@/lib/api/v1/with-api-v1'
+import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
+import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
+import { CategorizeTransactionSchema } from '@/lib/api/schemas'
+import type { SupabaseClient } from '@supabase/supabase-js'
+import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
+import {
+ getTemplateById,
+ buildMappingResultFromTemplate,
+ validateTemplateForEntity,
+} from '@/lib/bookkeeping/booking-templates'
+import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
+import { reverseEntry } from '@/lib/bookkeeping/engine'
+import { isBookkeepingError } from '@/lib/bookkeeping/errors'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
+import { eventBus } from '@/lib/events'
+import type { Logger } from '@/lib/logger'
+import type { EntityType, Transaction, TransactionCategory } from '@/types'
+
+const BatchItem = z.object({
+ transaction_id: z.string().uuid(),
+ categorization: CategorizeTransactionSchema,
+})
+
+const BatchRequest = z.object({
+ items: z.array(BatchItem).min(1).max(100),
+ all_or_nothing: z.boolean().optional().default(false),
+})
+
+const ResultItem = z.object({
+ ok: z.boolean(),
+ request_index: z.number().int().nonnegative(),
+ transaction_id: z.string().uuid(),
+ data: z.unknown().optional(),
+ error: z
+ .object({
+ code: z.string(),
+ message: z.string(),
+ details: z.unknown().optional(),
+ })
+ .optional(),
+})
+
+const BatchResponse = z.object({
+ results: z.array(ResultItem),
+ summary: z.object({
+ total: z.number().int(),
+ succeeded: z.number().int(),
+ failed: z.number().int(),
+ }),
+})
+
+registerEndpoint({
+ operation: 'transactions.batch-categorize',
+ method: 'POST',
+ path: '/api/v1/companies/:companyId/transactions/batch-categorize',
+ summary: 'Categorize up to 100 transactions in one call (partial-success).',
+ description:
+ 'Per-item categorization mirroring the single :categorize endpoint. Same `{ results, summary }` shape as the other bulk endpoints. all_or_nothing: true returns 501 NOT_IMPLEMENTED. Idempotent over the whole batch.',
+ useWhen:
+ 'You have many transactions to categorize with the same logic (e.g. apply a booking template across a queue, mark a batch as private, override accounts on a series).',
+ doNotUseFor:
+ 'Categorizing transactions with mixed logic — make multiple :categorize calls. Auto-categorization via templates — handled inside `ingest` for matching rows, no separate endpoint needed.',
+ pitfalls: [
+ 'Max 100 items per call. Sequential processing.',
+ 'Idempotency-Key covers the WHOLE batch — replays return the cached full response.',
+ 'all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist.',
+ ],
+ example: {
+ request: {
+ items: [
+ { transaction_id: 'tx_1', categorization: { is_business: true, category: 'expense_office' } },
+ ],
+ },
+ response: {
+ data: {
+ results: [{ ok: true, request_index: 0, transaction_id: 'tx_1', data: { journal_entry_id: 'je_…' } }],
+ summary: { total: 1, succeeded: 1, failed: 0 },
+ },
+ meta: { request_id: 'req_…', api_version: '2026-05-12' },
+ },
+ },
+ scope: 'transactions:write',
+ risk: 'medium',
+ idempotent: true,
+ reversible: true,
+ dryRunSupported: true,
+ request: { body: BatchRequest },
+ response: { success: BatchResponse },
+})
+
+interface Item {
+ ok: boolean
+ request_index: number
+ transaction_id: string
+ data?: unknown
+ error?: { code: string; message: string; details?: unknown }
+}
+
+async function categorizeOne(
+ supabase: SupabaseClient,
+ companyId: string,
+ userId: string,
+ entityType: EntityType,
+ index: number,
+ transactionId: string,
+ input: z.infer,
+ dryRun: boolean,
+ log: Logger,
+): Promise- {
+ const { data: transaction, error: fetchErr } = await supabase
+ .from('transactions')
+ .select('*')
+ .eq('id', transactionId)
+ .eq('company_id', companyId)
+ .single()
+ if (fetchErr || !transaction) {
+ return {
+ ok: false,
+ request_index: index,
+ transaction_id: transactionId,
+ error: { code: 'TX_CATEGORIZE_TX_NOT_FOUND', message: 'Transaction not found.' },
+ }
+ }
+
+ const { is_business, category } = input
+ let finalCategory: TransactionCategory
+ if (input.template_id) {
+ const template = getTemplateById(input.template_id)
+ if (!template) {
+ return {
+ ok: false,
+ request_index: index,
+ transaction_id: transactionId,
+ error: {
+ code: 'TX_CATEGORIZE_INVALID_TEMPLATE',
+ message: 'Unknown template id.',
+ details: { templateId: input.template_id },
+ },
+ }
+ }
+ const valid = validateTemplateForEntity(template, entityType)
+ if (!valid.valid) {
+ return {
+ ok: false,
+ request_index: index,
+ transaction_id: transactionId,
+ error: {
+ code: 'TX_CATEGORIZE_INVALID_TEMPLATE',
+ message: 'Template not valid for entity type.',
+ details: { templateId: input.template_id, reason: valid.error },
+ },
+ }
+ }
+ finalCategory = is_business ? template.fallback_category : 'private'
+ } else {
+ finalCategory = is_business ? category || 'uncategorized' : 'private'
+ }
+
+ let mappingResult
+ if (input.template_id) {
+ const template = getTemplateById(input.template_id)!
+ mappingResult = buildMappingResultFromTemplate(template, transaction as Transaction, entityType)
+ } else {
+ mappingResult = buildMappingResultFromCategory(
+ finalCategory,
+ transaction as Transaction,
+ is_business,
+ entityType,
+ input.vat_treatment,
+ )
+ }
+ if (!mappingResult.debit_account || !mappingResult.credit_account) {
+ return {
+ ok: false,
+ request_index: index,
+ transaction_id: transactionId,
+ error: {
+ code: 'TX_CATEGORIZE_INVALID_MAPPING',
+ message: 'Could not resolve debit/credit accounts.',
+ },
+ }
+ }
+
+ if (dryRun) {
+ return {
+ ok: true,
+ request_index: index,
+ transaction_id: transactionId,
+ data: {
+ preview: {
+ category: finalCategory,
+ debit_account: mappingResult.debit_account,
+ credit_account: mappingResult.credit_account,
+ vat_lines: mappingResult.vat_lines,
+ would_create_journal_entry: !transaction.journal_entry_id,
+ },
+ },
+ }
+ }
+
+ // Already-categorized: just flip flags.
+ if (transaction.journal_entry_id) {
+ const { error: updateErr } = await supabase
+ .from('transactions')
+ .update({ is_business, category: finalCategory })
+ .eq('id', transactionId)
+ .eq('company_id', companyId)
+ if (updateErr) {
+ return {
+ ok: false,
+ request_index: index,
+ transaction_id: transactionId,
+ error: { code: 'INTERNAL_ERROR', message: 'Failed to update flags.' },
+ }
+ }
+ return {
+ ok: true,
+ request_index: index,
+ transaction_id: transactionId,
+ data: {
+ journal_entry_created: false,
+ journal_entry_id: transaction.journal_entry_id,
+ category: finalCategory,
+ already_had_journal_entry: true,
+ },
+ }
+ }
+
+ // Period-lock pre-check — same rationale as the single :categorize route.
+ // A locked period surfaces as PERIOD_LOCKED on the per-item error rather
+ // than a generic INTERNAL_ERROR from the trigger exception.
+ const periodLock = await checkPeriodLock(supabase, companyId, transaction.date)
+ if (periodLock.locked) {
+ return {
+ ok: false,
+ request_index: index,
+ transaction_id: transactionId,
+ error: {
+ code: 'PERIOD_LOCKED',
+ message: 'Period is locked or closed; cannot post journal entry.',
+ details: {
+ transaction_date: transaction.date,
+ reason: periodLock.reason,
+ fiscal_period_id: periodLock.fiscal_period_id,
+ },
+ },
+ }
+ }
+
+ let journalEntryId: string | null = null
+ let journalEntryError: string | null = null
+ try {
+ const je = await createTransactionJournalEntry(
+ supabase,
+ companyId,
+ userId,
+ transaction as Transaction,
+ mappingResult,
+ )
+ if (je) journalEntryId = je.id
+ } catch (err) {
+ log.error('batch-categorize: journal entry creation failed', err as Error, {
+ request_index: index,
+ transactionId,
+ })
+ if (isBookkeepingError(err)) {
+ journalEntryError = getErrorMessage(err, { context: 'transaction' })
+ } else {
+ journalEntryError = err instanceof Error ? err.message : 'Unknown error'
+ }
+ }
+
+ const { data: updated, error: updateErr } = await supabase
+ .from('transactions')
+ .update({
+ is_business,
+ category: finalCategory,
+ journal_entry_id: journalEntryId,
+ })
+ .eq('id', transactionId)
+ .eq('company_id', companyId)
+ .is('journal_entry_id', null)
+ .select('id')
+ if (updateErr) {
+ return {
+ ok: false,
+ request_index: index,
+ transaction_id: transactionId,
+ error: { code: 'INTERNAL_ERROR', message: updateErr.message },
+ }
+ }
+ if ((!updated || updated.length === 0) && journalEntryId) {
+ // CAS race — storno the orphan (BFL 5 kap 5 §). Direct status flip
+ // would be blocked by enforce_journal_entry_immutability since the
+ // engine writes the JE as posted. Same fix as the single :categorize
+ // route. Storno keeps the verifikationsnummer series unbroken.
+ try {
+ await reverseEntry(supabase, companyId, userId, journalEntryId)
+ } catch (revErr) {
+ log.error('batch-categorize TX_CATEGORIZE_RACE: failed to storno orphaned JE', revErr as Error, {
+ request_index: index,
+ orphanJournalEntryId: journalEntryId,
+ })
+ // Document the gap so the orphan is traceable per BFL 5 kap 5 §.
+ try {
+ const { data: orphan } = await supabase
+ .from('journal_entries')
+ .select('fiscal_period_id, voucher_series, voucher_number')
+ .eq('id', journalEntryId)
+ .single()
+ if (orphan && orphan.voucher_series) {
+ // Same rationale as the single :categorize route: skip the gap row
+ // when no series exists rather than filing under a fallback series
+ // that an audit query won't find.
+ await supabase.from('voucher_gap_explanations').insert({
+ company_id: companyId,
+ fiscal_period_id: orphan.fiscal_period_id,
+ voucher_series: orphan.voucher_series,
+ gap_number: orphan.voucher_number,
+ explanation:
+ 'CAS-race orphan; automatisk storno misslyckades. Manuell reconciliation krävs.',
+ created_by: userId,
+ })
+ }
+ } catch (gapErr) {
+ log.error('batch-categorize: failed to log voucher_gap_explanations', gapErr as Error, {
+ request_index: index,
+ orphanJournalEntryId: journalEntryId,
+ })
+ }
+ }
+ return {
+ ok: false,
+ request_index: index,
+ transaction_id: transactionId,
+ error: { code: 'TX_CATEGORIZE_RACE', message: 'Concurrent state change.' },
+ }
+ }
+
+ try {
+ await eventBus.emit({
+ type: 'transaction.categorized',
+ payload: {
+ transaction: transaction as Transaction,
+ account: mappingResult.debit_account,
+ taxCode: mappingResult.vat_lines[0]?.account_number || '',
+ userId,
+ companyId,
+ },
+ })
+ } catch (err) {
+ log.warn('batch-categorize: event emit failed (non-critical)', err as Error)
+ }
+
+ return {
+ ok: true,
+ request_index: index,
+ transaction_id: transactionId,
+ data: {
+ journal_entry_created: !!journalEntryId,
+ journal_entry_id: journalEntryId,
+ journal_entry_error: journalEntryError,
+ category: finalCategory,
+ },
+ }
+}
+
+export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
+ 'transactions.batch-categorize',
+ async (request, ctx) => {
+ let rawBody: unknown
+ try {
+ rawBody = await request.json()
+ } catch {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: { field: 'body', message: 'Body is not valid JSON.' },
+ })
+ }
+ const parsed = BatchRequest.safeParse(rawBody)
+ if (!parsed.success) {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: {
+ issues: parsed.error.issues.map((i) => ({
+ field: i.path.join('.'),
+ message: i.message,
+ })),
+ },
+ })
+ }
+ const body = parsed.data
+
+ if (body.all_or_nothing) {
+ return v1ErrorResponseFromCode('NOT_IMPLEMENTED', ctx.log, {
+ requestId: ctx.requestId,
+ details: {
+ field: 'all_or_nothing',
+ message: 'Use partial-success semantics (omit the flag or pass false).',
+ },
+ })
+ }
+
+ const { data: settings } = await ctx.supabase
+ .from('company_settings')
+ .select('entity_type')
+ .eq('company_id', ctx.companyId!)
+ .single()
+ const entityType: EntityType =
+ (settings?.entity_type as EntityType) || 'enskild_firma'
+
+ const results: Item[] = []
+ for (let i = 0; i < body.items.length; i++) {
+ const item = body.items[i]
+ const r = await categorizeOne(
+ ctx.supabase,
+ ctx.companyId!,
+ ctx.userId,
+ entityType,
+ i,
+ item.transaction_id,
+ item.categorization,
+ ctx.dryRun,
+ ctx.log,
+ )
+ results.push(r)
+ }
+
+ const summary = {
+ total: results.length,
+ succeeded: results.filter((r) => r.ok).length,
+ failed: results.filter((r) => !r.ok).length,
+ }
+
+ if (ctx.dryRun) {
+ return dryRunPreview({ results, summary }, { requestId: ctx.requestId, log: ctx.log })
+ }
+ return ok({ results, summary }, { requestId: ctx.requestId })
+ },
+ { requireIdempotencyKey: true },
+)
diff --git a/app/api/v1/companies/[companyId]/transactions/ingest/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/ingest/__tests__/route.test.ts
new file mode 100644
index 00000000..65027bec
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/transactions/ingest/__tests__/route.test.ts
@@ -0,0 +1,266 @@
+/**
+ * Integration tests for POST /api/v1/companies/:companyId/transactions/ingest
+ * and POST .../batch-categorize.
+ */
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+beforeAll(() => {
+ if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
+ process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
+})
+
+vi.mock('@/lib/auth/api-keys', async () => {
+ const actual = await vi.importActual
('@/lib/auth/api-keys')
+ return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
+})
+vi.mock('@supabase/supabase-js', async () => {
+ const actual = await vi.importActual('@supabase/supabase-js')
+ return { ...actual, createClient: vi.fn().mockReturnValue({}) }
+})
+
+const { ingestMock, createTxJE } = vi.hoisted(() => ({
+ ingestMock: vi.fn().mockResolvedValue({
+ imported: 2,
+ duplicates: 1,
+ reconciled: 0,
+ auto_categorized: 0,
+ auto_matched_invoices: 0,
+ errors: 0,
+ transaction_ids: ['11111111-1111-4111-8111-111111111111', '22222222-2222-4222-8222-222222222222'],
+ }),
+ createTxJE: vi.fn().mockResolvedValue({ id: 'je-bc' }),
+}))
+
+vi.mock('@/lib/transactions/ingest', () => ({
+ ingestTransactions: ingestMock,
+}))
+vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
+ createTransactionJournalEntry: createTxJE,
+}))
+
+import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
+import { POST as ingestPOST } from '../route'
+import { POST as batchPOST } from '../../batch-categorize/route'
+
+const mockValidate = validateApiKey as ReturnType
+const mockServiceClient = createServiceClientNoCookies as ReturnType
+
+type MockResult = { data?: unknown; error?: unknown }
+function makeFlexibleSupabase(byTable: Record) {
+ const queues = new Map()
+ for (const [t, val] of Object.entries(byTable)) {
+ queues.set(t, Array.isArray(val) ? [...val] : [val])
+ }
+ const buildChain = (table: string): unknown => {
+ const handler: ProxyHandler = {
+ get(_target, prop) {
+ if (prop === 'then') {
+ return (resolve: (v: unknown) => void) => {
+ const q = queues.get(table)
+ const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
+ resolve(next)
+ }
+ }
+ return (..._args: unknown[]) => buildChain(table)
+ },
+ }
+ return new Proxy({}, handler)
+ }
+ return { from: vi.fn((table: string) => buildChain(table)) }
+}
+
+const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
+const TX_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
+
+function makeRequest(url: string, body: unknown): Request {
+ return new Request(url, {
+ method: 'POST',
+ headers: {
+ Authorization: 'Bearer test-fixture-not-a-real-key',
+ 'Content-Type': 'application/json',
+ 'Idempotency-Key': 'idem1234-aaaa-4abc-8def-1234567890ab',
+ },
+ body: JSON.stringify(body),
+ })
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockValidate.mockResolvedValue({
+ userId: 'user-1',
+ companyId: COMPANY_ID,
+ apiKeyId: 'ak_1',
+ scopes: ['transactions:write'],
+ mode: 'live',
+ })
+})
+
+const SAMPLE_TX = {
+ date: '2026-05-12',
+ description: 'ICA MAXI',
+ amount: -349.5,
+ currency: 'SEK',
+ external_id: 'csv-line-42',
+ merchant_name: 'ICA MAXI',
+}
+
+describe('POST /transactions/ingest', () => {
+ it('runs the ingest pipeline and returns the result', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ }),
+ )
+ const res = await ingestPOST(
+ makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/ingest`, {
+ transactions: [SAMPLE_TX, { ...SAMPLE_TX, external_id: 'csv-line-43' }],
+ }),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data.imported).toBe(2)
+ expect(body.data.duplicates).toBe(1)
+ expect(ingestMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('dry-run returns dedup decisions without inserting', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ transactions: { data: [], error: null },
+ }),
+ )
+ const res = await ingestPOST(
+ makeRequest(
+ `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/ingest?dry_run=true`,
+ { transactions: [SAMPLE_TX] },
+ ),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(200)
+ expect(res.headers.get('X-Dry-Run')).toBe('true')
+ expect(ingestMock).not.toHaveBeenCalled()
+ })
+
+ it('rejects > 500 items', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ }),
+ )
+ const transactions = Array.from({ length: 501 }, (_, i) => ({
+ ...SAMPLE_TX,
+ external_id: `csv-${i}`,
+ }))
+ const res = await ingestPOST(
+ makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/ingest`, {
+ transactions,
+ }),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(400)
+ })
+
+ it('rejects keys without transactions:write scope', async () => {
+ mockValidate.mockResolvedValue({
+ userId: 'user-1',
+ companyId: COMPANY_ID,
+ scopes: ['transactions:read'],
+ mode: 'live',
+ })
+ mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
+ const res = await ingestPOST(
+ makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/ingest`, {
+ transactions: [SAMPLE_TX],
+ }),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(403)
+ })
+})
+
+describe('POST /transactions/batch-categorize', () => {
+ it('categorizes a batch with mixed success/failure', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
+ transactions: [
+ {
+ data: {
+ id: TX_ID,
+ date: '2026-05-12',
+ amount: -100,
+ currency: 'SEK',
+ merchant_name: 'ICA',
+ journal_entry_id: null,
+ },
+ error: null,
+ },
+ { data: [{ id: TX_ID }], error: null }, // CAS update select for item 0
+ { data: null, error: { code: 'PGRST116' } }, // item 1 not found
+ ],
+ }),
+ )
+
+ const res = await batchPOST(
+ makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/batch-categorize`, {
+ items: [
+ {
+ transaction_id: TX_ID,
+ categorization: { is_business: true, category: 'expense_office' },
+ },
+ {
+ transaction_id: '99999999-9999-4999-8999-999999999999',
+ categorization: { is_business: true, category: 'expense_office' },
+ },
+ ],
+ }),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.data.summary.total).toBe(2)
+ expect(body.data.summary.succeeded).toBe(1)
+ expect(body.data.summary.failed).toBe(1)
+ expect(body.data.results[1].error.code).toBe('TX_CATEGORIZE_TX_NOT_FOUND')
+ })
+
+ it('rejects all_or_nothing: true with 501', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ }),
+ )
+ const res = await batchPOST(
+ makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/batch-categorize`, {
+ all_or_nothing: true,
+ items: [{ transaction_id: TX_ID, categorization: { is_business: false } }],
+ }),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(501)
+ })
+
+ it('rejects > 100 items', async () => {
+ mockServiceClient.mockReturnValue(
+ makeFlexibleSupabase({
+ company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
+ }),
+ )
+ const items = Array.from({ length: 101 }, () => ({
+ transaction_id: TX_ID,
+ categorization: { is_business: false },
+ }))
+ const res = await batchPOST(
+ makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/batch-categorize`, {
+ items,
+ }),
+ { params: Promise.resolve({ companyId: COMPANY_ID }) },
+ )
+ expect(res.status).toBe(400)
+ })
+})
diff --git a/app/api/v1/companies/[companyId]/transactions/ingest/route.ts b/app/api/v1/companies/[companyId]/transactions/ingest/route.ts
new file mode 100644
index 00000000..9124b6f1
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/transactions/ingest/route.ts
@@ -0,0 +1,236 @@
+/**
+ * POST /api/v1/companies/{companyId}/transactions/ingest
+ *
+ * Bulk-ingest transactions (CSV import, custom integrations, off-platform
+ * bank feeds). Wraps the shared `ingestTransactions` library used by the
+ * bank-file importer and the PSD2 sync.
+ *
+ * The pipeline runs:
+ * 1. Dedup by external_id + content-based (date+amount).
+ * 2. Insert into transactions.
+ * 3. Auto-match invoices (OCR/reference + amount+customer fallback).
+ * 4. Mapping-rule evaluation for auto-categorization.
+ * 5. High-confidence auto-JE creation.
+ *
+ * Dry-run skips all writes and returns the dedup decision per item so
+ * callers can preview what would be ingested before committing.
+ */
+import { z } from 'zod'
+import { ok } from '@/lib/api/v1/response'
+import { dryRunPreview } from '@/lib/api/v1/dry-run'
+import { registerEndpoint } from '@/lib/api/v1/registry'
+import { withApiV1 } from '@/lib/api/v1/with-api-v1'
+import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
+import { ingestTransactions } from '@/lib/transactions/ingest'
+import type { RawTransaction } from '@/types'
+
+const RawTx = z.object({
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be ISO yyyy-MM-dd'),
+ description: z.string().min(1).max(500),
+ amount: z.number().refine((n) => n !== 0, 'amount must be non-zero'),
+ currency: z.string().min(1).max(8),
+ external_id: z.string().min(1).max(200),
+ mcc_code: z.number().int().nullable().optional(),
+ merchant_name: z.string().max(200).nullable().optional(),
+ reference: z.string().max(200).nullable().optional(),
+ import_source: z.string().min(1).max(50).optional(),
+})
+
+const IngestRequest = z.object({
+ transactions: z.array(RawTx).min(1).max(500),
+ skip_auto_categorization: z.boolean().optional(),
+ settlement_account: z
+ .string()
+ .regex(/^\d{4}$/, 'settlement_account must be a 4-digit account number')
+ .optional(),
+ raw_insert_only: z.boolean().optional(),
+})
+
+const IngestResponse = z.object({
+ imported: z.number().int(),
+ duplicates: z.number().int(),
+ reconciled: z.number().int(),
+ auto_categorized: z.number().int(),
+ auto_matched_invoices: z.number().int(),
+ errors: z.number().int(),
+ transaction_ids: z.array(z.string().uuid()),
+})
+
+registerEndpoint({
+ operation: 'transactions.ingest',
+ method: 'POST',
+ path: '/api/v1/companies/:companyId/transactions/ingest',
+ summary: 'Bulk-ingest transactions (up to 500 per call).',
+ description:
+ 'Runs the same ingest pipeline as the dashboard CSV importer and the PSD2 bank sync: dedup, insert, invoice match, mapping-rule auto-categorize, auto-JE for high-confidence matches. Idempotent over the whole batch via Idempotency-Key. Dry-runnable.',
+ useWhen:
+ 'You\'re importing transactions from a CSV, a custom bank feed, or an external accounting system. Each item must have a stable external_id — this is the primary dedup key.',
+ doNotUseFor:
+ 'Single ad-hoc transactions (use the dashboard). Documents/receipts (use the documents endpoint). Manually-created journal entries (Phase 4).',
+ pitfalls: [
+ 'external_id is the primary dedup key — make it stable for the same physical transaction across reruns.',
+ 'Content-based dedup (date+amount) runs in addition: a CSV row that matches an already-booked transaction by date+amount is skipped even if external_id differs.',
+ 'raw_insert_only=true skips ALL post-insert pipeline steps (matching, categorization). Use for viewer-only imports.',
+ 'Max 500 items per call. For larger imports, split into pages of 500.',
+ 'Dry-run runs both dedup checks (external_id AND content-based date+amount against booked rows), matching the live pipeline. Numbers should agree barring concurrent imports between preview and commit.',
+ ],
+ example: {
+ request: {
+ transactions: [
+ {
+ date: '2026-05-12',
+ description: 'ICA MAXI',
+ amount: -349.5,
+ currency: 'SEK',
+ external_id: 'csv-line-42',
+ merchant_name: 'ICA MAXI',
+ },
+ ],
+ },
+ response: {
+ data: { imported: 1, skipped_duplicates: 0 },
+ meta: { request_id: 'req_…', api_version: '2026-05-12' },
+ },
+ },
+ scope: 'transactions:write',
+ risk: 'medium',
+ idempotent: true,
+ reversible: false,
+ dryRunSupported: true,
+ request: { body: IngestRequest },
+ response: { success: IngestResponse },
+})
+
+export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
+ 'transactions.ingest',
+ async (request, ctx) => {
+ let rawBody: unknown
+ try {
+ rawBody = await request.json()
+ } catch {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: { field: 'body', message: 'Body is not valid JSON.' },
+ })
+ }
+ const parsed = IngestRequest.safeParse(rawBody)
+ if (!parsed.success) {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: {
+ issues: parsed.error.issues.map((i) => ({
+ field: i.path.join('.'),
+ message: i.message,
+ })),
+ },
+ })
+ }
+ const body = parsed.data
+
+ if (ctx.dryRun) {
+ // Dry-run runs BOTH dedup checks the live pipeline runs:
+ // 1. external_id match against any existing transaction
+ // 2. content match (date + amount) against already-booked rows
+ // The live pipeline narrows (2) to date range + booked-only, so we
+ // mirror that here. Without this, an integrator who relies on dry-run
+ // to confirm uniqueness can ingest a duplicate affärshändelse —
+ // BFL 5 kap requires löpande bokföring to reflect actual transactions
+ // and forbids double-bookings.
+ const externalIds = body.transactions.map((t) => t.external_id)
+ const dates = [...body.transactions.map((t) => t.date)].sort()
+ const dateFrom = dates[0]
+ const dateTo = dates[dates.length - 1]
+
+ const { data: existingByExtId } = await ctx.supabase
+ .from('transactions')
+ .select('external_id')
+ .eq('company_id', ctx.companyId!)
+ .in('external_id', externalIds)
+ const knownExtIds = new Set(
+ (existingByExtId ?? []).map((r) => (r as { external_id: string }).external_id),
+ )
+
+ const { data: bookedInRange } = await ctx.supabase
+ .from('transactions')
+ .select('date, amount')
+ .eq('company_id', ctx.companyId!)
+ .not('journal_entry_id', 'is', null)
+ .gte('date', dateFrom)
+ .lte('date', dateTo)
+ // Normalize the amount to a fixed-precision string before keying.
+ // Both JS number-to-string ("-349.5") and Postgres numeric round-trip
+ // ("-349.50") collapse to the same "-349.50" representation here, so
+ // a SIE amount with trailing-zero precision lines up with an already-
+ // booked row whose amount JSON-encodes without it.
+ const amountKey = (n: number): string => n.toFixed(2)
+ const bookedKeys = new Set(
+ (bookedInRange ?? []).map((r) => {
+ const row = r as { date: string; amount: number }
+ return `${row.date}|${amountKey(row.amount)}`
+ }),
+ )
+
+ const previewRows = body.transactions.map((tx) => {
+ const extIdHit = knownExtIds.has(tx.external_id)
+ const contentHit = bookedKeys.has(`${tx.date}|${amountKey(tx.amount)}`)
+ const wouldSkip = extIdHit || contentHit
+ const reason = extIdHit
+ ? 'external_id_match'
+ : contentHit
+ ? 'content_match_booked'
+ : null
+ return {
+ external_id: tx.external_id,
+ date: tx.date,
+ amount: tx.amount,
+ currency: tx.currency,
+ would_skip: wouldSkip,
+ skip_reason: reason,
+ }
+ })
+ const wouldImport = previewRows.filter((r) => !r.would_skip).length
+ const wouldSkip = previewRows.filter((r) => r.would_skip).length
+
+ return dryRunPreview(
+ {
+ would_import: wouldImport,
+ would_skip_duplicates: wouldSkip,
+ items: previewRows,
+ },
+ { requestId: ctx.requestId, log: ctx.log },
+ )
+ }
+
+ let result
+ try {
+ result = await ingestTransactions(
+ ctx.supabase,
+ ctx.companyId!,
+ ctx.userId,
+ body.transactions as RawTransaction[],
+ {
+ skipAutoCategorization: body.skip_auto_categorization,
+ settlementAccount: body.settlement_account,
+ rawInsertOnly: body.raw_insert_only,
+ },
+ )
+ } catch (err) {
+ ctx.log.error('transactions.ingest: pipeline failed', err as Error)
+ return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
+ }
+
+ return ok(
+ {
+ imported: result.imported,
+ duplicates: result.duplicates,
+ reconciled: result.reconciled,
+ auto_categorized: result.auto_categorized,
+ auto_matched_invoices: result.auto_matched_invoices,
+ errors: result.errors,
+ transaction_ids: result.transaction_ids,
+ },
+ { requestId: ctx.requestId },
+ )
+ },
+ { requireIdempotencyKey: true },
+)
diff --git a/app/api/v1/companies/[companyId]/transactions/route.ts b/app/api/v1/companies/[companyId]/transactions/route.ts
new file mode 100644
index 00000000..f96bad57
--- /dev/null
+++ b/app/api/v1/companies/[companyId]/transactions/route.ts
@@ -0,0 +1,184 @@
+/**
+ * GET /api/v1/companies/{companyId}/transactions
+ *
+ * Cursor-paginated transaction list. Filters: status (booked/unbooked),
+ * date range, currency, search (description ilike). Default sort:
+ * (date DESC, id ASC) — newest first, deterministic tie-break.
+ */
+import { z } from 'zod'
+import { paginated } from '@/lib/api/v1/response'
+import {
+ decodeDefaultCursor,
+ encodeDefaultCursor,
+ parsePaginationParams,
+} from '@/lib/api/v1/pagination'
+import { registerEndpoint } from '@/lib/api/v1/registry'
+import { withApiV1 } from '@/lib/api/v1/with-api-v1'
+import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
+
+const TransactionSummary = z.object({
+ id: z.string().uuid(),
+ date: z.string(),
+ description: z.string().nullable(),
+ amount: z.number(),
+ currency: z.string(),
+ reference: z.string().nullable(),
+ merchant_name: z.string().nullable(),
+ journal_entry_id: z.string().uuid().nullable(),
+ invoice_id: z.string().uuid().nullable(),
+ supplier_invoice_id: z.string().uuid().nullable(),
+ is_business: z.boolean().nullable(),
+ category: z.string().nullable(),
+ import_source: z.string().nullable(),
+ created_at: z.string(),
+})
+
+const TransactionListResponse = z.object({
+ transactions: z.array(TransactionSummary),
+})
+
+// Explicit projection — no SELECT *. created_at is required for cursor
+// stability (see ordering rationale in the GET handler).
+const TRANSACTION_SUMMARY_COLUMNS =
+ 'id, date, description, amount, currency, reference, merchant_name, ' +
+ 'journal_entry_id, invoice_id, supplier_invoice_id, is_business, category, ' +
+ 'import_source, created_at'
+
+registerEndpoint({
+ operation: 'transactions.list',
+ method: 'GET',
+ path: '/api/v1/companies/:companyId/transactions',
+ summary: 'List transactions for a company.',
+ description:
+ 'Cursor-paginated transaction list ordered by created_at DESC, id ASC (newest-imported first; the `date` column is the transaction date and is filterable but not the sort key). Filter by ?status=booked|unbooked, ?currency, ?date_from / ?date_to, ?search (description ilike).',
+ useWhen:
+ 'You need to walk a company\'s bank ledger — building a categorization queue, reconciling against external statements, or sampling for audit.',
+ doNotUseFor:
+ 'Looking up one transaction by id (use the detail endpoint). Reconciliation status (use /reconciliation/bank/status).',
+ pitfalls: [
+ 'Default page size is 50. Pass ?limit=100 for the maximum. Cursor pagination — pass ?cursor= from the previous response.',
+ 'A booked transaction has a non-null journal_entry_id. is_business / category live on the transaction row even before booking.',
+ 'reverse-charge or storno entries can leave a transaction with journal_entry_id pointing at a cancelled JE — check status on the JE separately.',
+ ],
+ example: {
+ response: {
+ data: [
+ {
+ id: 'a8f1…',
+ date: '2026-05-12',
+ description: 'ICA MAXI',
+ amount: -349.5,
+ currency: 'SEK',
+ merchant_name: 'ICA MAXI',
+ journal_entry_id: null,
+ is_business: null,
+ category: null,
+ },
+ ],
+ meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null },
+ },
+ },
+ scope: 'transactions:read',
+ risk: 'low',
+ idempotent: true,
+ reversible: false,
+ dryRunSupported: false,
+ response: { success: TransactionListResponse },
+})
+
+export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
+ 'transactions.list',
+ async (request, ctx) => {
+ const url = new URL(request.url)
+ const { limit, cursor } = parsePaginationParams(url)
+ const decoded = decodeDefaultCursor(cursor)
+
+ const FiltersSchema = z.object({
+ status: z.enum(['booked', 'unbooked']).optional(),
+ currency: z.string().min(1).max(8).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(),
+ search: z.string().min(1).max(200).optional(),
+ })
+ const filtersResult = FiltersSchema.safeParse({
+ status: url.searchParams.get('status') ?? undefined,
+ currency: url.searchParams.get('currency') ?? undefined,
+ date_from: url.searchParams.get('date_from') ?? undefined,
+ date_to: url.searchParams.get('date_to') ?? undefined,
+ search: url.searchParams.get('search') ?? undefined,
+ })
+ if (!filtersResult.success) {
+ return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
+ requestId: ctx.requestId,
+ details: {
+ issues: filtersResult.error.issues.map((i) => ({
+ field: i.path.join('.'),
+ message: i.message,
+ })),
+ },
+ })
+ }
+ const f = filtersResult.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), unique within a company at the row insertion
+ // grain, and total-orderable. Sorting by `date` directly broke the
+ // cursor (date is YYYY-MM-DD only, decoder rejects it). For users
+ // who care about transaction-date ordering specifically, the date
+ // is still in every row and ?date_from / ?date_to filters work.
+ let query = ctx.supabase
+ .from('transactions')
+ .select(TRANSACTION_SUMMARY_COLUMNS)
+ .eq('company_id', ctx.companyId!)
+ .order('created_at', { ascending: false })
+ .order('id', { ascending: true })
+ .limit(limit + 1)
+
+ if (f.status === 'booked') query = query.not('journal_entry_id', 'is', null)
+ else if (f.status === 'unbooked') query = query.is('journal_entry_id', null)
+ if (f.currency) query = query.eq('currency', f.currency)
+ if (f.date_from) query = query.gte('date', f.date_from)
+ if (f.date_to) query = query.lte('date', f.date_to)
+ if (f.search) {
+ // Two-step escape (PostgREST .or delimiters, then LIKE wildcards). Same
+ // pattern as customers list.
+ const term = f.search.replace(/[,()]/g, '').replace(/[%_\\]/g, '\\$&')
+ query = query.or(`description.ilike.%${term}%,merchant_name.ilike.%${term}%`)
+ }
+
+ if (decoded) {
+ // Cursor is on (created_at DESC, id ASC). created_at moves backward;
+ // id breaks ties.
+ 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; created_at: string } & Record
+ 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, {
+ requestId: ctx.requestId,
+ nextCursor: nextCursor ?? undefined,
+ })
+ },
+)
diff --git a/components/import/SIEUploadStep.tsx b/components/import/SIEUploadStep.tsx
index 7fc9a6a7..cde4a0f9 100644
--- a/components/import/SIEUploadStep.tsx
+++ b/components/import/SIEUploadStep.tsx
@@ -17,7 +17,7 @@ interface SIEUploadStepProps {
onFileSelect: (file: File) => void
isLoading: boolean
error: string | null
- errorType?: 'duplicate' | 'duplicate_period' | 'validation' | 'parse'
+ errorType?: 'duplicate' | 'duplicate_period' | 'validation' | 'parse' | 'network'
validationErrors?: string[]
validationWarnings?: string[]
duplicateImportId?: string | null
@@ -186,6 +186,7 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error, errorTyp
{errorType === 'duplicate_period' && 'Överlappande räkenskapsår'}
{errorType === 'validation' && 'Filen innehåller valideringsfel'}
{errorType === 'parse' && 'Kunde inte tolka filen'}
+ {errorType === 'network' && 'Uppladdningen misslyckades'}
{!errorType && 'Ett fel uppstod'}
{error}
@@ -221,6 +222,9 @@ export default function SIEUploadStep({ onFileSelect, isLoading, error, errorTyp
{errorType === 'parse' && (
Kontrollera att filen är en SIE4-fil exporterad från ett bokföringsprogram (Fortnox, Visma, Bokio etc). Filen kan vara skadad om den redigerats manuellt.
)}
+ {errorType === 'network' && (
+ Kontrollera din internetanslutning och försök igen. Om problemet kvarstår, prova att ladda upp filen från en dator eller hör av dig till support.
+ )}
diff --git a/lib/api/v1/check-period-lock.ts b/lib/api/v1/check-period-lock.ts
new file mode 100644
index 00000000..7ae5ed2e
--- /dev/null
+++ b/lib/api/v1/check-period-lock.ts
@@ -0,0 +1,74 @@
+/**
+ * Application-layer period-lock check used by Phase 3 v1 write routes.
+ *
+ * The DB has two layers of protection:
+ * - `enforce_period_lock` trigger blocks writes to any journal entry
+ * whose fiscal_period is `is_closed = TRUE` or has `locked_at` set.
+ * - `enforce_company_lock_date` trigger blocks writes on/before the
+ * company-wide `bookkeeping_locked_through` date.
+ *
+ * Both triggers raise a Postgres exception, which Supabase surfaces as a
+ * generic 500 with `BOOKKEEPING_DB_ERROR`. For the public API we want a
+ * structured `PERIOD_LOCKED` response with enough context for an agent to
+ * decide between (a) post to a later period or (b) ask the user to unlock.
+ *
+ * This helper performs the same check the trigger would, returning a
+ * { locked, reason, fiscal_period_id } verdict. Run it BEFORE the JE insert
+ * so callers get the structured error instead of the trigger exception.
+ *
+ * Note: this is a TOCTOU-window check (a period could be locked between
+ * the check and the insert), but the trigger is still authoritative. The
+ * helper is for ergonomics, not security.
+ */
+import type { SupabaseClient } from '@supabase/supabase-js'
+
+export interface PeriodLockVerdict {
+ locked: boolean
+ reason?:
+ | 'company_lock_date_covers'
+ | 'period_locked_at_set'
+ | 'period_is_closed'
+ | 'no_fiscal_period'
+ fiscal_period_id?: string
+}
+
+export async function checkPeriodLock(
+ supabase: SupabaseClient,
+ companyId: string,
+ date: string,
+): Promise {
+ // Company-wide lock date covers everything on/before bookkeeping_locked_through.
+ const { data: settings } = await supabase
+ .from('company_settings')
+ .select('bookkeeping_locked_through')
+ .eq('company_id', companyId)
+ .maybeSingle()
+ const lockThrough = settings?.bookkeeping_locked_through ?? null
+ if (lockThrough && date <= lockThrough) {
+ return { locked: true, reason: 'company_lock_date_covers' }
+ }
+
+ // Find the fiscal period covering the date.
+ const { data: period } = await supabase
+ .from('fiscal_periods')
+ .select('id, is_closed, locked_at')
+ .eq('company_id', companyId)
+ .lte('period_start', date)
+ .gte('period_end', date)
+ .maybeSingle()
+
+ if (!period) {
+ // No covering period. The engine's own ensure-period helper will create
+ // one (open, unlocked) for ad-hoc booking dates, so this is not a hard
+ // lock; let the JE insert proceed and surface engine errors normally.
+ return { locked: false, reason: 'no_fiscal_period' }
+ }
+ if (period.is_closed) {
+ return { locked: true, reason: 'period_is_closed', fiscal_period_id: period.id }
+ }
+ if (period.locked_at) {
+ return { locked: true, reason: 'period_locked_at_set', fiscal_period_id: period.id }
+ }
+
+ return { locked: false, fiscal_period_id: period.id }
+}
diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts
index f8da4672..832c3078 100644
--- a/lib/api/v1/load-routes.ts
+++ b/lib/api/v1/load-routes.ts
@@ -30,4 +30,18 @@ import '@/app/api/v1/companies/[companyId]/invoices/bulk-create/route'
import '@/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route'
import '@/app/api/v1/companies/[companyId]/customers/bulk-create/route'
+// Phase 3 — transactions + reconciliation vertical.
+import '@/app/api/v1/companies/[companyId]/transactions/route'
+import '@/app/api/v1/companies/[companyId]/transactions/[id]/route'
+import '@/app/api/v1/companies/[companyId]/accounts/route'
+import '@/app/api/v1/companies/[companyId]/fiscal-periods/route'
+import '@/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route'
+import '@/app/api/v1/companies/[companyId]/transactions/[id]/uncategorize/route'
+import '@/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route'
+import '@/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route'
+import '@/app/api/v1/companies/[companyId]/transactions/ingest/route'
+import '@/app/api/v1/companies/[companyId]/transactions/batch-categorize/route'
+import '@/app/api/v1/companies/[companyId]/reconciliation/bank/run/route'
+import '@/app/api/v1/companies/[companyId]/reconciliation/bank/status/route'
+
export {}
diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts
index 1ab094ca..ac0ebfc7 100644
--- a/lib/auth/scopes.ts
+++ b/lib/auth/scopes.ts
@@ -69,6 +69,24 @@ export const V1_ENDPOINT_SCOPES: Record = {
'GET /api/v1/companies/:companyId/invoices/:id/pdf': 'invoices:read',
'POST /api/v1/companies/:companyId/customers/bulk-create': 'customers:write',
+ // Phase 3 — transactions + reconciliation vertical.
+ // Reads
+ 'GET /api/v1/companies/:companyId/transactions': 'transactions:read',
+ 'GET /api/v1/companies/:companyId/transactions/:id': 'transactions:read',
+ 'GET /api/v1/companies/:companyId/accounts': 'reports:read',
+ 'GET /api/v1/companies/:companyId/fiscal-periods': 'reports:read',
+ // Writes — single transaction verbs
+ 'POST /api/v1/companies/:companyId/transactions/:id/categorize': 'transactions:write',
+ 'POST /api/v1/companies/:companyId/transactions/:id/uncategorize': 'transactions:write',
+ 'POST /api/v1/companies/:companyId/transactions/:id/match-invoice': 'transactions:write',
+ 'POST /api/v1/companies/:companyId/transactions/:id/match-supplier-invoice': 'transactions:write',
+ // Writes — bulk
+ 'POST /api/v1/companies/:companyId/transactions/ingest': 'transactions:write',
+ 'POST /api/v1/companies/:companyId/transactions/batch-categorize': 'transactions:write',
+ // Reconciliation
+ 'POST /api/v1/companies/:companyId/reconciliation/bank/run': 'transactions:write',
+ 'GET /api/v1/companies/:companyId/reconciliation/bank/status': 'transactions:read',
+
// Webhooks (Phase 6 — placeholder so the catalogue is complete)
'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
'POST /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts
index 2e90f9b1..16c1108b 100644
--- a/lib/errors/structured-errors.ts
+++ b/lib/errors/structured-errors.ts
@@ -388,6 +388,26 @@ const MATCH_SI: Record = {
message_en:
'Cash accounting does not support exchange-rate differences. Switch to accrual or book the FX difference manually.',
},
+ TX_UNCATEGORIZE_NOT_BOOKED: {
+ httpStatus: 400,
+ message_sv: 'Transaktionen är inte bokförd. Det finns inget att av-kategorisera.',
+ message_en: 'Transaction has no journal entry — nothing to uncategorize.',
+ },
+ TX_UNCATEGORIZE_JE_NOT_POSTED: {
+ httpStatus: 400,
+ message_sv: 'Verifikationen är inte bokförd. Reversal kan inte utföras.',
+ message_en: 'Journal entry is not in posted status; reversal is not possible.',
+ },
+ TX_INGEST_INSERT_FAILED: {
+ httpStatus: 500,
+ message_sv: 'Transaktionerna kunde inte importeras.',
+ message_en: 'Transaction ingest failed.',
+ },
+ TX_BATCH_CATEGORIZE_EMPTY: {
+ httpStatus: 400,
+ message_sv: 'Batchen är tom.',
+ message_en: 'Batch is empty — pass at least one item.',
+ },
}
const INVOICE: Record = {