- 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>
17 KiB
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.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()). - 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
createDraftEntry(userId, input)— Createsstatus: 'draft',voucher_number: 0. Validates balance. Emitsjournal_entry.drafted.commitEntry(userId, entryId)— Assigns voucher number via DB RPC (concurrent-safe). Setsstatus: 'posted'. Emitsjournal_entry.committed.createJournalEntry(userId, input)— Draft + commit in one call. This is what all entry generators use.reverseEntry(userId, entryId)— Storno reversal: swaps debit/credit, links viareverses_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:
auto_exact(0.95) — exact amount + exact dateauto_reference(0.90) — exact amount + reference matchauto_date_range(0.85) — exact amount + date ±3 daysauto_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.
- 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 controlled by extensions.config.json. Core builds and runs with zero extensions.
How It Works
- Each extension has a
manifest.jsoninextensions/general/<name>/. extensions.config.jsonlists enabled extension IDs.npm run setup:extensionsgenerates files inlib/extensions/_generated/(static imports, workspace map, definitions).predev/prebuildhooks 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., /:id → searchParams.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 mockscreateMockRequest(),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()andeventBus.clear()inbeforeEach - API route tests: mock
@/lib/initand lib functions, test auth (401), validation (400), not found (404), errors (500), happy path
Database & Migrations
Location: supabase/migrations/ — 45 files, numbered 20240101000001–20240101000045.
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 thetax_codestable. - 023 (
document_version_chain) — Planned but never deployed. Document versioning columns/functions do not exist in production.
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
Key Enforcement Triggers (migration 017)
enforce_journal_entry_immutability— Blocks edits/deletes on posted/reversed entriesenforce_journal_entry_line_immutability— Blocks line modifications on committed entriesenforce_period_lock— Blocks writes to closed/locked fiscal periodsblock_document_deletion— Prevents deletion of documents linked to committed entriesenforce_retention_journal_entries— 7-year retention enforcementset_committed_at— Auto-sets timestamp on draft-to-posted transitioncalculate_retention_expiry— Auto-setsretention_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) — Addsuntaxed_reservestochart_of_accounts.account_typeCHECK constraint for BAS 21xx accounts (obeskattade reserver). - Migration 051 (
set_search_path_on_functions) — Pinssearch_path = publicon 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 viaimport 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