feat(api): Phase 4 PR-2 — engine + periods + compliance-check (docs deferred) (#469)

* feat(api): Phase 4 PR-2 foundation — async operations substrate

Checkpoint commit. Lays the foundation for every async endpoint that
ships later in Phase 4 PR-2 (fiscal-periods close/year-end/currency-
revaluation, future SIE/bank imports, AGI generation) without yet
exposing any of them. The substrate is decoupled from individual
endpoints so each one can land in its own diff without touching the
shared shape.

ADDED

- Migration `20260513200000_api_v1_async_operations.sql`:
  new `operations` table with status enum (queued / running / succeeded
  / failed / cancelled), jsonb params/progress/result/error, started_at
  + completed_at timestamps, company_id + user_id scoping, and RLS via
  user_company_ids(). Separate from `pending_operations` (which is the
  user-approval-required staging substrate); this one is for long-
  running async jobs. Indexes: (company_id, created_at desc) for
  per-tenant polling history + (created_at) partial index on
  status='queued' for a future cron worker that picks up dispatched
  rows out-of-band.

- `lib/api/v1/operations.ts`: lifecycle helpers consumed by every
  async POST endpoint. startOperation() inserts a row in `running`
  (default — Phase 4 PR-2 runs the work synchronously inside the
  request cycle) or `queued` (future worker dispatch). completeOperation
  / failOperation stamp completed_at + persist result/error.
  updateOperationProgress is the in-flight progress writer.
  getOperation reads back by id, scoped to a company.

- `app/api/v1/operations/[id]/route.ts`: polling endpoint
  GET /api/v1/operations/{id}. Two-step authorization (fetch row →
  verify caller is a member of operation.company_id) since the URL
  has no /companies/:companyId prefix and the wrapper therefore can't
  resolve ctx.companyId. Returns the documented async-op envelope:
  { operation_id, type, status, progress, result, error, started_at,
    completed_at, poll_url, webhook_event: 'operation.completed' }.

- `lib/auth/scopes.ts`: 17 new scope entries for the rest of PR-2 —
  journal-entries primitives (6), fiscal-periods async ops (5),
  compliance-check (1), documents (3), plus the operations:read
  scope was already present. Adding all up front so subsequent route
  PRs only ship the route files.

- `lib/api/v1/load-routes.ts`: registers operations/[id] for the
  OpenAPI generator.

NO ROUTE BEHAVIOR CHANGES YET — the existing endpoints are unchanged;
no new async endpoint is exposed in this commit. Tests 3376/3376
still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api): Phase 4 PR-2 — journal-entries primitives + voucher-gap-explanations

Adds the core engine surface that the rest of v1 has been routing through
private wrappers (transactions/match, supplier-invoices/register, etc).
Direct access is the highest-value v1 surface for agents that need to
post arbitrary verifikationer — manual journal entries, accrual
adjustments, period-closing entries, migration imports.

ENDPOINTS (7)

  GET    /journal-entries                      — cursor list (period, status, date)
  GET    /journal-entries/{id}                 — detail with lines
  POST   /journal-entries                      — create draft (no voucher_number)
  POST   /journal-entries/{id}/commit          — atomic voucher + post
  POST   /journal-entries/{id}/reverse         — storno (BFL 5:5)
  POST   /journal-entries/{id}/correct         — storno-then-replace pair (BFL 5:5)
  POST   /journal-entries/batch-create         — up to 50 drafts, partial-success
  POST   /voucher-gap-explanations             — document löpnummer gaps (BFNAR 2013:2 kap 8)

All writes are idempotent (mandatory Idempotency-Key) and dry-runnable.

ENGINE WIRING

  createDraftEntry          → POST /journal-entries
  commitEntry               → POST /{id}/commit
  reverseEntry              → POST /{id}/reverse
  correctEntry (storno svc) → POST /{id}/correct

