24 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, OpenAI (embeddings), Resend (email), web-push (VAPID).
Path alias: @/* maps to the project root (tsconfig.json).
Language: All code, comments, and commit messages must be in English.
Commands
npm run dev # Start development server
npm run build # Production build
npm run lint # ESLint
npm test # Run all Vitest tests
npx vitest run <dir> # Run tests in a specific directory
Architecture
app/
(auth)/ Login, auth callback
(onboarding)/ 6-step setup wizard
(dashboard)/ Authenticated routes (invoices, customers, transactions,
bookkeeping, reports, suppliers, supplier-invoices,
receipts, settings, calendar, help, import)
(public)/ Public invoice action links (no auth)
api/ API routes organized by domain
components/
ui/ shadcn/ui primitives (button, card, dialog, table, etc.)
bookkeeping/ Chart of accounts manager, account combobox, add/edit dialogs
calendar/ Calendar views, deadline cards, payment summary, todo widgets
chat/ ChatWidget, ChatPanel, ChatInput, ChatMessage
dashboard/ DashboardContent, DashboardNav, FSkattWarningCard
reports/ Report views (including BankReconciliationView)
settings/ CalendarFeedSettings
[feature]/ Feature-organized components (invoices, suppliers, import, etc.)
extensions/ First-party extension implementations
ai-categorization/ AI-powered transaction categorization
ai-chat/ Claude-based chat assistant
enable-banking/ PSD2 bank integration via Enable Banking (JWT auth, sync)
ne-bilaga/ NE tax form attachment generation
push-notifications/ Web push notification system
receipt-ocr/ Receipt image OCR processing
sru-export/ SRU file export
example-logger/ Minimal reference extension (not loaded by default)
lib/
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 (supports 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 codes, merchant patterns)
vat-entries.ts VAT line generation
bas-reference.ts BAS account catalog (~180 accounts with metadata, SRU codes)
account-descriptions.ts Human-readable account name lookup
validate-period-duration.ts Fiscal period duration validation (BFL 3 kap.)
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
calendar/ Calendar and scheduling utilities
currency/ Riksbanken exchange rates
customers/ Customer management helpers
deadlines/ Tax deadline tracking
email/ Email service (Resend integration)
events/ Event bus (bus.ts, types.ts)
extensions/ Extension registry, loader, types
import/ SIE and bank file parser
bank-file/ Bank file parser with format modules
formats/ camt053, generic-csv, handelsbanken, nordea, seb, swedbank
invoice/ VAT rules, invoice matching (vat-rules.ts, invoice-matching.ts)
invoices/ Invoice business logic (reminder-processor)
reconciliation/ Bank reconciliation engine (4-pass matching algorithm)
reports/ Financial reports (trial-balance, income-statement,
balance-sheet, vat-declaration, sie-export,
supplier-ledger, supplier-reconciliation,
general-ledger, journal-register,
ar-ledger, ar-reconciliation, monthly-breakdown)
supabase/ Client setup (client.ts = browser, server.ts = server/admin,
fetch-all.ts = pagination helper for large queries)
tax/ Tax calculations, deadlines, Swedish holidays
transactions/ Transaction processing helpers
init.ts Extension loader (idempotent, called by API routes)
utils.ts Shared utility functions
types/index.ts Canonical type definitions (120+ types, single source of truth)
types/chat.ts Chat-specific type definitions
tests/helpers.ts Mock factories and fixture builders
supabase/migrations/ SQL migration files
scripts/ Utility scripts (clear-user-data.sql)
dev_docs/ Extensive project documentation (PRD, architecture, BAS guide, etc.)
Key Relationships
- All journal entry creation routes through
lib/bookkeeping/engine.ts. The entry generators (invoice-entries.ts,transaction-entries.ts,supplier-invoice-entries.ts) callcreateJournalEntry()from the engine. - API routes that emit events must call
ensureInitialized()(fromlib/init.ts) at module level to load extensions. - Event bus (
lib/events/bus.ts) is a module-level singleton. Core services emit, extensions subscribe. - Supabase clients: browser (
lib/supabase/client.ts), server with user cookies (createClient()fromlib/supabase/server.ts), and service role (createServiceClient()).
Core Bookkeeping Engine
The bookkeeping engine (lib/bookkeeping/engine.ts) is the most critical system. All accounting flows route through it.
Journal Entry Lifecycle
createDraftEntry(userId, input)— Creates entry withstatus: 'draft',voucher_number: 0. Validates balance. Emitsjournal_entry.drafted.commitEntry(userId, entryId)— Assigns voucher number via DB RPC (next_voucher_number, concurrent-safe). Setsstatus: 'posted'. DB trigger setscommitted_at. Emitsjournal_entry.committed.createJournalEntry(userId, input)— Convenience: 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. Original marked'reversed'.
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 (per-rate lines) |
createInvoiceCashEntry() |
invoice-entries.ts |
Cash method: revenue + VAT at payment (per-rate) |
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
| Account | Description |
|---|---|
1510 |
Accounts receivable |
1930 |
Business bank account |
2013 |
Private withdrawals (enskild firma) |
2440 |
Accounts payable |
2611 / 2621 / 2631 |
Output VAT 25% / 12% / 6% |
2614 |
Output VAT reverse charge |
2641 |
Input VAT (deductible) |
2645 |
Calculated input VAT (EU reverse charge) |
2893 |
Loan from shareholders (aktiebolag) |
3001 / 3002 / 3003 |
Revenue by VAT rate (25% / 12% / 6%) |
3305 / 3308 |
Export / EU service revenue |
3960 / 7960 |
Exchange rate gains / losses |
VAT Treatments
standard_25, reduced_12, reduced_6, reverse_charge, export, exempt
Per-Line VAT
Invoice items support individual vat_rate values, enabling mixed-rate invoices. The helper generatePerRateLines() in invoice-entries.ts groups items by VAT rate and creates separate revenue + VAT account lines per rate group. Available rates depend on customer type — use getAvailableVatRates(customerType, vatNumberValidated) from lib/invoice/vat-rules.ts.
Bank Reconciliation
The reconciliation engine (lib/reconciliation/bank-reconciliation.ts) matches bank transactions to journal entry lines on account 1930 using a 4-pass algorithm:
| Pass | Method | Confidence | Match Criteria |
|---|---|---|---|
| 1 | auto_exact |
0.95 | Exact amount + exact date |
| 2 | auto_reference |
0.90 | Exact amount + reference/description match |
| 3 | auto_date_range |
0.85 | Exact amount + date within ±3 days |
| 4 | auto_fuzzy |
0.75 | Fuzzy amount (±0.01) + exact date |
Manual linking (manual method) is also supported. Only SEK transactions are reconciled. Greedy assignment prevents double-matching.
Accounting Guard Rails
These rules exist for legal compliance and are enforced by database triggers. Never violate them.
- Committed entries are immutable. Once
status: 'posted', an entry cannot be edited or deleted. Enforced by DB triggerenforce_journal_entry_immutability. - Never delete posted entries. Use
reverseEntry()(storno) to cancel. The reversal creates a new entry with swapped debit/credit and bidirectional linking. - Every entry must balance.
sum(debits) === sum(credits), both must be> 0. Checked byvalidateBalance()before insert. - Voucher numbers are sequential. Assigned via DB RPC
next_voucher_number(concurrent-safe). Never set manually. - Period lock enforcement. DB trigger
enforce_period_lockblocks writes to closed/locked fiscal periods. Check period status before creating entries. - 7-year document retention. DB triggers prevent deletion of documents linked to posted entries.
retention_expires_atis auto-calculated asperiod_end + 7 years. - Storno, never edit. To correct an error: reverse the wrong entry, then create a new correct one. Use
correctEntry()fromlib/core/bookkeeping/storno-service.tsfor atomic correction. - Use
Math.round(x * 100) / 100for all monetary calculations. Never usetoFixed()(returns strings, has rounding edge cases). - Always use engine functions. Never insert directly into
journal_entriesorjournal_entry_linestables. Always go throughlib/bookkeeping/engine.ts. - Account numbers are strings. BAS account numbers like
'1930'are always strings, never numbers.
Extension Development
Extensions are first-party plugins in the /extensions/ directory, loaded statically at startup.
Creating a New Extension
- Create
extensions/<name>/index.ts - Export an object implementing the
Extensioninterface fromlib/extensions/types.ts - Add a static import to the
FIRST_PARTY_EXTENSIONSarray inlib/extensions/loader.ts - Extensions cannot use dynamic imports (Next.js bundling constraint)
Extension Interface
interface Extension {
id: string // Unique identifier (e.g. 'receipt-ocr')
name: string // Display name
version: string // Semver
// Surfaces (all optional)
routes?: RouteDefinition[]
apiRoutes?: ApiRouteDefinition[]
sidebarItems?: SidebarItem[]
eventHandlers?: ExtensionEventHandler[]
mappingRuleTypes?: MappingRuleTypeDefinition[]
reportTypes?: ReportDefinition[]
settingsPanel?: SettingsPanelDefinition
taxCodes?: TaxCodeDefinition[]
dimensionTypes?: DimensionDefinition[]
// Lifecycle hooks
onInstall?(ctx: ExtensionContext): Promise<void>
onUninstall?(ctx: ExtensionContext): Promise<void>
}
Minimal Example
See extensions/example-logger/index.ts:
import type { Extension } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
export const myExtension: Extension = {
id: 'my-extension',
name: 'My Extension',
version: '0.1.0',
eventHandlers: [
{
eventType: 'journal_entry.committed',
handler: async (payload: EventPayload<'journal_entry.committed'>) => {
// React to committed journal entries
},
},
],
}
Available Event Types
All defined in lib/events/types.ts:
| Event | Payload |
|---|---|
journal_entry.drafted |
{ entry, userId } |
journal_entry.committed |
{ entry, userId } |
journal_entry.corrected |
{ original, storno, corrected, userId } |
document.uploaded |
{ document, userId } |
invoice.created |
{ invoice, userId } |
invoice.sent |
{ invoice, userId } |
invoice.paid |
{ invoice, transaction, kursdifferens?, userId } |
invoice.overdue |
{ invoice, days, userId } |
credit_note.created |
{ creditNote, userId } |
transaction.synced |
{ transactions[], userId } |
transaction.categorized |
{ transaction, account, taxCode, userId } |
bank.statement_received |
{ statement, userId } |
bank.payment_notification |
{ notification, userId } |
period.locked |
{ period, userId } |
period.year_closed |
{ period, userId } |
customer.created |
{ customer, userId } |
customer.pseudonymized |
{ customerId, userId } |
receipt.extracted |
{ receipt, documentId, confidence, userId } |
receipt.matched |
{ receipt, transaction, confidence, autoMatched, userId } |
receipt.confirmed |
{ receipt, businessTotal, privateTotal, userId } |
transaction.reconciled |
{ transaction, journalEntryId, method, userId } |
audit.security_event |
{ event, userId } |
Event Bus Behavior
- Handlers run concurrently via
Promise.allSettled— a failing handler never crashes the emitter - Module-level singleton, persists across requests in the same process
- One-way: core services emit, extensions subscribe
- Call
eventBus.clear()in tests to reset state
Testing Guidelines
Scope
Test business logic in lib/ and API routes in app/api/. No component tests, no E2E tests.
Framework
Vitest 4 with globals: true and environment: 'node'. Config in vitest.config.ts.
Test Location
Colocated __tests__/ directories alongside source files in lib/.
Test Helpers (tests/helpers.ts)
Supabase mock:
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: makeTransaction(), error: null })
// supabase.from('x').select('*').eq('id', '1') resolves to the mocked result
Fixture factories (all accept Partial<T> overrides):
makeReceipt()— Receipt with default merchant, amounts, datesmakeTransaction()— Transaction with default category, amountmakeFiscalPeriod()— FiscalPeriod with default date rangemakeJournalEntry()— Posted JournalEntry with voucher numbermakeJournalEntryLine()— Line with account number, zero amountsmakeDocumentAttachment()— Document with hash, storage pathmakeTaxCode()— TaxCode with default output VAT 25%
Patterns
- Always mock
@/lib/supabase/serverto avoid real DB calls - Use
vi.clearAllMocks()andeventBus.clear()inbeforeEach - Test balance validation edge cases (floating point precision, zero amounts)
- Test error paths (missing fiscal period, unbalanced entries)
- Verify events are emitted correctly
API Route Tests
- Colocated
__tests__/directories alongside route files (e.g.,app/api/invoices/__tests__/route.test.ts) - Mock
@/lib/supabase/server,@/lib/init, and lib functions — do NOT re-test lib business logic - Use
createMockRequest(),parseJsonResponse(),createMockRouteParams()fromtests/helpers.ts - Use
createQueuedMockSupabase()for routes with multiple sequential Supabase calls - Test: auth (401), validation (400), not found (404), errors (500), happy path, non-blocking journal entry failures
Reference Tests
lib/bookkeeping/__tests__/engine.test.ts— Balance validationlib/bookkeeping/__tests__/invoice-entries.test.ts— Per-line VAT, mixed-rate invoices, credit noteslib/core/bookkeeping/__tests__/storno-service.test.ts— Complex mock queueslib/core/documents/__tests__/document-service.test.ts— Storage mockinglib/events/__tests__/bus.test.ts— Event bus behaviorlib/extensions/__tests__/registry.test.ts— Extension registrationlib/import/__tests__/sie-parser.test.ts— SIE file parsinglib/import/bank-file/__tests__/parser.test.ts— Bank file format parsinglib/reconciliation/__tests__/bank-reconciliation.test.ts— Reconciliation matching algorithmlib/reports/__tests__/vat-declaration.test.ts— VAT declaration reportlib/tax/__tests__/deadline-config.test.ts— Tax deadline configurationlib/transactions/__tests__/ingest.test.ts— Transaction ingestion and dedup
Database & Migrations
Location
supabase/migrations/ — currently 32 files numbered 20240101000001 through 20240101000032.
Naming Convention
YYYYMMDD00NNNN_descriptive_name.sql — next migration: 20240101000033_*.sql
Migration Rules
- Always enable RLS on new tables:
ALTER TABLE public.tablename ENABLE ROW LEVEL SECURITY; - Always create RLS policies using
auth.uid() = user_id:CREATE POLICY "tablename_select" ON public.tablename FOR SELECT USING (auth.uid() = user_id); CREATE POLICY "tablename_insert" ON public.tablename FOR INSERT WITH CHECK (auth.uid() = user_id); CREATE POLICY "tablename_update" ON public.tablename FOR UPDATE USING (auth.uid() = user_id); - Always add
updated_attrigger:CREATE TRIGGER tablename_updated_at BEFORE UPDATE ON public.tablename FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); - UUID primary keys:
id UUID PRIMARY KEY DEFAULT uuid_generate_v4() - User ownership:
user_id UUID REFERENCES auth.users ON DELETE CASCADE NOT NULL - Never modify existing migrations — create new ones instead
- Never modify enforcement triggers (migration
20240101000017) — these are legally required - Never hardcode IDs in data migrations — use subqueries or variables
- Apply via Supabase MCP tool:
mcp__plugin_supabase_supabase__apply_migration - Test on a branch database before applying to production (use
create_branchMCP tool)
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 030 (
bank_reconciliation) — Addsreconciliation_methodcolumn totransactions(CHECK constraint for method types), indexes for unmatched transaction lookup, and RPCget_unlinked_1930_lines()for finding unreconciled GL lines. - Migration 031 (
invoice_document_type) — Addsdocument_typecolumn toinvoices(CHECK: invoice/proforma/delivery_note, default 'invoice') andconverted_from_idFK for tracking proforma-to-invoice conversions. - Migration 032 (
add_accounting_method) — Addsaccounting_methodcolumn tocompany_settings(CHECK: accrual/cash, default 'accrual') to support kontantmetoden vs faktureringsmetoden.
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
Standard pattern for new API routes:
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
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 })
}
// Business logic...
// Always filter by user_id (defense in depth alongside RLS)
// Wrap journal entry creation in try/catch (non-blocking side effect)
// Emit events after successful operations
return NextResponse.json({ data: result })
}
Key conventions:
- Call
ensureInitialized()at module level in any route that emits events - Dynamic route params use
{ params }: { params: Promise<{ id: string }> }(Next.js 16) - Response shapes:
{ data }for success,{ error }for failures - Journal entry creation is non-blocking: catch errors and continue
Skills Usage
- Always use the
/frontend-designskill when creating new UI pages or significant components. This ensures consistent use of shadcn/ui, Tailwind CSS 4, and the existing component library. - Use the
langchainskill when building or modifying AI features (ai-chat, ai-categorization extensions). - Use
vercel:deployfor deployment tasks.
Git Conventions
Use conventional commits:
feat: add supplier invoice payment tracking
fix: correct VAT calculation for reduced 12% rate
refactor: extract VAT line generation into vat-entries.ts
test: add balance validation edge cases
docs: update CLAUDE.md with extension guide
- Atomic commits — one logical change per commit
- Branch from
mainfor new work
Deployment
Hosted on Vercel with cron jobs defined in vercel.json:
| Cron Job | Schedule |
|---|---|
/api/extensions/enable-banking/sync/cron |
Daily 05:00 |
/api/deadlines/status/cron |
Daily 06:00 |
/api/invoices/reminders/cron |
Daily 08:00 |
/api/extensions/push-notifications/cron |
Daily 09:00 |
/api/tax-deadlines/cron |
Yearly, January 2nd at 00:00 |
/api/documents/verify/cron |
Weekly, Sunday 03:00 |
Required Environment Variables
NEXT_PUBLIC_SUPABASE_URL # Supabase project URL
NEXT_PUBLIC_SUPABASE_ANON_KEY # Supabase anonymous key
SUPABASE_SERVICE_ROLE_KEY # Supabase service role key
ENABLE_BANKING_APP_ID # Enable Banking app ID
ENABLE_BANKING_PRIVATE_KEY # Enable Banking private key (base64-encoded)
ANTHROPIC_API_KEY # Claude API key (ai-chat)
OPENAI_API_KEY # OpenAI API key (embeddings)
NEXT_PUBLIC_APP_URL # App base URL
NEXT_PUBLIC_VAPID_PUBLIC_KEY # Web push public key
VAPID_PRIVATE_KEY # Web push private key
Other
We should never create a nul file