Added extension functionality

This commit is contained in:
Emil
2026-02-21 11:39:45 +01:00
parent 2f83f232c6
commit 026497ed75
132 changed files with 3533 additions and 60 deletions
@@ -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<string, unknown> = {}
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<string, unknown> = {}) {
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/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/receipt-matcher', () => ({
autoMatchReceipts: vi.fn().mockReturnValue([]),
}))
import { createClient } from '@/lib/supabase/server'
import { analyzeReceipt } from '../lib/receipt-analyzer'
import { autoMatchReceipts } from '../lib/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()
})
})
@@ -0,0 +1,336 @@
'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Camera, X, RotateCcw, Check, Loader2, SunMedium, Move, Focus } from 'lucide-react'
import type { CameraQualityFeedback } from '@/types'
interface ReceiptCameraProps {
onCapture: (imageData: string, mimeType: string) => Promise<void>
onClose: () => void
}
export default function ReceiptCamera({ onCapture, onClose }: ReceiptCameraProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const streamRef = useRef<MediaStream | null>(null)
const [isStreaming, setIsStreaming] = useState(false)
const [capturedImage, setCapturedImage] = useState<string | null>(null)
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [quality, setQuality] = useState<CameraQualityFeedback>({
lightingOk: false,
distanceOk: false,
focusOk: false,
readyToCapture: false,
})
// Start camera stream
const startCamera = useCallback(async () => {
try {
setError(null)
const constraints: MediaStreamConstraints = {
video: {
facingMode: 'environment', // Use back camera on mobile
width: { ideal: 1920 },
height: { ideal: 1080 },
},
}
const stream = await navigator.mediaDevices.getUserMedia(constraints)
streamRef.current = stream
if (videoRef.current) {
videoRef.current.srcObject = stream
try {
await videoRef.current.play()
setIsStreaming(true)
} catch (playErr) {
// Ignore AbortError - this happens when stream is interrupted (e.g., component unmount)
if (playErr instanceof Error && playErr.name === 'AbortError') {
console.log('Video play interrupted - this is normal during navigation')
return
}
throw playErr
}
}
} catch (err) {
console.error('Camera access error:', err)
// Don't show error for AbortError
if (err instanceof Error && err.name === 'AbortError') return
setError('Kunde inte komma åt kameran. Kontrollera att du har gett tillåtelse.')
}
}, [])
// Stop camera stream
const stopCamera = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop())
streamRef.current = null
}
setIsStreaming(false)
}, [])
// Analyze video frame for quality feedback
const analyzeQuality = useCallback(() => {
if (!videoRef.current || !canvasRef.current || !isStreaming) return
const video = videoRef.current
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
if (!ctx) return
// Set canvas size to match video
canvas.width = video.videoWidth / 4 // Downsample for analysis
canvas.height = video.videoHeight / 4
// Draw current frame
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
// Get image data for analysis
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const data = imageData.data
// Calculate average brightness
let totalBrightness = 0
for (let i = 0; i < data.length; i += 4) {
const r = data[i]
const g = data[i + 1]
const b = data[i + 2]
totalBrightness += (r + g + b) / 3
}
const avgBrightness = totalBrightness / (data.length / 4)
// Calculate contrast (standard deviation of brightness)
let sumSquares = 0
for (let i = 0; i < data.length; i += 4) {
const r = data[i]
const g = data[i + 1]
const b = data[i + 2]
const brightness = (r + g + b) / 3
sumSquares += Math.pow(brightness - avgBrightness, 2)
}
const contrast = Math.sqrt(sumSquares / (data.length / 4))
// Quality assessment
const lightingOk = avgBrightness > 80 && avgBrightness < 200
const focusOk = contrast > 30 // Higher contrast suggests better focus
const distanceOk = true // Would need edge detection for proper distance check
const newQuality: CameraQualityFeedback = {
lightingOk,
distanceOk,
focusOk,
readyToCapture: lightingOk && focusOk && distanceOk,
}
if (!lightingOk) {
newQuality.message = avgBrightness < 80 ? 'För mörkt - öka ljuset' : 'För ljust - minska ljuset'
} else if (!focusOk) {
newQuality.message = 'Försök hålla kameran stilla'
}
setQuality(newQuality)
}, [isStreaming])
// Quality analysis loop
useEffect(() => {
if (!isStreaming) return
const interval = setInterval(analyzeQuality, 500)
return () => clearInterval(interval)
}, [isStreaming, analyzeQuality])
// Start camera on mount
useEffect(() => {
startCamera()
return () => stopCamera()
}, [startCamera, stopCamera])
// Capture image
const captureImage = useCallback(() => {
if (!videoRef.current || !canvasRef.current) return
const video = videoRef.current
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
if (!ctx) return
// Set canvas to full resolution
canvas.width = video.videoWidth
canvas.height = video.videoHeight
// Draw the current video frame
ctx.drawImage(video, 0, 0)
// Convert to base64
const imageData = canvas.toDataURL('image/jpeg', 0.9)
setCapturedImage(imageData)
stopCamera()
}, [stopCamera])
// Retake photo
const retake = useCallback(() => {
setCapturedImage(null)
startCamera()
}, [startCamera])
// Confirm and upload
const confirmCapture = async () => {
if (!capturedImage) return
setIsProcessing(true)
try {
// Extract base64 data (remove data:image/jpeg;base64, prefix)
const base64Data = capturedImage.split(',')[1]
await onCapture(base64Data, 'image/jpeg')
} catch (err) {
console.error('Upload error:', err)
setError('Kunde inte ladda upp bilden. Försök igen.')
setIsProcessing(false)
}
}
// Render captured image review
if (capturedImage) {
return (
<div className="fixed inset-0 bg-black z-[100] overflow-hidden">
{/* Header - fixed at top */}
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-4 bg-gradient-to-b from-black/70 to-transparent">
<Button variant="ghost" size="icon" onClick={retake} disabled={isProcessing}>
<RotateCcw className="h-5 w-5 text-white" />
</Button>
<h2 className="text-white font-medium">Granska bild</h2>
<Button variant="ghost" size="icon" onClick={onClose} disabled={isProcessing}>
<X className="h-5 w-5 text-white" />
</Button>
</div>
{/* Image preview - centered */}
<div className="absolute inset-0 flex items-center justify-center p-4 pt-20 pb-32">
<img
src={capturedImage}
alt="Captured receipt"
className="max-h-full max-w-full object-contain rounded-lg"
/>
</div>
{/* Action buttons - fixed at bottom */}
<div
className="absolute bottom-0 left-0 right-0 z-20 flex gap-4 justify-center bg-gradient-to-t from-black/70 to-transparent pt-8 pb-8 px-4"
style={{ paddingBottom: 'max(2rem, env(safe-area-inset-bottom, 2rem))' }}
>
<Button variant="outline" onClick={retake} disabled={isProcessing} className="bg-white/10 border-white text-white hover:bg-white/20">
<RotateCcw className="mr-2 h-4 w-4" />
Ta om
</Button>
<Button onClick={confirmCapture} disabled={isProcessing} className="bg-white text-black hover:bg-white/90">
{isProcessing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Analyserar...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Använd bild
</>
)}
</Button>
</div>
</div>
)
}
// Render camera view
return (
<div className="fixed inset-0 bg-black z-[100] overflow-hidden">
{/* Header - fixed at top */}
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-4 bg-gradient-to-b from-black/70 to-transparent">
<div className="w-10" />
<h2 className="text-white font-medium">Skanna kvitto</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5 text-white" />
</Button>
</div>
{/* Video preview - fills the screen */}
<video
ref={videoRef}
autoPlay
playsInline
muted
className="absolute inset-0 h-full w-full object-cover"
/>
{/* Quality feedback overlay */}
{isStreaming && (
<div className="absolute bottom-36 left-0 right-0 flex justify-center z-10">
<div className="bg-black/70 rounded-full px-4 py-2 flex items-center gap-4">
<div className={`flex items-center gap-1 ${quality.lightingOk ? 'text-green-400' : 'text-yellow-400'}`}>
<SunMedium className="h-4 w-4" />
<span className="text-xs">Ljus</span>
</div>
<div className={`flex items-center gap-1 ${quality.distanceOk ? 'text-green-400' : 'text-yellow-400'}`}>
<Move className="h-4 w-4" />
<span className="text-xs">Avstånd</span>
</div>
<div className={`flex items-center gap-1 ${quality.focusOk ? 'text-green-400' : 'text-yellow-400'}`}>
<Focus className="h-4 w-4" />
<span className="text-xs">Fokus</span>
</div>
</div>
</div>
)}
{/* Quality message */}
{quality.message && (
<div className="absolute top-20 left-0 right-0 flex justify-center z-10">
<div className="bg-warning text-warning-foreground px-4 py-2 rounded-full text-sm font-medium">
{quality.message}
</div>
</div>
)}
{/* Error message */}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80 z-30">
<div className="text-center p-4">
<p className="text-white mb-4">{error}</p>
<Button onClick={startCamera}>Försök igen</Button>
</div>
</div>
)}
{/* Guide overlay */}
{isStreaming && (
<div className="absolute inset-0 pointer-events-none z-10">
<div className="absolute top-20 left-8 right-8 bottom-36 border-2 border-white/30 rounded-lg">
<div className="absolute top-0 left-0 w-8 h-8 border-t-2 border-l-2 border-white rounded-tl-lg" />
<div className="absolute top-0 right-0 w-8 h-8 border-t-2 border-r-2 border-white rounded-tr-lg" />
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-2 border-l-2 border-white rounded-bl-lg" />
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-2 border-r-2 border-white rounded-br-lg" />
</div>
</div>
)}
{/* Capture button - fixed at bottom */}
<div
className="absolute bottom-0 left-0 right-0 z-20 flex justify-center bg-gradient-to-t from-black/70 to-transparent pt-8 pb-8"
style={{ paddingBottom: 'max(2rem, env(safe-area-inset-bottom, 2rem))' }}
>
<button
onClick={captureImage}
disabled={!isStreaming}
className="h-20 w-20 rounded-full border-4 border-white flex items-center justify-center bg-white/20 active:bg-white/40 disabled:opacity-50 transition-colors shadow-2xl backdrop-blur-sm"
>
<Camera className="h-8 w-8 text-white" />
</button>
</div>
{/* Hidden canvas for image processing */}
<canvas ref={canvasRef} className="hidden" />
</div>
)
}
@@ -0,0 +1,169 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Flame, Receipt, CreditCard, Camera, ArrowRight } from 'lucide-react'
import type { ReceiptQueueSummary } from '@/types'
interface ReceiptDashboardProps {
summary: ReceiptQueueSummary
onScanReceipt: () => void
onViewReceiptQueue: () => void
onViewTransactionQueue: () => void
}
export default function ReceiptDashboard({
summary,
onScanReceipt,
onViewReceiptQueue,
onViewTransactionQueue,
}: ReceiptDashboardProps) {
const hasWork =
summary.unmatched_receipts_count > 0 ||
summary.unmatched_transactions_count > 0 ||
summary.pending_review_count > 0
return (
<div className="space-y-4">
{/* Quick actions */}
<div className="grid grid-cols-1 gap-3">
<Button
variant="default"
className="h-auto py-4 flex-col gap-2"
onClick={onScanReceipt}
>
<Camera className="h-6 w-6" />
<span>Skanna kvitto</span>
</Button>
</div>
{/* Streak counter */}
{summary.streak_count > 0 && (
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
<Flame className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p className="text-2xl font-bold">{summary.streak_count}</p>
<p className="text-sm text-muted-foreground">dagars streak!</p>
</div>
</div>
<p className="text-xs text-muted-foreground max-w-24 text-right">
Fortsätt kategorisera varje dag
</p>
</div>
</CardContent>
</Card>
)}
{/* Queue cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Pending review */}
{summary.pending_review_count > 0 && (
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={onViewReceiptQueue}
>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
<Receipt className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="font-medium">Att granska</p>
<p className="text-sm text-muted-foreground">
{summary.pending_review_count} kvitton
</p>
</div>
</div>
<Badge variant="secondary">{summary.pending_review_count}</Badge>
</div>
</CardContent>
</Card>
)}
{/* Unmatched receipts */}
{summary.unmatched_receipts_count > 0 && (
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={onViewReceiptQueue}
>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<Receipt className="h-5 w-5 text-primary" />
</div>
<div>
<p className="font-medium">Kvitton utan transaktion</p>
<p className="text-sm text-muted-foreground">
{summary.unmatched_receipts_count} st
</p>
</div>
</div>
<ArrowRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardContent>
</Card>
)}
{/* Unmatched transactions */}
{summary.unmatched_transactions_count > 0 && (
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={onViewTransactionQueue}
>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
<CreditCard className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="font-medium">Transaktioner utan kvitto</p>
<p className="text-sm text-muted-foreground">
{summary.unmatched_transactions_count} st
</p>
</div>
</div>
<ArrowRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardContent>
</Card>
)}
</div>
{/* All done state */}
{!hasWork && (
<Card className="bg-success/5 border-success/20">
<CardContent className="pt-4 text-center py-8">
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center mx-auto mb-4">
<svg
className="h-8 w-8 text-success"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h3 className="font-semibold text-lg">Allt är uppdaterat!</h3>
<p className="text-sm text-muted-foreground mt-1">
Inga kvitton eller transaktioner att granska
</p>
</CardContent>
</Card>
)}
</div>
)
}
@@ -0,0 +1,99 @@
'use client'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { formatCurrency } from '@/lib/utils'
import { getExpenseCategories, CATEGORY_LABELS } from '../lib/receipt-categorizer'
import type { ReceiptLineItem, TransactionCategory } from '@/types'
interface ReceiptLineItemRowProps {
item: ReceiptLineItem
onToggleBusiness: (id: string, isBusiness: boolean) => void
onCategoryChange: (id: string, category: TransactionCategory) => void
disabled?: boolean
}
export default function ReceiptLineItemRow({
item,
onToggleBusiness,
onCategoryChange,
disabled = false,
}: ReceiptLineItemRowProps) {
const expenseCategories = getExpenseCategories()
const isBusiness = item.is_business === true
return (
<div className="flex flex-col gap-2 p-3 border rounded-lg bg-card">
{/* Description and amount */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{item.description}</p>
{item.quantity !== 1 && (
<p className="text-xs text-muted-foreground">
{item.quantity} x {item.unit_price ? formatCurrency(item.unit_price, 'SEK') : '—'}
</p>
)}
</div>
<div className="text-right flex-shrink-0">
<p className="font-semibold">{formatCurrency(item.line_total, 'SEK')}</p>
{item.vat_rate && (
<p className="text-xs text-muted-foreground">{item.vat_rate}% moms</p>
)}
</div>
</div>
{/* Classification controls */}
<div className="flex items-center gap-3 pt-2 border-t">
{/* Business/Private toggle */}
<div className="flex items-center gap-2">
<span className={`text-xs ${!isBusiness ? 'text-muted-foreground' : 'font-medium'}`}>
Privat
</span>
<Switch
checked={isBusiness}
onCheckedChange={(checked) => onToggleBusiness(item.id, checked)}
disabled={disabled}
/>
<span className={`text-xs ${isBusiness ? 'font-medium' : 'text-muted-foreground'}`}>
Företag
</span>
</div>
{/* Category selector (only for business) */}
{isBusiness && (
<Select
value={item.category || undefined}
onValueChange={(value) => onCategoryChange(item.id, value as TransactionCategory)}
disabled={disabled}
>
<SelectTrigger className="h-8 w-32 text-xs">
<SelectValue placeholder="Kategori" />
</SelectTrigger>
<SelectContent>
{expenseCategories.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* Confidence badge */}
{item.extraction_confidence !== null && item.extraction_confidence < 0.8 && (
<Badge variant="outline" className="text-xs">
{Math.round((item.extraction_confidence || 0) * 100)}%
</Badge>
)}
{/* AI suggestion indicator */}
{item.suggested_category && item.is_business === null && (
<Badge variant="secondary" className="text-xs">
Förslag: {CATEGORY_LABELS[`expense_${item.suggested_category}` as TransactionCategory] || item.suggested_category}
</Badge>
)}
</div>
</div>
)
}
@@ -0,0 +1,418 @@
'use client'
import { useState, useMemo } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
AlertTriangle,
Check,
X,
Building,
User,
Wine,
Utensils,
Globe,
ChevronUp,
ChevronDown,
Loader2,
} from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { calculateReceiptSplit, getDefaultClassification } from '../lib/receipt-categorizer'
import { calculateRepresentationLimits } from '../lib/receipt-utils'
import ReceiptLineItemRow from './ReceiptLineItemRow'
import type { Receipt, ReceiptLineItem, TransactionCategory, ConfirmLineItemInput } from '@/types'
interface ReceiptReviewViewProps {
receipt: Receipt & { line_items: ReceiptLineItem[] }
onConfirm: (data: {
line_items: ConfirmLineItemInput[]
representation_persons?: number
representation_purpose?: string
}) => Promise<void>
onCancel: () => void
onFindMatches?: () => void
}
export default function ReceiptReviewView({
receipt,
onConfirm,
onCancel,
onFindMatches,
}: ReceiptReviewViewProps) {
// Initialize line items with their current state
const [lineItems, setLineItems] = useState<ReceiptLineItem[]>(receipt.line_items || [])
const [isProcessing, setIsProcessing] = useState(false)
const [showImage, setShowImage] = useState(false)
// Restaurant representation state
const [representationPersons, setRepresentationPersons] = useState<number | undefined>(
receipt.representation_persons || undefined
)
const [representationPurpose, setRepresentationPurpose] = useState<string>(
receipt.representation_purpose || ''
)
// Get default classification info
const defaultClassification = getDefaultClassification(
receipt.is_restaurant,
receipt.is_systembolaget
)
// Calculate split summary
const splitSummary = useMemo(() => {
return calculateReceiptSplit(lineItems.map(item => ({
lineTotal: item.line_total,
is_business: item.is_business,
})))
}, [lineItems])
// Calculate representation limits if restaurant
const representationCalc = useMemo(() => {
if (!receipt.is_restaurant || !representationPersons || !receipt.total_amount) return null
return calculateRepresentationLimits(receipt.total_amount, representationPersons)
}, [receipt.is_restaurant, receipt.total_amount, representationPersons])
// Toggle business status for a line item
const handleToggleBusiness = (id: string, isBusiness: boolean) => {
setLineItems((items) =>
items.map((item) =>
item.id === id
? { ...item, is_business: isBusiness, category: isBusiness ? item.category : null }
: item
)
)
}
// Change category for a line item
const handleCategoryChange = (id: string, category: TransactionCategory) => {
setLineItems((items) =>
items.map((item) => (item.id === id ? { ...item, category } : item))
)
}
// Mark all as business
const handleAllBusiness = () => {
setLineItems((items) =>
items.map((item) => ({
...item,
is_business: true,
category: item.category || 'expense_other',
}))
)
}
// Mark all as private
const handleAllPrivate = () => {
setLineItems((items) =>
items.map((item) => ({
...item,
is_business: false,
category: null,
}))
)
}
// Confirm and save
const handleConfirm = async () => {
// Validate restaurant representation
if (receipt.is_restaurant && splitSummary.businessTotal > 0) {
if (!representationPersons || representationPersons < 1) {
alert('Ange antal personer för restaurangrepresentation')
return
}
if (!representationPurpose.trim()) {
alert('Ange syfte för restaurangrepresentation')
return
}
}
setIsProcessing(true)
try {
await onConfirm({
line_items: lineItems.map((item) => ({
id: item.id,
is_business: item.is_business ?? false,
category: item.category || undefined,
bas_account: item.bas_account || undefined,
})),
representation_persons: representationPersons,
representation_purpose: representationPurpose.trim() || undefined,
})
} catch (error) {
console.error('Confirm error:', error)
} finally {
setIsProcessing(false)
}
}
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b flex-shrink-0">
<Button variant="ghost" size="icon" onClick={onCancel} disabled={isProcessing} aria-label="Avbryt">
<X className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Granska kvitto</h1>
<div className="w-10" />
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-4 space-y-4">
{/* Merchant and total card */}
<Card>
<CardContent className="pt-4">
<div className="flex items-start justify-between">
<div>
<h2 className="font-semibold text-lg">{receipt.merchant_name || 'Okänd handlare'}</h2>
{receipt.receipt_date && (
<p className="text-sm text-muted-foreground">{formatDate(receipt.receipt_date)}</p>
)}
</div>
<div className="text-right">
<p className="text-2xl font-bold">
{formatCurrency(receipt.total_amount || 0, receipt.currency)}
</p>
{receipt.vat_amount && (
<p className="text-sm text-muted-foreground">
varav moms {formatCurrency(receipt.vat_amount, receipt.currency)}
</p>
)}
</div>
</div>
{/* Special flags */}
<div className="flex flex-wrap gap-2 mt-3">
{receipt.is_restaurant && (
<Badge variant="secondary">
<Utensils className="mr-1 h-3 w-3" /> Restaurang
</Badge>
)}
{receipt.is_systembolaget && (
<Badge variant="secondary">
<Wine className="mr-1 h-3 w-3" /> Systembolaget
</Badge>
)}
{receipt.is_foreign_merchant && (
<Badge variant="secondary">
<Globe className="mr-1 h-3 w-3" /> Utländskt
</Badge>
)}
{receipt.extraction_confidence && (
<Badge
variant={receipt.extraction_confidence > 0.8 ? 'outline' : receipt.extraction_confidence > 0.5 ? 'warning' : 'destructive'}
className={receipt.extraction_confidence > 0.8 ? 'border-success/50 text-success' : ''}
>
{Math.round(receipt.extraction_confidence * 100)}% säkerhet
{receipt.extraction_confidence <= 0.8 && ' — kontrollera'}
</Badge>
)}
</div>
{/* Toggle image preview */}
<Button
variant="ghost"
size="sm"
className="mt-2 text-xs"
onClick={() => setShowImage(!showImage)}
>
{showImage ? <ChevronUp className="mr-1 h-3 w-3" /> : <ChevronDown className="mr-1 h-3 w-3" />}
{showImage ? 'Dölj bild' : 'Visa bild'}
</Button>
{showImage && receipt.image_url && (
<img
src={receipt.image_url}
alt="Receipt"
className="mt-2 max-h-48 rounded-lg object-contain"
/>
)}
</CardContent>
</Card>
{/* Warning message */}
{defaultClassification.warningMessage && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-warning/5 border border-warning/30">
<AlertTriangle className="h-5 w-5 text-warning-foreground flex-shrink-0 mt-0.5" />
<p className="text-sm text-warning-foreground">
{defaultClassification.warningMessage}
</p>
</div>
)}
{/* Restaurant representation section */}
{receipt.is_restaurant && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Utensils className="h-4 w-4" />
Representation
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="persons" className="text-xs">
Antal personer
</Label>
<Input
id="persons"
type="number"
min={1}
value={representationPersons || ''}
onChange={(e) => setRepresentationPersons(parseInt(e.target.value) || undefined)}
placeholder="2"
/>
</div>
<div>
<Label className="text-xs">Per person</Label>
<p className="h-10 flex items-center font-medium">
{representationPersons && receipt.total_amount
? formatCurrency(receipt.total_amount / representationPersons, receipt.currency)
: '—'}
</p>
</div>
</div>
<div>
<Label htmlFor="purpose" className="text-xs">
Syfte med representationen
</Label>
<Textarea
id="purpose"
value={representationPurpose}
onChange={(e) => setRepresentationPurpose(e.target.value)}
placeholder="T.ex. kundmöte, kontraktsförhandling..."
rows={2}
/>
</div>
{representationCalc && (
<div className="p-2 rounded bg-muted text-xs space-y-1">
<p>
<span className="text-muted-foreground">Avdragsgill del:</span>{' '}
<span className="font-medium text-green-600">
{formatCurrency(representationCalc.deductibleAmount, receipt.currency)}
</span>
{' '}(max {representationCalc.maxDeductiblePerPerson} kr/person)
</p>
{representationCalc.nonDeductibleAmount > 0 && (
<p>
<span className="text-muted-foreground">Ej avdragsgill del:</span>{' '}
<span className="font-medium text-orange-600">
{formatCurrency(representationCalc.nonDeductibleAmount, receipt.currency)}
</span>
</p>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* Quick actions */}
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={handleAllBusiness}>
<Building className="mr-2 h-4 w-4" />
Allt företag
</Button>
<Button variant="outline" className="flex-1" onClick={handleAllPrivate}>
<User className="mr-2 h-4 w-4" />
Allt privat
</Button>
</div>
{/* Line items */}
<div className="space-y-2">
<h3 className="font-medium text-sm flex items-center justify-between">
Artiklar ({lineItems.length})
<span className="text-muted-foreground font-normal">
Svajpa eller klicka för att klassificera
</span>
</h3>
{lineItems.map((item) => (
<ReceiptLineItemRow
key={item.id}
item={item}
onToggleBusiness={handleToggleBusiness}
onCategoryChange={handleCategoryChange}
disabled={isProcessing}
/>
))}
</div>
{/* Summary */}
<Card>
<CardContent className="pt-4">
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Företag:</span>
<span className="font-medium text-green-600">
{formatCurrency(splitSummary.businessTotal, receipt.currency)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Privat:</span>
<span className="font-medium text-orange-600">
{formatCurrency(splitSummary.privateTotal, receipt.currency)}
</span>
</div>
{splitSummary.unclassifiedTotal > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Oklassificerat:</span>
<span className="font-medium text-yellow-600">
{formatCurrency(splitSummary.unclassifiedTotal, receipt.currency)}
</span>
</div>
)}
<div className="pt-2 border-t flex justify-between font-medium">
<span>Totalt:</span>
<span>{formatCurrency(receipt.total_amount || 0, receipt.currency)}</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Footer actions - large touch targets for mobile */}
<div className="p-4 border-t flex-shrink-0 space-y-2 safe-area-bottom">
{onFindMatches && (
<Button
variant="outline"
className="w-full h-12 text-base"
onClick={onFindMatches}
disabled={isProcessing}
>
Hitta matchande transaktion
</Button>
)}
<Button
className="w-full h-14 text-base font-medium"
onClick={handleConfirm}
disabled={isProcessing || splitSummary.unclassifiedTotal > 0}
>
{isProcessing ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Sparar...
</>
) : (
<>
<Check className="mr-2 h-5 w-5" />
Bekräfta klassificering
</>
)}
</Button>
{splitSummary.unclassifiedTotal > 0 && (
<p className="text-xs text-center text-muted-foreground">
Klassificera alla artiklar innan du bekräftar
</p>
)}
</div>
</div>
)
}
@@ -0,0 +1,229 @@
'use client'
import { useState, useEffect } from 'react'
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 { formatCurrency, formatDate } from '@/lib/utils'
import { X, Check, Link2, AlertCircle, Search, Loader2 } from 'lucide-react'
import type { Receipt, ReceiptMatchCandidate, Transaction } from '@/types'
interface TransactionMatcherProps {
receipt: Receipt
onMatch: (transactionId: string, confidence: number) => Promise<void>
onSkip: () => void
onClose: () => void
}
export default function TransactionMatcher({
receipt,
onMatch,
onSkip,
onClose,
}: TransactionMatcherProps) {
const [matches, setMatches] = useState<ReceiptMatchCandidate[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isMatching, setIsMatching] = useState(false)
const [selectedMatch, setSelectedMatch] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
// Fetch matches on mount
useEffect(() => {
const fetchMatches = async () => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`/api/extensions/receipt-ocr/${receipt.id}/match`, {
method: 'POST',
})
const data = await response.json()
if (response.ok && data.data?.matches) {
setMatches(data.data.matches)
} else {
setError(data.error || 'Kunde inte hämta matchningar')
}
} catch {
setError('Nätverksfel vid hämtning av matchningar')
} finally {
setIsLoading(false)
}
}
fetchMatches()
}, [receipt.id])
// Handle match confirmation
const handleConfirmMatch = async () => {
if (!selectedMatch) return
const match = matches.find((m) => m.transaction.id === selectedMatch)
if (!match) return
setIsMatching(true)
try {
await onMatch(selectedMatch, match.confidence)
} catch {
setError('Kunde inte koppla kvitto till transaktion')
} finally {
setIsMatching(false)
}
}
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Matcha transaktion</h1>
<div className="w-10" />
</div>
{/* Receipt summary */}
<Card className="m-4 mb-2">
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{receipt.merchant_name || 'Kvitto'}</p>
{receipt.receipt_date && (
<p className="text-sm text-muted-foreground">{formatDate(receipt.receipt_date)}</p>
)}
</div>
<p className="text-xl font-bold">
{formatCurrency(receipt.total_amount || 0, receipt.currency)}
</p>
</div>
</CardContent>
</Card>
{/* Content */}
<div className="flex-1 overflow-auto px-4 pb-4">
<h2 className="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
<Search className="h-4 w-4" />
Möjliga matchningar
</h2>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="pt-4">
<Skeleton className="h-4 w-2/3 mb-2" />
<Skeleton className="h-6 w-1/3 mb-2" />
<Skeleton className="h-3 w-1/2" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<Card className="border-destructive">
<CardContent className="pt-4 flex items-center gap-3 text-destructive">
<AlertCircle className="h-5 w-5" />
<p>{error}</p>
</CardContent>
</Card>
) : matches.length === 0 ? (
<Card>
<CardContent className="pt-4 text-center py-8">
<Search className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="font-medium">Inga matchande transaktioner hittades</p>
<p className="text-sm text-muted-foreground mt-1">
Det finns ingen banktransaktion som matchar detta kvitto
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{matches.map((match) => (
<Card
key={match.transaction.id}
className={`cursor-pointer transition-colors ${
selectedMatch === match.transaction.id
? 'border-primary ring-2 ring-primary/20'
: 'hover:border-primary/50'
}`}
onClick={() => setSelectedMatch(match.transaction.id)}
>
<CardContent className="pt-4">
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{match.transaction.description}</p>
<p className="text-sm text-muted-foreground">
{formatDate(match.transaction.date)}
</p>
</div>
<div className="text-right flex-shrink-0">
<p className="font-bold">
{formatCurrency(Math.abs(match.transaction.amount), match.transaction.currency)}
</p>
<Badge
variant={match.confidence > 0.8 ? 'default' : match.confidence > 0.6 ? 'secondary' : 'outline'}
>
{Math.round(match.confidence * 100)}% match
</Badge>
</div>
</div>
{/* Match reasons */}
<div className="flex flex-wrap gap-1 mt-2">
{match.matchReasons.map((reason, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{reason}
</Badge>
))}
</div>
{/* Variance info */}
<div className="flex gap-4 mt-2 text-xs text-muted-foreground">
{match.dateVariance > 0 && (
<span>Datum: ±{Math.round(match.dateVariance)} dagar</span>
)}
{match.amountVariance > 0.01 && (
<span>Belopp: ±{Math.round(match.amountVariance * 100)}%</span>
)}
</div>
{/* Selected indicator */}
{selectedMatch === match.transaction.id && (
<div className="absolute top-2 right-2">
<div className="h-6 w-6 rounded-full bg-primary flex items-center justify-center">
<Check className="h-4 w-4 text-primary-foreground" />
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
{/* Footer actions */}
<div className="p-4 border-t flex gap-3">
<Button variant="outline" className="flex-1" onClick={onSkip}>
Hoppa över
</Button>
<Button
className="flex-1"
onClick={handleConfirmMatch}
disabled={!selectedMatch || isMatching}
>
{isMatching ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Kopplar...
</>
) : (
<>
<Link2 className="mr-2 h-4 w-4" />
Koppla transaktion
</>
)}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,5 @@
export { default as ReceiptCamera } from './ReceiptCamera'
export { default as ReceiptLineItemRow } from './ReceiptLineItemRow'
export { default as ReceiptReviewView } from './ReceiptReviewView'
export { default as TransactionMatcher } from './TransactionMatcher'
export { default as ReceiptDashboard } from './ReceiptDashboard'
+322
View File
@@ -0,0 +1,322 @@
import { createClient } from '@/lib/supabase/server'
import { eventBus } from '@/lib/events/bus'
import { analyzeReceipt } from './lib/receipt-analyzer'
import { processLineItems } from './lib/receipt-categorizer'
import { autoMatchReceipts } from './lib/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<ReceiptOcrSettings> {
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<ReceiptOcrSettings>) }
}
export async function saveSettings(
userId: string,
partial: Partial<ReceiptOcrSettings>
): Promise<ReceiptOcrSettings> {
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<void> {
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<void> {
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)
},
}
@@ -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()
})
})
@@ -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')
})
})
@@ -0,0 +1,460 @@
/**
* Receipt Analyzer using Claude Haiku Vision API
*
* SERVER-ONLY: This module uses the Anthropic SDK and must only be imported
* in server components or API routes.
*
* Analyzes receipt images and extracts line items, merchant info,
* and special flags (restaurant, Systembolaget, foreign merchant).
*/
import 'server-only'
import Anthropic from '@anthropic-ai/sdk'
import { ChatAnthropic } from '@langchain/anthropic'
import { HumanMessage, SystemMessage } from '@langchain/core/messages'
import { z } from 'zod'
import type { ReceiptExtractionResult, ExtractedLineItem } from '@/types'
import {
SYSTEMBOLAGET_PATTERNS,
RESTAURANT_PATTERNS,
RESTAURANT_MCC_CODES,
} from './receipt-utils'
// Re-export client-safe functions for convenience
export {
calculateRepresentationLimits,
isRestaurantMCC,
detectSystembolaget,
detectRestaurant,
} from './receipt-utils'
const anthropic = new Anthropic()
// Maximum retries for API calls
const MAX_RETRIES = 3
const RETRY_DELAY_MS = 1000
/**
* Analyze a receipt image using Claude Haiku Vision
*/
export async function analyzeReceipt(
imageBase64: string,
mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' = 'image/jpeg'
): Promise<ReceiptExtractionResult> {
const systemPrompt = `Du är expert på att extrahera data från svenska kvitton och fakturor.
Din uppgift är att noggrant analysera kvittobilden och extrahera all relevant information.
VIKTIGT:
- Extrahera VARJE artikelrad, inte bara summan
- Identifiera momssats per rad om möjligt (25%, 12%, 6%)
- Flagga om detta är: restaurang, Systembolaget, eller utländsk handlare
- Svenska organisationsnummer är i format XXXXXX-XXXX
- Momsregistreringsnummer börjar med SE
- Datum ska vara i ISO-format (YYYY-MM-DD)
- Belopp ska vara numeriska värden utan valutasymboler
- Ange konfidenstal (0.0-1.0) för hela extraheringen baserat på bildkvalitet`
const userPrompt = `Analysera detta kvitto och extrahera strukturerad data.
Returnera ett JSON-objekt med följande struktur:
{
"merchant": {
"name": "Handlarens namn",
"orgNumber": "XXXXXX-XXXX eller null",
"vatNumber": "SE... eller null",
"isForeign": false
},
"receipt": {
"date": "YYYY-MM-DD",
"time": "HH:MM eller null",
"currency": "SEK"
},
"lineItems": [
{
"description": "Artikelbeskrivning",
"quantity": 1,
"unitPrice": 100.00,
"lineTotal": 100.00,
"vatRate": 25,
"suggestedCategory": "equipment|software|travel|office|marketing|professional_services|education|other"
}
],
"totals": {
"subtotal": 100.00,
"vatAmount": 25.00,
"total": 125.00
},
"flags": {
"isRestaurant": false,
"isSystembolaget": false,
"isForeignMerchant": false
},
"confidence": 0.95
}
KATEGORIER för suggestedCategory:
- equipment: Datorer, telefoner, kameror, teknikprylar
- software: Program, appar, molntjänster, prenumerationer
- travel: Flyg, tåg, hotell, taxi
- office: Kontorsmaterial, möbler, hyra
- marketing: Reklam, marknadsföring, PR
- professional_services: Konsulter, redovisning, juridik
- education: Kurser, böcker, utbildning
- other: Övrigt
Returnera ENDAST JSON-objektet, ingen annan text.`
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const message = await anthropic.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 4096,
messages: [
{
role: 'user',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: mimeType,
data: imageBase64,
},
},
{
type: 'text',
text: userPrompt,
},
],
},
],
system: systemPrompt,
})
// Extract the text content
const content = message.content[0]
if (content.type !== 'text') {
throw new Error('Unexpected response type from AI')
}
// Parse the JSON response - 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)
// Validate and enhance the result
return validateAndEnhanceResult(parsed)
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
// Don't retry on JSON parse errors
if (error instanceof SyntaxError) {
throw new Error(`Failed to parse AI response: ${lastError.message}`)
}
// Wait before retrying
if (attempt < MAX_RETRIES - 1) {
await sleep(RETRY_DELAY_MS * (attempt + 1))
}
}
}
throw new Error(`Receipt analysis failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
}
/**
* Validate and enhance the extraction result
*/
function validateAndEnhanceResult(raw: unknown): ReceiptExtractionResult {
if (!raw || typeof raw !== 'object') {
throw new Error('Invalid extraction result: not an object')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = raw as any
const merchant = data.merchant || {}
const receipt = data.receipt || {}
const totals = data.totals || {}
const flags = data.flags || {}
// Validate line items
const lineItems = validateLineItems(data.lineItems)
// Detect special merchants if not already flagged
const merchantName = (merchant.name || '').toLowerCase()
const isSystembolaget = flags.isSystembolaget || detectSystembolagetLocal(merchantName)
const isRestaurant = flags.isRestaurant || detectRestaurantLocal(merchantName)
const isForeign = flags.isForeignMerchant || detectForeignMerchant(merchant, receipt.currency)
const result: ReceiptExtractionResult = {
merchant: {
name: validateString(merchant.name),
orgNumber: validateOrgNumber(merchant.orgNumber),
vatNumber: validateVatNumber(merchant.vatNumber),
isForeign,
},
receipt: {
date: validateDate(receipt.date),
time: validateTime(receipt.time),
currency: validateString(receipt.currency) || 'SEK',
},
lineItems,
totals: {
subtotal: validateNumber(totals.subtotal),
vatAmount: validateNumber(totals.vatAmount),
total: validateNumber(totals.total),
},
flags: {
isRestaurant,
isSystembolaget,
isForeignMerchant: isForeign,
},
confidence: validateNumber(data.confidence) || 0.5,
}
return result
}
/**
* Validate line items array
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function validateLineItems(data: any): ExtractedLineItem[] {
if (!Array.isArray(data)) return []
return data
.filter((item) => item && typeof item === 'object' && item.description)
.map((item) => ({
description: String(item.description).trim(),
quantity: validateNumber(item.quantity) || 1,
unitPrice: validateNumber(item.unitPrice),
lineTotal: validateNumber(item.lineTotal) || 0,
vatRate: validateNumber(item.vatRate),
suggestedCategory: validateCategory(item.suggestedCategory),
confidence: validateNumber(item.confidence) || undefined,
}))
.filter((item) => item.lineTotal > 0 || item.description.length > 0)
}
/**
* Validate expense category suggestion
*/
function validateCategory(value: unknown): string | null {
const validCategories = [
'equipment',
'software',
'travel',
'office',
'marketing',
'professional_services',
'education',
'other',
]
if (typeof value === 'string' && validCategories.includes(value)) {
return value
}
return null
}
/**
* Detect if merchant is Systembolaget (local version using imported patterns)
*/
function detectSystembolagetLocal(merchantName: string): boolean {
return SYSTEMBOLAGET_PATTERNS.some((pattern) => merchantName.includes(pattern))
}
/**
* Detect if merchant is a restaurant (local version using imported patterns)
*/
function detectRestaurantLocal(merchantName: string): boolean {
return RESTAURANT_PATTERNS.some((pattern) => merchantName.includes(pattern))
}
/**
* Detect if merchant is foreign (non-Swedish)
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function detectForeignMerchant(merchant: any, currency?: string): boolean {
// Check currency (non-SEK suggests foreign)
if (currency && currency !== 'SEK') {
return true
}
// Check org number format (Swedish org numbers are 10 digits)
const orgNumber = merchant.orgNumber || ''
if (orgNumber && !isSwedishOrgNumber(orgNumber)) {
return true
}
// Check VAT number (Swedish starts with SE)
const vatNumber = merchant.vatNumber || ''
if (vatNumber && !vatNumber.toUpperCase().startsWith('SE')) {
return true
}
return false
}
/**
* Check if org number is Swedish format
*/
function isSwedishOrgNumber(value: string): boolean {
const digits = value.replace(/\D/g, '')
return digits.length === 10
}
// Validation helpers
function validateString(value: unknown): string | null {
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
return null
}
function validateNumber(value: unknown): number | null {
if (typeof value === 'number' && !isNaN(value)) {
return value
}
if (typeof value === 'string') {
const parsed = parseFloat(value.replace(/[^\d.-]/g, ''))
if (!isNaN(parsed)) {
return parsed
}
}
return null
}
function validateDate(value: unknown): string | null {
if (typeof value !== 'string') return null
const date = new Date(value)
if (isNaN(date.getTime())) return null
return date.toISOString().split('T')[0]
}
function validateTime(value: unknown): string | null {
if (typeof value !== 'string') return null
// Match HH:MM or HH:MM:SS
const match = value.match(/^(\d{2}):(\d{2})(:\d{2})?$/)
if (match) {
return `${match[1]}:${match[2]}`
}
return null
}
function validateOrgNumber(value: unknown): string | null {
if (typeof value !== 'string') return null
const digits = value.replace(/\D/g, '')
// Swedish org numbers are 10 digits
if (digits.length === 10) {
return `${digits.slice(0, 6)}-${digits.slice(6)}`
}
return null
}
function validateVatNumber(value: unknown): string | null {
if (typeof value !== 'string') return null
const cleaned = value.trim().toUpperCase()
if (cleaned.startsWith('SE') && cleaned.length >= 12) {
return cleaned
}
return null
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
const ProductEstimationSchema = z.object({
estimatedValue: z.number().describe('Uppskattat marknadsvärde i SEK'),
confidence: z.number().min(0).max(1).describe('Hur säker du är på uppskattningen (0.0-1.0)'),
description: z.string().describe('Kort beskrivning av produkten'),
brand: z.string().nullable().describe('Varumärke om identifierbart, annars null'),
})
/**
* Estimate product value from image using Claude Vision via LangChain
* Used for gift/product registration without receipt
*/
export async function estimateProductValue(
imageBase64: string,
mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' = 'image/jpeg'
): Promise<{
estimatedValue: number
confidence: number
description: string
brand: string | null
}> {
const model = new ChatAnthropic({
modelName: 'claude-haiku-4-5-20251001',
maxTokens: 1024,
temperature: 0,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
})
const structuredModel = model.withStructuredOutput(ProductEstimationSchema)
const systemPrompt = `Du är expert på att uppskatta marknadsvärdet på produkter baserat på bilder.
Din uppgift är att identifiera produkten och ge en rimlig uppskattning av dess marknadsvärde i svenska kronor.`
const userPrompt = `Analysera produkten i bilden och uppskatta dess marknadsvärde i SEK. Identifiera varumärke om möjligt.`
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const result = await structuredModel.invoke([
new SystemMessage(systemPrompt),
new HumanMessage({
content: [
{
type: 'image_url',
image_url: { url: `data:${mimeType};base64,${imageBase64}` },
},
{
type: 'text',
text: userPrompt,
},
],
}),
])
return {
estimatedValue: result.estimatedValue || 0,
confidence: result.confidence || 0.5,
description: result.description || 'Okänd produkt',
brand: result.brand,
}
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
if (attempt < MAX_RETRIES - 1) {
await sleep(RETRY_DELAY_MS * (attempt + 1))
}
}
}
throw new Error(`Product value estimation failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
}
@@ -0,0 +1,297 @@
/**
* Receipt Categorizer - Auto-categorization of receipt line items
*
* Maps line item descriptions to BAS accounts and expense categories
* using pattern matching and AI-suggested categories.
*/
import type { TransactionCategory, ReceiptLineItem, ExtractedLineItem } from '@/types'
// Category mappings from suggested category to TransactionCategory
const CATEGORY_MAPPING: Record<string, TransactionCategory> = {
equipment: 'expense_equipment',
software: 'expense_software',
travel: 'expense_travel',
office: 'expense_office',
marketing: 'expense_marketing',
professional_services: 'expense_professional_services',
education: 'expense_education',
other: 'expense_other',
}
// BAS account mappings for expense categories
const BAS_ACCOUNT_MAPPING: Record<TransactionCategory, string> = {
expense_equipment: '5410', // Förbrukningsinventarier
expense_software: '5420', // Programvaror
expense_travel: '5800', // Resekostnader
expense_office: '5010', // Lokalhyra / Kontorsmaterial
expense_marketing: '5910', // Annonsering
expense_professional_services: '6530', // Redovisningstjänster
expense_education: '6991', // Övriga avdragsgilla kostnader
expense_bank_fees: '6570', // Bankavgifter
expense_card_fees: '6570', // Kortavgifter
expense_currency_exchange: '7960', // Valutakursförluster
expense_other: '6991', // Övriga avdragsgilla kostnader
// Non-expense categories (for completeness)
income_services: '3001',
income_products: '3001',
income_other: '3900',
private: '2013',
uncategorized: '6991',
}
// Keyword patterns for auto-categorization
const CATEGORY_PATTERNS: Array<{
category: TransactionCategory
patterns: RegExp[]
}> = [
{
category: 'expense_equipment',
patterns: [
/\b(dator|laptop|macbook|ipad|tablet|telefon|iphone|samsung|kamera|camera|mikrofon|microphone|belysning|lighting|stativ|tripod|usb|kabel|adapter|hörlurar|headphones|airpods)\b/i,
/\b(apple|logitech|sony|canon|nikon|rode|elgato|razer)\b/i,
],
},
{
category: 'expense_software',
patterns: [
/\b(adobe|microsoft|spotify|netflix|youtube premium|dropbox|google|icloud|canva|notion|slack|zoom|teams|subscription|prenumeration|licens|license)\b/i,
/\b(app|program|software|saas|cloud)\b/i,
],
},
{
category: 'expense_travel',
patterns: [
/\b(flyg|flight|hotell|hotel|taxi|uber|bolt|tåg|train|sj|arlanda|bromma|landvetter|bensin|fuel|parkering|parking|biljett|ticket)\b/i,
/\b(resa|travel|transport|resekostnad)\b/i,
],
},
{
category: 'expense_office',
patterns: [
/\b(hyra|rent|el|electricity|internet|bredband|vatten|försäkring|insurance|städning|cleaning|kontorsmaterial|papper|toner|skrivare|printer)\b/i,
/\b(ikea|clas ohlson|biltema|staples|lyreco)\b/i,
],
},
{
category: 'expense_marketing',
patterns: [
/\b(reklam|advertising|annons|ad|meta ads|facebook ads|instagram ads|google ads|tiktok ads|influencer|sponsor|kampanj|campaign|pr|press)\b/i,
/\b(marknadsföring|marketing|promotion)\b/i,
],
},
{
category: 'expense_professional_services',
patterns: [
/\b(konsult|consultant|redovisning|accounting|bokföring|bookkeeping|juridik|legal|advokat|lawyer|revisor|auditor)\b/i,
/\b(tjänst|service|arvode|fee)\b/i,
],
},
{
category: 'expense_education',
patterns: [
/\b(kurs|course|utbildning|training|bok|book|seminar|konferens|conference|workshop|webinar|certifiering|certification)\b/i,
/\b(lärande|learning|studie|study)\b/i,
],
},
]
/**
* Get the TransactionCategory from AI-suggested category
*/
export function mapSuggestedCategory(suggestedCategory: string | null): TransactionCategory | null {
if (!suggestedCategory) return null
return CATEGORY_MAPPING[suggestedCategory] || null
}
/**
* Get BAS account for a category
*/
export function getBASAccount(category: TransactionCategory): string {
return BAS_ACCOUNT_MAPPING[category] || '6991'
}
/**
* Auto-categorize a line item based on description
*/
export function categorizeLineItem(description: string): {
category: TransactionCategory | null
confidence: number
} {
const normalizedDescription = description.toLowerCase()
for (const { category, patterns } of CATEGORY_PATTERNS) {
for (const pattern of patterns) {
if (pattern.test(normalizedDescription)) {
return {
category,
confidence: 0.7, // Pattern-based confidence
}
}
}
}
return {
category: null,
confidence: 0,
}
}
/**
* Process extracted line items and add category suggestions
*/
export function processLineItems(
lineItems: ExtractedLineItem[]
): Array<ExtractedLineItem & { category: TransactionCategory | null; basAccount: string | null }> {
return lineItems.map((item) => {
// First try the AI-suggested category
let category = mapSuggestedCategory(item.suggestedCategory)
let confidence = item.confidence || 0.8
// If no AI suggestion, try pattern matching
if (!category) {
const patternResult = categorizeLineItem(item.description)
category = patternResult.category
confidence = patternResult.confidence
}
return {
...item,
category,
basAccount: category ? getBASAccount(category) : null,
confidence,
}
})
}
/**
* Calculate split amounts for mixed business/private receipt
*/
export function calculateReceiptSplit(
lineItems: Array<{ lineTotal: number; is_business: boolean | null }>
): {
businessTotal: number
privateTotal: number
unclassifiedTotal: number
businessPercentage: number
} {
let businessTotal = 0
let privateTotal = 0
let unclassifiedTotal = 0
for (const item of lineItems) {
if (item.is_business === true) {
businessTotal += item.lineTotal
} else if (item.is_business === false) {
privateTotal += item.lineTotal
} else {
unclassifiedTotal += item.lineTotal
}
}
const total = businessTotal + privateTotal + unclassifiedTotal
const businessPercentage = total > 0 ? (businessTotal / total) * 100 : 0
return {
businessTotal: Math.round(businessTotal * 100) / 100,
privateTotal: Math.round(privateTotal * 100) / 100,
unclassifiedTotal: Math.round(unclassifiedTotal * 100) / 100,
businessPercentage: Math.round(businessPercentage * 10) / 10,
}
}
/**
* Aggregate line items by category for summary display
*/
export function aggregateByCategory(
lineItems: Array<{ lineTotal: number; category: TransactionCategory | null; is_business: boolean | null }>
): Array<{ category: TransactionCategory | null; total: number; count: number }> {
const aggregation = new Map<TransactionCategory | null, { total: number; count: number }>()
for (const item of lineItems) {
// Only aggregate business items
if (item.is_business !== true) continue
const existing = aggregation.get(item.category) || { total: 0, count: 0 }
aggregation.set(item.category, {
total: existing.total + item.lineTotal,
count: existing.count + 1,
})
}
return Array.from(aggregation.entries())
.map(([category, data]) => ({
category,
total: Math.round(data.total * 100) / 100,
count: data.count,
}))
.sort((a, b) => b.total - a.total)
}
/**
* Generate default line item classifications based on receipt flags
*/
export function getDefaultClassification(
isRestaurant: boolean,
isSystembolaget: boolean
): {
defaultIsBusiness: boolean | null
requiresReview: boolean
warningMessage: string | null
} {
if (isSystembolaget) {
return {
defaultIsBusiness: false, // Default to private for Systembolaget
requiresReview: true,
warningMessage: 'Alkohol från Systembolaget är normalt en privat utgift. Om det är representation, ange syfte och antal personer.',
}
}
if (isRestaurant) {
return {
defaultIsBusiness: null, // Need user input
requiresReview: true,
warningMessage: 'Restaurangbesök kräver uppgift om antal personer och syfte för att räknas som representation.',
}
}
return {
defaultIsBusiness: null, // User decides per item
requiresReview: false,
warningMessage: null,
}
}
/**
* Category labels in Swedish
*/
export const CATEGORY_LABELS: Record<TransactionCategory, string> = {
expense_equipment: 'Utrustning',
expense_software: 'Programvara',
expense_travel: 'Resa',
expense_office: 'Kontor',
expense_marketing: 'Marknadsföring',
expense_professional_services: 'Konsulttjänster',
expense_education: 'Utbildning',
expense_bank_fees: 'Bankavgift',
expense_card_fees: 'Kortavgift',
expense_currency_exchange: 'Valutaväxling',
expense_other: 'Övrigt',
income_services: 'Tjänsteintäkt',
income_products: 'Varuintäkt',
income_other: 'Övrig intäkt',
private: 'Privat',
uncategorized: 'Ej kategoriserad',
}
/**
* Get expense categories only (for dropdown)
*/
export function getExpenseCategories(): Array<{ value: TransactionCategory; label: string }> {
return Object.entries(CATEGORY_LABELS)
.filter(([key]) => key.startsWith('expense_'))
.map(([value, label]) => ({
value: value as TransactionCategory,
label,
}))
}
@@ -0,0 +1,288 @@
/**
* Receipt Matcher - Fuzzy matching between receipts and transactions
*
* Uses date variance, amount tolerance, and merchant name similarity
* to find potential transaction matches for receipts.
*/
import type { Transaction, Receipt, ReceiptMatchCandidate } from '@/types'
// Matching configuration
const DATE_TOLERANCE_DAYS = 3 // Allow ±3 days variance
const AMOUNT_TOLERANCE_PERCENT = 0.05 // Allow ±5% variance (for currency conversion)
const MIN_MATCH_CONFIDENCE = 0.4 // Minimum confidence to consider a match
/**
* Find potential transaction matches for a receipt
*/
export function findTransactionMatches(
receipt: Receipt,
transactions: Transaction[]
): ReceiptMatchCandidate[] {
if (!receipt.receipt_date || !receipt.total_amount) {
return []
}
const receiptDate = new Date(receipt.receipt_date)
const receiptAmount = Math.abs(receipt.total_amount)
const merchantName = (receipt.merchant_name || '').toLowerCase()
const candidates: ReceiptMatchCandidate[] = []
for (const transaction of transactions) {
// Skip already matched transactions
if (transaction.receipt_id) continue
// Only match expense transactions (negative amounts)
if (transaction.amount >= 0) continue
const transactionDate = new Date(transaction.date)
const transactionAmount = Math.abs(transaction.amount)
// Calculate date variance
const dateVariance = Math.abs(
(receiptDate.getTime() - transactionDate.getTime()) / (1000 * 60 * 60 * 24)
)
// Skip if outside date tolerance
if (dateVariance > DATE_TOLERANCE_DAYS) continue
// Calculate amount variance
const amountVariance = Math.abs(receiptAmount - transactionAmount) / receiptAmount
// Skip if outside amount tolerance (unless it's a foreign receipt with currency conversion)
if (amountVariance > AMOUNT_TOLERANCE_PERCENT && !receipt.is_foreign_merchant) continue
if (amountVariance > 0.15) continue // Hard limit even for foreign
// Calculate merchant similarity
const transactionMerchant = (transaction.merchant_name || transaction.description || '').toLowerCase()
const merchantSimilarity = calculateMerchantSimilarity(merchantName, transactionMerchant)
// Calculate overall confidence
const { confidence, matchReasons } = calculateMatchConfidence(
dateVariance,
amountVariance,
merchantSimilarity,
receipt,
transaction
)
if (confidence >= MIN_MATCH_CONFIDENCE) {
candidates.push({
transaction,
confidence,
matchReasons,
dateVariance,
amountVariance,
})
}
}
// Sort by confidence (highest first)
return candidates.sort((a, b) => b.confidence - a.confidence)
}
/**
* Calculate merchant name similarity using Levenshtein distance
*/
function calculateMerchantSimilarity(name1: string, name2: string): number {
if (!name1 || !name2) return 0
// Normalize names
const n1 = normalizeMerchantName(name1)
const n2 = normalizeMerchantName(name2)
// Check for exact match
if (n1 === n2) return 1
// Check if one contains the other
if (n1.includes(n2) || n2.includes(n1)) return 0.9
// Check for word overlap
const words1 = n1.split(/\s+/)
const words2 = n2.split(/\s+/)
const commonWords = words1.filter((w) => words2.includes(w))
if (commonWords.length > 0) {
const overlapScore = commonWords.length / Math.max(words1.length, words2.length)
if (overlapScore >= 0.5) return 0.7 + overlapScore * 0.2
}
// Calculate Levenshtein similarity
const distance = levenshteinDistance(n1, n2)
const maxLength = Math.max(n1.length, n2.length)
const similarity = 1 - distance / maxLength
return similarity
}
/**
* Normalize merchant name for comparison
*/
function normalizeMerchantName(name: string): string {
return name
.toLowerCase()
.replace(/[^\w\såäöé]/g, '') // Remove special chars except Swedish letters
.replace(/\b(ab|hb|kb|ek|för|stiftelse)\b/g, '') // Remove company suffixes
.replace(/\s+/g, ' ')
.trim()
}
/**
* Calculate Levenshtein distance between two strings
*/
function levenshteinDistance(str1: string, str2: string): number {
const m = str1.length
const n = str2.length
// Create matrix
const dp: number[][] = Array(m + 1)
.fill(null)
.map(() => Array(n + 1).fill(0))
// Initialize first row and column
for (let i = 0; i <= m; i++) dp[i][0] = i
for (let j = 0; j <= n; j++) dp[0][j] = j
// Fill the matrix
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1
dp[i][j] = Math.min(
dp[i - 1][j] + 1, // deletion
dp[i][j - 1] + 1, // insertion
dp[i - 1][j - 1] + cost // substitution
)
}
}
return dp[m][n]
}
/**
* Calculate overall match confidence and reasons
*/
function calculateMatchConfidence(
dateVariance: number,
amountVariance: number,
merchantSimilarity: number,
receipt: Receipt,
transaction: Transaction
): { confidence: number; matchReasons: string[] } {
const matchReasons: string[] = []
let totalWeight = 0
let weightedScore = 0
// Date score (weight: 25%)
const dateScore = Math.max(0, 1 - dateVariance / DATE_TOLERANCE_DAYS)
if (dateScore >= 0.8) {
matchReasons.push(dateVariance === 0 ? 'Exakt datum' : `Datum ±${Math.round(dateVariance)} dagar`)
}
weightedScore += dateScore * 0.25
totalWeight += 0.25
// Amount score (weight: 40%)
const amountScore = Math.max(0, 1 - amountVariance / AMOUNT_TOLERANCE_PERCENT)
if (amountVariance < 0.01) {
matchReasons.push('Exakt belopp')
} else if (amountVariance < AMOUNT_TOLERANCE_PERCENT) {
matchReasons.push(`Belopp ±${Math.round(amountVariance * 100)}%`)
}
weightedScore += amountScore * 0.4
totalWeight += 0.4
// Merchant score (weight: 35%)
if (merchantSimilarity > 0) {
if (merchantSimilarity >= 0.9) {
matchReasons.push('Handlare matchar')
} else if (merchantSimilarity >= 0.6) {
matchReasons.push('Trolig handlarmatch')
}
weightedScore += merchantSimilarity * 0.35
totalWeight += 0.35
}
// Bonus for MCC match (if transaction has MCC and receipt is flagged)
if (receipt.is_restaurant && transaction.mcc_code) {
const restaurantMCCs = [5812, 5813, 5814]
if (restaurantMCCs.includes(transaction.mcc_code)) {
matchReasons.push('Restaurang MCC matchar')
weightedScore += 0.1
}
}
const confidence = totalWeight > 0 ? weightedScore / totalWeight : 0
return {
confidence: Math.round(confidence * 100) / 100,
matchReasons,
}
}
/**
* Get unmatched transactions for a user
* (transactions without linked receipts)
*/
export function filterUnmatchedTransactions(transactions: Transaction[]): Transaction[] {
return transactions.filter((t) => !t.receipt_id && t.amount < 0)
}
/**
* Get unmatched receipts for a user
* (confirmed receipts without linked transactions)
*/
export function filterUnmatchedReceipts(receipts: Receipt[]): Receipt[] {
return receipts.filter((r) => !r.matched_transaction_id && r.status === 'confirmed')
}
/**
* Auto-match receipts to transactions
* Returns matches with confidence >= threshold
*/
export function autoMatchReceipts(
receipts: Receipt[],
transactions: Transaction[],
confidenceThreshold: number = 0.8
): Array<{ receipt: Receipt; match: ReceiptMatchCandidate }> {
const results: Array<{ receipt: Receipt; match: ReceiptMatchCandidate }> = []
for (const receipt of receipts) {
if (receipt.matched_transaction_id) continue
const matches = findTransactionMatches(receipt, transactions)
const bestMatch = matches[0]
if (bestMatch && bestMatch.confidence >= confidenceThreshold) {
results.push({ receipt, match: bestMatch })
// Mark the transaction as used so it's not matched again
// (Note: this mutates the transaction, caller should be aware)
}
}
return results
}
/**
* Calculate match statistics for queue display
*/
export function calculateMatchStats(
receipts: Receipt[],
transactions: Transaction[]
): {
unmatchedReceiptsCount: number
unmatchedTransactionsCount: number
pendingReviewCount: number
matchedCount: number
} {
const unmatchedReceipts = filterUnmatchedReceipts(receipts)
const unmatchedTransactions = filterUnmatchedTransactions(transactions)
const pendingReview = receipts.filter((r) => r.status === 'extracted')
const matched = receipts.filter((r) => r.matched_transaction_id)
return {
unmatchedReceiptsCount: unmatchedReceipts.length,
unmatchedTransactionsCount: unmatchedTransactions.length,
pendingReviewCount: pendingReview.length,
matchedCount: matched.length,
}
}
@@ -0,0 +1,94 @@
/**
* Receipt utility functions (client-safe)
*
* These functions can be used in both client and server components.
*/
// Known Swedish merchants for special handling
export const SYSTEMBOLAGET_PATTERNS = [
'systembolaget',
'systemet',
'bolaget',
]
export const RESTAURANT_MCC_CODES = [5812, 5813, 5814]
export const RESTAURANT_PATTERNS = [
'restaurang',
'restaurant',
'café',
'cafe',
'bistro',
'pizzeria',
'sushi',
'thai',
'wok',
'grill',
'bar',
'pub',
'krog',
'brasserie',
'trattoria',
'osteria',
'matsal',
'lunch',
'middag',
]
/**
* Check if a transaction MCC code indicates restaurant
*/
export function isRestaurantMCC(mccCode: number | null): boolean {
if (!mccCode) return false
return RESTAURANT_MCC_CODES.includes(mccCode)
}
/**
* Calculate restaurant representation limits
*
* Swedish rules (2024):
* - Max 60 kr/person för intern representation (lunch/middag)
* - Max 180 kr/person för extern representation
* - Max 180 kr/person för representationsgåvor
*
* For simplicity, we use 60 kr as the safe deductible limit per person
*/
export function calculateRepresentationLimits(
totalAmount: number,
persons: number
): {
deductibleAmount: number
nonDeductibleAmount: number
perPersonAmount: number
maxDeductiblePerPerson: number
} {
const MAX_DEDUCTIBLE_PER_PERSON = 60
const perPersonAmount = totalAmount / persons
const deductiblePerPerson = Math.min(perPersonAmount, MAX_DEDUCTIBLE_PER_PERSON)
const deductibleAmount = deductiblePerPerson * persons
const nonDeductibleAmount = totalAmount - deductibleAmount
return {
deductibleAmount: Math.round(deductibleAmount * 100) / 100,
nonDeductibleAmount: Math.round(nonDeductibleAmount * 100) / 100,
perPersonAmount: Math.round(perPersonAmount * 100) / 100,
maxDeductiblePerPerson: MAX_DEDUCTIBLE_PER_PERSON,
}
}
/**
* Detect if merchant is Systembolaget
*/
export function detectSystembolaget(merchantName: string): boolean {
const name = merchantName.toLowerCase()
return SYSTEMBOLAGET_PATTERNS.some((pattern) => name.includes(pattern))
}
/**
* Detect if merchant is a restaurant
*/
export function detectRestaurant(merchantName: string): boolean {
const name = merchantName.toLowerCase()
return RESTAURANT_PATTERNS.some((pattern) => name.includes(pattern))
}
@@ -0,0 +1,344 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
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 { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Camera,
Receipt,
Check,
Clock,
AlertCircle,
ArrowRight,
} from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { useRouter } from 'next/navigation'
import ReceiptDashboard from '../components/ReceiptDashboard'
import ReceiptReviewView from '../components/ReceiptReviewView'
import TransactionMatcher from '../components/TransactionMatcher'
import type { Receipt as ReceiptType, ReceiptLineItem, ReceiptQueueSummary, ConfirmLineItemInput } from '@/types'
type ViewMode = 'dashboard' | 'list' | 'review' | 'match'
type ListFilter = 'all' | 'pending' | 'confirmed'
export default function ReceiptsPage() {
const router = useRouter()
const [viewMode, setViewMode] = useState<ViewMode>('dashboard')
const [listFilter, setListFilter] = useState<ListFilter>('all')
const [receipts, setReceipts] = useState<(ReceiptType & { line_items: ReceiptLineItem[] })[]>([])
const [summary, setSummary] = useState<ReceiptQueueSummary | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Selected receipt for review/match
const [selectedReceipt, setSelectedReceipt] = useState<(ReceiptType & { line_items: ReceiptLineItem[] }) | null>(null)
// Fetch receipts and summary
const fetchData = useCallback(async () => {
setIsLoading(true)
try {
const [receiptsRes, queueRes] = await Promise.all([
fetch('/api/extensions/receipt-ocr'),
fetch('/api/extensions/receipt-ocr/queue'),
])
const [receiptsData, queueData] = await Promise.all([
receiptsRes.json(),
queueRes.json(),
])
if (receiptsData.data) {
setReceipts(receiptsData.data)
}
if (queueData.data?.summary) {
setSummary(queueData.data.summary)
}
} catch (error) {
console.error('Fetch error:', error)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
// Filter receipts based on tab
const filteredReceipts = receipts.filter((r) => {
if (listFilter === 'pending') return r.status === 'extracted'
if (listFilter === 'confirmed') return r.status === 'confirmed'
return true
})
// Handle scan receipt
const handleScanReceipt = () => {
router.push('/receipts/scan')
}
// Handle view receipt queue
const handleViewReceiptQueue = () => {
setListFilter('pending')
setViewMode('list')
}
// Handle view transaction queue
const handleViewTransactionQueue = () => {
router.push('/transactions?filter=unmatched')
}
// Handle receipt selection for review
const handleSelectReceipt = (receipt: ReceiptType & { line_items: ReceiptLineItem[] }) => {
setSelectedReceipt(receipt)
if (receipt.status === 'extracted') {
setViewMode('review')
} else {
setViewMode('match')
}
}
// Handle confirm receipt
const handleConfirmReceipt = async (data: {
line_items: ConfirmLineItemInput[]
representation_persons?: number
representation_purpose?: string
}) => {
if (!selectedReceipt) return
const response = await fetch(`/api/extensions/receipt-ocr/${selectedReceipt.id}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (response.ok) {
await fetchData()
setSelectedReceipt(null)
setViewMode('dashboard')
} else {
const errorData = await response.json()
throw new Error(errorData.error || 'Kunde inte bekräfta kvitto')
}
}
// Handle match receipt to transaction
const handleMatchReceipt = async (transactionId: string, confidence: number) => {
if (!selectedReceipt) return
const response = await fetch(`/api/extensions/receipt-ocr/${selectedReceipt.id}/match`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_id: transactionId, match_confidence: confidence }),
})
if (response.ok) {
await fetchData()
setSelectedReceipt(null)
setViewMode('dashboard')
}
}
// Render receipt review view
if (viewMode === 'review' && selectedReceipt) {
return (
<ReceiptReviewView
receipt={selectedReceipt}
onConfirm={handleConfirmReceipt}
onCancel={() => {
setSelectedReceipt(null)
setViewMode('dashboard')
}}
onFindMatches={() => setViewMode('match')}
/>
)
}
// Render transaction matcher
if (viewMode === 'match' && selectedReceipt) {
return (
<TransactionMatcher
receipt={selectedReceipt}
onMatch={handleMatchReceipt}
onSkip={() => {
setSelectedReceipt(null)
setViewMode('dashboard')
}}
onClose={() => {
setSelectedReceipt(null)
setViewMode('dashboard')
}}
/>
)
}
// Render main page
return (
<div className="container max-w-4xl mx-auto p-4 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Kvittohantering</h1>
<Button onClick={handleScanReceipt}>
<Camera className="mr-2 h-4 w-4" />
Skanna kvitto
</Button>
</div>
{/* Dashboard or List view toggle */}
<Tabs
value={viewMode === 'list' ? 'list' : 'dashboard'}
onValueChange={(v) => setViewMode(v as ViewMode)}
>
<TabsList>
<TabsTrigger value="dashboard">Översikt</TabsTrigger>
<TabsTrigger value="list">Alla kvitton</TabsTrigger>
</TabsList>
</Tabs>
{viewMode === 'dashboard' && (
<>
{isLoading ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
<Skeleton className="h-32" />
<Skeleton className="h-24" />
</div>
) : summary ? (
<ReceiptDashboard
summary={summary}
onScanReceipt={handleScanReceipt}
onViewReceiptQueue={handleViewReceiptQueue}
onViewTransactionQueue={handleViewTransactionQueue}
/>
) : (
<Card>
<CardContent className="pt-4 text-center py-8">
<AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p>Kunde inte ladda data</p>
<Button variant="outline" className="mt-4" onClick={fetchData}>
Försök igen
</Button>
</CardContent>
</Card>
)}
</>
)}
{viewMode === 'list' && (
<>
{/* List filters */}
<div className="flex gap-2">
<Button
variant={listFilter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setListFilter('all')}
>
Alla
</Button>
<Button
variant={listFilter === 'pending' ? 'default' : 'outline'}
size="sm"
onClick={() => setListFilter('pending')}
>
<Clock className="mr-1 h-3 w-3" />
Att granska
</Button>
<Button
variant={listFilter === 'confirmed' ? 'default' : 'outline'}
size="sm"
onClick={() => setListFilter('confirmed')}
>
<Check className="mr-1 h-3 w-3" />
Bekräftade
</Button>
</div>
{/* Receipt list */}
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
) : filteredReceipts.length === 0 ? (
<Card>
<CardContent className="pt-4 text-center py-8">
<Receipt className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">Inga kvitton hittades</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{filteredReceipts.map((receipt) => (
<Card
key={receipt.id}
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => handleSelectReceipt(receipt)}
>
<CardContent className="pt-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
{receipt.image_url ? (
<img
src={receipt.image_url}
alt="Receipt"
className="h-16 w-12 object-cover rounded"
/>
) : (
<div className="h-16 w-12 bg-muted rounded flex items-center justify-center">
<Receipt className="h-6 w-6 text-muted-foreground" />
</div>
)}
<div>
<p className="font-medium">{receipt.merchant_name || 'Okänt kvitto'}</p>
{receipt.receipt_date && (
<p className="text-sm text-muted-foreground">
{formatDate(receipt.receipt_date)}
</p>
)}
<div className="flex flex-wrap gap-1 mt-1">
<Badge
variant={
receipt.status === 'confirmed'
? 'default'
: receipt.status === 'extracted'
? 'secondary'
: receipt.status === 'error'
? 'destructive'
: 'outline'
}
>
{receipt.status === 'confirmed' && 'Bekräftat'}
{receipt.status === 'extracted' && 'Att granska'}
{receipt.status === 'processing' && 'Analyserar...'}
{receipt.status === 'pending' && 'Väntar'}
{receipt.status === 'error' && 'Fel'}
</Badge>
{receipt.matched_transaction_id && (
<Badge variant="outline">Kopplat</Badge>
)}
</div>
</div>
</div>
<div className="text-right">
<p className="font-bold">
{formatCurrency(receipt.total_amount || 0, receipt.currency)}
</p>
<ArrowRight className="h-4 w-4 text-muted-foreground mt-2 ml-auto" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
)}
</div>
)
}
@@ -0,0 +1,157 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import ReceiptCamera from '../../components/ReceiptCamera'
import ReceiptReviewView from '../../components/ReceiptReviewView'
import TransactionMatcher from '../../components/TransactionMatcher'
import { Loader2 } from 'lucide-react'
import type { Receipt, ReceiptLineItem, ConfirmLineItemInput } from '@/types'
type PageState = 'camera' | 'uploading' | 'review' | 'match' | 'done'
export default function ScanReceiptPage() {
const router = useRouter()
const [pageState, setPageState] = useState<PageState>('camera')
const [uploadError, setUploadError] = useState<string | null>(null)
const [receipt, setReceipt] = useState<(Receipt & { line_items: ReceiptLineItem[] }) | null>(null)
// Handle image capture
const handleCapture = async (imageData: string, mimeType: string) => {
setPageState('uploading')
setUploadError(null)
try {
// Convert base64 to blob
const byteCharacters = atob(imageData)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: mimeType })
// Create form data
const formData = new FormData()
formData.append('image', blob, 'receipt.jpg')
// Upload and analyze
const response = await fetch('/api/extensions/receipt-ocr/upload', {
method: 'POST',
body: formData,
})
const data = await response.json()
if (response.ok && data.data) {
setReceipt(data.data)
setPageState('review')
} else {
setUploadError(data.error || 'Kunde inte analysera kvittot')
setPageState('camera')
}
} catch (error) {
console.error('Upload error:', error)
setUploadError('Nätverksfel. Försök igen.')
setPageState('camera')
}
}
// Handle close/cancel
const handleClose = () => {
router.push('/receipts')
}
// Handle confirm receipt
const handleConfirm = async (data: {
line_items: ConfirmLineItemInput[]
representation_persons?: number
representation_purpose?: string
}) => {
if (!receipt) return
const response = await fetch(`/api/extensions/receipt-ocr/${receipt.id}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (response.ok) {
const updatedData = await response.json()
setReceipt(updatedData.data)
setPageState('match')
} else {
const errorData = await response.json()
throw new Error(errorData.error || 'Kunde inte bekräfta kvitto')
}
}
// Handle match to transaction
const handleMatch = async (transactionId: string, confidence: number) => {
if (!receipt) return
const response = await fetch(`/api/extensions/receipt-ocr/${receipt.id}/match`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_id: transactionId, match_confidence: confidence }),
})
if (response.ok) {
router.push('/receipts')
}
}
// Handle skip matching
const handleSkipMatch = () => {
router.push('/receipts')
}
// Render uploading state
if (pageState === 'uploading') {
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col items-center justify-center">
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
<p className="text-lg font-medium">Analyserar kvitto...</p>
<p className="text-sm text-muted-foreground mt-1">
AI läser av artiklar och belopp
</p>
</div>
)
}
// Render review view
if (pageState === 'review' && receipt) {
return (
<ReceiptReviewView
receipt={receipt}
onConfirm={handleConfirm}
onCancel={handleClose}
onFindMatches={() => setPageState('match')}
/>
)
}
// Render match view
if (pageState === 'match' && receipt) {
return (
<TransactionMatcher
receipt={receipt}
onMatch={handleMatch}
onSkip={handleSkipMatch}
onClose={handleClose}
/>
)
}
// Render camera (default)
return (
<>
{uploadError && (
<div className="fixed top-4 left-4 right-4 z-[60] bg-destructive text-destructive-foreground p-3 rounded-lg text-sm">
{uploadError}
</div>
)}
<ReceiptCamera onCapture={handleCapture} onClose={handleClose} />
</>
)
}
+171
View File
@@ -0,0 +1,171 @@
import type { TransactionCategory, Transaction } from '@/types'
// Receipt extraction status
export type ReceiptStatus = 'pending' | 'processing' | 'extracted' | 'confirmed' | 'error'
// Receipt record
export interface Receipt {
id: string
user_id: string
// Image storage
image_url: string
image_thumbnail_url: string | null
// Extraction status
status: ReceiptStatus
extraction_confidence: number | null
// Extracted header data
merchant_name: string | null
merchant_org_number: string | null
merchant_vat_number: string | null
receipt_date: string | null
receipt_time: string | null
total_amount: number | null
currency: string
vat_amount: number | null
// Special flags
is_restaurant: boolean
is_systembolaget: boolean
is_foreign_merchant: boolean
// Restaurant representation data
representation_persons: number | null
representation_purpose: string | null
// Transaction matching
matched_transaction_id: string | null
match_confidence: number | null
// Raw extraction data
raw_extraction: ReceiptExtractionResult | null
created_at: string
updated_at: string
// Relations (populated when fetched)
line_items?: ReceiptLineItem[]
matched_transaction?: Transaction
}
// Receipt line item record
export interface ReceiptLineItem {
id: string
receipt_id: string
// Extracted data
description: string
quantity: number
unit_price: number | null
line_total: number
vat_rate: number | null
vat_amount: number | null
// Classification
is_business: boolean | null
category: TransactionCategory | null
bas_account: string | null
// Confidence
extraction_confidence: number | null
suggested_category: string | null
sort_order: number
created_at: string
}
// AI extraction result from Claude Vision
export interface ReceiptExtractionResult {
merchant: {
name: string | null
orgNumber: string | null
vatNumber: string | null
isForeign: boolean
}
receipt: {
date: string | null
time: string | null
currency: string
}
lineItems: ExtractedLineItem[]
totals: {
subtotal: number | null
vatAmount: number | null
total: number | null
}
flags: {
isRestaurant: boolean
isSystembolaget: boolean
isForeignMerchant: boolean
}
confidence: number
}
// Extracted line item from AI
export interface ExtractedLineItem {
description: string
quantity: number
unitPrice: number | null
lineTotal: number
vatRate: number | null
suggestedCategory: string | null
confidence?: number
}
// Match candidate for receipt-to-transaction matching
export interface ReceiptMatchCandidate {
transaction: Transaction
confidence: number
matchReasons: string[]
dateVariance: number
amountVariance: number
}
// Input for creating a receipt
export interface CreateReceiptInput {
image_url: string
image_thumbnail_url?: string
}
// Input for confirming receipt line items
export interface ConfirmReceiptInput {
line_items: ConfirmLineItemInput[]
matched_transaction_id?: string
representation_persons?: number
representation_purpose?: string
}
export interface ConfirmLineItemInput {
id: string
is_business: boolean
category?: TransactionCategory
bas_account?: string
}
// Receipt queue summary
export interface ReceiptQueueSummary {
unmatched_receipts_count: number
unmatched_transactions_count: number
pending_review_count: number
streak_count: number
}
// Camera quality feedback
export interface CameraQualityFeedback {
lightingOk: boolean
distanceOk: boolean
focusOk: boolean
readyToCapture: boolean
message?: string
}
// Swedish labels for receipt status
export const RECEIPT_STATUS_LABELS: Record<ReceiptStatus, string> = {
pending: 'Väntar',
processing: 'Analyserar',
extracted: 'Extraherat',
confirmed: 'Bekräftat',
error: 'Fel'
}