Files
accounted/ARCHITECTURE.md
T
Jakob Wennberg 920400cfb5 docs: community health files, ARCHITECTURE.md, and README overhaul (#1072)
* docs: add community health files, ARCHITECTURE.md, and README overhaul

Community layer: CODE_OF_CONDUCT (Contributor Covenant 2.1), issue
templates (bug/feature YAML forms + contact links), PR template
mirroring the Definition of Done, and a visitor-grade ARCHITECTURE.md
distilled from internal docs.

README: fix the clone/cd case mismatch, add CI/docker badges and site
links, add a Why section (compliance-by-construction, agent-native MCP
surface, self-hostable), add missing payroll and MCP feature bullets,
and restructure the documentation/community sections. Hero screenshot
slot left as a comment pending an approved shot.

CONTRIBUTING: fix stale gnubok name, link Code of Conduct and
ARCHITECTURE.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Jakob Wennberg <jakob.wennberg@gmail.com>

* docs: address review feedback on community files

Disable blank issues so security reports stay on the private path,
require npm run build in the PR checklist to match CONTRIBUTING, and
align ARCHITECTURE.md immutability wording with the actual trigger
behavior (controlled posted-to-reversed transition is permitted).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Jakob Wennberg <jakob.wennberg@gmail.com>

---------

Signed-off-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:22:05 +02:00

6.1 KiB

Architecture

Accounted is a multi-tenant double-entry bookkeeping system built for Swedish accounting law. This document explains how the system is put together and why some parts are deliberately rigid. For contribution workflow, see CONTRIBUTING.md.

Overview

  • Framework: Next.js (App Router) with React and TypeScript in strict mode.
  • Database: Supabase (PostgreSQL with Row Level Security), which also provides auth (email/password plus TOTP MFA).
  • Deployment: Vercel-hosted is the primary target; a Docker self-hosted setup is fully supported (see docs/SELF-HOSTING.md).
  • UI: Tailwind CSS with shadcn/ui components. User-facing product language is Swedish and English (messages/sv.json, messages/en.json).

The bookkeeping engine

All accounting writes flow through one engine: lib/bookkeeping/engine.ts.

The journal entry lifecycle is draft, then commit:

  1. createDraftEntry() creates an uncommitted entry that can still change.
  2. commitEntry() posts it. The voucher number is assigned atomically by the commit_journal_entry database RPC, which keeps numbering sequential per series. Swedish law requires an unbroken, explainable voucher sequence.
  3. createJournalEntry() does both steps in one call.

Two invariants hold for every entry:

  • Debits equal credits, and both sides are greater than zero.
  • Once committed, an entry is never edited or deleted. Mistakes are corrected with reversal entries (storno): reverseEntry() cancels a voucher and correctEntry() replaces it (lib/core/bookkeeping/storno-service.ts).

If a gap still occurs in a voucher series (for example around imported history), it must be documented, and the explanation is stored (voucher_gap_explanations), following BFNAR 2013:2.

The rules above are not conventions; they are enforced by PostgreSQL triggers:

  • Committed journal entries cannot be edited or deleted. The only change the triggers permit is the controlled status transition used by the storno flow (marking an entry as reversed).
  • Writes to closed or locked accounting periods are rejected, as are writes behind a company-wide lock date.
  • Documents linked to posted entries cannot be deleted; Swedish law requires 7-year retention of accounting records.

Application code never works around these triggers. If a code path hits one, the code path is wrong, not the trigger.

Two smaller invariants that show up everywhere in the codebase:

  • Monetary amounts are rounded with Math.round(x * 100) / 100. String-based rounding such as toFixed() causes drift at the öre level and breaks entry balance.
  • Account numbers are strings ('1930', never 1930). They are identifiers, not quantities.

Multi-tenancy and security

Users belong to companies through company_members, and every business table carries a company_id. Access control is layered:

  • Row Level Security in PostgreSQL restricts rows to companies the user belongs to.
  • Explicit filtering: queries still filter by company_id in code, as defense in depth, because service-role code paths bypass RLS.
  • Route guards: API routes wrap a shared route context helper that resolves the authenticated user, the active company, and MFA enforcement in one place. Routes never hand-roll their own auth.

The active company is resolved server-side from the user's stored preference, so the Next.js app and RLS always agree on which company is active.

Extension system

Core is a complete accounting product on its own. Optional functionality (AI categorization, receipt OCR, email, calendar, the MCP server, and more) ships as extensions under extensions/, toggled by extensions.config.json.

The boundary is strict and CI-enforced:

  • Core code never imports from @/extensions/. CI builds core with zero extensions enabled, so a direct import breaks the build.
  • Extensions integrate through the event bus and documented extension APIs, and are wired via a generated static registry (npm run setup:extensions).

Licensing follows the same boundary: the project is AGPL-3.0, with an extension exception that allows third-party extensions using only the documented Extension API to be licensed under any terms. See LICENSE and docs/EXTENSIONS.md.

Agent surface (MCP)

The bookkeeping engine is exposed as an MCP (Model Context Protocol) server with over 100 tools, so AI agents can operate the ledger: list and categorize transactions, draft vouchers, reconcile periods, generate reports and declarations.

  • Authentication uses scoped API keys (stored as SHA-256 hashes, rate limited per key).
  • Posting operations are staged: an agent proposes an operation, and a human approves it before anything is committed to the journal.

Events

lib/events/bus.ts is a module-level singleton event bus. Domain events (for example "invoice created" or "transaction imported") are how extensions react to core activity without core knowing about them.

Repository map

Path Contents
app/ Next.js App Router pages and API routes
lib/bookkeeping/ Engine, entry generators, account mapping, BAS chart data
lib/core/ Periods, year-end, storno, tax codes, audit, documents
lib/reports/ Balance sheet, income statement, VAT, SIE, tax reports
lib/ (other) Invoices, transactions, imports, salary, reconciliation, tax, providers
components/ React components (shadcn/ui based)
extensions/ Opt-in extension plugins
supabase/migrations/ Database schema, RLS policies, enforcement triggers
packages/gnubok-mcp Published MCP bridge package
messages/ Swedish and English UI strings
tests/ Shared test helpers and fixtures
docs/ Self-hosting, Docker, extensions, white-label guides

Testing

  • Unit and route tests run on Vitest with mocked Supabase clients (npm test).
  • Database behavior (triggers, RPCs, RLS) is tested against a real PostgreSQL instance in *.pg.test.ts files (npm run test:pg), because mocking cannot prove trigger semantics.