6a85efb00a
* feat(mcp): allowlist Grok's connector callback and document the Grok path Grok custom connectors self-register through /api/mcp-oauth/register with redirect_uri https://grok.com/connectors-oauth-exchange-code/, which the built-in allowlist rejected with invalid_redirect_uri before consent. Add the callback as an exact-path BUILT_IN_PATTERNS entry (trailing slash optional, no prefix) with provider 'grok', named "Grok (xAI)" on the consent page. Tests: accept, foreign-host and other-path rejection, provider mapping, and a register route test for the Grok DCR shape. Surface Grok next to ChatGPT: a "Using Grok?" side door on the onboarding Claude step (one side door open at a time, telemetry step grok), a Grok row under "Other clients" in the API & MCP settings tab using ?client=grok, and sv/en strings for both. Docs: mcp-server rule, ARCHITECTURE, README, registry entry (install section), DECISIONS. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EGbspj3hiNqvqTWZqdwysa Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(mcp): cite X Corp's published Grok callback, test the consent label Review pass on #2158: the allowlist comment and DECISIONS entry claimed xAI publishes no callback and the value came from a live observation; X Corp lists https://grok.com/connectors-oauth-exchange-code/ as the "Grok (web)" redirect URL at docs.x.com/x-ads-api/mcp, and grok.com serves the path itself (slash form 308s to no-slash on the same origin). Reworded both to cite that. Adds the consent-page test for "Grok (xAI)" next to the ChatGPT one and a JSDoc on the onboarding side-door toggle. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EGbspj3hiNqvqTWZqdwysa Signed-off-by: Emil <emilmattsson14@gmail.com> --------- Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
157 lines
7.4 KiB
Markdown
157 lines
7.4 KiB
Markdown
# 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](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](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 silently edited or deleted. BFL 5 kap.
|
|
5 § allows two correction paths, and the code has exactly those two:
|
|
- Storno: `reverseEntry()` cancels a voucher with a reversal entry and
|
|
`correctEntry()` replaces it (`lib/core/bookkeeping/storno-service.ts`).
|
|
Always allowed.
|
|
- Inline rättelse (founder-approved 2026-07-23): the
|
|
`correct_entry_metadata` and `correct_entry_lines_inline` RPCs
|
|
strike-and-replace inside the same voucher, keeping the original
|
|
readable and writing an immutable who/when record to
|
|
`journal_entry_rattelse_log`. Only while the period is open and
|
|
unlocked; past a lock, close, or declared state, storno is the only path.
|
|
|
|
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.
|
|
|
|
## Legal enforcement lives in the database
|
|
|
|
The rules above are not conventions; they are enforced by PostgreSQL triggers:
|
|
|
|
- Committed journal entries cannot be edited or deleted. The only changes the
|
|
triggers permit are the controlled status transition used by the storno flow
|
|
(marking an entry as reversed) and the audited inline-rättelse RPCs above,
|
|
which are refused once the period is locked or closed.
|
|
- 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](LICENSE) and [docs/EXTENSIONS.md](docs/EXTENSIONS.md).
|
|
|
|
## Agent surface (MCP)
|
|
|
|
The bookkeeping engine is exposed as an MCP (Model Context Protocol) server
|
|
with 150+ 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). Claude, ChatGPT and Grok connectors instead authenticate with OAuth 2.1
|
|
(PKCE; `app/api/mcp-oauth/{authorize,register,token}` plus the
|
|
`.well-known` discovery documents), which mints a scoped API key behind the
|
|
scenes. Authentication is lazy: a client can connect, list tools, and call a
|
|
small set of public discovery tools before an account exists; the first
|
|
tenant-touching call answers 401 and triggers the client's connect prompt
|
|
(`extensions/general/mcp-server/public-tools.ts`).
|
|
- 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/accounted-mcp` | Published stdio MCP bridge for new installs (`accounted_*` tool namespace) |
|
|
| `packages/gnubok-mcp` | Compatibility MCP bridge for existing installs (kept on purpose) |
|
|
| `claude-plugin/` | Claude Code plugin: OAuth connector plus approval-gated workflow skills |
|
|
| `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.
|