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 = {