` 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: 5–7 │
+│ │
+│ [← 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` (0–3) | 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
diff --git a/dev_docs/base_architecture/RECEIPT_OCR_EXTENSION.md b/dev_docs/base_architecture/RECEIPT_OCR_EXTENSION.md
new file mode 100644
index 00000000..d6af3edb
--- /dev/null
+++ b/dev_docs/base_architecture/RECEIPT_OCR_EXTENSION.md
@@ -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=
`.
+
+- `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`
diff --git a/extensions/ai-categorization/categorizer.ts b/extensions/ai-categorization/categorizer.ts
new file mode 100644
index 00000000..dd5015eb
--- /dev/null
+++ b/extensions/ai-categorization/categorizer.ts
@@ -0,0 +1,269 @@
+/**
+ * AI Categorization Engine
+ *
+ * SERVER-ONLY: Uses the Anthropic SDK and must only be imported
+ * in server components or API routes.
+ *
+ * Provider-abstracted AI categorization for Swedish BAS account mapping.
+ * Default implementation uses Claude Haiku for cost efficiency.
+ */
+
+import 'server-only'
+import Anthropic from '@anthropic-ai/sdk'
+import type { TransactionCategory, EntityType } from '@/types'
+
+// ============================================================
+// Types
+// ============================================================
+
+export interface TransactionForCategorization {
+ id: string
+ description: string
+ amount: number
+ date: string
+ merchant_name: string | null
+ mcc_code: number | null
+ currency: string
+}
+
+export interface CategorizationContext {
+ entityType: EntityType
+ recentHistory: { description: string; category: string }[]
+}
+
+export interface CategorizationSuggestion {
+ transactionId: string
+ category: TransactionCategory
+ basAccount: string
+ taxCode: string | null
+ confidence: number
+ reasoning: string
+ isPrivate: boolean
+}
+
+export interface CategorizationProvider {
+ categorize(
+ transactions: TransactionForCategorization[],
+ context: CategorizationContext
+ ): Promise
+}
+
+// ============================================================
+// BAS Account + Category Mapping (used in prompt)
+// ============================================================
+
+const CATEGORY_ACCOUNT_MAP: Record = {
+ income_services: { account: '3001', label: 'Tjänsteförsäljning' },
+ income_products: { account: '3001', label: 'Varuförsäljning' },
+ income_other: { account: '3900', label: 'Övriga intäkter' },
+ expense_equipment: { account: '5410', label: 'Förbrukningsinventarier' },
+ expense_software: { account: '5420', label: 'Programvara' },
+ expense_travel: { account: '5800', label: 'Resekostnader' },
+ expense_office: { account: '5010', label: 'Lokalhyra/kontorskostnad' },
+ expense_marketing: { account: '5910', label: 'Annonsering/marknadsföring' },
+ expense_professional_services: { account: '6530', label: 'Redovisning/konsulttjänster' },
+ expense_education: { account: '6991', label: 'Utbildning' },
+ expense_bank_fees: { account: '6570', label: 'Bankavgifter' },
+ expense_card_fees: { account: '6570', label: 'Kortavgifter' },
+ expense_currency_exchange: { account: '7960', label: 'Valutakursförluster' },
+ expense_other: { account: '6991', label: 'Övriga kostnader' },
+ private: { account: '2013', label: 'Privat uttag (EF) / Skuld till ägare (AB)' },
+}
+
+const NON_DEDUCTIBLE_RULES = `
+ICKE-AVDRAGSGILLA KOSTNADER (svensk skatterätt):
+- Kläder: Normalt inte avdragsgilla (RÅ 1988 ref. 35)
+- Gym/träning: Inte avdragsgilla som personlig kostnad (IL 9 kap 2§)
+- Kosmetika/hudvård: Normalt inte avdragsgillt
+- Frisör: Normalt privat kostnad
+- Representation/måltider: Max 300 kr/person exkl. moms (IL 16 kap 2§)
+- Gåvor: Reklamgåvor max 300 kr/mottagare, representationsgåvor max 180 kr
+- Telefon/dator vid blandad användning: Bara yrkesmässig del avdragsgill
+`
+
+// ============================================================
+// Anthropic Provider
+// ============================================================
+
+const MAX_RETRIES = 3
+const RETRY_DELAY_MS = 1000
+const MAX_BATCH_SIZE = 20
+
+export class AnthropicCategorizationProvider implements CategorizationProvider {
+ private client: Anthropic
+ private model: string
+
+ constructor(model = 'claude-haiku-4-5-20251001') {
+ this.client = new Anthropic()
+ this.model = model
+ }
+
+ async categorize(
+ transactions: TransactionForCategorization[],
+ context: CategorizationContext
+ ): Promise {
+ // Cap batch size
+ const batch = transactions.slice(0, MAX_BATCH_SIZE)
+ if (batch.length === 0) return []
+
+ const privateAccount = context.entityType === 'aktiebolag' ? '2893' : '2013'
+
+ const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
+Din uppgift är att kategorisera varje transaktion till rätt kategori och BAS-konto.
+
+KATEGORIER OCH BAS-KONTON:
+${Object.entries(CATEGORY_ACCOUNT_MAP)
+ .map(([cat, info]) => `- ${cat}: ${info.account} (${info.label})`)
+ .join('\n')}
+
+Företagsform: ${context.entityType === 'aktiebolag' ? 'Aktiebolag (AB)' : 'Enskild firma (EF)'}
+Privatkonto: ${privateAccount}
+
+MOMSHANTERING:
+- Bankavgifter, kortavgifter, valutaväxling: MOMSFRIA
+- Övriga affärskostnader: Normalt 25% moms (ingående moms, MPI)
+- Intäkter: Normalt 25% moms (utgående moms, MP1)
+
+${NON_DEDUCTIBLE_RULES}
+
+REGLER:
+1. Negativa belopp = utgifter, positiva = intäkter
+2. Markera transaktioner som troligen är privata med isPrivate: true
+3. Ange confidence 0.0-1.0 baserat på hur säker du är
+4. Ange kort reasoning på svenska
+5. Om en transaktion liknar privat konsumtion (kläder, gym, etc.), sätt category: "private"
+6. taxCode: "MPI" för avdragsgilla affärskostnader med moms, "MP1" för intäkter med moms, null för momsfria/privata`
+
+ const historyContext =
+ context.recentHistory.length > 0
+ ? `\nAnvändarens senaste kategoriseringar (lär dig mönster):\n${context.recentHistory
+ .slice(0, 30)
+ .map((h) => `- "${h.description}" → ${h.category}`)
+ .join('\n')}`
+ : ''
+
+ const transactionList = batch
+ .map(
+ (t, i) =>
+ `${i + 1}. ID: ${t.id}
+ Beskrivning: ${t.description}
+ Belopp: ${t.amount} ${t.currency}
+ Datum: ${t.date}${t.merchant_name ? `\n Handlare: ${t.merchant_name}` : ''}${t.mcc_code ? `\n MCC: ${t.mcc_code}` : ''}`
+ )
+ .join('\n\n')
+
+ const userPrompt = `Kategorisera följande transaktioner:
+${historyContext}
+
+TRANSAKTIONER:
+${transactionList}
+
+Returnera ett JSON-objekt med följande struktur:
+{
+ "suggestions": [
+ {
+ "transactionId": "id",
+ "category": "expense_software",
+ "basAccount": "5420",
+ "taxCode": "MPI",
+ "confidence": 0.9,
+ "reasoning": "Spotify-prenumeration, typisk programvarukostnad",
+ "isPrivate": false
+ }
+ ]
+}
+
+Returnera ENDAST JSON-objektet, ingen annan text.`
+
+ let lastError: Error | null = null
+
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
+ try {
+ const message = await this.client.messages.create({
+ model: this.model,
+ max_tokens: 4096,
+ system: systemPrompt,
+ messages: [
+ {
+ role: 'user',
+ content: userPrompt,
+ },
+ ],
+ })
+
+ const content = message.content[0]
+ if (content.type !== 'text') {
+ throw new Error('Unexpected response type from AI')
+ }
+
+ // Strip markdown code blocks if present
+ let jsonText = content.text.trim()
+ if (jsonText.startsWith('```json')) {
+ jsonText = jsonText.slice(7)
+ } else if (jsonText.startsWith('```')) {
+ jsonText = jsonText.slice(3)
+ }
+ if (jsonText.endsWith('```')) {
+ jsonText = jsonText.slice(0, -3)
+ }
+ jsonText = jsonText.trim()
+
+ const parsed = JSON.parse(jsonText)
+ return this.validateSuggestions(parsed.suggestions || [], batch)
+ } catch (error) {
+ lastError = error instanceof Error ? error : new Error('Unknown error')
+
+ // Don't retry on parse errors
+ if (error instanceof SyntaxError) {
+ throw new Error(`Failed to parse AI response: ${lastError.message}`)
+ }
+
+ if (attempt < MAX_RETRIES - 1) {
+ await sleep(RETRY_DELAY_MS * (attempt + 1))
+ }
+ }
+ }
+
+ throw new Error(
+ `AI categorization failed after ${MAX_RETRIES} attempts: ${lastError?.message}`
+ )
+ }
+
+ private validateSuggestions(
+ raw: unknown[],
+ transactions: TransactionForCategorization[]
+ ): CategorizationSuggestion[] {
+ if (!Array.isArray(raw)) return []
+
+ const validTransactionIds = new Set(transactions.map((t) => t.id))
+ const validCategories = new Set(Object.keys(CATEGORY_ACCOUNT_MAP).concat(['uncategorized']))
+
+ return raw
+ .filter(
+ (s): s is Record =>
+ s !== null && typeof s === 'object' && 'transactionId' in s
+ )
+ .filter((s) => validTransactionIds.has(s.transactionId as string))
+ .map((s) => {
+ const category = validCategories.has(s.category as string)
+ ? (s.category as TransactionCategory)
+ : 'expense_other'
+
+ const accountInfo = CATEGORY_ACCOUNT_MAP[category]
+
+ return {
+ transactionId: s.transactionId as string,
+ category,
+ basAccount: accountInfo?.account || (s.basAccount as string) || '6991',
+ taxCode: (s.taxCode as string) || null,
+ confidence: Math.max(0, Math.min(1, Number(s.confidence) || 0.5)),
+ reasoning: (s.reasoning as string) || '',
+ isPrivate: category === 'private' || Boolean(s.isPrivate),
+ }
+ })
+ }
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
diff --git a/extensions/ai-categorization/index.ts b/extensions/ai-categorization/index.ts
new file mode 100644
index 00000000..73dd779b
--- /dev/null
+++ b/extensions/ai-categorization/index.ts
@@ -0,0 +1,256 @@
+import { createClient } from '@/lib/supabase/server'
+import type { Extension } from '@/lib/extensions/types'
+import type { EventPayload } from '@/lib/events/types'
+import type { Transaction, EntityType } from '@/types'
+import {
+ AnthropicCategorizationProvider,
+ type CategorizationProvider,
+ type TransactionForCategorization,
+ type CategorizationContext,
+ type CategorizationSuggestion,
+} from './categorizer'
+
+// ============================================================
+// Settings
+// ============================================================
+
+export interface AiCategorizationSettings {
+ autoSuggestEnabled: boolean
+ confidenceThreshold: number
+ providerModel: string
+}
+
+const DEFAULT_SETTINGS: AiCategorizationSettings = {
+ autoSuggestEnabled: true,
+ confidenceThreshold: 0.7,
+ providerModel: 'claude-haiku-4-5-20251001',
+}
+
+export async function getSettings(userId: string): Promise {
+ const supabase = await createClient()
+
+ const { data } = await supabase
+ .from('extension_data')
+ .select('value')
+ .eq('user_id', userId)
+ .eq('extension_id', 'ai-categorization')
+ .eq('key', 'settings')
+ .single()
+
+ if (!data?.value) return { ...DEFAULT_SETTINGS }
+
+ return { ...DEFAULT_SETTINGS, ...(data.value as Partial) }
+}
+
+export async function saveSettings(
+ userId: string,
+ partial: Partial
+): Promise {
+ const current = await getSettings(userId)
+ const merged = { ...current, ...partial }
+
+ const supabase = await createClient()
+
+ await supabase
+ .from('extension_data')
+ .upsert(
+ {
+ user_id: userId,
+ extension_id: 'ai-categorization',
+ key: 'settings',
+ value: merged,
+ },
+ { onConflict: 'user_id,extension_id,key' }
+ )
+
+ return merged
+}
+
+// ============================================================
+// Provider
+// ============================================================
+
+let provider: CategorizationProvider | null = null
+
+function getProvider(model?: string): CategorizationProvider {
+ if (!provider) {
+ provider = new AnthropicCategorizationProvider(model)
+ }
+ return provider
+}
+
+// ============================================================
+// Public API — on-demand categorization
+// ============================================================
+
+export async function categorizeTransactions(
+ userId: string,
+ transactionIds: string[]
+): Promise {
+ const supabase = await createClient()
+ const settings = await getSettings(userId)
+
+ // Fetch transactions
+ const { data: transactions } = await supabase
+ .from('transactions')
+ .select('id, description, amount, date, merchant_name, mcc_code, currency')
+ .eq('user_id', userId)
+ .in('id', transactionIds)
+
+ if (!transactions || transactions.length === 0) return []
+
+ const batch: TransactionForCategorization[] = transactions.map((t) => ({
+ id: t.id,
+ description: t.description,
+ amount: t.amount,
+ date: t.date,
+ merchant_name: t.merchant_name,
+ mcc_code: t.mcc_code,
+ currency: t.currency,
+ }))
+
+ const context = await buildContext(userId, supabase)
+
+ const aiProvider = getProvider(settings.providerModel)
+ const suggestions = await aiProvider.categorize(batch, context)
+
+ // Store suggestions
+ await storeSuggestions(userId, suggestions, supabase)
+
+ return suggestions
+}
+
+// ============================================================
+// Event Handler
+// ============================================================
+
+async function handleTransactionSynced(
+ payload: EventPayload<'transaction.synced'>
+): Promise {
+ const { transactions: syncedTransactions, userId } = payload
+
+ // Gate: Is autoSuggestEnabled?
+ const settings = await getSettings(userId)
+ if (!settings.autoSuggestEnabled) {
+ return
+ }
+
+ // Gate: Filter to uncategorized transactions only
+ const uncategorized = syncedTransactions.filter(
+ (t: Transaction) => t.is_business === null
+ )
+ if (uncategorized.length === 0) {
+ return
+ }
+
+ console.log(
+ `[ai-categorization] Auto-suggest triggered for ${uncategorized.length} uncategorized transactions`
+ )
+
+ try {
+ const supabase = await createClient()
+
+ const batch: TransactionForCategorization[] = uncategorized.map((t: Transaction) => ({
+ id: t.id,
+ description: t.description,
+ amount: t.amount,
+ date: t.date,
+ merchant_name: t.merchant_name,
+ mcc_code: t.mcc_code,
+ currency: t.currency,
+ }))
+
+ const context = await buildContext(userId, supabase)
+ const aiProvider = getProvider(settings.providerModel)
+ const suggestions = await aiProvider.categorize(batch, context)
+
+ // Store only suggestions above confidence threshold
+ const qualifiedSuggestions = suggestions.filter(
+ (s) => s.confidence >= settings.confidenceThreshold
+ )
+
+ if (qualifiedSuggestions.length > 0) {
+ await storeSuggestions(userId, qualifiedSuggestions, supabase)
+ }
+
+ console.log(
+ `[ai-categorization] Generated ${suggestions.length} suggestions, ${qualifiedSuggestions.length} above threshold (${settings.confidenceThreshold})`
+ )
+ } catch (error) {
+ console.error('[ai-categorization] handleTransactionSynced failed:', error)
+ }
+}
+
+// ============================================================
+// Helpers
+// ============================================================
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+async function buildContext(userId: string, supabase: any): Promise {
+ // Fetch entity type
+ const { data: companySettings } = await supabase
+ .from('company_settings')
+ .select('entity_type')
+ .eq('user_id', userId)
+ .single()
+
+ const entityType: EntityType = companySettings?.entity_type || 'enskild_firma'
+
+ // Fetch recent categorization history
+ const { data: historicalTxns } = await supabase
+ .from('transactions')
+ .select('description, category')
+ .eq('user_id', userId)
+ .not('is_business', 'is', null)
+ .neq('category', 'uncategorized')
+ .order('updated_at', { ascending: false })
+ .limit(50)
+
+ const recentHistory = (historicalTxns || []).map(
+ (t: { description: string; category: string }) => ({
+ description: t.description,
+ category: t.category,
+ })
+ )
+
+ return { entityType, recentHistory }
+}
+
+async function storeSuggestions(
+ userId: string,
+ suggestions: CategorizationSuggestion[],
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ supabase: any
+): Promise {
+ for (const suggestion of suggestions) {
+ await supabase.from('extension_data').upsert(
+ {
+ user_id: userId,
+ extension_id: 'ai-categorization',
+ key: `suggestion:${suggestion.transactionId}`,
+ value: suggestion,
+ },
+ { onConflict: 'user_id,extension_id,key' }
+ )
+ }
+}
+
+// ============================================================
+// Extension Object
+// ============================================================
+
+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)
+ },
+}
diff --git a/extensions/example-logger/index.ts b/extensions/example-logger/index.ts
new file mode 100644
index 00000000..9e9ed134
--- /dev/null
+++ b/extensions/example-logger/index.ts
@@ -0,0 +1,34 @@
+import type { Extension } from '@/lib/extensions/types'
+import type { EventPayload } from '@/lib/events/types'
+
+/**
+ * Example Logger Extension
+ *
+ * Minimal reference implementation that logs events to the console.
+ * Not wired into the loader by default — add to FIRST_PARTY_EXTENSIONS
+ * in lib/extensions/loader.ts to activate.
+ */
+export const exampleLoggerExtension: Extension = {
+ id: 'example-logger',
+ name: 'Example Logger',
+ version: '0.1.0',
+
+ eventHandlers: [
+ {
+ eventType: 'journal_entry.committed',
+ handler: async (payload: EventPayload<'journal_entry.committed'>) => {
+ console.log(
+ `[example-logger] Journal entry committed: ${payload.entry.voucher_series}${payload.entry.voucher_number} — ${payload.entry.description}`
+ )
+ },
+ },
+ {
+ eventType: 'document.uploaded',
+ handler: async (payload: EventPayload<'document.uploaded'>) => {
+ console.log(
+ `[example-logger] Document uploaded: ${payload.document.file_name} (${payload.document.sha256_hash.slice(0, 12)}…)`
+ )
+ },
+ },
+ ],
+}
diff --git a/extensions/receipt-ocr/__tests__/index.test.ts b/extensions/receipt-ocr/__tests__/index.test.ts
new file mode 100644
index 00000000..c1b2a7c1
--- /dev/null
+++ b/extensions/receipt-ocr/__tests__/index.test.ts
@@ -0,0 +1,256 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { eventBus } from '@/lib/events/bus'
+
+// ============================================================
+// Mocks — must be defined before importing the module under test
+// ============================================================
+
+let resultIdx: number
+let results: Array<{ data?: unknown; error?: unknown }>
+
+function makeBuilder() {
+ const b: Record = {}
+ for (const m of ['select', 'eq', 'in', 'is', 'insert', 'upsert', 'update', 'not', 'gte', 'lte', 'or', 'order', 'limit']) {
+ b[m] = vi.fn().mockReturnValue(b)
+ }
+ b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+ b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+ b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
+ return b
+}
+
+function makeClient(storageOverrides: Record = {}) {
+ return {
+ from: vi.fn().mockImplementation(() => makeBuilder()),
+ rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
+ storage: {
+ from: vi.fn().mockReturnValue({
+ download: vi.fn().mockResolvedValue({
+ data: new Blob(['fake-image']),
+ error: null,
+ }),
+ getPublicUrl: vi.fn().mockReturnValue({
+ data: { publicUrl: 'https://example.com/receipt.jpg' },
+ }),
+ ...storageOverrides,
+ }),
+ },
+ }
+}
+
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: vi.fn(async () => makeClient()),
+}))
+
+vi.mock('@/lib/receipts/receipt-analyzer', () => ({
+ analyzeReceipt: vi.fn().mockResolvedValue({
+ merchant: { name: 'ICA', orgNumber: null, vatNumber: null, isForeign: false },
+ receipt: { date: '2024-06-15', time: '14:30', currency: 'SEK' },
+ lineItems: [
+ { description: 'Mjölk', quantity: 1, unitPrice: 19, lineTotal: 19, vatRate: 12, suggestedCategory: null, confidence: 0.9 },
+ ],
+ totals: { subtotal: 19, vatAmount: 2.04, total: 19 },
+ flags: { isRestaurant: false, isSystembolaget: false, isForeignMerchant: false },
+ confidence: 0.92,
+ }),
+}))
+
+vi.mock('@/lib/receipts/receipt-matcher', () => ({
+ autoMatchReceipts: vi.fn().mockReturnValue([]),
+}))
+
+import { createClient } from '@/lib/supabase/server'
+import { analyzeReceipt } from '@/lib/receipts/receipt-analyzer'
+import { autoMatchReceipts } from '@/lib/receipts/receipt-matcher'
+import { getSettings, saveSettings, receiptOcrExtension } from '../index'
+import { extensionRegistry } from '@/lib/extensions/registry'
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ eventBus.clear()
+ extensionRegistry.clear()
+ resultIdx = 0
+ results = []
+ // Reset the mock to use default makeClient
+ vi.mocked(createClient).mockImplementation(async () => makeClient() as never)
+})
+
+// ============================================================
+// Settings tests
+// ============================================================
+
+describe('getSettings', () => {
+ it('returns defaults when no DB record', async () => {
+ results = [{ data: null, error: { code: 'PGRST116' } }]
+
+ const settings = await getSettings('user-1')
+ expect(settings.autoOcrEnabled).toBe(true)
+ expect(settings.autoMatchEnabled).toBe(true)
+ expect(settings.autoMatchThreshold).toBe(0.8)
+ expect(settings.ocrConfidenceThreshold).toBe(0.6)
+ })
+
+ it('merges DB value with defaults', async () => {
+ results = [{ data: { value: { autoOcrEnabled: false } }, error: null }]
+
+ const settings = await getSettings('user-1')
+ expect(settings.autoOcrEnabled).toBe(false)
+ expect(settings.autoMatchEnabled).toBe(true)
+ })
+})
+
+describe('saveSettings', () => {
+ it('merges partial into current settings', async () => {
+ results = [
+ // getSettings read
+ { data: { value: { autoOcrEnabled: true, autoMatchEnabled: true, autoMatchThreshold: 0.8, ocrConfidenceThreshold: 0.6 } }, error: null },
+ // upsert (thenable)
+ { data: null, error: null },
+ ]
+
+ const result = await saveSettings('user-1', { autoMatchThreshold: 0.9 })
+ expect(result.autoMatchThreshold).toBe(0.9)
+ expect(result.autoOcrEnabled).toBe(true)
+ })
+})
+
+// ============================================================
+// Extension object tests
+// ============================================================
+
+describe('receiptOcrExtension', () => {
+ it('has correct id, name, version', () => {
+ expect(receiptOcrExtension.id).toBe('receipt-ocr')
+ expect(receiptOcrExtension.name).toBe('Receipt OCR')
+ expect(receiptOcrExtension.version).toBe('1.0.0')
+ })
+
+ it('has event handlers for document.uploaded and transaction.synced', () => {
+ expect(receiptOcrExtension.eventHandlers).toBeDefined()
+ const types = receiptOcrExtension.eventHandlers!.map((h) => h.eventType)
+ expect(types).toContain('document.uploaded')
+ expect(types).toContain('transaction.synced')
+ })
+})
+
+// ============================================================
+// handleDocumentUploaded gate tests
+// ============================================================
+
+describe('handleDocumentUploaded gates', () => {
+ it('skips non-image mime types', async () => {
+ extensionRegistry.register(receiptOcrExtension)
+
+ await eventBus.emit({
+ type: 'document.uploaded',
+ payload: {
+ document: {
+ id: 'doc-1',
+ mime_type: 'application/pdf',
+ storage_path: 'docs/file.pdf',
+ } as never,
+ userId: 'user-1',
+ },
+ })
+
+ expect(analyzeReceipt).not.toHaveBeenCalled()
+ })
+
+ it('skips when autoOcrEnabled is false', async () => {
+ // Settings return autoOcr disabled
+ results = [
+ { data: { value: { autoOcrEnabled: false, autoMatchEnabled: true, autoMatchThreshold: 0.8, ocrConfidenceThreshold: 0.6 } }, error: null },
+ ]
+
+ extensionRegistry.register(receiptOcrExtension)
+
+ await eventBus.emit({
+ type: 'document.uploaded',
+ payload: {
+ document: {
+ id: 'doc-1',
+ mime_type: 'image/jpeg',
+ storage_path: 'docs/receipt.jpg',
+ } as never,
+ userId: 'user-1',
+ },
+ })
+
+ expect(analyzeReceipt).not.toHaveBeenCalled()
+ })
+
+ it('skips when confidence below threshold', async () => {
+ // Settings with very high threshold (0.99, above the 0.92 from analyzeReceipt mock)
+ results = [
+ { data: { value: { autoOcrEnabled: true, autoMatchEnabled: true, autoMatchThreshold: 0.8, ocrConfidenceThreshold: 0.99 } }, error: null },
+ ]
+
+ vi.mocked(createClient).mockImplementation(async () =>
+ makeClient({
+ download: vi.fn().mockResolvedValue({
+ data: new Blob(['fake-image-data']),
+ error: null,
+ }),
+ }) as never
+ )
+
+ extensionRegistry.register(receiptOcrExtension)
+
+ await eventBus.emit({
+ type: 'document.uploaded',
+ payload: {
+ document: {
+ id: 'doc-1',
+ mime_type: 'image/jpeg',
+ storage_path: 'docs/receipt.jpg',
+ } as never,
+ userId: 'user-1',
+ },
+ })
+
+ // analyzeReceipt IS called but confidence (0.92) < threshold (0.99)
+ expect(analyzeReceipt).toHaveBeenCalled()
+ })
+})
+
+// ============================================================
+// handleTransactionSynced gate tests
+// ============================================================
+
+describe('handleTransactionSynced gates', () => {
+ it('skips when autoMatchEnabled is false', async () => {
+ results = [
+ { data: { value: { autoOcrEnabled: true, autoMatchEnabled: false, autoMatchThreshold: 0.8, ocrConfidenceThreshold: 0.6 } }, error: null },
+ ]
+
+ extensionRegistry.register(receiptOcrExtension)
+
+ await eventBus.emit({
+ type: 'transaction.synced',
+ payload: {
+ transactions: [{ id: 'tx1', amount: -100 }] as never,
+ userId: 'user-1',
+ },
+ })
+
+ expect(autoMatchReceipts).not.toHaveBeenCalled()
+ })
+
+ it('skips when no expense transactions', async () => {
+ results = [
+ { data: { value: { autoOcrEnabled: true, autoMatchEnabled: true, autoMatchThreshold: 0.8, ocrConfidenceThreshold: 0.6 } }, error: null },
+ ]
+
+ extensionRegistry.register(receiptOcrExtension)
+
+ await eventBus.emit({
+ type: 'transaction.synced',
+ payload: {
+ transactions: [{ id: 'tx1', amount: 500 }] as never, // income
+ userId: 'user-1',
+ },
+ })
+
+ expect(autoMatchReceipts).not.toHaveBeenCalled()
+ })
+})
diff --git a/extensions/receipt-ocr/index.ts b/extensions/receipt-ocr/index.ts
new file mode 100644
index 00000000..4612d169
--- /dev/null
+++ b/extensions/receipt-ocr/index.ts
@@ -0,0 +1,322 @@
+import { createClient } from '@/lib/supabase/server'
+import { eventBus } from '@/lib/events/bus'
+import { analyzeReceipt } from '@/lib/receipts/receipt-analyzer'
+import { processLineItems } from '@/lib/receipts/receipt-categorizer'
+import { autoMatchReceipts } from '@/lib/receipts/receipt-matcher'
+import type { Extension } from '@/lib/extensions/types'
+import type { EventPayload } from '@/lib/events/types'
+import type { Receipt, Transaction } from '@/types'
+
+// ============================================================
+// Settings
+// ============================================================
+
+export interface ReceiptOcrSettings {
+ autoOcrEnabled: boolean
+ autoMatchEnabled: boolean
+ autoMatchThreshold: number
+ ocrConfidenceThreshold: number
+}
+
+const DEFAULT_SETTINGS: ReceiptOcrSettings = {
+ autoOcrEnabled: true,
+ autoMatchEnabled: true,
+ autoMatchThreshold: 0.8,
+ ocrConfidenceThreshold: 0.6,
+}
+
+export async function getSettings(userId: string): Promise {
+ const supabase = await createClient()
+
+ const { data } = await supabase
+ .from('extension_data')
+ .select('value')
+ .eq('user_id', userId)
+ .eq('extension_id', 'receipt-ocr')
+ .eq('key', 'settings')
+ .single()
+
+ if (!data?.value) return { ...DEFAULT_SETTINGS }
+
+ // Merge with defaults for forward-compatibility
+ return { ...DEFAULT_SETTINGS, ...(data.value as Partial) }
+}
+
+export async function saveSettings(
+ userId: string,
+ partial: Partial
+): Promise {
+ const current = await getSettings(userId)
+ const merged = { ...current, ...partial }
+
+ const supabase = await createClient()
+
+ await supabase
+ .from('extension_data')
+ .upsert(
+ {
+ user_id: userId,
+ extension_id: 'receipt-ocr',
+ key: 'settings',
+ value: merged,
+ },
+ { onConflict: 'user_id,extension_id,key' }
+ )
+
+ return merged
+}
+
+// ============================================================
+// Event Handlers
+// ============================================================
+
+const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
+
+/**
+ * When an image is uploaded via the document archive, auto-trigger OCR.
+ */
+async function handleDocumentUploaded(
+ payload: EventPayload<'document.uploaded'>
+): Promise {
+ const { document, userId } = payload
+
+ // Gate: Is it an image?
+ if (!document.mime_type || !IMAGE_MIME_TYPES.includes(document.mime_type)) {
+ return
+ }
+
+ // Gate: Is autoOcrEnabled?
+ const settings = await getSettings(userId)
+ if (!settings.autoOcrEnabled) {
+ return
+ }
+
+ console.log(`[receipt-ocr] Auto-OCR triggered for document ${document.id}`)
+
+ try {
+ const supabase = await createClient()
+
+ // Download image from storage
+ const { data: fileData, error: downloadError } = await supabase.storage
+ .from('documents')
+ .download(document.storage_path)
+
+ if (downloadError || !fileData) {
+ console.error('[receipt-ocr] Failed to download document:', downloadError)
+ return
+ }
+
+ // Convert to base64
+ const arrayBuffer = await fileData.arrayBuffer()
+ const base64 = Buffer.from(arrayBuffer).toString('base64')
+ const mimeType = document.mime_type as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
+
+ // Analyze receipt
+ const extraction = await analyzeReceipt(base64, mimeType)
+
+ // Gate: Is confidence high enough?
+ if (extraction.confidence < settings.ocrConfidenceThreshold) {
+ console.log(
+ `[receipt-ocr] Confidence ${extraction.confidence} below threshold ${settings.ocrConfidenceThreshold}, skipping`
+ )
+ return
+ }
+
+ // Process line items
+ const processedLineItems = processLineItems(extraction.lineItems)
+
+ // Get public URL for the document
+ const { data: urlData } = supabase.storage
+ .from('documents')
+ .getPublicUrl(document.storage_path)
+
+ // Create receipt record
+ const { data: receipt, error: insertError } = await supabase
+ .from('receipts')
+ .insert({
+ user_id: userId,
+ image_url: urlData.publicUrl,
+ status: 'extracted',
+ extraction_confidence: extraction.confidence,
+ merchant_name: extraction.merchant.name,
+ merchant_org_number: extraction.merchant.orgNumber,
+ merchant_vat_number: extraction.merchant.vatNumber,
+ receipt_date: extraction.receipt.date,
+ receipt_time: extraction.receipt.time,
+ total_amount: extraction.totals.total,
+ currency: extraction.receipt.currency,
+ vat_amount: extraction.totals.vatAmount,
+ is_restaurant: extraction.flags.isRestaurant,
+ is_systembolaget: extraction.flags.isSystembolaget,
+ is_foreign_merchant: extraction.flags.isForeignMerchant,
+ raw_extraction: extraction,
+ })
+ .select()
+ .single()
+
+ if (insertError || !receipt) {
+ console.error('[receipt-ocr] Failed to create receipt:', insertError)
+ return
+ }
+
+ // Insert line items
+ if (processedLineItems.length > 0) {
+ const lineItemsToInsert = processedLineItems.map((item, index) => ({
+ receipt_id: receipt.id,
+ description: item.description,
+ quantity: item.quantity,
+ unit_price: item.unitPrice,
+ line_total: item.lineTotal,
+ vat_rate: item.vatRate,
+ vat_amount:
+ item.vatRate && item.lineTotal
+ ? (item.lineTotal * item.vatRate) / (100 + item.vatRate)
+ : null,
+ extraction_confidence: item.confidence,
+ suggested_category: item.suggestedCategory,
+ sort_order: index,
+ }))
+
+ await supabase.from('receipt_line_items').insert(lineItemsToInsert)
+ }
+
+ // Fetch complete receipt with line items
+ const { data: completeReceipt } = await supabase
+ .from('receipts')
+ .select('*, line_items:receipt_line_items(*)')
+ .eq('id', receipt.id)
+ .single()
+
+ // Emit receipt.extracted
+ await eventBus.emit({
+ type: 'receipt.extracted',
+ payload: {
+ receipt: (completeReceipt || receipt) as unknown as Receipt,
+ documentId: document.id,
+ confidence: extraction.confidence,
+ userId,
+ },
+ })
+
+ console.log(`[receipt-ocr] Receipt ${receipt.id} created from document ${document.id}`)
+ } catch (error) {
+ console.error('[receipt-ocr] handleDocumentUploaded failed:', error)
+ }
+}
+
+/**
+ * When new transactions arrive from banking sync, auto-match unmatched receipts.
+ */
+async function handleTransactionSynced(
+ payload: EventPayload<'transaction.synced'>
+): Promise {
+ const { transactions: syncedTransactions, userId } = payload
+
+ // Gate: Is autoMatchEnabled?
+ const settings = await getSettings(userId)
+ if (!settings.autoMatchEnabled) {
+ return
+ }
+
+ // Only consider expense transactions
+ const expenseTransactions = syncedTransactions.filter((t) => t.amount < 0)
+ if (expenseTransactions.length === 0) {
+ return
+ }
+
+ console.log(
+ `[receipt-ocr] Auto-match triggered for ${expenseTransactions.length} expense transactions`
+ )
+
+ try {
+ const supabase = await createClient()
+
+ // Fetch unmatched receipts
+ const { data: unmatchedReceipts, error: fetchError } = await supabase
+ .from('receipts')
+ .select('*, line_items:receipt_line_items(*)')
+ .eq('user_id', userId)
+ .in('status', ['extracted', 'confirmed'])
+ .is('matched_transaction_id', null)
+
+ if (fetchError || !unmatchedReceipts || unmatchedReceipts.length === 0) {
+ return
+ }
+
+ // Run auto-matching
+ const matches = autoMatchReceipts(
+ unmatchedReceipts as unknown as Receipt[],
+ expenseTransactions,
+ settings.autoMatchThreshold
+ )
+
+ // Process each match
+ for (const { receipt, match } of matches) {
+ // Update receipt with match
+ await supabase
+ .from('receipts')
+ .update({
+ matched_transaction_id: match.transaction.id,
+ match_confidence: match.confidence,
+ })
+ .eq('id', receipt.id)
+
+ // Update transaction with receipt link
+ await supabase
+ .from('transactions')
+ .update({ receipt_id: receipt.id })
+ .eq('id', match.transaction.id)
+
+ // Emit receipt.matched
+ await eventBus.emit({
+ type: 'receipt.matched',
+ payload: {
+ receipt,
+ transaction: match.transaction,
+ confidence: match.confidence,
+ autoMatched: true,
+ userId,
+ },
+ })
+
+ console.log(
+ `[receipt-ocr] Auto-matched receipt ${receipt.id} to transaction ${match.transaction.id} (confidence: ${match.confidence})`
+ )
+ }
+ } catch (error) {
+ console.error('[receipt-ocr] handleTransactionSynced failed:', error)
+ }
+}
+
+// ============================================================
+// Extension Object
+// ============================================================
+
+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',
+ description: 'Auto-categorize transactions based on OCR-extracted merchant names',
+ },
+ {
+ id: 'receipt-ocr-category',
+ name: 'OCR Category Suggestion',
+ description: 'Suggest transaction categories from receipt line item analysis',
+ },
+ ],
+ settingsPanel: {
+ label: 'Receipt OCR',
+ path: '/settings/extensions/receipt-ocr',
+ },
+ async onInstall(ctx) {
+ await saveSettings(ctx.userId, DEFAULT_SETTINGS)
+ },
+}
diff --git a/lib/bookkeeping/__tests__/engine.test.ts b/lib/bookkeeping/__tests__/engine.test.ts
new file mode 100644
index 00000000..9ae0e755
--- /dev/null
+++ b/lib/bookkeeping/__tests__/engine.test.ts
@@ -0,0 +1,64 @@
+import { describe, it, expect } from 'vitest'
+import { validateBalance } from '../engine'
+import type { CreateJournalEntryLineInput } from '@/types'
+
+describe('validateBalance', () => {
+ it('balanced entry (debit == credit) → valid: true', () => {
+ const lines: CreateJournalEntryLineInput[] = [
+ { account_number: '1930', debit_amount: 1000, credit_amount: 0 },
+ { account_number: '3001', debit_amount: 0, credit_amount: 1000 },
+ ]
+
+ const result = validateBalance(lines)
+ expect(result.valid).toBe(true)
+ expect(result.totalDebit).toBe(1000)
+ expect(result.totalCredit).toBe(1000)
+ })
+
+ it('unbalanced entry → valid: false', () => {
+ const lines: CreateJournalEntryLineInput[] = [
+ { account_number: '1930', debit_amount: 1000, credit_amount: 0 },
+ { account_number: '3001', debit_amount: 0, credit_amount: 500 },
+ ]
+
+ const result = validateBalance(lines)
+ expect(result.valid).toBe(false)
+ expect(result.totalDebit).toBe(1000)
+ expect(result.totalCredit).toBe(500)
+ })
+
+ it('zero amounts → valid: false (roundedDebit must be > 0)', () => {
+ const lines: CreateJournalEntryLineInput[] = [
+ { account_number: '1930', debit_amount: 0, credit_amount: 0 },
+ { account_number: '3001', debit_amount: 0, credit_amount: 0 },
+ ]
+
+ const result = validateBalance(lines)
+ expect(result.valid).toBe(false)
+ expect(result.totalDebit).toBe(0)
+ expect(result.totalCredit).toBe(0)
+ })
+
+ it('floating point edge case (33.33 + 33.33 + 33.34) → valid: true', () => {
+ const lines: CreateJournalEntryLineInput[] = [
+ { account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
+ { account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
+ { account_number: '1930', debit_amount: 33.34, credit_amount: 0 },
+ { account_number: '3001', debit_amount: 0, credit_amount: 100 },
+ ]
+
+ const result = validateBalance(lines)
+ expect(result.valid).toBe(true)
+ expect(result.totalDebit).toBe(100)
+ expect(result.totalCredit).toBe(100)
+ })
+
+ it('single line (only debit, no credit) → valid: false', () => {
+ const lines: CreateJournalEntryLineInput[] = [
+ { account_number: '1930', debit_amount: 500, credit_amount: 0 },
+ ]
+
+ const result = validateBalance(lines)
+ expect(result.valid).toBe(false)
+ })
+})
diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts
index 44a608e4..f330cfdc 100644
--- a/lib/bookkeeping/engine.ts
+++ b/lib/bookkeeping/engine.ts
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
+import { eventBus } from '@/lib/events'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
@@ -30,6 +31,7 @@ export function validateBalance(lines: CreateJournalEntryLineInput[]): {
/**
* Get the next voucher number for a user/period/series
+ * Uses the concurrent-safe INSERT ON CONFLICT implementation in the database
*/
export async function getNextVoucherNumber(
userId: string,
@@ -104,9 +106,168 @@ export async function findFiscalPeriod(
return data.id
}
+/**
+ * Build line insert objects from input lines, resolving account IDs and
+ * including tax_code, cost_center, project dimensions
+ */
+function buildLineInserts(
+ entryId: string,
+ lines: CreateJournalEntryLineInput[],
+ accountIdMap: Map
+) {
+ return lines.map((line, index) => ({
+ journal_entry_id: entryId,
+ account_number: line.account_number,
+ account_id: accountIdMap.get(line.account_number) || null,
+ debit_amount: Math.round((line.debit_amount || 0) * 100) / 100,
+ credit_amount: Math.round((line.credit_amount || 0) * 100) / 100,
+ currency: line.currency || 'SEK',
+ amount_in_currency: line.amount_in_currency ? Math.round(line.amount_in_currency * 100) / 100 : null,
+ exchange_rate: line.exchange_rate || null,
+ line_description: line.line_description || null,
+ tax_code: line.tax_code || null,
+ cost_center: line.cost_center || null,
+ project: line.project || null,
+ sort_order: index,
+ }))
+}
+
+/**
+ * Create a draft journal entry with lines (no voucher number assigned yet)
+ * The entry stays in 'draft' status until commitEntry() is called.
+ */
+export async function createDraftEntry(
+ userId: string,
+ input: CreateJournalEntryInput
+): Promise {
+ // Validate balance
+ const balance = validateBalance(input.lines)
+ if (!balance.valid) {
+ throw new Error(
+ `Journal entry is not balanced: debits (${balance.totalDebit}) != credits (${balance.totalCredit})`
+ )
+ }
+
+ const supabase = await createClient()
+
+ // Resolve account IDs
+ const accountIdMap = await resolveAccountIds(supabase, userId, input.lines)
+
+ // Insert journal entry header as draft (voucher_number = 0, will be assigned on commit)
+ const { data: entry, error: entryError } = await supabase
+ .from('journal_entries')
+ .insert({
+ user_id: userId,
+ fiscal_period_id: input.fiscal_period_id,
+ voucher_number: 0,
+ voucher_series: input.voucher_series || 'A',
+ entry_date: input.entry_date,
+ description: input.description,
+ source_type: input.source_type,
+ source_id: input.source_id || null,
+ status: 'draft',
+ })
+ .select()
+ .single()
+
+ if (entryError || !entry) {
+ throw new Error(`Failed to create draft journal entry: ${entryError?.message}`)
+ }
+
+ // Insert journal entry lines with dimensions
+ const lineInserts = buildLineInserts(entry.id, input.lines, accountIdMap)
+
+ const { error: linesError } = await supabase
+ .from('journal_entry_lines')
+ .insert(lineInserts)
+
+ if (linesError) {
+ await supabase.from('journal_entries').delete().eq('id', entry.id)
+ throw new Error(`Failed to create journal entry lines: ${linesError.message}`)
+ }
+
+ // Fetch complete entry with lines
+ const { data: completeEntry } = await supabase
+ .from('journal_entries')
+ .select('*, lines:journal_entry_lines(*)')
+ .eq('id', entry.id)
+ .single()
+
+ const result = completeEntry as JournalEntry
+
+ await eventBus.emit({
+ type: 'journal_entry.drafted',
+ payload: { entry: result, userId },
+ })
+
+ return result
+}
+
+/**
+ * Commit a draft entry: assigns voucher number and transitions to 'posted'
+ * Triggers balance validation and sets committed_at via DB triggers
+ */
+export async function commitEntry(
+ userId: string,
+ entryId: string
+): Promise {
+ const supabase = await createClient()
+
+ // Fetch the draft entry
+ const { data: entry, error: fetchError } = await supabase
+ .from('journal_entries')
+ .select('*')
+ .eq('id', entryId)
+ .eq('user_id', userId)
+ .eq('status', 'draft')
+ .single()
+
+ if (fetchError || !entry) {
+ throw new Error('Draft journal entry not found')
+ }
+
+ // Assign voucher number
+ const voucherNumber = await getNextVoucherNumber(
+ userId,
+ entry.fiscal_period_id,
+ entry.voucher_series || 'A'
+ )
+
+ // Update to posted with voucher number
+ // DB triggers will: validate balance, set committed_at, write audit log
+ const { error: postError } = await supabase
+ .from('journal_entries')
+ .update({
+ voucher_number: voucherNumber,
+ status: 'posted',
+ })
+ .eq('id', entryId)
+
+ if (postError) {
+ throw new Error(`Failed to commit journal entry: ${postError.message}`)
+ }
+
+ // Fetch complete posted entry with lines
+ const { data: completeEntry } = await supabase
+ .from('journal_entries')
+ .select('*, lines:journal_entry_lines(*)')
+ .eq('id', entryId)
+ .single()
+
+ const result = completeEntry as JournalEntry
+
+ await eventBus.emit({
+ type: 'journal_entry.committed',
+ payload: { entry: result, userId },
+ })
+
+ return result
+}
+
/**
* Create a journal entry with lines (verifikation)
- * Validates balance, resolves account IDs, assigns voucher number, inserts atomically
+ * Convenience wrapper: creates draft + commits in one step.
+ * Validates balance, resolves account IDs, assigns voucher number, inserts atomically.
*/
export async function createJournalEntry(
userId: string,
@@ -153,19 +314,8 @@ export async function createJournalEntry(
throw new Error(`Failed to create journal entry: ${entryError?.message}`)
}
- // Insert journal entry lines (round amounts to 2 decimal places to avoid floating point issues)
- const lineInserts = input.lines.map((line, index) => ({
- journal_entry_id: entry.id,
- account_number: line.account_number,
- account_id: accountIdMap.get(line.account_number) || null,
- debit_amount: Math.round((line.debit_amount || 0) * 100) / 100,
- credit_amount: Math.round((line.credit_amount || 0) * 100) / 100,
- currency: line.currency || 'SEK',
- amount_in_currency: line.amount_in_currency ? Math.round(line.amount_in_currency * 100) / 100 : null,
- exchange_rate: line.exchange_rate || null,
- line_description: line.line_description || null,
- sort_order: index,
- }))
+ // Insert journal entry lines with dimensions
+ const lineInserts = buildLineInserts(entry.id, input.lines, accountIdMap)
const { error: linesError } = await supabase
.from('journal_entry_lines')
@@ -177,7 +327,7 @@ export async function createJournalEntry(
throw new Error(`Failed to create journal entry lines: ${linesError.message}`)
}
- // Post the entry (triggers balance validation in DB)
+ // Post the entry (triggers balance validation + committed_at in DB)
const { data: postedEntry, error: postError } = await supabase
.from('journal_entries')
.update({ status: 'posted' })
@@ -199,11 +349,19 @@ export async function createJournalEntry(
.eq('id', entry.id)
.single()
- return completeEntry as JournalEntry
+ const result = completeEntry as JournalEntry
+
+ await eventBus.emit({
+ type: 'journal_entry.committed',
+ payload: { entry: result, userId },
+ })
+
+ return result
}
/**
* Create a reversal entry for an existing journal entry
+ * Sets reversed_by_id/reverses_id links for compliance tracking
*/
export async function reverseEntry(
userId: string,
@@ -229,7 +387,7 @@ export async function reverseEntry(
const lines = (original.lines as JournalEntryLine[]) || []
- // Create reversed lines (swap debit and credit)
+ // Create reversed lines (swap debit and credit, preserve dimensions)
const reversedLines: CreateJournalEntryLineInput[] = lines.map((line) => ({
account_number: line.account_number,
debit_amount: line.credit_amount,
@@ -240,24 +398,89 @@ export async function reverseEntry(
? -line.amount_in_currency
: undefined,
exchange_rate: line.exchange_rate || undefined,
+ tax_code: line.tax_code || undefined,
+ cost_center: line.cost_center || undefined,
+ project: line.project || undefined,
}))
- // Create reversal entry
- const reversalEntry = await createJournalEntry(userId, {
- fiscal_period_id: original.fiscal_period_id,
- entry_date: new Date().toISOString().split('T')[0],
- description: `Makulering: ${original.description}`,
- source_type: original.source_type,
- source_id: original.source_id,
- voucher_series: original.voucher_series,
- lines: reversedLines,
- })
+ // Get voucher number for the reversal
+ const voucherNumber = await getNextVoucherNumber(
+ userId,
+ original.fiscal_period_id,
+ original.voucher_series || 'A'
+ )
- // Mark original as reversed
+ // Resolve account IDs
+ const accountIdMap = await resolveAccountIds(supabase, userId, reversedLines)
+
+ // Create reversal entry with reverses_id link
+ const { data: reversalEntry, error: reversalError } = await supabase
+ .from('journal_entries')
+ .insert({
+ user_id: userId,
+ fiscal_period_id: original.fiscal_period_id,
+ voucher_number: voucherNumber,
+ voucher_series: original.voucher_series || 'A',
+ entry_date: new Date().toISOString().split('T')[0],
+ description: `Makulering: ${original.description}`,
+ source_type: 'storno',
+ source_id: original.source_id || null,
+ reverses_id: entryId,
+ status: 'draft',
+ })
+ .select()
+ .single()
+
+ if (reversalError || !reversalEntry) {
+ throw new Error(`Failed to create reversal entry: ${reversalError?.message}`)
+ }
+
+ // Insert reversal lines with dimensions
+ const lineInserts = buildLineInserts(reversalEntry.id, reversedLines, accountIdMap)
+
+ const { error: linesError } = await supabase
+ .from('journal_entry_lines')
+ .insert(lineInserts)
+
+ if (linesError) {
+ await supabase.from('journal_entries').delete().eq('id', reversalEntry.id)
+ throw new Error(`Failed to create reversal lines: ${linesError.message}`)
+ }
+
+ // Post the reversal entry
+ const { error: postError } = await supabase
+ .from('journal_entries')
+ .update({ status: 'posted' })
+ .eq('id', reversalEntry.id)
+
+ if (postError) {
+ await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
+ await supabase.from('journal_entries').delete().eq('id', reversalEntry.id)
+ throw new Error(`Failed to post reversal entry: ${postError.message}`)
+ }
+
+ // Mark original as reversed with reversed_by_id link
await supabase
.from('journal_entries')
- .update({ status: 'reversed' })
+ .update({
+ status: 'reversed',
+ reversed_by_id: reversalEntry.id,
+ })
.eq('id', entryId)
- return reversalEntry
+ // Fetch complete reversal entry with lines
+ const { data: completeEntry } = await supabase
+ .from('journal_entries')
+ .select('*, lines:journal_entry_lines(*)')
+ .eq('id', reversalEntry.id)
+ .single()
+
+ const result = completeEntry as JournalEntry
+
+ await eventBus.emit({
+ type: 'journal_entry.committed',
+ payload: { entry: result, userId },
+ })
+
+ return result
}
diff --git a/lib/core/audit/audit-service.ts b/lib/core/audit/audit-service.ts
new file mode 100644
index 00000000..fec200dc
--- /dev/null
+++ b/lib/core/audit/audit-service.ts
@@ -0,0 +1,146 @@
+import { createClient } from '@/lib/supabase/server'
+import type { AuditLogEntry, AuditAction } from '@/types'
+
+/**
+ * Audit Service - Read-only service for the audit log
+ *
+ * The audit log is written exclusively by database triggers (SECURITY DEFINER).
+ * This service provides read access for compliance reporting and investigation.
+ */
+
+export interface AuditLogFilters {
+ action?: AuditAction
+ table_name?: string
+ record_id?: string
+ from_date?: string
+ to_date?: string
+ page?: number
+ pageSize?: number
+}
+
+/**
+ * Get paginated audit log entries for a user
+ */
+export async function getAuditLog(
+ userId: string,
+ filters: AuditLogFilters = {}
+): Promise<{ data: AuditLogEntry[]; count: number }> {
+ const supabase = await createClient()
+ const page = filters.page ?? 1
+ const pageSize = filters.pageSize ?? 50
+ const offset = (page - 1) * pageSize
+
+ let query = supabase
+ .from('audit_log')
+ .select('*', { count: 'exact' })
+ .eq('user_id', userId)
+ .order('created_at', { ascending: false })
+ .range(offset, offset + pageSize - 1)
+
+ if (filters.action) {
+ query = query.eq('action', filters.action)
+ }
+ if (filters.table_name) {
+ query = query.eq('table_name', filters.table_name)
+ }
+ if (filters.record_id) {
+ query = query.eq('record_id', filters.record_id)
+ }
+ if (filters.from_date) {
+ query = query.gte('created_at', filters.from_date)
+ }
+ if (filters.to_date) {
+ query = query.lte('created_at', filters.to_date)
+ }
+
+ const { data, error, count } = await query
+
+ if (error) {
+ throw new Error(`Failed to fetch audit log: ${error.message}`)
+ }
+
+ return {
+ data: (data as AuditLogEntry[]) || [],
+ count: count ?? 0,
+ }
+}
+
+/**
+ * Get full history of a single record (all mutations)
+ */
+export async function getEntityHistory(
+ userId: string,
+ tableName: string,
+ recordId: string
+): Promise {
+ const supabase = await createClient()
+
+ const { data, error } = await supabase
+ .from('audit_log')
+ .select('*')
+ .eq('user_id', userId)
+ .eq('table_name', tableName)
+ .eq('record_id', recordId)
+ .order('created_at', { ascending: true })
+
+ if (error) {
+ throw new Error(`Failed to fetch entity history: ${error.message}`)
+ }
+
+ return (data as AuditLogEntry[]) || []
+}
+
+/**
+ * Trace the correction chain for a journal entry:
+ * original → storno (reversal) → corrected entry
+ */
+export async function getCorrectionChain(
+ userId: string,
+ journalEntryId: string
+): Promise {
+ const supabase = await createClient()
+
+ // First, find the entry and its linked entries
+ const { data: entry, error: entryError } = await supabase
+ .from('journal_entries')
+ .select('id, reverses_id, reversed_by_id, correction_of_id')
+ .eq('id', journalEntryId)
+ .eq('user_id', userId)
+ .single()
+
+ if (entryError || !entry) {
+ throw new Error('Journal entry not found')
+ }
+
+ // Collect all related entry IDs
+ const relatedIds = new Set([entry.id])
+ if (entry.reverses_id) relatedIds.add(entry.reverses_id)
+ if (entry.reversed_by_id) relatedIds.add(entry.reversed_by_id)
+ if (entry.correction_of_id) relatedIds.add(entry.correction_of_id)
+
+ // Also look for entries that reference this one
+ const { data: referencing } = await supabase
+ .from('journal_entries')
+ .select('id')
+ .eq('user_id', userId)
+ .or(`reverses_id.eq.${journalEntryId},reversed_by_id.eq.${journalEntryId},correction_of_id.eq.${journalEntryId}`)
+
+ for (const ref of referencing || []) {
+ relatedIds.add(ref.id)
+ }
+
+ // Fetch audit log entries for all related IDs
+ const { data, error } = await supabase
+ .from('audit_log')
+ .select('*')
+ .eq('user_id', userId)
+ .eq('table_name', 'journal_entries')
+ .in('record_id', Array.from(relatedIds))
+ .order('created_at', { ascending: true })
+
+ if (error) {
+ throw new Error(`Failed to fetch correction chain: ${error.message}`)
+ }
+
+ return (data as AuditLogEntry[]) || []
+}
diff --git a/lib/core/bookkeeping/__tests__/period-service.test.ts b/lib/core/bookkeeping/__tests__/period-service.test.ts
new file mode 100644
index 00000000..e46c0cb6
--- /dev/null
+++ b/lib/core/bookkeeping/__tests__/period-service.test.ts
@@ -0,0 +1,178 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { eventBus } from '@/lib/events/bus'
+import { makeFiscalPeriod } from '@/tests/helpers'
+
+// ============================================================
+// Mock — separate client (no .then) from query builder (thenable)
+// ============================================================
+
+let resultIdx: number
+let results: Array<{ data?: unknown; error?: unknown; count?: number | null }>
+
+function makeBuilder() {
+ const b: Record = {}
+ for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'not', 'or', 'order', 'limit', 'is']) {
+ b[m] = vi.fn().mockReturnValue(b)
+ }
+ b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+ b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+ // Thenable for chains awaited without .single()
+ b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
+ return b
+}
+
+function makeClient() {
+ // Client has NO .then — won't be consumed by `await createClient()`
+ return {
+ from: vi.fn().mockImplementation(() => makeBuilder()),
+ rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
+ }
+}
+
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: vi.fn(async () => makeClient()),
+}))
+
+import { lockPeriod, closePeriod, createNextPeriod } from '../period-service'
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ eventBus.clear()
+ resultIdx = 0
+ results = []
+})
+
+describe('lockPeriod', () => {
+ it('sets locked_at and emits period.locked', async () => {
+ const period = makeFiscalPeriod({ id: 'fp-1', locked_at: null, is_closed: false })
+ const lockedPeriod = { ...period, locked_at: '2024-12-31T23:59:59Z' }
+
+ results = [
+ { data: period, error: null }, // fetch
+ { data: lockedPeriod, error: null }, // update
+ ]
+
+ const handler = vi.fn()
+ eventBus.on('period.locked', handler)
+
+ const result = await lockPeriod('user-1', 'fp-1')
+
+ expect(result.locked_at).toBeTruthy()
+ expect(handler).toHaveBeenCalledOnce()
+ })
+
+ it('rejects already-locked period', async () => {
+ const period = makeFiscalPeriod({
+ id: 'fp-1',
+ locked_at: '2024-06-01T00:00:00Z',
+ is_closed: false,
+ })
+
+ results = [{ data: period, error: null }]
+
+ await expect(lockPeriod('user-1', 'fp-1')).rejects.toThrow('already locked')
+ })
+})
+
+describe('closePeriod', () => {
+ it('requires period is locked and has closing_entry_id', async () => {
+ const period = makeFiscalPeriod({
+ id: 'fp-1',
+ locked_at: '2024-12-31T23:59:59Z',
+ is_closed: false,
+ closing_entry_id: 'ce-1',
+ })
+ const closedPeriod = { ...period, is_closed: true, closed_at: '2024-12-31T23:59:59Z' }
+
+ results = [
+ { data: period, error: null },
+ { data: closedPeriod, error: null },
+ ]
+
+ const result = await closePeriod('user-1', 'fp-1')
+ expect(result.is_closed).toBe(true)
+ })
+
+ it('rejects if not locked', async () => {
+ const period = makeFiscalPeriod({
+ id: 'fp-1',
+ locked_at: null,
+ is_closed: false,
+ closing_entry_id: 'ce-1',
+ })
+
+ results = [{ data: period, error: null }]
+
+ await expect(closePeriod('user-1', 'fp-1')).rejects.toThrow('must be locked')
+ })
+
+ it('rejects if no closing_entry_id', async () => {
+ const period = makeFiscalPeriod({
+ id: 'fp-1',
+ locked_at: '2024-12-31T23:59:59Z',
+ is_closed: false,
+ closing_entry_id: null,
+ })
+
+ results = [{ data: period, error: null }]
+
+ await expect(closePeriod('user-1', 'fp-1')).rejects.toThrow(
+ 'Year-end closing must be executed'
+ )
+ })
+})
+
+describe('createNextPeriod', () => {
+ it('calculates correct dates for standard (Jan-Dec) fiscal year', async () => {
+ const current = makeFiscalPeriod({
+ id: 'fp-2024',
+ period_start: '2024-01-01',
+ period_end: '2024-12-31',
+ })
+
+ const nextPeriod = makeFiscalPeriod({
+ id: 'fp-2025',
+ name: 'FY 2025',
+ period_start: '2025-01-01',
+ period_end: '2025-12-31',
+ previous_period_id: 'fp-2024',
+ })
+
+ results = [
+ { data: current, error: null }, // fetch current
+ { data: null, error: null }, // check if next exists (maybeSingle)
+ { data: nextPeriod, error: null }, // insert
+ ]
+
+ const result = await createNextPeriod('user-1', 'fp-2024')
+ expect(result.period_start).toBe('2025-01-01')
+ expect(result.period_end).toBe('2025-12-31')
+ expect(result.previous_period_id).toBe('fp-2024')
+ })
+
+ it('calculates correct dates for broken (Jul-Jun) fiscal year', async () => {
+ const current = makeFiscalPeriod({
+ id: 'fp-2024',
+ period_start: '2023-07-01',
+ period_end: '2024-06-30',
+ })
+
+ const nextPeriod = makeFiscalPeriod({
+ id: 'fp-2025',
+ name: 'FY 2024/2025',
+ period_start: '2024-07-01',
+ period_end: '2025-06-30',
+ previous_period_id: 'fp-2024',
+ })
+
+ results = [
+ { data: current, error: null },
+ { data: null, error: null },
+ { data: nextPeriod, error: null },
+ ]
+
+ const result = await createNextPeriod('user-1', 'fp-2024')
+ expect(result.period_start).toBe('2024-07-01')
+ expect(result.period_end).toBe('2025-06-30')
+ })
+})
diff --git a/lib/core/bookkeeping/__tests__/storno-service.test.ts b/lib/core/bookkeeping/__tests__/storno-service.test.ts
new file mode 100644
index 00000000..36ab1016
--- /dev/null
+++ b/lib/core/bookkeeping/__tests__/storno-service.test.ts
@@ -0,0 +1,144 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { eventBus } from '@/lib/events/bus'
+import { makeJournalEntry, makeJournalEntryLine } from '@/tests/helpers'
+
+// ============================================================
+// Mock — separate client (no .then) from query builder (thenable)
+// ============================================================
+
+let resultIdx: number
+let results: Array<{ data?: unknown; error?: unknown }>
+
+function makeBuilder() {
+ const b: Record = {}
+ for (const m of ['select', 'eq', 'in', 'insert', 'update', 'delete']) {
+ b[m] = vi.fn().mockReturnValue(b)
+ }
+ b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+ b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
+ return b
+}
+
+function makeClient() {
+ return {
+ from: vi.fn().mockImplementation(() => makeBuilder()),
+ rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
+ }
+}
+
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: vi.fn(async () => makeClient()),
+}))
+
+vi.mock('@/lib/bookkeeping/engine', () => ({
+ validateBalance: vi.fn().mockReturnValue({ valid: true, totalDebit: 1000, totalCredit: 1000 }),
+ getNextVoucherNumber: vi.fn(async () => ++resultIdx), // just increment
+}))
+
+import { correctEntry } from '../storno-service'
+import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ eventBus.clear()
+ resultIdx = 0
+ results = []
+
+ // Reset the mock implementations after clearAllMocks
+ vi.mocked(validateBalance).mockReturnValue({ valid: true, totalDebit: 1000, totalCredit: 1000 })
+ let voucherNum = 0
+ vi.mocked(getNextVoucherNumber).mockImplementation(async () => ++voucherNum)
+})
+
+describe('correctEntry', () => {
+ const originalEntry = makeJournalEntry({
+ id: 'orig-1',
+ status: 'posted',
+ description: 'Test purchase',
+ fiscal_period_id: 'fp-1',
+ voucher_series: 'A',
+ lines: [
+ makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
+ makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
+ ],
+ })
+
+ const correctedLines = [
+ { account_number: '5420', debit_amount: 1200, credit_amount: 0 },
+ { account_number: '1930', debit_amount: 0, credit_amount: 1200 },
+ ]
+
+ function setupResults() {
+ const reversalEntry = makeJournalEntry({ id: 'reversal-1', reverses_id: 'orig-1' })
+ const correctedEntry = makeJournalEntry({ id: 'corrected-1', correction_of_id: 'orig-1' })
+
+ results = [
+ // 0: fetch original
+ { data: originalEntry, error: null },
+ // 1: insert reversal entry
+ { data: reversalEntry, error: null },
+ // 2: insert reversal lines (thenable, no .single())
+ { data: null, error: null },
+ // 3: update reversal to posted (thenable)
+ { data: null, error: null },
+ // 4: mark original as reversed (thenable)
+ { data: null, error: null },
+ // 5: fetch accounts for corrected lines
+ { data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null },
+ // 6: insert corrected entry
+ { data: correctedEntry, error: null },
+ // 7: insert corrected lines (thenable)
+ { data: null, error: null },
+ // 8: update corrected to posted (thenable)
+ { data: null, error: null },
+ // 9: fetch final reversal
+ { data: { ...reversalEntry, lines: [] }, error: null },
+ // 10: fetch final corrected
+ { data: { ...correctedEntry, lines: correctedLines }, error: null },
+ ]
+ }
+
+ it('creates reversal with swapped debit/credit lines', async () => {
+ setupResults()
+ const result = await correctEntry('user-1', 'orig-1', correctedLines)
+ expect(result.reversal).toBeDefined()
+ expect(result.reversal.reverses_id).toBe('orig-1')
+ })
+
+ it('links original ↔ reversal ↔ corrected via IDs', async () => {
+ setupResults()
+ const result = await correctEntry('user-1', 'orig-1', correctedLines)
+ expect(result.reversal.id).toBe('reversal-1')
+ expect(result.corrected.id).toBe('corrected-1')
+ expect(result.corrected.correction_of_id).toBe('orig-1')
+ })
+
+ it('validates balance of corrected lines (rejects unbalanced)', async () => {
+ vi.mocked(validateBalance).mockReturnValueOnce({
+ valid: false,
+ totalDebit: 1200,
+ totalCredit: 1000,
+ })
+
+ await expect(
+ correctEntry('user-1', 'orig-1', [
+ { account_number: '5420', debit_amount: 1200, credit_amount: 0 },
+ { account_number: '1930', debit_amount: 0, credit_amount: 1000 },
+ ])
+ ).rejects.toThrow('not balanced')
+ })
+
+ it('emits journal_entry.corrected event', async () => {
+ setupResults()
+
+ const handler = vi.fn()
+ eventBus.on('journal_entry.corrected', handler)
+
+ await correctEntry('user-1', 'orig-1', correctedLines)
+
+ expect(handler).toHaveBeenCalledOnce()
+ expect(handler).toHaveBeenCalledWith(
+ expect.objectContaining({ userId: 'user-1' })
+ )
+ })
+})
diff --git a/lib/core/bookkeeping/__tests__/year-end-service.test.ts b/lib/core/bookkeeping/__tests__/year-end-service.test.ts
new file mode 100644
index 00000000..4ef94630
--- /dev/null
+++ b/lib/core/bookkeeping/__tests__/year-end-service.test.ts
@@ -0,0 +1,197 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { eventBus } from '@/lib/events/bus'
+import { makeFiscalPeriod } from '@/tests/helpers'
+
+// ============================================================
+// Mock — separate client (no .then) from query builder (thenable)
+// ============================================================
+
+let resultIdx: number
+let results: Array<{ data?: unknown; error?: unknown; count?: number | null }>
+
+function makeBuilder() {
+ const b: Record = {}
+ for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'not', 'or', 'order', 'limit', 'is']) {
+ b[m] = vi.fn().mockReturnValue(b)
+ }
+ b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+ b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+ b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
+ return b
+}
+
+function makeClient() {
+ return {
+ from: vi.fn().mockImplementation(() => makeBuilder()),
+ rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
+ }
+}
+
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: vi.fn(async () => makeClient()),
+}))
+
+vi.mock('@/lib/reports/trial-balance', () => ({
+ generateTrialBalance: vi.fn(),
+}))
+
+vi.mock('@/lib/reports/income-statement', () => ({
+ generateIncomeStatement: vi.fn(),
+}))
+
+vi.mock('@/lib/bookkeeping/engine', () => ({
+ createJournalEntry: vi.fn(),
+}))
+
+vi.mock('../period-service', () => ({
+ lockPeriod: vi.fn(),
+ closePeriod: vi.fn(),
+ createNextPeriod: vi.fn(),
+}))
+
+import { validateYearEndReadiness, previewYearEndClosing } from '../year-end-service'
+import { generateTrialBalance } from '@/lib/reports/trial-balance'
+import { generateIncomeStatement } from '@/lib/reports/income-statement'
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ eventBus.clear()
+ resultIdx = 0
+ results = []
+})
+
+describe('validateYearEndReadiness', () => {
+ it('returns errors when drafts exist', async () => {
+ const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
+
+ results = [
+ // 0: fetch period (.single)
+ { data: period, error: null },
+ // 1: count drafts (thenable chain) — count: 3
+ { data: null, error: null, count: 3 },
+ // 2: count posted entries (thenable chain) — count: 10
+ { data: null, error: null, count: 10 },
+ ]
+
+ vi.mocked(generateTrialBalance).mockResolvedValue({
+ rows: [],
+ isBalanced: true,
+ totalDebit: 0,
+ totalCredit: 0,
+ } as never)
+
+ const result = await validateYearEndReadiness('user-1', 'fp-1')
+ expect(result.ready).toBe(false)
+ expect(result.errors.some((e: string) => e.includes('draft'))).toBe(true)
+ })
+
+ it('returns errors when trial balance is unbalanced', async () => {
+ const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
+
+ results = [
+ { data: period, error: null },
+ { data: null, error: null, count: 0 }, // no drafts
+ { data: null, error: null, count: 5 }, // some posted
+ ]
+
+ vi.mocked(generateTrialBalance).mockResolvedValue({
+ rows: [],
+ isBalanced: false,
+ totalDebit: 10000,
+ totalCredit: 9500,
+ } as never)
+
+ const result = await validateYearEndReadiness('user-1', 'fp-1')
+ expect(result.ready).toBe(false)
+ expect(result.trialBalanceBalanced).toBe(false)
+ expect(result.errors.some((e: string) => e.includes('Trial balance'))).toBe(true)
+ })
+
+ it('warns on voucher gaps', async () => {
+ const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
+
+ // Override makeClient to return gaps from rpc
+ const { createClient } = await import('@/lib/supabase/server')
+ const builder = makeBuilder()
+ const client = {
+ from: vi.fn().mockImplementation(() => builder),
+ rpc: vi.fn().mockResolvedValue({
+ data: [{ gap_start: 5, gap_end: 7 }],
+ error: null,
+ }),
+ }
+ vi.mocked(createClient).mockResolvedValue(client as never)
+
+ resultIdx = 0
+ results = [
+ { data: period, error: null },
+ { data: null, error: null, count: 0 },
+ { data: null, error: null, count: 5 },
+ ]
+
+ vi.mocked(generateTrialBalance).mockResolvedValue({
+ rows: [],
+ isBalanced: true,
+ totalDebit: 10000,
+ totalCredit: 10000,
+ } as never)
+
+ const result = await validateYearEndReadiness('user-1', 'fp-1')
+ expect(result.warnings.some((w: string) => w.includes('gap'))).toBe(true)
+ expect(result.voucherGaps).toHaveLength(1)
+ })
+})
+
+describe('previewYearEndClosing', () => {
+ it('calculates net result from class 3-8 accounts', async () => {
+ results = [
+ // 0: fetch company_settings (.single)
+ { data: { entity_type: 'aktiebolag' }, error: null },
+ ]
+
+ vi.mocked(generateIncomeStatement).mockResolvedValue({
+ net_result: 150000,
+ } as never)
+
+ vi.mocked(generateTrialBalance).mockResolvedValue({
+ rows: [
+ { account_number: '3001', account_name: 'Tjänsteintäkter', account_class: 3, closing_debit: 0, closing_credit: 500000 },
+ { account_number: '5010', account_name: 'Lokalhyra', account_class: 5, closing_debit: 200000, closing_credit: 0 },
+ { account_number: '6570', account_name: 'Bankavgifter', account_class: 6, closing_debit: 150000, closing_credit: 0 },
+ ],
+ isBalanced: true,
+ totalDebit: 350000,
+ totalCredit: 500000,
+ } as never)
+
+ const preview = await previewYearEndClosing('user-1', 'fp-1')
+
+ expect(preview.netResult).toBe(150000)
+ expect(preview.closingAccount).toBe('2099')
+ expect(preview.closingAccountName).toBe('Årets resultat')
+ expect(preview.closingLines.length).toBeGreaterThanOrEqual(3)
+ expect(preview.resultAccountSummary).toHaveLength(3)
+ })
+
+ it('uses 2010 for EF entity type', async () => {
+ results = [
+ { data: { entity_type: 'enskild_firma' }, error: null },
+ ]
+
+ vi.mocked(generateIncomeStatement).mockResolvedValue({ net_result: 50000 } as never)
+ vi.mocked(generateTrialBalance).mockResolvedValue({
+ rows: [
+ { account_number: '3001', account_name: 'Intäkter', account_class: 3, closing_debit: 0, closing_credit: 100000 },
+ { account_number: '5010', account_name: 'Kostnader', account_class: 5, closing_debit: 50000, closing_credit: 0 },
+ ],
+ isBalanced: true,
+ totalDebit: 50000,
+ totalCredit: 100000,
+ } as never)
+
+ const preview = await previewYearEndClosing('user-1', 'fp-1')
+
+ expect(preview.closingAccount).toBe('2010')
+ expect(preview.closingAccountName).toBe('Eget kapital')
+ })
+})
diff --git a/lib/core/bookkeeping/period-service.ts b/lib/core/bookkeeping/period-service.ts
new file mode 100644
index 00000000..f6a59b38
--- /dev/null
+++ b/lib/core/bookkeeping/period-service.ts
@@ -0,0 +1,235 @@
+import { createClient } from '@/lib/supabase/server'
+import { eventBus } from '@/lib/events'
+import type { FiscalPeriod, PeriodStatus } from '@/types'
+
+/**
+ * Lock a fiscal period — prevents new journal entries from being posted.
+ * Requires: period exists, belongs to user, not already locked/closed.
+ */
+export async function lockPeriod(
+ userId: string,
+ fiscalPeriodId: string
+): Promise {
+ const supabase = await createClient()
+
+ // Fetch period
+ const { data: period, error: fetchError } = await supabase
+ .from('fiscal_periods')
+ .select('*')
+ .eq('id', fiscalPeriodId)
+ .eq('user_id', userId)
+ .single()
+
+ if (fetchError || !period) {
+ throw new Error('Fiscal period not found')
+ }
+
+ if (period.is_closed) {
+ throw new Error('Period is already closed')
+ }
+
+ if (period.locked_at) {
+ throw new Error('Period is already locked')
+ }
+
+ const { data: updated, error: updateError } = await supabase
+ .from('fiscal_periods')
+ .update({ locked_at: new Date().toISOString() })
+ .eq('id', fiscalPeriodId)
+ .eq('user_id', userId)
+ .select()
+ .single()
+
+ if (updateError || !updated) {
+ throw new Error(`Failed to lock period: ${updateError?.message}`)
+ }
+
+ const result = updated as FiscalPeriod
+
+ await eventBus.emit({
+ type: 'period.locked',
+ payload: { period: result, userId },
+ })
+
+ return result
+}
+
+/**
+ * Close a fiscal period — marks it as permanently closed.
+ * Requires: period is locked AND closing_entry_id is set (year-end must run first).
+ */
+export async function closePeriod(
+ userId: string,
+ fiscalPeriodId: string
+): Promise {
+ const supabase = await createClient()
+
+ const { data: period, error: fetchError } = await supabase
+ .from('fiscal_periods')
+ .select('*')
+ .eq('id', fiscalPeriodId)
+ .eq('user_id', userId)
+ .single()
+
+ if (fetchError || !period) {
+ throw new Error('Fiscal period not found')
+ }
+
+ if (period.is_closed) {
+ throw new Error('Period is already closed')
+ }
+
+ if (!period.locked_at) {
+ throw new Error('Period must be locked before closing')
+ }
+
+ if (!period.closing_entry_id) {
+ throw new Error('Year-end closing must be executed before closing the period')
+ }
+
+ const { data: updated, error: updateError } = await supabase
+ .from('fiscal_periods')
+ .update({
+ is_closed: true,
+ closed_at: new Date().toISOString(),
+ })
+ .eq('id', fiscalPeriodId)
+ .eq('user_id', userId)
+ .select()
+ .single()
+
+ if (updateError || !updated) {
+ throw new Error(`Failed to close period: ${updateError?.message}`)
+ }
+
+ return updated as FiscalPeriod
+}
+
+/**
+ * Create the next fiscal period following the current one.
+ * Computes dates based on the current period's length (handles brutet räkenskapsår).
+ * Sets previous_period_id for chain validation.
+ */
+export async function createNextPeriod(
+ userId: string,
+ currentPeriodId: string
+): Promise {
+ const supabase = await createClient()
+
+ const { data: current, error: fetchError } = await supabase
+ .from('fiscal_periods')
+ .select('*')
+ .eq('id', currentPeriodId)
+ .eq('user_id', userId)
+ .single()
+
+ if (fetchError || !current) {
+ throw new Error('Current fiscal period not found')
+ }
+
+ // Check if next period already exists
+ const nextStart = new Date(current.period_end)
+ nextStart.setDate(nextStart.getDate() + 1)
+
+ const { data: existing } = await supabase
+ .from('fiscal_periods')
+ .select('id')
+ .eq('user_id', userId)
+ .eq('period_start', nextStart.toISOString().split('T')[0])
+ .maybeSingle()
+
+ if (existing) {
+ throw new Error('Next fiscal period already exists')
+ }
+
+ // Compute period length from current period to handle broken fiscal years
+ const currentStart = new Date(current.period_start)
+ const currentEnd = new Date(current.period_end)
+
+ // Calculate months difference
+ const monthsDiff =
+ (currentEnd.getFullYear() - currentStart.getFullYear()) * 12 +
+ (currentEnd.getMonth() - currentStart.getMonth())
+
+ // Next period end: add same number of months from next start, then go to end of that month
+ const nextEnd = new Date(nextStart)
+ nextEnd.setMonth(nextEnd.getMonth() + monthsDiff)
+ // Go to end of the month
+ nextEnd.setMonth(nextEnd.getMonth() + 1)
+ nextEnd.setDate(0)
+
+ const nextStartStr = nextStart.toISOString().split('T')[0]
+ const nextEndStr = nextEnd.toISOString().split('T')[0]
+
+ // Generate name: e.g. "FY 2025" or "FY 2025/2026"
+ const startYear = nextStart.getFullYear()
+ const endYear = nextEnd.getFullYear()
+ const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}`
+
+ const { data: newPeriod, error: insertError } = await supabase
+ .from('fiscal_periods')
+ .insert({
+ user_id: userId,
+ name,
+ period_start: nextStartStr,
+ period_end: nextEndStr,
+ previous_period_id: currentPeriodId,
+ })
+ .select()
+ .single()
+
+ if (insertError || !newPeriod) {
+ throw new Error(`Failed to create next period: ${insertError?.message}`)
+ }
+
+ return newPeriod as FiscalPeriod
+}
+
+/**
+ * Get status summary for a fiscal period.
+ */
+export async function getPeriodStatus(
+ userId: string,
+ fiscalPeriodId: string
+): Promise {
+ const supabase = await createClient()
+
+ const { data: period, error: fetchError } = await supabase
+ .from('fiscal_periods')
+ .select('*')
+ .eq('id', fiscalPeriodId)
+ .eq('user_id', userId)
+ .single()
+
+ if (fetchError || !period) {
+ throw new Error('Fiscal period not found')
+ }
+
+ // Count draft entries in this period
+ const { count: draftCount } = await supabase
+ .from('journal_entries')
+ .select('id', { count: 'exact', head: true })
+ .eq('user_id', userId)
+ .eq('fiscal_period_id', fiscalPeriodId)
+ .eq('status', 'draft')
+
+ // Check if next period exists
+ const nextStart = new Date(period.period_end)
+ nextStart.setDate(nextStart.getDate() + 1)
+
+ const { data: nextPeriod } = await supabase
+ .from('fiscal_periods')
+ .select('id')
+ .eq('user_id', userId)
+ .eq('previous_period_id', fiscalPeriodId)
+ .maybeSingle()
+
+ return {
+ is_locked: !!period.locked_at,
+ is_closed: period.is_closed,
+ has_closing_entry: !!period.closing_entry_id,
+ has_opening_balances: period.opening_balances_set,
+ draft_count: draftCount ?? 0,
+ next_period_exists: !!nextPeriod,
+ }
+}
diff --git a/lib/core/bookkeeping/storno-service.ts b/lib/core/bookkeeping/storno-service.ts
new file mode 100644
index 00000000..8ef4da27
--- /dev/null
+++ b/lib/core/bookkeeping/storno-service.ts
@@ -0,0 +1,237 @@
+import { createClient } from '@/lib/supabase/server'
+import { eventBus } from '@/lib/events'
+import type {
+ CreateJournalEntryLineInput,
+ JournalEntry,
+ JournalEntryLine,
+} from '@/types'
+import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
+
+/**
+ * Storno Service - 3-step correction flow per Bokföringslagen
+ *
+ * Swedish bookkeeping law requires that committed entries cannot be modified.
+ * To correct an error, you must:
+ * 1. Create a storno (reversal) entry that nullifies the original
+ * 2. Create a corrected entry with the right data
+ * 3. Link all three via reverses_id, reversed_by_id, correction_of_id
+ */
+
+/**
+ * Correct an existing posted journal entry using the storno method.
+ *
+ * Returns: { reversal, corrected } - the two new entries created
+ */
+export async function correctEntry(
+ userId: string,
+ originalEntryId: string,
+ correctedLines: CreateJournalEntryLineInput[]
+): Promise<{ reversal: JournalEntry; corrected: JournalEntry }> {
+ // Validate the corrected lines are balanced
+ const balance = validateBalance(correctedLines)
+ if (!balance.valid) {
+ throw new Error(
+ `Corrected entry is not balanced: debits (${balance.totalDebit}) != credits (${balance.totalCredit})`
+ )
+ }
+
+ const supabase = await createClient()
+
+ // Fetch original entry with lines
+ const { data: original, error: fetchError } = await supabase
+ .from('journal_entries')
+ .select('*, lines:journal_entry_lines(*)')
+ .eq('id', originalEntryId)
+ .eq('user_id', userId)
+ .single()
+
+ if (fetchError || !original) {
+ throw new Error('Original journal entry not found')
+ }
+
+ if (original.status !== 'posted') {
+ throw new Error('Can only correct posted entries')
+ }
+
+ const originalLines = (original.lines as JournalEntryLine[]) || []
+
+ // ===== Step 1: Create storno (reversal) entry =====
+ const reversalVoucherNumber = await getNextVoucherNumber(
+ userId,
+ original.fiscal_period_id,
+ original.voucher_series || 'A'
+ )
+
+ const { data: reversalEntry, error: reversalError } = await supabase
+ .from('journal_entries')
+ .insert({
+ user_id: userId,
+ fiscal_period_id: original.fiscal_period_id,
+ voucher_number: reversalVoucherNumber,
+ voucher_series: original.voucher_series || 'A',
+ entry_date: new Date().toISOString().split('T')[0],
+ description: `Storno: ${original.description}`,
+ source_type: 'storno',
+ reverses_id: originalEntryId,
+ status: 'draft',
+ })
+ .select()
+ .single()
+
+ if (reversalError || !reversalEntry) {
+ throw new Error(`Failed to create reversal entry: ${reversalError?.message}`)
+ }
+
+ // Insert reversed lines (swap debit and credit)
+ const reversalLineInserts = originalLines.map((line, index) => ({
+ journal_entry_id: reversalEntry.id,
+ account_number: line.account_number,
+ account_id: line.account_id || null,
+ debit_amount: Math.round((Number(line.credit_amount) || 0) * 100) / 100,
+ credit_amount: Math.round((Number(line.debit_amount) || 0) * 100) / 100,
+ currency: line.currency || 'SEK',
+ amount_in_currency: line.amount_in_currency ? -Number(line.amount_in_currency) : null,
+ exchange_rate: line.exchange_rate || null,
+ line_description: `Storno: ${line.line_description || ''}`,
+ tax_code: line.tax_code || null,
+ cost_center: line.cost_center || null,
+ project: line.project || null,
+ sort_order: index,
+ }))
+
+ const { error: reversalLinesError } = await supabase
+ .from('journal_entry_lines')
+ .insert(reversalLineInserts)
+
+ if (reversalLinesError) {
+ await supabase.from('journal_entries').delete().eq('id', reversalEntry.id)
+ throw new Error(`Failed to create reversal lines: ${reversalLinesError.message}`)
+ }
+
+ // Post the reversal entry
+ const { error: postReversalError } = await supabase
+ .from('journal_entries')
+ .update({ status: 'posted' })
+ .eq('id', reversalEntry.id)
+
+ if (postReversalError) {
+ throw new Error(`Failed to post reversal entry: ${postReversalError.message}`)
+ }
+
+ // Mark original as reversed
+ await supabase
+ .from('journal_entries')
+ .update({
+ status: 'reversed',
+ reversed_by_id: reversalEntry.id,
+ })
+ .eq('id', originalEntryId)
+
+ // ===== Step 2: Create corrected entry =====
+ const correctedVoucherNumber = await getNextVoucherNumber(
+ userId,
+ original.fiscal_period_id,
+ original.voucher_series || 'A'
+ )
+
+ // Resolve account IDs for corrected lines
+ const accountNumbers = [...new Set(correctedLines.map((l) => l.account_number))]
+ const { data: accounts } = await supabase
+ .from('chart_of_accounts')
+ .select('id, account_number')
+ .eq('user_id', userId)
+ .in('account_number', accountNumbers)
+
+ const accountIdMap = new Map()
+ for (const account of accounts || []) {
+ accountIdMap.set(account.account_number, account.id)
+ }
+
+ const { data: correctedEntry, error: correctedError } = await supabase
+ .from('journal_entries')
+ .insert({
+ user_id: userId,
+ fiscal_period_id: original.fiscal_period_id,
+ voucher_number: correctedVoucherNumber,
+ voucher_series: original.voucher_series || 'A',
+ entry_date: new Date().toISOString().split('T')[0],
+ description: `Rättelse: ${original.description}`,
+ source_type: 'correction',
+ correction_of_id: originalEntryId,
+ status: 'draft',
+ })
+ .select()
+ .single()
+
+ if (correctedError || !correctedEntry) {
+ throw new Error(`Failed to create corrected entry: ${correctedError?.message}`)
+ }
+
+ // Insert corrected lines
+ const correctedLineInserts = correctedLines.map((line, index) => ({
+ journal_entry_id: correctedEntry.id,
+ account_number: line.account_number,
+ account_id: accountIdMap.get(line.account_number) || null,
+ debit_amount: Math.round((line.debit_amount || 0) * 100) / 100,
+ credit_amount: Math.round((line.credit_amount || 0) * 100) / 100,
+ currency: line.currency || 'SEK',
+ amount_in_currency: line.amount_in_currency
+ ? Math.round(line.amount_in_currency * 100) / 100
+ : null,
+ exchange_rate: line.exchange_rate || null,
+ line_description: line.line_description || null,
+ tax_code: line.tax_code || null,
+ cost_center: line.cost_center || null,
+ project: line.project || null,
+ sort_order: index,
+ }))
+
+ const { error: correctedLinesError } = await supabase
+ .from('journal_entry_lines')
+ .insert(correctedLineInserts)
+
+ if (correctedLinesError) {
+ await supabase.from('journal_entries').delete().eq('id', correctedEntry.id)
+ throw new Error(`Failed to create corrected lines: ${correctedLinesError.message}`)
+ }
+
+ // Post the corrected entry
+ const { error: postCorrectedError } = await supabase
+ .from('journal_entries')
+ .update({ status: 'posted' })
+ .eq('id', correctedEntry.id)
+
+ if (postCorrectedError) {
+ throw new Error(`Failed to post corrected entry: ${postCorrectedError.message}`)
+ }
+
+ // ===== Step 3: Fetch complete entries =====
+ const { data: finalReversal } = await supabase
+ .from('journal_entries')
+ .select('*, lines:journal_entry_lines(*)')
+ .eq('id', reversalEntry.id)
+ .single()
+
+ const { data: finalCorrected } = await supabase
+ .from('journal_entries')
+ .select('*, lines:journal_entry_lines(*)')
+ .eq('id', correctedEntry.id)
+ .single()
+
+ const result = {
+ reversal: finalReversal as JournalEntry,
+ corrected: finalCorrected as JournalEntry,
+ }
+
+ await eventBus.emit({
+ type: 'journal_entry.corrected',
+ payload: {
+ original: original as JournalEntry,
+ storno: result.reversal,
+ corrected: result.corrected,
+ userId,
+ },
+ })
+
+ return result
+}
diff --git a/lib/core/bookkeeping/year-end-service.ts b/lib/core/bookkeeping/year-end-service.ts
new file mode 100644
index 00000000..59d1d8f7
--- /dev/null
+++ b/lib/core/bookkeeping/year-end-service.ts
@@ -0,0 +1,424 @@
+import { createClient } from '@/lib/supabase/server'
+import { eventBus } from '@/lib/events'
+import { createJournalEntry } from '@/lib/bookkeeping/engine'
+import { generateTrialBalance } from '@/lib/reports/trial-balance'
+import { generateIncomeStatement } from '@/lib/reports/income-statement'
+import { lockPeriod, closePeriod, createNextPeriod } from './period-service'
+import type {
+ YearEndValidation,
+ YearEndPreview,
+ YearEndResult,
+ CreateJournalEntryLineInput,
+ FiscalPeriod,
+ JournalEntry,
+ VoucherGap,
+} from '@/types'
+
+/**
+ * Validate whether a fiscal period is ready for year-end closing.
+ * Returns blocking errors and informational warnings.
+ */
+export async function validateYearEndReadiness(
+ userId: string,
+ fiscalPeriodId: string
+): Promise {
+ const supabase = await createClient()
+ const errors: string[] = []
+ const warnings: string[] = []
+
+ // Fetch the period
+ const { data: period, error: fetchError } = await supabase
+ .from('fiscal_periods')
+ .select('*')
+ .eq('id', fiscalPeriodId)
+ .eq('user_id', userId)
+ .single()
+
+ if (fetchError || !period) {
+ return {
+ ready: false,
+ errors: ['Fiscal period not found'],
+ warnings: [],
+ draftCount: 0,
+ voucherGaps: [],
+ trialBalanceBalanced: false,
+ }
+ }
+
+ // Check: period not already closed
+ if (period.is_closed) {
+ errors.push('Period is already closed')
+ }
+
+ // Check: closing entry doesn't already exist
+ if (period.closing_entry_id) {
+ errors.push('Year-end closing entry already exists for this period')
+ }
+
+ // Check: no draft entries
+ const { count: draftCount } = await supabase
+ .from('journal_entries')
+ .select('id', { count: 'exact', head: true })
+ .eq('user_id', userId)
+ .eq('fiscal_period_id', fiscalPeriodId)
+ .eq('status', 'draft')
+
+ const drafts = draftCount ?? 0
+ if (drafts > 0) {
+ errors.push(`${drafts} draft journal entries must be posted or deleted before closing`)
+ }
+
+ // Check: voucher continuity
+ let voucherGaps: VoucherGap[] = []
+ const { data: gaps, error: gapsError } = await supabase.rpc('detect_voucher_gaps', {
+ p_user_id: userId,
+ p_fiscal_period_id: fiscalPeriodId,
+ p_series: 'A',
+ })
+
+ if (!gapsError && gaps && gaps.length > 0) {
+ voucherGaps = gaps as VoucherGap[]
+ warnings.push(
+ `Voucher number gaps detected: ${voucherGaps.map((g) => `${g.gap_start}-${g.gap_end}`).join(', ')}`
+ )
+ }
+
+ // Check: trial balance is balanced
+ const trialBalance = await generateTrialBalance(userId, fiscalPeriodId)
+ const trialBalanceBalanced = trialBalance.isBalanced
+
+ if (!trialBalanceBalanced) {
+ errors.push(
+ `Trial balance is not balanced: debit=${trialBalance.totalDebit}, credit=${trialBalance.totalCredit}`
+ )
+ }
+
+ // Check: at least some entries exist
+ const { count: entryCount } = await supabase
+ .from('journal_entries')
+ .select('id', { count: 'exact', head: true })
+ .eq('user_id', userId)
+ .eq('fiscal_period_id', fiscalPeriodId)
+ .eq('status', 'posted')
+
+ if ((entryCount ?? 0) === 0) {
+ warnings.push('No posted journal entries in this period')
+ }
+
+ return {
+ ready: errors.length === 0,
+ errors,
+ warnings,
+ draftCount: drafts,
+ voucherGaps,
+ trialBalanceBalanced,
+ }
+}
+
+/**
+ * Preview year-end closing without persisting anything.
+ * Shows the net result, closing account, and the journal entry lines that would be created.
+ */
+export async function previewYearEndClosing(
+ userId: string,
+ fiscalPeriodId: string
+): Promise {
+ const supabase = await createClient()
+
+ // Get entity type to determine closing account
+ const { data: settings } = await supabase
+ .from('company_settings')
+ .select('entity_type')
+ .eq('user_id', userId)
+ .single()
+
+ const entityType = settings?.entity_type ?? 'aktiebolag'
+ const closingAccount = entityType === 'enskild_firma' ? '2010' : '2099'
+ const closingAccountName =
+ entityType === 'enskild_firma'
+ ? 'Eget kapital'
+ : 'Årets resultat'
+
+ // Get income statement for net result
+ const incomeStatement = await generateIncomeStatement(userId, fiscalPeriodId)
+ const netResult = incomeStatement.net_result
+
+ // Get trial balance for individual account balances in class 3-8
+ const { rows } = await generateTrialBalance(userId, fiscalPeriodId)
+ const resultAccounts = rows.filter(
+ (r) => r.account_class >= 3 && r.account_class <= 8
+ )
+
+ // Build closing lines: zero each result account
+ const closingLines: CreateJournalEntryLineInput[] = []
+ const resultAccountSummary: { account_number: string; account_name: string; amount: number }[] = []
+
+ for (const account of resultAccounts) {
+ const netBalance = account.closing_debit - account.closing_credit
+
+ if (Math.abs(netBalance) < 0.005) continue
+
+ resultAccountSummary.push({
+ account_number: account.account_number,
+ account_name: account.account_name,
+ amount: netBalance,
+ })
+
+ // To zero this account: reverse its net balance
+ if (netBalance > 0) {
+ // Account has debit balance → credit it to zero
+ closingLines.push({
+ account_number: account.account_number,
+ debit_amount: 0,
+ credit_amount: Math.round(netBalance * 100) / 100,
+ line_description: `Closing: ${account.account_name}`,
+ })
+ } else {
+ // Account has credit balance → debit it to zero
+ closingLines.push({
+ account_number: account.account_number,
+ debit_amount: Math.round(Math.abs(netBalance) * 100) / 100,
+ credit_amount: 0,
+ line_description: `Closing: ${account.account_name}`,
+ })
+ }
+ }
+
+ // Final line: transfer net result to closing account (2099/2010)
+ // Net result = revenue - expenses + financial
+ // If positive (profit): credit to equity (2099/2010)
+ // If negative (loss): debit to equity (2099/2010)
+ const totalClosingDebit = closingLines.reduce((sum, l) => sum + l.debit_amount, 0)
+ const totalClosingCredit = closingLines.reduce((sum, l) => sum + l.credit_amount, 0)
+ const balancingAmount = Math.round(Math.abs(totalClosingDebit - totalClosingCredit) * 100) / 100
+
+ if (balancingAmount > 0.005) {
+ if (totalClosingDebit > totalClosingCredit) {
+ // More debits than credits → need credit on closing account
+ closingLines.push({
+ account_number: closingAccount,
+ debit_amount: 0,
+ credit_amount: balancingAmount,
+ line_description: `Årets resultat → ${closingAccountName}`,
+ })
+ } else {
+ // More credits than debits → need debit on closing account
+ closingLines.push({
+ account_number: closingAccount,
+ debit_amount: balancingAmount,
+ credit_amount: 0,
+ line_description: `Årets resultat → ${closingAccountName}`,
+ })
+ }
+ }
+
+ return {
+ netResult,
+ closingAccount,
+ closingAccountName,
+ closingLines,
+ resultAccountSummary,
+ }
+}
+
+/**
+ * Execute year-end closing for a fiscal period.
+ *
+ * 1. Validate readiness
+ * 2. Create closing entry (zeros class 3-8 accounts)
+ * 3. Set closing_entry_id on the period
+ * 4. Lock the period
+ * 5. Close the period
+ * 6. Create next fiscal period
+ * 7. Generate opening balances in next period
+ */
+export async function executeYearEndClosing(
+ userId: string,
+ fiscalPeriodId: string
+): Promise {
+ // 1. Validate readiness
+ const validation = await validateYearEndReadiness(userId, fiscalPeriodId)
+ if (!validation.ready) {
+ throw new Error(`Year-end closing not ready: ${validation.errors.join('; ')}`)
+ }
+
+ const supabase = await createClient()
+
+ // Fetch the period for dates
+ const { data: period } = await supabase
+ .from('fiscal_periods')
+ .select('*')
+ .eq('id', fiscalPeriodId)
+ .eq('user_id', userId)
+ .single()
+
+ if (!period) {
+ throw new Error('Fiscal period not found')
+ }
+
+ // 2. Get closing preview
+ const preview = await previewYearEndClosing(userId, fiscalPeriodId)
+
+ if (preview.closingLines.length === 0) {
+ throw new Error('No result accounts to close — period has no activity')
+ }
+
+ // 3. Create closing entry via the journal engine
+ const closingEntry = await createJournalEntry(userId, {
+ fiscal_period_id: fiscalPeriodId,
+ entry_date: period.period_end,
+ description: `Årsbokslut ${period.name}`,
+ source_type: 'year_end',
+ voucher_series: 'A',
+ lines: preview.closingLines,
+ })
+
+ // 4. Update fiscal period with closing_entry_id
+ const { error: updateError } = await supabase
+ .from('fiscal_periods')
+ .update({ closing_entry_id: closingEntry.id })
+ .eq('id', fiscalPeriodId)
+ .eq('user_id', userId)
+
+ if (updateError) {
+ throw new Error(`Failed to set closing_entry_id: ${updateError.message}`)
+ }
+
+ // 5. Lock the period
+ await lockPeriod(userId, fiscalPeriodId)
+
+ // 6. Close the period
+ await closePeriod(userId, fiscalPeriodId)
+
+ // 7. Create next period
+ const nextPeriod = await createNextPeriod(userId, fiscalPeriodId)
+
+ // 8. Generate opening balances in next period
+ const openingBalanceEntry = await generateOpeningBalances(
+ userId,
+ fiscalPeriodId,
+ nextPeriod.id
+ )
+
+ // Fetch the now-closed period for the event payload
+ const { data: closedPeriod } = await supabase
+ .from('fiscal_periods')
+ .select('*')
+ .eq('id', fiscalPeriodId)
+ .eq('user_id', userId)
+ .single()
+
+ if (closedPeriod) {
+ await eventBus.emit({
+ type: 'period.year_closed',
+ payload: { period: closedPeriod as FiscalPeriod, userId },
+ })
+ }
+
+ return {
+ closingEntry,
+ nextPeriod,
+ openingBalanceEntry,
+ }
+}
+
+/**
+ * Generate opening balance entries in the next period from the closed period's
+ * balance sheet accounts (class 1-2).
+ *
+ * Each account's closing balance becomes its opening balance.
+ * The entry must be balanced (total debit openings = total credit openings).
+ */
+export async function generateOpeningBalances(
+ userId: string,
+ closedPeriodId: string,
+ nextPeriodId: string
+): Promise {
+ const supabase = await createClient()
+
+ // Get next period for the entry date
+ const { data: nextPeriod } = await supabase
+ .from('fiscal_periods')
+ .select('*')
+ .eq('id', nextPeriodId)
+ .eq('user_id', userId)
+ .single()
+
+ if (!nextPeriod) {
+ throw new Error('Next fiscal period not found')
+ }
+
+ // Get trial balance of closed period (includes the closing entry)
+ const { rows } = await generateTrialBalance(userId, closedPeriodId)
+
+ // Filter to balance sheet accounts (class 1-2) with non-zero closing balance
+ const balanceSheetAccounts = rows.filter(
+ (r) => r.account_class >= 1 && r.account_class <= 2
+ )
+
+ const openingLines: CreateJournalEntryLineInput[] = []
+
+ for (const account of balanceSheetAccounts) {
+ const netBalance = account.closing_debit - account.closing_credit
+
+ if (Math.abs(netBalance) < 0.005) continue
+
+ if (netBalance > 0) {
+ // Debit balance → opening debit
+ openingLines.push({
+ account_number: account.account_number,
+ debit_amount: Math.round(netBalance * 100) / 100,
+ credit_amount: 0,
+ line_description: `Ingående balans: ${account.account_name}`,
+ })
+ } else {
+ // Credit balance → opening credit
+ openingLines.push({
+ account_number: account.account_number,
+ debit_amount: 0,
+ credit_amount: Math.round(Math.abs(netBalance) * 100) / 100,
+ line_description: `Ingående balans: ${account.account_name}`,
+ })
+ }
+ }
+
+ if (openingLines.length === 0) {
+ throw new Error('No balance sheet accounts with non-zero closing balance')
+ }
+
+ // Verify balance before creating
+ const totalDebit = openingLines.reduce((sum, l) => sum + l.debit_amount, 0)
+ const totalCredit = openingLines.reduce((sum, l) => sum + l.credit_amount, 0)
+
+ if (Math.abs(totalDebit - totalCredit) > 0.01) {
+ throw new Error(
+ `Opening balances are not balanced: debit=${totalDebit}, credit=${totalCredit}`
+ )
+ }
+
+ // Create opening balance entry in next period
+ const openingEntry = await createJournalEntry(userId, {
+ fiscal_period_id: nextPeriodId,
+ entry_date: nextPeriod.period_start,
+ description: `Ingående balans ${nextPeriod.name}`,
+ source_type: 'opening_balance',
+ voucher_series: 'A',
+ lines: openingLines,
+ })
+
+ // Mark next period with opening balance entry
+ const { error: updateError } = await supabase
+ .from('fiscal_periods')
+ .update({
+ opening_balance_entry_id: openingEntry.id,
+ opening_balances_set: true,
+ })
+ .eq('id', nextPeriodId)
+ .eq('user_id', userId)
+
+ if (updateError) {
+ throw new Error(`Failed to set opening_balance_entry_id: ${updateError.message}`)
+ }
+
+ return openingEntry
+}
diff --git a/lib/core/documents/__tests__/document-service.test.ts b/lib/core/documents/__tests__/document-service.test.ts
new file mode 100644
index 00000000..3168f4d6
--- /dev/null
+++ b/lib/core/documents/__tests__/document-service.test.ts
@@ -0,0 +1,174 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { eventBus } from '@/lib/events/bus'
+import { makeDocumentAttachment } from '@/tests/helpers'
+
+// ============================================================
+// Mock — separate client (no .then) from query builder (thenable)
+// ============================================================
+
+let resultIdx: number
+let results: Array<{ data?: unknown; error?: unknown }>
+
+function makeBuilder() {
+ const b: Record = {}
+ for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'not', 'or', 'order', 'limit', 'is']) {
+ b[m] = vi.fn().mockReturnValue(b)
+ }
+ b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+ b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+ b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
+ return b
+}
+
+function makeClient(storageOverrides: Record = {}) {
+ return {
+ from: vi.fn().mockImplementation(() => makeBuilder()),
+ rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
+ storage: {
+ from: vi.fn().mockReturnValue({
+ upload: vi.fn().mockResolvedValue({ data: {}, error: null }),
+ download: vi.fn().mockResolvedValue({
+ data: new Blob(['test content']),
+ error: null,
+ }),
+ remove: vi.fn().mockResolvedValue({ data: [], error: null }),
+ getPublicUrl: vi.fn().mockReturnValue({
+ data: { publicUrl: 'https://example.com/file.pdf' },
+ }),
+ ...storageOverrides,
+ }),
+ },
+ }
+}
+
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: vi.fn(async () => makeClient()),
+}))
+
+import { uploadDocument, createNewVersion, verifyIntegrity } from '../document-service'
+import { createClient } from '@/lib/supabase/server'
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ eventBus.clear()
+ resultIdx = 0
+ results = []
+ // Reset the mock to use default makeClient
+ vi.mocked(createClient).mockImplementation(async () => makeClient() as never)
+})
+
+describe('uploadDocument', () => {
+ it('computes SHA-256 hash, stores metadata, emits document.uploaded', async () => {
+ const doc = makeDocumentAttachment({
+ id: 'doc-1',
+ file_name: 'test.pdf',
+ sha256_hash: 'computed-hash',
+ })
+
+ results = [
+ { data: doc, error: null }, // insert record
+ ]
+
+ const handler = vi.fn()
+ eventBus.on('document.uploaded', handler)
+
+ const buffer = new TextEncoder().encode('test content').buffer
+ const result = await uploadDocument('user-1', {
+ name: 'test.pdf',
+ buffer: buffer as ArrayBuffer,
+ type: 'application/pdf',
+ })
+
+ expect(result.id).toBe('doc-1')
+ expect(result.file_name).toBe('test.pdf')
+ expect(handler).toHaveBeenCalledOnce()
+ expect(handler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ document: expect.objectContaining({ id: 'doc-1' }),
+ userId: 'user-1',
+ })
+ )
+ })
+})
+
+describe('createNewVersion', () => {
+ it('increments version and supersedes previous', async () => {
+ const current = makeDocumentAttachment({
+ id: 'doc-1',
+ version: 1,
+ is_current_version: true,
+ original_id: null,
+ })
+ const newVersion = makeDocumentAttachment({
+ id: 'doc-2',
+ version: 2,
+ is_current_version: true,
+ original_id: 'doc-1',
+ })
+
+ results = [
+ { data: current, error: null }, // fetch current
+ { data: newVersion, error: null }, // insert new version
+ ]
+
+ const buffer = new TextEncoder().encode('new content').buffer
+ const result = await createNewVersion('user-1', 'doc-1', {
+ name: 'test-v2.pdf',
+ buffer: buffer as ArrayBuffer,
+ type: 'application/pdf',
+ })
+
+ expect(result.version).toBe(2)
+ expect(result.original_id).toBe('doc-1')
+ expect(result.is_current_version).toBe(true)
+ })
+})
+
+describe('verifyIntegrity', () => {
+ it('returns valid when hashes match', async () => {
+ const content = 'test content for integrity check'
+ const buffer = new TextEncoder().encode(content)
+ const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
+ const hashArray = Array.from(new Uint8Array(hashBuffer))
+ const expectedHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
+
+ results = [
+ { data: { storage_path: 'docs/test.pdf', sha256_hash: expectedHash }, error: null },
+ ]
+
+ // Override createClient to provide matching download content
+ vi.mocked(createClient).mockImplementation(async () =>
+ makeClient({
+ download: vi.fn().mockResolvedValue({
+ data: new Blob([content]),
+ error: null,
+ }),
+ }) as never
+ )
+
+ const result = await verifyIntegrity('user-1', 'doc-1')
+ expect(result.valid).toBe(true)
+ expect(result.storedHash).toBe(expectedHash)
+ expect(result.computedHash).toBe(expectedHash)
+ })
+
+ it('returns invalid when hashes do not match', async () => {
+ results = [
+ { data: { storage_path: 'docs/test.pdf', sha256_hash: 'stored-hash-abc' }, error: null },
+ ]
+
+ vi.mocked(createClient).mockImplementation(async () =>
+ makeClient({
+ download: vi.fn().mockResolvedValue({
+ data: new Blob(['different content']),
+ error: null,
+ }),
+ }) as never
+ )
+
+ const result = await verifyIntegrity('user-1', 'doc-1')
+ expect(result.valid).toBe(false)
+ expect(result.storedHash).toBe('stored-hash-abc')
+ expect(result.computedHash).not.toBe('stored-hash-abc')
+ })
+})
diff --git a/lib/core/documents/document-service.ts b/lib/core/documents/document-service.ts
new file mode 100644
index 00000000..c410f8e1
--- /dev/null
+++ b/lib/core/documents/document-service.ts
@@ -0,0 +1,243 @@
+import { createClient } from '@/lib/supabase/server'
+import { eventBus } from '@/lib/events'
+import type { DocumentAttachment, CreateDocumentAttachmentInput, DocumentUploadSource } from '@/types'
+
+/**
+ * Document Service - WORM-style document archive
+ *
+ * Handles document upload with SHA-256 integrity, version chains,
+ * and linking to journal entries. Deletion is blocked by DB triggers
+ * for documents linked to committed entries.
+ */
+
+/**
+ * Compute SHA-256 hash of a file buffer
+ */
+async function computeSHA256(buffer: ArrayBuffer): Promise {
+ const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
+ const hashArray = Array.from(new Uint8Array(hashBuffer))
+ return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
+}
+
+/**
+ * Upload a document and create a record with SHA-256 integrity hash
+ */
+export async function uploadDocument(
+ userId: string,
+ file: { name: string; buffer: ArrayBuffer; type?: string },
+ metadata: {
+ upload_source?: DocumentUploadSource
+ journal_entry_id?: string
+ journal_entry_line_id?: string
+ } = {}
+): Promise {
+ const supabase = await createClient()
+
+ // Compute SHA-256 hash
+ const sha256Hash = await computeSHA256(file.buffer)
+
+ // Generate storage path
+ const timestamp = Date.now()
+ const storagePath = `documents/${userId}/${timestamp}_${file.name}`
+
+ // Upload to Supabase Storage
+ const { error: uploadError } = await supabase.storage
+ .from('documents')
+ .upload(storagePath, file.buffer, {
+ contentType: file.type || 'application/octet-stream',
+ upsert: false,
+ })
+
+ if (uploadError) {
+ throw new Error(`Failed to upload document: ${uploadError.message}`)
+ }
+
+ // Create document record
+ const { data, error } = await supabase
+ .from('document_attachments')
+ .insert({
+ user_id: userId,
+ storage_path: storagePath,
+ file_name: file.name,
+ file_size_bytes: file.buffer.byteLength,
+ mime_type: file.type || null,
+ sha256_hash: sha256Hash,
+ version: 1,
+ is_current_version: true,
+ uploaded_by: userId,
+ upload_source: metadata.upload_source || 'file_upload',
+ digitization_date: new Date().toISOString(),
+ journal_entry_id: metadata.journal_entry_id || null,
+ journal_entry_line_id: metadata.journal_entry_line_id || null,
+ })
+ .select()
+ .single()
+
+ if (error) {
+ // Clean up uploaded file on record creation failure
+ await supabase.storage.from('documents').remove([storagePath])
+ throw new Error(`Failed to create document record: ${error.message}`)
+ }
+
+ const result = data as DocumentAttachment
+
+ await eventBus.emit({
+ type: 'document.uploaded',
+ payload: { document: result, userId },
+ })
+
+ return result
+}
+
+/**
+ * Create a new version of an existing document (WORM: old version is superseded)
+ */
+export async function createNewVersion(
+ userId: string,
+ originalId: string,
+ file: { name: string; buffer: ArrayBuffer; type?: string }
+): Promise {
+ const supabase = await createClient()
+
+ // Fetch the original/current version
+ const { data: current, error: fetchError } = await supabase
+ .from('document_attachments')
+ .select('*')
+ .eq('id', originalId)
+ .eq('user_id', userId)
+ .eq('is_current_version', true)
+ .single()
+
+ if (fetchError || !current) {
+ throw new Error('Original document not found or not the current version')
+ }
+
+ const rootOriginalId = current.original_id || current.id
+ const newVersion = current.version + 1
+
+ // Compute SHA-256 hash
+ const sha256Hash = await computeSHA256(file.buffer)
+
+ // Upload new file
+ const timestamp = Date.now()
+ const storagePath = `documents/${userId}/${timestamp}_v${newVersion}_${file.name}`
+
+ const { error: uploadError } = await supabase.storage
+ .from('documents')
+ .upload(storagePath, file.buffer, {
+ contentType: file.type || 'application/octet-stream',
+ upsert: false,
+ })
+
+ if (uploadError) {
+ throw new Error(`Failed to upload new version: ${uploadError.message}`)
+ }
+
+ // Create new version record
+ const { data: newDoc, error: insertError } = await supabase
+ .from('document_attachments')
+ .insert({
+ user_id: userId,
+ storage_path: storagePath,
+ file_name: file.name,
+ file_size_bytes: file.buffer.byteLength,
+ mime_type: file.type || null,
+ sha256_hash: sha256Hash,
+ version: newVersion,
+ original_id: rootOriginalId,
+ is_current_version: true,
+ uploaded_by: userId,
+ upload_source: current.upload_source,
+ digitization_date: new Date().toISOString(),
+ journal_entry_id: current.journal_entry_id,
+ journal_entry_line_id: current.journal_entry_line_id,
+ })
+ .select()
+ .single()
+
+ if (insertError) {
+ await supabase.storage.from('documents').remove([storagePath])
+ throw new Error(`Failed to create new version record: ${insertError.message}`)
+ }
+
+ // Mark old version as superseded
+ await supabase
+ .from('document_attachments')
+ .update({
+ is_current_version: false,
+ superseded_by_id: newDoc.id,
+ })
+ .eq('id', current.id)
+
+ return newDoc as DocumentAttachment
+}
+
+/**
+ * Link an existing document to a journal entry
+ */
+export async function linkToJournalEntry(
+ userId: string,
+ documentId: string,
+ journalEntryId: string,
+ journalEntryLineId?: string
+): Promise {
+ const supabase = await createClient()
+
+ const { data, error } = await supabase
+ .from('document_attachments')
+ .update({
+ journal_entry_id: journalEntryId,
+ journal_entry_line_id: journalEntryLineId || null,
+ })
+ .eq('id', documentId)
+ .eq('user_id', userId)
+ .select()
+ .single()
+
+ if (error) {
+ throw new Error(`Failed to link document: ${error.message}`)
+ }
+
+ return data as DocumentAttachment
+}
+
+/**
+ * Verify document integrity by re-hashing and comparing
+ */
+export async function verifyIntegrity(
+ userId: string,
+ documentId: string
+): Promise<{ valid: boolean; storedHash: string; computedHash: string }> {
+ const supabase = await createClient()
+
+ // Fetch document record
+ const { data: doc, error: docError } = await supabase
+ .from('document_attachments')
+ .select('storage_path, sha256_hash')
+ .eq('id', documentId)
+ .eq('user_id', userId)
+ .single()
+
+ if (docError || !doc) {
+ throw new Error('Document not found')
+ }
+
+ // Download file from storage
+ const { data: fileData, error: downloadError } = await supabase.storage
+ .from('documents')
+ .download(doc.storage_path)
+
+ if (downloadError || !fileData) {
+ throw new Error(`Failed to download document: ${downloadError?.message}`)
+ }
+
+ // Re-compute hash
+ const buffer = await fileData.arrayBuffer()
+ const computedHash = await computeSHA256(buffer)
+
+ return {
+ valid: computedHash === doc.sha256_hash,
+ storedHash: doc.sha256_hash,
+ computedHash,
+ }
+}
diff --git a/lib/core/tax/__tests__/tax-code-service.test.ts b/lib/core/tax/__tests__/tax-code-service.test.ts
new file mode 100644
index 00000000..bf122371
--- /dev/null
+++ b/lib/core/tax/__tests__/tax-code-service.test.ts
@@ -0,0 +1,112 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { makeTaxCode } from '@/tests/helpers'
+
+// ============================================================
+// Mock — separate client (no .then) from query builder (thenable)
+// ============================================================
+
+let resultIdx: number
+let results: Array<{ data?: unknown; error?: unknown; count?: number | null }>
+
+function makeBuilder() {
+ const b: Record = {}
+ for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'not', 'or', 'order', 'limit', 'is']) {
+ b[m] = vi.fn().mockReturnValue(b)
+ }
+ b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+ b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+ b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
+ return b
+}
+
+function makeClient() {
+ return {
+ from: vi.fn().mockImplementation(() => makeBuilder()),
+ rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
+ }
+}
+
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: vi.fn(async () => makeClient()),
+}))
+
+import { getTaxCodeByCode, calculateMomsFromTaxCodes } from '../tax-code-service'
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ resultIdx = 0
+ results = []
+})
+
+describe('getTaxCodeByCode', () => {
+ it('prefers user code over system code', async () => {
+ const userCode = makeTaxCode({
+ id: 'tc-user',
+ user_id: 'user-1',
+ code: 'MP1',
+ description: 'Custom 25% moms',
+ rate: 25,
+ })
+
+ results = [{ data: userCode, error: null }]
+
+ const result = await getTaxCodeByCode('user-1', 'MP1')
+ expect(result).not.toBeNull()
+ expect(result!.user_id).toBe('user-1')
+ expect(result!.description).toBe('Custom 25% moms')
+ })
+
+ it('returns null when code does not exist', async () => {
+ results = [{ data: null, error: { code: 'PGRST116' } }]
+
+ const result = await getTaxCodeByCode('user-1', 'NONEXISTENT')
+ expect(result).toBeNull()
+ })
+})
+
+describe('calculateMomsFromTaxCodes', () => {
+ it('aggregates correctly to moms boxes', async () => {
+ const mp1 = makeTaxCode({
+ code: 'MP1',
+ rate: 25,
+ moms_basis_boxes: ['05'],
+ moms_tax_boxes: ['10'],
+ moms_input_boxes: [],
+ is_output_vat: true,
+ })
+ const ip1 = makeTaxCode({
+ code: 'IP1',
+ rate: 25,
+ moms_basis_boxes: [],
+ moms_tax_boxes: [],
+ moms_input_boxes: ['48'],
+ is_output_vat: false,
+ })
+
+ const lines = [
+ { tax_code: 'MP1', debit_amount: 0, credit_amount: 10000, journal_entry_id: 'je1', journal_entries: {} },
+ { tax_code: 'MP1', debit_amount: 0, credit_amount: 5000, journal_entry_id: 'je2', journal_entries: {} },
+ { tax_code: 'IP1', debit_amount: 2500, credit_amount: 0, journal_entry_id: 'je3', journal_entries: {} },
+ ]
+
+ results = [
+ // 0: journal lines query (thenable — no .single())
+ { data: lines, error: null },
+ // 1: getTaxCodes query (thenable — no .single())
+ { data: [mp1, ip1], error: null },
+ ]
+
+ const result = await calculateMomsFromTaxCodes('user-1', '2024-01-01', '2024-12-31')
+
+ expect(result.length).toBeGreaterThan(0)
+ // Results should be sorted by box
+ for (let i = 1; i < result.length; i++) {
+ expect(result[i].box >= result[i - 1].box).toBe(true)
+ }
+ // Check that we have the expected boxes
+ const boxes = result.map((r) => r.box)
+ expect(boxes).toContain('05')
+ expect(boxes).toContain('10')
+ expect(boxes).toContain('48')
+ })
+})
diff --git a/lib/core/tax/tax-code-service.ts b/lib/core/tax/tax-code-service.ts
new file mode 100644
index 00000000..4fcacf11
--- /dev/null
+++ b/lib/core/tax/tax-code-service.ts
@@ -0,0 +1,167 @@
+import { createClient } from '@/lib/supabase/server'
+import type { TaxCode } from '@/types'
+
+/**
+ * Tax Code Service
+ *
+ * Manages decoupled tax codes for momsdeklaration.
+ * Tax codes map journal entry lines to specific moms rutor (boxes)
+ * on the Swedish VAT declaration form.
+ */
+
+/**
+ * Get all active tax codes for a user (including system codes)
+ */
+export async function getTaxCodes(userId: string): Promise {
+ const supabase = await createClient()
+
+ const { data, error } = await supabase
+ .from('tax_codes')
+ .select('*')
+ .or(`user_id.eq.${userId},user_id.is.null`)
+ .order('code')
+
+ if (error) {
+ throw new Error(`Failed to fetch tax codes: ${error.message}`)
+ }
+
+ return (data as TaxCode[]) || []
+}
+
+/**
+ * Get a single tax code by code string
+ */
+export async function getTaxCodeByCode(
+ userId: string,
+ code: string
+): Promise {
+ const supabase = await createClient()
+
+ // Prefer user-specific code over system code
+ const { data, error } = await supabase
+ .from('tax_codes')
+ .select('*')
+ .eq('code', code)
+ .or(`user_id.eq.${userId},user_id.is.null`)
+ .order('user_id', { ascending: false, nullsFirst: false })
+ .limit(1)
+ .single()
+
+ if (error) {
+ return null
+ }
+
+ return data as TaxCode
+}
+
+/**
+ * Moms box result from tax code aggregation
+ */
+export interface MomsBoxResult {
+ /** Ruta number (e.g. '05', '10', '48') */
+ box: string
+ /** Sum of amounts for this box */
+ amount: number
+}
+
+/**
+ * Calculate momsdeklaration from journal entry lines grouped by tax_code,
+ * then mapped via the tax_codes table to moms boxes.
+ *
+ * This is the new, tax-code-driven approach that replaces the hardcoded
+ * category-based VAT calculation.
+ */
+export async function calculateMomsFromTaxCodes(
+ userId: string,
+ periodStart: string,
+ periodEnd: string
+): Promise {
+ const supabase = await createClient()
+
+ // Fetch journal entry lines with tax_code in the period
+ const { data: lines, error: linesError } = await supabase
+ .from('journal_entry_lines')
+ .select(`
+ tax_code,
+ debit_amount,
+ credit_amount,
+ journal_entry_id,
+ journal_entries!inner (
+ user_id,
+ entry_date,
+ status,
+ fiscal_period_id
+ )
+ `)
+ .not('tax_code', 'is', null)
+ .eq('journal_entries.user_id', userId)
+ .eq('journal_entries.status', 'posted')
+ .gte('journal_entries.entry_date', periodStart)
+ .lte('journal_entries.entry_date', periodEnd)
+
+ if (linesError) {
+ throw new Error(`Failed to fetch journal lines: ${linesError.message}`)
+ }
+
+ // Fetch all tax codes for lookup
+ const taxCodes = await getTaxCodes(userId)
+ const taxCodeMap = new Map()
+ for (const tc of taxCodes) {
+ // User codes take precedence over system codes
+ if (!taxCodeMap.has(tc.code) || tc.user_id) {
+ taxCodeMap.set(tc.code, tc)
+ }
+ }
+
+ // Aggregate amounts by moms box
+ const boxTotals = new Map()
+
+ for (const line of lines || []) {
+ if (!line.tax_code) continue
+
+ const taxCode = taxCodeMap.get(line.tax_code)
+ if (!taxCode) continue
+
+ const netAmount = Number(line.debit_amount || 0) - Number(line.credit_amount || 0)
+ const absAmount = Math.abs(netAmount)
+
+ // For output VAT: debit_amount goes to basis boxes, tax amount to tax boxes
+ // For input VAT: the amount goes to input boxes
+ const allBoxes = [
+ ...taxCode.moms_basis_boxes,
+ ...taxCode.moms_tax_boxes,
+ ...taxCode.moms_input_boxes,
+ ]
+
+ for (const box of allBoxes) {
+ const current = boxTotals.get(box) || 0
+ boxTotals.set(box, current + absAmount)
+ }
+ }
+
+ // Convert to result array
+ const results: MomsBoxResult[] = []
+ for (const [box, amount] of boxTotals) {
+ results.push({
+ box,
+ amount: Math.round(amount * 100) / 100,
+ })
+ }
+
+ return results.sort((a, b) => a.box.localeCompare(b.box))
+}
+
+/**
+ * Seed tax codes for a user by calling the database function
+ */
+export async function seedTaxCodes(userId: string): Promise {
+ const supabase = await createClient()
+
+ const { error } = await supabase.rpc('seed_tax_codes_for_user', {
+ p_user_id: userId,
+ })
+
+ if (error) {
+ throw new Error(`Failed to seed tax codes: ${error.message}`)
+ }
+}
diff --git a/lib/events/__tests__/bus.test.ts b/lib/events/__tests__/bus.test.ts
new file mode 100644
index 00000000..07a79be6
--- /dev/null
+++ b/lib/events/__tests__/bus.test.ts
@@ -0,0 +1,111 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { eventBus } from '../bus'
+import type { JournalEntry } from '@/types'
+
+const fakeEntry = { id: 'e1' } as JournalEntry
+
+beforeEach(() => {
+ eventBus.clear()
+})
+
+describe('EventBus', () => {
+ it('on() subscribes a handler and returns an unsubscribe function', () => {
+ const handler = vi.fn()
+ const unsub = eventBus.on('journal_entry.drafted', handler)
+
+ expect(typeof unsub).toBe('function')
+ })
+
+ it('emit() calls all handlers for that event type', async () => {
+ const handler1 = vi.fn()
+ const handler2 = vi.fn()
+
+ eventBus.on('journal_entry.committed', handler1)
+ eventBus.on('journal_entry.committed', handler2)
+
+ await eventBus.emit({
+ type: 'journal_entry.committed',
+ payload: { entry: fakeEntry, userId: 'u1' },
+ })
+
+ expect(handler1).toHaveBeenCalledWith({ entry: fakeEntry, userId: 'u1' })
+ expect(handler2).toHaveBeenCalledWith({ entry: fakeEntry, userId: 'u1' })
+ })
+
+ it('emit() uses Promise.allSettled — a failing handler does not crash others', async () => {
+ const failingHandler = vi.fn().mockRejectedValue(new Error('boom'))
+ const goodHandler = vi.fn()
+
+ eventBus.on('journal_entry.committed', failingHandler)
+ eventBus.on('journal_entry.committed', goodHandler)
+
+ // Should not throw
+ await eventBus.emit({
+ type: 'journal_entry.committed',
+ payload: { entry: fakeEntry, userId: 'u1' },
+ })
+
+ expect(failingHandler).toHaveBeenCalled()
+ expect(goodHandler).toHaveBeenCalled()
+ })
+
+ it('emit() with no handlers is a no-op', async () => {
+ // Should not throw
+ await eventBus.emit({
+ type: 'journal_entry.drafted',
+ payload: { entry: fakeEntry, userId: 'u1' },
+ })
+ })
+
+ it('unsubscribe removes the handler, future emits do not call it', async () => {
+ const handler = vi.fn()
+ const unsub = eventBus.on('journal_entry.committed', handler)
+
+ unsub()
+
+ await eventBus.emit({
+ type: 'journal_entry.committed',
+ payload: { entry: fakeEntry, userId: 'u1' },
+ })
+
+ expect(handler).not.toHaveBeenCalled()
+ })
+
+ it('clear() removes all handlers', async () => {
+ const handler1 = vi.fn()
+ const handler2 = vi.fn()
+
+ eventBus.on('journal_entry.committed', handler1)
+ eventBus.on('journal_entry.drafted', handler2)
+
+ eventBus.clear()
+
+ await eventBus.emit({
+ type: 'journal_entry.committed',
+ payload: { entry: fakeEntry, userId: 'u1' },
+ })
+ await eventBus.emit({
+ type: 'journal_entry.drafted',
+ payload: { entry: fakeEntry, userId: 'u1' },
+ })
+
+ expect(handler1).not.toHaveBeenCalled()
+ expect(handler2).not.toHaveBeenCalled()
+ })
+
+ it('handlers for different event types do not interfere', async () => {
+ const committedHandler = vi.fn()
+ const draftedHandler = vi.fn()
+
+ eventBus.on('journal_entry.committed', committedHandler)
+ eventBus.on('journal_entry.drafted', draftedHandler)
+
+ await eventBus.emit({
+ type: 'journal_entry.committed',
+ payload: { entry: fakeEntry, userId: 'u1' },
+ })
+
+ expect(committedHandler).toHaveBeenCalledOnce()
+ expect(draftedHandler).not.toHaveBeenCalled()
+ })
+})
diff --git a/lib/events/bus.ts b/lib/events/bus.ts
new file mode 100644
index 00000000..bdf2faf9
--- /dev/null
+++ b/lib/events/bus.ts
@@ -0,0 +1,71 @@
+import type { CoreEvent, CoreEventType, EventHandler } from './types'
+
+// Internal handler type — loose enough for the Map, but type-safe at the public API
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type AnyHandler = (payload: any) => Promise | void
+
+/**
+ * In-process event bus.
+ *
+ * - Handlers run concurrently via Promise.allSettled (failing handler never crashes emitter)
+ * - Module-level singleton (persists across requests in same process)
+ * - One-way: core services emit, extensions subscribe
+ */
+class EventBus {
+ private handlers = new Map>()
+
+ /**
+ * Subscribe to an event type.
+ * Returns an unsubscribe function.
+ */
+ on(
+ eventType: T,
+ handler: EventHandler
+ ): () => void {
+ if (!this.handlers.has(eventType)) {
+ this.handlers.set(eventType, new Set())
+ }
+
+ const handlerSet = this.handlers.get(eventType)!
+ handlerSet.add(handler as AnyHandler)
+
+ return () => {
+ handlerSet.delete(handler as AnyHandler)
+ if (handlerSet.size === 0) {
+ this.handlers.delete(eventType)
+ }
+ }
+ }
+
+ /**
+ * Emit an event to all registered handlers.
+ * Uses Promise.allSettled so a failing handler never crashes the emitter.
+ */
+ async emit(event: CoreEvent): Promise {
+ const handlerSet = this.handlers.get(event.type)
+ if (!handlerSet || handlerSet.size === 0) return
+
+ const results = await Promise.allSettled(
+ [...handlerSet].map((handler) => handler(event.payload))
+ )
+
+ for (const result of results) {
+ if (result.status === 'rejected') {
+ console.error(
+ `[EventBus] Handler failed for "${event.type}":`,
+ result.reason
+ )
+ }
+ }
+ }
+
+ /**
+ * Remove all handlers (useful for testing).
+ */
+ clear(): void {
+ this.handlers.clear()
+ }
+}
+
+/** Module-level singleton */
+export const eventBus = new EventBus()
diff --git a/lib/events/index.ts b/lib/events/index.ts
new file mode 100644
index 00000000..b898c3e1
--- /dev/null
+++ b/lib/events/index.ts
@@ -0,0 +1,8 @@
+export { eventBus } from './bus'
+export type {
+ CoreEvent,
+ CoreEventType,
+ EventPayload,
+ EventHandler,
+ EventSubscription,
+} from './types'
diff --git a/lib/events/types.ts b/lib/events/types.ts
new file mode 100644
index 00000000..b108701a
--- /dev/null
+++ b/lib/events/types.ts
@@ -0,0 +1,83 @@
+import type {
+ JournalEntry,
+ Invoice,
+ Transaction,
+ Customer,
+ FiscalPeriod,
+ DocumentAttachment,
+ Receipt,
+ CreditNote,
+ CAMT053Statement,
+ CAMT054Notification,
+ AuditSecurityEvent,
+} from '@/types'
+
+// ============================================================
+// Core Event Types — discriminated union of all system events
+// ============================================================
+
+export type CoreEvent =
+ // Bookkeeping
+ | { type: 'journal_entry.drafted'; payload: { entry: JournalEntry; userId: string } }
+ | { type: 'journal_entry.committed'; payload: { entry: JournalEntry; userId: string } }
+ | { type: 'journal_entry.corrected'; payload: { original: JournalEntry; storno: JournalEntry; corrected: JournalEntry; userId: string } }
+ // Documents
+ | { type: 'document.uploaded'; payload: { document: DocumentAttachment; userId: string } }
+ // Invoicing
+ | { type: 'invoice.created'; payload: { invoice: Invoice; userId: string } }
+ | { type: 'invoice.sent'; payload: { invoice: Invoice; userId: string } }
+ | { type: 'invoice.paid'; payload: { invoice: Invoice; transaction: Transaction; kursdifferens?: number; userId: string } }
+ | { type: 'invoice.overdue'; payload: { invoice: Invoice; days: number; userId: string } }
+ | { type: 'credit_note.created'; payload: { creditNote: CreditNote; userId: string } }
+ // Banking
+ | { type: 'transaction.synced'; payload: { transactions: Transaction[]; userId: string } }
+ | { type: 'transaction.categorized'; payload: { transaction: Transaction; account: string; taxCode: string; userId: string } }
+ | { type: 'bank.statement_received'; payload: { statement: CAMT053Statement; userId: string } }
+ | { type: 'bank.payment_notification'; payload: { notification: CAMT054Notification; userId: string } }
+ // Periods
+ | { type: 'period.locked'; payload: { period: FiscalPeriod; userId: string } }
+ | { type: 'period.year_closed'; payload: { period: FiscalPeriod; userId: string } }
+ // Customers
+ | { type: 'customer.created'; payload: { customer: Customer; userId: string } }
+ | { type: 'customer.pseudonymized'; payload: { customerId: string; userId: string } }
+ // Receipts
+ | { type: 'receipt.extracted'; payload: {
+ receipt: Receipt;
+ documentId: string | null;
+ confidence: number;
+ userId: string;
+ }}
+ | { type: 'receipt.matched'; payload: {
+ receipt: Receipt;
+ transaction: Transaction;
+ confidence: number;
+ autoMatched: boolean;
+ userId: string;
+ }}
+ | { type: 'receipt.confirmed'; payload: {
+ receipt: Receipt;
+ businessTotal: number;
+ privateTotal: number;
+ userId: string;
+ }}
+ // Audit
+ | { type: 'audit.security_event'; payload: { event: AuditSecurityEvent; userId: string } }
+
+// ============================================================
+// Helper Types
+// ============================================================
+
+/** All possible event type strings */
+export type CoreEventType = CoreEvent['type']
+
+/** Extract the payload type for a given event type */
+export type EventPayload = Extract['payload']
+
+/** Handler function for a specific event type */
+export type EventHandler = (payload: EventPayload) => Promise | void
+
+/** Subscription: event type + handler */
+export interface EventSubscription {
+ eventType: T
+ handler: EventHandler
+}
diff --git a/lib/extensions/__tests__/registry.test.ts b/lib/extensions/__tests__/registry.test.ts
new file mode 100644
index 00000000..7c15deb1
--- /dev/null
+++ b/lib/extensions/__tests__/registry.test.ts
@@ -0,0 +1,129 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { extensionRegistry } from '../registry'
+import { eventBus } from '@/lib/events/bus'
+import type { Extension } from '../types'
+
+beforeEach(() => {
+ extensionRegistry.clear()
+ eventBus.clear()
+})
+
+function makeExtension(overrides: Partial = {}): Extension {
+ return {
+ id: 'test-ext',
+ name: 'Test Extension',
+ version: '1.0.0',
+ ...overrides,
+ }
+}
+
+describe('ExtensionRegistry', () => {
+ it('register() stores extension, queryable via get() and getAll()', () => {
+ const ext = makeExtension()
+ extensionRegistry.register(ext)
+
+ expect(extensionRegistry.get('test-ext')).toBe(ext)
+ expect(extensionRegistry.getAll()).toEqual([ext])
+ })
+
+ it('register() wires event handlers to the bus', async () => {
+ const handler = vi.fn()
+ const ext = makeExtension({
+ id: 'event-ext',
+ eventHandlers: [{ eventType: 'journal_entry.committed', handler }],
+ })
+
+ extensionRegistry.register(ext)
+
+ await eventBus.emit({
+ type: 'journal_entry.committed',
+ payload: { entry: { id: 'e1' } as never, userId: 'u1' },
+ })
+
+ expect(handler).toHaveBeenCalledWith({ entry: { id: 'e1' }, userId: 'u1' })
+ })
+
+ it('register() skips duplicate registration (same id)', () => {
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const ext1 = makeExtension()
+ const ext2 = makeExtension({ name: 'Duplicate' })
+
+ extensionRegistry.register(ext1)
+ extensionRegistry.register(ext2)
+
+ // Original is kept
+ expect(extensionRegistry.get('test-ext')!.name).toBe('Test Extension')
+ expect(extensionRegistry.getAll()).toHaveLength(1)
+
+ consoleSpy.mockRestore()
+ })
+
+ it('unregister() removes extension and unsubscribes handlers', async () => {
+ const handler = vi.fn()
+ const ext = makeExtension({
+ id: 'removable',
+ eventHandlers: [{ eventType: 'journal_entry.committed', handler }],
+ })
+
+ extensionRegistry.register(ext)
+ extensionRegistry.unregister('removable')
+
+ expect(extensionRegistry.get('removable')).toBeUndefined()
+
+ await eventBus.emit({
+ type: 'journal_entry.committed',
+ payload: { entry: { id: 'e1' } as never, userId: 'u1' },
+ })
+
+ expect(handler).not.toHaveBeenCalled()
+ })
+
+ it('getByCapability() filters correctly', () => {
+ const ext1 = makeExtension({
+ id: 'with-settings',
+ settingsPanel: { label: 'Test', path: '/test' },
+ })
+ const ext2 = makeExtension({ id: 'without-settings' })
+
+ extensionRegistry.register(ext1)
+ extensionRegistry.register(ext2)
+
+ const withSettings = extensionRegistry.getByCapability('settingsPanel')
+ expect(withSettings).toHaveLength(1)
+ expect(withSettings[0].id).toBe('with-settings')
+ })
+
+ it('clear() removes all extensions and unsubscribes all handlers', async () => {
+ const handler1 = vi.fn()
+ const handler2 = vi.fn()
+
+ extensionRegistry.register(
+ makeExtension({
+ id: 'ext1',
+ eventHandlers: [{ eventType: 'journal_entry.committed', handler: handler1 }],
+ })
+ )
+ extensionRegistry.register(
+ makeExtension({
+ id: 'ext2',
+ eventHandlers: [{ eventType: 'journal_entry.drafted', handler: handler2 }],
+ })
+ )
+
+ extensionRegistry.clear()
+
+ expect(extensionRegistry.getAll()).toHaveLength(0)
+
+ await eventBus.emit({
+ type: 'journal_entry.committed',
+ payload: { entry: { id: 'e1' } as never, userId: 'u1' },
+ })
+ await eventBus.emit({
+ type: 'journal_entry.drafted',
+ payload: { entry: { id: 'e1' } as never, userId: 'u1' },
+ })
+
+ expect(handler1).not.toHaveBeenCalled()
+ expect(handler2).not.toHaveBeenCalled()
+ })
+})
diff --git a/lib/extensions/index.ts b/lib/extensions/index.ts
new file mode 100644
index 00000000..80f55d60
--- /dev/null
+++ b/lib/extensions/index.ts
@@ -0,0 +1,15 @@
+export { extensionRegistry } from './registry'
+export { loadExtensions } from './loader'
+export type {
+ Extension,
+ RouteDefinition,
+ ApiRouteDefinition,
+ SidebarItem,
+ ReportDefinition,
+ SettingsPanelDefinition,
+ TaxCodeDefinition,
+ DimensionDefinition,
+ MappingRuleTypeDefinition,
+ ExtensionEventHandler,
+ ExtensionContext,
+} from './types'
diff --git a/lib/extensions/loader.ts b/lib/extensions/loader.ts
new file mode 100644
index 00000000..c9a1a7e2
--- /dev/null
+++ b/lib/extensions/loader.ts
@@ -0,0 +1,30 @@
+import { extensionRegistry } from './registry'
+import { receiptOcrExtension } from '@/extensions/receipt-ocr'
+import { aiCategorizationExtension } from '@/extensions/ai-categorization'
+import type { Extension } from './types'
+
+/**
+ * Explicit list of first-party extensions.
+ *
+ * Next.js bundling requires static imports — no dynamic filesystem scanning.
+ * Add extensions here as they are built.
+ */
+const FIRST_PARTY_EXTENSIONS: Extension[] = [
+ receiptOcrExtension,
+ aiCategorizationExtension,
+]
+
+let loaded = false
+
+/**
+ * Load and register all first-party extensions.
+ * Idempotent — safe to call multiple times.
+ */
+export function loadExtensions(): void {
+ if (loaded) return
+ loaded = true
+
+ for (const extension of FIRST_PARTY_EXTENSIONS) {
+ extensionRegistry.register(extension)
+ }
+}
diff --git a/lib/extensions/registry.ts b/lib/extensions/registry.ts
new file mode 100644
index 00000000..55d33957
--- /dev/null
+++ b/lib/extensions/registry.ts
@@ -0,0 +1,79 @@
+import { eventBus } from '@/lib/events/bus'
+import type { CoreEventType } from '@/lib/events/types'
+import type { Extension } from './types'
+
+/**
+ * Extension Registry — singleton that manages extension lifecycle.
+ *
+ * - register() stores extension and wires event handlers to the bus
+ * - unregister() unhooks handlers and removes extension
+ * - getAll(), get(), getByCapability() for querying
+ */
+class ExtensionRegistry {
+ private extensions = new Map()
+ private unsubscribers = new Map void)[]>()
+
+ /**
+ * Register an extension: store it and wire its event handlers to the bus.
+ */
+ register(extension: Extension): void {
+ if (this.extensions.has(extension.id)) {
+ console.warn(`[ExtensionRegistry] Extension "${extension.id}" already registered, skipping`)
+ return
+ }
+
+ this.extensions.set(extension.id, extension)
+
+ // Wire event handlers to the bus
+ const unsubs: (() => void)[] = []
+ if (extension.eventHandlers) {
+ for (const { eventType, handler } of extension.eventHandlers) {
+ // Cast is safe: the handler is stored by eventType key, so it only receives matching payloads
+ const unsub = eventBus.on(eventType as CoreEventType, handler)
+ unsubs.push(unsub)
+ }
+ }
+ this.unsubscribers.set(extension.id, unsubs)
+ }
+
+ /**
+ * Unregister an extension: unhook all event handlers and remove.
+ */
+ unregister(extensionId: string): void {
+ const unsubs = this.unsubscribers.get(extensionId)
+ if (unsubs) {
+ for (const unsub of unsubs) {
+ unsub()
+ }
+ this.unsubscribers.delete(extensionId)
+ }
+ this.extensions.delete(extensionId)
+ }
+
+ /** Get all registered extensions. */
+ getAll(): Extension[] {
+ return [...this.extensions.values()]
+ }
+
+ /** Get a specific extension by ID. */
+ get(id: string): Extension | undefined {
+ return this.extensions.get(id)
+ }
+
+ /** Get all extensions that have a specific capability. */
+ getByCapability(key: keyof Extension): Extension[] {
+ return [...this.extensions.values()].filter(
+ (ext) => ext[key] !== undefined && ext[key] !== null
+ )
+ }
+
+ /** Clear all extensions (useful for testing). */
+ clear(): void {
+ for (const id of this.extensions.keys()) {
+ this.unregister(id)
+ }
+ }
+}
+
+/** Module-level singleton */
+export const extensionRegistry = new ExtensionRegistry()
diff --git a/lib/extensions/types.ts b/lib/extensions/types.ts
new file mode 100644
index 00000000..736a77f0
--- /dev/null
+++ b/lib/extensions/types.ts
@@ -0,0 +1,100 @@
+import type { CoreEventType } from '@/lib/events/types'
+
+// ============================================================
+// Extension Interface & Supporting Types
+// ============================================================
+
+/** A route exposed by an extension (page route) */
+export interface RouteDefinition {
+ path: string
+ label: string
+}
+
+/** An API route exposed by an extension */
+export interface ApiRouteDefinition {
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
+ path: string
+ handler: (request: Request) => Promise
+}
+
+/** Sidebar navigation item added by an extension */
+export interface SidebarItem {
+ label: string
+ icon?: string
+ path: string
+ order?: number
+}
+
+/** Report type added by an extension */
+export interface ReportDefinition {
+ id: string
+ name: string
+ description: string
+}
+
+/** Settings panel exposed by an extension */
+export interface SettingsPanelDefinition {
+ label: string
+ path: string
+}
+
+/** Tax code definition added by an extension */
+export interface TaxCodeDefinition {
+ code: string
+ rate: number
+ description: string
+}
+
+/** Dimension type definition added by an extension */
+export interface DimensionDefinition {
+ id: string
+ name: string
+ description: string
+}
+
+/** Mapping rule type added by an extension */
+export interface MappingRuleTypeDefinition {
+ id: string
+ name: string
+ description: string
+}
+
+/** Event handler registration for an extension */
+export interface ExtensionEventHandler {
+ eventType: CoreEventType
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ handler: (payload: any) => Promise | void
+}
+
+/** Context passed to extension lifecycle hooks */
+export interface ExtensionContext {
+ userId: string
+ extensionId: string
+}
+
+/**
+ * Extension interface — the contract for all add-ons.
+ *
+ * Extensions declare what they provide (routes, event handlers, sidebar items, etc.)
+ * and the registry wires them into the system.
+ */
+export 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 hooks
+ onInstall?(ctx: ExtensionContext): Promise
+ onUninstall?(ctx: ExtensionContext): Promise
+}
diff --git a/lib/init.ts b/lib/init.ts
new file mode 100644
index 00000000..1b40eb85
--- /dev/null
+++ b/lib/init.ts
@@ -0,0 +1,10 @@
+import { loadExtensions } from '@/lib/extensions/loader'
+
+/**
+ * Ensure the system is initialized (extensions loaded).
+ * Called from API routes that emit events.
+ * Idempotent — safe to call multiple times.
+ */
+export function ensureInitialized(): void {
+ loadExtensions()
+}
diff --git a/lib/receipts/__tests__/receipt-categorizer.test.ts b/lib/receipts/__tests__/receipt-categorizer.test.ts
new file mode 100644
index 00000000..db9f4fbf
--- /dev/null
+++ b/lib/receipts/__tests__/receipt-categorizer.test.ts
@@ -0,0 +1,154 @@
+import { describe, it, expect } from 'vitest'
+import {
+ mapSuggestedCategory,
+ getBASAccount,
+ categorizeLineItem,
+ processLineItems,
+ calculateReceiptSplit,
+ getDefaultClassification,
+} from '../receipt-categorizer'
+import type { ExtractedLineItem } from '@/types'
+
+describe('mapSuggestedCategory', () => {
+ it('maps AI categories to TransactionCategory', () => {
+ expect(mapSuggestedCategory('equipment')).toBe('expense_equipment')
+ expect(mapSuggestedCategory('software')).toBe('expense_software')
+ expect(mapSuggestedCategory('travel')).toBe('expense_travel')
+ expect(mapSuggestedCategory('office')).toBe('expense_office')
+ expect(mapSuggestedCategory('marketing')).toBe('expense_marketing')
+ expect(mapSuggestedCategory('professional_services')).toBe('expense_professional_services')
+ expect(mapSuggestedCategory('education')).toBe('expense_education')
+ expect(mapSuggestedCategory('other')).toBe('expense_other')
+ })
+
+ it('returns null for unknown categories', () => {
+ expect(mapSuggestedCategory('nonexistent')).toBeNull()
+ expect(mapSuggestedCategory(null)).toBeNull()
+ })
+})
+
+describe('getBASAccount', () => {
+ it('returns correct BAS account per category', () => {
+ expect(getBASAccount('expense_equipment')).toBe('5410')
+ expect(getBASAccount('expense_software')).toBe('5420')
+ expect(getBASAccount('expense_travel')).toBe('5800')
+ expect(getBASAccount('expense_office')).toBe('5010')
+ expect(getBASAccount('expense_marketing')).toBe('5910')
+ expect(getBASAccount('expense_professional_services')).toBe('6530')
+ expect(getBASAccount('expense_education')).toBe('6991')
+ expect(getBASAccount('expense_bank_fees')).toBe('6570')
+ expect(getBASAccount('income_services')).toBe('3001')
+ })
+})
+
+describe('categorizeLineItem', () => {
+ it('keyword patterns match Swedish terms — dator → expense_equipment', () => {
+ const result = categorizeLineItem('MacBook Pro dator')
+ expect(result.category).toBe('expense_equipment')
+ expect(result.confidence).toBe(0.7)
+ })
+
+ it('matches software patterns', () => {
+ const result = categorizeLineItem('Adobe Creative Cloud prenumeration')
+ expect(result.category).toBe('expense_software')
+ })
+
+ it('matches travel patterns', () => {
+ const result = categorizeLineItem('SJ tåg Stockholm-Malmö')
+ expect(result.category).toBe('expense_travel')
+ })
+
+ it('returns null category for unrecognized descriptions', () => {
+ const result = categorizeLineItem('xyzzy foobarbaz')
+ expect(result.category).toBeNull()
+ expect(result.confidence).toBe(0)
+ })
+})
+
+describe('processLineItems', () => {
+ it('prefers AI suggestion over pattern match', () => {
+ const items: ExtractedLineItem[] = [
+ {
+ description: 'MacBook Pro dator', // pattern → equipment
+ quantity: 1,
+ unitPrice: 15000,
+ lineTotal: 15000,
+ vatRate: 25,
+ suggestedCategory: 'software', // AI says software
+ confidence: 0.9,
+ },
+ ]
+
+ const result = processLineItems(items)
+ expect(result[0].category).toBe('expense_software') // AI wins
+ expect(result[0].basAccount).toBe('5420')
+ })
+
+ it('falls back to pattern match when no AI suggestion', () => {
+ const items: ExtractedLineItem[] = [
+ {
+ description: 'MacBook Pro dator',
+ quantity: 1,
+ unitPrice: 15000,
+ lineTotal: 15000,
+ vatRate: 25,
+ suggestedCategory: null,
+ },
+ ]
+
+ const result = processLineItems(items)
+ expect(result[0].category).toBe('expense_equipment') // pattern match
+ expect(result[0].basAccount).toBe('5410')
+ })
+})
+
+describe('calculateReceiptSplit', () => {
+ it('correct business/private/unclassified totals', () => {
+ const items = [
+ { lineTotal: 100, is_business: true as boolean | null },
+ { lineTotal: 50, is_business: false as boolean | null },
+ { lineTotal: 25, is_business: null as boolean | null },
+ ]
+
+ const result = calculateReceiptSplit(items)
+ expect(result.businessTotal).toBe(100)
+ expect(result.privateTotal).toBe(50)
+ expect(result.unclassifiedTotal).toBe(25)
+ // 100 / 175 * 100 = 57.142... → 57.1
+ expect(result.businessPercentage).toBeCloseTo(57.1, 1)
+ })
+
+ it('handles rounding correctly', () => {
+ const items = [
+ { lineTotal: 33.333, is_business: true as boolean | null },
+ { lineTotal: 66.667, is_business: false as boolean | null },
+ ]
+
+ const result = calculateReceiptSplit(items)
+ expect(result.businessTotal).toBe(33.33)
+ expect(result.privateTotal).toBe(66.67)
+ })
+})
+
+describe('getDefaultClassification', () => {
+ it('Systembolaget defaults to private', () => {
+ const result = getDefaultClassification(false, true)
+ expect(result.defaultIsBusiness).toBe(false)
+ expect(result.requiresReview).toBe(true)
+ expect(result.warningMessage).toContain('Alkohol')
+ })
+
+ it('restaurant requires review', () => {
+ const result = getDefaultClassification(true, false)
+ expect(result.defaultIsBusiness).toBeNull()
+ expect(result.requiresReview).toBe(true)
+ expect(result.warningMessage).toContain('Restaurangbesök')
+ })
+
+ it('non-restaurant, non-systembolaget has no warning', () => {
+ const result = getDefaultClassification(false, false)
+ expect(result.defaultIsBusiness).toBeNull()
+ expect(result.requiresReview).toBe(false)
+ expect(result.warningMessage).toBeNull()
+ })
+})
diff --git a/lib/receipts/__tests__/receipt-matcher.test.ts b/lib/receipts/__tests__/receipt-matcher.test.ts
new file mode 100644
index 00000000..11151789
--- /dev/null
+++ b/lib/receipts/__tests__/receipt-matcher.test.ts
@@ -0,0 +1,286 @@
+import { describe, it, expect } from 'vitest'
+import {
+ findTransactionMatches,
+ autoMatchReceipts,
+ filterUnmatchedTransactions,
+ filterUnmatchedReceipts,
+} from '../receipt-matcher'
+import { makeReceipt, makeTransaction } from '@/tests/helpers'
+
+describe('findTransactionMatches', () => {
+ it('exact date + exact amount → high confidence', () => {
+ const receipt = makeReceipt({
+ receipt_date: '2024-06-15',
+ total_amount: 299,
+ merchant_name: 'ICA Maxi',
+ })
+ const transactions = [
+ makeTransaction({
+ date: '2024-06-15',
+ amount: -299,
+ merchant_name: 'ICA Maxi',
+ receipt_id: null,
+ }),
+ ]
+
+ const matches = findTransactionMatches(receipt, transactions)
+ expect(matches).toHaveLength(1)
+ expect(matches[0].confidence).toBeGreaterThanOrEqual(0.8)
+ expect(matches[0].dateVariance).toBe(0)
+ })
+
+ it('date within ±3 days → matches with lower confidence', () => {
+ const receipt = makeReceipt({
+ receipt_date: '2024-06-15',
+ total_amount: 500,
+ merchant_name: '',
+ })
+ const transactions = [
+ makeTransaction({
+ date: '2024-06-17',
+ amount: -500,
+ merchant_name: '',
+ receipt_id: null,
+ }),
+ ]
+
+ const matches = findTransactionMatches(receipt, transactions)
+ expect(matches.length).toBeGreaterThanOrEqual(1)
+ expect(matches[0].dateVariance).toBeCloseTo(2, 0)
+ })
+
+ it('date outside ±3 days → no match', () => {
+ const receipt = makeReceipt({
+ receipt_date: '2024-06-15',
+ total_amount: 500,
+ merchant_name: '',
+ })
+ const transactions = [
+ makeTransaction({
+ date: '2024-06-25',
+ amount: -500,
+ merchant_name: '',
+ receipt_id: null,
+ }),
+ ]
+
+ const matches = findTransactionMatches(receipt, transactions)
+ expect(matches).toHaveLength(0)
+ })
+
+ it('amount within 5% tolerance → matches', () => {
+ const receipt = makeReceipt({
+ receipt_date: '2024-06-15',
+ total_amount: 1000,
+ merchant_name: '',
+ })
+ // 4% off = 960
+ const transactions = [
+ makeTransaction({
+ date: '2024-06-15',
+ amount: -960,
+ merchant_name: '',
+ receipt_id: null,
+ }),
+ ]
+
+ const matches = findTransactionMatches(receipt, transactions)
+ expect(matches.length).toBeGreaterThanOrEqual(1)
+ })
+
+ it('amount outside tolerance → no match', () => {
+ const receipt = makeReceipt({
+ receipt_date: '2024-06-15',
+ total_amount: 1000,
+ merchant_name: '',
+ is_foreign_merchant: false,
+ })
+ // 10% off = 900 (way above 5%)
+ const transactions = [
+ makeTransaction({
+ date: '2024-06-15',
+ amount: -900,
+ merchant_name: '',
+ receipt_id: null,
+ }),
+ ]
+
+ const matches = findTransactionMatches(receipt, transactions)
+ expect(matches).toHaveLength(0)
+ })
+
+ it('merchant name similarity boosts confidence', () => {
+ const receipt = makeReceipt({
+ receipt_date: '2024-06-15',
+ total_amount: 500,
+ merchant_name: 'Coop Konsum',
+ })
+
+ const txWithMerchant = makeTransaction({
+ id: 'tx-with',
+ date: '2024-06-15',
+ amount: -500,
+ merchant_name: 'Coop Konsum Stockholm',
+ receipt_id: null,
+ })
+ const txWithout = makeTransaction({
+ id: 'tx-without',
+ date: '2024-06-15',
+ amount: -500,
+ merchant_name: '',
+ receipt_id: null,
+ })
+
+ const matchesWithMerchant = findTransactionMatches(receipt, [txWithMerchant])
+ const matchesWithout = findTransactionMatches(receipt, [txWithout])
+
+ // Both should match since date+amount are exact
+ expect(matchesWithMerchant.length).toBeGreaterThanOrEqual(1)
+ expect(matchesWithout.length).toBeGreaterThanOrEqual(1)
+
+ // Merchant match should have higher confidence
+ expect(matchesWithMerchant[0].confidence).toBeGreaterThan(
+ matchesWithout[0].confidence
+ )
+ })
+
+ it('skips already-matched transactions (receipt_id set)', () => {
+ const receipt = makeReceipt({
+ receipt_date: '2024-06-15',
+ total_amount: 299,
+ })
+ const transactions = [
+ makeTransaction({
+ date: '2024-06-15',
+ amount: -299,
+ receipt_id: 'already-matched',
+ }),
+ ]
+
+ const matches = findTransactionMatches(receipt, transactions)
+ expect(matches).toHaveLength(0)
+ })
+
+ it('skips income transactions (amount >= 0)', () => {
+ const receipt = makeReceipt({
+ receipt_date: '2024-06-15',
+ total_amount: 299,
+ })
+ const transactions = [
+ makeTransaction({
+ date: '2024-06-15',
+ amount: 299, // income, positive
+ receipt_id: null,
+ }),
+ ]
+
+ const matches = findTransactionMatches(receipt, transactions)
+ expect(matches).toHaveLength(0)
+ })
+})
+
+describe('autoMatchReceipts', () => {
+ it('returns matches above threshold', () => {
+ const receipts = [
+ makeReceipt({
+ id: 'r1',
+ receipt_date: '2024-06-15',
+ total_amount: 500,
+ merchant_name: 'Coop',
+ matched_transaction_id: null,
+ }),
+ ]
+ const transactions = [
+ makeTransaction({
+ id: 'tx1',
+ date: '2024-06-15',
+ amount: -500,
+ merchant_name: 'Coop',
+ receipt_id: null,
+ }),
+ ]
+
+ const results = autoMatchReceipts(receipts, transactions, 0.5)
+ expect(results).toHaveLength(1)
+ expect(results[0].receipt.id).toBe('r1')
+ expect(results[0].match.confidence).toBeGreaterThanOrEqual(0.5)
+ })
+
+ it('respects custom threshold', () => {
+ const receipts = [
+ makeReceipt({
+ id: 'r1',
+ receipt_date: '2024-06-15',
+ total_amount: 500,
+ merchant_name: '',
+ matched_transaction_id: null,
+ }),
+ ]
+ const transactions = [
+ makeTransaction({
+ id: 'tx1',
+ date: '2024-06-17', // 2 days off, no merchant → moderate confidence
+ amount: -500,
+ merchant_name: '',
+ receipt_id: null,
+ }),
+ ]
+
+ // With a very high threshold, it should not match
+ const highThreshold = autoMatchReceipts(receipts, transactions, 0.99)
+ expect(highThreshold).toHaveLength(0)
+
+ // With a lower threshold, it should match
+ const lowThreshold = autoMatchReceipts(receipts, transactions, 0.4)
+ expect(lowThreshold).toHaveLength(1)
+ })
+
+ it('skips already-matched receipts', () => {
+ const receipts = [
+ makeReceipt({
+ id: 'r1',
+ receipt_date: '2024-06-15',
+ total_amount: 500,
+ matched_transaction_id: 'existing-tx',
+ }),
+ ]
+ const transactions = [
+ makeTransaction({
+ date: '2024-06-15',
+ amount: -500,
+ receipt_id: null,
+ }),
+ ]
+
+ const results = autoMatchReceipts(receipts, transactions)
+ expect(results).toHaveLength(0)
+ })
+})
+
+describe('filterUnmatchedTransactions', () => {
+ it('returns only unmatched expenses', () => {
+ const transactions = [
+ makeTransaction({ id: 't1', receipt_id: null, amount: -100 }),
+ makeTransaction({ id: 't2', receipt_id: 'r1', amount: -200 }), // matched
+ makeTransaction({ id: 't3', receipt_id: null, amount: 300 }), // income
+ ]
+
+ const result = filterUnmatchedTransactions(transactions)
+ expect(result).toHaveLength(1)
+ expect(result[0].id).toBe('t1')
+ })
+})
+
+describe('filterUnmatchedReceipts', () => {
+ it('returns only confirmed unmatched receipts', () => {
+ const receipts = [
+ makeReceipt({ id: 'r1', status: 'confirmed', matched_transaction_id: null }),
+ makeReceipt({ id: 'r2', status: 'confirmed', matched_transaction_id: 'tx1' }),
+ makeReceipt({ id: 'r3', status: 'extracted', matched_transaction_id: null }),
+ ]
+
+ const result = filterUnmatchedReceipts(receipts)
+ expect(result).toHaveLength(1)
+ expect(result[0].id).toBe('r1')
+ })
+})
diff --git a/lib/reports/sie-export.ts b/lib/reports/sie-export.ts
index 7c994a4a..379ff53f 100644
--- a/lib/reports/sie-export.ts
+++ b/lib/reports/sie-export.ts
@@ -45,6 +45,21 @@ export async function generateSIEExport(
.eq('status', 'posted')
.order('voucher_number')
+ // Fetch cost centers and projects for dimension records
+ const { data: costCenters } = await supabase
+ .from('cost_centers')
+ .select('*')
+ .eq('user_id', userId)
+ .eq('is_active', true)
+ .order('code')
+
+ const { data: projects } = await supabase
+ .from('projects')
+ .select('*')
+ .eq('user_id', userId)
+ .eq('is_active', true)
+ .order('code')
+
const lines: string[] = []
const now = new Date()
@@ -66,10 +81,34 @@ export async function generateSIEExport(
// Use date strings directly to avoid timezone conversion issues
lines.push(`#RAR 0 ${dateStringToSIE(period.period_start)} ${dateStringToSIE(period.period_end)}`)
+ // === Dimension definitions ===
+ // SIE standard: dimension 1 = kostnadsställe, dimension 6 = projekt
+ const hasCostCenters = costCenters && costCenters.length > 0
+ const hasProjects = projects && projects.length > 0
+
+ if (hasCostCenters) {
+ lines.push('#DIM 1 "Kostnadsställe"')
+ }
+ if (hasProjects) {
+ lines.push('#DIM 6 "Projekt"')
+ }
+
+ // === Dimension objects (#OBJEKT) ===
+ for (const cc of costCenters || []) {
+ lines.push(`#OBJEKT 1 "${escapeQuotes(cc.code)}" "${escapeQuotes(cc.name)}"`)
+ }
+ for (const proj of projects || []) {
+ lines.push(`#OBJEKT 6 "${escapeQuotes(proj.code)}" "${escapeQuotes(proj.name)}"`)
+ }
+
// === Chart of accounts ===
for (const account of (accounts as BASAccount[]) || []) {
lines.push(`#KONTO ${account.account_number} "${escapeQuotes(account.account_name)}"`)
- // SRU codes could be added here if available
+
+ // #SRU records from chart_of_accounts.sru_code
+ if (account.sru_code) {
+ lines.push(`#SRU ${account.account_number} ${account.sru_code}`)
+ }
}
// === Opening balances (IB) ===
@@ -96,7 +135,17 @@ export async function generateSIEExport(
? ` "${escapeQuotes(line.line_description)}"`
: ''
- lines.push(`\t#TRANS ${line.account_number} {} ${formatAmount(amount)} ${entryDate}${lineDesc}`)
+ // Build dimension object list for #TRANS line
+ const dimParts: string[] = []
+ if (line.cost_center) {
+ dimParts.push(`1 "${escapeQuotes(line.cost_center)}"`)
+ }
+ if (line.project) {
+ dimParts.push(`6 "${escapeQuotes(line.project)}"`)
+ }
+ const objList = dimParts.length > 0 ? `{${dimParts.join(' ')}}` : '{}'
+
+ lines.push(`\t#TRANS ${line.account_number} ${objList} ${formatAmount(amount)} ${entryDate}${lineDesc}`)
}
lines.push('}')
diff --git a/lib/reports/vat-declaration.ts b/lib/reports/vat-declaration.ts
index 74b9c1ff..3ac9988a 100644
--- a/lib/reports/vat-declaration.ts
+++ b/lib/reports/vat-declaration.ts
@@ -6,6 +6,7 @@ import type {
Invoice,
Transaction,
Receipt,
+ TaxCode,
} from '@/types'
/**
@@ -419,3 +420,125 @@ export function formatPeriodLabel(
return `${year}`
}
}
+
+// ============================================================
+// Tax-code-driven VAT declaration (new approach)
+// ============================================================
+
+/**
+ * Calculate VAT declaration using tax codes from journal entry lines.
+ *
+ * This is the new, tax-code-driven approach that sums journal_entry_lines
+ * grouped by tax_code, then maps via the tax_codes table to moms boxes.
+ * Falls back to the legacy invoice/transaction/receipt approach for
+ * lines without tax codes.
+ */
+export async function calculateVatDeclarationFromTaxCodes(
+ userId: string,
+ periodType: VatPeriodType,
+ year: number,
+ period: number
+): Promise {
+ const supabase = await createClient()
+ const { start, end } = calculatePeriodDates(periodType, year, period)
+
+ // Fetch tax codes for this user (including system codes)
+ const { data: taxCodesData } = await supabase
+ .from('tax_codes')
+ .select('*')
+ .or(`user_id.eq.${userId},user_id.is.null`)
+
+ const taxCodes = (taxCodesData as TaxCode[]) || []
+ const taxCodeMap = new Map()
+ for (const tc of taxCodes) {
+ if (!taxCodeMap.has(tc.code) || tc.user_id) {
+ taxCodeMap.set(tc.code, tc)
+ }
+ }
+
+ // Fetch posted journal entry lines with tax_code in the period
+ const { data: lines } = await supabase
+ .from('journal_entry_lines')
+ .select(`
+ tax_code,
+ debit_amount,
+ credit_amount,
+ journal_entry_id,
+ journal_entries!inner (
+ user_id,
+ entry_date,
+ status
+ )
+ `)
+ .not('tax_code', 'is', null)
+ .eq('journal_entries.user_id', userId)
+ .eq('journal_entries.status', 'posted')
+ .gte('journal_entries.entry_date', start)
+ .lte('journal_entries.entry_date', end)
+
+ // Aggregate amounts by moms box
+ const boxTotals = new Map()
+
+ for (const line of lines || []) {
+ if (!line.tax_code) continue
+
+ const taxCode = taxCodeMap.get(line.tax_code)
+ if (!taxCode) continue
+
+ const amount = Math.abs(Number(line.debit_amount || 0) - Number(line.credit_amount || 0))
+
+ // Map to all relevant boxes
+ for (const box of [...taxCode.moms_basis_boxes, ...taxCode.moms_tax_boxes, ...taxCode.moms_input_boxes]) {
+ const current = boxTotals.get(box) || 0
+ boxTotals.set(box, current + amount)
+ }
+ }
+
+ // Build rutor from box totals
+ const rutor: VatDeclarationRutor = {
+ ruta05: round(boxTotals.get('05') || 0),
+ ruta06: round(boxTotals.get('06') || 0),
+ ruta07: round(boxTotals.get('07') || 0),
+ ruta10: round(boxTotals.get('10') || 0),
+ ruta11: round(boxTotals.get('11') || 0),
+ ruta12: round(boxTotals.get('12') || 0),
+ ruta39: round(boxTotals.get('39') || 0),
+ ruta40: round(boxTotals.get('40') || 0),
+ ruta48: round(boxTotals.get('48') || 0),
+ ruta49: 0,
+ }
+
+ const totalOutputVat = round(rutor.ruta05 + rutor.ruta06 + rutor.ruta07)
+ rutor.ruta49 = round(totalOutputVat - rutor.ruta48)
+
+ return {
+ period: {
+ type: periodType,
+ year,
+ period,
+ start,
+ end,
+ },
+ rutor,
+ invoiceCount: 0,
+ transactionCount: (lines || []).length,
+ breakdown: {
+ invoices: {
+ ruta05: 0,
+ ruta06: 0,
+ ruta07: 0,
+ ruta10: 0,
+ ruta11: 0,
+ ruta12: 0,
+ ruta39: 0,
+ ruta40: 0,
+ },
+ transactions: {
+ ruta48: 0,
+ },
+ receipts: {
+ ruta48: 0,
+ },
+ },
+ }
+}
diff --git a/lib/transactions/category-suggestions.ts b/lib/transactions/category-suggestions.ts
index 7d7235fa..2acf6c01 100644
--- a/lib/transactions/category-suggestions.ts
+++ b/lib/transactions/category-suggestions.ts
@@ -7,7 +7,7 @@ export interface SuggestedCategory {
label: string
account: string | null
confidence: number
- source: 'mapping_rule' | 'pattern' | 'history'
+ source: 'mapping_rule' | 'pattern' | 'history' | 'ai'
}
const CATEGORY_LABELS: Record = {
@@ -148,3 +148,32 @@ function accountToCategory(account: string, amount: number): string | null {
}
return expenseMap[account] || null
}
+
+/**
+ * Merge AI-generated suggestions into existing suggestion list.
+ * Deduplicates by category, preserving the higher-confidence entry.
+ */
+export function mergeAiSuggestions(
+ existing: SuggestedCategory[],
+ aiSuggestions: { category: string; basAccount: string; confidence: number; reasoning: string }[]
+): SuggestedCategory[] {
+ const seen = new Set(existing.map((s) => s.category))
+ const merged = [...existing]
+
+ for (const ai of aiSuggestions) {
+ if (seen.has(ai.category)) continue
+ seen.add(ai.category)
+
+ merged.push({
+ category: ai.category as TransactionCategory,
+ label: CATEGORY_LABELS[ai.category] || ai.category,
+ account: ai.basAccount || null,
+ confidence: ai.confidence,
+ source: 'ai',
+ })
+ }
+
+ return merged
+ .sort((a, b) => b.confidence - a.confidence)
+ .slice(0, 5)
+}
diff --git a/package-lock.json b/package-lock.json
index 2a5c9996..605773df 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -60,7 +60,8 @@
"eslint": "^9",
"eslint-config-next": "16.1.5",
"tailwindcss": "^4",
- "typescript": "^5"
+ "typescript": "^5",
+ "vitest": "^4.0.18"
}
},
"node_modules/@alloc/quick-lru": {
@@ -384,6 +385,448 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
+ "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
+ "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
+ "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
+ "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
+ "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
+ "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
+ "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
+ "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
+ "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
+ "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
+ "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
+ "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
+ "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
+ "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
+ "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
+ "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
+ "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
+ "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
+ "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
+ "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
+ "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -3034,6 +3477,356 @@
"url": "https://opencollective.com/immer"
}
},
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
+ "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
+ "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
+ "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
+ "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
+ "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
+ "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
+ "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
+ "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
+ "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
+ "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
+ "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
+ "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
+ "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
+ "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
+ "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
+ "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
+ "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
+ "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
+ "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
+ "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
+ "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
+ "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
+ "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
+ "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
+ "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -3467,6 +4260,17 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -3539,6 +4343,13 @@
"@types/ms": "*"
}
},
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -4225,6 +5036,117 @@
"react": ">= 16.8.0"
}
},
+ "node_modules/@vitest/expect": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
+ "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
+ "chai": "^6.2.1",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
+ "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.0.18",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
+ "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
+ "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.0.18",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
+ "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.18",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
+ "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
+ "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.18",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.8",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
@@ -4507,6 +5429,16 @@
"safer-buffer": "^2.1.0"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -4805,6 +5737,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -5620,6 +6562,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -5690,6 +6639,48 @@
"benchmarks"
]
},
+ "node_modules/esbuild": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
+ "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.3",
+ "@esbuild/android-arm": "0.27.3",
+ "@esbuild/android-arm64": "0.27.3",
+ "@esbuild/android-x64": "0.27.3",
+ "@esbuild/darwin-arm64": "0.27.3",
+ "@esbuild/darwin-x64": "0.27.3",
+ "@esbuild/freebsd-arm64": "0.27.3",
+ "@esbuild/freebsd-x64": "0.27.3",
+ "@esbuild/linux-arm": "0.27.3",
+ "@esbuild/linux-arm64": "0.27.3",
+ "@esbuild/linux-ia32": "0.27.3",
+ "@esbuild/linux-loong64": "0.27.3",
+ "@esbuild/linux-mips64el": "0.27.3",
+ "@esbuild/linux-ppc64": "0.27.3",
+ "@esbuild/linux-riscv64": "0.27.3",
+ "@esbuild/linux-s390x": "0.27.3",
+ "@esbuild/linux-x64": "0.27.3",
+ "@esbuild/netbsd-arm64": "0.27.3",
+ "@esbuild/netbsd-x64": "0.27.3",
+ "@esbuild/openbsd-arm64": "0.27.3",
+ "@esbuild/openbsd-x64": "0.27.3",
+ "@esbuild/openharmony-arm64": "0.27.3",
+ "@esbuild/sunos-x64": "0.27.3",
+ "@esbuild/win32-arm64": "0.27.3",
+ "@esbuild/win32-ia32": "0.27.3",
+ "@esbuild/win32-x64": "0.27.3"
+ }
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -6137,6 +7128,16 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -6162,6 +7163,16 @@
"node": ">=0.8.x"
}
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -6358,6 +7369,21 @@
}
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -9104,6 +10130,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
"node_modules/openai": {
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.17.0.tgz",
@@ -9341,6 +10378,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/pdfjs-dist": {
"version": "5.4.530",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.530.tgz",
@@ -9903,6 +10947,51 @@
"node": ">=0.10.0"
}
},
+ "node_modules/rollup": {
+ "version": "4.57.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
+ "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.57.1",
+ "@rollup/rollup-android-arm64": "4.57.1",
+ "@rollup/rollup-darwin-arm64": "4.57.1",
+ "@rollup/rollup-darwin-x64": "4.57.1",
+ "@rollup/rollup-freebsd-arm64": "4.57.1",
+ "@rollup/rollup-freebsd-x64": "4.57.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.57.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.57.1",
+ "@rollup/rollup-linux-arm64-musl": "4.57.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.57.1",
+ "@rollup/rollup-linux-loong64-musl": "4.57.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.57.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.57.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.57.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.57.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.57.1",
+ "@rollup/rollup-linux-x64-gnu": "4.57.1",
+ "@rollup/rollup-linux-x64-musl": "4.57.1",
+ "@rollup/rollup-openbsd-x64": "4.57.1",
+ "@rollup/rollup-openharmony-arm64": "4.57.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.57.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.57.1",
+ "@rollup/rollup-win32-x64-gnu": "4.57.1",
+ "@rollup/rollup-win32-x64-msvc": "4.57.1",
+ "fsevents": "~2.3.2"
+ }
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -10254,6 +11343,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
@@ -10295,6 +11391,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
@@ -10305,6 +11408,13 @@
"fast-sha256": "^1.3.0"
}
},
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -10608,6 +11718,23 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
+ "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -10656,6 +11783,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
+ "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/tlds": {
"version": "1.261.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
@@ -11237,6 +12374,81 @@
"d3-timer": "^3.0.1"
}
},
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
"node_modules/vite-compatible-readable-stream": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
@@ -11251,6 +12463,128 @@
"node": ">= 6"
}
},
+ "node_modules/vite/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
+ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.0.18",
+ "@vitest/mocker": "4.0.18",
+ "@vitest/pretty-format": "4.0.18",
+ "@vitest/runner": "4.0.18",
+ "@vitest/snapshot": "4.0.18",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
+ "es-module-lexer": "^1.7.0",
+ "expect-type": "^1.2.2",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^3.10.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.0.3",
+ "vite": "^6.0.0 || ^7.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.0.18",
+ "@vitest/browser-preview": "4.0.18",
+ "@vitest/browser-webdriverio": "4.0.18",
+ "@vitest/ui": "4.0.18",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
@@ -11375,6 +12709,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
diff --git a/package.json b/package.json
index 057a9b03..02c71d04 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "eslint"
+ "lint": "eslint",
+ "test": "vitest"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.72.1",
@@ -61,6 +62,7 @@
"eslint": "^9",
"eslint-config-next": "16.1.5",
"tailwindcss": "^4",
- "typescript": "^5"
+ "typescript": "^5",
+ "vitest": "^4.0.18"
}
}
diff --git a/supabase/migrations/20240101000011_alter_existing_tables.sql b/supabase/migrations/20240101000011_alter_existing_tables.sql
new file mode 100644
index 00000000..a9263555
--- /dev/null
+++ b/supabase/migrations/20240101000011_alter_existing_tables.sql
@@ -0,0 +1,80 @@
+-- Migration 11: ALTER Existing Tables
+-- Add compliance-critical columns to chart_of_accounts, journal_entries,
+-- journal_entry_lines, and fiscal_periods
+
+-- =============================================================================
+-- 1. chart_of_accounts: Add SRU code for Skatteverket tax filing
+-- =============================================================================
+ALTER TABLE public.chart_of_accounts
+ ADD COLUMN IF NOT EXISTS sru_code text;
+
+-- =============================================================================
+-- 2. journal_entries: Add compliance columns
+-- =============================================================================
+
+-- Track when draft became posted
+ALTER TABLE public.journal_entries
+ ADD COLUMN IF NOT EXISTS committed_at timestamptz;
+
+-- Link to storno entry that reversed this
+ALTER TABLE public.journal_entries
+ ADD COLUMN IF NOT EXISTS reversed_by_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL;
+
+-- Link to entry this storno reverses
+ALTER TABLE public.journal_entries
+ ADD COLUMN IF NOT EXISTS reverses_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL;
+
+-- Link to original in correction chain
+ALTER TABLE public.journal_entries
+ ADD COLUMN IF NOT EXISTS correction_of_id uuid REFERENCES public.journal_entries(id) ON DELETE SET NULL;
+
+-- Expand source_type CHECK to include storno, correction, import, system
+-- First drop the existing check constraint, then re-add with expanded values
+ALTER TABLE public.journal_entries
+ DROP CONSTRAINT IF EXISTS journal_entries_source_type_check;
+
+ALTER TABLE public.journal_entries
+ ADD CONSTRAINT journal_entries_source_type_check
+ CHECK (source_type IN (
+ 'manual', 'bank_transaction', 'invoice_created',
+ 'invoice_paid', 'credit_note', 'salary_payment',
+ 'opening_balance', 'year_end',
+ 'storno', 'correction', 'import', 'system'
+ ));
+
+-- Indexes for the new FK columns
+CREATE INDEX IF NOT EXISTS idx_journal_entries_reversed_by_id ON public.journal_entries (reversed_by_id);
+CREATE INDEX IF NOT EXISTS idx_journal_entries_reverses_id ON public.journal_entries (reverses_id);
+CREATE INDEX IF NOT EXISTS idx_journal_entries_correction_of_id ON public.journal_entries (correction_of_id);
+
+-- =============================================================================
+-- 3. journal_entry_lines: Add dimension columns
+-- =============================================================================
+
+-- Decoupled tax code reference
+ALTER TABLE public.journal_entry_lines
+ ADD COLUMN IF NOT EXISTS tax_code text;
+
+-- Kostnadsställe dimension
+ALTER TABLE public.journal_entry_lines
+ ADD COLUMN IF NOT EXISTS cost_center text;
+
+-- Projekt dimension
+ALTER TABLE public.journal_entry_lines
+ ADD COLUMN IF NOT EXISTS project text;
+
+CREATE INDEX IF NOT EXISTS idx_journal_entry_lines_tax_code ON public.journal_entry_lines (tax_code);
+CREATE INDEX IF NOT EXISTS idx_journal_entry_lines_cost_center ON public.journal_entry_lines (cost_center);
+CREATE INDEX IF NOT EXISTS idx_journal_entry_lines_project ON public.journal_entry_lines (project);
+
+-- =============================================================================
+-- 4. fiscal_periods: Add lock and retention columns
+-- =============================================================================
+
+-- Period lock timestamp (separate from is_closed)
+ALTER TABLE public.fiscal_periods
+ ADD COLUMN IF NOT EXISTS locked_at timestamptz;
+
+-- Auto-calculated: period_end + 7 years
+ALTER TABLE public.fiscal_periods
+ ADD COLUMN IF NOT EXISTS retention_expires_at date;
diff --git a/supabase/migrations/20240101000012_tax_codes.sql b/supabase/migrations/20240101000012_tax_codes.sql
new file mode 100644
index 00000000..e283a41b
--- /dev/null
+++ b/supabase/migrations/20240101000012_tax_codes.sql
@@ -0,0 +1,123 @@
+-- Migration 12: Tax Code Engine
+-- Decoupled tax codes for momsdeklaration mapping
+
+-- =============================================================================
+-- 1. tax_codes table
+-- =============================================================================
+CREATE TABLE public.tax_codes (
+ id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id uuid REFERENCES auth.users ON DELETE CASCADE,
+ code text NOT NULL,
+ description text NOT NULL,
+ rate numeric NOT NULL DEFAULT 0,
+
+ -- Momsdeklaration ruta mapping
+ moms_basis_boxes text[] DEFAULT '{}', -- e.g. {'10'} for 25% basis
+ moms_tax_boxes text[] DEFAULT '{}', -- e.g. {'05'} for 25% output VAT
+ moms_input_boxes text[] DEFAULT '{}', -- e.g. {'48'} for input VAT
+
+ -- Classification flags
+ is_output_vat boolean DEFAULT false,
+ is_reverse_charge boolean DEFAULT false,
+ is_eu boolean DEFAULT false,
+ is_export boolean DEFAULT false,
+ is_oss boolean DEFAULT false,
+ is_system boolean DEFAULT false,
+
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+
+ -- System codes have NULL user_id; user codes have unique code per user
+ UNIQUE (user_id, code)
+);
+
+ALTER TABLE public.tax_codes ENABLE ROW LEVEL SECURITY;
+
+-- Users can see their own + system (user_id IS NULL) codes
+CREATE POLICY "tax_codes_select" ON public.tax_codes
+ FOR SELECT USING (auth.uid() = user_id OR user_id IS NULL);
+
+CREATE POLICY "tax_codes_insert" ON public.tax_codes
+ FOR INSERT WITH CHECK (auth.uid() = user_id);
+
+CREATE POLICY "tax_codes_update" ON public.tax_codes
+ FOR UPDATE USING (auth.uid() = user_id);
+
+CREATE POLICY "tax_codes_delete" ON public.tax_codes
+ FOR DELETE USING (auth.uid() = user_id);
+
+CREATE INDEX idx_tax_codes_user_id ON public.tax_codes (user_id);
+CREATE INDEX idx_tax_codes_code ON public.tax_codes (code);
+
+CREATE TRIGGER tax_codes_updated_at
+ BEFORE UPDATE ON public.tax_codes
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+-- =============================================================================
+-- 2. Seed system tax codes (12 standard Swedish tax codes)
+-- =============================================================================
+INSERT INTO public.tax_codes (user_id, code, description, rate, moms_basis_boxes, moms_tax_boxes, moms_input_boxes, is_output_vat, is_reverse_charge, is_eu, is_export, is_oss, is_system)
+VALUES
+ -- Output VAT (utgående moms)
+ (NULL, 'MP1', 'Utgående moms 25%', 0.25, '{10}', '{05}', '{}', true, false, false, false, false, true),
+ (NULL, 'MP2', 'Utgående moms 12%', 0.12, '{11}', '{06}', '{}', true, false, false, false, false, true),
+ (NULL, 'MP3', 'Utgående moms 6%', 0.06, '{12}', '{07}', '{}', true, false, false, false, false, true),
+
+ -- Input VAT (ingående moms)
+ (NULL, 'MPI', 'Ingående moms 25%', 0.25, '{}', '{}', '{48}', false, false, false, false, false, true),
+ (NULL, 'MPI12', 'Ingående moms 12%', 0.12, '{}', '{}', '{48}', false, false, false, false, false, true),
+ (NULL, 'MPI6', 'Ingående moms 6%', 0.06, '{}', '{}', '{48}', false, false, false, false, false, true),
+
+ -- EU / International
+ (NULL, 'IV', 'Intra-EU förvärv (omvänd moms)', 0.25, '{20,21}', '{30,31}', '{48}', false, true, true, false, false, true),
+ (NULL, 'EUS', 'EU försäljning (omvänd moms)', 0, '{39}', '{}', '{}', false, true, true, false, false, true),
+ (NULL, 'IP', 'Import (tull/moms)', 0.25, '{22}', '{32}', '{48}', false, false, false, false, false, true),
+ (NULL, 'EXP', 'Export utanför EU', 0, '{40}', '{}', '{}', false, false, false, true, false, true),
+
+ -- OSS (One Stop Shop)
+ (NULL, 'OSS', 'OSS försäljning EU konsument', 0, '{}', '{}', '{}', false, false, true, false, true, true),
+
+ -- Exempt
+ (NULL, 'NONE', 'Momsfritt', 0, '{}', '{}', '{}', false, false, false, false, false, true);
+
+-- =============================================================================
+-- 3. Function to copy system tax codes to user scope
+-- =============================================================================
+CREATE OR REPLACE FUNCTION public.seed_tax_codes_for_user(p_user_id uuid)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_count integer;
+BEGIN
+ -- Only seed if user has no existing tax codes
+ SELECT count(*) INTO v_count
+ FROM public.tax_codes
+ WHERE user_id = p_user_id;
+
+ IF v_count > 0 THEN
+ RETURN;
+ END IF;
+
+ INSERT INTO public.tax_codes (user_id, code, description, rate, moms_basis_boxes, moms_tax_boxes, moms_input_boxes, is_output_vat, is_reverse_charge, is_eu, is_export, is_oss, is_system)
+ SELECT
+ p_user_id,
+ code,
+ description,
+ rate,
+ moms_basis_boxes,
+ moms_tax_boxes,
+ moms_input_boxes,
+ is_output_vat,
+ is_reverse_charge,
+ is_eu,
+ is_export,
+ is_oss,
+ false -- user copies are NOT system
+ FROM public.tax_codes
+ WHERE user_id IS NULL AND is_system = true;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.seed_tax_codes_for_user(uuid) TO authenticated;
diff --git a/supabase/migrations/20240101000013_document_archive.sql b/supabase/migrations/20240101000013_document_archive.sql
new file mode 100644
index 00000000..7c2736e2
--- /dev/null
+++ b/supabase/migrations/20240101000013_document_archive.sql
@@ -0,0 +1,63 @@
+-- Migration 13: Document Archive
+-- WORM-style document storage with hash integrity and version chain
+
+-- =============================================================================
+-- 1. document_attachments table
+-- =============================================================================
+CREATE TABLE public.document_attachments (
+ id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id uuid REFERENCES auth.users ON DELETE CASCADE NOT NULL,
+
+ -- Storage
+ storage_path text NOT NULL,
+ file_name text NOT NULL,
+ file_size_bytes bigint,
+ mime_type text,
+
+ -- Integrity
+ sha256_hash text NOT NULL,
+
+ -- Version chain (WORM: Write Once, Read Many)
+ version integer NOT NULL DEFAULT 1,
+ original_id uuid REFERENCES public.document_attachments(id) ON DELETE SET NULL,
+ superseded_by_id uuid REFERENCES public.document_attachments(id) ON DELETE SET NULL,
+ is_current_version boolean NOT NULL DEFAULT true,
+
+ -- Digitization metadata
+ uploaded_by uuid REFERENCES auth.users ON DELETE SET NULL,
+ upload_source text CHECK (upload_source IN (
+ 'camera', 'file_upload', 'email', 'e_invoice', 'scan', 'api', 'system'
+ )),
+ digitization_date timestamptz DEFAULT now(),
+
+ -- Linkage to journal entries (ON DELETE RESTRICT prevents deletion of linked entries)
+ journal_entry_id uuid REFERENCES public.journal_entries(id) ON DELETE RESTRICT,
+ journal_entry_line_id uuid REFERENCES public.journal_entry_lines(id) ON DELETE RESTRICT,
+
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+ALTER TABLE public.document_attachments ENABLE ROW LEVEL SECURITY;
+
+-- RLS: select, insert, update for owner. NO DELETE policy (handled by trigger).
+CREATE POLICY "document_attachments_select" ON public.document_attachments
+ FOR SELECT USING (auth.uid() = user_id);
+
+CREATE POLICY "document_attachments_insert" ON public.document_attachments
+ FOR INSERT WITH CHECK (auth.uid() = user_id);
+
+CREATE POLICY "document_attachments_update" ON public.document_attachments
+ FOR UPDATE USING (auth.uid() = user_id);
+
+-- Intentionally NO DELETE policy -- deletion is blocked by trigger
+
+CREATE INDEX idx_document_attachments_user_id ON public.document_attachments (user_id);
+CREATE INDEX idx_document_attachments_journal_entry_id ON public.document_attachments (journal_entry_id);
+CREATE INDEX idx_document_attachments_journal_entry_line_id ON public.document_attachments (journal_entry_line_id);
+CREATE INDEX idx_document_attachments_sha256_hash ON public.document_attachments (sha256_hash);
+CREATE INDEX idx_document_attachments_original_id ON public.document_attachments (original_id);
+
+CREATE TRIGGER document_attachments_updated_at
+ BEFORE UPDATE ON public.document_attachments
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
diff --git a/supabase/migrations/20240101000014_audit_log.sql b/supabase/migrations/20240101000014_audit_log.sql
new file mode 100644
index 00000000..7b3b21fb
--- /dev/null
+++ b/supabase/migrations/20240101000014_audit_log.sql
@@ -0,0 +1,59 @@
+-- Migration 14: Audit Log
+-- Append-only audit log for all compliance-critical mutations
+
+-- =============================================================================
+-- 1. audit_log table
+-- =============================================================================
+CREATE TABLE public.audit_log (
+ id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id uuid NOT NULL, -- No FK cascade: survives user deletion
+ action text NOT NULL CHECK (action IN (
+ 'INSERT', 'UPDATE', 'DELETE',
+ 'COMMIT', 'REVERSE', 'CORRECT',
+ 'LOCK_PERIOD', 'CLOSE_PERIOD',
+ 'DOCUMENT_DELETE_BLOCKED', 'RETENTION_BLOCK',
+ 'SECURITY_EVENT'
+ )),
+ table_name text,
+ record_id uuid,
+ actor_id uuid,
+ old_state jsonb,
+ new_state jsonb,
+ description text,
+ created_at timestamptz NOT NULL DEFAULT now()
+ -- Intentionally NO updated_at: append-only
+);
+
+ALTER TABLE public.audit_log ENABLE ROW LEVEL SECURITY;
+
+-- Users can only read their own audit log entries
+CREATE POLICY "audit_log_select" ON public.audit_log
+ FOR SELECT USING (auth.uid() = user_id);
+
+-- No INSERT policy for normal users -- audit log is written by SECURITY DEFINER triggers
+-- No UPDATE or DELETE policies -- immutability enforced by triggers below
+
+CREATE INDEX idx_audit_log_user_id ON public.audit_log (user_id);
+CREATE INDEX idx_audit_log_table_record ON public.audit_log (table_name, record_id);
+CREATE INDEX idx_audit_log_action ON public.audit_log (action);
+CREATE INDEX idx_audit_log_created_at ON public.audit_log (created_at);
+
+-- =============================================================================
+-- 2. Immutability triggers: block UPDATE and DELETE on audit_log
+-- =============================================================================
+CREATE OR REPLACE FUNCTION public.audit_log_immutable()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ RAISE EXCEPTION 'Audit log entries cannot be modified or deleted';
+END;
+$$;
+
+CREATE TRIGGER audit_log_no_update
+ BEFORE UPDATE ON public.audit_log
+ FOR EACH ROW EXECUTE FUNCTION public.audit_log_immutable();
+
+CREATE TRIGGER audit_log_no_delete
+ BEFORE DELETE ON public.audit_log
+ FOR EACH ROW EXECUTE FUNCTION public.audit_log_immutable();
diff --git a/supabase/migrations/20240101000015_dimensions.sql b/supabase/migrations/20240101000015_dimensions.sql
new file mode 100644
index 00000000..f81aa704
--- /dev/null
+++ b/supabase/migrations/20240101000015_dimensions.sql
@@ -0,0 +1,68 @@
+-- Migration 15: Dimensions
+-- Cost centers (kostnadsställen) and projects for journal entry lines
+
+-- =============================================================================
+-- 1. cost_centers table
+-- =============================================================================
+CREATE TABLE public.cost_centers (
+ id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id uuid REFERENCES auth.users ON DELETE CASCADE NOT NULL,
+ code text NOT NULL,
+ name text NOT NULL,
+ is_active boolean NOT NULL DEFAULT true,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+
+ UNIQUE (user_id, code)
+);
+
+ALTER TABLE public.cost_centers ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "cost_centers_select" ON public.cost_centers
+ FOR SELECT USING (auth.uid() = user_id);
+CREATE POLICY "cost_centers_insert" ON public.cost_centers
+ FOR INSERT WITH CHECK (auth.uid() = user_id);
+CREATE POLICY "cost_centers_update" ON public.cost_centers
+ FOR UPDATE USING (auth.uid() = user_id);
+CREATE POLICY "cost_centers_delete" ON public.cost_centers
+ FOR DELETE USING (auth.uid() = user_id);
+
+CREATE INDEX idx_cost_centers_user_id ON public.cost_centers (user_id);
+
+CREATE TRIGGER cost_centers_updated_at
+ BEFORE UPDATE ON public.cost_centers
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+-- =============================================================================
+-- 2. projects table
+-- =============================================================================
+CREATE TABLE public.projects (
+ id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id uuid REFERENCES auth.users ON DELETE CASCADE NOT NULL,
+ code text NOT NULL,
+ name text NOT NULL,
+ is_active boolean NOT NULL DEFAULT true,
+ start_date date,
+ end_date date,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+
+ UNIQUE (user_id, code)
+);
+
+ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "projects_select" ON public.projects
+ FOR SELECT USING (auth.uid() = user_id);
+CREATE POLICY "projects_insert" ON public.projects
+ FOR INSERT WITH CHECK (auth.uid() = user_id);
+CREATE POLICY "projects_update" ON public.projects
+ FOR UPDATE USING (auth.uid() = user_id);
+CREATE POLICY "projects_delete" ON public.projects
+ FOR DELETE USING (auth.uid() = user_id);
+
+CREATE INDEX idx_projects_user_id ON public.projects (user_id);
+
+CREATE TRIGGER projects_updated_at
+ BEFORE UPDATE ON public.projects
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
diff --git a/supabase/migrations/20240101000016_voucher_sequences.sql b/supabase/migrations/20240101000016_voucher_sequences.sql
new file mode 100644
index 00000000..f37d2191
--- /dev/null
+++ b/supabase/migrations/20240101000016_voucher_sequences.sql
@@ -0,0 +1,153 @@
+-- Migration 16: Voucher Sequence Hardening
+-- Concurrent-safe voucher numbering and balance constraint
+
+-- =============================================================================
+-- 1. voucher_sequences table
+-- =============================================================================
+CREATE TABLE public.voucher_sequences (
+ id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+ user_id uuid REFERENCES auth.users ON DELETE CASCADE NOT NULL,
+ fiscal_period_id uuid REFERENCES public.fiscal_periods(id) ON DELETE CASCADE NOT NULL,
+ voucher_series text NOT NULL DEFAULT 'A',
+ last_number integer NOT NULL DEFAULT 0,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+
+ UNIQUE (user_id, fiscal_period_id, voucher_series)
+);
+
+ALTER TABLE public.voucher_sequences ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "voucher_sequences_select" ON public.voucher_sequences
+ FOR SELECT USING (auth.uid() = user_id);
+CREATE POLICY "voucher_sequences_insert" ON public.voucher_sequences
+ FOR INSERT WITH CHECK (auth.uid() = user_id);
+CREATE POLICY "voucher_sequences_update" ON public.voucher_sequences
+ FOR UPDATE USING (auth.uid() = user_id);
+
+CREATE TRIGGER voucher_sequences_updated_at
+ BEFORE UPDATE ON public.voucher_sequences
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+-- =============================================================================
+-- 2. Replace next_voucher_number() with concurrent-safe version
+-- Uses INSERT ON CONFLICT + UPDATE RETURNING for row-level locking
+-- =============================================================================
+CREATE OR REPLACE FUNCTION public.next_voucher_number(
+ p_user_id uuid,
+ p_fiscal_period_id uuid,
+ p_series text DEFAULT 'A'
+)
+RETURNS integer
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_next integer;
+BEGIN
+ -- INSERT or UPDATE with row-level lock (prevents race conditions)
+ INSERT INTO public.voucher_sequences (user_id, fiscal_period_id, voucher_series, last_number)
+ VALUES (p_user_id, p_fiscal_period_id, p_series, 1)
+ ON CONFLICT (user_id, fiscal_period_id, voucher_series)
+ DO UPDATE SET
+ last_number = public.voucher_sequences.last_number + 1,
+ updated_at = now()
+ RETURNING last_number INTO v_next;
+
+ RETURN v_next;
+END;
+$$;
+
+-- =============================================================================
+-- 3. Balance constraint trigger for posted entries
+-- Validates debit == credit (DEFERRABLE to allow batch line inserts)
+-- =============================================================================
+CREATE OR REPLACE FUNCTION public.check_journal_entry_balance()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_total_debit numeric;
+ v_total_credit numeric;
+ v_status text;
+ v_entry_id uuid;
+BEGIN
+ -- Determine the entry ID based on trigger context
+ IF TG_TABLE_NAME = 'journal_entries' THEN
+ v_entry_id := NEW.id;
+ v_status := NEW.status;
+ ELSE
+ v_entry_id := NEW.journal_entry_id;
+ SELECT status INTO v_status
+ FROM public.journal_entries
+ WHERE id = v_entry_id;
+ END IF;
+
+ -- Only enforce on posted entries
+ IF v_status != 'posted' THEN
+ RETURN NEW;
+ END IF;
+
+ SELECT COALESCE(SUM(debit_amount), 0), COALESCE(SUM(credit_amount), 0)
+ INTO v_total_debit, v_total_credit
+ FROM public.journal_entry_lines
+ WHERE journal_entry_id = v_entry_id;
+
+ IF ROUND(v_total_debit, 2) != ROUND(v_total_credit, 2) THEN
+ RAISE EXCEPTION 'Journal entry % is not balanced: debit=% credit=%',
+ v_entry_id, v_total_debit, v_total_credit;
+ END IF;
+
+ IF v_total_debit = 0 THEN
+ RAISE EXCEPTION 'Journal entry % has zero total', v_entry_id;
+ END IF;
+
+ RETURN NEW;
+END;
+$$;
+
+-- Apply as DEFERRABLE constraint trigger on journal_entries status change
+CREATE CONSTRAINT TRIGGER check_balance_on_post
+ AFTER UPDATE ON public.journal_entries
+ DEFERRABLE INITIALLY DEFERRED
+ FOR EACH ROW
+ WHEN (NEW.status = 'posted' AND OLD.status = 'draft')
+ EXECUTE FUNCTION public.check_journal_entry_balance();
+
+-- =============================================================================
+-- 4. Function to detect voucher gaps for compliance reporting
+-- =============================================================================
+CREATE OR REPLACE FUNCTION public.detect_voucher_gaps(
+ p_user_id uuid,
+ p_fiscal_period_id uuid,
+ p_series text DEFAULT 'A'
+)
+RETURNS TABLE (
+ gap_start integer,
+ gap_end integer
+)
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+BEGIN
+ RETURN QUERY
+ WITH numbered AS (
+ SELECT voucher_number,
+ LEAD(voucher_number) OVER (ORDER BY voucher_number) AS next_number
+ FROM public.journal_entries
+ WHERE user_id = p_user_id
+ AND fiscal_period_id = p_fiscal_period_id
+ AND voucher_series = p_series
+ AND status != 'draft'
+ ORDER BY voucher_number
+ )
+ SELECT
+ voucher_number + 1 AS gap_start,
+ next_number - 1 AS gap_end
+ FROM numbered
+ WHERE next_number IS NOT NULL
+ AND next_number > voucher_number + 1;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.detect_voucher_gaps(uuid, uuid, text) TO authenticated;
diff --git a/supabase/migrations/20240101000017_enforcement_triggers.sql b/supabase/migrations/20240101000017_enforcement_triggers.sql
new file mode 100644
index 00000000..b3a208cd
--- /dev/null
+++ b/supabase/migrations/20240101000017_enforcement_triggers.sql
@@ -0,0 +1,293 @@
+-- Migration 17: Enforcement Triggers
+-- Critical compliance triggers for Bokföringslagen
+
+-- =============================================================================
+-- 1. enforce_journal_entry_immutability()
+-- BEFORE UPDATE/DELETE on journal_entries
+-- Allows: draft→draft edits, draft→posted commit, posted→reversed transition
+-- Blocks: all other updates/deletes on posted/reversed entries
+-- =============================================================================
+CREATE OR REPLACE FUNCTION public.enforce_journal_entry_immutability()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ IF TG_OP = 'DELETE' THEN
+ -- Allow deleting drafts
+ IF OLD.status = 'draft' THEN
+ RETURN OLD;
+ END IF;
+ RAISE EXCEPTION 'Cannot delete a % journal entry (id: %)', OLD.status, OLD.id;
+ END IF;
+
+ -- TG_OP = 'UPDATE'
+ -- Allow: draft → draft (editing a draft)
+ IF OLD.status = 'draft' AND NEW.status = 'draft' THEN
+ RETURN NEW;
+ END IF;
+
+ -- Allow: draft → posted (committing)
+ IF OLD.status = 'draft' AND NEW.status = 'posted' THEN
+ RETURN NEW;
+ END IF;
+
+ -- Allow: posted → reversed (storno reversal)
+ IF OLD.status = 'posted' AND NEW.status = 'reversed' THEN
+ -- Only allow setting reversed_by_id during this transition
+ IF NEW.description != OLD.description
+ OR NEW.entry_date != OLD.entry_date
+ OR NEW.fiscal_period_id != OLD.fiscal_period_id
+ OR NEW.voucher_number != OLD.voucher_number THEN
+ RAISE EXCEPTION 'Cannot modify fields of a posted entry during reversal (id: %)', OLD.id;
+ END IF;
+ RETURN NEW;
+ END IF;
+
+ -- Block all other transitions
+ RAISE EXCEPTION 'Cannot modify a % journal entry (id: %). Committed entries are immutable per Bokföringslagen.',
+ OLD.status, OLD.id;
+END;
+$$;
+
+CREATE TRIGGER enforce_journal_entry_immutability
+ BEFORE UPDATE OR DELETE ON public.journal_entries
+ FOR EACH ROW EXECUTE FUNCTION public.enforce_journal_entry_immutability();
+
+-- =============================================================================
+-- 2. enforce_journal_entry_line_immutability()
+-- BEFORE UPDATE/DELETE on journal_entry_lines
+-- Blocks modifications to lines of posted/reversed entries
+-- =============================================================================
+CREATE OR REPLACE FUNCTION public.enforce_journal_entry_line_immutability()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_status text;
+BEGIN
+ -- Get the parent entry status
+ SELECT status INTO v_status
+ FROM public.journal_entries
+ WHERE id = COALESCE(OLD.journal_entry_id, NEW.journal_entry_id);
+
+ -- Allow modifications to lines of draft entries
+ IF v_status = 'draft' THEN
+ IF TG_OP = 'DELETE' THEN
+ RETURN OLD;
+ END IF;
+ RETURN NEW;
+ END IF;
+
+ -- Block modifications to lines of posted/reversed entries
+ RAISE EXCEPTION 'Cannot % lines of a % journal entry. Committed entries are immutable per Bokföringslagen.',
+ TG_OP, v_status;
+END;
+$$;
+
+CREATE TRIGGER enforce_journal_entry_line_immutability
+ BEFORE UPDATE OR DELETE ON public.journal_entry_lines
+ FOR EACH ROW EXECUTE FUNCTION public.enforce_journal_entry_line_immutability();
+
+-- =============================================================================
+-- 3. enforce_period_lock()
+-- BEFORE INSERT/UPDATE on journal_entries
+-- Rejects writes when fiscal_periods.is_closed=true OR locked_at IS NOT NULL
+-- =============================================================================
+CREATE OR REPLACE FUNCTION public.enforce_period_lock()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_is_closed boolean;
+ v_locked_at timestamptz;
+ v_period_name text;
+BEGIN
+ SELECT is_closed, locked_at, name
+ INTO v_is_closed, v_locked_at, v_period_name
+ FROM public.fiscal_periods
+ WHERE id = NEW.fiscal_period_id;
+
+ IF v_is_closed OR v_locked_at IS NOT NULL THEN
+ RAISE EXCEPTION 'Cannot write to locked/closed fiscal period "%" (is_closed=%, locked_at=%)',
+ v_period_name, v_is_closed, v_locked_at;
+ END IF;
+
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER enforce_period_lock
+ BEFORE INSERT OR UPDATE ON public.journal_entries
+ FOR EACH ROW EXECUTE FUNCTION public.enforce_period_lock();
+
+-- =============================================================================
+-- 4. enforce_period_lock_documents()
+-- BEFORE INSERT/UPDATE on document_attachments
+-- Blocks doc attachment to entries in locked periods
+-- =============================================================================
+CREATE OR REPLACE FUNCTION public.enforce_period_lock_documents()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+DECLARE
+ v_is_closed boolean;
+ v_locked_at timestamptz;
+BEGIN
+ -- Only check if linking to a journal entry
+ IF NEW.journal_entry_id IS NULL THEN
+ RETURN NEW;
+ END IF;
+
+ SELECT fp.is_closed, fp.locked_at
+ INTO v_is_closed, v_locked_at
+ FROM public.journal_entries je
+ JOIN public.fiscal_periods fp ON fp.id = je.fiscal_period_id
+ WHERE je.id = NEW.journal_entry_id;
+
+ IF v_is_closed OR v_locked_at IS NOT NULL THEN
+ RAISE EXCEPTION 'Cannot attach documents to entries in a locked/closed fiscal period';
+ END IF;
+
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER enforce_period_lock_documents
+ BEFORE INSERT OR UPDATE ON public.document_attachments
+ FOR EACH ROW EXECUTE FUNCTION public.enforce_period_lock_documents();
+
+-- =============================================================================
+-- 5. block_document_deletion()
+-- BEFORE DELETE on document_attachments
+-- Blocks deletion if linked to committed entry or within retention window
+-- =============================================================================
+CREATE OR REPLACE FUNCTION public.block_document_deletion()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_entry_status text;
+ v_retention_expires date;
+BEGIN
+ -- Check if linked to a committed journal entry
+ IF OLD.journal_entry_id IS NOT NULL THEN
+ SELECT je.status INTO v_entry_status
+ FROM public.journal_entries je
+ WHERE je.id = OLD.journal_entry_id;
+
+ IF v_entry_status IN ('posted', 'reversed') THEN
+ -- Log the blocked attempt
+ INSERT INTO public.audit_log (user_id, action, table_name, record_id, description)
+ VALUES (OLD.user_id, 'DOCUMENT_DELETE_BLOCKED', 'document_attachments', OLD.id,
+ 'Attempted deletion of document linked to ' || v_entry_status || ' journal entry ' || OLD.journal_entry_id);
+
+ RAISE EXCEPTION 'Cannot delete document linked to a % journal entry (Bokföringslagen)',
+ v_entry_status;
+ END IF;
+ END IF;
+
+ -- Check retention window
+ IF OLD.journal_entry_id IS NOT NULL THEN
+ SELECT fp.retention_expires_at INTO v_retention_expires
+ FROM public.journal_entries je
+ JOIN public.fiscal_periods fp ON fp.id = je.fiscal_period_id
+ WHERE je.id = OLD.journal_entry_id;
+
+ IF v_retention_expires IS NOT NULL AND v_retention_expires > CURRENT_DATE THEN
+ INSERT INTO public.audit_log (user_id, action, table_name, record_id, description)
+ VALUES (OLD.user_id, 'RETENTION_BLOCK', 'document_attachments', OLD.id,
+ 'Attempted deletion within retention period (expires ' || v_retention_expires || ')');
+
+ RAISE EXCEPTION 'Cannot delete document within 7-year retention period (expires %)',
+ v_retention_expires;
+ END IF;
+ END IF;
+
+ RETURN OLD;
+END;
+$$;
+
+CREATE TRIGGER block_document_deletion
+ BEFORE DELETE ON public.document_attachments
+ FOR EACH ROW EXECUTE FUNCTION public.block_document_deletion();
+
+-- =============================================================================
+-- 6. enforce_retention_journal_entries()
+-- BEFORE DELETE on journal_entries
+-- Blocks deletion within 7-year retention window
+-- =============================================================================
+CREATE OR REPLACE FUNCTION public.enforce_retention_journal_entries()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_retention_expires date;
+BEGIN
+ SELECT fp.retention_expires_at INTO v_retention_expires
+ FROM public.fiscal_periods fp
+ WHERE fp.id = OLD.fiscal_period_id;
+
+ IF v_retention_expires IS NOT NULL AND v_retention_expires > CURRENT_DATE THEN
+ INSERT INTO public.audit_log (user_id, action, table_name, record_id, description)
+ VALUES (OLD.user_id, 'RETENTION_BLOCK', 'journal_entries', OLD.id,
+ 'Attempted deletion within retention period (expires ' || v_retention_expires || ')');
+
+ RAISE EXCEPTION 'Cannot delete journal entry within 7-year retention period (expires %)',
+ v_retention_expires;
+ END IF;
+
+ RETURN OLD;
+END;
+$$;
+
+-- Note: This trigger must fire BEFORE the immutability trigger so we check retention first
+CREATE TRIGGER enforce_retention_journal_entries
+ BEFORE DELETE ON public.journal_entries
+ FOR EACH ROW EXECUTE FUNCTION public.enforce_retention_journal_entries();
+
+-- =============================================================================
+-- 7. set_committed_at()
+-- BEFORE UPDATE on journal_entries
+-- Auto-sets committed_at = now() on draft→posted transition
+-- =============================================================================
+CREATE OR REPLACE FUNCTION public.set_committed_at()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ IF OLD.status = 'draft' AND NEW.status = 'posted' THEN
+ NEW.committed_at := now();
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER set_committed_at
+ BEFORE UPDATE ON public.journal_entries
+ FOR EACH ROW EXECUTE FUNCTION public.set_committed_at();
+
+-- =============================================================================
+-- 8. calculate_retention_expiry()
+-- BEFORE INSERT/UPDATE on fiscal_periods
+-- Auto-sets retention_expires_at = period_end + 7 years
+-- =============================================================================
+CREATE OR REPLACE FUNCTION public.calculate_retention_expiry()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ NEW.retention_expires_at := NEW.period_end + INTERVAL '7 years';
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER calculate_retention_expiry
+ BEFORE INSERT OR UPDATE ON public.fiscal_periods
+ FOR EACH ROW EXECUTE FUNCTION public.calculate_retention_expiry();
+
+-- Backfill existing fiscal periods
+UPDATE public.fiscal_periods
+SET retention_expires_at = period_end + INTERVAL '7 years'
+WHERE retention_expires_at IS NULL;
diff --git a/supabase/migrations/20240101000018_audit_triggers.sql b/supabase/migrations/20240101000018_audit_triggers.sql
new file mode 100644
index 00000000..aedde3c1
--- /dev/null
+++ b/supabase/migrations/20240101000018_audit_triggers.sql
@@ -0,0 +1,116 @@
+-- Migration 18: Audit Logging Triggers
+-- Generic audit log writer with AFTER triggers on compliance-critical tables
+
+-- =============================================================================
+-- 1. Generic write_audit_log() SECURITY DEFINER function
+-- Detects action type from TG_OP and state transitions
+-- =============================================================================
+CREATE OR REPLACE FUNCTION public.write_audit_log()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ v_user_id uuid;
+ v_action text;
+ v_old_state jsonb;
+ v_new_state jsonb;
+ v_record_id uuid;
+ v_desc text;
+BEGIN
+ -- Determine user_id from the record
+ IF TG_OP = 'DELETE' THEN
+ v_user_id := OLD.user_id;
+ v_record_id := OLD.id;
+ v_old_state := to_jsonb(OLD);
+ v_new_state := NULL;
+ v_action := 'DELETE';
+ v_desc := 'Deleted ' || TG_TABLE_NAME || ' record';
+ ELSIF TG_OP = 'INSERT' THEN
+ v_user_id := NEW.user_id;
+ v_record_id := NEW.id;
+ v_old_state := NULL;
+ v_new_state := to_jsonb(NEW);
+ v_action := 'INSERT';
+ v_desc := 'Created ' || TG_TABLE_NAME || ' record';
+ ELSIF TG_OP = 'UPDATE' THEN
+ v_user_id := COALESCE(NEW.user_id, OLD.user_id);
+ v_record_id := COALESCE(NEW.id, OLD.id);
+ v_old_state := to_jsonb(OLD);
+ v_new_state := to_jsonb(NEW);
+ v_action := 'UPDATE';
+ v_desc := 'Updated ' || TG_TABLE_NAME || ' record';
+
+ -- Detect specific state transitions for journal_entries
+ IF TG_TABLE_NAME = 'journal_entries' THEN
+ IF OLD.status = 'draft' AND NEW.status = 'posted' THEN
+ v_action := 'COMMIT';
+ v_desc := 'Committed journal entry ' || NEW.voucher_series || NEW.voucher_number;
+ ELSIF OLD.status = 'posted' AND NEW.status = 'reversed' THEN
+ v_action := 'REVERSE';
+ v_desc := 'Reversed journal entry ' || OLD.voucher_series || OLD.voucher_number;
+ END IF;
+ END IF;
+
+ -- Detect period lock/close
+ IF TG_TABLE_NAME = 'fiscal_periods' THEN
+ IF (OLD.locked_at IS NULL AND NEW.locked_at IS NOT NULL) THEN
+ v_action := 'LOCK_PERIOD';
+ v_desc := 'Locked fiscal period "' || NEW.name || '"';
+ ELSIF (NOT OLD.is_closed AND NEW.is_closed) THEN
+ v_action := 'CLOSE_PERIOD';
+ v_desc := 'Closed fiscal period "' || NEW.name || '"';
+ END IF;
+ END IF;
+ END IF;
+
+ -- Write to audit log (bypass RLS via SECURITY DEFINER)
+ INSERT INTO public.audit_log (user_id, action, table_name, record_id, actor_id, old_state, new_state, description)
+ VALUES (v_user_id, v_action, TG_TABLE_NAME, v_record_id, v_user_id, v_old_state, v_new_state, v_desc);
+
+ -- Return appropriate value
+ IF TG_OP = 'DELETE' THEN
+ RETURN OLD;
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+-- =============================================================================
+-- 2. AFTER triggers on compliance-critical tables
+-- =============================================================================
+
+-- journal_entries
+CREATE TRIGGER audit_journal_entries
+ AFTER INSERT OR UPDATE OR DELETE ON public.journal_entries
+ FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
+
+-- journal_entry_lines
+CREATE TRIGGER audit_journal_entry_lines
+ AFTER INSERT OR UPDATE OR DELETE ON public.journal_entry_lines
+ FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
+
+-- chart_of_accounts
+CREATE TRIGGER audit_chart_of_accounts
+ AFTER INSERT OR UPDATE OR DELETE ON public.chart_of_accounts
+ FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
+
+-- document_attachments
+CREATE TRIGGER audit_document_attachments
+ AFTER INSERT OR UPDATE OR DELETE ON public.document_attachments
+ FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
+
+-- fiscal_periods
+CREATE TRIGGER audit_fiscal_periods
+ AFTER INSERT OR UPDATE OR DELETE ON public.fiscal_periods
+ FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
+
+-- company_settings
+CREATE TRIGGER audit_company_settings
+ AFTER INSERT OR UPDATE OR DELETE ON public.company_settings
+ FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
+
+-- tax_codes
+CREATE TRIGGER audit_tax_codes
+ AFTER INSERT OR UPDATE OR DELETE ON public.tax_codes
+ FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
diff --git a/supabase/migrations/20240101000019_period_closing.sql b/supabase/migrations/20240101000019_period_closing.sql
new file mode 100644
index 00000000..bedfe3b3
--- /dev/null
+++ b/supabase/migrations/20240101000019_period_closing.sql
@@ -0,0 +1,58 @@
+-- Migration 19: Fiscal Period Closing Metadata
+-- Adds columns for year-end closing workflow and opening balance tracking
+
+-- =============================================================================
+-- 1. Add closing_entry_id — tracks which journal entry closed this period
+-- =============================================================================
+ALTER TABLE public.fiscal_periods
+ ADD COLUMN IF NOT EXISTS closing_entry_id uuid REFERENCES public.journal_entries(id);
+
+-- =============================================================================
+-- 2. Add opening_balance_entry_id — tracks which entry set opening balances
+-- =============================================================================
+ALTER TABLE public.fiscal_periods
+ ADD COLUMN IF NOT EXISTS opening_balance_entry_id uuid REFERENCES public.journal_entries(id);
+
+-- =============================================================================
+-- 3. Add previous_period_id — chain validation link
+-- =============================================================================
+ALTER TABLE public.fiscal_periods
+ ADD COLUMN IF NOT EXISTS previous_period_id uuid REFERENCES public.fiscal_periods(id);
+
+-- =============================================================================
+-- 4. Trigger: block modification of opening balance entries
+-- Once a fiscal period has opening_balance_entry_id set and the entry is posted,
+-- the opening_balance_entry_id cannot be changed.
+-- =============================================================================
+CREATE OR REPLACE FUNCTION public.enforce_opening_balance_immutability()
+RETURNS trigger
+LANGUAGE plpgsql
+AS $$
+BEGIN
+ -- Only check if opening_balance_entry_id is being changed
+ IF OLD.opening_balance_entry_id IS NOT NULL
+ AND OLD.opening_balances_set = true
+ AND NEW.opening_balance_entry_id IS DISTINCT FROM OLD.opening_balance_entry_id THEN
+ RAISE EXCEPTION 'Cannot modify opening_balance_entry_id on period "%" — opening balances are immutable once set',
+ OLD.name;
+ END IF;
+
+ -- Also block changing closing_entry_id once set
+ IF OLD.closing_entry_id IS NOT NULL
+ AND NEW.closing_entry_id IS DISTINCT FROM OLD.closing_entry_id THEN
+ RAISE EXCEPTION 'Cannot modify closing_entry_id on period "%" — year-end closing is immutable',
+ OLD.name;
+ END IF;
+
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER enforce_opening_balance_immutability
+ BEFORE UPDATE ON public.fiscal_periods
+ FOR EACH ROW EXECUTE FUNCTION public.enforce_opening_balance_immutability();
+
+-- Indexes for the new FK columns
+CREATE INDEX IF NOT EXISTS idx_fiscal_periods_closing_entry ON public.fiscal_periods (closing_entry_id);
+CREATE INDEX IF NOT EXISTS idx_fiscal_periods_opening_balance_entry ON public.fiscal_periods (opening_balance_entry_id);
+CREATE INDEX IF NOT EXISTS idx_fiscal_periods_previous_period ON public.fiscal_periods (previous_period_id);
diff --git a/supabase/migrations/20240101000020_extension_data.sql b/supabase/migrations/20240101000020_extension_data.sql
new file mode 100644
index 00000000..9654f7ce
--- /dev/null
+++ b/supabase/migrations/20240101000020_extension_data.sql
@@ -0,0 +1,64 @@
+-- ============================================================
+-- Extension Data & Event Log Tables
+-- Part 3: Event Bus & Extension Registry
+-- ============================================================
+
+-- Generic key-value store for extensions
+create table if not exists 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 default '{}',
+ created_at timestamptz default now(),
+ updated_at timestamptz default now(),
+ unique(user_id, extension_id, key)
+);
+
+-- RLS: users can only access their own extension data
+alter table extension_data enable row level security;
+
+create policy "Users can select own extension data"
+ on extension_data for select
+ using (auth.uid() = user_id);
+
+create policy "Users can insert own extension data"
+ on extension_data for insert
+ with check (auth.uid() = user_id);
+
+create policy "Users can update own extension data"
+ on extension_data for update
+ using (auth.uid() = user_id);
+
+create policy "Users can delete own extension data"
+ on extension_data for delete
+ using (auth.uid() = user_id);
+
+-- Auto-update updated_at
+create trigger extension_data_updated_at
+ before update on extension_data
+ for each row execute function update_updated_at();
+
+-- Append-only event log for observability
+create table if not exists event_log (
+ id uuid primary key default gen_random_uuid(),
+ user_id uuid references auth.users not null,
+ event_type text not null,
+ payload jsonb not null default '{}',
+ created_at timestamptz default now()
+);
+
+-- RLS: users can select and insert only (no update, no delete)
+alter table event_log enable row level security;
+
+create policy "Users can select own event log"
+ on event_log for select
+ using (auth.uid() = user_id);
+
+create policy "Users can insert own event log"
+ on event_log for insert
+ with check (auth.uid() = user_id);
+
+-- Index for querying by event type
+create index if not exists idx_event_log_user_type on event_log (user_id, event_type);
+create index if not exists idx_event_log_created_at on event_log (created_at);
diff --git a/tests/helpers.ts b/tests/helpers.ts
new file mode 100644
index 00000000..510aba58
--- /dev/null
+++ b/tests/helpers.ts
@@ -0,0 +1,264 @@
+/**
+ * Shared test helpers — mock factories and fixture builders
+ */
+import { vi } from 'vitest'
+import type {
+ Receipt,
+ Transaction,
+ FiscalPeriod,
+ JournalEntry,
+ JournalEntryLine,
+ DocumentAttachment,
+ TaxCode,
+} from '@/types'
+
+// ============================================================
+// Chainable Supabase mock
+// ============================================================
+
+/**
+ * Creates a deeply chainable mock that mirrors the Supabase client API.
+ *
+ * Usage:
+ * const { supabase, mockResult } = createMockSupabase()
+ * mockResult({ data: [...], error: null })
+ * const { data } = await supabase.from('table').select('*').eq('id', '1').single()
+ */
+export function createMockSupabase() {
+ // The value that terminal calls (.single(), .maybeSingle(), or the chain itself) resolve to
+ let pendingResult: { data: unknown; error: unknown; count?: number | null } = {
+ data: null,
+ error: null,
+ }
+
+ const mockResult = (result: {
+ data?: unknown
+ error?: unknown
+ count?: number | null
+ }) => {
+ pendingResult = {
+ data: result.data ?? null,
+ error: result.error ?? null,
+ count: result.count ?? null,
+ }
+ }
+
+ // Build a proxy that returns itself for any chained method call,
+ // and resolves to pendingResult when awaited.
+ const buildChain = (): unknown => {
+ const handler: ProxyHandler