From cdf1dcc4c8f38bdc96be8d05eb00e3d5122b8a2e Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 19 Feb 2026 09:48:02 +0100 Subject: [PATCH] New Base func --- .claude/settings.local.json | 5 +- app/(dashboard)/bookkeeping/page.tsx | 21 +- app/(dashboard)/bookkeeping/year-end/page.tsx | 699 +++++++++ app/api/banking/sync/route.ts | 29 +- .../fiscal-periods/[id]/close/route.ts | 26 + .../fiscal-periods/[id]/lock/route.ts | 26 + .../fiscal-periods/[id]/year-end/route.ts | 63 + app/api/customers/route.ts | 11 +- .../ai-categorization/settings/route.ts | 60 + .../ai-categorization/suggestions/route.ts | 92 ++ .../extensions/receipt-ocr/settings/route.ts | 61 + app/api/invoices/[id]/send/route.ts | 9 + app/api/invoices/route.ts | 16 +- app/api/receipts/[id]/confirm/route.ts | 29 +- app/api/receipts/[id]/match/route.ts | 20 +- app/api/receipts/upload/route.ts | 15 + app/api/transactions/[id]/categorize/route.ts | 14 + .../transactions/suggest-categories/route.ts | 29 +- dev_docs/ARCHITECTURE.md | 391 +++++ .../AI_CATEGORIZATION_EXTENSION.md | 381 +++++ .../base_architecture/PART1_IMPLEMENTATION.md | 169 ++ .../base_architecture/PART2_IMPLEMENTATION.md | 198 +++ .../base_architecture/PART3_IMPLEMENTATION.md | 235 +++ .../base_architecture/PART4_YEAR_END_UI.md | 251 +++ .../RECEIPT_OCR_EXTENSION.md | 260 ++++ extensions/ai-categorization/categorizer.ts | 269 ++++ extensions/ai-categorization/index.ts | 256 ++++ extensions/example-logger/index.ts | 34 + .../receipt-ocr/__tests__/index.test.ts | 256 ++++ extensions/receipt-ocr/index.ts | 322 ++++ lib/bookkeeping/__tests__/engine.test.ts | 64 + lib/bookkeeping/engine.ts | 283 +++- lib/core/audit/audit-service.ts | 146 ++ .../__tests__/period-service.test.ts | 178 +++ .../__tests__/storno-service.test.ts | 144 ++ .../__tests__/year-end-service.test.ts | 197 +++ lib/core/bookkeeping/period-service.ts | 235 +++ lib/core/bookkeeping/storno-service.ts | 237 +++ lib/core/bookkeeping/year-end-service.ts | 424 ++++++ .../__tests__/document-service.test.ts | 174 +++ lib/core/documents/document-service.ts | 243 +++ .../tax/__tests__/tax-code-service.test.ts | 112 ++ lib/core/tax/tax-code-service.ts | 167 ++ lib/events/__tests__/bus.test.ts | 111 ++ lib/events/bus.ts | 71 + lib/events/index.ts | 8 + lib/events/types.ts | 83 + lib/extensions/__tests__/registry.test.ts | 129 ++ lib/extensions/index.ts | 15 + lib/extensions/loader.ts | 30 + lib/extensions/registry.ts | 79 + lib/extensions/types.ts | 100 ++ lib/init.ts | 10 + .../__tests__/receipt-categorizer.test.ts | 154 ++ .../__tests__/receipt-matcher.test.ts | 286 ++++ lib/reports/sie-export.ts | 53 +- lib/reports/vat-declaration.ts | 123 ++ lib/transactions/category-suggestions.ts | 31 +- package-lock.json | 1353 ++++++++++++++++- package.json | 6 +- .../20240101000011_alter_existing_tables.sql | 80 + .../migrations/20240101000012_tax_codes.sql | 123 ++ .../20240101000013_document_archive.sql | 63 + .../migrations/20240101000014_audit_log.sql | 59 + .../migrations/20240101000015_dimensions.sql | 68 + .../20240101000016_voucher_sequences.sql | 153 ++ .../20240101000017_enforcement_triggers.sql | 293 ++++ .../20240101000018_audit_triggers.sql | 116 ++ .../20240101000019_period_closing.sql | 58 + .../20240101000020_extension_data.sql | 64 + tests/helpers.ts | 264 ++++ types/index.ts | 239 +++ vitest.config.ts | 14 + 73 files changed, 11036 insertions(+), 51 deletions(-) create mode 100644 app/(dashboard)/bookkeeping/year-end/page.tsx create mode 100644 app/api/bookkeeping/fiscal-periods/[id]/close/route.ts create mode 100644 app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts create mode 100644 app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts create mode 100644 app/api/extensions/ai-categorization/settings/route.ts create mode 100644 app/api/extensions/ai-categorization/suggestions/route.ts create mode 100644 app/api/extensions/receipt-ocr/settings/route.ts create mode 100644 dev_docs/ARCHITECTURE.md create mode 100644 dev_docs/base_architecture/AI_CATEGORIZATION_EXTENSION.md create mode 100644 dev_docs/base_architecture/PART1_IMPLEMENTATION.md create mode 100644 dev_docs/base_architecture/PART2_IMPLEMENTATION.md create mode 100644 dev_docs/base_architecture/PART3_IMPLEMENTATION.md create mode 100644 dev_docs/base_architecture/PART4_YEAR_END_UI.md create mode 100644 dev_docs/base_architecture/RECEIPT_OCR_EXTENSION.md create mode 100644 extensions/ai-categorization/categorizer.ts create mode 100644 extensions/ai-categorization/index.ts create mode 100644 extensions/example-logger/index.ts create mode 100644 extensions/receipt-ocr/__tests__/index.test.ts create mode 100644 extensions/receipt-ocr/index.ts create mode 100644 lib/bookkeeping/__tests__/engine.test.ts create mode 100644 lib/core/audit/audit-service.ts create mode 100644 lib/core/bookkeeping/__tests__/period-service.test.ts create mode 100644 lib/core/bookkeeping/__tests__/storno-service.test.ts create mode 100644 lib/core/bookkeeping/__tests__/year-end-service.test.ts create mode 100644 lib/core/bookkeeping/period-service.ts create mode 100644 lib/core/bookkeeping/storno-service.ts create mode 100644 lib/core/bookkeeping/year-end-service.ts create mode 100644 lib/core/documents/__tests__/document-service.test.ts create mode 100644 lib/core/documents/document-service.ts create mode 100644 lib/core/tax/__tests__/tax-code-service.test.ts create mode 100644 lib/core/tax/tax-code-service.ts create mode 100644 lib/events/__tests__/bus.test.ts create mode 100644 lib/events/bus.ts create mode 100644 lib/events/index.ts create mode 100644 lib/events/types.ts create mode 100644 lib/extensions/__tests__/registry.test.ts create mode 100644 lib/extensions/index.ts create mode 100644 lib/extensions/loader.ts create mode 100644 lib/extensions/registry.ts create mode 100644 lib/extensions/types.ts create mode 100644 lib/init.ts create mode 100644 lib/receipts/__tests__/receipt-categorizer.test.ts create mode 100644 lib/receipts/__tests__/receipt-matcher.test.ts create mode 100644 supabase/migrations/20240101000011_alter_existing_tables.sql create mode 100644 supabase/migrations/20240101000012_tax_codes.sql create mode 100644 supabase/migrations/20240101000013_document_archive.sql create mode 100644 supabase/migrations/20240101000014_audit_log.sql create mode 100644 supabase/migrations/20240101000015_dimensions.sql create mode 100644 supabase/migrations/20240101000016_voucher_sequences.sql create mode 100644 supabase/migrations/20240101000017_enforcement_triggers.sql create mode 100644 supabase/migrations/20240101000018_audit_triggers.sql create mode 100644 supabase/migrations/20240101000019_period_closing.sql create mode 100644 supabase/migrations/20240101000020_extension_data.sql create mode 100644 tests/helpers.ts create mode 100644 vitest.config.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 87e97b8d..f464510c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,10 @@ { "permissions": { "allow": [ - "mcp__ide__getDiagnostics" + "mcp__ide__getDiagnostics", + "Bash(grep:*)", + "Bash(xargs:*)", + "Bash(ls:*)" ] } } diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx index 27d73023..fa16d229 100644 --- a/app/(dashboard)/bookkeeping/page.tsx +++ b/app/(dashboard)/bookkeeping/page.tsx @@ -1,21 +1,32 @@ 'use client' import { useState } from 'react' +import Link from 'next/link' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { Button } from '@/components/ui/button' import JournalEntryList from '@/components/bookkeeping/JournalEntryList' import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm' import ChartOfAccounts from '@/components/bookkeeping/ChartOfAccounts' +import { Lock } from 'lucide-react' export default function BookkeepingPage() { const [refreshKey, setRefreshKey] = useState(0) return (
-
-

