Files
accounted/CLAUDE.md
T
Jakob Wennberg 54d8b2cda5 fix: pin search_path on all DB functions and remove dashboard subtitle
- Add migration 051 to SET search_path = public on all 24 custom
  functions, preventing search_path injection attacks
- Remove dashboard subtitle (status summary line)
- Update CLAUDE.md with new migration reference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:18:01 +01:00

17 KiB
Raw Blame History

CLAUDE.md — erp-base

Project Overview

erp-base 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 + magic link auth), Tailwind CSS 4 + shadcn/ui, Vercel hosting.

Integrations: Enable Banking (PSD2), Anthropic SDK, LangChain, OpenAI (embeddings), Resend (email), web-push (VAPID).

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

Architecture

app/
  (auth)/              Login, auth callback
  (onboarding)/        6-step setup wizard
  (dashboard)/         Authenticated routes (invoices, customers, transactions,
                       bookkeeping, reports, suppliers, supplier-invoices,
                       receipts, deadlines, settings, help, import, extensions)
  (public)/            Public invoice action links (no auth)
  api/                 API routes organized by domain

components/
  ui/                  shadcn/ui primitives
  bookkeeping/         Chart of accounts, journal entry form/list/review
  chat/                ChatWidget, ChatPanel
  extensions/          Extension marketplace UI, general/ workspace components
  transactions/        Transaction list, categorization, booking, VAT treatment
  (+ customers/, dashboard/, deadlines/, import/, invoices/,
     onboarding/, reports/, settings/, suppliers/)

extensions/general/    Config-driven extensions (see Extension System below)

lib/
  api/                 Zod validation schemas (schemas.ts) and helpers (validate.ts)
  bookkeeping/         Core journal entry engine and all entry generators
    engine.ts          Draft/commit workflow, balance validation, voucher numbering
    invoice-entries.ts Sales invoice journal entries (per-line VAT rates)
    transaction-entries.ts  Bank transaction journal entries
    supplier-invoice-entries.ts  Purchase invoice journal entries
    category-mapping.ts     Category-to-BAS-account mapping
    mapping-engine.ts       Rule-based auto-categorization (MCC, merchant patterns)
    bas-reference.ts        BAS account catalog (~180 accounts)
    handlers/               Booking handler functions
  core/
    bookkeeping/       Period service, storno reversal, year-end closing
    documents/         Document archive (upload, versioning, SHA-256 integrity)
    audit/             Audit trail service
    tax/               Tax code service
  email/               EmailService interface + NoopEmailService default
  events/              Event bus (bus.ts, types.ts) — singleton, core emits, extensions subscribe
  extensions/          Extension system (loader, registry, types, hooks, context-factory)
    _generated/        Code-generated files (DO NOT EDIT)
  import/              SIE parser, bank file parser (10 Swedish bank formats)
  invoices/            VAT rules, invoice matching, PDF template, reminders
  reconciliation/      Bank reconciliation engine (4-pass matching)
  reports/             Financial reports (trial balance, income statement, balance sheet,
                       VAT declaration, SIE export, general ledger, NE-bilaga, SRU export)
  supabase/            Client setup (client.ts = browser, server.ts = server)
  tax/                 Tax calculations, deadlines, Swedish holidays
  vat/                 VIES validation, moms box mapping
  init.ts              Extension loader (idempotent, called by API routes)

types/index.ts         Canonical type definitions (single source of truth)
types/chat.ts          Chat types
tests/helpers.ts       Mock factories and fixture builders
supabase/migrations/   SQL migration files (45 files)
extensions.config.json Extension opt-in configuration

Key Relationships

  • All journal entry creation routes through lib/bookkeeping/engine.ts via createJournalEntry().
  • API routes that emit events must call ensureInitialized() (from lib/init.ts) at module level.
  • Event bus (lib/events/bus.ts) is a module-level singleton. Handlers run via Promise.allSettled.
  • Supabase clients: browser (lib/supabase/client.ts), server with cookies (createClient() from server.ts), service role (createServiceClient()).
  • Extension system: Opt-in via extensions.config.json. Core builds and runs with zero extensions.
  • NE-bilaga and SRU export are core reports (in lib/reports/), not extensions.

Core Bookkeeping Engine

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

