- Consolidate migration numbering (move full_bas_2026 to slot 044) - Remove dead/superseded migrations (document_matching, reversal columns) - Update CLAUDE.md with placeholder migration notes and corrected descriptions - Fix migration SQL for invoice_inbox, extension_data, supplier_invoices Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
46 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 (tsconfig.json).
Language: All code, comments, and commit messages must be in English.
Commands
npm run dev # Start development 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, e/[sector]/[slug])
(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,
journal entry form/list/review, document upload, correction dialog
chat/ ChatWidget, ChatPanel, ChatInput, ChatMessage
customers/ CustomerForm
dashboard/ DashboardContent, DashboardNav, FSkattWarningCard
deadlines/ DeadlineCard, DeadlineFilters, DeadlineForm, DeadlineList,
TaxTodoWidget, UpcomingDeadlinesWidget
extensions/ Extension marketplace UI (ExtensionCard, SectorCard,
ExtensionToggleButton, workspace components,
general/ subdirectory for general extension workspaces)
import/ Bank file import workflow components (SIE + bank file steps)
invoices/ InvoiceReviewContent
onboarding/ NewUserChecklist, setup step components
reports/ Report views (BankReconciliationView, charts)
settings/ CalendarFeedSettings
suppliers/ SupplierForm, SupplierInvoiceReviewContent
transactions/ Transaction list, categorization, booking, swipe review,
batch operations, invoice matching, VAT treatment
extensions/ Config-driven extension directory (general only)
general/ General-purpose extensions (all businesses)
ai-categorization/ AI-powered transaction categorization
ai-chat/ Claude-based chat assistant (LangChain RAG)
calendar/ Payment calendar views, deadline cards, payment summary
email/ Email service (Resend) — registers via EmailService interface
enable-banking/ PSD2 bank integration (opt-in)
example-logger/ Minimal reference extension (not loaded)
invoice-inbox/ Supplier invoice intake via email/upload with AI extraction
push-notifications/ Web push notification system
receipt-ocr/ Receipt image OCR processing (includes components/pages)
user-description-match/ User description matching for transaction categorization
lib/
api/ Zod validation schemas and utilities for API routes
schemas.ts Zod schemas for all API request bodies and query params
validate.ts validateBody() and validateQuery() helpers
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
booking-templates.ts Booking template patterns for AI suggestions
template-embeddings.ts AI embeddings for booking template matching
client-account-names.ts Custom account name management
validate-period-duration.ts Fiscal period duration validation (BFL 3 kap.)
handlers/ Booking handler functions (supplier-invoice-handler.ts)
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 utilities, ICS feed generation
currency/ Riksbanken exchange rates
deadlines/ Tax deadline tracking, status engine
email/ Email service interface and registry
service.ts EmailService interface, NoopEmailService default,
getEmailService()/registerEmailService() registry
resend.ts Legacy Resend helpers (invoice/reminder templates)
errors/ Error message utilities (get-error-message.ts)
events/ Event bus (bus.ts, types.ts)
extensions/ Extension system
loader.ts Loads extensions from generated FIRST_PARTY_EXTENSIONS
registry.ts Runtime extension registry
types.ts Extension, Sector, ExtensionDefinition, toggle types
sectors.ts Sector shells + generated extension definitions
hooks.ts React hooks for extension state
context-factory.ts Extension context builder
toggle-check.ts Extension enable/disable logic
validation.ts Extension data validation
workspace-registry.tsx Workspace component lookup from generated map
icon-resolver.tsx Dynamic icon lookup for extensions
use-account-totals.ts Hook for account balance queries
use-extension-data.ts Hook for extension-specific data
invoice-inbox-utils.ts Utilities for supplier invoice inbox
use-mock-data.ts Mock data utilities for development
_generated/ Code-generated files (DO NOT EDIT)
extension-list.ts FIRST_PARTY_EXTENSIONS array
sector-definitions.ts EXTENSION_DEFINITIONS per sector
workspace-map.tsx Lazy-loaded workspace component registry
enabled-extensions.ts Set of enabled extension IDs
hooks/ React hooks (use-unsaved-changes.ts)
import/ SIE parser, SIE import orchestrator, bank file parser
bank-file/ Bank file parser with format modules
formats/ camt053, generic-csv, handelsbanken, nordea, seb, swedbank,
ica-banken, lansforsakringar, lunar, skandia
invoices/ VAT rules, invoice matching, PDF template, reminder processor
reconciliation/ Bank reconciliation engine (4-pass matching algorithm)
reports/ Financial reports
trial-balance.ts Trial balance report
income-statement.ts Income statement (resultatrakning)
balance-sheet.ts Balance sheet (balansrakning)
vat-declaration.ts VAT declaration (momsdeklaration)
sie-export.ts SIE file export
general-ledger.ts General ledger (huvudbok)
journal-register.ts Journal register (grundbok)
ar-ledger.ts Accounts receivable ledger
ar-reconciliation.ts AR reconciliation
supplier-ledger.ts Supplier/AP ledger
supplier-reconciliation.ts Supplier reconciliation
monthly-breakdown.ts Monthly breakdown report
ne-bilaga/ NE tax form attachment (core report, not extension)
ne-engine.ts NE declaration engine
types.ts NE-specific types
sru-export/ SRU file export (core report, not extension)
sru-engine.ts SRU generation engine
sru-generator.ts SRU file generator
sru-generic-generator.ts Generic SRU generator
types.ts SRU-specific types
supabase/ Client setup (client.ts = browser, server.ts = server/admin,
fetch-all.ts = pagination helper, middleware.ts)
tax/ Tax calculations, deadlines, deadline generator,
Swedish holidays, expense warnings
transactions/ Transaction processing, category suggestions
vat/ VAT utilities
vies-client.ts VIES EU VAT number validation
moms-box-mapping.ts BAS account to momsdeklaration box mapping
init.ts Extension loader (idempotent, called by API routes)
logger.ts Centralized logging utility
utils.ts Shared utility functions
types/index.ts Canonical type definitions (single source of truth)
types/chat.ts Chat-specific type definitions
tests/helpers.ts Mock factories and fixture builders
supabase/migrations/ SQL migration files (45 files)
scripts/
generate-extension-registry.ts Code generator for extension system
create-extension.ts Helper for creating new extensions
clear-user-data.sql Utility SQL for data cleanup
dev_docs/ Project documentation (BAS account guides, gap analysis,
Enable Banking docs, Bokio reference screenshots)
extensions.config.json Extension opt-in configuration (controls which extensions load)
extensions.schema.json JSON Schema for extensions.config.json
extensions.md Extension system design document
.github/workflows/ CI workflows
core-build.yml PR build — verifies core builds/tests with zero extensions
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()). - Extension system: Extensions are opt-in via
extensions.config.json. Code generation (npm run setup:extensions) produces static imports from manifests. Core builds and runs with zero extensions. - Email service: Core defines
EmailServiceinterface inlib/email/service.tswith aNoopEmailServicedefault. Theemailextension registers the real Resend implementation at load time. Core usesgetEmailService()which degrades gracefully. - NE-bilaga and SRU export are core reports (in
lib/reports/), not extensions. They are always available.
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/invoices/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 System
Extensions are opt-in plugins controlled by extensions.config.json. The system uses a manifest-driven, code-generated architecture where core builds and runs with zero extensions.
How It Works
- Each extension has a
manifest.jsondeclaring its metadata, entry point, workspace component, dependencies, and required env vars. extensions.config.jsonlists which extensions to load (by ID).npm run setup:extensionsreads the config and manifests, then generates four files inlib/extensions/_generated/:extension-list.ts—FIRST_PARTY_EXTENSIONSarray (static imports)sector-definitions.ts—EXTENSION_DEFINITIONSmap for marketplace UIworkspace-map.tsx— Lazy-loaded workspace component registryenabled-extensions.ts— Set of enabled extension IDs
predevandprebuildhooks run this automatically.
Enabling Extensions
Edit extensions.config.json:
{
"$schema": "./extensions.schema.json",
"extensions": ["receipt-ocr", "ai-categorization", "email"]
}
Then run npm run setup:extensions (or just npm run dev/npm run build).
Available Extensions
All extensions live in extensions/general/ with manifest.json files:
| Extension | Category | Data Pattern | Env Vars Required |
|---|---|---|---|
receipt-ocr |
import | manual | ANTHROPIC_API_KEY |
ai-categorization |
operations | core | OPENAI_API_KEY |
ai-chat |
operations | manual | ANTHROPIC_API_KEY, OPENAI_API_KEY |
push-notifications |
operations | core | NEXT_PUBLIC_VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT |
invoice-inbox |
import | manual | ANTHROPIC_API_KEY |
calendar |
operations | core | — |
enable-banking |
import | manual | ENABLE_BANKING_APP_ID, ENABLE_BANKING_PRIVATE_KEY |
email |
operations | core | RESEND_API_KEY, RESEND_FROM_EMAIL |
user-description-match |
operations | core | — |
Sector System
Only the general sector exists. Extensions are grouped under it.
Key types (from lib/extensions/types.ts):
SectorSlug—'general'ExtensionDefinition— Marketplace metadata (slug, name, sector, category, icon, dataPattern, description, longDescription, readsCoreTables, hasOwnData)ExtensionCategory—'import' | 'operations' | 'reports' | 'accounting'ExtensionDataPattern—'core' | 'manual' | 'both'(how extension accesses data)ExtensionToggle— Per-user enable/disable state for extensions
Creating a New Extension
Use the scaffolding script:
npx tsx scripts/create-extension.ts \
--name my-extension \
--sector general \
--category operations \
--description "Short description"
This creates:
extensions/general/my-extension/manifest.json— Full manifest templateextensions/general/my-extension/index.ts— Extension object skeletonextensions/general/my-extension/api-routes.ts— Empty API routes array- Updates
extensions.schema.jsonwith the new ID
Then to activate:
- Add
"my-extension"toextensions.config.json - Run
npm run setup:extensionsto regenerate
Manual steps (if not using the script):
- Create
extensions/general/<name>/manifest.json(see Manifest Format below) - Create
extensions/general/<name>/index.tsexporting anExtensionobject - Optionally create
extensions/general/<name>/api-routes.tsfor API endpoints - Add the extension ID to
extensions.schema.jsonandextensions.config.json - Run
npm run setup:extensions
Constraints: Extensions cannot use dynamic imports (Next.js bundling constraint).
Manifest Format
Every extension declares a manifest.json. All fields are required unless noted:
{
"id": "my-extension",
"sector": "general",
"exportName": "myExtensionExtension",
"entryPoint": "@/extensions/general/my-extension",
"workspace": "@/components/extensions/general/MyExtensionWorkspace",
"requiredEnvVars": ["MY_API_KEY"],
"optionalEnvVars": [],
"npmDependencies": ["some-sdk"],
"definition": {
"name": "My Extension",
"category": "operations",
"icon": "Wrench",
"dataPattern": "core",
"readsCoreTables": ["transactions"],
"hasOwnData": false,
"description": "Short one-line description (Swedish)",
"longDescription": "Multi-line description (Swedish)",
"quickAction": {
"label": "Do Thing",
"description": "Does the thing",
"icon": "Wrench",
"href": "/my-page"
},
"subscriptionNotice": "Optional notice shown when enabling"
}
}
| Field | Type | Description |
|---|---|---|
id |
string | Unique kebab-case ID |
sector |
string | Always "general" |
exportName |
string | null | Named export from entryPoint (null = metadata-only, no runtime code) |
entryPoint |
string | null | Import path to Extension definition (null if no runtime code) |
workspace |
string | null | Import path to React workspace component (null if no UI) |
requiredEnvVars |
string[] | Env vars that must be set for the extension to function |
optionalEnvVars |
string[] | Env vars that enhance but are not required |
npmDependencies |
string[] | npm packages the extension needs |
definition.name |
string | Display name for marketplace UI |
definition.category |
string | 'import' | 'operations' | 'reports' | 'accounting' |
definition.icon |
string | Lucide icon name (e.g., "Camera", "Brain") |
definition.dataPattern |
string | 'core' (reads existing data), 'manual' (user submits data), 'both' |
definition.readsCoreTables |
string[] | Optional. Which tables it reads (for core/both pattern) |
definition.hasOwnData |
boolean | Optional. Whether users submit data (for manual/both pattern) |
definition.description |
string | One-line description (Swedish) |
definition.longDescription |
string | Multi-line description (Swedish) |
definition.quickAction |
object | Optional. Dashboard quick action button |
definition.subscriptionNotice |
string | Optional. Notice shown when user enables the extension |
Extension Interface
interface Extension {
id: string // Unique identifier (e.g. 'receipt-ocr')
name: string // Display name
version: string // Semver
sector?: SectorSlug // Always 'general'
// Surfaces (all optional)
routes?: RouteDefinition[]
apiRoutes?: ApiRouteDefinition[]
sidebarItems?: SidebarItem[]
eventHandlers?: ExtensionEventHandler[]
mappingRuleTypes?: MappingRuleTypeDefinition[]
reportTypes?: ReportDefinition[]
settingsPanel?: SettingsPanelDefinition
taxCodes?: TaxCodeDefinition[]
dimensionTypes?: DimensionDefinition[]
services?: Record<string, (...args: any[]) => Promise<any>>
// Lifecycle hooks
onInstall?(ctx: ExtensionContext): Promise<void>
onUninstall?(ctx: ExtensionContext): Promise<void>
}
Extension Context
Event handlers and API route handlers receive an ExtensionContext (lib/extensions/context-factory.ts):
interface ExtensionContext {
userId: string // Current authenticated user
extensionId: string // Which extension is running
supabase: SupabaseClient // Pre-authenticated Supabase client
emit(event: CoreEvent): Promise<void> // Emit events to event bus
settings: ExtensionSettings // Key-value store (JSONB in extension_data table)
storage: ExtensionStorage // Supabase Storage wrapper
log: ExtensionLogger // Scoped logging (prefixed "ext:extension-id")
services: ExtensionServices // Core services (ingestTransactions, etc.)
}
ExtensionSettings — Key-value JSONB persisted in extension_data table:
await ctx.settings.get<MySettings>() // Get 'settings' key (default)
await ctx.settings.get<MyConfig>('config') // Get specific key
await ctx.settings.set('settings', newValue) // Set value
ExtensionStorage — Supabase Storage wrapper:
await ctx.storage.upload('receipts', path, data, { contentType: 'image/jpeg' })
await ctx.storage.download('receipts', path)
ctx.storage.getPublicUrl('receipts', path)
ExtensionLogger — Namespaced logging:
ctx.log.info('Processing receipt') // logs: "ext:receipt-ocr: Processing receipt"
ctx.log.warn('Low confidence')
ctx.log.error('OCR failed', error)
Extension API Routes
Extensions expose API endpoints via apiRoutes. These are dispatched through a single catch-all route at app/api/extensions/ext/[...path]/route.ts.
URL scheme: /api/extensions/ext/{extensionId}/{...routePath}
Examples:
POST /api/extensions/ext/receipt-ocr/upload→ matchesPOST /uploadGET /api/extensions/ext/receipt-ocr/abc123→ matchesGET /:idPOST /api/extensions/ext/ai-categorization/suggestions→ matchesPOST /suggestions
Defining routes (in api-routes.ts):
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse } from 'next/server'
export const myApiRoutes: ApiRouteDefinition[] = [
{
method: 'GET',
path: '/',
handler: async (request: Request, ctx?: ExtensionContext) => {
const userId = ctx!.userId
const { data } = await ctx!.supabase
.from('my_table')
.select('*')
.eq('user_id', userId)
return NextResponse.json({ data })
},
},
{
method: 'GET',
path: '/:id',
handler: async (request: Request, ctx?: ExtensionContext) => {
// Path params are extracted as _paramName search params
const id = new URL(request.url).searchParams.get('_id')
// ...
},
},
{
method: 'POST',
path: '/:id/confirm',
handler: async (request: Request, ctx?: ExtensionContext) => {
const id = new URL(request.url).searchParams.get('_id')
const body = await request.json()
// Emit events after success:
await ctx!.emit({ type: 'receipt.confirmed', payload: { ... } })
return NextResponse.json({ data: result })
},
},
]
Dispatcher behavior:
- Authenticates user (401 if not logged in)
- Looks up extension in registry (404 if not found)
- Checks extension toggle for user (403 if disabled)
- Matches HTTP method + path pattern (supports
:paramwildcards) - Builds
ExtensionContextand calls the matched handler
Service Provider Pattern
Extensions can register capabilities that core calls without direct imports. Two patterns:
1. Interface registration (email pattern):
Core defines an interface with a no-op default (lib/email/service.ts):
export interface EmailService {
sendEmail(options: SendEmailOptions): Promise<SendEmailResult>
isConfigured(): boolean
}
let emailService: EmailService = new NoopEmailService()
export function getEmailService(): EmailService { return emailService }
export function registerEmailService(svc: EmailService): void { emailService = svc }
Extension registers the real implementation at load time (extensions/general/email/index.ts):
import { registerEmailService } from '@/lib/email/service'
import { ResendEmailService } from './lib/resend-service'
registerEmailService(new ResendEmailService())
export const emailExtension: Extension = {
id: 'email',
name: 'E-post (Resend)',
version: '1.0.0',
}
Core callers use getEmailService() — gracefully degrades if extension not loaded.
2. Services record (ai-categorization pattern):
Extension exposes named functions via services:
export const aiCategorizationExtension: Extension = {
id: 'ai-categorization',
name: 'AI Kategorisering',
version: '1.0.0',
services: {
findSimilarTemplates: async (...args: unknown[]) => {
const { findSimilarTemplates } = await import('./lib/template-embeddings')
return findSimilarTemplates(args[0], args[1], args[2], args[3])
},
},
}
Core looks up via registry (no direct import):
const ext = extensionRegistry.get('ai-categorization')
const results = await ext?.services?.findSimilarTemplates(tx, entityType, limit)
if (results) { /* use results */ } // Gracefully degrades if not loaded
Extension Examples
Minimal — extensions/general/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
},
},
],
}
Full-featured — Receipt OCR extension (extensions/general/receipt-ocr/index.ts):
export const receiptOcrExtension: Extension = {
id: 'receipt-ocr',
name: 'Receipt OCR',
version: '1.0.0',
sector: 'general',
apiRoutes: receiptOcrApiRoutes, // GET /, POST /upload, GET /:id, POST /:id/confirm, etc.
eventHandlers: [
{ eventType: 'document.uploaded', handler: handleDocumentUploaded },
{ eventType: 'transaction.synced', handler: handleTransactionSynced },
],
mappingRuleTypes: [
{ id: 'receipt-ocr-merchant', name: 'OCR Merchant Match', description: '...' },
],
settingsPanel: {
label: 'Receipt OCR',
path: '/settings/extensions/receipt-ocr',
},
async onInstall(ctx) {
await ctx.settings.set('settings', DEFAULT_SETTINGS)
},
}
Key Extension Files
| File | Purpose |
|---|---|
extensions/general/*/manifest.json |
Metadata for each extension |
extensions/general/*/index.ts |
Extension object (services, handlers, etc.) |
extensions/general/*/api-routes.ts |
API route definitions |
extensions.config.json |
Which extensions are enabled |
extensions.schema.json |
JSON Schema for config validation |
scripts/generate-extension-registry.ts |
Generator script |
scripts/create-extension.ts |
Scaffolding helper |
app/api/extensions/ext/[...path]/route.ts |
Catch-all API route dispatcher |
lib/extensions/context-factory.ts |
Builds ExtensionContext for handlers |
lib/extensions/loader.ts |
Loads FIRST_PARTY_EXTENSIONS into registry |
lib/extensions/_generated/* |
Auto-generated files (DO NOT EDIT) |
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 } |
credit_note.created |
{ creditNote, userId } |
transaction.synced |
{ transactions[], userId } |
transaction.categorized |
{ transaction, account, taxCode, userId } |
transaction.reconciled |
{ transaction, journalEntryId, method, userId } |
period.locked |
{ period, userId } |
period.year_closed |
{ period, userId } |
customer.created |
{ customer, userId } |
receipt.extracted |
{ receipt, documentId, confidence, userId } |
receipt.matched |
{ receipt, transaction, confidence, autoMatched, userId } |
receipt.confirmed |
{ receipt, businessTotal, privateTotal, userId } |
supplier_invoice.received |
{ inboxItem, userId } |
supplier_invoice.extracted |
{ inboxItem, confidence, userId } |
supplier_invoice.confirmed |
{ inboxItem, supplierInvoice, 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%makeInvoice()— Invoice with default customer, amounts, datesmakeCustomer()— Customer with default name, addressmakeSupplier()— Supplier with default detailsmakeSupplierInvoice()— Supplier invoice with default amountsmakeCompanySettings()— Company settings with defaultsmakeInvoiceInboxItem()— Invoice inbox item for supplier invoice intakemakeExtensionToggle()— Extension toggle state
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/api/__tests__/schemas.test.ts— Zod schema validationlib/api/__tests__/validate.test.ts— Body/query validation helperslib/bookkeeping/__tests__/engine.test.ts— Balance validationlib/bookkeeping/__tests__/invoice-entries.test.ts— Per-line VAT, mixed-rate invoices, credit noteslib/bookkeeping/__tests__/mapping-engine.test.ts— Auto-categorization rule matchinglib/bookkeeping/__tests__/category-mapping.test.ts— Category-to-account mappinglib/bookkeeping/__tests__/booking-templates.test.ts— Booking template patternslib/bookkeeping/__tests__/template-embeddings.test.ts— AI embedding matchinglib/bookkeeping/__tests__/validate-period-duration.test.ts— Fiscal period durationlib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts— Supplier booking handlerlib/core/bookkeeping/__tests__/storno-service.test.ts— Complex mock queueslib/core/bookkeeping/__tests__/period-service.test.ts— Fiscal period managementlib/core/bookkeeping/__tests__/year-end-service.test.ts— Year-end closinglib/core/documents/__tests__/document-service.test.ts— Storage mockinglib/core/tax/__tests__/tax-code-service.test.ts— Tax code managementlib/currency/__tests__/riksbanken.test.ts— Exchange rate fetchinglib/events/__tests__/bus.test.ts— Event bus behaviorlib/extensions/__tests__/registry.test.ts— Extension registrationlib/extensions/__tests__/loader.test.ts— Extension loaderlib/extensions/__tests__/toggle-check.test.ts— Extension toggle logiclib/extensions/__tests__/validation.test.ts— Extension data validationlib/extensions/__tests__/sectors.test.ts— Sector registrylib/extensions/__tests__/context-factory.test.ts— Extension context builderlib/extensions/__tests__/invoice-inbox-utils.test.ts— Invoice inbox utilitieslib/import/__tests__/sie-parser.test.ts— SIE file parsinglib/import/__tests__/sie-import.test.ts— SIE import orchestrationlib/import/__tests__/account-mapper.test.ts— SIE account mappinglib/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 deduplib/vat/__tests__/vies-client.test.ts— VIES VAT number validation
Database & Migrations
Location
supabase/migrations/ — currently 45 files numbered 20240101000001 through 20240101000045.
Naming Convention
YYYYMMDD00NNNN_descriptive_name.sql — next migration: 20240101000046_*.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 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 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).
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'
import { validateBody } from '@/lib/api/validate'
import { CreateInvoiceSchema } 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 })
}
// Validate request body with Zod schema
const result = await validateBody(request, CreateInvoiceSchema)
if (!result.success) return result.response
const { data } = result
// Business logic using validated `data`...
// 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 - Validate all input with
validateBody()/validateQuery()fromlib/api/validate.tsusing schemas fromlib/api/schemas.ts - 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
CI
GitHub Actions workflow (.github/workflows/core-build.yml) runs on pull requests:
- Resets
extensions.config.jsonto empty (zero extensions) - Runs
setup:extensions,build, andtest - Verifies no core code (
lib/,app/api/,components/) imports directly from@/extensions/(only generated files and the loader are allowed)
This ensures the core application always builds and passes tests independently of any extensions.
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
NEXT_PUBLIC_APP_URL # App base URL
CRON_SECRET # Auth secret for Vercel cron jobs
# Extension-dependent (only needed when extension is enabled)
RESEND_API_KEY # Resend email service API key (email extension)
RESEND_FROM_EMAIL # Sender email (email extension)
RESEND_WEBHOOK_SECRET # Webhook auth for Resend (email extension)
ENABLE_BANKING_APP_ID # Enable Banking app ID (enable-banking extension)
ENABLE_BANKING_PRIVATE_KEY # Enable Banking private key, base64 (enable-banking extension)
ENABLE_BANKING_SANDBOX # Enable Banking sandbox mode (enable-banking extension)
ANTHROPIC_API_KEY # Claude API key (ai-chat, ai-categorization, receipt-ocr)
OPENAI_API_KEY # OpenAI API key for embeddings (ai-categorization)
NEXT_PUBLIC_VAPID_PUBLIC_KEY # Web push public key (push-notifications extension)
VAPID_PRIVATE_KEY # Web push private key (push-notifications extension)
VAPID_SUBJECT # VAPID subject mailto: URI (push-notifications extension)
Other
Never create a NUL/nul file: C:\Users\emilm\projects\erp-base\NUL