New Base func

This commit is contained in:
Jakob Wennberg
2026-02-19 09:48:02 +01:00
parent afc9f69def
commit cdf1dcc4c8
73 changed files with 11036 additions and 51 deletions
+391
View File
@@ -0,0 +1,391 @@
Base ERP Architecture: Core + Add-on System (v2)
The Core
Every item here is non-negotiable for legal compliance or market viability. Nothing below can be an add-on.
Authentication, Tenancy & Access Control
Magic link auth via Supabase
Onboarding wizard (entity type EF/AB, company details, tax registration, fiscal year, bank connection)
RLS on every table, user_id scoping
Multi-company support per user
Delegated access roles: owner, accountant (full read/write), audit:read (read-only, scoped to fiscal year). The audit role is the foundation for Digital Audit 2026 compliance where Skatteverket gets API access to a specific fiscal year.
Document Archive (Compliance Layer)
The July 2024 Bokföringslagen amendment makes the system the legal archive. This is not a feature, it is a legal obligation.
Hash-on-upload. Every uploaded file (receipt image, e-invoice XML, PDF) gets a SHA-256 hash computed and stored alongside the blob. The hash is the proof of integrity.
WORM storage. Uploaded documents are write-once. Any modification (crop, contrast, re-scan) creates a new version. The original remains accessible and linked in the version chain.
Deletion blocking. The system hard-rejects any attempt to delete a document linked to a committed voucher or a locked period. No admin override.
Digitization metadata. Every upload logs: user who uploaded, timestamp, source (camera, file upload, e-invoice), and a digitization date field. This justifies destruction of the paper original.
Linkage integrity. Strict foreign key from journal_entry_lines to document_attachments. No orphaned documents, no undocumented entries.
Seven-Year Retention & Purge Prevention
System calculates retention expiry: fiscal year end + 7 calendar years.
All delete operations (company, fiscal year, journal entries, documents) are blocked within the retention window.
"Delete Company" requires a verified full SIE4 export + linked document archive before proceeding, and only after retention expires.
GDPR conflict resolution: pseudonymize CRM master data on request, but never touch the ledger. Invoice snapshots with names remain intact as part of the fiscal record.
Chart of Accounts (BAS Kontoplan)
BAS seeding per entity type (EF/AB), K1 vs full plan
Account CRUD: add, deactivate, rename. Deactivated accounts preserve historical data but block new postings.
Account metadata: type (tillgång/skuld/intäkt/kostnad), default tax code, SRU code mapping
SRU mapping table. Every BAS account maps to an SRU code. This is what makes tax filing work. Without it the system cannot generate Inkomstdeklaration 2 data.
Annual BAS updates. Migration mechanism for BAS Group changes. Deprecated accounts get frozen (no new postings), not deleted.
Dimensions. Minimum two dimension types: Kostnadsställe (cost center) and Projekt. Stored on journal entry lines. Required for SIE4 dimension export (#OBJEKT) and expected by any consultancy or construction firm.
Double-Entry Bookkeeping (Immutable Ledger)
Draft/Commit lifecycle:
Journal entries start as drafts with temporary IDs (TMP-xxxx). Drafts are freely editable.
On commit ("Bokför"), the system assigns the next permanent voucher number from the series. At this moment the row becomes immutable.
DB-level enforcement: committed rows have UPDATE and DELETE restrictions. Application code cannot bypass this.
Voucher series management:
Sequential numbering per series per fiscal year. Gaps are impermissible under Bokföringslagen.
Gap detection: background check that flags any missing numbers in a committed series.
Concurrent write safety: SELECT ... FOR UPDATE or advisory locks to prevent duplicate number assignment.
Storno correction logic:
Posted vouchers are never edited. Corrections follow the three-step flow:
Step 1: System generates a reversal voucher (storno) that nullifies the original.
Step 2: System generates the corrected entry with the right data.
Step 3: All three vouchers (original, reversal, correction) are linked in the behandlingshistorik.
UI presents a single "Correct" button. The user sees a "Corrected" status tag. The triple-entry logic runs in the background.
Debit == Credit validation: enforced at DB level via check constraint or trigger. No exceptions.
Guaranteed delivery: transactional outbox pattern for journal creation from upstream events (invoice created, payment received, etc.). Failed entries go to a dead letter queue with alerting. Silent failure is not acceptable.
Audit Trail (Behandlingshistorik)
Every mutation to journal entries, accounts, documents, settings, user roles logged with: actor, timestamp, action type, before-state, after-state.
Committed vouchers log all correction chains (original -> storno -> corrected).
Attempted deletions of protected data logged as security events.
The audit log itself is append-only. No updates, no deletes.
Period Management
Fiscal year definition with support for broken fiscal years (brutet räkenskapsår).
Multi-fiscal-year support with clean year boundaries.
Period locking (låsning av period). Locked periods reject all writes to journal entries and documents within that period. Locking is one-way without admin unlock + audit log entry.
Year-end closing (årsbokslut):
Zero out result accounts (class 3-8).
Transfer net result to equity (account 2099).
Generate closing entries as committed vouchers.
Calculate and verify that UB of year N == IB of year N+1.
Block manual editing of IB to prevent breaking continuity.
Opening balances workflow for new companies or mid-year migrations.
Tax Code Engine
Decoupled from the chart of accounts. Tax codes tag transaction lines independently.
Code
Rate
Description
Momsdeklaration Boxes
MP1
25%
Standard output VAT
05 (basis) + 10 (tax)
MP2
12%
Food/hotel
06 (basis) + 11 (tax)
MP3
6%
Books/transport/culture
07 (basis) + 12 (tax)
MPI
25/12/6%
Standard input VAT
48
IV
0%
Intra-community acquisition
20 (basis) + 30 (input) + 30 (output)
EUS
0%
EU sale of goods/services
35/36 + Periodisk sammanställning
IP
0%
Import of goods
50 (basis) + 60 (output) + 48 (input)
EXP
0%
Export outside EU
08 (basis)
OSS
varies
One Stop Shop (e-commerce)
Excluded from boxes 05-49, routed to OSS report
Momsdeklaration generated by summing per tax code, not per account. This survives any account plan customization.
Validation: calculated tax (basis * rate) must match reported tax within tolerance. Deviations trigger warnings.
Periodisk sammanställning (EC Sales List) auto-populated from EUS-tagged lines.
Financial Reports
Resultaträkning (income statement) by BAS class
Balansräkning (balance sheet) with assets == equity + liabilities validation
Råbalans (trial balance) with zero-sum verification
Momsdeklaration (all rutor 05-49) generated from tax code engine
SRU-based tax data for Inkomstdeklaration 2 (sums per SRU code)
All reports respect period locks, fiscal year boundaries, and dimension filters (kostnadsställe, projekt)
SIE4
Export: spec-validated output including #IB, #UB, #RES, #VER, #OBJEKT (dimensions), #KONTO with all used accounts. Explicit character encoding handling (CP437/Latin-1) with Swedish character validation. #ORGNR validated against Luhn algorithm.
Import: 4-step wizard (upload, parse, map accounts, review & execute). Creates journal entries from imported data. Validates that imported IB matches existing UB if prior year exists.
Round-trip integrity: export from system, re-import, verify all balances match with zero difference.
Cross-system validation: export must parse without errors in Visma and Fortnox.
Invoicing
Create, edit, send, track invoices
Credit notes with automatic storno reversal entries
VAT via tax code engine (not hardcoded per account)
Multi-currency with Riksbanken exchange rates
Currency gain/loss (kursdifferens). When payment arrives at a different rate than invoiced, system auto-books the difference to 3960/7960.
PDF generation
Peppol BIS Billing 3.0. Generate and send e-invoices via Peppol network. This is the mandated B2G standard and increasingly B2B. Validate output against Peppol Schematron. This replaces email delivery for Peppol-capable recipients.
Public payment/dispute page (token-based, no auth)
Configurable reminder system (intervals, templates, enable/disable)
Banking
PSD2 connection via Enable Banking for transaction sync
OAuth consent flow with 90-day renewal handling
Transaction sync with deduplication (unique(user_id, external_id))
Invoice-to-payment matching (amount + date + OCR reference)
ISO 20022 file handling:
PAIN.001 generation for outgoing supplier payments. Batch multiple payments per PaymentInformation block. Validate against bank-specific XSD before download.
CAMT.053 parsing for end-of-day bank statements. Feed into reconciliation engine matching to general ledger.
CAMT.054 parsing for incoming payment notifications with OCR references. Auto-mark invoices as paid.
Transaction Management
Transaction list with categorization
Manual categorization creates journal entries (via draft/commit flow)
Mapping rules engine: MCC code, merchant name, description pattern, amount threshold
Extensible rule types (hook for add-ons to register custom rules)
Customers
Name, org number, VAT number (validated format), address, payment terms, international flag
Peppol participant ID (for e-invoice routing)
Linked to invoices
Subject to GDPR pseudonymization (but not deletion if linked to fiscal records)
Tax Calendar
Auto-generated Swedish tax deadlines: F-skatt, arbetsgivardeklaration, momsdeklaration (monthly/quarterly), inkomstdeklaration, årsredovisning, bokslut
Calendar views (month/week/day) + ICS export
Deadline status tracking (upcoming, due, overdue, filed)
The Extension Architecture
1. Event Bus
The core emits events. Extensions subscribe. One-way dependency.
typescript
// lib/events/types.ts
export type CoreEvent =
// Bookkeeping
| { type: 'journal_entry.drafted'; payload: DraftJournalEntry }
| { type: 'journal_entry.committed'; payload: JournalEntry }
| { type: 'journal_entry.corrected'; payload: { original: JournalEntry; storno: JournalEntry; corrected: JournalEntry } }
// Documents
| { type: 'document.uploaded'; payload: Document & { hash: string } }
// Invoicing
| { type: 'invoice.created'; payload: Invoice }
| { type: 'invoice.sent'; payload: Invoice }
| { type: 'invoice.paid'; payload: Invoice & { transaction: Transaction; kursdifferens?: number } }
| { type: 'invoice.overdue'; payload: Invoice & { days: number } }
| { type: 'credit_note.created'; payload: CreditNote }
// Banking
| { type: 'transaction.synced'; payload: Transaction[] }
| { type: 'transaction.categorized'; payload: Transaction & { account: string; taxCode: string } }
| { type: 'bank.statement_received'; payload: CAMT053Statement }
| { type: 'bank.payment_notification'; payload: CAMT054Notification }
// Periods
| { type: 'period.locked'; payload: { fiscalYear: number; period: number } }
| { type: 'period.year_closed'; payload: { fiscalYear: number } }
// Customers
| { type: 'customer.created'; payload: Customer }
| { type: 'customer.pseudonymized'; payload: { customerId: string } }
// Audit
| { type: 'audit.security_event'; payload: AuditSecurityEvent }
Implementation: in-process handlers initially. Add webhook dispatch (POST to registered URLs) when external plugin consumers exist.
2. Extension Registry
typescript
// lib/extensions/types.ts
export interface Extension {
id: string
name: string
version: string
// Surfaces
routes?: RouteDefinition[]
apiRoutes?: ApiRouteDefinition[]
sidebarItems?: SidebarItem[]
eventHandlers?: EventSubscription[]
mappingRuleTypes?: MappingRuleType[]
reportTypes?: ReportDefinition[]
settingsPanel?: SettingsPanelDef
taxCodes?: TaxCodeDefinition[] // for add-ons introducing new tax scenarios
dimensionTypes?: DimensionDefinition[] // for add-ons adding custom dimensions beyond the base two
onInstall?(ctx: ExtensionContext): Promise<void>
onUninstall?(ctx: ExtensionContext): Promise<void>
}
3. Database Extension Pattern
First-party add-ons: own migration folder, own tables with user_id + RLS.
Third-party add-ons: use API routes + webhook events + generic extension_data table:
sql
create table extension_data (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users not null,
extension_id text not null,
key text not null,
value jsonb not null,
created_at timestamptz default now(),
unique(user_id, extension_id, key)
);
```
### Core constraint
The base never imports from `extensions/`. Dependency flows one direction: extensions import from `lib/core/`, `lib/events/`, `lib/extensions/`.
---
## Add-ons
Each add-on is self-contained. Listed by priority tier.
### Tier 1: High value, build soon after core
**`receipt-ocr`**
- Subscribes to: `document.uploaded`
- Does: Claude Vision OCR, extracts merchant/date/line items/totals, fuzzy-matches to bank transactions (±3 days, amount similarity), suggests BAS account + tax code
- Special rules: Systembolaget -> non-deductible, restaurant -> representation (90 kr/person limit)
- Registers: custom mapping rule types for OCR-based categorization
**`ai-categorization`**
- Subscribes to: `transaction.synced`
- Does: suggests BAS account + tax code for uncategorized transactions
- No hard dependency on any specific AI provider. Interface-based so the model is swappable.
**`ne-bilaga`**
- Registers as: `reportType` via extension registry
- Does: generates NE-bilaga (income tax appendix for enskild firma, fields R1-R11) from journal entries
- Only relevant for EF entity type. Hidden for AB.
**`sru-export`**
- Registers as: `reportType`
- Does: generates SRU files for Skatteverket electronic filing. Reads SRU mappings from core account metadata.
**`push-notifications`**
- Subscribes to: `invoice.overdue`, `period.locked`, deadline events from tax calendar
- Does: Web Push via VAPID. Per-user preferences with quiet hours.
**`owner-payroll`**
- Registers: routes, sidebar item, settings panel
- Does: single-employee salary for AB owner. Gross salary, tax deduction, employer contributions (arbetsgivaravgifter). Monthly AGI XML generation for Skatteverket. Box 821 absence reporting (VAB, parental leave) with date tracking. Bilförmån and traktamente input fields with Skatteverket standard rates.
- Subscribes to: tax calendar deadline events for arbetsgivardeklaration due dates
### Tier 2: Market differentiation
**`annual-report`**
- Registers as: `reportType` + routes
- Does: K2 taxonomy mapping from BAS accounts. Generates iXBRL for Bolagsverket digital filing. API integration: validate, upload, redirect to BankID signing. Board member signature flow.
- K3 support as a sub-toggle within this add-on (component depreciation, fair value).
**`ai-chat`**
- Registers: floating widget component, routes for session management
- Does: RAG-powered Swedish tax/accounting assistant using LangChain + embeddings. Session history. Rate limited.
- No event subscriptions. Read-only access to user's bookkeeping data for context.
**`bankid`**
- Registers: auth provider, signing flow component
- Does: BankID integration for login and document signing. Secure Start (animated QR code, mandatory since May 2024). Certificate management for merchant certificates.
- Used by: `annual-report` (Bolagsverket signing), `owner-payroll` (AGI signing), future audit access.
**`deductions`**
- Registers: routes, sidebar item, report types
- Does: Schablonavdrag for mileage (korjournal, 25 kr/mil) and home office (2,000-4,000 kr/year). Generates journal entries.
### Tier 3: Vertical enablers
**`inventory-value`**
- Registers: routes, report type
- Does: tracks financial value of stock on account 1400. Accepts journal entries from vertical inventory modules (retail, construction, food). Does not do logistics, variants, batches, or expiry tracking. That is the vertical's job.
**`multi-currency-advanced`**
- Registers: additional tax codes, report types
- Does: automated unrealized gain/loss calculations at period end. Currency revaluation entries. Beyond the base kursdifferens on invoice payment.
**`oss-reporting`**
- Registers: report type, tax codes
- Does: OSS (One Stop Shop) VAT return for e-commerce sellers. Transactions tagged with OSS tax codes excluded from standard momsdeklaration and routed here.
**`saf-t-export`**
- Registers: report type
- Does: SAF-T XML generation. Forward-looking compliance for potential 2026 EU mandate. Maps from the core's granular data model (header -> line -> tax detail).
---
## Repo Structure
```
app/
(auth)/
(onboarding)/
(dashboard)/
bookkeeping/
invoices/
transactions/
banking/
customers/
reports/
calendar/
settings/
extensions/ → marketplace / management
(public)/
api/
journal-entries/
invoices/
transactions/
banking/
reports/
customers/
deadlines/
documents/ → upload, hash verification, version history
audit/ → audit log queries, security events
extensions/ → register, list, config
webhooks/ → outbound event delivery
lib/
core/
bookkeeping/ → draft/commit, storno, voucher series, period locking
accounts/ → BAS kontoplan, SRU mapping, dimensions
reports/ → resultaträkning, balansräkning, råbalans, moms
invoicing/ → create, send, credit, VAT, Peppol, reminders
banking/ → PSD2, sync, matching, ISO 20022 (PAIN/CAMT)
transactions/ → categorization, mapping rules
tax/ → tax code engine, deadlines, fiscal year, year-end closing
sie/ → import + export with dimension support
documents/ → hash-on-upload, WORM storage, deletion blocking, versioning
audit/ → append-only audit log, behandlingshistorik
retention/ → purge prevention, retention expiry calculation
events/ → event bus, types, webhook dispatch
extensions/ → registry, types, loader
extensions/ → first-party add-ons
receipt-ocr/
ai-categorization/
ai-chat/
ne-bilaga/
sru-export/
push-notifications/
owner-payroll/
annual-report/
bankid/
deductions/
inventory-value/
multi-currency-advanced/
oss-reporting/
saf-t-export/
components/
ui/ → Radix primitives, design system
core/ → base feature components
extensions/ → shared extension UI patterns
supabase/
migrations/ → base schema only
types/
@@ -0,0 +1,381 @@
# AI Categorization Extension — Implementation Summary
This document describes the ai-categorization extension: the second first-party extension built on the Part 3 event bus and extension registry. It uses Claude Haiku to suggest BAS account categorizations for bank transactions, following the same canonical pattern established by receipt-ocr.
---
## Problem
Transaction categorization is the most frequent daily task. Every downstream report (momsdeklaration, income statement, balance sheet, NE-bilaga, SRU export) depends on transactions being mapped to the correct BAS accounts.
Before this extension, suggestions came only from:
- **Mapping rules** — user-defined merchant/description patterns (confidence 0.8)
- **Pattern matching** — built-in regex heuristics from `expense-warnings.ts` (confidence 0.6)
- **User history** — most frequently used categories (confidence 0.10.5)
These sources cover common recurring transactions but fail on novel descriptions, edge cases, and new users with no history.
## Solution
An `ai-categorization` extension that:
1. Listens to `transaction.synced` events and auto-generates AI-powered category suggestions for uncategorized transactions
2. Stores suggestions in `extension_data` (pre-computed, ready when the user opens the transaction list)
3. Exposes an on-demand API for manual "AI suggest" triggers
4. Merges AI suggestions into the existing suggestion pipeline alongside rule/pattern/history sources
**Key constraint:** Suggestions only, never auto-commit. The extension stores suggestions in `extension_data` but never creates journal entries. The user confirms via the existing categorization UI, preserving audit trail integrity.
---
## Files Changed
### New files
| File | Purpose |
|------|---------|
| `extensions/ai-categorization/categorizer.ts` | AI provider interface + Anthropic implementation |
| `extensions/ai-categorization/index.ts` | Extension: settings, event handler, public API, extension object |
| `app/api/extensions/ai-categorization/settings/route.ts` | GET/PATCH API for per-user extension settings |
| `app/api/extensions/ai-categorization/suggestions/route.ts` | GET (pre-computed) / POST (on-demand) suggestions API |
### Modified files
| File | Change |
|------|--------|
| `lib/extensions/loader.ts` | Imported and registered `aiCategorizationExtension` |
| `lib/transactions/category-suggestions.ts` | Added `'ai'` to `SuggestedCategory.source` union; added `mergeAiSuggestions()` |
| `app/api/transactions/suggest-categories/route.ts` | Reads pre-computed AI suggestions from `extension_data` and merges into results |
---
## Provider Abstraction
The architecture doc requires "no hard dependency on any specific AI provider". The categorizer implements this via a `CategorizationProvider` interface.
### `CategorizationProvider` interface
```typescript
interface CategorizationProvider {
categorize(
transactions: TransactionForCategorization[],
context: CategorizationContext
): Promise<CategorizationSuggestion[]>
}
```
### `TransactionForCategorization`
Minimal transaction data sent to the AI:
```typescript
interface TransactionForCategorization {
id: string
description: string
amount: number // negative = expense, positive = income
date: string
merchant_name: string | null
mcc_code: number | null
currency: string
}
```
### `CategorizationContext`
Contextual data that improves accuracy:
```typescript
interface CategorizationContext {
entityType: EntityType // 'enskild_firma' | 'aktiebolag'
recentHistory: { description: string; category: string }[] // last 50 categorized
}
```
### `CategorizationSuggestion`
The result per transaction:
```typescript
interface CategorizationSuggestion {
transactionId: string
category: TransactionCategory
basAccount: string // BAS account number (e.g. '5420')
taxCode: string | null // 'MPI', 'MP1', or null
confidence: number // 0.01.0
reasoning: string // Swedish-language explanation
isPrivate: boolean // true = likely private expense
}
```
### `AnthropicCategorizationProvider`
The default implementation using `@anthropic-ai/sdk` (already a project dependency):
- Model: `claude-haiku-4-5-20251001` (same as receipt-analyzer, chosen for cost efficiency)
- Batch size: max 20 transactions per API call (cross-transaction pattern recognition)
- Retry logic: 3 attempts with exponential backoff, no retry on JSON parse errors
- Response validation: filters to valid transaction IDs and valid `TransactionCategory` values
The system prompt includes:
1. Full `TransactionCategory` → BAS account mapping table
2. Entity type (EF uses 2013 for private, AB uses 2893)
3. Swedish non-deductible expense rules (kläder, gym, kosmetika, etc. with legal references)
4. VAT treatment rules (bank fees exempt, standard 25% otherwise)
5. User's recent categorization history (up to 30 entries) for learning patterns
---
## Extension: `extensions/ai-categorization/index.ts`
### Settings
```typescript
interface AiCategorizationSettings {
autoSuggestEnabled: boolean // default: true
confidenceThreshold: number // default: 0.7
providerModel: string // default: 'claude-haiku-4-5-20251001'
}
```
Stored as an `extension_data` row with `extension_id='ai-categorization'`, `key='settings'`, `value=<jsonb>`.
- `getSettings(userId)` reads from DB and merges with defaults (forward-compatible)
- `saveSettings(userId, partial)` merges partial update with current, upserts on `(user_id, extension_id, key)`
### Event Handler: `transaction.synced`
When new transactions arrive from banking sync:
1. **Gate:** Is `autoSuggestEnabled` in user's settings? — if not, return
2. **Gate:** Filter to uncategorized transactions only (`is_business === null`) — if none, return
3. Fetch entity type from `company_settings`
4. Fetch user's last 50 categorized transactions (for learning patterns)
5. Call `provider.categorize(batch, context)`
6. Filter suggestions to those above `confidenceThreshold`
7. Store each qualified suggestion to `extension_data` as `key: "suggestion:{transactionId}"`
8. Log summary with `[ai-categorization]` prefix
### Public API: `categorizeTransactions(userId, transactionIds)`
Exported function for on-demand categorization (used by the suggestions POST endpoint):
1. Fetch transactions by IDs
2. Build context (entity type + history)
3. Call provider
4. Store all suggestions (no threshold filtering — user explicitly requested)
5. Return suggestions
### Extension Object
```typescript
export const aiCategorizationExtension: Extension = {
id: 'ai-categorization',
name: 'AI Kategorisering',
version: '1.0.0',
eventHandlers: [
{ eventType: 'transaction.synced', handler: handleTransactionSynced },
],
settingsPanel: {
label: 'AI Kategorisering',
path: '/settings/extensions/ai-categorization',
},
async onInstall(ctx) { await saveSettings(ctx.userId, DEFAULT_SETTINGS) },
}
```
---
## Suggestion Storage
Suggestions are stored as individual rows in `extension_data`:
| Column | Value |
|--------|-------|
| `user_id` | The user who owns the transaction |
| `extension_id` | `'ai-categorization'` |
| `key` | `'suggestion:{transactionId}'` |
| `value` | The full `CategorizationSuggestion` object as JSONB |
This per-transaction key scheme allows:
- Fast lookup by transaction ID (used by the suggest-categories route)
- Batch lookup via `IN` clause on keys
- Natural overwrite on re-categorization (upsert on unique constraint)
---
## Suggestions API: `app/api/extensions/ai-categorization/suggestions/route.ts`
### `GET ?transaction_ids=id1,id2,...`
Reads pre-computed suggestions from `extension_data`. Returns only what's already stored — no AI call.
Response: `{ suggestions: { [txId]: CategorizationSuggestion } }`
### `POST { transaction_ids: [...] }`
Triggers on-demand AI categorization via `categorizeTransactions()`. Stores results and returns them.
Response: `{ suggestions: { [txId]: CategorizationSuggestion } }`
Both endpoints limit to 50 transaction IDs per request.
---
## Settings API: `app/api/extensions/ai-categorization/settings/route.ts`
Mirrors the receipt-ocr settings route exactly:
- **GET** — Returns the current user's merged settings (DB value + defaults)
- **PATCH** — Accepts a partial settings object, validates keys against allowlist (`autoSuggestEnabled`, `confidenceThreshold`, `providerModel`), saves via `saveSettings()`
---
## Integration with Existing Suggestion Pipeline
### `lib/transactions/category-suggestions.ts`
Two changes:
1. **Source type extended:** `SuggestedCategory.source` union widened from `'mapping_rule' | 'pattern' | 'history'` to `'mapping_rule' | 'pattern' | 'history' | 'ai'`
2. **New merge function:**
```typescript
function mergeAiSuggestions(
existing: SuggestedCategory[],
aiSuggestions: { category: string; basAccount: string; confidence: number; reasoning: string }[]
): SuggestedCategory[]
```
Inserts AI suggestions into the list, deduplicating by category (skips categories already present from higher-priority sources). Returns top 5 sorted by confidence.
### `app/api/transactions/suggest-categories/route.ts`
After computing rule/pattern/history suggestions for each transaction, the route now:
1. Fetches pre-computed AI suggestions from `extension_data` for all requested transaction IDs (single batch query)
2. For each transaction with an AI suggestion, calls `mergeAiSuggestions()` to blend it in
3. Returns the merged result
This means AI suggestions appear alongside existing sources with no latency — they were pre-computed during bank sync.
---
## Suggestion Priority
The existing pipeline already sorts by confidence. With AI added, the effective priority becomes:
| Source | Typical Confidence | When |
|--------|--------------------|------|
| Mapping rules | 0.8 | User-defined patterns match |
| AI | 0.70.95 | Pre-computed from sync |
| Pattern matching | 0.6 | Built-in regex matches |
| User history | 0.10.5 | Most frequently used categories |
AI suggestions naturally slot between mapping rules and pattern matching. For novel transactions where no mapping rule or pattern exists, AI becomes the top suggestion.
---
## Event Flow
```
Bank Sync
|
POST /banking/sync
|
emit transaction.synced
|
+---> receipt-ocr extension (auto-match receipts)
|
+---> ai-categorization extension
|
Gate: autoSuggestEnabled?
Gate: has uncategorized transactions?
|
Fetch entity type + history
Call AnthropicCategorizationProvider.categorize()
Filter by confidenceThreshold
Store to extension_data (suggestion:{txId})
|
[suggestions pre-computed and waiting]
User opens transaction list
|
POST /api/transactions/suggest-categories
|
+---> getSuggestedCategories() [mapping rules + patterns + history]
+---> Read extension_data [pre-computed AI suggestions]
+---> mergeAiSuggestions()
|
v
Response: merged suggestions with source labels
|
User sees: "AI: Programvara (5420) — confidence 0.9"
User clicks "AI suggest" button (on-demand)
|
POST /api/extensions/ai-categorization/suggestions
|
+---> categorizeTransactions()
| Call AI provider
| Store results
|
v
Response: fresh AI suggestions
```
---
## Existing Code Reused
| Import | From | Used in |
|--------|------|---------|
| `Anthropic` | `@anthropic-ai/sdk` | `AnthropicCategorizationProvider` |
| `getSettings()`/`saveSettings()` pattern | `extensions/receipt-ocr/index.ts` | Settings management (same pattern) |
| `getSuggestedCategories()` | `lib/transactions/category-suggestions.ts` | Existing pipeline (unchanged) |
| `createClient()` | `lib/supabase/server.ts` | DB access throughout |
No existing service logic was duplicated. The extension adds a new AI-powered source to the existing suggestion pipeline.
---
## Architectural Patterns Followed
1. **Suggestions only, never auto-commit.** AI writes to `extension_data`, never to `journal_entries`. The user confirms via existing categorization UI.
2. **Provider abstraction from day one.** `CategorizationProvider` interface means the AI model is swappable without changing extension logic.
3. **Cost-efficient model.** Claude Haiku (same as receipt-analyzer) keeps per-sync costs low.
4. **Batch processing.** One AI call per sync handles up to 20 transactions. Cross-transaction context (e.g., "all ICA transactions = groceries") improves accuracy.
5. **Pre-computed suggestions.** AI runs on sync, results are stored. No user-facing latency when opening the transaction list.
6. **Graceful degradation.** If the AI call fails, the handler catches and logs. Existing rule/pattern/history suggestions still work. No user-facing error.
7. **Gate-guarded.** Every handler checks user settings before doing work.
8. **One-way dependency.** Base never imports from `extensions/`. Only `loader.ts` imports the extension object.
9. **`[ai-categorization]` prefix.** Console logging convention for grep-ability.
---
## No New Migrations
No database schema changes were needed. The existing `extension_data` table (created in Part 3, migration `20240101000020_extension_data.sql`) handles all storage:
- Settings: `key='settings'`
- Per-transaction suggestions: `key='suggestion:{transactionId}'`
The unique constraint `(user_id, extension_id, key)` ensures upsert semantics.
---
## Verification
- `npx tsc --noEmit` — zero TypeScript errors
- `npx vitest run` — all 78 existing tests pass (11 test files)
- Manual: trigger bank sync → check console for `[ai-categorization]` logs
- Manual: open transactions page → uncategorized transactions show AI suggestions (source: `'ai'`) alongside existing pattern/history suggestions
- Manual: disable `autoSuggestEnabled` in settings → sync does not trigger AI
- Manual: `POST /api/extensions/ai-categorization/suggestions` with transaction IDs → returns on-demand suggestions
- Manual: `GET /api/extensions/ai-categorization/settings` → returns default settings
- Manual: `PATCH /api/extensions/ai-categorization/settings` → updates settings
@@ -0,0 +1,169 @@
# Part 1: Database Foundation & Compliance Core — Implementation Record
## What was implemented
8 Supabase migrations, TypeScript type updates, 4 new service files, and modifications to 3 existing files. No UI changes.
---
## Migrations
### Migration 11: ALTER Existing Tables
`supabase/migrations/20240101000011_alter_existing_tables.sql`
- `chart_of_accounts` — added `sru_code text` for Skatteverket SRU mapping
- `journal_entries` — added `committed_at timestamptz`, `reversed_by_id uuid FK→self`, `reverses_id uuid FK→self`, `correction_of_id uuid FK→self`
- `journal_entries` — expanded `source_type` CHECK to include `storno`, `correction`, `import`, `system`
- `journal_entry_lines` — added `tax_code text`, `cost_center text`, `project text`
- `fiscal_periods` — added `locked_at timestamptz`, `retention_expires_at date`
### Migration 12: Tax Code Engine
`supabase/migrations/20240101000012_tax_codes.sql`
New table `tax_codes` with columns: `id`, `user_id`, `code`, `description`, `rate`, `moms_basis_boxes text[]`, `moms_tax_boxes text[]`, `moms_input_boxes text[]`, flags (`is_output_vat`, `is_reverse_charge`, `is_eu`, `is_export`, `is_oss`, `is_system`).
RLS: select own + system (user_id IS NULL), insert/update/delete own only.
Seeded 12 system tax codes: MP1 (25%), MP2 (12%), MP3 (6%), MPI, MPI12, MPI6, IV (intra-EU), EUS (EU sale), IP (import), EXP (export), OSS, NONE.
New function `seed_tax_codes_for_user(p_user_id)` copies system codes to user scope.
### Migration 13: Document Archive
`supabase/migrations/20240101000013_document_archive.sql`
New table `document_attachments` with: storage fields (`storage_path`, `file_name`, `file_size_bytes`, `mime_type`), integrity (`sha256_hash NOT NULL`), version chain (`version`, `original_id FK→self`, `superseded_by_id FK→self`, `is_current_version`), digitization metadata (`uploaded_by`, `upload_source`, `digitization_date`), linkage (`journal_entry_id FK ON DELETE RESTRICT`, `journal_entry_line_id FK ON DELETE RESTRICT`).
No DELETE RLS policy — deletion handled by trigger in migration 17.
### Migration 14: Audit Log
`supabase/migrations/20240101000014_audit_log.sql`
New table `audit_log`: `user_id uuid NOT NULL` (no FK cascade — survives user deletion), `action text` with CHECK constraint, `table_name`, `record_id`, `actor_id`, `old_state jsonb`, `new_state jsonb`, `description`. No `updated_at` — append-only.
BEFORE UPDATE and BEFORE DELETE triggers raise exception to enforce immutability.
### Migration 15: Dimensions
`supabase/migrations/20240101000015_dimensions.sql`
Two new tables:
- `cost_centers` (`user_id`, `code`, `name`, `is_active`) with UNIQUE(user_id, code)
- `projects` (`user_id`, `code`, `name`, `is_active`, `start_date`, `end_date`) with UNIQUE(user_id, code)
Both with standard RLS and updated_at triggers.
### Migration 16: Voucher Sequence Hardening
`supabase/migrations/20240101000016_voucher_sequences.sql`
New table `voucher_sequences` (`user_id`, `fiscal_period_id`, `voucher_series`, `last_number`) for tracking sequence state.
Replaced `next_voucher_number()` with concurrent-safe version using `INSERT ON CONFLICT DO UPDATE RETURNING` (row-level lock instead of MAX+1).
New DEFERRABLE constraint trigger `check_balance_on_post` validates debit==credit when an entry transitions from draft to posted.
New function `detect_voucher_gaps(p_user_id, p_fiscal_period_id, p_series)` returns gap ranges for compliance reporting.
### Migration 17: Enforcement Triggers
`supabase/migrations/20240101000017_enforcement_triggers.sql`
8 trigger functions:
1. **`enforce_journal_entry_immutability()`** — allows draft→draft, draft→posted, posted→reversed. Blocks all other updates/deletes on committed entries.
2. **`enforce_journal_entry_line_immutability()`** — blocks modifications to lines of posted/reversed entries.
3. **`enforce_period_lock()`** — rejects journal_entries writes when `is_closed=true` OR `locked_at IS NOT NULL`.
4. **`enforce_period_lock_documents()`** — blocks document attachment to entries in locked periods.
5. **`block_document_deletion()`** — blocks deletion if linked to committed entry or within retention window. Logs blocked attempts to audit_log.
6. **`enforce_retention_journal_entries()`** — blocks journal entry deletion within 7-year retention window.
7. **`set_committed_at()`** — auto-sets `committed_at = now()` on draft→posted transition.
8. **`calculate_retention_expiry()`** — auto-sets `retention_expires_at = period_end + 7 years`. Backfills existing rows.
### Migration 18: Audit Logging Triggers
`supabase/migrations/20240101000018_audit_triggers.sql`
SECURITY DEFINER function `write_audit_log()` that detects action type from TG_OP and state transitions (draft→posted = COMMIT, posted→reversed = REVERSE, locked_at set = LOCK_PERIOD, is_closed set = CLOSE_PERIOD). Captures old_state/new_state as JSONB.
AFTER triggers on: `journal_entries`, `journal_entry_lines`, `chart_of_accounts`, `document_attachments`, `fiscal_periods`, `company_settings`, `tax_codes`.
---
## TypeScript Changes
### Modified types in `types/index.ts`
| Type | Change |
|------|--------|
| `JournalEntrySourceType` | Added `'storno' \| 'correction' \| 'import' \| 'system'` |
| `JournalEntry` | Added `committed_at`, `reversed_by_id`, `reverses_id`, `correction_of_id` |
| `JournalEntryLine` | Added `tax_code`, `cost_center`, `project` |
| `CreateJournalEntryLineInput` | Added optional `tax_code`, `cost_center`, `project` |
| `FiscalPeriod` | Added `locked_at`, `retention_expires_at` |
| `BASAccount` | Added `sru_code` |
### New types added to `types/index.ts`
- `TaxCode` interface, `TaxCodeId` union type
- `DocumentAttachment` interface, `DocumentUploadSource` type, `CreateDocumentAttachmentInput`
- `AuditLogEntry` interface, `AuditAction` union type
- `CostCenter` interface
- `Project` interface
- `VoucherGap` interface
---
## New Service Files
### `lib/core/audit/audit-service.ts`
Read-only service (audit log is written by DB triggers):
- `getAuditLog(userId, filters)` — paginated query with action/table/date filters
- `getEntityHistory(userId, tableName, recordId)` — full mutation history of one record
- `getCorrectionChain(userId, journalEntryId)` — traces original→storno→corrected via linked IDs
### `lib/core/documents/document-service.ts`
- `uploadDocument(userId, file, metadata)` — computes SHA-256 via Web Crypto, uploads to Supabase Storage, creates record
- `createNewVersion(userId, originalId, file)` — creates new version, marks old as superseded (WORM)
- `linkToJournalEntry(userId, documentId, journalEntryId)` — links document to entry
- `verifyIntegrity(userId, documentId)` — re-downloads, re-hashes, compares to stored hash
### `lib/core/tax/tax-code-service.ts`
- `getTaxCodes(userId)` — returns user codes + system codes
- `getTaxCodeByCode(userId, code)` — single lookup, user code takes precedence
- `calculateMomsFromTaxCodes(userId, periodStart, periodEnd)` — sums journal lines by tax_code, maps to moms boxes via tax_codes table
- `seedTaxCodes(userId)` — calls `seed_tax_codes_for_user` RPC
### `lib/core/bookkeeping/storno-service.ts`
- `correctEntry(userId, originalEntryId, correctedLines)` — 3-step correction:
1. Creates storno entry with swapped debits/credits, `source_type='storno'`, `reverses_id` set
2. Creates corrected entry with new data, `source_type='correction'`, `correction_of_id` set
3. Marks original as reversed with `reversed_by_id` set
4. Returns `{ reversal, corrected }`
---
## Modified Existing Files
### `lib/bookkeeping/engine.ts`
- New `buildLineInserts()` helper that includes `tax_code`, `cost_center`, `project` in all line inserts
- New `createDraftEntry(userId, input)` — inserts as draft with `voucher_number=0`, no commit
- New `commitEntry(userId, entryId)` — assigns voucher number via `next_voucher_number` RPC, transitions to posted (DB triggers handle `committed_at` and balance validation)
- Existing `createJournalEntry()` kept as convenience wrapper (create + immediate commit)
- `reverseEntry()` rewritten: now sets `reverses_id` on the reversal entry, sets `reversed_by_id` on the original, uses `source_type='storno'`, preserves dimensions on reversed lines
### `lib/reports/vat-declaration.ts`
- Added `TaxCode` import
- New `calculateVatDeclarationFromTaxCodes(userId, periodType, year, period)` — generates momsdeklaration by querying journal_entry_lines grouped by `tax_code`, then mapping via `tax_codes` table to moms boxes
- Legacy `calculateVatDeclaration()` preserved for backward compatibility (invoice/transaction/receipt approach)
### `lib/reports/sie-export.ts`
- Now fetches `cost_centers` and `projects` tables
- Outputs `#DIM 1 "Kostnadsställe"` and `#DIM 6 "Projekt"` dimension definitions
- Outputs `#OBJEKT` records for each cost center and project
- Outputs `#SRU` records from `chart_of_accounts.sru_code` after each `#KONTO`
- `#TRANS` lines now include dimension object lists: `{1 "CC01" 6 "P01"}` when cost_center/project are set
---
## Verification
- `npx tsc --noEmit` passes with zero errors
@@ -0,0 +1,198 @@
# Part 2: Period Management & Year-End Closing
## Overview
Part 2 implements **year-end closing (årsbokslut)** — the process that legally closes a fiscal year per Bokföringslagen. This includes period locking, closing entry generation, opening balance propagation, and the API surface to drive the workflow.
**Depends on Part 1:** immutable ledger, audit trail, tax codes, document archive, period lock enforcement, retention protection.
---
## What Was Built
### Migration 19: Period Closing Metadata
**File:** `supabase/migrations/20240101000019_period_closing.sql`
Three new columns on `fiscal_periods`:
| Column | Type | Purpose |
|--------|------|---------|
| `closing_entry_id` | `uuid FK → journal_entries` | Links to the year-end closing journal entry |
| `opening_balance_entry_id` | `uuid FK → journal_entries` | Links to the opening balance entry in this period |
| `previous_period_id` | `uuid FK → fiscal_periods` | Chain link to the prior period for validation |
One new trigger:
- **`enforce_opening_balance_immutability`** — Once `opening_balance_entry_id` or `closing_entry_id` are set, they cannot be changed. This prevents tampering with the closing chain after the fact.
---
### TypeScript Types
**File:** `types/index.ts`
Extended `FiscalPeriod` with the three new nullable fields.
New interfaces:
| Interface | Purpose |
|-----------|---------|
| `YearEndValidation` | Result of readiness check: `ready`, `errors[]`, `warnings[]`, `draftCount`, `voucherGaps[]`, `trialBalanceBalanced` |
| `YearEndPreview` | Preview of closing: `netResult`, `closingAccount` (2099/2010), `closingLines[]`, `resultAccountSummary[]` |
| `YearEndResult` | Result of execution: `closingEntry`, `nextPeriod`, `openingBalanceEntry` |
| `PeriodStatus` | Status summary: lock/close/draft/opening state |
---
### Period Service
**File:** `lib/core/bookkeeping/period-service.ts`
| Function | What it does |
|----------|-------------|
| `lockPeriod(userId, fiscalPeriodId)` | Sets `locked_at = now()`. Validates period exists, belongs to user, isn't already locked/closed. After locking, the `enforce_period_lock` trigger (from Part 1) blocks new journal entries. |
| `closePeriod(userId, fiscalPeriodId)` | Sets `is_closed = true, closed_at = now()`. Requires: already locked AND `closing_entry_id` is set. This is the final, permanent state. |
| `createNextPeriod(userId, currentPeriodId)` | Creates the next fiscal year. Computes dates from the current period's length to handle **brutet räkenskapsår** (broken fiscal years, e.g. JulJun). Sets `previous_period_id` for chain validation. Auto-generates name like "FY 2025" or "FY 2025/2026". |
| `getPeriodStatus(userId, fiscalPeriodId)` | Returns a summary: `is_locked`, `is_closed`, `has_closing_entry`, `has_opening_balances`, `draft_count`, `next_period_exists`. |
---
### Year-End Service
**File:** `lib/core/bookkeeping/year-end-service.ts`
This is the core new logic.
#### `validateYearEndReadiness(userId, fiscalPeriodId)` → `YearEndValidation`
Checks preconditions before allowing year-end closing:
- **Blocking errors** (prevent closing):
- Period already closed
- Closing entry already exists
- Draft journal entries exist (must be posted or deleted)
- Trial balance is not balanced
- **Warnings** (informational):
- Voucher number gaps detected (via `detect_voucher_gaps()` SQL function)
- No posted entries in the period
#### `previewYearEndClosing(userId, fiscalPeriodId)` → `YearEndPreview`
Generates a preview without persisting anything:
1. Looks up `entity_type` from `company_settings` → determines closing account:
- **Aktiebolag (AB):** account `2099` (Årets resultat)
- **Enskild firma (EF):** account `2010` (Eget kapital)
2. Runs income statement to get `net_result`
3. Gets trial balance, filters to class 38 accounts
4. For each account with a non-zero balance: creates a line that zeros it
5. Adds a final balancing line to the closing account (2099/2010)
6. Returns the preview with all lines and a summary of result accounts
#### `executeYearEndClosing(userId, fiscalPeriodId)` → `YearEndResult`
Full orchestration (the main entry point):
```
1. validateYearEndReadiness() → abort if errors
2. previewYearEndClosing() → get closing lines
3. createJournalEntry() → create closing entry (source_type: 'year_end')
4. UPDATE fiscal_periods → set closing_entry_id
5. lockPeriod() → lock the period
6. closePeriod() → permanently close
7. createNextPeriod() → create next fiscal year
8. generateOpeningBalances() → carry forward class 1-2 balances
9. Return { closingEntry, nextPeriod, openingBalanceEntry }
```
#### `generateOpeningBalances(userId, closedPeriodId, nextPeriodId)` → `JournalEntry`
Creates opening balance entries in the new period:
1. Gets trial balance of the closed period (after closing entry)
2. Filters to balance sheet accounts (class 12) with non-zero closing balance
3. Creates a journal entry with `source_type: 'opening_balance'`:
- Debit accounts get debit opening, credit accounts get credit opening
4. Verifies the entry is balanced (total debit = total credit)
5. Sets `opening_balance_entry_id` and `opening_balances_set = true` on the next period
**Key invariant:** UB (utgående balans) of year N == IB (ingående balans) of year N+1.
---
### API Routes
All follow the existing pattern: authenticate via Supabase, delegate to service, return JSON.
| Method | Path | Handler |
|--------|------|---------|
| `POST` | `/api/bookkeeping/fiscal-periods/[id]/lock` | `lockPeriod()` |
| `GET` | `/api/bookkeeping/fiscal-periods/[id]/year-end` | `validateYearEndReadiness()` + `previewYearEndClosing()` |
| `POST` | `/api/bookkeeping/fiscal-periods/[id]/year-end` | `executeYearEndClosing()` |
| `POST` | `/api/bookkeeping/fiscal-periods/[id]/close` | `closePeriod()` |
---
## Reused Components
| Component | From | Used by |
|-----------|------|---------|
| `generateTrialBalance()` | `lib/reports/trial-balance.ts` | Balance aggregation for closing + opening entries |
| `generateIncomeStatement()` | `lib/reports/income-statement.ts` | Net result calculation |
| `createJournalEntry()` | `lib/bookkeeping/engine.ts` | Creating closing + opening entries (auto-posts) |
| `validateBalance()` | `lib/bookkeeping/engine.ts` | Pre-flight balance check |
| `detect_voucher_gaps()` | Migration 16 SQL function | Gap validation during readiness check |
| `enforce_period_lock` trigger | Migration 17 | Blocks writes after locking |
| `enforce_journal_entry_immutability` trigger | Migration 17 | Protects closing/opening entries after posting |
---
## Period Lifecycle Diagram
```
┌─────────┐
│ OPEN │ ← Journal entries can be posted
└────┬────┘
│ lockPeriod()
┌─────────┐
│ LOCKED │ ← No new entries (enforce_period_lock trigger)
└────┬────┘
│ closePeriod() (requires closing_entry_id)
┌─────────┐
│ CLOSED │ ← Permanent, immutable
└─────────┘
```
The `executeYearEndClosing()` function drives the full flow from OPEN → CLOSED in one call, including creating the closing entry, locking, closing, creating the next period, and generating opening balances.
---
## Verification Checklist
- [x] `npx tsc --noEmit` — zero TypeScript errors
- [ ] Migration 19 applies cleanly (`\d fiscal_periods` shows new columns)
- [ ] `GET /api/bookkeeping/fiscal-periods/[id]/year-end` returns preview with net result
- [ ] `POST /api/bookkeeping/fiscal-periods/[id]/year-end` creates closing entry, locks, closes, creates next period, generates opening balances
- [ ] Closing entry zeros all class 38 accounts
- [ ] Opening balance entry in next period matches UB of closed period (class 12 only)
- [ ] Closed period rejects new journal entries (period lock trigger)
- [ ] Period with draft entries → validation fails with blocking error
- [ ] Period already closed → validation fails
- [ ] EF entity type → closing goes to 2010 (not 2099)
---
## Files Changed/Created
| File | Action |
|------|--------|
| `supabase/migrations/20240101000019_period_closing.sql` | **Created** — 3 ALTER columns + 1 trigger |
| `types/index.ts` | **Modified** — extended FiscalPeriod, added 4 new interfaces |
| `lib/core/bookkeeping/period-service.ts` | **Created** — lockPeriod, closePeriod, createNextPeriod, getPeriodStatus |
| `lib/core/bookkeeping/year-end-service.ts` | **Created** — validateYearEndReadiness, previewYearEndClosing, executeYearEndClosing, generateOpeningBalances |
| `app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts` | **Created** — POST lock endpoint |
| `app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts` | **Created** — GET preview + POST execute |
| `app/api/bookkeeping/fiscal-periods/[id]/close/route.ts` | **Created** — POST close endpoint |
@@ -0,0 +1,235 @@
Part 3: Event Bus & Extension Registry — Implementation
## What Was Built
An in-process event bus, extension registry with static discovery, database tables for extension data and event observability, and event emission retrofitted into all existing service and API route code paths. Backend only, no UI.
This is the foundation for all Tier 1 add-ons (receipt-ocr, ai-categorization, push-notifications, ne-bilaga, etc.). Without it, every add-on would need to be hardwired into core services.
---
## New Files
### Event Bus — `lib/events/`
**`lib/events/types.ts`**
Defines the `CoreEvent` discriminated union covering 18 event types across six domains:
| Domain | Events |
|--------|--------|
| Bookkeeping | `journal_entry.drafted`, `journal_entry.committed`, `journal_entry.corrected` |
| Documents | `document.uploaded` |
| Invoicing | `invoice.created`, `invoice.sent`, `invoice.paid`, `invoice.overdue`, `credit_note.created` |
| Banking | `transaction.synced`, `transaction.categorized`, `bank.statement_received`, `bank.payment_notification` |
| Periods | `period.locked`, `period.year_closed` |
| Customers | `customer.created`, `customer.pseudonymized` |
| Audit | `audit.security_event` |
Helper types for consuming events:
- `CoreEventType` — string literal union of all event type names
- `EventPayload<T>` — extracts the payload type for a given event type
- `EventHandler<T>` — handler function signature for a specific event type
- `EventSubscription<T>` — event type + handler pair
**`lib/events/bus.ts`**
The event bus singleton. Key design:
- `eventBus.on(eventType, handler)` — subscribe, returns unsubscribe function
- `eventBus.emit(event)` — runs all handlers via `Promise.allSettled` (a failing handler never crashes the emitter)
- `eventBus.clear()` — remove all handlers (for testing)
- Handlers run concurrently, errors logged to console
- Module-level singleton (persists across requests in same Node.js process)
**`lib/events/index.ts`** — Barrel export.
### Extension Registry — `lib/extensions/`
**`lib/extensions/types.ts`**
The `Extension` interface — the contract for all add-ons:
```typescript
interface Extension {
id: string
name: string
version: string
// Surfaces
routes?: RouteDefinition[]
apiRoutes?: ApiRouteDefinition[]
sidebarItems?: SidebarItem[]
eventHandlers?: ExtensionEventHandler[]
mappingRuleTypes?: MappingRuleTypeDefinition[]
reportTypes?: ReportDefinition[]
settingsPanel?: SettingsPanelDefinition
taxCodes?: TaxCodeDefinition[]
dimensionTypes?: DimensionDefinition[]
// Lifecycle
onInstall?(ctx: ExtensionContext): Promise<void>
onUninstall?(ctx: ExtensionContext): Promise<void>
}
```
Supporting types: `RouteDefinition`, `ApiRouteDefinition`, `SidebarItem`, `ReportDefinition`, `SettingsPanelDefinition`, `TaxCodeDefinition`, `DimensionDefinition`, `MappingRuleTypeDefinition`, `ExtensionEventHandler`, `ExtensionContext`.
**`lib/extensions/registry.ts`**
The `extensionRegistry` singleton:
- `register(extension)` — stores extension, wires event handlers to the bus
- `unregister(extensionId)` — unhooks handlers, removes extension
- `getAll()` — all registered extensions
- `get(id)` — specific extension by ID
- `getByCapability(key)` — extensions that have a specific surface (e.g. all extensions with `reportTypes`)
- `clear()` — remove all (for testing)
**`lib/extensions/loader.ts`**
Static extension discovery. Next.js bundling requires explicit imports, not dynamic filesystem scanning. Contains an empty `FIRST_PARTY_EXTENSIONS` array — extensions are added here as they are built. `loadExtensions()` has an idempotency guard.
**`lib/extensions/index.ts`** — Barrel export.
### Initialization — `lib/init.ts`
`ensureInitialized()` — calls `loadExtensions()` once. Called from API routes that emit events (at module scope, not per-request).
### Example Extension — `extensions/example-logger/index.ts`
Minimal reference implementation that logs `journal_entry.committed` and `document.uploaded` events to console. Not wired into the loader by default — exists as a template for building real extensions.
---
## Migration
**`supabase/migrations/20240101000020_extension_data.sql`**
Two tables:
**`extension_data`** — generic key-value store for extensions:
- Columns: `id`, `user_id`, `extension_id`, `key`, `value` (jsonb), `created_at`, `updated_at`
- `UNIQUE(user_id, extension_id, key)`
- RLS: select, insert, update, delete own rows
- Auto-update `updated_at` trigger
**`event_log`** — append-only event observability:
- Columns: `id`, `user_id`, `event_type`, `payload` (jsonb), `created_at`
- RLS: select + insert only (no update, no delete — append-only)
- Indexes on `(user_id, event_type)` and `created_at`
---
## Type Additions — `types/index.ts`
Placeholder types for event payloads not yet fully built:
| Type | Purpose |
|------|---------|
| `CreditNote` | Extends `Invoice` with required `credited_invoice_id` |
| `CAMT053Statement` | Bank statement (CAMT parsing not yet implemented) |
| `CAMT054Notification` | Payment notification (CAMT parsing not yet implemented) |
| `AuditSecurityEvent` | Security event payload for audit events |
| `ExtensionDataRecord` | Row type for the `extension_data` table |
---
## Retrofitted Event Emissions
The pattern is identical everywhere: import `eventBus`, call `await eventBus.emit(...)` after the successful operation. No control flow changes, no return type changes. All events include a `userId` field for RLS-scoped observability.
### Phase A — Service Layer
| File | Function | Event |
|------|----------|-------|
| `lib/bookkeeping/engine.ts` | `createDraftEntry()` | `journal_entry.drafted` |
| `lib/bookkeeping/engine.ts` | `commitEntry()` | `journal_entry.committed` |
| `lib/bookkeeping/engine.ts` | `createJournalEntry()` | `journal_entry.committed` |
| `lib/bookkeeping/engine.ts` | `reverseEntry()` | `journal_entry.committed` |
| `lib/core/bookkeeping/storno-service.ts` | `correctEntry()` | `journal_entry.corrected` |
| `lib/core/documents/document-service.ts` | `uploadDocument()` | `document.uploaded` |
| `lib/core/bookkeeping/period-service.ts` | `lockPeriod()` | `period.locked` |
| `lib/core/bookkeeping/year-end-service.ts` | `executeYearEndClosing()` | `period.year_closed` |
### Phase B — API Routes
| File | Event |
|------|-------|
| `app/api/invoices/route.ts` (POST) | `invoice.created` |
| `app/api/invoices/route.ts` (createCreditNote) | `credit_note.created` |
| `app/api/invoices/[id]/send/route.ts` | `invoice.sent` |
| `app/api/customers/route.ts` (POST) | `customer.created` |
| `app/api/transactions/[id]/categorize/route.ts` | `transaction.categorized` |
| `app/api/banking/sync/route.ts` | `transaction.synced` |
API routes also call `ensureInitialized()` at module scope to ensure extensions are loaded before events are emitted.
### Deferred (Phase C)
These events are defined in the type system but not yet emitted because the underlying infrastructure doesn't exist:
| Event | Reason |
|-------|--------|
| `invoice.paid` | Payment matching with kursdifferens not fully wired |
| `invoice.overdue` | Needs cron-based detection |
| `bank.statement_received` | CAMT053 parsing not implemented |
| `bank.payment_notification` | CAMT054 parsing not implemented |
| `customer.pseudonymized` | GDPR flow not implemented |
| `audit.security_event` | Already logged at DB level; app-level TBD |
---
## Design Decisions
1. **In-process bus** — architecture specifies "in-process handlers initially, add webhook dispatch when external plugin consumers exist." No message queue, no outbox pattern at the event bus level.
2. **`Promise.allSettled`** — a failing handler never crashes the emitting service. Errors are logged to console. The emitter's control flow is never affected.
3. **Module-level singletons**`eventBus` and `extensionRegistry` persist across requests in the same Node.js process. They are not per-request or per-user.
4. **Static extension imports** — Next.js bundling requires explicit imports in `loader.ts`, not dynamic `fs.readdirSync`. Extensions are added to the `FIRST_PARTY_EXTENSIONS` array as they are built.
5. **One-way dependency**`lib/events/` depends on nothing except `types/`. Core services import from `lib/events/`. Extensions import from `lib/core/`, `lib/events/`, and `lib/extensions/`. The base never imports from `extensions/`.
6. **`ensureInitialized()` at module scope** — API routes call this at the top of the file (not inside request handlers). This means extensions are loaded once when the module is first imported by Next.js, not on every request.
7. **Every event payload includes `userId`** — enables RLS-scoped event logging and per-user extension behavior without needing to pass auth context through the bus.
---
## How to Build an Extension
1. Create a directory under `extensions/your-extension/`
2. Export an object satisfying the `Extension` interface
3. Import it in `lib/extensions/loader.ts` and add to `FIRST_PARTY_EXTENSIONS`
Example (see `extensions/example-logger/index.ts`):
```typescript
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'>) => {
// Your logic here
},
},
],
}
```
---
## Verification
- `npx tsc --noEmit` — zero errors
- All existing API routes unchanged in behavior — event emission is additive, never blocking
- Extension system is fully wired but dormant (empty extension list) until extensions are added to the loader
@@ -0,0 +1,251 @@
# Part 4: Year-End Closing UI (Årsbokslut)
## Overview
Part 4 implements the **user interface for year-end closing** — a 4-step wizard that guides the user through validating, previewing, and executing the annual closing per Bokföringslagen. This is a pure frontend implementation; all backend services, API routes, and database migrations were completed in Part 2.
**Depends on Part 2:** period-service, year-end-service, fiscal period API routes (`GET`/`POST` at `/api/bookkeeping/fiscal-periods/[id]/year-end`).
---
## What Was Built
### Year-End Wizard Page
**File:** `app/(dashboard)/bookkeeping/year-end/page.tsx`
A single `'use client'` page containing a 4-step wizard at route `/bookkeeping/year-end`. The wizard maps directly to the existing API surface:
| Step | Label | API Call | Purpose |
|------|-------|----------|---------|
| 0 | Välj period | `GET /api/bookkeeping/fiscal-periods` | Select which fiscal period to close |
| 1 | Validering | `GET /api/bookkeeping/fiscal-periods/[id]/year-end` | Check readiness (errors + warnings) |
| 2 | Förhandsgranskning | *(uses data from step 1)* | Review closing entry before committing |
| 3 | Genomför | `POST /api/bookkeeping/fiscal-periods/[id]/year-end` | Execute with confirmation dialog |
#### Step 0: Period Selection
- Fetches all fiscal periods on mount
- Pre-selects the first open (non-closed) period
- Displays each period as a selectable card with:
- Period name and date range (`period_start period_end`)
- Status badge: **Öppen** (default), **Låst** (outline), **Stängd** (secondary, disabled)
- Closed periods are visually dimmed and not selectable
#### Step 1: Validation
- Calls `GET /api/bookkeeping/fiscal-periods/[id]/year-end` which returns `{ validation, preview }` from parallel `validateYearEndReadiness()` + `previewYearEndClosing()`
- Displays a ready/not-ready banner:
- Green `CheckCircle2` + "Perioden är redo för årsbokslut" when `validation.ready === true`
- Red `AlertCircle` + "Perioden kan inte stängas ännu" when `validation.ready === false`
- **Blocking errors** (red): draft entries, unbalanced trial balance, already closed, closing entry exists
- **Warnings** (amber): voucher number gaps, no posted entries
- Detail cards showing draft count and trial balance status
- Voucher gap badges when gaps exist
- "Validera igen" button to re-run checks after fixing issues
- "Nästa" button gated on `validation.ready === true`
#### Step 2: Preview
Three cards displaying the preview data:
1. **Net result highlight** — Large centered number with color coding (green for profit, red for loss), closing account label (e.g. "2099 — Årets resultat")
2. **Result account summary** — Table of class 38 accounts being zeroed (account number, name, amount)
3. **Closing journal lines** — Expandable/collapsible table showing the full closing entry (account, description, debit, credit) with a totals row
#### Step 3: Execute
Pre-execution state:
- Summary of actions: closing entry creation, period lock + close, next period + opening balances
- Irreversibility warning banner (amber) referencing Bokföringslagen
- "Genomför årsbokslut" button (destructive variant) opens a confirmation dialog
Confirmation dialog (`Dialog` component):
- Repeats period name and net result
- "Avbryt" and "Stäng perioden" (destructive) buttons
Post-execution success state:
- `SuccessAnimation` overlay with celebration variant
- Summary card showing: closing entry link, closed period badge, new period name, opening balances status
- "Tillbaka till bokföring" navigation
---
### Bookkeeping Page Link
**File:** `app/(dashboard)/bookkeeping/page.tsx`
Added a header action button linking to the year-end wizard:
```tsx
<Button variant="outline" asChild>
<Link href="/bookkeeping/year-end">
<Lock className="mr-2 h-4 w-4" />
Årsbokslut
</Link>
</Button>
```
The header was restructured from a plain `<div>` to a `flex items-center justify-between` layout to accommodate the button alongside the existing title and description.
---
## User Flow Diagram
```
/bookkeeping
│ Click "Årsbokslut" button
┌─────────────────────────────────────────────────────┐
│ Step 0: Välj period │
│ ┌───────────────────────────────────┐ │
│ │ FY 2024 (2024-01-01 2024-12-31) │ [Öppen] │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ FY 2023 (2023-01-01 2023-12-31) │ [Stängd] │
│ └───────────────────────────────────┘ │
│ [Nästa →] │
└────────────────────────┬────────────────────────────┘
GET /api/bookkeeping/fiscal-periods/[id]/year-end
┌─────────────────────────────────────────────────────┐
│ Step 1: Validering │
│ ✅ Perioden är redo för årsbokslut │
│ ─ or ─ │
│ ❌ 3 draft entries must be posted │
│ ⚠️ Voucher gaps: 57 │
│ │
│ [← Tillbaka] [Validera igen] [Nästa →] │
└────────────────────────┬────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ Step 2: Förhandsgranskning │
│ │
│ Årets resultat: 150 000,00 kr │
│ Bokförs på 2099 — Årets resultat │
│ │
│ ┌─ Resultatkonton som nollställs ────────────────┐ │
│ │ 3001 Tjänsteintäkter -500 000,00 │ │
│ │ 5010 Lokalhyra 200 000,00 │ │
│ │ 6570 Bankavgifter 150 000,00 │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ [← Tillbaka] [Nästa →] │
└────────────────────────┬────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ Step 3: Genomför │
│ │
│ ⚠️ Denna åtgärd kan inte ångras │
│ │
│ [← Tillbaka] [Genomför årsbokslut] │
│ │ │
│ ┌─────────▼──────────┐ │
│ │ Bekräfta årsbokslut │ │
│ │ Stäng FY 2024? │ │
│ │ │ │
│ │ [Avbryt] [Stäng] │ │
│ └─────────┬──────────┘ │
│ │ │
│ POST /api/.../year-end │
│ │ │
│ ▼ │
│ ✅ Årsbokslutet är genomfört │
│ • Bokslutsverifikation [Visa] │
│ • Period stängd [Stängd] │
│ • Nytt räkenskapsår FY 2025 │
│ • Ingående balanser [Skapade] │
│ │
│ [← Tillbaka till bokföring] │
└─────────────────────────────────────────────────────┘
```
---
## State Management
All state is local to the page component via `useState`:
| State | Type | Purpose |
|-------|------|---------|
| `step` | `number` (03) | Current wizard step |
| `periods` | `FiscalPeriod[]` | All fiscal periods from API |
| `selectedPeriodId` | `string` | Currently selected period |
| `validation` | `YearEndValidation \| null` | Validation result from API |
| `preview` | `YearEndPreview \| null` | Preview result from API |
| `result` | `YearEndResult \| null` | Execution result from API |
| `loading` | `boolean` | Loading state for validation fetch |
| `loadingPeriods` | `boolean` | Loading state for periods fetch |
| `executing` | `boolean` | Loading state for POST execution |
| `error` | `string \| null` | Error message banner |
| `showConfirmDialog` | `boolean` | Confirmation dialog visibility |
| `showLinesDetail` | `boolean` | Expandable closing lines table |
| `showSuccess` | `boolean` | Success animation overlay |
---
## Reused Components
| Component | From | Used for |
|-----------|------|----------|
| `Card`, `CardContent`, `CardHeader`, `CardTitle` | `components/ui/card.tsx` | All step containers |
| `Button` | `components/ui/button.tsx` | Navigation, actions, links |
| `Badge` | `components/ui/badge.tsx` | Period status, voucher gaps, success indicators |
| `Table`, `TableBody`, `TableCell`, `TableHead`, `TableHeader`, `TableRow` | `components/ui/table.tsx` | Result accounts + closing lines |
| `Dialog`, `DialogContent`, `DialogHeader`, `DialogTitle`, `DialogDescription`, `DialogFooter` | `components/ui/dialog.tsx` | Execution confirmation |
| `Skeleton` | `components/ui/skeleton.tsx` | Loading states |
| `SuccessAnimation` | `components/ui/success-animation.tsx` | Post-execution celebration overlay |
| `useToast` | `components/ui/use-toast.tsx` | Error notifications |
| `formatAmount()` | Inline helper (same pattern as `reports/page.tsx`) | Swedish locale number formatting |
Lucide icons used: `CheckCircle2`, `AlertCircle`, `AlertTriangle`, `ArrowLeft`, `ArrowRight`, `Loader2`, `Lock`, `BookOpen`, `ChevronDown`, `ChevronUp`.
---
## API Endpoints Used
No new API routes were created. The wizard consumes existing endpoints from Part 2:
| Method | Path | Response | Used in step |
|--------|------|----------|--------------|
| `GET` | `/api/bookkeeping/fiscal-periods` | `{ data: FiscalPeriod[] }` | 0 (period list) |
| `GET` | `/api/bookkeeping/fiscal-periods/[id]/year-end` | `{ data: { validation: YearEndValidation, preview: YearEndPreview } }` | 1 + 2 (validation + preview) |
| `POST` | `/api/bookkeeping/fiscal-periods/[id]/year-end` | `{ data: YearEndResult }` | 3 (execution) |
---
## Files Changed/Created
| File | Action |
|------|--------|
| `app/(dashboard)/bookkeeping/year-end/page.tsx` | **Created** — 4-step year-end closing wizard (700 lines) |
| `app/(dashboard)/bookkeeping/page.tsx` | **Modified** — Added "Årsbokslut" link button in header, restructured header layout |
**No new API routes.** No backend changes. No new dependencies. No database migrations.
---
## Verification Checklist
- [x] `npx tsc --noEmit` — zero TypeScript errors
- [x] `npm run build` — builds clean, `/bookkeeping/year-end` route registered
- [x] `npx vitest run` — all 78 existing tests pass
- [ ] Navigate to `/bookkeeping` → "Årsbokslut" button visible in header
- [ ] Click "Årsbokslut" → wizard loads at step 0 with period selector
- [ ] Closed periods appear dimmed and cannot be selected
- [ ] Select open period → "Nästa" → validation step loads with skeleton, then shows results
- [ ] Period with draft entries → red error "X draft journal entries must be posted or deleted"
- [ ] Period with unbalanced trial balance → red error "Trial balance is not balanced"
- [ ] Period with voucher gaps → amber warning with gap badges
- [ ] Fully valid period → green "Perioden är redo för årsbokslut", "Nästa" enabled
- [ ] Preview step → net result displayed with correct color, result accounts table, expandable closing lines
- [ ] EF entity type → closing account shows "2010 — Eget kapital"
- [ ] AB entity type → closing account shows "2099 — Årets resultat"
- [ ] Execute step → irreversibility warning shown, "Genomför årsbokslut" opens confirmation dialog
- [ ] Confirmation dialog → "Stäng perioden" triggers POST, success animation + summary displayed
- [ ] Success state → shows new period name, closing entry link, opening balances badge
@@ -0,0 +1,260 @@
# Receipt-OCR Extension — Implementation Summary
This document describes the receipt-ocr extension: the first real extension built on the Part 3 event bus and extension registry infrastructure. It bridges the document archive to the receipt pipeline via events and establishes the canonical pattern for all future extensions.
---
## Problem
Receipt-OCR functionality existed as built-in code (`lib/receipts/`, `app/api/receipts/`), but was disconnected from the event system:
- Uploading a document via the archive did not trigger OCR
- New bank transactions did not auto-match to receipts
- No domain events were emitted when receipts were extracted, matched, or confirmed
The event bus and extension registry (Part 3) were built but had zero real extensions using them.
## Solution
A `receipt-ocr` extension that:
1. Listens to `document.uploaded` events and auto-triggers OCR on images
2. Listens to `transaction.synced` events and auto-matches receipts to new transactions
3. Emits its own domain events (`receipt.extracted`, `receipt.matched`, `receipt.confirmed`) so downstream extensions can react
**Principle followed:** Services = reusable logic in `lib/`. Extensions = event-driven glue. API routes = HTTP interface.
---
## Files Changed
### New files
| File | Purpose |
|------|---------|
| `extensions/receipt-ocr/index.ts` | The extension: settings, event handlers, extension object |
| `app/api/extensions/receipt-ocr/settings/route.ts` | GET/PATCH API for per-user extension settings |
### Modified files
| File | Change |
|------|--------|
| `lib/events/types.ts` | Added `Receipt` import and 3 new events to `CoreEvent` union |
| `lib/extensions/loader.ts` | Imported and registered `receiptOcrExtension` |
| `app/api/receipts/upload/route.ts` | Emits `receipt.extracted` after successful OCR |
| `app/api/receipts/[id]/match/route.ts` | Emits `receipt.matched` after manual match; upgraded selects to fetch full objects |
| `app/api/receipts/[id]/confirm/route.ts` | Emits `receipt.confirmed` with computed business/private totals |
---
## New Events
Three events were added to `lib/events/types.ts`:
### `receipt.extracted`
Fires when OCR extraction completes on a receipt image, whether via the direct upload path or the document archive path.
```typescript
{ type: 'receipt.extracted'; payload: {
receipt: Receipt;
documentId: string | null; // null when from direct upload path
confidence: number;
userId: string;
}}
```
### `receipt.matched`
Fires when a receipt is linked to a bank transaction, whether by user manual action or extension auto-match.
```typescript
{ type: 'receipt.matched'; payload: {
receipt: Receipt;
transaction: Transaction;
confidence: number;
autoMatched: boolean; // true = extension, false = user manual
userId: string;
}}
```
### `receipt.confirmed`
Fires when a user confirms line item classifications (business vs private).
```typescript
{ type: 'receipt.confirmed'; payload: {
receipt: Receipt;
businessTotal: number;
privateTotal: number;
userId: string;
}}
```
---
## Event Retrofitting
Existing API routes were retrofitted to emit events after their success paths. Each route received:
- `import { eventBus } from '@/lib/events/bus'`
- `import { ensureInitialized } from '@/lib/init'`
- `ensureInitialized()` at module scope
- `await eventBus.emit(...)` after the successful operation, before the response
### `app/api/receipts/upload/route.ts`
Emits `receipt.extracted` after the complete receipt (with line items) is fetched, with `documentId: null` since this is the direct upload path.
### `app/api/receipts/[id]/match/route.ts`
The PATCH handler's ownership verification queries were upgraded from `select('id')` to `select('*, line_items:receipt_line_items(*)')` and `select('*')` respectively, so the full receipt and transaction objects are available for the event payload. Emits `receipt.matched` with `autoMatched: false`.
### `app/api/receipts/[id]/confirm/route.ts`
Computes `businessTotal` and `privateTotal` by iterating over the updated receipt's line items. Emits `receipt.confirmed` with these totals.
---
## Extension: `extensions/receipt-ocr/index.ts`
### Settings
```typescript
interface ReceiptOcrSettings {
autoOcrEnabled: boolean // default: true
autoMatchEnabled: boolean // default: true
autoMatchThreshold: number // default: 0.8
ocrConfidenceThreshold: number // default: 0.6
}
```
Stored as an `extension_data` row with `extension_id='receipt-ocr'`, `key='settings'`, `value=<jsonb>`.
- `getSettings(userId)` reads from DB and merges with defaults (forward-compatible when new settings are added)
- `saveSettings(userId, partial)` merges partial update with current settings, then upserts on the unique constraint `(user_id, extension_id, key)`
### Event Handler: `document.uploaded`
When an image is uploaded via the document archive:
1. **Gate:** Is `document.mime_type` an image? (`image/jpeg|png|webp|gif`) — if not, return
2. **Gate:** Is `autoOcrEnabled` in user's settings? — if not, return
3. Downloads image from `documents` storage bucket
4. Converts to base64, calls `analyzeReceipt()` from `lib/receipts/receipt-analyzer.ts`
5. **Gate:** Is `extraction.confidence >= ocrConfidenceThreshold`? — if not, return
6. Calls `processLineItems()` from `lib/receipts/receipt-categorizer.ts`
7. Creates receipt record (status: `extracted`) + line items in DB
8. Emits `receipt.extracted` with `documentId: document.id`
### Event Handler: `transaction.synced`
When new transactions arrive from banking sync:
1. **Gate:** Is `autoMatchEnabled`? — if not, return
2. Filters to expense transactions only (amount < 0)
3. Fetches unmatched receipts (`status IN ('extracted','confirmed')`, `matched_transaction_id IS NULL`)
4. Calls `autoMatchReceipts()` from `lib/receipts/receipt-matcher.ts` with `settings.autoMatchThreshold`
5. For each match: updates receipt + transaction bidirectional link, emits `receipt.matched` with `autoMatched: true`
### Extension Object
```typescript
export const receiptOcrExtension: Extension = {
id: 'receipt-ocr',
name: 'Receipt OCR',
version: '1.0.0',
eventHandlers: [
{ eventType: 'document.uploaded', handler: handleDocumentUploaded },
{ eventType: 'transaction.synced', handler: handleTransactionSynced },
],
mappingRuleTypes: [
{ id: 'receipt-ocr-merchant', name: 'OCR Merchant Match', ... },
{ id: 'receipt-ocr-category', name: 'OCR Category Suggestion', ... },
],
settingsPanel: { label: 'Receipt OCR', path: '/settings/extensions/receipt-ocr' },
async onInstall(ctx) { await saveSettings(ctx.userId, DEFAULT_SETTINGS) },
}
```
---
## Settings API: `app/api/extensions/receipt-ocr/settings/route.ts`
Establishes the convention `app/api/extensions/{id}/settings/route.ts` for all extensions.
- **GET** — Returns the current user's merged settings (DB value + defaults)
- **PATCH** — Accepts a partial settings object, validates keys against an allowlist, saves via `saveSettings()`
---
## Existing Code Reused
| Import | From | Used in |
|--------|------|---------|
| `analyzeReceipt()` | `lib/receipts/receipt-analyzer.ts` | `handleDocumentUploaded` |
| `processLineItems()` | `lib/receipts/receipt-categorizer.ts` | `handleDocumentUploaded` |
| `autoMatchReceipts()` | `lib/receipts/receipt-matcher.ts` | `handleTransactionSynced` |
| `eventBus` | `lib/events/bus.ts` | Both handlers + retrofit |
| `createClient()` | `lib/supabase/server.ts` | Settings + DB ops |
No service logic was duplicated. The extension only acts as event-driven glue between existing services.
---
## Event Flow
```
Document Archive Upload Direct Receipt Upload Bank Sync
| | |
uploadDocument() POST /receipts/upload POST /banking/sync
| | |
emit document.uploaded analyzeReceipt() inline emit transaction.synced
| | |
v v v
+-----------------+ emit receipt.extracted +---------------------+
| receipt-ocr | | receipt-ocr |
| extension | | extension |
| | | |
| Gate: image? | | Gate: enabled? |
| Gate: enabled? | | Fetch unmatched |
| Download image | | autoMatchReceipts() |
| analyzeReceipt()| | Link matches |
| Create receipt | | emit receipt.matched|
| emit receipt. | +---------------------+
| extracted |
+-----------------+
User confirms receipt --> POST /receipts/[id]/confirm
|
emit receipt.confirmed
|
v
[Future extensions]
push-notifications
ne-bilaga, etc.
```
---
## Architectural Patterns Established
1. **Extensions never duplicate service logic.** They call existing functions from `lib/`.
2. **Extensions are gate-guarded.** Every handler checks user settings before doing work.
3. **Extensions emit domain events.** Downstream extensions react without coupling.
4. **Events fire from both paths.** Whether a receipt enters via archive (event-driven) or direct upload (API), the same `receipt.extracted` event fires.
5. **Settings use `extension_data` with `key='settings'`.** Helpers merge with defaults for forward-compatible schema evolution.
6. **`onInstall` seeds defaults.** Idempotent via upsert.
7. **Handlers never crash the emitter.** `Promise.allSettled` in the bus handles this.
8. **Console logging with `[extension-id]` prefix.** Convention for grep-ability.
9. **One-way dependency.** Base never imports from `extensions/`. Only `loader.ts` imports extension objects.
---
## Verification
- `npx tsc --noEmit` passes with zero errors
- Manual: upload image via document archive -> receipt auto-created with OCR extraction
- Manual: sync bank transactions -> unmatched receipts auto-matched
- Manual: direct receipt upload still works unchanged, now also emits `receipt.extracted`
- Manual: confirm receipt -> emits `receipt.confirmed`