Journal Entry Lifecycle

  1. createDraftEntry(userId, input) — Creates status: 'draft', voucher_number: 0. Validates balance. Emits journal_entry.drafted.
  2. commitEntry(userId, entryId) — Assigns voucher number via DB RPC (concurrent-safe). Sets status: 'posted'. Emits journal_entry.committed.
  3. createJournalEntry(userId, input) — Draft + commit in one call. This is what all entry generators use.
  4. reverseEntry(userId, entryId) — Storno reversal: swaps debit/credit, links via reverses_id/reversed_by_id.

Entry Generators

Function File Purpose
createInvoiceJournalEntry() invoice-entries.ts Debit 1510, Credit 30xx + 26xx VAT (per-line VAT rates)
createInvoicePaymentJournalEntry() invoice-entries.ts Debit 1930, Credit 1510
createCreditNoteJournalEntry() invoice-entries.ts Reverses original invoice entry
createTransactionJournalEntry() transaction-entries.ts Maps bank transactions via MappingResult
createSupplierInvoiceRegistrationEntry() supplier-invoice-entries.ts Debit expense + 2641, Credit 2440
createSupplierInvoicePaymentEntry() supplier-invoice-entries.ts Debit 2440, Credit 1930

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.

Bank Reconciliation

lib/reconciliation/bank-reconciliation.ts — 4-pass matching on account 1930:

  1. auto_exact (0.95) — exact amount + exact date
  2. auto_reference (0.90) — exact amount + reference match
  3. auto_date_range (0.85) — exact amount + date ±3 days
  4. auto_fuzzy (0.75) — fuzzy amount (±0.01) + exact date

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 via DB RPC. Never set manually.
  5. Period lock enforcement. DB trigger blocks writes to closed/locked periods.
  6. 7-year document retention. DB triggers prevent deletion of documents linked to posted entries.
  7. Storno, never edit. Use correctEntry() from lib/core/bookkeeping/storno-service.ts.
  8. Use Math.round(x * 100) / 100 for monetary calculations. Never toFixed().
  9. Always use engine functions. Never insert directly into journal tables.
  10. Account numbers are strings. '1930', never 1930.

Extension System

Extensions are opt-in plugins controlled by extensions.config.json. Core builds and runs with zero extensions.

How It Works

  1. Each extension has a manifest.json in extensions/general/<name>/.
  2. extensions.config.json lists enabled extension IDs.
  3. npm run setup:extensions generates files in lib/extensions/_generated/ (static imports, workspace map, definitions).
  4. predev/prebuild hooks run this automatically.

Available Extensions

Extension Category Env Vars Required
receipt-ocr import ANTHROPIC_API_KEY
ai-categorization operations ANTHROPIC_API_KEY, OPENAI_API_KEY
ai-chat operations ANTHROPIC_API_KEY, OPENAI_API_KEY
push-notifications operations VAPID keys
invoice-inbox import ANTHROPIC_API_KEY
calendar operations
enable-banking import Enable Banking keys
email operations RESEND_API_KEY, RESEND_FROM_EMAIL

Creating Extensions

npx tsx scripts/create-extension.ts --name my-ext --sector general --category operations --description "..."

Then add "my-ext" to extensions.config.json and run npm run setup:extensions.

Constraints: Extensions cannot use dynamic imports (Next.js bundling).

Extension Interface

interface Extension {
  id: string; name: string; version: string; sector?: SectorSlug
  // All optional surfaces:
  apiRoutes?: ApiRouteDefinition[]
  eventHandlers?: ExtensionEventHandler[]
  services?: Record<string, (...args: any[]) => Promise<any>>
  sidebarItems?: SidebarItem[]
  mappingRuleTypes?: MappingRuleTypeDefinition[]
  settingsPanel?: SettingsPanelDefinition
  onInstall?(ctx: ExtensionContext): Promise<void>
  onUninstall?(ctx: ExtensionContext): Promise<void>
}

Extension Context

Handlers receive an ExtensionContext with: userId, extensionId, supabase (pre-authenticated), emit(), settings (JSONB key-value), storage (Supabase Storage), log (scoped logger), services.

Extension API Routes

Dispatched via catch-all at app/api/extensions/ext/[...path]/route.ts. URL scheme: /api/extensions/ext/{extensionId}/{routePath}

