Files
accounted/CLAUDE.md
T
Jakob WennbergandClaude Opus 4.7 c06395f633 feat(mcp): agent-native API sprint — quick wins (items 8/10/38/39/50) (#505)
* feat(mcp): agent-native API sprint — quick wins (items 8/10/38/39/50)

Five Tier-S items from dev_docs/api_ai_architecture/PLAN.md, picked for highest
impact-per-day on a solo budget. ~7.5 engineer-days of work.

Item 38 — gnubok_reverse_journal_entry MCP tool. Wraps the existing
reverseEntry() engine function (lib/bookkeeping/engine.ts) as a staged
high-risk operation. Description distinguishes pure makulering (use this) from
rättelse (use gnubok_correct_entry) per BFL 5 kap 5§ guidance — leaving a real
affärshändelse unbooked is itself a BFL violation, so agents must understand
which storno pattern to apply. New operation_type 'reverse_entry' wired through
PendingOperationType, risk-tiers (high), commit.ts executor, and TOOL_SCOPE_MAP
(bookkeeping:write). Six executor cases + three staging-gate cases cover the
new tool.

Item 39 — period_status threading. New helper resolvePeriodStatusForDate() in
lib/core/bookkeeping/period-service.ts returns { period_id, status, lock_date }
using the same two-layer logic as the v1 REST check (company-wide
bookkeeping_locked_through + fiscal_period flags). Threaded through
stagePendingOperation via a new dateForPeriodCheck option so agents and widgets
can detect locked/closed periods without round-trips. Applied to seven
bookkeeping-touching tools: categorize_transaction, create_transactions,
create_voucher, approve_supplier_invoice, mark_invoice_as_paid, correct_entry,
reverse_journal_entry. Resolution failure is non-fatal — DB triggers stay
authoritative.

Item 50 — gnubok://company/current expansion. Replaces the metadata-only
resource with per-company working memory: active fiscal period status, lock
dates, counts (customers, suppliers, open AR/AP, uncategorized transactions),
voucher series state across open periods, recency signals (last categorization,
last invoice sent, last bank sync), and the next five approaching deadlines.
All queries parallelized via Promise.all; payload stays well under 8 KB.
Mirrors the context.md pattern from Shipper+Claude's agent-native architecture
guidance and prevents the context-starvation anti-pattern.

Item 8 — schema strictness. additionalProperties: false on every one of the 67
inputSchemas in extensions/general/mcp-server/server.ts. New
strict-schemas.test.ts guards against regression on newly authored tools.
CLAUDE.md documents the tool-authoring contract (strict input schemas,
description ≤280 chars, STAGED_OPERATION_SCHEMA + next as the
completion-signal pattern — do NOT introduce a parallel S/H/C/O envelope).
Payload-size ceiling raised from 20K → 25K tokens with a comment pointing at
item 15 (Tool Search + defer_loading) as the long-term answer rather than
relaxing the watchdog further.

Item 10 — prompt cache groundwork. The only Anthropic SDK call site in the
codebase is the invoice-inbox extension's Bedrock-backed extractor; tagged the
~3.5 KB SYSTEM_PROMPT with cache_control: { type: 'ephemeral' } and added
usage logging (cache_read_input_tokens / cache_creation_input_tokens) so the
hit ratio is measurable. The plan's 1h TTL is direct-Anthropic-only;
documented the constraint and the MCP-side determinism contract (tool
definitions must be byte-stable across requests) in the new mcp-server
README.md.

Carry-over: includes a small untracked migration
(20260516060000_journal_entries_source_type_inbox_item) and its pg test guard
that fix a production CHECK-constraint gap for source_type='inbox_item' —
unrelated to the sprint but bundled per request.

Tests: 3615/3615 pass across 252 files. TypeScript build clean.

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

* fix(mcp): address PR #505 review — cross-tenant leaks, company-wide lock, PII

Five reviewer findings on PR #505 addressed:

1. Cross-tenant leak — voucher_sequences (OWASP V8.2.1, SOC 2 CC6.3).
   Resource query filtered by user_id only; switched to company_id since the
   table has both (added in the 2026-03 multi-tenant refactor migration).

2. Cross-tenant leak — deadlines (OWASP V8.2.1, GDPR Art.5(1)(f), ISO A.8.3).
   Same fix; the deadlines table also gained a company_id column in the
   multi-tenant refactor and the RLS policies enforce it. With the company_id
   filter active, the userId parameter is no longer needed in the resource —
   removed from the destructure.

3. Compliance gap — commitReverseEntry and commitCorrectEntry only checked
   fiscal_periods.is_closed, not company_settings.bookkeeping_locked_through.
   Agents could stage a reversal with period_status: locked warning (caught
   by resolvePeriodStatusForDate at staging time), have the user approve,
   and the commit would slip through. Both executors now run
   resolvePeriodStatusForDate at commit time so the gate matches the
   staging-time signal. Pre-existing gap on commitCorrectEntry also fixed.

4. Schema mismatch — period_status was spread into both `preview` and the
   top-level response, but STAGED_OPERATION_SCHEMA only declares it at the
   top level. Removed the preview-nested copy to match the schema and avoid
   ambiguous reads.

5. Tool description — swedish-compliance bot flagged that "pure makulering
   (storno)" conflates two distinct Swedish accounting terms: storno
   preserves the original; makulering voids it entirely. Code does storno;
   description now says so plainly and cites BFL 5 kap.

6. Input hardening — added ^\d{4}-\d{2}-\d{2}$ pattern to reversal_date in
   inputSchema plus a runtime regex check in execute(), so a malformed date
   never reaches the pending_operations payload.

7. GDPR — ai_extraction_usage and the two pre-existing fileName log
   emissions in extract-invoice-fields.ts replaced raw fileName with a
   12-char SHA-256 prefix. Raw invoice file names (e.g.
   "faktura_Sven_Andersson.pdf") can constitute personal data; hashing
   preserves operator correlation without exposing PII to log destinations
   that may lack documented retention controls.

Notes on findings NOT addressed:
- Double-reversal guard (Greptile/swedish-compliance): false positive.
  reverseEntry() flips the original's status to 'reversed' (engine.ts:538)
  and the staging tool already rejects anything not 'posted'. Engine also
  has a CAS guard at lines 541-551.
- Staging vs commit TOCTOU re-validation: pre-flight + DB triggers remain
  authoritative; the window is narrow enough that adding executor-side
  re-checks isn't load-bearing this sprint.
- Runtime Zod validation of args inside execute(): codebase doesn't do
  this for any MCP tool today; cross-cutting refactor deferred.

New test: voucher-executors.test.ts adds a case for the company-wide lock
branch on reverse_entry (verifies the new resolvePeriodStatusForDate gate
fires when bookkeeping_locked_through covers entry_date).

Tests: 3616/3616 pass. TypeScript build clean.

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

* fix(mcp): address second-round PR #505 review — locked_at, reason cap, log

Re-review by compliance-swarm and swedish-accounting-compliance bots after the
first fixes raised three more legitimate findings:

1. Per-period `locked_at` not directly checked from the fetched row
   (swedish-accounting-compliance). Both commitCorrectEntry and
   commitReverseEntry already call resolvePeriodStatusForDate which covers
   locked_at, but a transient DB blip in the resolve helper would silently
   skip that gate. Now reading locked_at directly from the inner-join row and
   checking it alongside is_closed before the resolve helper runs — same
   pattern, two defense-in-depth layers instead of one.

2. `reason` field had no maxLength (OWASP V4.5). Added maxLength: 500 to the
   inputSchema and a runtime length check; an adversarial agent could
   otherwise push an arbitrarily large string into pending_operations.

3. periodStatus resolution failure was silently swallowed (ISO 27001 A.8.15).
   Now logging via console.warn with operationType, companyId,
   dateForPeriodCheck, and error so a systematic outage (missing
   company_settings row, dropped query) is observable in audit logs rather
   than degraded silently.

Findings deliberately NOT addressed (pushed back to the bots):

- gnubok_reverse_journal_entry needs per-operation role check (V8.2.1) and
  narrower 'bookkeeping:reverse' scope (CC6.3) — cross-cutting refactor; no
  MCP tool in gnubok enforces per-operation roles today. Introducing it just
  for one tool would be inconsistent. Will surface as a separate item.
- Reduce line_description in reverse_entry preview (A.8.3, Art.5(1)(c)) —
  the preview is shown to the human approver who needs to see what they're
  approving under BFL 5 kap. Aggregate-only previews would harm the
  approval workflow.
- Audit company-current fields for PII (A.8.12, Art.25(1)) — vat_number,
  org_number, etc. are intentionally part of working memory; agents need
  them to make compliant booking decisions.
- Payload-size ADR reference (A.8.9) — the test comment already cites plan
  item 15 (Tool Search) as the long-term answer.
- mime_type classification label (CC7.2) — theoretical concern;
  ai_extraction_usage events are already operator-only.
- False positive: commitReverseEntry already has the closed-period check
  (V2.3); bot was hallucinating.

Tests: 3616/3616 pass. TypeScript build clean.

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

* fix(mcp,env): structured logger + description trim + env alias support

Two further follow-ups on PR #505:

1. resolvePeriodStatusForDate catch now uses the structured logger
   (createLogger from @/lib/logger) instead of console.warn. Three
   reviewers (compliance-swarm V16.1.1, ISO 27001 A.8.15, SOC 2 CC7.2)
   independently flagged that console.warn bypasses the centralized log
   aggregation pipeline used elsewhere, so systemic outages of the
   period-status resolver were invisible to the SIEM. log.warn now routes
   through the same sink as other server events.

2. Tool description for gnubok_reverse_journal_entry now routes the refund
   case explicitly to gnubok_credit_invoice. The Swedish accounting
   compliance bot flagged that the previous "cancelled credit invoice"
   example was ambiguous — a real credit invoice flow goes through
   gnubok_credit_invoice, not this tool. Description stays under 280 chars.

3. lib/init.ts: REQUIRED_EXTENSION_VARS now models each entry as a list of
   acceptable aliases instead of a single required name. The fallback in
   extensions/general/enable-banking/lib/jwt.ts already accepts the
   _PRODUCTION-suffixed variants (used by Vercel prod) as equivalent to
   the base names, but the env validator at boot didn't, so every cold
   start in prod warned about missing ENABLE_BANKING_APP_ID even though
   ENABLE_BANKING_APP_ID_PRODUCTION was set and the runtime was healthy.
   Each entry now satisfies if ANY listed alias is present; missing
   entries print all acceptable names so operators can pick either form.

Tests: 3616/3616 pass. TypeScript build clean.

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

* fix(mcp): staging tools reject locked_at periods too, not just is_closed

Swedish accounting compliance bot flagged that gnubok_reverse_journal_entry
and gnubok_correct_entry pre-flight checks only rejected closed periods —
locked-but-not-closed periods passed staging and only got rejected at
commit time. The commit-time gate was correct (both executors check
is_closed AND locked_at AND resolvePeriodStatusForDate), but the
staging-time signal was confusing: agent saw staged:true with
period_status:"locked" in the same envelope.

Now the staging pre-flight reads locked_at from the same inner-join and
rejects on either flag, matching the commit-time pattern. The error
message updated to "locked or closed" since both branches reach the same
throw. BFL 5 kap 5§ alignment is unchanged — both paths still block
mutations to locked/closed periods; only the layer at which the rejection
fires changes.

Findings pushed back (response in PR thread, not addressed here):
- companyId/mimeType in log.warn flagged as PII (overreach; tenant IDs
  are operational identifiers, not personal data, and the codebase logs
  them consistently elsewhere).
- HMAC-keyed file_name_hash instead of plain SHA-256 prefix (overreach;
  48 bits already addresses the immediate GDPR Art. 5(1)(f) concern).
- 'title' field in deadlines may contain PII (overreach; would require
  redacting every text field in every read resource).
- RLS regression test for voucher_sequences/deadlines (legitimate but
  pg-test scope; tracked for a follow-up sprint).
- Payload-size ADR record (comment already cites plan item 15).
- company-current data minimisation (already pushed back; agents need
  the fields for compliant booking decisions).
- Error message conflates "locked" and "closed" — minor UX nit not
  worth distinguishing here since the remediation step (unlock / omprövning)
  is the same for the user.
- reversal_date period attribution & voucher series integrity flagged as
  unverifiable from diff — false positives, both already handled by the
  engine (period_id from original, atomic voucher number).

Tests: 3616/3616 pass. TypeScript build clean.

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

* fix(mcp): address Swedish-accounting compliance round 4 — BFL invariant + VAT warning

Three legitimate findings from the swedish-accounting-compliance bot acted on
(out of five total; two pushed back as theoretical/false positive):

1. BFL 5 kap 5§ invariant assertion (finding 1). The engine guarantees that
   reverseEntry() posts the storno to original.fiscal_period_id (engine.ts:492
   — verified by reading the code), but the executor previously took that on
   faith. commitReverseEntry now asserts reversal.fiscal_period_id ===
   original.fiscal_period_id after the call and returns a 500 with an
   explicit "BFL invariant broken" error if the engine ever drifts. New
   executor test covers this. The reversal_date parameter is unchanged —
   it's used as the storno's entry_date (operational date), not for period
   attribution, per BFL practice (entry_date can differ from period_id's
   range for a rättelse made later).

2. resolvePeriodStatusForDate unhandled-rejection path (finding 2). Both
   commitCorrectEntry and commitReverseEntry now wrap the resolve call in
   try/catch, returning a clean Swedish 500 instead of letting the
   dispatcher surface a raw Postgres error message. Matches the
   log-and-degrade pattern already used at staging time in
   stagePendingOperation.

3. VAT-period warning in the reverse preview (finding 4 — swedish-vat).
   When the original entry contains 2610–2670 BAS accounts, the staged
   preview now includes a Swedish warnings[] field telling the approver
   that a storno is legally insufficient if the moms period has been
   filed with Skatteverket — they must use omprövning per ML 2023:200
   instead. Soft warning (not a hard block) since gnubok doesn't track
   per-VAT-period filing status today; the human decides at approval.

Pushed back:

- Finding 3 (TOCTOU between staging and commit on fiscal_period_id):
  posted entries are immutable per the enforce_journal_entry_immutability
  trigger (migration 20240101000017). fiscal_period_id can't change
  between staging and commit. Status change is already caught by the
  status !== 'posted' check.

- Finding 5 (migration 20260516060000 not wrapped in BEGIN/COMMIT):
  Supabase migration tooling runs each migration file in an implicit
  transaction. PostgreSQL DDL is transactional. The DROP/ADD pair is
  atomic in practice. The bot acknowledges this as low severity.

Tests: 3617/3617 pass (one new — BFL invariant assertion). Build 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>
2026-05-16 11:42:47 +02:00

32 KiB
Raw Blame History

CLAUDE.md — Gnubok

Project Overview

gnubok is a Swedish-focused accounting SaaS for sole traders (enskild firma) and limited companies (aktiebolag). It implements double-entry bookkeeping compliant with Swedish accounting law (Bokforingslagen), including VAT handling, tax reporting, and 7-year document retention. Multi-tenant: each user can own or be a member of multiple companies, optionally grouped into teams (for consultants).

Tech stack: Next.js 16.1.5 (App Router), React 19.2.3, TypeScript 5 (strict), Zod 4, Supabase (PostgreSQL + RLS + email/password + TOTP MFA auth), Tailwind CSS 4 + shadcn/ui, Vercel hosting, Docker (self-hosted).

Integrations: Enable Banking (PSD2), TIC Identity, Anthropic SDK, AWS Bedrock, OpenAI, Resend, Sentry, Svix, web-push, Upstash Redis, Google Drive, JSZip, sharp, Framer Motion, Recharts, PDF.js, @react-pdf/renderer, xlsx, fuse.js, ics.

Path alias: @/* maps to the project root. Language: All code, comments, and commit messages in English. License: AGPL-3.0-or-later.


Commands

npm run dev              # Start dev server (runs setup:extensions first)
npm run build            # Production build (runs setup:extensions first)
npm run lint             # ESLint
npm test                 # Run all Vitest tests
npx vitest run <dir>     # Run tests in a specific directory
npm run setup:extensions # Regenerate extension registry from extensions.config.json

Key Architectural Relationships

  • Multi-tenant model: companies owns all business data. company_members links users to companies (owner/admin/member/viewer). teams group companies. Context resolved via gnubok-company-id cookie in lib/supabase/middleware.ts.
  • All journal entry creation routes through lib/bookkeeping/engine.ts. Lifecycle: createDraftEntry() → commitEntry() (atomic voucher via commit_journal_entry RPC). createJournalEntry() does both. Reversal: reverseEntry(). Correction: correctEntry() in lib/core/bookkeeping/storno-service.ts.
  • API routes emitting events must call ensureInitialized() (lib/init.ts) at module level to load extensions and wire handlers.
  • Event bus (lib/events/bus.ts) is a module-level singleton using Promise.allSettled. 36 event types in lib/events/types.ts. Persisted to event_log table (30-day TTL).
  • Supabase clients: browser (client.ts), server cookies (createClient()), service role (createServiceClient()), cookieless service role for API keys (createServiceClientNoCookies()). Pagination: fetchAllRows().
  • Extension system: Opt-in via extensions.config.json. Core runs with zero extensions. Enabled: enable-banking, email, arcim-migration, tic, mcp-server, cloud-backup.
  • Core reports (lib/reports/): balance sheet, income statement, trial balance, general ledger, AR/supplier ledger + reconciliation, VAT declaration, journal register, monthly breakdown, continuity check, opening balances, KPI, NE-bilaga, INK2, SIE export, full archive, salary journal, vacation liability, avgifter basis.
  • Types: Shared types in types/index.ts (~2,570 lines). Import via import type { T } from '@/types'. Event types in lib/events/types.ts. Extension types in lib/extensions/types.ts.
  • Error messages: lib/errors/get-error-message.ts maps to Swedish (Zod → Postgres → HTTP → fallback).

Multi-Tenant Architecture

  • companies: Business unit. All business data has a company_id column.
  • company_members: Roles owner/admin/member/viewer, source direct|team.
  • teams: Consultant grouping. Team members auto-sync to company_members via DB triggers.
  • user_preferences: Stores active_company_id.

Context resolution (lib/supabase/middleware.ts): cookie → user_preferences.active_company_id → first membership. RLS uses user_company_ids() helper.

Invitations: company_invitations/team_invitations with gnubok_inv_ tokens (SHA-256, 7-day TTL). See lib/auth/invite-tokens.ts.


Authentication

Supabase Auth: email+password (primary), magic link (fallback), TOTP MFA. MFA enforced application-side (middleware + API routes), not in RLS.

  • NEXT_PUBLIC_SELF_HOSTED=true → MFA never enforced
  • NEXT_PUBLIC_REQUIRE_MFA=true → middleware redirects to /mfa/enroll or /mfa/verify until AAL2

API route auth (lib/auth/require-auth.ts): requireAuth() returns { user, supabase, error }, enforces MFA on hosted. API keys (lib/auth/api-keys.ts): SHA-256 hashed, gnubok_sk_ prefix. Scoped via TOOL_SCOPE_MAP. Rate limited 100 RPM via validate_and_increment_api_key RPC. Cron auth (lib/auth/cron.ts): verifyCronSecret() constant-time comparison.


Core Bookkeeping Engine

The engine (lib/bookkeeping/engine.ts) is the most critical system. All accounting flows route through it.

Lifecycle: createDraftEntry() → commitEntry() (atomic voucher via commit_journal_entry RPC). createJournalEntry() does both. reverseEntry() for storno; correctEntry() (lib/core/bookkeeping/storno-service.ts) for corrections.

Engine files: transaction-entries.ts, invoice-entries.ts (with generatePerRateLines() for mixed-rate), supplier-invoice-entries.ts, vat-entries.ts, currency-revaluation.ts, mapping-engine.ts, booking-templates.ts/counterparty-templates.ts, propose-payment-lines.ts/propose-send-lines.ts, handlers/supplier-invoice-handler.ts.

BAS data (bookkeeping/bas-data/): Full BAS 2026 chart by class (1–8) + SRU mapping.

Key BAS Accounts

1510 Accounts receivable | 1930 Business bank account | 2013 Private withdrawals (EF) | 2440 Accounts payable | 2611/2621/2631 Output VAT 25%/12%/6% | 2641 Input VAT | 2645 Calculated input VAT (EU) | 2893 Shareholder loan (AB) | 3001/3002/3003 Revenue 25%/12%/6% | 3305/3308 Export/EU service revenue

VAT Treatments

standard_25, reduced_12, reduced_6, reverse_charge, export, exempt

Invoice items support individual vat_rate values (mixed-rate invoices). Use getAvailableVatRates(customerType, vatNumberValidated) from lib/invoices/vat-rules.ts. VIES validation via lib/vat/vies-client.ts.

VAT Declaration Rutor (SKV 4700)

VatDeclarationRutor type maps to momsdeklaration:

  • Ruta 05: Domestic taxable sales (3001+3002+3003)
  • Ruta 06/07: Unused, always 0
  • Ruta 10/11/12: Output VAT 25%/12%/6% (2611/2621/2631)
  • Ruta 39/40: EU services / Export (3308/3305)
  • Ruta 48: Input VAT (2641/2645)
  • Ruta 49: Moms att betala/återfå = (10+11+12+30+31+32+60+61+62) − 48

Core Services (lib/core/)

  • bookkeeping/period-service.ts — Fiscal period lifecycle management (open, close, lock)
  • bookkeeping/year-end-service.ts — Year-end closing procedures
  • bookkeeping/storno-service.ts — Reversal/correction entry generation
  • tax/tax-code-service.ts — Tax code definitions and rates
  • audit/audit-service.ts — Audit trail and compliance logging
  • documents/document-service.ts — Document attachment lifecycle (WORM storage with version chains)

Accounting Guard Rails

These rules exist for legal compliance, enforced by database triggers. Never violate them.

  1. Committed entries are immutable. Once status: 'posted', cannot be edited or deleted (DB trigger).
  2. Never delete posted entries. Use reverseEntry() (storno) to cancel.
  3. Every entry must balance. sum(debits) === sum(credits), both > 0.
  4. Voucher numbers are sequential. Assigned atomically via commit_journal_entry DB RPC. Never set manually.
  5. Voucher gap documentation. BFNAR 2013:2 requires documented explanations for gaps (voucher_gap_explanations table, detect_voucher_gaps RPC).
  6. Period lock enforcement. DB trigger blocks writes to closed/locked periods. Company-wide lock date enforced via enforce_company_lock_date() trigger.
  7. 7-year document retention. DB triggers prevent deletion of documents linked to posted entries.
  8. Storno, never edit. Use correctEntry() from lib/core/bookkeeping/storno-service.ts.
  9. Use Math.round(x * 100) / 100 for monetary calculations. Never toFixed().
  10. Always use engine functions. Never insert directly into journal tables.
  11. Account numbers are strings. '1930', never 1930.

Extension System

Extensions are opt-in plugins in extensions/general/<name>/, controlled by extensions.config.json. Core runs with zero extensions. npm run setup:extensions generates static imports in lib/extensions/_generated/ (auto via predev/prebuild). Extensions cannot use dynamic imports.

Available (12): Enabled — enable-banking (PSD2), email (Resend), arcim-migration, tic (org lookup), mcp-server, cloud-backup (Google Drive). Disabled — inbox-smart-match, invoice-inbox, push-notifications, calendar, skatteverket, example-logger.

Registration (lib/extensions/registry.ts): Singleton. register() wires handlers. get(id), getAll(), getByCapability(key). Context (lib/extensions/context-factory.ts): ExtensionContext = userId, companyId, extensionId, supabase, emit(), settings, storage, log, services. API routes: app/api/extensions/ext/[...path]/route.ts catch-all → /api/extensions/ext/{extensionId}/{routePath}. Path params as _paramName query. Service patterns: Interface registration (email — registerEmailService()/getEmailService()) or services record (extension exposes via services property). Creating: npx tsx scripts/create-extension.ts --name my-ext --sector general --category operations --description "...".


MCP Server & API Keys

gnubok exposes its bookkeeping engine as an MCP server for Claude Desktop/Code.

MCP extension (extensions/general/mcp-server/): 35 tools covering transactions, categorization, customers/suppliers, invoices, accounts, fiscal periods, reports (trial balance, GL, BS, IS, AR/supplier ledger, VAT, KPI), reconciliation, salary runs, AGI, document upload. JSON-RPC 2.0. Endpoint: /api/extensions/ext/mcp-server/mcp.

API keys (lib/auth/api-keys.ts, api_keys table): SHA-256, gnubok_sk_ prefix, scoped via TOOL_SCOPE_MAP, 100 RPM via validate_and_increment_api_key RPC. createServiceClientNoCookies() — all queries filter by company_id (defense in depth).

OAuth 2.1 for Claude connectors: .well-known/oauth-protected-resource + .well-known/oauth-authorization-server discovery; /api/mcp-oauth/authorize, /token (PKCE), /register. Stateless AES-256-GCM auth codes (lib/auth/oauth-codes.ts). Single-use via oauth_used_codes. Allowlist: claude.ai/api/*, claude.com/api/*, localhost.

npm package (packages/gnubok-mcp): Stdio-to-HTTP bridge; users run npx gnubok-mcp with API key.

Tool authoring conventions (enforced by tests):

  • Every inputSchema must declare additionalProperties: false at the top level. Guarded by extensions/general/mcp-server/__tests__/strict-schemas.test.ts.
  • Tool descriptions must be ≤ 280 chars (guarded by output-schema.test.ts). No Args: / Returns: / Examples: blocks — those belong in JSON Schema, not description prose. Use agent-native hints like "Use to…" / "Call X first" instead.
  • Completion-signal pattern: write tools that stage operations return STAGED_OPERATION_SCHEMA (server.ts:495) — { staged, risk_level, actor, message, preview, period_status?, next? }. The staged: true boolean is the explicit completion signal; agents must not infer completion from prose. Do NOT introduce a parallel { success, shouldContinue, output } envelope.
  • Tools that touch a fiscal-period-bound date (categorize, mark paid, create voucher, correct/reverse entry, approve supplier invoice) pass dateForPeriodCheck to stagePendingOperation so the response includes period_status: { period_id, status: open|locked|closed, lock_date }. Widgets and agents use this to disable writes without round-trips.

API Route Pattern

import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { MySchema } from '@/lib/api/schemas'

ensureInitialized()  // Module-level — loads extensions for event emission

export async function POST(request: Request) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const result = await validateBody(request, MySchema)
  if (!result.success) return result.response

  // Business logic... always filter by company_id (defense in depth alongside RLS)
  return NextResponse.json({ data: result })
}
  • Dynamic route params: { params }: { params: Promise<{ id: string }> } (Next.js 16)
  • Response shapes: { data } for success, { error } for failures
  • Zod schemas in lib/api/schemas.ts — 30+ schemas with shared primitives (uuid, isoDate, accountNumber, nonNegativeAmount)

Key lib/ Directories

  • bookkeeping/ — Engine, entry generators, mapping, templates, BAS data
  • core/ — Period, year-end, storno, tax codes, audit, documents
  • events/ — Bus singleton, 36 event types, event log handler
  • auth/ — API keys, require-auth/write, MFA, OAuth codes, invite tokens, cron, BankID
  • supabase/ — Clients, middleware, fetchAllRows pagination
  • api/ — Zod validation (validateBody/validateQuery), schemas
  • reports/ — 20 report generators
  • invoices/ — Matching, payment log, reminders, VAT rules, PDF
  • transactions/ — ingest.ts, AI suggestions
  • import/ — SIE, bank file, opening balance, account mapper
  • documents/ — Matchers (single + batch)
  • extensions/ — Registry, loader, context factory
  • email/ — Service interface, Resend, templates
  • company/ — Context resolution, CRUD, fiscal period computation
  • providers/ — Fortnox, Bokio, Briox, BL, Visma (OAuth, retry, consent)
  • salary/ — Payroll engine, tax tables, AGI, KU, payslips, löneväxling, personnummer
  • processing-history/, reconciliation/, tax/, vat/ (VIES, MOMS box), deadlines/, currency/ (Riksbanken), skatteverket/, bankgiro/ (Luhn), calendar/ (ICS)
  • errors/ — Swedish error mapping (Zod → Postgres → HTTP → fallback)
  • rate-limits/ — Postgres-backed checkInboxUploadRateLimit via check_and_increment_inbox_quota RPC; fails open
  • hooks/, logger.ts, support.ts, utils.ts (cn(), formatCurrency(), formatDate(), formatOrgNumber())

App Routes

Pages: /login, /register, /reset-password, /mfa/{enroll,verify}, /onboarding, /companies/new, /invite/[token], / (dashboard), /transactions, /invoices[/new|/[id]|/[id]/credit], /supplier-invoices[/new|/[id]], /customers[/[id]], /suppliers[/[id]], /expenses[/new|/[id]], /receipts[/scan], /bookkeeping[/[id]|/year-end], /salary[/employees|/runs], /reports, /import, /kpi, /deadlines, /pending, /help, /extensions[/[sector]/[ext]], /e/[sector]/[slug] (workspace), /settings/*, /dpa, /privacy, /invoice-action/[token], /sandbox.

API endpoints:

  • /api/bookkeeping/* — accounts, fiscal periods, journal entries (CRUD/reverse/correct), mapping rules, voucher gaps
  • /api/invoices/*, /api/supplier-invoices/* — CRUD + state transitions
  • /api/transactions/* — categorize, describe, book, match-{invoice,supplier-invoice}, batch, AI suggestions
  • /api/customers/*, /api/suppliers/* — CRUD
  • /api/documents/* — CRUD, versions, link, match-sweep, verify cron
  • /api/reports/* — 19 endpoints (GL, TB, BS, IS, AR/supplier ledger, VAT, SIE, INK2, NE-bilaga, KPI, audit, continuity, monthly, full-archive, salary, vacation, avgifter)
  • /api/salary/* — employees, payroll-config, tax-tables, KU, runs
  • /api/import/* — bank-file, SIE (parse/execute/mappings)
  • /api/reconciliation/bank/*, /api/settings/*, /api/company/*, /api/team/*
  • /api/deadlines/*, /api/tax-deadlines/* — CRUD + crons
  • /api/pending-operations/*, /api/events/*, /api/audit-trail/*
  • /api/calendar/feed/[token], /api/mcp-oauth/*, /api/support/contact, /api/account/delete
  • /api/log, /api/health, /api/vat/validate, /api/currency/rate, /api/sandbox/*
  • /api/extensions/ext/[...path] — dynamic extension routes

Testing

Framework: Vitest 4, node env, tests in __tests__/. Scope: lib/ and app/api/. No component/E2E tests.

Helpers (tests/helpers.ts): createMockSupabase(), createQueuedMockSupabase(), createMockRequest(), parseJsonResponse(), createMockRouteParams(), plus fixture factories (makeTransaction, makeJournalEntry, makeInvoice, makeCustomer, makeSupplier, makeSupplierInvoice, makeFiscalPeriod, makeReceipt, makeDocumentAttachment, makeCompany, makeCompanySettings, makeTaxCode, makeSIEVoucher, makeBankConnection, etc.).

Patterns: Always mock @/lib/supabase/server. vi.clearAllMocks() + eventBus.clear() in beforeEach. Test auth (401), validation (400), 404, 500, happy path.

pg-real: Parallel Vitest project for triggers/RPCs/RLS using real Postgres (CI: supabase/postgres:15, migrations replayed). Local: npm run test:pg. File convention *.pg.test.ts. Helpers: tests/pg/setup.ts (getPool(), withUserContext()), tests/pg/fixtures.ts (seedCompany(), insertDraftJournalEntry(), etc.). Required: any PR touching a trigger/RPC/RLS/DEFERRABLE must include or extend a *.pg.test.ts.


Database & Migrations

Location: supabase/migrations/ — 118 files. Early migrations use sequential numbering (20240101000001–20240101000038), later ones use real timestamps.

Key Tables (~60)

  • Multi-tenant: companies, company_members, company_invitations, teams, team_members, team_invitations, user_preferences, profiles
  • Bookkeeping: chart_of_accounts, fiscal_periods, journal_entries, journal_entry_lines, account_balances, voucher_sequences, voucher_gap_explanations
  • Invoicing: customers, invoices, invoice_items, invoice_payments, invoice_inbox_items
  • Suppliers: suppliers, supplier_invoices, supplier_invoice_items
  • Banking: bank_connections, transactions, bank_file_imports, payment_match_log
  • Documents: document_attachments (WORM), receipts, receipt_line_items
  • Settings: company_settings, mapping_rules, categorization_templates, booking_template_library, extension_data
  • Dimensions: cost_centers, projects
  • Tax/Deadlines: tax_rates, tax_table_rates, deadlines, calendar_feeds, skatteverket_tokens
  • API/Auth: api_keys, oauth_used_codes, bankid_identities
  • Audit/Ops: audit_log (immutable), event_log (30d TTL), pending_operations, processing_history, ai_usage_tracking, automation_webhooks
  • Inbox: invoice_inbox_items, company_inboxes, email_connections
  • Salary: employees, salary_runs, salary_run_employees, salary_line_items, salary_payroll_config, agi_declarations
  • Providers: provider_consents, provider_consent_tokens, provider_otc
  • Other: sandbox_users

Key RPC Functions

  • create_company_with_owner() — Atomic company + owner creation
  • commit_journal_entry() — Atomic draft→posted with voucher number
  • next_voucher_number() — Concurrent-safe voucher generation
  • detect_voucher_gaps() — BFNAR 2013:2 gap detection
  • generate_invoice_number(), get_next_arrival_number(), generate_delivery_note_number() — Sequence generators
  • seed_chart_of_accounts() — BAS chart seeding per entity type
  • validate_and_increment_api_key() — Atomic rate limiting
  • user_company_ids() — RLS helper returning user's company IDs
  • get_unlinked_1930_lines() — Bank reconciliation helper
  • cleanup_sandbox_user(), cleanup_expired_sandbox_users() — Sandbox lifecycle

Key Triggers

  • check_journal_entry_balance() — Debit must equal credit
  • enforce_journal_entry_immutability() — Posted entries cannot be modified
  • enforce_period_lock() — No entries in closed/locked periods
  • enforce_company_lock_date() — Company-wide bookkeeping lock date
  • block_document_deletion() — WORM compliance
  • enforce_retention_journal_entries() — 7-year retention
  • audit_log_immutable() — Audit log cannot be modified
  • write_audit_log() — Auto-audit on DML operations
  • sync_team_member_to_companies() — Auto-sync team→company membership

Migration Rules

  1. Enable RLS + policies using user_company_ids() for company-scoped data
  2. Add updated_at trigger via update_updated_at_column()
  3. UUID PKs: DEFAULT uuid_generate_v4()
  4. Company ownership: company_id UUID REFERENCES companies NOT NULL + user_id UUID REFERENCES auth.users ON DELETE CASCADE NOT NULL
  5. Never modify existing migrations — create new ones
  6. Never modify enforcement triggers (migration 017) — legally required
  7. Apply via Supabase MCP apply_migration
  8. Always end with NOTIFY pgrst, 'reload schema' when altering table structure

Skills, Git & CI

Skills: Always use /frontend-design for new UI. Use vercel:deploy for deployment. Use /supabase-migration for new migrations. Use /erp-api-route for new API routes. Use /create-extension for new extensions. Use the Swedish domain skills (swedish-sie-import-export, swedish-accounting-compliance, swedish-vat, swedish-invoice-compliance, swedish-payroll, swedish-year-end-closing, swedish-financial-reporting, swedish-sru-filing, swedish-asset-accounting, swedish-project-accounting, swedish-tax-planning) for accounting domain questions.

Git: Conventional commits (feat:, fix:, refactor:, test:, docs:). Atomic commits, branch from main.

CI:

  • .github/workflows/core-build.yml — resets extensions to empty, runs build + test, verifies no core code imports from @/extensions/ directly.
  • .github/workflows/swedish-compliance-review.yml — Swedish accounting compliance review on PRs touching bookkeeping/reports/tax logic.
  • .github/workflows/docker-publish.yml — pushes images to GHCR on main.

Docker (.github/workflows/docker-publish.yml): Pushes to GHCR (erp-mafia/erp-base) on main push. 4-stage Dockerfile (base → deps → builder → runner) with Node 22 Alpine. Runtime env placeholder replacement via docker-entrypoint.sh. Docker Compose with app + supercronic cron service.


Deployment

Vercel (Hosted)

Cron jobs in vercel.json: deadline status (6:00), invoice reminders (8:00), tax deadlines (yearly Jan 2), enable-banking sync (5:00), document verify (3:00), sandbox cleanup (4:00), event log cleanup (2:00, 30-day TTL), cloud-backup auto-sync (hourly).

Docker (Self-Hosted)

  • Dockerfile: 4-stage Node 22 Alpine build with standalone output
  • docker-compose.yml: App service + supercronic cron scheduler
  • docker-entrypoint.sh: Validates required env vars, replaces build-time placeholders in .next/static/ JS
  • Extension presets: docker/extensions.self-hosted.json, docker/extensions.hosted.json

Environment Variables

Required: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, NEXT_PUBLIC_APP_URL, CRON_SECRET

Auth: NEXT_PUBLIC_REQUIRE_MFA (set true on hosted), NEXT_PUBLIC_SELF_HOSTED (set true for Docker)

Extension-specific (only when extension is enabled): ENABLE_BANKING_APP_ID/ENABLE_BANKING_APP_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, RESEND_API_KEY, VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY

Optional: SENTRY_DSN, SENTRY_AUTH_TOKEN

Other

Never create a NUL/nul file: \gnubok\NUL


Design Context

Users

Swedish sole traders (enskild firma) and small business owners (aktiebolag) who need to manage their own bookkeeping. They are not accountants — they are professionals (consultants, freelancers, shop owners) who want to stay compliant without hiring one. They use gnubok in short, focused sessions: sending an invoice, categorizing bank transactions, filing a VAT declaration. Speed and clarity matter — every second spent in the app is a second away from their real work.

Brand & Aesthetic

Editorial monochrome. Paper-white surfaces, hairline borders, serif headlines. The interface should feel like a well-made instrument — considered, quiet, confident. Anti-references: enterprise software (SAP/Oracle density), neon SaaS coldness.

  • Palette: Achromatic foundation. Pure white background, warm beige (40 11% 89%) for chips / active sidebar / hover / secondary buttons. Achromatic primary (no cool tint). Semantic colors (--success sage, --warning ochre, --destructive terracotta) exist but are data-only — they appear in charts and financial numbers (positive/negative deltas), never as chrome backgrounds. In chrome, only --destructive survives.
  • Typography: Hedvig Letters Serif for display headings, Geist (sans) for body, forms, and tables. Hedvig is single-weight (400) — do not apply font-medium to display text; its natural high-contrast strokes carry the weight. Tabular numbers everywhere financial data appears.
  • Surfaces: Cards sit flat on the page — no shadow, full-opacity hairline border (border-border), rounded-lg (8px). Card background matches page background; the border carries hierarchy. Dark mode drops the warm tint from secondary for a pure-gray mood shift; light mode keeps the beige.
  • Spacing: Generous whitespace. Dense data (tables, ledgers) uses tighter spacing but never feels cramped.
  • Motion: Functional, not decorative. No press-scale, no hover-lift, no spring overshoot. Hover state is a flat background shift (bg-secondary/60). transition-colors duration-150 is the default. Stagger animations on list entry are fine. Respect prefers-reduced-motion (already wired).
  • Icons: Lucide — 15px in navigation, slightly larger in empty states.

Design Principles

  1. Clarity over cleverness — Swedish labels, obvious hierarchy.
  2. Earned minimalism — remove what doesn't serve the task, keep compliance context.
  3. Numbers are first-class — tabular-nums, alignment, positive/negative clarity.
  4. Trust through consistency.
  5. Speed is a feature — optimize for the 90-second session.

Accessibility

WCAG AA (4.5:1 text, 3:1 UI). Keyboard-navigable + visible focus rings. Respect prefers-reduced-motion. Color never sole state indicator. Touch targets ≥40px (44px for mobile-critical). Icon-only buttons need aria-label.

Design System Tokens

These conventions are locked. Don't reinvent them in new code; deviating from them on existing pages is a regression.

Spacing scale. Only use Tailwind values 1, 2, 3, 4, 6, 8, 10, 12. Forbidden: 2.5, 5, hardcoded pixels in page logic.

Token Tailwind Use for
4 1 icon padding
8 2 tight inline gaps
12 3 dense list rows, badge gaps
16 4 default form / control / grid gap
24 6 card padding default (p-6)
32 8 between page sections (space-y-8 on page root)
40 10 hero spacing
48 12 top of page after header

Compact metric cards (e.g. dashboard tiles, salary KPI row) use p-4. Detail cards use p-6. Never mix p-5.

Layout.

  • Sidebar width: md:w-64 (256px). Main content offset: md:pl-64.
  • Main container: max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10 (via components/dashboard/MainContainer.tsx).
  • Page root: <div className="space-y-8">.

Primitives — always use these, don't hand-roll.

Need Component Notes
Page title + action components/ui/page-header.tsx PageHeader Use this, not bespoke <h1> + <p> blocks. Drop the description prop when it just paraphrases the title.
Data table components/ui/table.tsx Table / TableHeader / TableHead / TableRow / TableCell Header style is baked in: text-[11px] font-medium uppercase tracking-wider text-muted-foreground. Wrap in <CardContent className="p-0"> when the table is a card's primary content. Add tabular-nums to numeric cells.
Status indicator components/ui/badge.tsx <Badge variant> Variants: default / secondary / success / warning / destructive / outline. Never use raw Tailwind colors (bg-blue-100, bg-emerald-500/10, etc.) for status. Map status → variant via a small Record per feature.
No-data state components/ui/empty-state.tsx EmptyState Don't hand-roll <div className="flex flex-col items-center py-12">…</div>. Preset variants exist (EmptyInvoices, EmptyCustomers, EmptyTransactions, etc.).
Loading placeholder components/ui/skeleton.tsx <Skeleton> Don't hand-roll bg-muted rounded animate-pulse divs.
Inline help / formulas components/ui/info-tooltip.tsx InfoTooltip Hover-revealed; don't use always-visible info buttons.
Fiscal year picker components/common/FiscalYearSelector.tsx Don't use raw <select> for fiscal periods.

Tabular display rules.

  • All financial values get tabular-nums.
  • Dates in tables: tabular-nums for fixed width.
  • Right-align numeric columns (text-right).
  • For group bands inside tables (Resultatrapport-style): <tr className="bg-muted/30"><td colSpan={n} className="px-4 py-2 text-[12px] font-semibold text-muted-foreground">{label}</td></tr>.

Date formatting. Two helpers in lib/utils.ts:

  • formatDate(x) → 2026-05-11 (ISO yyyy-MM-dd). Use for accounting data — transaction dates, invoice dates, payment dates, voucher dates. Aligns in tables, matches SIE/BFL convention.
  • formatDateLong(x) → 11 maj 2026 (Swedish long form). Use for metadata — when something was created, linked, verified, expires. Settings panels and audit displays.

Never render raw {x.invoice_date} directly — always route through formatDate() for code consistency.

Currency. formatCurrency(n, currency?) from lib/utils.ts. Default SEK.

Typography.

  • Page title: use PageHeader (renders font-display text-3xl md:text-4xl tracking-tight). Do not hand-roll an <h1>.
  • Card title: <CardTitle className="text-base"> for sections, default for primary cards. The primitive already drops font-medium — do not add it back.
  • Section divider header inside a page: <h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">.
  • Headline number: font-display text-xl tabular-nums. No font-medium — Hedvig's natural weight carries the gravitas.
  • Display font (font-display, Hedvig Letters Serif) reserved for h1/h2/h3 and primary financial numbers. If a specific font-display numeral reads weak inside a compact metric card, override that call site with font-sans tabular-nums (Geist) — better legibility on small numerals.

Forbidden / dead patterns.

  • Page descriptions that paraphrase the page title (e.g. <PageHeader title="Fakturor" description="Hantera dina fakturor">) → drop the description.
  • Two different status indicators on the same element (e.g. colored card border and Badge for status) → pick one (prefer Badge).
  • Mobile-specific <select> duplicating desktop tabs in code — use a single Tabs primitive or a single grouped Select.
  • Hand-rolled icon buttons smaller than h-10 w-10. Use shadcn Button size="icon".
  • Color-coded status using full-rainbow Tailwind palette (bg-amber-100, bg-emerald-500/10, etc.). Use Badge variants tied to the brand palette.
  • shadow-sm / shadow-md / shadow-lg on cards, buttons, or list items. The aesthetic is flat-with-hairlines — surfaces use border-border, not elevation. Shadows survive only on dialogs/popovers/dropdowns (anything that overlays the page).
  • active:scale-[...] on buttons. Buttons do not bounce.
  • bg-gradient-to-* on page or card backgrounds. Flat surfaces only.
  • font-medium on display elements (font-display, h1/h2/h3, CardTitle, PageHeader title). Hedvig is single-weight by design.
  • rounded-xl (12px) on cards. Cards are rounded-lg (8px). rounded-xl survives only on prominent hero-style surfaces if absolutely needed.
  • Opacity-suffixed border classes (border-border/30, border-border/60) on cards and primary surfaces. Use full-opacity border-border — the new border token is calibrated for that.