Bokföring

-

- Verifikationer, kontoplan och manuella bokföringsorder -

+
+
+

Bokföring

+

+ Verifikationer, kontoplan och manuella bokföringsorder +

+
+
diff --git a/app/(dashboard)/bookkeeping/year-end/page.tsx b/app/(dashboard)/bookkeeping/year-end/page.tsx new file mode 100644 index 00000000..1bf3ec76 --- /dev/null +++ b/app/(dashboard)/bookkeeping/year-end/page.tsx @@ -0,0 +1,699 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import Link from 'next/link' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { SuccessAnimation } from '@/components/ui/success-animation' +import { useToast } from '@/components/ui/use-toast' +import { + CheckCircle2, + AlertCircle, + AlertTriangle, + ArrowLeft, + ArrowRight, + Loader2, + Lock, + BookOpen, + ChevronDown, + ChevronUp, +} from 'lucide-react' +import type { + FiscalPeriod, + YearEndValidation, + YearEndPreview, + YearEndResult, +} from '@/types' + +function formatAmount(amount: number): string { + return amount.toLocaleString('sv-SE', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) +} + +const STEP_LABELS = ['Välj period', 'Validering', 'Förhandsgranskning', 'Genomför'] + +export default function YearEndPage() { + const { toast } = useToast() + + const [step, setStep] = useState(0) + const [periods, setPeriods] = useState([]) + const [selectedPeriodId, setSelectedPeriodId] = useState('') + const [validation, setValidation] = useState(null) + const [preview, setPreview] = useState(null) + const [result, setResult] = useState(null) + const [loading, setLoading] = useState(false) + const [loadingPeriods, setLoadingPeriods] = useState(true) + const [executing, setExecuting] = useState(false) + const [error, setError] = useState(null) + const [showConfirmDialog, setShowConfirmDialog] = useState(false) + const [showLinesDetail, setShowLinesDetail] = useState(false) + const [showSuccess, setShowSuccess] = useState(false) + + const selectedPeriod = periods.find((p) => p.id === selectedPeriodId) + + useEffect(() => { + fetchPeriods() + }, []) + + async function fetchPeriods() { + try { + const res = await fetch('/api/bookkeeping/fiscal-periods') + const { data } = await res.json() + const allPeriods: FiscalPeriod[] = data || [] + setPeriods(allPeriods) + // Pre-select first open period + const openPeriod = allPeriods.find((p) => !p.is_closed) + if (openPeriod) { + setSelectedPeriodId(openPeriod.id) + } + } catch { + toast({ title: 'Fel', description: 'Kunde inte hämta räkenskapsår', variant: 'destructive' }) + } finally { + setLoadingPeriods(false) + } + } + + const fetchValidationAndPreview = useCallback(async () => { + if (!selectedPeriodId) return + setLoading(true) + setError(null) + setValidation(null) + setPreview(null) + + try { + const res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/year-end`) + const json = await res.json() + + if (!res.ok) { + setError(json.error || 'Kunde inte validera perioden') + return + } + + setValidation(json.data.validation) + setPreview(json.data.preview) + } catch { + setError('Nätverksfel vid validering') + } finally { + setLoading(false) + } + }, [selectedPeriodId]) + + async function executeYearEnd() { + setShowConfirmDialog(false) + setExecuting(true) + setError(null) + + try { + const res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/year-end`, { + method: 'POST', + }) + const json = await res.json() + + if (!res.ok) { + setError(json.error || 'Årsbokslut misslyckades') + toast({ title: 'Fel', description: json.error || 'Årsbokslut misslyckades', variant: 'destructive' }) + return + } + + setResult(json.data) + setShowSuccess(true) + } catch { + setError('Nätverksfel vid genomförande') + toast({ title: 'Fel', description: 'Nätverksfel vid genomförande', variant: 'destructive' }) + } finally { + setExecuting(false) + } + } + + function goToStep(nextStep: number) { + if (nextStep === 1 && !validation) { + fetchValidationAndPreview() + } + setStep(nextStep) + } + + function getPeriodStatus(period: FiscalPeriod) { + if (period.is_closed) return { label: 'Stängd', variant: 'secondary' as const } + if (period.locked_at) return { label: 'Låst', variant: 'outline' as const } + return { label: 'Öppen', variant: 'default' as const } + } + + return ( +
+ {/* Header */} +
+
+

Årsbokslut

+

+ Stäng räkenskapsåret och generera ingående balanser +

+
+ +
+ + {/* Step indicator */} +
+ {STEP_LABELS.map((label, i) => ( +
+
+
+ {i < step ? : i + 1} +
+ +
+ {i < STEP_LABELS.length - 1 && ( +
+ )} +
+ ))} +
+ + {/* Error banner */} + {error && ( + + + +

{error}

+
+
+ )} + + {/* Step 0: Period Selection */} + {step === 0 && ( + + + Välj räkenskapsår att stänga + + + {loadingPeriods ? ( +
+ + +
+ ) : periods.length === 0 ? ( +

+ Inga räkenskapsår hittades. Skapa ett räkenskapsår först. +

+ ) : ( + <> +
+ {periods.map((period) => { + const status = getPeriodStatus(period) + const isSelected = period.id === selectedPeriodId + return ( + + ) + })} +
+ +
+ +
+ + )} +
+
+ )} + + {/* Step 1: Validation */} + {step === 1 && ( + + + Validering — {selectedPeriod?.name} + + + {loading ? ( +
+ + + +
+ ) : validation ? ( + <> + {/* Ready indicator */} +
+ {validation.ready ? ( + + ) : ( + + )} +
+

+ {validation.ready + ? 'Perioden är redo för årsbokslut' + : 'Perioden kan inte stängas ännu'} +

+ {!validation.ready && ( +

+ Åtgärda felen nedan innan du kan fortsätta +

+ )} +
+
+ + {/* Errors */} + {validation.errors.length > 0 && ( +
+

Fel som måste åtgärdas

+ {validation.errors.map((err, i) => ( +
+ + {err} +
+ ))} +
+ )} + + {/* Warnings */} + {validation.warnings.length > 0 && ( +
+

Varningar

+ {validation.warnings.map((warn, i) => ( +
+ + {warn} +
+ ))} +
+ )} + + {/* Details */} +
+
+

Utkast kvar

+

{validation.draftCount}

+
+
+

Saldobalans

+

+ {validation.trialBalanceBalanced ? 'Balanserad' : 'Obalanserad'} +

+
+
+ + {/* Voucher gaps */} + {validation.voucherGaps.length > 0 && ( +
+

Verifikationsnummerluckor

+
+ {validation.voucherGaps.map((gap, i) => ( + + {gap.gap_start}–{gap.gap_end} + + ))} +
+
+ )} + + ) : null} + +
+ +
+ + +
+
+
+
+ )} + + {/* Step 2: Preview */} + {step === 2 && preview && ( +
+ {/* Net result highlight */} + + +
+

Årets resultat

+

= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400' + }`} + > + {formatAmount(preview.netResult)} kr +

+

+ Bokförs på {preview.closingAccount} — {preview.closingAccountName} +

+
+
+
+ + {/* Result account summary */} + + + Resultatkonton som nollställs + + + + + + Konto + Namn + Belopp + + + + {preview.resultAccountSummary.map((account) => ( + + {account.account_number} + {account.account_name} + + {formatAmount(account.amount)} kr + + + ))} + +
+
+
+ + {/* Closing journal lines (expandable) */} + + + + + {showLinesDetail && ( + + + + + Konto + Beskrivning + Debet + Kredit + + + + {preview.closingLines.map((line, i) => ( + + {line.account_number} + {line.line_description} + + {line.debit_amount > 0 ? formatAmount(line.debit_amount) : ''} + + + {line.credit_amount > 0 ? formatAmount(line.credit_amount) : ''} + + + ))} + {/* Totals row */} + + Summa + + {formatAmount( + preview.closingLines.reduce((sum, l) => sum + l.debit_amount, 0) + )} + + + {formatAmount( + preview.closingLines.reduce((sum, l) => sum + l.credit_amount, 0) + )} + + + +
+
+ )} +
+ + {/* Navigation */} +
+ + +
+
+ )} + + {/* Step 3: Execute */} + {step === 3 && !result && ( + + + Genomför årsbokslut + + +
+

+ Följande åtgärder kommer att genomföras: +

+
    +
  • + + Bokslutsverifikation skapas med {preview?.closingLines.length} rader +
  • +
  • + + Perioden {selectedPeriod?.name} låses och stängs permanent +
  • +
  • + + Nytt räkenskapsår skapas med ingående balanser +
  • +
+ {preview && ( +
+

+ Årets resultat:{' '} + + {formatAmount(preview.netResult)} kr + {' '} + → {preview.closingAccount} ({preview.closingAccountName}) +

+
+ )} +
+ +
+
+ +
+

+ Denna åtgärd kan inte ångras +

+

+ Perioden stängs permanent enligt Bokföringslagen. Säkerställ att alla bokföringar + är korrekta innan du fortsätter. +

+
+
+
+ +
+ + +
+
+
+ )} + + {/* Step 3: Success state */} + {step === 3 && result && ( + + +
+
+
+ +
+
+
+

Årsbokslutet är genomfört

+

+ {selectedPeriod?.name} har stängts och ett nytt räkenskapsår har skapats. +

+
+ +
+
+ Bokslutsverifikation + + Visa + +
+
+ Period stängd + + + Stängd + +
+
+ Nytt räkenskapsår + {result.nextPeriod.name} +
+
+ Ingående balanser + + + Skapade + +
+
+ +
+ +
+
+
+
+ )} + + {/* Confirmation dialog */} + + + + Bekräfta årsbokslut + + Är du säker på att du vill stänga {selectedPeriod?.name}? + Denna åtgärd kan inte ångras. Perioden kommer att stängas permanent. + + + {preview && ( +
+

+ Årets resultat:{' '} + {formatAmount(preview.netResult)} kr +

+

+ Bokförs på {preview.closingAccount} — {preview.closingAccountName} +

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