diff --git a/app/api/v1/companies/[companyId]/compliance/check/route.ts b/app/api/v1/companies/[companyId]/compliance/check/route.ts new file mode 100644 index 00000000..a354af53 --- /dev/null +++ b/app/api/v1/companies/[companyId]/compliance/check/route.ts @@ -0,0 +1,297 @@ +/** + * GET /api/v1/companies/{companyId}/compliance/check?type=... + * + * gnubok's defensible edge: a single, structured pre-flight endpoint that + * surfaces the same compliance checks the MCP / dashboard run, in a form + * an agent can act on programmatically. + * + * Generalises the existing MCP tools (gnubok_vat_close_check, + * gnubok_year_end_readiness) under a single response shape. New check types + * can be added by registering an entry in CHECK_RUNNERS — the response + * envelope stays stable so agents only learn one shape. + * + * Response shape: + * { + * type, ready: boolean, findings: [{ severity, code, message, details }], + * summary: string, generated_at, params: { ... } + * } + */ + +import { z } from 'zod' +import type { SupabaseClient } from '@supabase/supabase-js' +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 { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period' +import { validateYearEndReadiness } from '@/lib/core/bookkeeping/year-end-service' + +// NOTE: `vat_close` is documented in the plan as a supported check type but +// is NOT shipped here yet. The underlying logic lives in +// `extensions/general/mcp-server/server.ts::computeVatCloseCheck` and core +// routes can't import from `@/extensions/` (CI guard `core-only.yml`). A +// follow-up PR will extract that function into `lib/reports/` so it can be +// re-used from both the MCP tool and this endpoint without violating the +// extension/core boundary. The CHECK_RUNNERS shape is ready — adding the +// type back is a one-liner once the function is in `lib/`. + +// -------------------------------------------------------------------- +// Response envelope: identical shape across check types so agents learn +// one structure. +// -------------------------------------------------------------------- + +const Finding = z.object({ + severity: z.enum(['info', 'warning', 'blocker']), + code: z.string(), + message: z.string(), + details: z.unknown().optional(), +}) + +const ComplianceCheckResponse = z.object({ + type: z.string(), + ready: z.boolean(), + findings: z.array(Finding), + summary: z.string(), + generated_at: z.string(), + params: z.record(z.string(), z.unknown()), +}) + +type FindingShape = z.infer + +interface CheckResult { + ready: boolean + findings: FindingShape[] + summary: string + /** Free-form extra payload merged into the response under `details` (e.g. the VAT rutor + payment block). */ + extra?: Record +} + +// -------------------------------------------------------------------- +// Check runners. Each runner is responsible for its own param parsing. +// -------------------------------------------------------------------- + +const SUPPORTED_TYPES = [ + 'year_end_readiness', + 'voucher_gaps', +] as const +type CheckType = (typeof SUPPORTED_TYPES)[number] + +const CheckTypeSchema = z.enum(SUPPORTED_TYPES) + +async function runYearEndReadinessCheck( + supabase: SupabaseClient, + companyId: string, + userId: string, + url: URL, +): Promise { + const fiscalPeriodId = url.searchParams.get('fiscal_period_id') + if (!fiscalPeriodId || !z.string().uuid().safeParse(fiscalPeriodId).success) { + return { + error: 'year_end_readiness requires fiscal_period_id (UUID) query param.', + } + } + + // Defense-in-depth: confirm the period belongs to this company before + // handing the id to the engine. The engine ALSO scopes by company_id, + // but returning a clean structured "not found" here is a better UX than + // letting the engine throw a Swedish error string. + const periodCheck = await ownsFiscalPeriod(supabase, companyId, fiscalPeriodId) + if (!periodCheck) { + return { error: 'fiscal_period_id not found in this company.' } + } + + const validation = await validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId) + const findings: FindingShape[] = [] + + for (const err of validation.errors ?? []) { + findings.push({ severity: 'blocker', code: 'YEAR_END_BLOCKER', message: err }) + } + for (const w of validation.warnings ?? []) { + findings.push({ severity: 'warning', code: 'YEAR_END_WARNING', message: w }) + } + if ((validation.draftCount ?? 0) > 0) { + findings.push({ + severity: 'blocker', + code: 'YEAR_END_DRAFTS_PRESENT', + message: `${validation.draftCount} draft journal entries must be committed or cancelled before year-end.`, + details: { draft_count: validation.draftCount }, + }) + } + if ((validation.unexplainedGaps ?? []).length > 0) { + findings.push({ + severity: 'blocker', + code: 'YEAR_END_UNEXPLAINED_VOUCHER_GAPS', + message: `${validation.unexplainedGaps.length} voucher-number gap(s) lack an explanation (BFNAR 2013:2 kap 8 §).`, + details: { gaps: validation.unexplainedGaps }, + }) + } + if (!validation.trialBalanceBalanced) { + findings.push({ + severity: 'blocker', + code: 'YEAR_END_TRIAL_BALANCE_UNBALANCED', + message: 'Trial balance does not balance; close blockers before year-end.', + }) + } + + return { + ready: validation.ready, + findings, + summary: validation.ready + ? 'Period is ready for year-end closing.' + : `Period is NOT ready (${findings.filter((f) => f.severity === 'blocker').length} blocker(s)).`, + extra: { + draft_count: validation.draftCount, + unexplained_gap_count: validation.unexplainedGaps?.length ?? 0, + sequence_mismatch_count: validation.sequenceMismatches?.length ?? 0, + trial_balance_balanced: validation.trialBalanceBalanced, + }, + } +} + +async function runVoucherGapsCheck( + supabase: SupabaseClient, + companyId: string, + url: URL, +): Promise { + const fiscalPeriodId = url.searchParams.get('fiscal_period_id') + if (!fiscalPeriodId || !z.string().uuid().safeParse(fiscalPeriodId).success) { + return { error: 'voucher_gaps requires fiscal_period_id (UUID) query param.' } + } + + // Same ownership pre-check as year_end_readiness — the RPC scopes by + // company_id but returning a clean error here is better UX. + const periodCheck = await ownsFiscalPeriod(supabase, companyId, fiscalPeriodId) + if (!periodCheck) { + return { error: 'fiscal_period_id not found in this company.' } + } + + const { data, error } = await supabase.rpc('detect_voucher_gaps', { + p_company_id: companyId, + p_fiscal_period_id: fiscalPeriodId, + }) + if (error) throw error + + type GapRow = { voucher_series: string; gap_start: number; gap_end: number; has_explanation: boolean } + const rows = (data ?? []) as GapRow[] + + const findings: FindingShape[] = rows.map((r) => ({ + severity: r.has_explanation ? 'info' : 'blocker', + code: r.has_explanation ? 'VOUCHER_GAP_EXPLAINED' : 'VOUCHER_GAP_UNEXPLAINED', + message: `Series ${r.voucher_series}: gap ${r.gap_start}${r.gap_end > r.gap_start ? `–${r.gap_end}` : ''}${r.has_explanation ? ' (explained)' : ' (no explanation)'}.`, + details: { voucher_series: r.voucher_series, gap_start: r.gap_start, gap_end: r.gap_end, has_explanation: r.has_explanation }, + })) + + const unexplainedCount = findings.filter((f) => f.code === 'VOUCHER_GAP_UNEXPLAINED').length + + return { + ready: unexplainedCount === 0, + findings, + summary: + rows.length === 0 + ? 'Verifikationsserie is continuous (no gaps).' + : `${unexplainedCount} unexplained gap(s) of ${rows.length} total. Document via POST /voucher-gap-explanations.`, + extra: { total_gaps: rows.length, unexplained_count: unexplainedCount }, + } +} + +// -------------------------------------------------------------------- +// Endpoint definition +// -------------------------------------------------------------------- + +registerEndpoint({ + operation: 'compliance.check', + method: 'GET', + path: '/api/v1/companies/:companyId/compliance/check', + summary: 'Run a structured compliance pre-flight check.', + description: + 'Generalised pre-flight that consolidates the gnubok pre-close validators under one envelope. Supported check types: year_end_readiness (BFNAR 2017:3 + ÅRL 2:1 blockers), voucher_gaps (BFNAR 2013:2 kap 8 § series continuity). vat_close is planned for a follow-up PR (the underlying function currently lives in the MCP extension and core routes cannot import from extensions; it will be extracted into lib/reports/ then exposed here). New types can be added without changing the response shape.', + useWhen: + 'Before committing to an irreversible action (VAT close, year-end close), or as a periodic audit sweep to surface blockers before they become urgent.', + doNotUseFor: + 'Executing the underlying action — this is read-only. After a passing check, call the corresponding async endpoint (POST /fiscal-periods/{id}/year-end, etc).', + pitfalls: [ + 'year_end_readiness and voucher_gaps require fiscal_period_id (UUID).', + 'A passing check is a SNAPSHOT — the state can change between the check and the action. The same blocker logic runs again on commit.', + 'vat_close is documented in the plan but NOT yet supported by this endpoint — call gnubok_vat_close_check via the MCP server until the function is extracted into lib/reports/.', + ], + example: { + response: { + data: { + type: 'year_end_readiness', + ready: false, + findings: [ + { severity: 'blocker', code: 'YEAR_END_DRAFTS_PRESENT', message: '3 draft journal entries must be committed or cancelled before year-end.', details: { draft_count: 3 } }, + ], + summary: 'Period is NOT ready (1 blocker(s)).', + generated_at: '2026-05-12T14:00:00Z', + params: { fiscal_period_id: 'a8f1…' }, + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'compliance:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: ComplianceCheckResponse }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'compliance.check', + async (request, ctx) => { + const url = new URL(request.url) + const typeRaw = url.searchParams.get('type') + const typeParse = CheckTypeSchema.safeParse(typeRaw) + if (!typeParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'type', + message: `type must be one of: ${SUPPORTED_TYPES.join(', ')}.`, + supported_types: SUPPORTED_TYPES, + }, + }) + } + const type: CheckType = typeParse.data + + try { + let result: CheckResult | { error: string; details?: unknown } + const params: Record = { type } + + switch (type) { + case 'year_end_readiness': + result = await runYearEndReadinessCheck(ctx.supabase, ctx.companyId!, ctx.userId, url) + params.fiscal_period_id = url.searchParams.get('fiscal_period_id') + break + case 'voucher_gaps': + result = await runVoucherGapsCheck(ctx.supabase, ctx.companyId!, url) + params.fiscal_period_id = url.searchParams.get('fiscal_period_id') + break + } + + if ('error' in result) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { message: result.error, ...(result.details ? { issues: result.details } : {}) }, + }) + } + + return ok( + { + type, + ready: result.ready, + findings: result.findings, + summary: result.summary, + generated_at: new Date().toISOString(), + params, + ...(result.extra ? { details: result.extra } : {}), + }, + { requestId: ctx.requestId }, + ) + } catch (err) { + ctx.log.error('compliance.check failed', err as Error, { type }) + return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId }) + } + }, +) diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/close/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/close/route.ts new file mode 100644 index 00000000..131c66ba --- /dev/null +++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/close/route.ts @@ -0,0 +1,136 @@ +/** + * POST /api/v1/companies/{companyId}/fiscal-periods/{id}/close + * + * Closes a fiscal period — sets is_closed=true and closed_at. Requires the + * period to be locked AND year-end closing to have been executed (i.e. + * closing_entry_id IS NOT NULL). Wraps lib/core/bookkeeping/period-service.closePeriod. + * Synchronous; the actual closing-entry work happens earlier via /year-end. + * + * Per BFL 5 kap 8 §, close is IRREVERSIBLE — there is no /unlock-after-close path. + */ + +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 { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { closePeriod } from '@/lib/core/bookkeeping/period-service' + +const PeriodClosedResponse = z.object({ + id: z.string().uuid(), + is_closed: z.literal(true), + closed_at: z.string(), +}) + +registerEndpoint({ + operation: 'fiscal-periods.close', + method: 'POST', + path: '/api/v1/companies/:companyId/fiscal-periods/:id/close', + summary: 'Close a fiscal period (IRREVERSIBLE per BFL 5 kap 8 §).', + description: + 'Sets is_closed=true + closed_at on the period. Pre-requisites: period must be locked (call /lock first) AND year-end closing must have been executed (call /year-end first). Sync. The DB blocks any subsequent JE inserts.', + useWhen: + 'Final step in the year-end flow: lock → year-end → close. Closing freezes the period for BFL 7 kap retention.', + doNotUseFor: + 'Locking a period (use /lock). Running the year-end closing entry (use /year-end). UNDOING a close (not supported — irreversible).', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'IRREVERSIBLE. Once is_closed=true, the period is read-only forever (BFL 5 kap 8 § + 7 kap).', + 'Pre-conditions: locked + closing_entry_id present. Otherwise the call returns CONFLICT.', + ], + example: { + response: { + data: { id: 'a8f1…', is_closed: true, closed_at: '2026-05-12T14:30:00Z' }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'bookkeeping:write', + risk: 'high', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: PeriodClosedResponse }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'fiscal-periods.close', + 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: 'fiscal_period id must be a UUID.' }, + }) + } + // Explicit pre-flight checks BEFORE the engine call. closePeriod throws + // Swedish error strings on each precondition violation; matching against + // those strings is brittle (engine message changes silently). Read the + // period's state columns directly and return structured codes here. + const { data: period } = await ctx.supabase + .from('fiscal_periods') + .select('id, is_closed, locked_at, closing_entry_id') + .eq('id', idParse.data) + .eq('company_id', ctx.companyId!) + .maybeSingle() + if (!period) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, details: { resource: 'fiscal_period' }, + }) + } + const periodRow = period as { is_closed: boolean; locked_at: string | null; closing_entry_id: string | null } + if (periodRow.is_closed) { + return v1ErrorResponseFromCode('CONFLICT', ctx.log, { + requestId: ctx.requestId, details: { reason: 'already_closed' }, + }) + } + if (!periodRow.locked_at) { + return v1ErrorResponseFromCode('PERIOD_NOT_LOCKED', ctx.log, { requestId: ctx.requestId }) + } + if (!periodRow.closing_entry_id) { + return v1ErrorResponseFromCode('CONFLICT', ctx.log, { + requestId: ctx.requestId, + details: { + reason: 'year_end_not_executed', + remediation: 'Call POST /fiscal-periods/{id}/year-end first.', + }, + }) + } + + try { + const updated = await closePeriod(ctx.supabase, ctx.companyId!, ctx.userId, idParse.data) + return ok( + { id: updated.id, is_closed: true as const, closed_at: updated.closed_at! }, + { requestId: ctx.requestId }, + ) + } catch (err) { + const msg = err instanceof Error ? err.message : 'unknown' + ctx.log.warn('fiscal-periods.close refused', { fiscalPeriodId: idParse.data, reason: msg }) + if (msg.includes('not found')) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, details: { resource: 'fiscal_period' }, + }) + } + if (msg.includes('already closed')) { + return v1ErrorResponseFromCode('CONFLICT', ctx.log, { + requestId: ctx.requestId, details: { reason: 'already_closed' }, + }) + } + if (msg.includes('must be locked')) { + return v1ErrorResponseFromCode('PERIOD_NOT_LOCKED', ctx.log, { + requestId: ctx.requestId, + }) + } + if (msg.includes('Year-end closing must be executed')) { + return v1ErrorResponseFromCode('CONFLICT', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'year_end_not_executed', remediation: 'Call POST /fiscal-periods/{id}/year-end first.' }, + }) + } + return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, details: { reason: msg }, + }) + } + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts new file mode 100644 index 00000000..72fd642b --- /dev/null +++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route.ts @@ -0,0 +1,189 @@ +/** + * POST /api/v1/companies/{companyId}/fiscal-periods/{id}/currency-revaluation + * + * Runs FX revaluation for the period — re-rates open foreign-currency AR + * (1510) + AP (2440) at the closing date's rate and posts the delta to + * 3960 / 7960. Wraps lib/bookkeeping/currency-revaluation.executeCurrencyRevaluation. + * Records an operation row and returns 202 + operation_id. + * + * Idempotent per-period (engine throws on second invocation against the same + * fiscal_period_id). Use /reverse on the resulting JE to retry. + */ + +import { z } from 'zod' +import { accepted } from '@/lib/api/v1/response' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period' +import { startOperation, completeOperation, failOperation } from '@/lib/api/v1/operations' +import { executeCurrencyRevaluation } from '@/lib/bookkeeping/currency-revaluation' + +const Body = z + .object({ as_of_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional() }) + .strict() + +const RevaluationAccepted = z.object({ + operation_id: z.string().uuid(), + type: z.literal('fiscal_periods.currency_revaluation'), + status: z.enum(['queued', 'running', 'succeeded', 'failed']), + poll_url: z.string(), + webhook_event: z.literal('operation.completed'), +}) + +registerEndpoint({ + operation: 'fiscal-periods.currency-revaluation', + method: 'POST', + path: '/api/v1/companies/:companyId/fiscal-periods/:id/currency-revaluation', + summary: 'Run FX revaluation for the fiscal period.', + description: + 'Re-rates open foreign-currency AR (1510) and AP (2440) at the closing date\'s Riksbanken rate and posts the SEK delta to 3960 (valutakursvinst) / 7960 (valutakursförlust). Returns 202 with operation_id. Idempotent per-period: the engine throws if a revaluation has already been posted for the same fiscal_period_id.', + useWhen: + 'Before /year-end if your books have open foreign-currency receivables or payables. /year-end also runs this internally, so you only need to call it separately when you want the FX-only entry without the full closing.', + doNotUseFor: + 'Re-running on the same period (CURRENCY_REVALUATION_ALREADY_EXISTS). Revaluing a closed period (the trigger blocks JE writes to closed periods).', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'Engine returns null if no open foreign-currency items exist — the operation succeeds with result.revaluation_entry_id=null.', + 'as_of_date defaults to period_end if omitted.', + ], + example: { + response: { + data: { operation_id: '0e9c…', type: 'fiscal_periods.currency_revaluation', status: 'succeeded', poll_url: '/api/v1/operations/0e9c…', webhook_event: 'operation.completed' }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'bookkeeping:write', + risk: 'high', + idempotent: true, + reversible: true, + dryRunSupported: false, + request: { body: Body }, + response: { success: RevaluationAccepted }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'fiscal-periods.currency-revaluation', + 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: 'fiscal_period id must be a UUID.' }, + }) + } + const fiscalPeriodId = idParse.data + + let bodyAsOfDate: string | undefined + let rawBody: unknown = null + try { + const text = await request.text() + if (text.trim()) rawBody = JSON.parse(text) + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + if (rawBody) { + const parsed = Body.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 })) }, + }) + } + bodyAsOfDate = parsed.data.as_of_date + } + + // Ownership pre-check on the URL period — UNCONDITIONAL. Round-3 + // missed this when as_of_date was supplied in the body (the + // ownership-by-side-effect via period_end lookup was conditional). + if (!(await ownsFiscalPeriod(ctx.supabase, ctx.companyId!, fiscalPeriodId))) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, details: { resource: 'fiscal_period' }, + }) + } + + // Resolve as_of_date — default to period_end. Ownership is already + // confirmed above, so this is a pure read. + let asOfDate = bodyAsOfDate + if (!asOfDate) { + const { data: period } = await ctx.supabase + .from('fiscal_periods') + .select('period_end') + .eq('id', fiscalPeriodId) + .eq('company_id', ctx.companyId!) + .maybeSingle() + if (!period) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, details: { resource: 'fiscal_period' }, + }) + } + asOfDate = (period as { period_end: string }).period_end + } + + // Wrap startOperation in its own try/catch so a DB-unreachable failure + // is reported as a structured INTERNAL_ERROR rather than escaping as a + // 500 with no operation row recorded (BFNAR 2013:2 kap 8 § + // behandlingshistorik). + let operationId: string + try { + const started = await startOperation( + ctx.supabase, + { + companyId: ctx.companyId!, userId: ctx.userId, + operationType: 'fiscal_periods.currency_revaluation', + params: { fiscal_period_id: fiscalPeriodId, as_of_date: asOfDate }, + initialStatus: 'running', + }, + ctx.log, + ) + operationId = started.id + } catch (err) { + ctx.log.error('startOperation failed for currency-revaluation', err as Error, { fiscalPeriodId }) + return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { step: 'operation_record_create', reason: (err as Error).message ?? 'unknown' }, + }) + } + + try { + const result = await executeCurrencyRevaluation( + ctx.supabase, ctx.companyId!, asOfDate, fiscalPeriodId, ctx.userId, + ) + await completeOperation( + ctx.supabase, + { + id: operationId, + result: { + revaluation_entry_id: result?.entry?.id ?? null, + total_gain: result?.preview?.totalGain ?? 0, + total_loss: result?.preview?.totalLoss ?? 0, + net_effect: result?.preview?.netEffect ?? 0, + item_count: result?.preview?.items?.length ?? 0, + }, + }, + ctx.log, + ) + return accepted(operationId, 'fiscal_periods.currency_revaluation', { requestId: ctx.requestId }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'unknown' + ctx.log.error('currency-revaluation failed', err as Error, { fiscalPeriodId, operationId }) + await failOperation( + ctx.supabase, + { + id: operationId, + error: { + code: msg.includes('already exists') ? 'CURRENCY_REVALUATION_ALREADY_EXISTS' : 'CURRENCY_REVALUATION_FAILED', + message: msg, + }, + }, + ctx.log, + ) + return accepted(operationId, 'fiscal_periods.currency_revaluation', { requestId: ctx.requestId }) + } + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/lock/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/lock/route.ts new file mode 100644 index 00000000..9086a203 --- /dev/null +++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/lock/route.ts @@ -0,0 +1,108 @@ +/** + * POST /api/v1/companies/{companyId}/fiscal-periods/{id}/lock + * + * Locks a fiscal period — sets locked_at and prevents new bokföringsposter + * with entry_date inside the period. Wraps lib/core/bookkeeping/period-service.lockPeriod. + * Synchronous; returns 200 with the updated period. + */ + +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 { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { lockPeriod } from '@/lib/core/bookkeeping/period-service' + +const PeriodLockedResponse = z.object({ + id: z.string().uuid(), + locked_at: z.string(), + is_closed: z.boolean(), +}) + +registerEndpoint({ + operation: 'fiscal-periods.lock', + method: 'POST', + path: '/api/v1/companies/:companyId/fiscal-periods/:id/lock', + summary: 'Lock a fiscal period (no new entries can be posted into it).', + description: + 'Sets locked_at on the period. Refuses if uncategorised business transactions remain in the period — they must be bokfört first. The DB trigger blocks JE inserts into locked periods; locking is the application-level pre-step before /close. Sync.', + useWhen: + 'Finishing a period and you want to stop new postings. Step 1 of a three-step year-end flow: lock → year-end → close.', + doNotUseFor: + 'Locking an already-closed period (no-op). Bypassing the uncategorised-transactions guard — categorise or mark-private first.', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'A period with uncategorised business transactions cannot be locked; the response surfaces the count.', + 'Locking is reversible until /close. The unlock endpoint is not in v1; use the dashboard.', + ], + example: { + response: { + data: { id: 'a8f1…', locked_at: '2026-05-12T14:00:00Z', is_closed: false }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'bookkeeping:write', + risk: 'high', + idempotent: true, + reversible: true, + dryRunSupported: false, + response: { success: PeriodLockedResponse }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'fiscal-periods.lock', + 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: 'fiscal_period id must be a UUID.' }, + }) + } + try { + const updated = await lockPeriod(ctx.supabase, ctx.companyId!, ctx.userId, idParse.data) + return ok( + { + id: updated.id, + locked_at: updated.locked_at!, + is_closed: updated.is_closed, + }, + { requestId: ctx.requestId }, + ) + } catch (err) { + const msg = err instanceof Error ? err.message : 'unknown' + ctx.log.warn('fiscal-periods.lock refused', { fiscalPeriodId: idParse.data, reason: msg }) + // Map known throw messages to structured codes + if (msg.includes('not found')) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'fiscal_period' }, + }) + } + if (msg.includes('already closed') || msg.includes('already locked')) { + return v1ErrorResponseFromCode('CONFLICT', ctx.log, { + requestId: ctx.requestId, + details: { reason: msg }, + }) + } + // lockPeriod's uncategorised-transactions error message is in Swedish + // ("affärstransaktion(er) saknar bokföring"). Only map TO that code + // when the message actually looks like that path — otherwise an + // infra error (DB timeout, network) would loop the agent through + // pointless remediation. + if (msg.includes('saknar bokföring') || msg.toLowerCase().includes('uncategorised')) { + return v1ErrorResponseFromCode('PERIOD_HAS_UNBOOKED_TRANSACTIONS', ctx.log, { + requestId: ctx.requestId, + details: { reason: msg }, + }) + } + ctx.log.error('fiscal-periods.lock unexpected error', err as Error, { fiscalPeriodId: idParse.data }) + return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { reason: msg }, + }) + } + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route.ts new file mode 100644 index 00000000..74711f9b --- /dev/null +++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route.ts @@ -0,0 +1,161 @@ +/** + * POST /api/v1/companies/{companyId}/fiscal-periods/{id}/opening-balances + * + * Generates the opening-balance verifikation for the next period from the + * closed period's trial balance (BAS class 1–2 accounts with non-zero + * closing balance). Wraps lib/core/bookkeeping/year-end-service.generateOpeningBalances. + * Synchronous. + * + * URL param `id` is the CLOSED period; body field next_period_id is the + * target where the IB entry lands. + */ + +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 { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period' +import { generateOpeningBalances } from '@/lib/core/bookkeeping/year-end-service' + +const Body = z.object({ next_period_id: z.string().uuid() }).strict() + +const OpeningBalancesResponse = z.object({ + opening_entry_id: z.string().uuid(), + voucher_series: z.string(), + voucher_number: z.number().int(), + next_period_id: z.string().uuid(), +}) + +registerEndpoint({ + operation: 'fiscal-periods.opening-balances', + method: 'POST', + path: '/api/v1/companies/:companyId/fiscal-periods/:id/opening-balances', + summary: 'Generate opening-balance verifikation for the next fiscal period.', + description: + 'Reads the closed period\'s trial balance, filters to BAS class 1–2 accounts with non-zero closing balance, and posts an opening verifikation (status=posted) onto the next_period_id. Sync. The path id is the CLOSED period; body.next_period_id is the target.', + useWhen: + 'After /year-end + /close on a period, generate the IB into the next period so the new year starts with the correct balance sheet.', + doNotUseFor: + 'Posting opening balances on a manually-edited basis (use POST /journal-entries with source_type=manual). Re-running on the same target period (will produce duplicate IB entries).', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'next_period_id must reference the SAME company and must NOT already have an IB entry. The engine throws if it does.', + 'Only class 1 (assets) and 2 (equity/liabilities) flow into the IB; class 3-8 are zeroed by the closing entry.', + ], + example: { + request: { next_period_id: '7b3a…' }, + response: { + data: { opening_entry_id: '4d2a…', voucher_series: 'A', voucher_number: 1, next_period_id: '7b3a…' }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'bookkeeping:write', + risk: 'high', + idempotent: true, + reversible: true, + dryRunSupported: false, + request: { body: Body }, + response: { success: OpeningBalancesResponse }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'fiscal-periods.opening-balances', + async (request, ctx, params) => { + const { id: closedPeriodId } = await params.params + const idParse = z.string().uuid().safeParse(closedPeriodId) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'closed_period id must be a UUID.' }, + }) + } + + 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 = Body.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 })) }, + }) + } + + // Ownership pre-check on BOTH ids: the closed period (URL) and the next + // period (body). The wrapper has already verified the user's membership + // in companyId, but the period ids themselves come from caller input + // and need to be confirmed to belong to that company before the engine + // call. + if (!(await ownsFiscalPeriod(ctx.supabase, ctx.companyId!, idParse.data))) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'fiscal_period', field: 'id' }, + }) + } + if (!(await ownsFiscalPeriod(ctx.supabase, ctx.companyId!, parsed.data.next_period_id))) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'fiscal_period', field: 'next_period_id' }, + }) + } + + // Duplicate IB detection. `executeYearEndClosing` generates the opening + // balance entry as part of its own flow (see YearEndResult.openingBalance- + // Entry on the year-end route's result mapping). If a caller separately + // hits this endpoint after year-end ran, the engine would silently post + // a SECOND IB into the next period, doubling the equity. Reject up front + // with CONFLICT so the caller can inspect what's already there. + const { count: existingIbCount } = await ctx.supabase + .from('journal_entries') + .select('id', { count: 'exact', head: true }) + .eq('company_id', ctx.companyId!) + .eq('fiscal_period_id', parsed.data.next_period_id) + .eq('source_type', 'opening_balance') + .neq('status', 'cancelled') + if ((existingIbCount ?? 0) > 0) { + return v1ErrorResponseFromCode('CONFLICT', ctx.log, { + requestId: ctx.requestId, + details: { + reason: 'opening_balance_already_posted', + next_period_id: parsed.data.next_period_id, + remediation: + '/year-end already generates the opening balance entry. If you ran /year-end first, no further call is needed. Inspect existing IB via GET /journal-entries?fiscal_period_id={next_period_id}&source_type=opening_balance.', + }, + }) + } + + try { + const entry = await generateOpeningBalances( + ctx.supabase, ctx.companyId!, ctx.userId, + idParse.data, parsed.data.next_period_id, + ) + return ok( + { + opening_entry_id: entry.id, + voucher_series: entry.voucher_series, + voucher_number: entry.voucher_number, + next_period_id: parsed.data.next_period_id, + }, + { requestId: ctx.requestId }, + ) + } catch (err) { + const msg = err instanceof Error ? err.message : 'unknown' + ctx.log.warn('opening-balances refused', { reason: msg }) + if (msg.includes('not found')) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, details: { resource: 'fiscal_period' }, + }) + } + return v1ErrorResponseFromCode('BOOKKEEPING_DATABASE_ERROR', ctx.log, { + requestId: ctx.requestId, details: { reason: msg, step: 'opening_balances' }, + }) + } + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/fiscal-periods/[id]/year-end/route.ts b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/year-end/route.ts new file mode 100644 index 00000000..5cecf192 --- /dev/null +++ b/app/api/v1/companies/[companyId]/fiscal-periods/[id]/year-end/route.ts @@ -0,0 +1,153 @@ +/** + * POST /api/v1/companies/{companyId}/fiscal-periods/{id}/year-end + * + * Executes year-end closing for a fiscal period: runs currency revaluation, + * posts the closing entry (zeroes class 3-8 onto årets resultat — 2099 for AB, eget kapital range 2010-2019 for EF). + * Wraps lib/core/bookkeeping/year-end-service.executeYearEndClosing. + * + * Records an operation row and returns 202 + operation_id so callers can + * subscribe to operation.completed (Phase 6 webhook) or poll + * GET /v1/operations/{id}. The work itself runs synchronously inside this + * request (typical year-end is <30s); a future Vercel cron worker can + * dispatch it out-of-band by changing the initialStatus to 'queued' in + * startOperation. + */ + +import { z } from 'zod' +import { accepted } from '@/lib/api/v1/response' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period' +import { startOperation, completeOperation, failOperation } from '@/lib/api/v1/operations' +import { executeYearEndClosing } from '@/lib/core/bookkeeping/year-end-service' + +const YearEndAcceptedResponse = z.object({ + operation_id: z.string().uuid(), + type: z.literal('fiscal_periods.year_end'), + status: z.enum(['queued', 'running', 'succeeded', 'failed']), + poll_url: z.string(), + webhook_event: z.literal('operation.completed'), +}) + +registerEndpoint({ + operation: 'fiscal-periods.year-end', + method: 'POST', + path: '/api/v1/companies/:companyId/fiscal-periods/:id/year-end', + summary: 'Execute year-end closing (currency revaluation + closing entry).', + description: + 'Async-operation endpoint. Runs the year-end closing flow: currency revaluation (FX gains/losses to 3960/7960), then posts the closing entry that zeroes class 3-8 onto årets resultat (2099 for AB, the relevant eget-kapital account in the 2010-2019 range for enskild firma — the engine resolves which based on company.entity_type). Returns 202 with operation_id; subscribe to operation.completed or poll /v1/operations/{id}.', + useWhen: + 'After /lock and a passing /compliance/check?type=year_end_readiness, you want to run the closing entry. This is step 2 of the lock → year-end → close flow.', + doNotUseFor: + 'Re-running year-end (per-period idempotent — fails if closing_entry_id is already set). Closing the period (use /close after year-end succeeds).', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'Period must pass year_end_readiness checks (no drafts, no unexplained voucher gaps, trial balance balanced). The engine re-validates and aborts if not.', + 'Closing entry is itself a verifikation (posted) — the period must NOT already be closed.', + ], + example: { + response: { + data: { + operation_id: '0e9c…', type: 'fiscal_periods.year_end', + status: 'succeeded', + poll_url: '/api/v1/operations/0e9c…', + webhook_event: 'operation.completed', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'bookkeeping:write', + risk: 'high', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: YearEndAcceptedResponse }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'fiscal-periods.year-end', + 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: 'fiscal_period id must be a UUID.' }, + }) + } + const fiscalPeriodId = idParse.data + + // Ownership pre-check on the URL period — fail fast before recording + // an operation row for a period the caller doesn't own. + if (!(await ownsFiscalPeriod(ctx.supabase, ctx.companyId!, fiscalPeriodId))) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, details: { resource: 'fiscal_period' }, + }) + } + + // Wrap startOperation in its own try/catch — same rationale as + // currency-revaluation. A DB-unreachable failure here must NOT escape + // as an unstructured 500. + let operationId: string + try { + const started = await startOperation( + ctx.supabase, + { + companyId: ctx.companyId!, + userId: ctx.userId, + operationType: 'fiscal_periods.year_end', + params: { fiscal_period_id: fiscalPeriodId }, + initialStatus: 'running', + }, + ctx.log, + ) + operationId = started.id + } catch (err) { + ctx.log.error('startOperation failed for year-end', err as Error, { fiscalPeriodId }) + return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { step: 'operation_record_create', reason: (err as Error).message ?? 'unknown' }, + }) + } + + try { + const result = await executeYearEndClosing( + ctx.supabase, + ctx.companyId!, + ctx.userId, + fiscalPeriodId, + ) + await completeOperation( + ctx.supabase, + { + id: operationId, + result: { + closing_entry_id: result.closingEntry?.id ?? null, + revaluation_entry_id: result.revaluationEntry?.id ?? null, + opening_balance_entry_id: result.openingBalanceEntry?.id ?? null, + next_period_id: result.nextPeriod?.id ?? null, + }, + }, + ctx.log, + ) + return accepted(operationId, 'fiscal_periods.year_end', { requestId: ctx.requestId }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'unknown' + ctx.log.error('fiscal-periods.year-end failed', err as Error, { fiscalPeriodId, operationId }) + await failOperation( + ctx.supabase, + { + id: operationId, + error: { code: 'YEAR_END_FAILED', message: msg }, + }, + ctx.log, + ) + // We've already recorded the failure on the operation row; return 202 + // so the caller polls the operation for the structured failure rather + // than getting a different shape via direct error envelope. + return accepted(operationId, 'fiscal_periods.year_end', { requestId: ctx.requestId }) + } + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/journal-entries/[id]/commit/route.ts b/app/api/v1/companies/[companyId]/journal-entries/[id]/commit/route.ts new file mode 100644 index 00000000..bfbe46f0 --- /dev/null +++ b/app/api/v1/companies/[companyId]/journal-entries/[id]/commit/route.ts @@ -0,0 +1,146 @@ +/** + * POST /api/v1/companies/{companyId}/journal-entries/{id}/commit + * + * Commits a draft journal entry: assigns the next voucher_number from the + * series atomically via the `commit_journal_entry` RPC and flips status to + * 'posted'. The RPC is a single Postgres transaction — if the balance + * trigger or any other constraint rejects, the sequence does NOT advance + * (no löpnummer gap per BFL 5 kap 7 §). + * + * Idempotent (mandatory Idempotency-Key). Dry-runnable (the dry-run reports + * the would-be voucher_number from `get_next_voucher_number` without + * advancing the sequence). + */ + +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 { commitEntry, getNextVoucherNumber } from '@/lib/bookkeeping/engine' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' + +const JE_RESPONSE_COLUMNS = + 'id, fiscal_period_id, voucher_series, voucher_number, entry_date, description, status, source_type, source_id, created_at, updated_at' + +const JournalEntryCommitted = z.object({ + id: z.string().uuid(), + voucher_series: z.string(), + voucher_number: z.number().int(), + status: z.literal('posted'), + entry_date: z.string(), +}) + +registerEndpoint({ + operation: 'journal-entries.commit', + method: 'POST', + path: '/api/v1/companies/:companyId/journal-entries/:id/commit', + summary: 'Commit a draft journal entry.', + description: + 'Atomically advances the voucher series and flips the draft to posted. The voucher_number is the smallest integer not yet used in (fiscal_period_id, voucher_series); a failed commit does NOT burn the number.', + useWhen: + 'You created a draft via POST /journal-entries and now want to post it to the books. After commit the entry is immutable per BFL 5 kap 2 §; corrections require /reverse or /correct.', + doNotUseFor: + 'Re-committing an already-posted entry (returns 409). Committing across companies — the URL companyId must match the draft\'s company.', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'Posted entries cannot be edited. Plan the lines carefully or call /correct after commit if you need to change them.', + 'Voucher numbers are sequential within (fiscal_period_id, voucher_series). A commit failure (e.g. period locked between draft creation and commit) does not advance the sequence.', + ], + example: { + response: { + data: { id: '0e9c…', voucher_series: 'A', voucher_number: 143, status: 'posted', entry_date: '2026-05-12' }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'bookkeeping:write', + risk: 'high', + idempotent: true, + reversible: true, + dryRunSupported: true, + response: { success: JournalEntryCommitted }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'journal-entries.commit', + 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: 'Journal entry id must be a UUID.' }, + }) + } + const entryId = idParse.data + + // Pre-flight: confirm the draft exists, status='draft', and is in this company. + const { data: existing, error: fetchErr } = await ctx.supabase + .from('journal_entries') + .select('id, status, fiscal_period_id, voucher_series, entry_date') + .eq('company_id', ctx.companyId!) + .eq('id', entryId) + .maybeSingle() + + if (fetchErr) return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + if (!existing) { + return v1ErrorResponseFromCode('JOURNAL_ENTRY_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + const typed = existing as { id: string; status: string; fiscal_period_id: string; voucher_series: string; entry_date: string } + if (typed.status !== 'draft') { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'status', message: 'Only draft entries can be committed.', current_status: typed.status }, + }) + } + + if (ctx.dryRun) { + // Report the next voucher number WITHOUT advancing the sequence. The + // engine helper `getNextVoucherNumber` is a peek + increment; for a + // true dry-run we'd want a non-advancing peek. Project convention: the + // dry-run reports the PROJECTED number, with the caveat that a + // concurrent commit could advance the sequence between dry-run and + // commit — same caveat the dry-run.ts substrate documents. + const projectedNumber = await getNextVoucherNumber( + ctx.supabase, + ctx.companyId!, + typed.fiscal_period_id, + typed.voucher_series ?? 'A', + ) + return dryRunPreview( + { + id: typed.id, + status: 'posted' as const, + voucher_series: typed.voucher_series ?? 'A', + voucher_number_assigned_on_commit: projectedNumber, + entry_date: typed.entry_date, + would_advance_sequence_by: 1, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + try { + const committed = await commitEntry(ctx.supabase, ctx.companyId!, ctx.userId, entryId) + // Refetch the projection-only columns to keep the response shape tight. + const { data } = await ctx.supabase + .from('journal_entries') + .select(JE_RESPONSE_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('id', entryId) + .maybeSingle() + return ok(data ?? committed, { requestId: ctx.requestId }) + } catch (err) { + if (isBookkeepingError(err)) { + return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId }) + } + ctx.log.error('journal-entries.commit failed', err as Error, { entryId }) + return v1ErrorResponseFromCode('BOOKKEEPING_DATABASE_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { step: 'commit' }, + }) + } + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts b/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts new file mode 100644 index 00000000..85837014 --- /dev/null +++ b/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts @@ -0,0 +1,205 @@ +/** + * POST /api/v1/companies/{companyId}/journal-entries/{id}/correct + * + * 3-step correction flow per Bokföringslagen (BFL 5 kap 5 §): the original + * stays posted, a storno reversal nullifies it, and a corrected entry is + * posted with the new lines. All three remain in the verifikationsserie, + * linked via reverses_id, reversed_by_id, and correction_of_id. + * + * Body: `{ lines: [...] }` — the new balanced lines. The corrected entry + * inherits entry_date, fiscal_period_id, description, and voucher_series + * from the original. + * + * Idempotent (mandatory Idempotency-Key). 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 { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { checkPeriodLock } from '@/lib/api/v1/check-period-lock' +import { CorrectJournalEntrySchema } from '@/lib/api/schemas' +import { validateBalance } from '@/lib/bookkeeping/engine' +import { correctEntry } from '@/lib/core/bookkeeping/storno-service' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' + +const JournalEntryCorrected = z.object({ + reversal_id: z.string().uuid(), + corrected_id: z.string().uuid(), + original_id: z.string().uuid(), + voucher_series: z.string(), + reversal_voucher_number: z.number().int(), + corrected_voucher_number: z.number().int(), +}) + +registerEndpoint({ + operation: 'journal-entries.correct', + method: 'POST', + path: '/api/v1/companies/:companyId/journal-entries/:id/correct', + summary: 'Correct a posted journal entry (BFL 5:5 storno-then-replace).', + description: + 'Per Bokföringslagen 5 kap 5 §, posted entries cannot be modified. This endpoint creates the canonical correction trail: a storno reversing the original, then a new entry with the corrected lines. All three are visible in the verifikationsserie and linked via reverses_id / reversed_by_id / correction_of_id. Idempotent. Dry-runnable.', + useWhen: + 'You need to amend a posted verifikation. Use this rather than /reverse when the entry is being REPLACED with new lines — /reverse just nullifies.', + doNotUseFor: + 'Drafts (no voucher_number — cancel via dashboard). Already-corrected entries (the chain only supports one correction; correct the latest in the chain).', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'The new lines must balance. JOURNAL_ENTRY_NOT_BALANCED if not.', + 'The original\'s entry_date and fiscal_period_id are inherited. If the original\'s period has been locked since posting, the call returns PERIOD_LOCKED.', + 'Three voucher numbers are advanced in this call: the original (already burned), the reversal, and the corrected. The series stays unbroken.', + ], + example: { + request: { + lines: [ + { account_number: '6570', debit_amount: 75, credit_amount: 0, line_description: 'Bankavgift (rättad)' }, + { account_number: '1930', debit_amount: 0, credit_amount: 75, line_description: 'Företagskonto' }, + ], + }, + response: { + data: { + reversal_id: '4d2a…', + corrected_id: '7b3a…', + original_id: '0e9c…', + voucher_series: 'A', + reversal_voucher_number: 144, + corrected_voucher_number: 145, + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'bookkeeping:write', + risk: 'high', + idempotent: true, + reversible: false, + dryRunSupported: true, + request: { body: CorrectJournalEntrySchema }, + response: { success: JournalEntryCorrected }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'journal-entries.correct', + 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: 'Journal entry id must be a UUID.' }, + }) + } + const entryId = 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 = CorrectJournalEntrySchema.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 { lines } = parsed.data + + const balance = validateBalance(lines) + if (!balance.valid) { + return v1ErrorResponseFromCode('JOURNAL_ENTRY_NOT_BALANCED', ctx.log, { + requestId: ctx.requestId, + details: { total_debit: balance.totalDebit, total_credit: balance.totalCredit }, + }) + } + + // Pre-flight: confirm the original is posted (storno-service throws + // CANNOT_CORRECT_NON_POSTED otherwise but we want the structured envelope). + const { data: original, error: fetchErr } = await ctx.supabase + .from('journal_entries') + .select('id, status, entry_date, voucher_series') + .eq('company_id', ctx.companyId!) + .eq('id', entryId) + .maybeSingle() + + if (fetchErr) return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + if (!original) { + return v1ErrorResponseFromCode('JOURNAL_ENTRY_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + const typed = original as { id: string; status: string; entry_date: string; voucher_series: string } + if (typed.status !== 'posted') { + return v1ErrorResponseFromCode('CANNOT_CORRECT_NON_POSTED', ctx.log, { + requestId: ctx.requestId, + details: { current_status: typed.status }, + }) + } + + // Period-lock pre-check on the INHERITED entry_date. /reverse already has + // this guard against its `reversal_date`; /correct must match because + // both the storno and the corrected entry land on typed.entry_date and + // either fails the engine if the period is locked. Returning the + // structured PERIOD_LOCKED here beats letting the engine throw a Swedish + // string that falls through to BOOKKEEPING_DATABASE_ERROR. + const lockVerdict = await checkPeriodLock(ctx.supabase, ctx.companyId!, typed.entry_date) + if (lockVerdict.locked) { + return v1ErrorResponseFromCode('PERIOD_LOCKED', ctx.log, { + requestId: ctx.requestId, + details: { + reason: lockVerdict.reason, + fiscal_period_id: lockVerdict.fiscal_period_id, + entry_date: typed.entry_date, + }, + }) + } + + if (ctx.dryRun) { + return dryRunPreview( + { + original_id: entryId, + would_create_reversal: true, + would_create_corrected: true, + voucher_series: typed.voucher_series, + inherited_entry_date: typed.entry_date, + new_lines_balance: { debit: balance.totalDebit, credit: balance.totalCredit }, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + try { + const { reversal, corrected } = await correctEntry( + ctx.supabase, + ctx.companyId!, + ctx.userId, + entryId, + lines, + ) + return ok( + { + reversal_id: reversal.id, + corrected_id: corrected.id, + original_id: entryId, + voucher_series: corrected.voucher_series, + reversal_voucher_number: reversal.voucher_number, + corrected_voucher_number: corrected.voucher_number, + }, + { requestId: ctx.requestId }, + ) + } catch (err) { + if (isBookkeepingError(err)) { + return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId }) + } + ctx.log.error('journal-entries.correct failed', err as Error, { entryId }) + return v1ErrorResponseFromCode('BOOKKEEPING_DATABASE_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { step: 'correct' }, + }) + } + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/journal-entries/[id]/reverse/route.ts b/app/api/v1/companies/[companyId]/journal-entries/[id]/reverse/route.ts new file mode 100644 index 00000000..9c24e2d2 --- /dev/null +++ b/app/api/v1/companies/[companyId]/journal-entries/[id]/reverse/route.ts @@ -0,0 +1,184 @@ +/** + * POST /api/v1/companies/{companyId}/journal-entries/{id}/reverse + * + * Storno: posts a reversing journal entry that nullifies the original. + * The original stays in place (posted entries are immutable per BFL 5 kap 2 §); + * the reversal carries `reverses_id` back to it and the original is annotated + * with `reversed_by_id`. Both entries remain visible in the verifikationsserie. + * + * Optional body: `{ reversal_date?: ISO date }`. Defaults to today. + * + * Idempotent (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 { checkPeriodLock } from '@/lib/api/v1/check-period-lock' +import { reverseEntry } from '@/lib/bookkeeping/engine' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' + +const ReverseRequest = z + .object({ + reversal_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'reversal_date must be ISO YYYY-MM-DD').optional(), + }) + .strict() + +const JournalEntryReversed = z.object({ + reversal_id: z.string().uuid(), + original_id: z.string().uuid(), + voucher_series: z.string(), + voucher_number: z.number().int(), + entry_date: z.string(), + status: z.literal('posted'), +}) + +registerEndpoint({ + operation: 'journal-entries.reverse', + method: 'POST', + path: '/api/v1/companies/:companyId/journal-entries/:id/reverse', + summary: 'Storno a posted journal entry.', + description: + 'Creates a reversing journal entry that nullifies the original. The original remains posted and visible — the reversal links via reverses_id and the original is annotated reversed_by_id. The reversal carries its own voucher_number in the same series so the löpnummer chain stays unbroken (BFL 5 kap 5–7 §§).', + useWhen: + 'A posted entry needs to be cancelled and there is no replacement coming — e.g. a duplicate booking, an entry posted to the wrong period. Use /correct instead when you need to replace the entry with corrected lines.', + doNotUseFor: + 'Cancelling a draft (drafts have no voucher_number; cancel via the dashboard). Reversing an already-reversed entry (returns ENTRY_ALREADY_REVERSED).', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'reversal_date defaults to today; the reversal is posted in the fiscal period covering that date. If today\'s period is locked the call returns PERIOD_LOCKED.', + 'You cannot reverse a draft (status must be posted). Use /correct after commit if the original needs replacing.', + ], + example: { + request: { reversal_date: '2026-05-13' }, + response: { + data: { + reversal_id: '4d2a…', original_id: '0e9c…', + voucher_series: 'A', voucher_number: 144, entry_date: '2026-05-13', status: 'posted', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'bookkeeping:write', + risk: 'high', + idempotent: true, + reversible: false, + dryRunSupported: true, + request: { body: ReverseRequest }, + response: { success: JournalEntryReversed }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'journal-entries.reverse', + 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: 'Journal entry id must be a UUID.' }, + }) + } + const entryId = idParse.data + + let bodyReversalDate: string | undefined + let rawBody: unknown = null + try { + const text = await request.text() + if (text.trim()) rawBody = JSON.parse(text) + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + if (rawBody) { + const parsed = ReverseRequest.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 })) }, + }) + } + bodyReversalDate = parsed.data.reversal_date + } + + const today = new Date().toISOString().split('T')[0] + const reversalDate = bodyReversalDate || today + + // Period-lock on the reversal date. Engine + DB trigger are still + // authoritative; this gives a structured error instead of a 500. + const lockVerdict = await checkPeriodLock(ctx.supabase, ctx.companyId!, reversalDate) + if (lockVerdict.locked) { + return v1ErrorResponseFromCode('PERIOD_LOCKED', ctx.log, { + requestId: ctx.requestId, + details: { reason: lockVerdict.reason, fiscal_period_id: lockVerdict.fiscal_period_id, reversal_date: reversalDate }, + }) + } + + // Pre-flight: confirm the original exists, is posted, and not already reversed. + const { data: original, error: fetchErr } = await ctx.supabase + .from('journal_entries') + .select('id, status, reversed_by_id, voucher_series, voucher_number') + .eq('company_id', ctx.companyId!) + .eq('id', entryId) + .maybeSingle() + + if (fetchErr) return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + if (!original) { + return v1ErrorResponseFromCode('JOURNAL_ENTRY_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + const typed = original as { id: string; status: string; reversed_by_id: string | null } + if (typed.status !== 'posted') { + return v1ErrorResponseFromCode('CANNOT_REVERSE_NON_POSTED', ctx.log, { + requestId: ctx.requestId, + details: { current_status: typed.status }, + }) + } + if (typed.reversed_by_id) { + return v1ErrorResponseFromCode('ENTRY_ALREADY_REVERSED', ctx.log, { + requestId: ctx.requestId, + details: { existing_reversal_id: typed.reversed_by_id }, + }) + } + + if (ctx.dryRun) { + return dryRunPreview( + { + original_id: entryId, + reversal_date: reversalDate, + would_create_reversal_with_status: 'posted', + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + try { + const reversal = await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, entryId, reversalDate) + return ok( + { + reversal_id: reversal.id, + original_id: entryId, + voucher_series: reversal.voucher_series, + voucher_number: reversal.voucher_number, + entry_date: reversal.entry_date, + status: 'posted' as const, + }, + { requestId: ctx.requestId }, + ) + } catch (err) { + if (isBookkeepingError(err)) { + return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId }) + } + ctx.log.error('journal-entries.reverse failed', err as Error, { entryId }) + return v1ErrorResponseFromCode('BOOKKEEPING_DATABASE_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { step: 'reverse' }, + }) + } + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/journal-entries/[id]/route.ts b/app/api/v1/companies/[companyId]/journal-entries/[id]/route.ts new file mode 100644 index 00000000..46e7d9fd --- /dev/null +++ b/app/api/v1/companies/[companyId]/journal-entries/[id]/route.ts @@ -0,0 +1,117 @@ +/** + * GET /api/v1/companies/{companyId}/journal-entries/{id} + * + * Returns the full verifikation including lines, source links + * (reverses_id, reversed_by_id, correction_of_id), and dimensions. + */ + +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 JE_LINE_COLUMNS = + 'id, account_number, debit_amount, credit_amount, line_description, currency, amount_in_currency, exchange_rate, tax_code, cost_center, project, sort_order' +const JE_DETAIL_COLUMNS = + 'id, fiscal_period_id, voucher_series, voucher_number, entry_date, description, status, source_type, source_id, notes, reverses_id, reversed_by_id, correction_of_id, created_at, updated_at' + +const JournalEntryLine = z.object({ + id: z.string().uuid(), + account_number: z.string(), + debit_amount: z.number(), + credit_amount: z.number(), + line_description: z.string().nullable(), + currency: z.string().nullable(), + amount_in_currency: z.number().nullable(), + exchange_rate: z.number().nullable(), + tax_code: z.string().nullable(), + cost_center: z.string().nullable(), + project: z.string().nullable(), + sort_order: z.number().int(), +}) + +const JournalEntryDetail = z.object({ + id: z.string().uuid(), + fiscal_period_id: z.string().uuid(), + voucher_series: z.string(), + voucher_number: z.number().int(), + entry_date: z.string(), + description: z.string(), + status: z.enum(['draft', 'posted', 'cancelled']), + source_type: z.string(), + source_id: z.string().nullable(), + notes: z.string().nullable(), + reverses_id: z.string().uuid().nullable(), + reversed_by_id: z.string().uuid().nullable(), + correction_of_id: z.string().uuid().nullable(), + lines: z.array(JournalEntryLine), + created_at: z.string(), + updated_at: z.string(), +}) + +registerEndpoint({ + operation: 'journal-entries.get', + method: 'GET', + path: '/api/v1/companies/:companyId/journal-entries/:id', + summary: 'Retrieve a single verifikation by id.', + description: + 'Returns the full journal entry including all lines, dimensions, and the storno chain (reverses_id, reversed_by_id, correction_of_id).', + useWhen: + 'You need the full verifikation for audit / reconciliation, or to display the line-by-line breakdown.', + doNotUseFor: + 'Listing entries (use the list endpoint with filters).', + pitfalls: [ + 'Cancelled drafts are returned (no filter on status here); inspect status before assuming the entry is posted.', + 'Lines are sorted by sort_order; the order matters for display but not for accounting (the sum across lines is the meaningful quantity).', + ], + example: { + response: { + data: { + id: '0e9c…', + voucher_series: 'A', + voucher_number: 142, + entry_date: '2026-05-12', + status: 'posted', + lines: [ + { account_number: '6570', debit_amount: 50, credit_amount: 0, sort_order: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 50, sort_order: 1 }, + ], + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'reports:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: JournalEntryDetail }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'journal-entries.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: 'Journal entry id must be a UUID.' }, + }) + } + + const { data, error } = await ctx.supabase + .from('journal_entries') + .select(`${JE_DETAIL_COLUMNS}, lines:journal_entry_lines(${JE_LINE_COLUMNS})`) + .eq('company_id', ctx.companyId!) + .eq('id', idParse.data) + .maybeSingle() + + if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + if (!data) { + return v1ErrorResponseFromCode('JOURNAL_ENTRY_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + return ok(data, { requestId: ctx.requestId }) + }, +) diff --git a/app/api/v1/companies/[companyId]/journal-entries/batch-create/route.ts b/app/api/v1/companies/[companyId]/journal-entries/batch-create/route.ts new file mode 100644 index 00000000..89460edd --- /dev/null +++ b/app/api/v1/companies/[companyId]/journal-entries/batch-create/route.ts @@ -0,0 +1,218 @@ +/** + * POST /api/v1/companies/{companyId}/journal-entries/batch-create + * + * Bulk-create draft journal entries (up to 50 per call). Each item is + * processed independently — per-item failures don't roll back successes + * (partial-success semantics, matching /invoices/bulk-create and + * /suppliers/bulk-create). + * + * The endpoint creates DRAFTS only — committing them is a separate per-id + * call. This keeps batch behaviour symmetric with the single POST and makes + * the failure modes simpler (no half-committed batches). + * + * Idempotent (mandatory Idempotency-Key). Dry-runnable. + */ + +import { z } from 'zod' +import type { SupabaseClient } from '@supabase/supabase-js' +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 { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period' +import { CreateJournalEntrySchema } from '@/lib/api/schemas' +import { createDraftEntry } from '@/lib/bookkeeping/engine' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' +import type { Logger } from '@/lib/logger' + +const BulkRequest = z.object({ + journal_entries: z.array(CreateJournalEntrySchema).min(1).max(50), + all_or_nothing: z.boolean().optional().default(false), +}) + +const BulkResultItem = z.object({ + ok: z.boolean(), + request_index: z.number().int().nonnegative(), + data: z.unknown().optional(), + error: z.object({ code: z.string(), message: z.string(), details: z.unknown().optional() }).optional(), +}) + +const BulkResponse = z.object({ + results: z.array(BulkResultItem), + summary: z.object({ + total: z.number().int(), + succeeded: z.number().int(), + failed: z.number().int(), + }), +}) + +registerEndpoint({ + operation: 'journal-entries.batch-create', + method: 'POST', + path: '/api/v1/companies/:companyId/journal-entries/batch-create', + summary: 'Create up to 50 draft journal entries (partial-success).', + description: + 'Bulk-create endpoint mirroring /invoices/bulk-create and /suppliers/bulk-create. Each entry is validated and inserted independently — per-item failures do not roll back items that succeeded. Returns DRAFTS only; commit each separately. Idempotent over the whole batch. Dry-runnable.', + useWhen: + 'You\'re replaying historical bookkeeping from another system, or batching a set of manual verifikationer from a spreadsheet. Use dry-run first to validate the batch.', + doNotUseFor: + 'Committing posted entries — use POST /{id}/commit per entry. Transactional all-or-nothing imports — passing all_or_nothing: true returns 501 NOT_IMPLEMENTED.', + pitfalls: [ + 'Idempotency-Key is mandatory and covers the WHOLE batch.', + 'all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist.', + 'Each entry must balance independently. Per-item JOURNAL_ENTRY_NOT_BALANCED appears in the results array.', + ], + example: { + request: { + journal_entries: [ + { + fiscal_period_id: 'a8f1…', entry_date: '2026-05-12', description: 'Bankavgift', + lines: [ + { account_number: '6570', debit_amount: 50, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 50 }, + ], + }, + ], + }, + response: { + data: { + results: [{ ok: true, request_index: 0, data: { id: '0e9c…', status: 'draft' } }], + summary: { total: 1, succeeded: 1, failed: 0 }, + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'bookkeeping:write', + risk: 'high', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: BulkRequest }, + response: { success: BulkResponse }, +}) + +interface ResultItem { + ok: boolean + request_index: number + data?: unknown + error?: { code: string; message: string; details?: unknown } +} + +async function createOne( + supabase: SupabaseClient, + companyId: string, + userId: string, + index: number, + input: z.infer, + dryRun: boolean, + log: Logger, +): Promise { + if (dryRun) { + return { + ok: true, + request_index: index, + data: { + preview: { + status: 'draft' as const, + voucher_series: input.voucher_series ?? 'A', + voucher_number: 0, + fiscal_period_id: input.fiscal_period_id, + entry_date: input.entry_date, + description: input.description, + lines: input.lines, + }, + }, + } + } + try { + const entry = await createDraftEntry(supabase, companyId, userId, input) + return { + ok: true, + request_index: index, + data: { id: entry.id, status: entry.status, voucher_series: entry.voucher_series, voucher_number: entry.voucher_number }, + } + } catch (err) { + if (isBookkeepingError(err)) { + const e = err as { code?: string; message?: string; details?: unknown } + return { + ok: false, + request_index: index, + error: { + code: e.code ?? 'BOOKKEEPING_DATABASE_ERROR', + message: e.message ?? 'Engine error', + details: e.details, + }, + } + } + log.error('batch-create: createDraftEntry failed', err as Error, { request_index: index }) + return { + ok: false, + request_index: index, + error: { code: 'BOOKKEEPING_DATABASE_ERROR', message: (err as Error).message ?? 'unknown' }, + } + } +} + +export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'journal-entries.batch-create', + 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 = BulkRequest.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: 'all_or_nothing: true is not yet implemented.' }, + }) + } + + // Ownership pre-check on every distinct fiscal_period_id in the batch. + // Bulk endpoints are particularly attractive for cross-tenant probing + // (50 ids per call vs 1) so we batch-verify up front rather than per- + // item. Any unknown id fails the entire batch with a structured error + // — partial-success semantics only apply AFTER ownership is established. + const uniquePeriodIds = Array.from(new Set(body.journal_entries.map((e) => e.fiscal_period_id))) + for (const periodId of uniquePeriodIds) { + if (!(await ownsFiscalPeriod(ctx.supabase, ctx.companyId!, periodId))) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'fiscal_period', field: 'fiscal_period_id', value: periodId }, + }) + } + } + + const results: ResultItem[] = [] + for (let i = 0; i < body.journal_entries.length; i++) { + results.push(await createOne(ctx.supabase, ctx.companyId!, ctx.userId, i, body.journal_entries[i], ctx.dryRun, ctx.log)) + } + const summary = { + total: results.length, + succeeded: results.filter((r) => r.ok).length, + failed: results.filter((r) => !r.ok).length, + } + + ctx.log.info('journal-entries.batch-create completed', { ...summary, dryRun: ctx.dryRun }) + + 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]/journal-entries/route.ts b/app/api/v1/companies/[companyId]/journal-entries/route.ts new file mode 100644 index 00000000..5669898d --- /dev/null +++ b/app/api/v1/companies/[companyId]/journal-entries/route.ts @@ -0,0 +1,361 @@ +/** + * /api/v1/companies/{companyId}/journal-entries — list + create draft. + * + * GET — cursor-paginated list with filters (fiscal_period_id, status, date range). + * Cursor on (entry_date DESC, id DESC). + * POST — create a draft verifikation. Idempotent (mandatory Idempotency-Key). + * Dry-runnable. The draft has no voucher number until you call + * /commit, so a draft that's never committed produces no löpnummer gap + * (BFL 5 kap 6–7 §§). + * + * Strict-mode v1: any engine failure aborts before any state change. The + * `createDraftEntry` engine call is itself atomic (rollbacks the row on + * line-insert failure); the route surface just propagates structured errors. + */ + +import { z } from 'zod' +import { created, paginated } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { + decodeDefaultCursor, + encodeDefaultCursor, + parsePaginationParams, +} from '@/lib/api/v1/pagination' +import { registerEndpoint } 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 { ownsFiscalPeriod } from '@/lib/api/v1/owns-fiscal-period' +import { CreateJournalEntrySchema } from '@/lib/api/schemas' +import { createDraftEntry, validateBalance } from '@/lib/bookkeeping/engine' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' + +const JE_LINE_COLUMNS = + 'id, account_number, debit_amount, credit_amount, line_description, currency, amount_in_currency, exchange_rate, tax_code, cost_center, project, sort_order' +const JE_COLUMNS = + 'id, fiscal_period_id, voucher_series, voucher_number, entry_date, description, status, source_type, source_id, notes, reverses_id, reversed_by_id, correction_of_id, created_at, updated_at' + +const JournalEntryStatus = z.enum(['draft', 'posted', 'cancelled']) + +const JournalEntrySummary = z.object({ + id: z.string().uuid(), + fiscal_period_id: z.string().uuid(), + voucher_series: z.string(), + voucher_number: z.number().int(), + entry_date: z.string(), + description: z.string(), + status: JournalEntryStatus, + source_type: z.string(), + created_at: z.string(), +}) + +const JournalEntriesListResponse = z.object({ journal_entries: z.array(JournalEntrySummary) }) + +const JournalEntryLine = z.object({ + id: z.string().uuid(), + account_number: z.string(), + debit_amount: z.number(), + credit_amount: z.number(), + line_description: z.string().nullable(), + currency: z.string().nullable(), + amount_in_currency: z.number().nullable(), + exchange_rate: z.number().nullable(), + tax_code: z.string().nullable(), + cost_center: z.string().nullable(), + project: z.string().nullable(), +}) + +const JournalEntryDetail = JournalEntrySummary.extend({ + notes: z.string().nullable(), + reverses_id: z.string().uuid().nullable(), + reversed_by_id: z.string().uuid().nullable(), + correction_of_id: z.string().uuid().nullable(), + lines: z.array(JournalEntryLine), +}) + +registerEndpoint({ + operation: 'journal-entries.list', + method: 'GET', + path: '/api/v1/companies/:companyId/journal-entries', + summary: 'List journal entries (verifikationer).', + description: + 'Cursor-paginated list of journal entries. Filters: fiscal_period_id, status, date_from, date_to. Excludes status=cancelled by default; pass status=cancelled to inspect storno-cancelled drafts.', + useWhen: + 'You need to walk the verifikationsserie for a period (audit, SIE export, gap detection) or list recent activity for a UI.', + doNotUseFor: + 'Reading a single verifikation (use GET /{id}). Reading lines without the header (no separate endpoint — they ride in /{id}).', + pitfalls: [ + 'Cancelled drafts are hidden by default. They are NOT a löpnummer gap (no voucher_number is allocated for drafts); the filter is for noise reduction.', + 'voucher_number=0 indicates a draft that has not been committed. Posted entries always have voucher_number > 0.', + ], + example: { + response: { + data: [ + { + id: '0e9c…', + fiscal_period_id: 'a8f1…', + voucher_series: 'A', + voucher_number: 142, + entry_date: '2026-05-12', + description: 'Levfaktura 2026-1234, Office Depot AB (ankomst 42)', + status: 'posted', + source_type: 'supplier_invoice_registered', + created_at: '2026-05-13T15:00:00Z', + }, + ], + meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null }, + }, + }, + scope: 'reports:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: JournalEntriesListResponse }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'journal-entries.list', + async (request, ctx) => { + const url = new URL(request.url) + const { limit, cursor } = parsePaginationParams(url) + const decoded = decodeDefaultCursor(cursor) + + const FiltersSchema = z.object({ + fiscal_period_id: z.string().uuid().optional(), + status: JournalEntryStatus.optional(), + date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + }) + const fr = FiltersSchema.safeParse({ + fiscal_period_id: url.searchParams.get('fiscal_period_id') ?? undefined, + status: url.searchParams.get('status') ?? undefined, + date_from: url.searchParams.get('date_from') ?? undefined, + date_to: url.searchParams.get('date_to') ?? undefined, + }) + if (!fr.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { issues: fr.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) }, + }) + } + const filters = fr.data + + let query = ctx.supabase + .from('journal_entries') + .select(JE_COLUMNS) + .eq('company_id', ctx.companyId!) + .order('entry_date', { ascending: false }) + .order('id', { ascending: false }) + .limit(limit + 1) + + if (filters.fiscal_period_id) query = query.eq('fiscal_period_id', filters.fiscal_period_id) + if (filters.status) { + query = query.eq('status', filters.status) + } else { + query = query.neq('status', 'cancelled') + } + if (filters.date_from) query = query.gte('entry_date', filters.date_from) + if (filters.date_to) query = query.lte('entry_date', filters.date_to) + + if (decoded) { + query = query.or(`entry_date.lt.${decoded.ts},and(entry_date.eq.${decoded.ts},id.lt.${decoded.id})`) + } + + const { data, error } = await query + if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + + type Row = { + id: string + fiscal_period_id: string + voucher_series: string + voucher_number: number + entry_date: string + description: string + status: string + source_type: string + created_at: string + } & Record + + 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.entry_date }) + : null + + return paginated( + trimmed.map((r) => ({ + id: r.id, + fiscal_period_id: r.fiscal_period_id, + voucher_series: r.voucher_series, + voucher_number: r.voucher_number, + entry_date: r.entry_date, + description: r.description, + status: r.status, + source_type: r.source_type, + created_at: r.created_at, + })), + { requestId: ctx.requestId, nextCursor: nextCursor ?? undefined }, + ) + }, +) + +// ────────────────────────────────────────────────────────────────── +// POST — create draft verifikation +// ────────────────────────────────────────────────────────────────── + +registerEndpoint({ + operation: 'journal-entries.create-draft', + method: 'POST', + path: '/api/v1/companies/:companyId/journal-entries', + summary: 'Create a draft journal entry (verifikation).', + description: + 'Creates a draft journal entry via the engine\'s createDraftEntry(). The draft has no voucher_number until /commit is called. Idempotent (mandatory Idempotency-Key). Dry-runnable: a dry-run validates balance + account-chart membership + period date constraints without inserting any row.', + useWhen: + 'You\'re posting an arbitrary verifikation — manual journal entries, accrual reversals, period closing adjustments — outside the invoicing / supplier-invoice / transaction flows.', + doNotUseFor: + 'Bookkeeping flows that have a dedicated endpoint (invoices, supplier-invoices, transactions). Editing an existing posted entry — use /correct instead.', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'Lines must sum to zero (Σ debit = Σ credit). Engine rejects with JOURNAL_ENTRY_NOT_BALANCED on imbalance.', + 'entry_date must fall within fiscal_period_id\'s [period_start, period_end]; otherwise ENTRY_DATE_OUTSIDE_FISCAL_PERIOD.', + 'All account_numbers must be active in the chart_of_accounts; otherwise ACCOUNTS_NOT_IN_CHART.', + 'voucher_series defaults to "A" if omitted. Must be a single uppercase letter.', + 'This creates a DRAFT only — call POST /{id}/commit to assign the voucher_number and post atomically.', + ], + example: { + request: { + fiscal_period_id: 'a8f1…', + entry_date: '2026-05-12', + description: 'Bankavgift maj 2026', + lines: [ + { account_number: '6570', debit_amount: 50, credit_amount: 0, line_description: 'Bankavgift' }, + { account_number: '1930', debit_amount: 0, credit_amount: 50, line_description: 'Företagskonto' }, + ], + }, + response: { + data: { id: '0e9c…', status: 'draft', voucher_series: 'A', voucher_number: 0 }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'bookkeeping:write', + risk: 'high', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: CreateJournalEntrySchema }, + response: { success: JournalEntryDetail }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'journal-entries.create-draft', + 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 = CreateJournalEntrySchema.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 input = parsed.data + + // Ownership pre-check: the caller-supplied fiscal_period_id must belong + // to ctx.companyId. The engine scopes by company_id internally but the + // engine throws a Swedish error string on mismatch; the route returns + // a structured envelope before the engine call. + if (!(await ownsFiscalPeriod(ctx.supabase, ctx.companyId!, input.fiscal_period_id))) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'fiscal_period', field: 'fiscal_period_id' }, + }) + } + + // Balance pre-check — same logic the engine runs, but cheap to fail fast. + const balance = validateBalance(input.lines) + if (!balance.valid) { + return v1ErrorResponseFromCode('JOURNAL_ENTRY_NOT_BALANCED', ctx.log, { + requestId: ctx.requestId, + details: { total_debit: balance.totalDebit, total_credit: balance.totalCredit }, + }) + } + + // Period-lock pre-check — drafts CAN technically be inserted into locked + // periods (no JE-trigger fires until commit), but rejecting up front is + // cleaner UX and avoids leaving an undeletable draft behind. + const lockVerdict = await checkPeriodLock(ctx.supabase, ctx.companyId!, input.entry_date) + if (lockVerdict.locked) { + return v1ErrorResponseFromCode('PERIOD_LOCKED', ctx.log, { + requestId: ctx.requestId, + details: { reason: lockVerdict.reason, fiscal_period_id: lockVerdict.fiscal_period_id }, + }) + } + + if (ctx.dryRun) { + // Dry-run preview: report the balanced lines + would-be header. No row + // is inserted, so the engine's per-line account-id resolution doesn't + // happen — chart-lookup failures will only be reported on live commit. + return dryRunPreview( + { + status: 'draft' as const, + voucher_series: input.voucher_series ?? 'A', + voucher_number: 0, + fiscal_period_id: input.fiscal_period_id, + entry_date: input.entry_date, + description: input.description, + source_type: input.source_type ?? 'manual', + source_id: input.source_id ?? null, + notes: input.notes ?? null, + lines: input.lines.map((l, i) => ({ + sort_order: i, + account_number: l.account_number, + debit_amount: l.debit_amount, + credit_amount: l.credit_amount, + line_description: l.line_description ?? null, + currency: l.currency ?? null, + amount_in_currency: l.amount_in_currency ?? null, + exchange_rate: l.exchange_rate ?? null, + tax_code: l.tax_code ?? null, + cost_center: l.cost_center ?? null, + project: l.project ?? null, + })), + totals: { debit: balance.totalDebit, credit: balance.totalCredit }, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + try { + const entry = await createDraftEntry(ctx.supabase, ctx.companyId!, ctx.userId, input) + // Refetch with lines to return the full detail shape. + const { data: complete } = await ctx.supabase + .from('journal_entries') + .select(`${JE_COLUMNS}, lines:journal_entry_lines(${JE_LINE_COLUMNS})`) + .eq('company_id', ctx.companyId!) + .eq('id', entry.id) + .maybeSingle() + return created(complete ?? entry, { requestId: ctx.requestId }) + } catch (err) { + if (isBookkeepingError(err)) { + return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId }) + } + ctx.log.error('journal-entries.create-draft failed', err as Error) + return v1ErrorResponseFromCode('BOOKKEEPING_DATABASE_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { step: 'create_draft' }, + }) + } + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/voucher-gap-explanations/route.ts b/app/api/v1/companies/[companyId]/voucher-gap-explanations/route.ts new file mode 100644 index 00000000..5f42f1ad --- /dev/null +++ b/app/api/v1/companies/[companyId]/voucher-gap-explanations/route.ts @@ -0,0 +1,163 @@ +/** + * POST /api/v1/companies/{companyId}/voucher-gap-explanations + * + * Document a gap in a verifikationsserie per BFL 5 kap 6-7 §§ (the + * unbroken-löpnummer obligation) — supplemented by BFNAR 2013:2 kap 8 § + * for the systemdokumentation / behandlingshistorik aspect. Voucher + * numbers are sequential within (fiscal_period_id, voucher_series); any + * missing number must have a documented explanation. The gap can be a + * single number (gap_start = gap_end) or a range. + * + * Used by: + * - Migration / import flows that need to claim numbers but can't fill them + * - Audit response when a number was burned by a failed commit attempt + * - Operational recovery after manual reconciliation + * + * Idempotent (mandatory Idempotency-Key). Insert is small — no dry-run helper. + */ + +import { z } from 'zod' +import { created } 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' + +const CreateVoucherGapExplanation = z + .object({ + fiscal_period_id: z.string().uuid(), + voucher_series: z.string().regex(/^[A-Z]$/, 'voucher_series must be a single uppercase letter'), + gap_start: z.number().int().positive(), + gap_end: z.number().int().positive(), + explanation: z.string().min(1).max(2000), + }) + .strict() + .refine((d) => d.gap_end >= d.gap_start, { + message: 'gap_end must be >= gap_start', + path: ['gap_end'], + }) + +const VoucherGapExplanationCreated = z.object({ + id: z.string().uuid(), + fiscal_period_id: z.string().uuid(), + voucher_series: z.string(), + gap_start: z.number().int(), + gap_end: z.number().int(), + explanation: z.string(), + created_at: z.string(), +}) + +registerEndpoint({ + operation: 'voucher-gap-explanations.create', + method: 'POST', + path: '/api/v1/companies/:companyId/voucher-gap-explanations', + summary: 'Document a gap in the verifikationsserie (BFL 5 kap 6-7 §§).', + description: + 'Records an explanation for one or more missing voucher numbers in a series. Required when a number is unaccounted for during audit. Statutory basis: BFL 5 kap 6-7 §§ (verifikationsnummer i löpande följd utan luckor); BFNAR 2013:2 kap 8 § governs the systemdokumentation that surfaces the gap. Idempotent. Dry-runnable.', + useWhen: + 'You\'re responding to a voucher-gap audit finding and need to document the cause. Also used by migration flows that claim numbers without filling them.', + doNotUseFor: + 'Falsifying a series — every gap MUST have a genuine explanation. The dashboard surfaces these for auditor review.', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'gap_end must be >= gap_start; a single-number gap has gap_start = gap_end.', + 'voucher_series is a single uppercase letter (A–Z); the same series + period + numeric range must not already exist.', + ], + example: { + request: { + fiscal_period_id: 'a8f1…', + voucher_series: 'A', + gap_start: 142, + gap_end: 145, + explanation: + 'Migration from previous bookkeeping system on 2026-05-12 — series A148-onwards corresponds to the new gnubok numbering; numbers A142-A145 were assigned in the legacy system to manual paper vouchers archived offline (BFL 7 kap retention applies). Paper vouchers are stored in the company archive under reference 2026-PAPER-Q2.', + }, + response: { + data: { id: '0e9c…', voucher_series: 'A', gap_start: 142, gap_end: 145 }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'bookkeeping:write', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: true, + request: { body: CreateVoucherGapExplanation }, + response: { success: VoucherGapExplanationCreated }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'voucher-gap-explanations.create', + 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 = CreateVoucherGapExplanation.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 + + // Ownership pre-check: the caller-supplied `fiscal_period_id` must belong + // to ctx.companyId. Otherwise an insert would persist a row with + // company_id from the URL pointing at a fiscal_period from another + // company — a broken-link state that confuses every downstream gap- + // detection query. (No cross-tenant data leak per se, but the row is + // garbage.) + const { data: periodCheck } = await ctx.supabase + .from('fiscal_periods') + .select('id') + .eq('id', body.fiscal_period_id) + .eq('company_id', ctx.companyId!) + .maybeSingle() + if (!periodCheck) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'fiscal_period', field: 'fiscal_period_id' }, + }) + } + + if (ctx.dryRun) { + return dryRunPreview( + { + fiscal_period_id: body.fiscal_period_id, + voucher_series: body.voucher_series, + gap_start: body.gap_start, + gap_end: body.gap_end, + explanation: body.explanation, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + const { data, error } = await ctx.supabase + .from('voucher_gap_explanations') + .insert({ + company_id: ctx.companyId!, + user_id: ctx.userId, + fiscal_period_id: body.fiscal_period_id, + voucher_series: body.voucher_series, + gap_start: body.gap_start, + gap_end: body.gap_end, + explanation: body.explanation, + }) + .select('id, fiscal_period_id, voucher_series, gap_start, gap_end, explanation, created_at') + .single() + + if (error) { + ctx.log.error('voucher-gap-explanations insert failed', error) + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + return created(data, { requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/operations/[id]/route.ts b/app/api/v1/operations/[id]/route.ts new file mode 100644 index 00000000..5bbadc4c --- /dev/null +++ b/app/api/v1/operations/[id]/route.ts @@ -0,0 +1,165 @@ +/** + * GET /api/v1/operations/{id} + * + * Polling endpoint for async operations. Returns the current snapshot of + * the operation row including status, progress (if the work is in-flight), + * result (on success), and error (on failure). + * + * The operation_id is global (cross-company in the URL) but every read is + * scoped to the caller's company in `getOperation()` — so two companies' + * UUIDs can never collide into the wrong tenant. The wrapper has already + * validated company membership. + * + * Webhook alternative (Phase 6): subscribe to `operation.completed` instead + * of polling. + */ + +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 { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { getOperation } from '@/lib/api/v1/operations' + +const OperationStatus = z.enum(['queued', 'running', 'succeeded', 'failed', 'cancelled']) + +const OperationDetail = z.object({ + operation_id: z.string().uuid(), + type: z.string(), + status: OperationStatus, + progress: z.record(z.string(), z.unknown()).optional(), + result: z.unknown().nullable(), + error: z + .object({ code: z.string().optional(), message: z.string().optional(), details: z.unknown().optional() }) + .nullable(), + started_at: z.string().nullable(), + completed_at: z.string().nullable(), + poll_url: z.string(), + webhook_event: z.literal('operation.completed'), +}) + +registerEndpoint({ + operation: 'operations.get', + method: 'GET', + path: '/api/v1/operations/:id', + summary: 'Poll a long-running operation by id.', + description: + 'Returns the current snapshot of a v1 async operation: status (queued / running / succeeded / failed / cancelled), progress (jsonb, free-form), result (on success), and error (on failure). The operation_id is returned by the POST endpoints that initiate async work (period close, year-end, currency revaluation, SIE import).', + useWhen: + 'You started an async operation and need to know whether it has finished. Poll every 5–30 seconds; switch to the `operation.completed` webhook for production integrations.', + doNotUseFor: + 'Fetching the resource the operation produced — once status=succeeded, read the result field or call the resource-specific GET endpoint. Cancelling a running operation (no cancel endpoint exists in v1).', + pitfalls: [ + 'Terminal statuses (`succeeded`, `failed`, `cancelled`) are final; the row never transitions out of them.', + 'progress is free-form jsonb; agents should treat it as opaque except for the documented fields `phase` (string), `current` / `total` (numbers for percent calculation).', + 'started_at is null while status=queued (the work has not begun yet); completed_at is null until a terminal status is reached.', + ], + example: { + response: { + data: { + operation_id: '0e9c-…', + type: 'fiscal_periods.year_end', + status: 'succeeded', + progress: { phase: 'committed', current: 142, total: 142 }, + result: { journal_entries_created: 4, opening_balances_set: 138 }, + error: null, + started_at: '2026-05-12T10:01:23Z', + completed_at: '2026-05-12T10:01:48Z', + poll_url: '/api/v1/operations/0e9c-…', + webhook_event: 'operation.completed', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'operations:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: OperationDetail }, +}) + +export const GET = withApiV1<{ params: Promise<{ id: string }> }>( + 'operations.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: 'Operation id must be a UUID.' }, + }) + } + const operationId = idParse.data + + // The operations URL has no /companies/:companyId prefix, so the wrapper + // can't resolve ctx.companyId from a path segment. We fetch by id alone + // (service-role bypasses RLS), then verify the operation's company is one + // this caller belongs to. Two-step lookup keeps the resource id global + // while still hard-scoping reads to the caller's tenancies. + const { data: opRow, error: opErr } = await ctx.supabase + .from('operations') + .select('company_id') + .eq('id', operationId) + .maybeSingle() + + if (opErr) { + ctx.log.error('operations.get fetch failed', opErr as Error, { operationId }) + return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { requestId: ctx.requestId }) + } + if (!opRow) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'operation' }, + }) + } + + const opCompanyId = (opRow as { company_id: string }).company_id + const { data: membership } = await ctx.supabase + .from('company_members') + .select('company_id') + .eq('user_id', ctx.userId) + .eq('company_id', opCompanyId) + .maybeSingle() + + if (!membership) { + // Enumeration hardening — wrong id and cross-tenant id are + // indistinguishable from outside. + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'operation' }, + }) + } + + const row = await getOperation(ctx.supabase, { + id: operationId, + companyId: opCompanyId, + }) + + if (!row) { + // Race between the membership read and the operation read — extremely + // unlikely but defended. + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'operation' }, + }) + } + + return ok( + { + operation_id: row.id, + type: row.operation_type, + status: row.status, + progress: row.progress, + result: row.result, + error: row.error, + started_at: row.started_at, + completed_at: row.completed_at, + poll_url: `/api/v1/operations/${row.id}`, + webhook_event: 'operation.completed', + }, + { requestId: ctx.requestId }, + ) + }, +) diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index a58ca0a9..5f197f0b 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -15,6 +15,28 @@ import '@/app/api/v1/health/route' import '@/app/api/v1/companies/route' +// Phase 4 PR-2 (foundation) — async operations polling endpoint. +import '@/app/api/v1/operations/[id]/route' + +// Phase 4 PR-2 — journal-entries primitives + voucher-gap-explanations. +import '@/app/api/v1/companies/[companyId]/journal-entries/route' +import '@/app/api/v1/companies/[companyId]/journal-entries/[id]/route' +import '@/app/api/v1/companies/[companyId]/journal-entries/[id]/commit/route' +import '@/app/api/v1/companies/[companyId]/journal-entries/[id]/reverse/route' +import '@/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route' +import '@/app/api/v1/companies/[companyId]/journal-entries/batch-create/route' +import '@/app/api/v1/companies/[companyId]/voucher-gap-explanations/route' + +// Phase 4 PR-2 — compliance-check (gnubok's defensible edge). +import '@/app/api/v1/companies/[companyId]/compliance/check/route' + +// Phase 4 PR-2 — fiscal-periods async ops (lock/close/year-end/opening-balances/currency-revaluation). +import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/lock/route' +import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/close/route' +import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/year-end/route' +import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route' +import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route' + // Phase 2 PR-A — invoice + customer reads. import '@/app/api/v1/companies/[companyId]/invoices/route' import '@/app/api/v1/companies/[companyId]/invoices/[id]/route' diff --git a/lib/api/v1/operations.ts b/lib/api/v1/operations.ts new file mode 100644 index 00000000..c6936713 --- /dev/null +++ b/lib/api/v1/operations.ts @@ -0,0 +1,186 @@ +/** + * v1 async-operation lifecycle helpers. + * + * Substrate: the `operations` table (separate from `pending_operations`). + * + * Design: + * - POST handlers that start a long-running job call `startOperation()` + * to insert a row (status='running', started_at=now) and return its id. + * - The handler then runs the work (synchronously in Phase 4 PR-2; a + * future cron worker can take over by picking up `queued` rows). + * - On success: `completeOperation(id, result)`. On failure: + * `failOperation(id, error)`. Both stamp `completed_at`. + * - The 202 envelope is built by the helper, so call sites only need to + * return what it gives back. + * + * The shape stays stable when (or if) we move to true out-of-band processing: + * the POST simply leaves the row at status='queued' for a worker to pick up, + * the response is identical, and the GET poll endpoint surfaces progress as + * the worker updates it. + */ + +import type { SupabaseClient } from '@supabase/supabase-js' +import type { Logger } from '@/lib/logger' + +export type OperationStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' + +export interface OperationProgress { + /** Optional human-readable phase label (e.g. 'parsing', 'committing'). */ + phase?: string + /** Optional unit counters for percent calculation client-side. */ + current?: number + total?: number + /** Free-form additional fields — kept under jsonb so additions don't migrate. */ + [k: string]: unknown +} + +export interface OperationRow { + id: string + company_id: string + user_id: string + operation_type: string + status: OperationStatus + started_at: string | null + completed_at: string | null + params: Record + progress: OperationProgress + result: unknown + error: { code?: string; message?: string; details?: unknown } | null + created_at: string + updated_at: string +} + +/** + * Insert a new operation row in `running` status (start_at=now). Returns the + * id the POST handler should report back. The caller continues with the work + * and then resolves via `completeOperation` / `failOperation`. + * + * For true async dispatch (future cron worker), pass `status='queued'` and + * leave `started_at` null — the worker stamps it when it picks the row up. + */ +export async function startOperation( + supabase: SupabaseClient, + args: { + companyId: string + userId: string + operationType: string + params?: Record + /** Default `'running'` for inline execution; `'queued'` for worker dispatch. */ + initialStatus?: Extract + }, + log: Logger, +): Promise<{ id: string }> { + const initialStatus = args.initialStatus ?? 'running' + const startedAt = initialStatus === 'running' ? new Date().toISOString() : null + + const { data, error } = await supabase + .from('operations') + .insert({ + company_id: args.companyId, + user_id: args.userId, + operation_type: args.operationType, + status: initialStatus, + started_at: startedAt, + params: args.params ?? {}, + }) + .select('id') + .single() + + if (error || !data) { + log.error('startOperation insert failed', error as Error, { + companyId: args.companyId, + operationType: args.operationType, + }) + throw new Error('Failed to record operation start') + } + return { id: (data as { id: string }).id } +} + +/** + * Mark an operation as succeeded. Stamps `completed_at` and persists `result`. + * Best-effort: a failure to record the success doesn't roll back the work + * (the work already committed to the DB via whatever engine call ran). + */ +export async function completeOperation( + supabase: SupabaseClient, + args: { id: string; result: unknown; finalProgress?: OperationProgress }, + log: Logger, +): Promise { + const { error } = await supabase + .from('operations') + .update({ + status: 'succeeded', + completed_at: new Date().toISOString(), + result: args.result, + ...(args.finalProgress ? { progress: args.finalProgress } : {}), + }) + .eq('id', args.id) + if (error) { + log.warn('completeOperation update failed', { operationId: args.id, errorCode: error.code }) + } +} + +/** + * Mark an operation as failed. Stamps `completed_at` and persists `error`. + * The caller has already converted the underlying error into a structured + * code+message envelope. + */ +export async function failOperation( + supabase: SupabaseClient, + args: { + id: string + error: { code: string; message: string; details?: unknown } + finalProgress?: OperationProgress + }, + log: Logger, +): Promise { + const { error } = await supabase + .from('operations') + .update({ + status: 'failed', + completed_at: new Date().toISOString(), + error: args.error, + ...(args.finalProgress ? { progress: args.finalProgress } : {}), + }) + .eq('id', args.id) + if (error) { + log.warn('failOperation update failed', { operationId: args.id, errorCode: error.code }) + } +} + +/** + * Update progress on a running operation. Non-blocking: a write failure is + * logged but not raised — the work continues regardless. + */ +export async function updateOperationProgress( + supabase: SupabaseClient, + args: { id: string; progress: OperationProgress }, + log: Logger, +): Promise { + const { error } = await supabase + .from('operations') + .update({ progress: args.progress }) + .eq('id', args.id) + if (error) { + log.warn('updateOperationProgress failed', { operationId: args.id, errorCode: error.code }) + } +} + +/** + * Read an operation row, scoped to the caller's company. Returns null when + * the id is not found (or belongs to another company — RLS already excludes + * those, but the explicit `.eq('company_id')` keeps the contract clear). + */ +export async function getOperation( + supabase: SupabaseClient, + args: { id: string; companyId: string }, +): Promise { + const { data, error } = await supabase + .from('operations') + .select('id, company_id, user_id, operation_type, status, started_at, completed_at, params, progress, result, error, created_at, updated_at') + .eq('id', args.id) + .eq('company_id', args.companyId) + .maybeSingle() + if (error || !data) return null + return data as OperationRow +} diff --git a/lib/api/v1/owns-fiscal-period.ts b/lib/api/v1/owns-fiscal-period.ts new file mode 100644 index 00000000..37768b98 --- /dev/null +++ b/lib/api/v1/owns-fiscal-period.ts @@ -0,0 +1,47 @@ +/** + * Defense-in-depth ownership check for caller-supplied `fiscal_period_id` + * inputs. Every v1 endpoint that accepts a fiscal_period_id in the request + * body / query string must call this BEFORE handing the id to the engine. + * + * Why this exists: + * - The engine functions all scope by company_id internally + * (`createDraftEntry`, `generateOpeningBalances`, etc), so there is no + * literal cross-tenant data leak today. + * - But the engine throws Swedish error strings on mismatch + * ("Fiscal period not found"), and the route layer would otherwise have + * to scrape that string to produce a structured error envelope. + * - More importantly, an INSERT that takes both `company_id` (from URL) + * and `fiscal_period_id` (from body) without verifying they belong + * together creates a broken-link state — the row persists with a + * pointer at another company's period. Downstream queries return + * garbage even though no data was leaked. See: + * - voucher_gap_explanations: detect_voucher_gaps would never match. + * - journal_entries: balance triggers fire against the wrong period. + * + * Use everywhere a `fiscal_period_id` enters the system from the caller. + */ + +import type { SupabaseClient } from '@supabase/supabase-js' + +/** + * Returns true when (fiscal_period_id, company_id) is a real pairing in the + * `fiscal_periods` table. Cheap point lookup; the caller maps `false` to a + * structured NOT_FOUND or VALIDATION_ERROR envelope as appropriate. + * + * Cross-period checks that need additional state (is_closed, locked_at) + * should still go through `checkPeriodLock` — this helper only answers the + * ownership question. + */ +export async function ownsFiscalPeriod( + supabase: SupabaseClient, + companyId: string, + fiscalPeriodId: string, +): Promise { + const { data } = await supabase + .from('fiscal_periods') + .select('id') + .eq('id', fiscalPeriodId) + .eq('company_id', companyId) + .maybeSingle() + return !!data +} diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index 35c5030d..acca25d5 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -87,6 +87,29 @@ export const V1_ENDPOINT_SCOPES: Record = { 'POST /api/v1/companies/:companyId/supplier-invoices/:id/mark-paid': 'suppliers:write', 'POST /api/v1/companies/:companyId/supplier-invoices/:id/credit': 'suppliers:write', + // Phase 4 PR-2 — Engine, periods async ops, documents, compliance-check. + // Journal-entries primitives (highest-risk surface). + 'GET /api/v1/companies/:companyId/journal-entries': 'reports:read', + 'GET /api/v1/companies/:companyId/journal-entries/:id': 'reports:read', + 'POST /api/v1/companies/:companyId/journal-entries': 'bookkeeping:write', + 'POST /api/v1/companies/:companyId/journal-entries/:id/commit': 'bookkeeping:write', + 'POST /api/v1/companies/:companyId/journal-entries/:id/reverse': 'bookkeeping:write', + 'POST /api/v1/companies/:companyId/journal-entries/:id/correct': 'bookkeeping:write', + 'POST /api/v1/companies/:companyId/journal-entries/batch-create': 'bookkeeping:write', + 'POST /api/v1/companies/:companyId/voucher-gap-explanations': 'bookkeeping:write', + // Fiscal-periods async ops. + 'POST /api/v1/companies/:companyId/fiscal-periods/:id/lock': 'bookkeeping:write', + 'POST /api/v1/companies/:companyId/fiscal-periods/:id/close': 'bookkeeping:write', + 'POST /api/v1/companies/:companyId/fiscal-periods/:id/year-end': 'bookkeeping:write', + 'POST /api/v1/companies/:companyId/fiscal-periods/:id/opening-balances': 'bookkeeping:write', + 'POST /api/v1/companies/:companyId/fiscal-periods/:id/currency-revaluation': 'bookkeeping:write', + // Compliance check (gnubok's defensible edge). + 'GET /api/v1/companies/:companyId/compliance/check': 'compliance:read', + // Note: documents (multipart) scopes are intentionally NOT pre-registered + // here — they ship in the dedicated documents follow-up PR so an API key + // issued today with documents:write cannot match a route that doesn't + // yet exist. + // Phase 3 — transactions + reconciliation vertical. // Reads 'GET /api/v1/companies/:companyId/transactions': 'transactions:read', diff --git a/supabase/migrations/20260513200000_api_v1_async_operations.sql b/supabase/migrations/20260513200000_api_v1_async_operations.sql new file mode 100644 index 00000000..74b98b08 --- /dev/null +++ b/supabase/migrations/20260513200000_api_v1_async_operations.sql @@ -0,0 +1,81 @@ +-- Migration: api_v1_async_operations +-- +-- Substrate for the v1 async-operation lifecycle. Distinct from +-- `pending_operations` (which is the "stage and wait for human approval" +-- substrate used by the MCP write tools) — this table tracks long-running +-- jobs initiated by v1 callers that the API needs to report progress + final +-- status against, without blocking the request cycle. +-- +-- Response contract (per the Phase 4 plan): +-- POST returns 202 with { operation_id, status: 'queued', poll_url, webhook_event } +-- GET /v1/operations/{id} returns { operation_id, type, status, progress, result, error, started_at, completed_at } +-- +-- Used by: +-- - POST /fiscal-periods/{id}/close +-- - POST /fiscal-periods/{id}/year-end +-- - POST /fiscal-periods/{id}/currency-revaluation +-- - POST /imports/sie (future PR) +-- - POST /imports/bank (future PR) +-- - POST /salary-runs/{id}/generate-agi (future PR) +-- +-- Phase 4 PR-2 ships this with synchronous execution inside the POST handler +-- (status flips queued → running → succeeded/failed in one request cycle). +-- A future PR can introduce a Vercel cron worker that picks up `queued` rows +-- and processes them out-of-band; the row format remains stable. + +CREATE TABLE IF NOT EXISTS public.operations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Tenancy + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Operation identity + operation_type text NOT NULL, + -- Free-form tag for which v1 surface initiated this op (e.g. + -- 'fiscal_periods.close', 'fiscal_periods.year_end', 'imports.sie'). + -- The set of accepted values is open by design — adding a new async + -- endpoint should not require an enum migration. + + -- Lifecycle + status text NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')), + started_at timestamptz, + completed_at timestamptz, + + -- Payload + params jsonb NOT NULL DEFAULT '{}'::jsonb, + progress jsonb NOT NULL DEFAULT '{}'::jsonb, + result jsonb, + error jsonb, + + -- Audit + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.operations ENABLE ROW LEVEL SECURITY; + +-- Members of the company can read their company's operations. Only the +-- service role writes (via the v1 wrapper); no anon/authenticated INSERT/ +-- UPDATE/DELETE policy is exposed. +CREATE POLICY "operations_select" + ON public.operations FOR SELECT + USING (company_id IN (SELECT public.user_company_ids())); + +CREATE TRIGGER operations_updated_at + BEFORE UPDATE ON public.operations + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- Indexes: +-- - Polling: GET /v1/operations/{id} is point-lookup on PK. +-- - Listing by company + recency for future GET /v1/operations endpoint. +-- - Worker queries (future cron): pick up oldest `queued` ops per company. +CREATE INDEX idx_operations_company_created + ON public.operations (company_id, created_at DESC); + +CREATE INDEX idx_operations_status_queued + ON public.operations (created_at) + WHERE status = 'queued'; + +NOTIFY pgrst, 'reload schema';