Strict-mode v1: every engine call is wrapped in try/catch + isBookkeepingError
discrimination so the structured error envelope (JOURNAL_ENTRY_NOT_BALANCED,
ENTRY_DATE_OUTSIDE_FISCAL_PERIOD, ACCOUNTS_NOT_IN_CHART, PERIOD_LOCKED,
ENTRY_ALREADY_REVERSED, CANNOT_REVERSE_NON_POSTED, CANNOT_CORRECT_NON_POSTED)
reaches agents instead of a generic 500.

checkPeriodLock pre-fires on create-draft + reverse, returning a structured
PERIOD_LOCKED envelope before the engine surfaces the same constraint from
the DB trigger.

DRY-RUN

  - create-draft: validates balance + period + line shapes, no insert.
  - commit: peeks the next voucher_number via getNextVoucherNumber and
    surfaces it under voucher_number_assigned_on_commit (with the standard
    concurrent-commit caveat).
  - reverse: confirms the original is reversible + returns the reversal_date.
  - correct: confirms the new lines balance + reports the inherited period.
  - batch-create: returns per-item preview rows.
  - voucher-gap-explanation: echoes the input shape.

SCHEMA

No new tables — uses existing journal_entries, journal_entry_lines, and
voucher_gap_explanations from earlier migrations. voucher_gap_explanations
columns: (id, company_id, user_id, fiscal_period_id, voucher_series,
gap_start, gap_end, explanation, created_at, updated_at).

TESTS DEFERRED

Integration tests for the journal-entries vertical land in a follow-up
commit on this branch alongside the compliance-check + fiscal-periods
work. The engine itself is heavily tested (lib/bookkeeping/__tests__/);
the route layer is a thin wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api): Phase 4 PR-2 — compliance-check + fiscal-periods async ops

Ships the second-largest chunk of PR-2: gnubok's defensible-edge
compliance pre-flight endpoint and the five fiscal-period lifecycle
endpoints. Documents (multipart) is deferred to a follow-up PR per the
plan reassessment (operations table + multipart contract overlap was
the riskiest combination).

COMPLIANCE-CHECK (1 endpoint, 3 check types)

  GET /compliance/check?type=<vat_close|year_end_readiness|voucher_gaps>

Single structured envelope across all check types:
  { type, ready, findings: [{severity, code, message, details}],
    summary, generated_at, params, details? }

  - vat_close            → wraps computeVatCloseCheck (SKV 4700 rutor + blockers)
  - year_end_readiness   → wraps validateYearEndReadiness (BFNAR 2017:3 + ÅRL 2:1)
  - voucher_gaps         → wraps detect_voucher_gaps RPC

Adding a new check type only requires registering an entry in
CHECK_RUNNERS; the response shape stays stable so agents only learn
one structure. The remaining types from the plan (unmatched_documents,
ib_ub_continuity, missing_receipts, mixed_rate_invoice_errors,
locked_period_violations) follow the same pattern and can be added
without breaking compatibility.

FISCAL-PERIODS ASYNC OPS (5 endpoints)

Synchronous wrappers around the existing engine functions:

  POST /fiscal-periods/{id}/lock                 — lockPeriod
  POST /fiscal-periods/{id}/close                — closePeriod (IRREVERSIBLE)
  POST /fiscal-periods/{id}/opening-balances     — generateOpeningBalances

Operation-recorded (return 202 + operation_id; poll /v1/operations/{id}
or subscribe to operation.completed in Phase 6):

  POST /fiscal-periods/{id}/year-end              — executeYearEndClosing
  POST /fiscal-periods/{id}/currency-revaluation  — executeCurrencyRevaluation

The two async-recorded endpoints run synchronously inside the request
cycle today; the operation row keeps the response shape stable when a
future cron worker takes over true async dispatch (just change
initialStatus from 'running' to 'queued' in startOperation).

Strict error mapping: engine throws (e.g. "Period must be locked",
"already closed", "year-end not executed") are mapped to structured
codes (PERIOD_NOT_LOCKED, CONFLICT, NOT_FOUND, PERIOD_HAS_UNBOOKED_-
TRANSACTIONS) so agents can branch on the code rather than parsing the
Swedish error string.

LOAD-ROUTES