Path params extracted as _paramName search params (e.g., /:idsearchParams.get('_id')).

Service Provider Patterns

Interface registration (email pattern): Core defines interface with noop default in lib/email/service.ts. Extension calls registerEmailService() at load time. Core uses getEmailService() — degrades gracefully.

Services record (ai-categorization pattern): Extension exposes functions via services property. Core looks up via registry: extensionRegistry.get('ai-categorization')?.services?.findSimilarTemplates(...).

Event Types (lib/events/types.ts)

journal_entry.drafted/committed/corrected | document.uploaded | invoice.created/sent | credit_note.created | transaction.synced/categorized/reconciled | period.locked/year_closed | customer.created | receipt.extracted/matched/confirmed | supplier_invoice.received/extracted/confirmed


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() — Supabase mocks
  • createMockRequest(), parseJsonResponse(), createMockRouteParams() — API route testing
  • Fixture factories: makeTransaction(), makeJournalEntry(), makeInvoice(), makeCustomer(), makeSupplier(), makeFiscalPeriod(), makeReceipt(), makeDocumentAttachment(), makeCompanySettings(), 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/ — 45 files, numbered 2024010100000120240101000045. Next migration: 20240101000052_*.sql

Placeholder Migrations

Some migrations are no-op placeholders to preserve the numbering sequence:

  • 012 (tax_codes) — Planned but never deployed. The system operates without the tax_codes table.
  • 023 (document_version_chain) — Planned but never deployed. Document versioning columns/functions do not exist in production.

Migration Rules

  1. Always enable RLS and create SELECT/INSERT/UPDATE policies using auth.uid() = user_id
  2. Always add updated_at trigger using update_updated_at_column()
  3. UUID primary keys: DEFAULT uuid_generate_v4()
  4. User ownership: 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 tool: mcp__plugin_supabase_supabase__apply_migration

Key Enforcement Triggers (migration 017)

  • enforce_journal_entry_immutability — Blocks edits/deletes on posted/reversed entries
  • enforce_journal_entry_line_immutability — Blocks line modifications on committed entries
  • enforce_period_lock — Blocks writes to closed/locked fiscal periods
  • block_document_deletion — Prevents deletion of documents linked to committed entries
  • enforce_retention_journal_entries — 7-year retention enforcement
  • set_committed_at — Auto-sets timestamp on draft-to-posted transition
  • calculate_retention_expiry — Auto-sets retention_expires_at = period_end + 7 years

Recent Migrations

  • Migration 039 (invoice_inbox) — Invoice inbox table with document type classification, AI extraction, supplier/transaction matching, and receipt linking.
  • Migration 040 (booking_template_embeddings) — Booking templates with AI embeddings for suggestion matching.
  • Migration 041 (user_description_matching) — User description matching for transaction categorization.
  • Migration 042 (prevent_overlapping_fiscal_periods) — Exclusion constraint preventing overlapping fiscal periods per user.
  • Migration 043 (enforce_fiscal_period_month_boundaries) — Ensures fiscal periods start/end on month boundaries.
  • Migration 044 (full_bas_2026) — Full BAS 2026 account catalog, K2-excluded flag, and SRU code backfill.
  • Migration 045 (expand_account_type_untaxed_reserves) — Adds untaxed_reserves to chart_of_accounts.account_type CHECK constraint for BAS 21xx accounts (obeskattade reserver).
  • Migration 051 (set_search_path_on_functions) — Pins search_path = public on all 24 custom functions to prevent search_path injection.

Type System

  • All shared types live in types/index.ts — this is the single source of truth
  • Import via import type { TypeName } from '@/types'
  • When adding new domain types, add them to types/index.ts
  • Event types are the exception — they live in lib/events/types.ts (since they reference domain types)

API Route Patterns

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

Type System

  • All shared types in types/index.ts (single source of truth). Import via import type { T } from '@/types'
  • Event types in lib/events/types.ts

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 in vercel.json (banking sync daily 05:00, deadlines 06:00, reminders 08:00, push notifications 09:00, tax deadlines yearly Jan 2, document verify weekly Sunday 03:00).

Core env vars: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, NEXT_PUBLIC_APP_URL, CRON_SECRET. Extension env vars only needed when that extension is enabled.

Other

Never create a NUL/nul file: \erp-base\NUL