KPI page: removed operational grid (Intäktstillväxt, Kostnadsandel, Snittbetaltid — all usually empty or redundant). Now shows 4 cards (Resultat, Kassa, Kundfordringar, Moms) + trend chart. Removed unused fields from KPIReport type and simplified API route. CLAUDE.md: documented MCP server extension, API key infrastructure, OAuth 2.1 flow, gnubok-mcp npm package, KPI page, cookieless Supabase client, and updated migration count (65 → 70). Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
14 KiB
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.
Tech stack: Next.js 16 (App Router), React 19, TypeScript (strict), Supabase (PostgreSQL + RLS + email/password + TOTP MFA auth), Tailwind CSS 4 + shadcn/ui, Vercel hosting.
Integrations: Enable Banking (PSD2), Anthropic SDK, LangChain, OpenAI (embeddings), Resend (email), JSZip (archive export).
Path alias: @/* maps to the project root. Language: All code, comments, and commit messages in English.
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
- All journal entry creation routes through
lib/bookkeeping/engine.tsviacreateJournalEntry(). - API routes that emit events must call
ensureInitialized()(fromlib/init.ts) at module level. - Event bus (
lib/events/bus.ts) is a module-level singleton. Handlers run viaPromise.allSettled. - Supabase clients: browser (
lib/supabase/client.ts), server with cookies (createClient()fromserver.ts), service role (createServiceClient()), cookieless service role for API key auth (createServiceClientNoCookies()fromlib/auth/api-keys.ts). - Extension system: Opt-in via
extensions.config.json. Core builds and runs with zero extensions. - NE-bilaga, INK2 declaration, SRU export, and full archive export are core reports (in
lib/reports/), not extensions. - AI consent gate (
lib/extensions/ai-consent.ts): AI extensions (receipt-ocr,ai-categorization,ai-chat) require user consent before API calls. Returns403 AI_CONSENT_REQUIREDif missing. - Types: All shared types in
types/index.ts(single source of truth). Import viaimport type { T } from '@/types'. Event types live inlib/events/types.ts.
Authentication
Supabase Auth with email+password (primary) and magic link (fallback). MFA via TOTP is supported.
MFA is enforced application-side (middleware + API routes), not in RLS policies. Controlled by two env vars:
NEXT_PUBLIC_SELF_HOSTED=true→ MFA never enforced (users can enable voluntarily)NEXT_PUBLIC_REQUIRE_MFA=true(hosted/Vercel) → middleware redirects to/mfa/enrollor/mfa/verifyuntil AAL2
Core Bookkeeping Engine
The engine (lib/bookkeeping/engine.ts) is the most critical system. All accounting flows route through it.
Lifecycle: createDraftEntry() → commitEntry() (assigns voucher number via DB RPC). Convenience: createJournalEntry() does both in one call. Reversal via reverseEntry() (storno). Correction via correctEntry() in lib/core/bookkeeping/storno-service.ts.
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). generatePerRateLines() in invoice-entries.ts groups by rate. Use getAvailableVatRates(customerType, vatNumberValidated) from lib/invoices/vat-rules.ts.
VAT Declaration Rutor (SKV 4700)
The VatDeclarationRutor type maps to the Swedish tax authority's momsdeklaration form:
- Ruta 05: Momspliktig försäljning — total domestic taxable sales (all rates combined, from 3001+3002+3003)
- Ruta 06/07: Unused (momspliktiga uttag / vinstmarginalbeskattning), always 0
- Ruta 10/11/12: Utgående moms 25%/12%/6% — output VAT per rate (from 2611/2621/2631)
- Ruta 39/40: EU services / Export (from 3308/3305)
- Ruta 48: Ingående moms — input VAT (from 2641/2645)
- Ruta 49: Moms att betala/återfå = (ruta 10 + 11 + 12) - ruta 48
VatDeclaration.breakdown.invoices also includes base25/base12/base6 for per-rate revenue breakdown in the UI.
Accounting Guard Rails
These rules exist for legal compliance, enforced by database triggers. Never violate them.
- Committed entries are immutable. Once
status: 'posted', cannot be edited or deleted (DB trigger). - Never delete posted entries. Use
reverseEntry()(storno) to cancel. - Every entry must balance.
sum(debits) === sum(credits), both> 0. - Voucher numbers are sequential. Assigned via DB RPC. Never set manually.
- Period lock enforcement. DB trigger blocks writes to closed/locked periods.
- 7-year document retention. DB triggers prevent deletion of documents linked to posted entries.
- Storno, never edit. Use
correctEntry()fromlib/core/bookkeeping/storno-service.ts. - Use
Math.round(x * 100) / 100for monetary calculations. NevertoFixed(). - Always use engine functions. Never insert directly into journal tables.
- Account numbers are strings.
'1930', never1930.
Extension System
Extensions are opt-in plugins in extensions/general/<name>/, controlled by extensions.config.json. Core builds and runs with zero extensions. npm run setup:extensions generates static imports in lib/extensions/_generated/ (runs automatically via predev/prebuild). Extensions cannot use dynamic imports (Next.js bundling).
API routes: Dispatched via catch-all at app/api/extensions/ext/[...path]/route.ts. URL: /api/extensions/ext/{extensionId}/{routePath}. Path params extracted as _paramName search params.
Service provider patterns:
- Interface registration (email): Core defines noop default in
lib/email/service.ts, extension callsregisterEmailService(), core usesgetEmailService(). - Services record (ai-categorization): Extension exposes via
servicesproperty, core looks up viaextensionRegistry.get('id')?.services?.method(...).
Creating extensions: npx tsx scripts/create-extension.ts --name my-ext --sector general --category operations --description "...", then add to extensions.config.json.
MCP Server & API Keys
gnubok exposes its bookkeeping engine as an MCP (Model Context Protocol) server, letting users do bookkeeping through Claude Desktop, Claude Code, or any MCP-compatible client.
MCP extension (extensions/general/mcp-server/): 10 tools — transactions, categorization, customers, invoices, trial balance, VAT report, KPI report, income statement. JSON-RPC 2.0 protocol implemented directly (no SDK dependency). Endpoint: /api/extensions/ext/mcp-server/mcp.
API key infrastructure (lib/auth/api-keys.ts, api_keys table): SHA-256 hashed keys with gnubok_sk_ prefix. Rate limited at 100 RPM via atomic DB RPC (validate_and_increment_api_key). createServiceClientNoCookies() creates a Supabase service client without cookies for API key auth — all queries filter by user_id (defense in depth).
OAuth 2.1 for Claude Desktop connectors (beta — Claude's callback has a known issue, see #78):
.well-known/oauth-protected-resourceand.well-known/oauth-authorization-server— discovery endpoints (excluded from auth middleware)/api/mcp-oauth/authorize— consent page + auth code generation/api/mcp-oauth/token— PKCE verification + API key creation/api/mcp-oauth/register— dynamic client registration- Stateless encrypted auth codes (AES-256-GCM via
lib/auth/oauth-codes.ts) - Single-use enforcement via
oauth_used_codestable - Redirect URI allowlist:
claude.ai/api/*,claude.com/api/*,localhost
npm package (packages/gnubok-mcp): Published as gnubok-mcp on npm. Stdio-to-HTTP bridge for Claude Desktop. Users configure npx gnubok-mcp with their API key.
KPI page (/kpi): 4 metrics (Resultat, Kassa, Kundfordringar, Moms) + monthly trend chart. API at /api/reports/kpi.
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 user_id (defense in depth alongside RLS)
// Wrap journal entry creation in try/catch (non-blocking side effect)
return NextResponse.json({ data: result })
}
- Dynamic route params:
{ params }: { params: Promise<{ id: string }> }(Next.js 16) - Response shapes:
{ data }for success,{ error }for failures
Testing
Framework: Vitest 4, globals: true, environment: 'node'. Tests colocated in __tests__/ directories. Scope: business logic in lib/ and API routes in app/api/. No component or E2E tests.
Test helpers (tests/helpers.ts): createMockSupabase(), createQueuedMockSupabase(), createMockRequest(), parseJsonResponse(), createMockRouteParams(), and fixture factories (makeTransaction(), makeJournalEntry(), makeInvoice(), makeCustomer(), makeSupplier(), makeSupplierInvoice(), makeFiscalPeriod(), makeReceipt(), makeDocumentAttachment(), makeCompanySettings(), makeInvoiceInboxItem(), etc.).
Patterns: Always mock @/lib/supabase/server. Use vi.clearAllMocks() and eventBus.clear() in beforeEach. API route tests: mock @/lib/init and lib functions, test auth (401), validation (400), not found (404), errors (500), happy path.
Database & Migrations
Location: supabase/migrations/ — 70 files. Early migrations use sequential numbering (20240101000001–20240101000038), later ones use real timestamps.
Migration Rules
- Always enable RLS and create
SELECT/INSERT/UPDATEpolicies usingauth.uid() = user_id - Always add
updated_attrigger usingupdate_updated_at_column() - UUID primary keys:
DEFAULT uuid_generate_v4() - User ownership:
user_id UUID REFERENCES auth.users ON DELETE CASCADE NOT NULL - Never modify existing migrations — create new ones
- Never modify enforcement triggers (migration 017) — legally required
- Apply via Supabase MCP tool:
mcp__plugin_supabase_supabase__apply_migration
Skills, Git & CI
Skills: Always use /frontend-design for new UI. Use langchain for AI features. Use vercel:deploy for deployment.
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.
Deployment
Hosted on Vercel. Cron jobs defined in vercel.json (banking sync, deadlines, reminders, tax deadlines, document verification, sandbox cleanup).
Core env vars: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, NEXT_PUBLIC_APP_URL, CRON_SECRET. Auth env vars: NEXT_PUBLIC_REQUIRE_MFA (set true on hosted), NEXT_PUBLIC_SELF_HOSTED (set true for Docker). Extension env vars only needed when that extension is enabled.
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
Minimal. Sharp. Efficient. The interface should feel like a well-made instrument: considered, quiet, and confident. Reference: Mercury (banking). Anti-reference: enterprise software (SAP/Oracle density).
- Palette: Grayscale foundation with restrained semantic colors — sage green (success/balance), terracotta (errors/overdue), ochre (warnings/attention). No loud brand color.
- Typography: Fraunces (serif) for display headings, Geist (sans) for body. Tabular numbers everywhere financial data appears.
- Surfaces: White/near-white cards on light gray backgrounds. Subtle borders (60% opacity). Soft shadows. Dark mode follows the same restraint.
- Spacing: Generous whitespace. Dense data (tables, ledgers) uses tighter spacing but never feels cramped.
- Motion: Subtle and purposeful. Stagger animations for list entry, spring easing for feedback. Never decorative.
- Icons: Lucide — 15px in navigation, slightly larger in empty states.
Design Principles
- Clarity over cleverness. Every element immediately understandable. Clear labels (in Swedish), obvious hierarchy.
- Earned minimalism. Remove what doesn't serve the task, but don't strip context that prevents compliance errors.
- Numbers are first-class. Tabular-nums, proper alignment, adequate contrast, clear positive/negative distinction.
- Trust through consistency. Same patterns, spacing, and behavior everywhere.
- Speed is a feature. Optimize for the 90-second session.
Accessibility
- WCAG AA: 4.5:1 text contrast, 3:1 UI components
- Keyboard-navigable with visible focus rings
- Respect
prefers-reduced-motion - Color never sole indicator of state — always pair with icons, text, or shape