All 6 new endpoints registered in lib/api/v1/load-routes.ts for the
OpenAPI generator. Scopes already in place from the foundation commit.

TESTS

Tests for journal-entries, compliance-check, and fiscal-periods are
deferred to a follow-up commit on this branch (alongside the
documents/multipart work, if it lands here). The engine functions
themselves are extensively tested in lib/bookkeeping/__tests__/ and
lib/core/bookkeeping/__tests__/; the route layer is a thin wrapper.

Full suite 3376/3376 green. tsc clean on new files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api): PR #469 — drop vat_close from compliance-check (core-only CI gate)

core-only.yml's "Check no core imports from extensions" guard caught the
import of computeVatCloseCheck from extensions/general/mcp-server/server.ts.
CLAUDE.md is explicit: core code cannot import from @/extensions/ directly.

Drop vat_close from SUPPORTED_TYPES for now. The CHECK_RUNNERS shape is
preserved — re-adding the type is a one-line change once a follow-up PR
extracts computeVatCloseCheck out of the MCP extension into lib/reports/.
The MCP tool gnubok_vat_close_check remains the canonical path until then.

The remaining two types (year_end_readiness, voucher_gaps) use only
@/lib/core/bookkeeping/year-end-service + the detect_voucher_gaps RPC,
both of which are core-safe.

Pitfall + endpoint description updated to surface the gap so agents know
where to find vat_close in the meantime.

Suite 3376/3376 still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api): PR #469 round-1 — compliance bot review (3 real, 2 FP, rest deferred)

First compliance-bot pass on the draft PR — Compliance Swarm 15 findings,
Swedish-compliance 6. Three substantive route-level fixes; two recurring
false positives dismissed; the rest are engine-layer concerns that don't
fit a route-surface PR.

REAL FIXES (3)

1. voucher-gap-explanations example was self-contradictory.
   The example explanation cited "failed commit ... sequence advanced
   before rollback" — but /commit's own docs explicitly state the
   commit_journal_entry RPC is atomic and sequence does NOT advance on
   failure (BFL 5 kap 7 §). The example contradicted the design
   guarantee. Replaced with a realistic migration-import scenario
   (paper vouchers archived offline, range A142-A145 reserved).

2. Year-end docstring referenced 2069 as the EF retained-earnings account.
   Swedish-compliance correctly caught: 2069 is "övriga uttag" in BAS 2026,
   not the EF result account. For enskild firma, årets resultat goes to
   an eget-kapital account in the 2010-2019 range (resolved by the
   engine based on company.entity_type). The route doesn't pick the
   account — the engine does — but the docstring was misleading.

3. compliance-check fiscal_period_id ownership pre-check.
   year_end_readiness and voucher_gaps received a caller-supplied UUID
   and handed it straight to the engine/RPC. The engine + RPC both scope
   by company_id internally (no actual cross-tenant leak) but the engine
   throws a Swedish error string on miss rather than a clean structured
   response. Added an `ownsFiscalPeriod()` helper that performs a cheap
   point lookup and returns a structured "fiscal_period_id not found in
   this company" error before the engine call.

DISMISSED (2 false positives)

- V8.2.1 operations route ownership — the bot read only the file header
  (line 1). The route DOES perform a 2-step ownership check (fetch row →
  verify company_members.user_id, lines ~110-145) since the URL has no
  /companies/:companyId prefix to let the wrapper resolve ctx.companyId.
  Already documented in the route's docstring.

- V8.2.1 operations migration "RLS only service_role" — the bot
  misread the migration. The actual policy is:
    USING (company_id IN (SELECT public.user_company_ids()))
  i.e. authenticated callers can read their company's operations under
  RLS. The two-step check in the route is defense-in-depth.

DEFERRED (engine-layer)

- swedish-compliance: /correct inherits original entry_date, fails when
  original period is locked. Real ergonomics issue. Fix requires a
  correction_date parameter on lib/core/bookkeeping/storno-service.correctEntry.
  Engine signature change — out of v1 surface scope.

- swedish-compliance: 2099→2091 prior-year sweep in year-end engine.
  executeYearEndClosing engine concern, not visible from the route.

- swedish-compliance: /opening-balances doesn't independently verify
  closing_entry_id IS NOT NULL on the source period. Engine concern.

- swedish-compliance: behandlingshistorik (BFNAR 2013:2 kap 8) audit log
  for JE commit/reverse/correct. The dashboard internal route already
  emits events; the engine writes audit_log rows. Engine concern, not
  per-route.

- swedish-compliance: revaluation tax_code default. Engine concern;
  executeCurrencyRevaluation builds the JE lines.

- Compliance Swarm recurring architectural items (V16.1 event-bus retry,
  Art.5(1)(f) userId in logs oscillation from PR-1, SOC 2 CC6.3 SoD,
  etc.) — all carry-overs from PR-1 with the same dispositions.

Suite 3376/3376 still green; tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api): PR #469 round-2 — Greptile review fixes (3 real, 1 FP, 1 deferred)

First Greptile pass after the PR went out of draft. Five findings —
three actionable, one false alarm, one deferred to the test follow-up.

REAL FIXES (3)

1. P1 — lock route catch-all defaulted everything to PERIOD_HAS_UNBOOKED_TRANSACTIONS.
   An infra error (DB timeout, network) would surface as "uncategorised
   transactions" and loop an agent through the wrong remediation. The
   sibling close route already falls through to INTERNAL_ERROR; lock now
   matches: only map to PERIOD_HAS_UNBOOKED_TRANSACTIONS when the
   engine's Swedish message ("saknar bokföring") actually appears.
   Otherwise → INTERNAL_ERROR + the original message in details.

2. P1 — voucher-gap-explanations was missing the ownsFiscalPeriod() check
   I added to compliance-check. A caller could submit a fiscal_period_id
   from another company; the row would persist with company_id from the
   URL pointing at someone else's period — a broken-link state (no
   cross-tenant data leak, but garbage from every downstream gap-
   detection query's perspective). Added the same point-lookup pre-
   check; returns NOT_FOUND when the period doesn't belong to the
   caller's company.

3. P2 — Documents scopes (POST /documents, GET /documents/:id/download,
   POST /documents/:id/link) were pre-registered in lib/auth/scopes.ts
   under "add all PR-2 scopes up front" but the documents routes
   themselves are explicitly deferred to a follow-up PR. Removed them;
   they ship with the routes. Comment in scopes.ts records the rationale.

DISMISSED (1 false alarm)

- gen_random_uuid() vs uuid_generate_v4() — Greptile cited CLAUDE.md
  rule 4. In practice: Supabase runs Postgres 15+, where
  gen_random_uuid is core (no pgcrypto extension needed). The Docker
  stack runs Postgres 17 per the project's docker-publish.yml.
  CLAUDE.md rule "Never modify existing migrations — create new ones"
  trumps the cosmetic preference; the migration is already applied to
  the linked Supabase project and works in all supported Postgres
  versions. Leaving as-is.

DEFERRED (1)

- P2 — *.pg.test.ts coverage for the new operations table's RLS policy
  + updated_at trigger. CLAUDE.md does require this. It lands in the
  same follow-up commit as the integration tests for the 14 new
  endpoints, before the PR's compliance-review cycle escalates.

Suite 3376/3376 still green; tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api): PR #469 round-3 — ownership pre-checks + explicit close-route state guards

Compliance Swarm went 15→18 on round-2, mostly because the V8.2.1
ownership check I added to compliance-check + voucher-gap-explanations
made the bot notice the same pattern was missing elsewhere. Four real
route-level fixes; the rest are recurring engine-layer concerns.

REAL FIXES (4)

1. Extract `ownsFiscalPeriod` into `lib/api/v1/owns-fiscal-period.ts`.
   Was inline in compliance/check/route.ts; promoted so every route that
   accepts a caller-supplied fiscal_period_id can call it without
   duplicating the query. Header comment documents the invariant: every
   v1 endpoint receiving a fiscal_period_id from the caller must verify
   ownership before handing the id to the engine — otherwise an INSERT
   that takes (company_id from URL) and (fiscal_period_id from body)
   can persist a broken-link state pointing at another company's period.

2. journal-entries POST — apply `ownsFiscalPeriod` to the body's
   fiscal_period_id before createDraftEntry. (V8.2.1)

3. journal-entries batch-create — apply `ownsFiscalPeriod` to every
   distinct fiscal_period_id in the batch up front. Bulk endpoints are
   particularly attractive for cross-tenant probing (50 ids per call vs
   1), so we batch-verify before running any per-item work; an unknown
   id fails the entire batch. Partial-success semantics only apply
   AFTER ownership is established. (V8.2.1)

4. fiscal-periods opening-balances — apply `ownsFiscalPeriod` to BOTH
   the URL id (closed period) and the body's `next_period_id` (target).
   Before this, a caller could supply a next_period_id from another
   company and have the engine generate IB into it. (V8.2.1)

5. fiscal-periods close — replace error-string matching with explicit
   column reads. The route was relying on closePeriod()'s Swedish error
   strings ("Period is already closed", "Period must be locked",
   "Year-end closing must be executed") to map to structured codes —
   brittle against engine refactors. Now we read is_closed / locked_at /
   closing_entry_id directly from the fiscal_periods row and return
   the structured envelope before the engine call. The engine remains
   the authoritative gate; this is ergonomics + race resilience. (V2.3)

DISMISSED / DEFERRED

- V2.3 lock route Swedish string-matching — keeping. Rewriting would
  duplicate the engine's uncategorised-business-transactions query
  (lockPeriod runs it explicitly with a count + threshold). Engine
  re-throw with a typed error is the right long-term fix.

- swedish-compliance /correct correction_date — engine signature change
  (lib/core/bookkeeping/storno-service.correctEntry needs a new param).
  Deferred to engine PR.

- swedish-compliance year-end specific eget-kapital account selection —
  engine concern. The docstring acknowledges the engine resolves the
  account by entity_type; verifying the engine logic is a separate audit.

- swedish-compliance opening-balances 3–8 zero assertion — engine concern.
  /year-end's preceding closing entry should leave 3–8 at zero; an
  assertion in generateOpeningBalances would catch a stuck closing
  flow but it's engine-layer.

- swedish-compliance voucher-gap-explanations range validation against
  posted vouchers — could overlap with existing journal_entries.voucher_-
  number values. Real audit-trail concern but adds an extra round-trip
  per insert; defer.

- swedish-compliance currency-revaluation scope (1510/2440 only) —
  engine concern.

- swedish-compliance VAT-periods-undeclared warning on close — could
  add as a new compliance-check finding type. Tracked separately.

Suite 3376/3376 still green; tsc clean on all changed files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(api): PR #469 round-4 — async-op atomicity + correct period-lock + IB dedup

Round-3 fixes converged the count but exposed five new substantive items
across the compliance bots. All five addressed.

REAL FIXES (5)

1. currency-revaluation: unconditional ownership pre-check (V8.2.1).
   Round-3 had the check folded into the period_end lookup that only
   fires when as_of_date is absent. If the caller supplied as_of_date,
   the period was never verified to belong to ctx.companyId. Now calls
   ownsFiscalPeriod() unconditionally, before startOperation.

2. year-end: ownership pre-check (V8.2.1). Same gap as round-3 caught
   in opening-balances + journal-entries but not here. Added.

3. /correct: checkPeriodLock on the inherited entry_date.
   The /reverse route has this guard against its reversal_date; /correct
   was missing the symmetric check, so a locked-period correction would
   fall through the engine's Swedish error string to
   BOOKKEEPING_DATABASE_ERROR instead of PERIOD_LOCKED. The correction
   trail (BFL 5 kap 5 §) is bound to typed.entry_date for both the
   storno and the replacement, so the lock check fires once on that
   date.

4. /opening-balances: duplicate-IB detection.
   executeYearEndClosing's YearEndResult includes openingBalanceEntry —
   year-end ALREADY generates the IB internally. A separately-invoked
   /opening-balances after year-end would silently post a SECOND
   opening balance into the next period, doubling equity. Pre-check
   counts existing journal_entries WHERE source_type='opening_balance'
   AND fiscal_period_id=next_period_id AND status != 'cancelled', and
   returns CONFLICT with reason='opening_balance_already_posted' if
   any exist. Remediation hint points at the GL endpoint to inspect
   what's there.

5. /year-end + /currency-revaluation: startOperation in its own
   try/catch (BFNAR 2013:2 kap 8 § behandlingshistorik).
   Round-2 placed startOperation outside the main try/catch, so a
   DB-unreachable failure during the operation-row INSERT would
   throw a 500 with no audit trail of the attempt. Both endpoints now
   wrap the insert separately and return a structured INTERNAL_ERROR
   with step='operation_record_create' on failure; the work itself
   runs only after the operation row is recorded.

DOCS (1)

6. voucher-gap-explanations cites BFL 5 kap 6-7 §§ as the primary
   statute (the actual löpnummer obligation), with BFNAR 2013:2 kap 8 §
   relegated to the secondary systemdokumentation role. Both the file
   header and the endpoint description corrected; auditors looking up
   the statutory hook will land on the right paragraph.

DISMISSED / DEFERRED

- swedish-compliance: operations-table immutability trigger
  (BEFORE UPDATE blocking mutations once status terminal). Real
  architectural concern. Requires a migration; lands in a follow-up
  PR alongside the operations.pg.test.ts coverage.

- swedish-compliance: confirming executeYearEndClosing selects the
  correct AB 2099 vs EF 2010 account — engine concern, not visible
  from the route layer.

- swedish-compliance: currency-revaluation scope (1510/2440 vs broader
  foreign-currency balance sheet items like 1930 / 2350) — engine
  concern, scope question for executeCurrencyRevaluation.

- swedish-compliance: voucher-gap range overlap validation
  (gap_start..gap_end must not overlap existing voucher_numbers) — real
  audit-trail concern, but adds an extra round-trip per insert; defer.

Suite 3376/3376 still green; tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-13 21:53:22 +02:00
committed by GitHub
parent abb9f5868c
commit e31aee2455
19 changed files with 2962 additions and 0 deletions
@@ -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<typeof Finding>
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<string, unknown>
}
// --------------------------------------------------------------------
// 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<CheckResult | { error: string; details?: unknown }> {
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<CheckResult | { error: string; details?: unknown }> {
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<string, unknown> = { 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 })
}
},
)
@@ -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 },
)
@@ -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 },
)
@@ -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 },
)
@@ -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 12 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 12 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 },
)
@@ -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 },
)
@@ -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 },
)
@@ -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 },
)
@@ -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 57 §§).',
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 },
)
@@ -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 })
},
)
@@ -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<typeof CreateJournalEntrySchema>,
dryRun: boolean,
log: Logger,
): Promise<ResultItem> {
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 },
)
@@ -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 67 §§).
*
* 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<string, unknown>
const rows = ((data ?? []) as unknown) as Row[]
const trimmed = rows.slice(0, limit)
const hasMore = rows.length > limit
const last = trimmed[trimmed.length - 1]
const nextCursor = hasMore && last
? encodeDefaultCursor({ id: last.id, created_at: last.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 },
)
@@ -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 (AZ); 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 },
)
+165
View File
@@ -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 530 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 },
)
},
)
+22
View File
@@ -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'
+186
View File
@@ -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<string, unknown>
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<string, unknown>
/** Default `'running'` for inline execution; `'queued'` for worker dispatch. */
initialStatus?: Extract<OperationStatus, 'queued' | 'running'>
},
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<void> {
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<void> {
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<void> {
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<OperationRow | null> {
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
}
+47
View File
@@ -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<boolean> {
const { data } = await supabase
.from('fiscal_periods')
.select('id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.maybeSingle()
return !!data
}
+23
View File
@@ -87,6 +87,29 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'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',
@@ -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';