Added extension functionality
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* AI Categorization Engine
|
||||
*
|
||||
* SERVER-ONLY: Uses the Anthropic SDK and must only be imported
|
||||
* in server components or API routes.
|
||||
*
|
||||
* Provider-abstracted AI categorization for Swedish BAS account mapping.
|
||||
* Default implementation uses Claude Haiku for cost efficiency.
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import type { TransactionCategory, EntityType } from '@/types'
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export interface TransactionForCategorization {
|
||||
id: string
|
||||
description: string
|
||||
amount: number
|
||||
date: string
|
||||
merchant_name: string | null
|
||||
mcc_code: number | null
|
||||
currency: string
|
||||
}
|
||||
|
||||
export interface CategorizationContext {
|
||||
entityType: EntityType
|
||||
recentHistory: { description: string; category: string }[]
|
||||
}
|
||||
|
||||
export interface CategorizationSuggestion {
|
||||
transactionId: string
|
||||
category: TransactionCategory
|
||||
basAccount: string
|
||||
taxCode: string | null
|
||||
confidence: number
|
||||
reasoning: string
|
||||
isPrivate: boolean
|
||||
}
|
||||
|
||||
export interface CategorizationProvider {
|
||||
categorize(
|
||||
transactions: TransactionForCategorization[],
|
||||
context: CategorizationContext
|
||||
): Promise<CategorizationSuggestion[]>
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BAS Account + Category Mapping (used in prompt)
|
||||
// ============================================================
|
||||
|
||||
function getCategoryAccountMap(entityType: EntityType): Record<string, { account: string; label: string }> {
|
||||
const educationAccount = entityType === 'aktiebolag' ? '7610' : '6991'
|
||||
return {
|
||||
income_services: { account: '3001', label: 'Tjänsteförsäljning' },
|
||||
income_products: { account: '3001', label: 'Varuförsäljning' },
|
||||
income_other: { account: '3900', label: 'Övriga intäkter' },
|
||||
expense_equipment: { account: '5410', label: 'Förbrukningsinventarier' },
|
||||
expense_software: { account: '5420', label: 'Programvara' },
|
||||
expense_travel: { account: '5800', label: 'Resekostnader' },
|
||||
expense_office: { account: '5010', label: 'Lokalhyra/kontorskostnad' },
|
||||
expense_marketing: { account: '5910', label: 'Annonsering/marknadsföring' },
|
||||
expense_professional_services: { account: '6530', label: 'Redovisning/konsulttjänster' },
|
||||
expense_education: { account: educationAccount, label: 'Utbildning' },
|
||||
expense_bank_fees: { account: '6570', label: 'Bankavgifter' },
|
||||
expense_card_fees: { account: '6570', label: 'Kortavgifter' },
|
||||
expense_currency_exchange: { account: '7960', label: 'Valutakursförluster' },
|
||||
expense_other: { account: '6991', label: 'Övriga kostnader' },
|
||||
private: { account: '2013', label: 'Privat uttag (EF) / Skuld till ägare (AB)' },
|
||||
}
|
||||
}
|
||||
|
||||
const NON_DEDUCTIBLE_RULES = `
|
||||
ICKE-AVDRAGSGILLA KOSTNADER (svensk skatterätt):
|
||||
- Kläder: Normalt inte avdragsgilla (RÅ 1988 ref. 35)
|
||||
- Gym/träning: Inte avdragsgilla som personlig kostnad (IL 9 kap 2§)
|
||||
- Kosmetika/hudvård: Normalt inte avdragsgillt
|
||||
- Frisör: Normalt privat kostnad
|
||||
- Representation/måltider: Max 300 kr/person exkl. moms (IL 16 kap 2§)
|
||||
- Gåvor: Reklamgåvor max 300 kr/mottagare, representationsgåvor max 180 kr
|
||||
- Telefon/dator vid blandad användning: Bara yrkesmässig del avdragsgill
|
||||
`
|
||||
|
||||
// ============================================================
|
||||
// Anthropic Provider
|
||||
// ============================================================
|
||||
|
||||
const MAX_RETRIES = 3
|
||||
const RETRY_DELAY_MS = 1000
|
||||
const MAX_BATCH_SIZE = 20
|
||||
|
||||
export class AnthropicCategorizationProvider implements CategorizationProvider {
|
||||
private client: Anthropic
|
||||
private model: string
|
||||
|
||||
constructor(model = 'claude-haiku-4-5-20251001') {
|
||||
this.client = new Anthropic()
|
||||
this.model = model
|
||||
}
|
||||
|
||||
async categorize(
|
||||
transactions: TransactionForCategorization[],
|
||||
context: CategorizationContext
|
||||
): Promise<CategorizationSuggestion[]> {
|
||||
// Cap batch size
|
||||
const batch = transactions.slice(0, MAX_BATCH_SIZE)
|
||||
if (batch.length === 0) return []
|
||||
|
||||
const privateAccount = context.entityType === 'aktiebolag' ? '2893' : '2013'
|
||||
const categoryAccountMap = getCategoryAccountMap(context.entityType)
|
||||
|
||||
const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
|
||||
Din uppgift är att kategorisera varje transaktion till rätt kategori och BAS-konto.
|
||||
|
||||
KATEGORIER OCH BAS-KONTON:
|
||||
${Object.entries(categoryAccountMap)
|
||||
.map(([cat, info]) => `- ${cat}: ${info.account} (${info.label})`)
|
||||
.join('\n')}
|
||||
|
||||
Företagsform: ${context.entityType === 'aktiebolag' ? 'Aktiebolag (AB)' : 'Enskild firma (EF)'}
|
||||
Privatkonto: ${privateAccount}
|
||||
|
||||
MOMSHANTERING:
|
||||
- Bankavgifter, kortavgifter, valutaväxling: MOMSFRIA
|
||||
- Övriga affärskostnader: Normalt 25% moms (ingående moms, MPI)
|
||||
- Intäkter: Normalt 25% moms (utgående moms, MP1)
|
||||
|
||||
${NON_DEDUCTIBLE_RULES}
|
||||
|
||||
REGLER:
|
||||
1. Negativa belopp = utgifter, positiva = intäkter
|
||||
2. Markera transaktioner som troligen är privata med isPrivate: true
|
||||
3. Ange confidence 0.0-1.0 baserat på hur säker du är
|
||||
4. Ange kort reasoning på svenska
|
||||
5. Om en transaktion liknar privat konsumtion (kläder, gym, etc.), sätt category: "private"
|
||||
6. taxCode: "MPI" för avdragsgilla affärskostnader med moms, "MP1" för intäkter med moms, null för momsfria/privata`
|
||||
|
||||
const historyContext =
|
||||
context.recentHistory.length > 0
|
||||
? `\nAnvändarens senaste kategoriseringar (lär dig mönster):\n${context.recentHistory
|
||||
.slice(0, 30)
|
||||
.map((h) => `- "${h.description}" → ${h.category}`)
|
||||
.join('\n')}`
|
||||
: ''
|
||||
|
||||
const transactionList = batch
|
||||
.map(
|
||||
(t, i) =>
|
||||
`${i + 1}. ID: ${t.id}
|
||||
Beskrivning: ${t.description}
|
||||
Belopp: ${t.amount} ${t.currency}
|
||||
Datum: ${t.date}${t.merchant_name ? `\n Handlare: ${t.merchant_name}` : ''}${t.mcc_code ? `\n MCC: ${t.mcc_code}` : ''}`
|
||||
)
|
||||
.join('\n\n')
|
||||
|
||||
const userPrompt = `Kategorisera följande transaktioner:
|
||||
${historyContext}
|
||||
|
||||
TRANSAKTIONER:
|
||||
${transactionList}
|
||||
|
||||
Returnera ett JSON-objekt med följande struktur:
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"transactionId": "id",
|
||||
"category": "expense_software",
|
||||
"basAccount": "5420",
|
||||
"taxCode": "MPI",
|
||||
"confidence": 0.9,
|
||||
"reasoning": "Spotify-prenumeration, typisk programvarukostnad",
|
||||
"isPrivate": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const message = await this.client.messages.create({
|
||||
model: this.model,
|
||||
max_tokens: 4096,
|
||||
system: systemPrompt,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: userPrompt,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const content = message.content[0]
|
||||
if (content.type !== 'text') {
|
||||
throw new Error('Unexpected response type from AI')
|
||||
}
|
||||
|
||||
// Strip markdown code blocks if present
|
||||
let jsonText = content.text.trim()
|
||||
if (jsonText.startsWith('```json')) {
|
||||
jsonText = jsonText.slice(7)
|
||||
} else if (jsonText.startsWith('```')) {
|
||||
jsonText = jsonText.slice(3)
|
||||
}
|
||||
if (jsonText.endsWith('```')) {
|
||||
jsonText = jsonText.slice(0, -3)
|
||||
}
|
||||
jsonText = jsonText.trim()
|
||||
|
||||
const parsed = JSON.parse(jsonText)
|
||||
return this.validateSuggestions(parsed.suggestions || [], batch, context.entityType)
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||
|
||||
// Don't retry on parse errors
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(`Failed to parse AI response: ${lastError.message}`)
|
||||
}
|
||||
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
await sleep(RETRY_DELAY_MS * (attempt + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`AI categorization failed after ${MAX_RETRIES} attempts: ${lastError?.message}`
|
||||
)
|
||||
}
|
||||
|
||||
private validateSuggestions(
|
||||
raw: unknown[],
|
||||
transactions: TransactionForCategorization[],
|
||||
entityType: EntityType
|
||||
): CategorizationSuggestion[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
|
||||
const categoryAccountMap = getCategoryAccountMap(entityType)
|
||||
const validTransactionIds = new Set(transactions.map((t) => t.id))
|
||||
const validCategories = new Set(Object.keys(categoryAccountMap).concat(['uncategorized']))
|
||||
|
||||
return raw
|
||||
.filter(
|
||||
(s): s is Record<string, unknown> =>
|
||||
s !== null && typeof s === 'object' && 'transactionId' in s
|
||||
)
|
||||
.filter((s) => validTransactionIds.has(s.transactionId as string))
|
||||
.map((s) => {
|
||||
const category = validCategories.has(s.category as string)
|
||||
? (s.category as TransactionCategory)
|
||||
: 'expense_other'
|
||||
|
||||
const accountInfo = categoryAccountMap[category]
|
||||
|
||||
return {
|
||||
transactionId: s.transactionId as string,
|
||||
category,
|
||||
basAccount: accountInfo?.account || (s.basAccount as string) || '6991',
|
||||
taxCode: (s.taxCode as string) || null,
|
||||
confidence: Math.max(0, Math.min(1, Number(s.confidence) || 0.5)),
|
||||
reasoning: (s.reasoning as string) || '',
|
||||
isPrivate: category === 'private' || Boolean(s.isPrivate),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
import {
|
||||
AnthropicCategorizationProvider,
|
||||
type CategorizationProvider,
|
||||
type TransactionForCategorization,
|
||||
type CategorizationContext,
|
||||
type CategorizationSuggestion,
|
||||
} from './categorizer'
|
||||
|
||||
// ============================================================
|
||||
// Settings
|
||||
// ============================================================
|
||||
|
||||
export interface AiCategorizationSettings {
|
||||
autoSuggestEnabled: boolean
|
||||
confidenceThreshold: number
|
||||
providerModel: string
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AiCategorizationSettings = {
|
||||
autoSuggestEnabled: true,
|
||||
confidenceThreshold: 0.7,
|
||||
providerModel: 'claude-haiku-4-5-20251001',
|
||||
}
|
||||
|
||||
export async function getSettings(userId: string): Promise<AiCategorizationSettings> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', userId)
|
||||
.eq('extension_id', 'ai-categorization')
|
||||
.eq('key', 'settings')
|
||||
.single()
|
||||
|
||||
if (!data?.value) return { ...DEFAULT_SETTINGS }
|
||||
|
||||
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<AiCategorizationSettings>) }
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
userId: string,
|
||||
partial: Partial<AiCategorizationSettings>
|
||||
): Promise<AiCategorizationSettings> {
|
||||
const current = await getSettings(userId)
|
||||
const merged = { ...current, ...partial }
|
||||
|
||||
const supabase = await createClient()
|
||||
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: 'ai-categorization',
|
||||
key: 'settings',
|
||||
value: merged,
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Provider
|
||||
// ============================================================
|
||||
|
||||
let provider: CategorizationProvider | null = null
|
||||
|
||||
function getProvider(model?: string): CategorizationProvider {
|
||||
if (!provider) {
|
||||
provider = new AnthropicCategorizationProvider(model)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Public API — on-demand categorization
|
||||
// ============================================================
|
||||
|
||||
export async function categorizeTransactions(
|
||||
userId: string,
|
||||
transactionIds: string[]
|
||||
): Promise<CategorizationSuggestion[]> {
|
||||
const supabase = await createClient()
|
||||
const settings = await getSettings(userId)
|
||||
|
||||
// Fetch transactions
|
||||
const { data: transactions } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, description, amount, date, merchant_name, mcc_code, currency')
|
||||
.eq('user_id', userId)
|
||||
.in('id', transactionIds)
|
||||
|
||||
if (!transactions || transactions.length === 0) return []
|
||||
|
||||
const batch: TransactionForCategorization[] = transactions.map((t) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
amount: t.amount,
|
||||
date: t.date,
|
||||
merchant_name: t.merchant_name,
|
||||
mcc_code: t.mcc_code,
|
||||
currency: t.currency,
|
||||
}))
|
||||
|
||||
const context = await buildContext(userId, supabase)
|
||||
|
||||
const aiProvider = getProvider(settings.providerModel)
|
||||
const suggestions = await aiProvider.categorize(batch, context)
|
||||
|
||||
// Store suggestions
|
||||
await storeSuggestions(userId, suggestions, supabase)
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event Handler
|
||||
// ============================================================
|
||||
|
||||
async function handleTransactionSynced(
|
||||
payload: EventPayload<'transaction.synced'>
|
||||
): Promise<void> {
|
||||
const { transactions: syncedTransactions, userId } = payload
|
||||
|
||||
// Gate: Is autoSuggestEnabled?
|
||||
const settings = await getSettings(userId)
|
||||
if (!settings.autoSuggestEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Gate: Filter to uncategorized transactions only
|
||||
const uncategorized = syncedTransactions.filter(
|
||||
(t: Transaction) => t.is_business === null
|
||||
)
|
||||
if (uncategorized.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ai-categorization] Auto-suggest triggered for ${uncategorized.length} uncategorized transactions`
|
||||
)
|
||||
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
|
||||
const batch: TransactionForCategorization[] = uncategorized.map((t: Transaction) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
amount: t.amount,
|
||||
date: t.date,
|
||||
merchant_name: t.merchant_name,
|
||||
mcc_code: t.mcc_code,
|
||||
currency: t.currency,
|
||||
}))
|
||||
|
||||
const context = await buildContext(userId, supabase)
|
||||
const aiProvider = getProvider(settings.providerModel)
|
||||
const suggestions = await aiProvider.categorize(batch, context)
|
||||
|
||||
// Store only suggestions above confidence threshold
|
||||
const qualifiedSuggestions = suggestions.filter(
|
||||
(s) => s.confidence >= settings.confidenceThreshold
|
||||
)
|
||||
|
||||
if (qualifiedSuggestions.length > 0) {
|
||||
await storeSuggestions(userId, qualifiedSuggestions, supabase)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ai-categorization] Generated ${suggestions.length} suggestions, ${qualifiedSuggestions.length} above threshold (${settings.confidenceThreshold})`
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[ai-categorization] handleTransactionSynced failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function buildContext(userId: string, supabase: any): Promise<CategorizationContext> {
|
||||
// Fetch entity type
|
||||
const { data: companySettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
const entityType: EntityType = companySettings?.entity_type || 'enskild_firma'
|
||||
|
||||
// Fetch recent categorization history
|
||||
const { data: historicalTxns } = await supabase
|
||||
.from('transactions')
|
||||
.select('description, category')
|
||||
.eq('user_id', userId)
|
||||
.not('is_business', 'is', null)
|
||||
.neq('category', 'uncategorized')
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(50)
|
||||
|
||||
const recentHistory = (historicalTxns || []).map(
|
||||
(t: { description: string; category: string }) => ({
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
})
|
||||
)
|
||||
|
||||
return { entityType, recentHistory }
|
||||
}
|
||||
|
||||
async function storeSuggestions(
|
||||
userId: string,
|
||||
suggestions: CategorizationSuggestion[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
supabase: any
|
||||
): Promise<void> {
|
||||
for (const suggestion of suggestions) {
|
||||
await supabase.from('extension_data').upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: 'ai-categorization',
|
||||
key: `suggestion:${suggestion.transactionId}`,
|
||||
value: suggestion,
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Extension Object
|
||||
// ============================================================
|
||||
|
||||
export const aiCategorizationExtension: Extension = {
|
||||
id: 'ai-categorization',
|
||||
name: 'AI Kategorisering',
|
||||
version: '1.0.0',
|
||||
eventHandlers: [
|
||||
{ eventType: 'transaction.synced', handler: handleTransactionSynced },
|
||||
],
|
||||
settingsPanel: {
|
||||
label: 'AI Kategorisering',
|
||||
path: '/settings/extensions/ai-categorization',
|
||||
},
|
||||
async onInstall(ctx) {
|
||||
await saveSettings(ctx.userId, DEFAULT_SETTINGS)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { ChatAnthropic } from '@langchain/anthropic'
|
||||
import { HumanMessage, SystemMessage } from '@langchain/core/messages'
|
||||
import { CHATBOT_CONFIG } from './config'
|
||||
import {
|
||||
SYSTEM_PROMPT,
|
||||
formatContextFromSources,
|
||||
formatConversationHistory,
|
||||
} from './prompts'
|
||||
import {
|
||||
retrieveRelevantDocuments,
|
||||
documentsToSources,
|
||||
type RetrievedDocument,
|
||||
} from './retriever'
|
||||
import type { ChatMessage, SourceReference } from '@/types/chat'
|
||||
|
||||
// Initialize the LLM
|
||||
function getChatModel() {
|
||||
return new ChatAnthropic({
|
||||
modelName: CHATBOT_CONFIG.model,
|
||||
maxTokens: CHATBOT_CONFIG.maxTokens,
|
||||
temperature: CHATBOT_CONFIG.temperature,
|
||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||
})
|
||||
}
|
||||
|
||||
export interface ChatResult {
|
||||
content: string
|
||||
sources: SourceReference[]
|
||||
}
|
||||
|
||||
export async function generateChatResponse(
|
||||
userMessage: string,
|
||||
conversationHistory: ChatMessage[]
|
||||
): Promise<ChatResult> {
|
||||
// 1. Retrieve relevant documents
|
||||
const relevantDocs = await retrieveRelevantDocuments(userMessage)
|
||||
|
||||
// 2. Format context from retrieved documents
|
||||
const context = formatContextFromSources(
|
||||
relevantDocs.map((doc) => ({
|
||||
content: doc.content,
|
||||
title: doc.title,
|
||||
section_title: doc.section_title,
|
||||
source_file: doc.source_file,
|
||||
}))
|
||||
)
|
||||
|
||||
// 3. Format conversation history (last N messages)
|
||||
const recentHistory = conversationHistory.slice(
|
||||
-CHATBOT_CONFIG.maxHistoryMessages
|
||||
)
|
||||
const historyText = formatConversationHistory(
|
||||
recentHistory.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
}))
|
||||
)
|
||||
|
||||
// 4. Build the system prompt with context
|
||||
const systemPrompt = SYSTEM_PROMPT.replace('{context}', context).replace(
|
||||
'{history}',
|
||||
historyText
|
||||
)
|
||||
|
||||
// 5. Generate response
|
||||
const model = getChatModel()
|
||||
const response = await model.invoke([
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(userMessage),
|
||||
])
|
||||
|
||||
// 6. Extract content and sources
|
||||
const content =
|
||||
typeof response.content === 'string'
|
||||
? response.content
|
||||
: JSON.stringify(response.content)
|
||||
|
||||
return {
|
||||
content,
|
||||
sources: documentsToSources(relevantDocs),
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamChatResponse(
|
||||
userMessage: string,
|
||||
conversationHistory: ChatMessage[]
|
||||
): AsyncGenerator<{ type: 'content' | 'sources'; data: string | SourceReference[] }> {
|
||||
// 1. Retrieve relevant documents first
|
||||
const relevantDocs = await retrieveRelevantDocuments(userMessage)
|
||||
|
||||
// 2. Format context from retrieved documents
|
||||
const context = formatContextFromSources(
|
||||
relevantDocs.map((doc) => ({
|
||||
content: doc.content,
|
||||
title: doc.title,
|
||||
section_title: doc.section_title,
|
||||
source_file: doc.source_file,
|
||||
}))
|
||||
)
|
||||
|
||||
// 3. Format conversation history
|
||||
const recentHistory = conversationHistory.slice(
|
||||
-CHATBOT_CONFIG.maxHistoryMessages
|
||||
)
|
||||
const historyText = formatConversationHistory(
|
||||
recentHistory.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
}))
|
||||
)
|
||||
|
||||
// 4. Build the system prompt
|
||||
const systemPrompt = SYSTEM_PROMPT.replace('{context}', context).replace(
|
||||
'{history}',
|
||||
historyText
|
||||
)
|
||||
|
||||
// 5. Stream the response
|
||||
const model = getChatModel()
|
||||
const stream = await model.stream([
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(userMessage),
|
||||
])
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const content =
|
||||
typeof chunk.content === 'string'
|
||||
? chunk.content
|
||||
: JSON.stringify(chunk.content)
|
||||
if (content) {
|
||||
yield { type: 'content', data: content }
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Yield sources at the end
|
||||
yield { type: 'sources', data: documentsToSources(relevantDocs) }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// AI Chatbot configuration
|
||||
|
||||
export const CHATBOT_CONFIG = {
|
||||
// LLM settings
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
maxTokens: 2048,
|
||||
temperature: 0.3,
|
||||
|
||||
// Retrieval settings
|
||||
retrievalK: 5,
|
||||
similarityThreshold: 0.7,
|
||||
|
||||
// Embedding settings
|
||||
embeddingModel: 'text-embedding-ada-002',
|
||||
|
||||
// Chunking settings for ingestion
|
||||
chunkSize: 1000,
|
||||
chunkOverlap: 200,
|
||||
|
||||
// Rate limiting
|
||||
rateLimitPerMinute: 10,
|
||||
|
||||
// Conversation history
|
||||
maxHistoryMessages: 10,
|
||||
} as const
|
||||
@@ -0,0 +1,25 @@
|
||||
import { OpenAIEmbeddings } from '@langchain/openai'
|
||||
import { CHATBOT_CONFIG } from './config'
|
||||
|
||||
// Singleton instance for embeddings
|
||||
let embeddingsInstance: OpenAIEmbeddings | null = null
|
||||
|
||||
export function getEmbeddings(): OpenAIEmbeddings {
|
||||
if (!embeddingsInstance) {
|
||||
embeddingsInstance = new OpenAIEmbeddings({
|
||||
modelName: CHATBOT_CONFIG.embeddingModel,
|
||||
openAIApiKey: process.env.OPENAI_API_KEY,
|
||||
})
|
||||
}
|
||||
return embeddingsInstance
|
||||
}
|
||||
|
||||
export async function generateEmbedding(text: string): Promise<number[]> {
|
||||
const embeddings = getEmbeddings()
|
||||
return embeddings.embedQuery(text)
|
||||
}
|
||||
|
||||
export async function generateEmbeddings(texts: string[]): Promise<number[][]> {
|
||||
const embeddings = getEmbeddings()
|
||||
return embeddings.embedDocuments(texts)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Swedish system prompt for bookkeeping AI assistant
|
||||
|
||||
export const SYSTEM_PROMPT = `Du är en expert AI-assistent som hjälper svenska företagare med skatt, moms, bokföring och företagsekonomi. Du arbetar inom en ekonomiplattform för småföretag.
|
||||
|
||||
## Dina kunskapsområden:
|
||||
- Svensk skattlagstiftning för enskild firma och aktiebolag
|
||||
- Moms och momsdeklaration
|
||||
- Bokföring enligt BAS-kontoplanen
|
||||
- Egenavgifter och socialförsäkring
|
||||
- Avdrag för utrustning, resor, hemmakontor och liknande
|
||||
- Fakturering och kundhantering
|
||||
- NE-bilaga och inkomstdeklaration
|
||||
|
||||
## Viktiga tröskelvärden att komma ihåg:
|
||||
- Momsregistrering: 120 000 kr omsättning under 12 månader
|
||||
- Direktavdrag vs inventarier: 26 250 kr (halvt prisbasbelopp)
|
||||
- SGI-gräns för sjukpenning: 13 500 kr/år minsta inkomst
|
||||
- Karensavdrag: 20% av sjuklönen
|
||||
- Friskvårdsbidrag max: 6 000 kr/år (ej skattepliktigt)
|
||||
- Representationsavdrag mat: 90 kr exkl moms per person
|
||||
|
||||
## Instruktioner:
|
||||
1. Svara alltid på svenska med korrekt terminologi
|
||||
2. Var konkret och ge specifika exempel när möjligt
|
||||
3. Referera till relevanta tröskelvärden och regler
|
||||
4. Om du är osäker, säg det och rekommendera att användaren konsulterar en revisor
|
||||
5. Använd information från de tillhandahållna källorna för att ge korrekta svar
|
||||
6. Formatera svaren tydligt med punktlistor när det passar
|
||||
7. Om frågan gäller något utanför dina kunskapsområden, hänvisa till rätt instans
|
||||
|
||||
## Kontext från kunskapsbasen:
|
||||
{context}
|
||||
|
||||
## Tidigare konversation:
|
||||
{history}
|
||||
|
||||
Svara på användarens fråga baserat på din expertkunskap och den tillhandahållna kontexten. Om kontexten inte innehåller relevant information, använd din allmänna kunskap om svenska skatteregler för företagare.`
|
||||
|
||||
export const RETRIEVAL_PROMPT = `Baserat på följande fråga, hitta relevant information från kunskapsbasen.
|
||||
|
||||
Fråga: {question}
|
||||
|
||||
Sök efter information som hjälper att besvara frågan korrekt och fullständigt.`
|
||||
|
||||
export function formatContextFromSources(
|
||||
sources: Array<{
|
||||
content: string
|
||||
title: string
|
||||
section_title: string | null
|
||||
source_file: string
|
||||
}>
|
||||
): string {
|
||||
if (sources.length === 0) {
|
||||
return 'Ingen specifik kontext hittades i kunskapsbasen.'
|
||||
}
|
||||
|
||||
return sources
|
||||
.map((source, index) => {
|
||||
const sectionInfo = source.section_title
|
||||
? ` > ${source.section_title}`
|
||||
: ''
|
||||
return `[Källa ${index + 1}: ${source.title}${sectionInfo}]\n${source.content}`
|
||||
})
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
|
||||
export function formatConversationHistory(
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
): string {
|
||||
if (messages.length === 0) {
|
||||
return 'Ingen tidigare konversation.'
|
||||
}
|
||||
|
||||
return messages
|
||||
.map((msg) => {
|
||||
const role = msg.role === 'user' ? 'Användare' : 'Assistent'
|
||||
return `${role}: ${msg.content}`
|
||||
})
|
||||
.join('\n\n')
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { generateEmbedding } from './embeddings'
|
||||
import { CHATBOT_CONFIG } from './config'
|
||||
import type { SourceReference } from '@/types/chat'
|
||||
|
||||
export interface RetrievedDocument {
|
||||
id: string
|
||||
source_file: string
|
||||
title: string
|
||||
section_title: string | null
|
||||
content: string
|
||||
metadata: Record<string, unknown>
|
||||
similarity: number
|
||||
}
|
||||
|
||||
export async function retrieveRelevantDocuments(
|
||||
query: string,
|
||||
matchCount: number = CHATBOT_CONFIG.retrievalK,
|
||||
matchThreshold: number = CHATBOT_CONFIG.similarityThreshold
|
||||
): Promise<RetrievedDocument[]> {
|
||||
const supabase = await createServiceClient()
|
||||
|
||||
// Generate embedding for the query
|
||||
const queryEmbedding = await generateEmbedding(query)
|
||||
|
||||
// Call the match_documents function
|
||||
const { data, error } = await supabase.rpc('match_documents', {
|
||||
query_embedding: queryEmbedding,
|
||||
match_count: matchCount,
|
||||
match_threshold: matchThreshold,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Error retrieving documents:', error)
|
||||
throw new Error('Failed to retrieve relevant documents')
|
||||
}
|
||||
|
||||
return (data || []) as RetrievedDocument[]
|
||||
}
|
||||
|
||||
export function documentsToSources(
|
||||
documents: RetrievedDocument[]
|
||||
): SourceReference[] {
|
||||
return documents.map((doc) => ({
|
||||
id: doc.id,
|
||||
source_file: doc.source_file,
|
||||
title: doc.title,
|
||||
section_title: doc.section_title,
|
||||
similarity: doc.similarity,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
|
||||
/**
|
||||
* AI Chat Extension
|
||||
*
|
||||
* Provides an AI-powered chatbot assistant for Swedish tax and bookkeeping
|
||||
* questions. Uses RAG (Retrieval Augmented Generation) with a knowledge base
|
||||
* of Swedish tax laws, regulations, and best practices.
|
||||
*
|
||||
* Components:
|
||||
* - chatbot/: Chain, config, prompts, embeddings, retriever
|
||||
* - ingestion/: CLI tool for ingesting knowledge base documents
|
||||
*/
|
||||
export const aiChatExtension: Extension = {
|
||||
id: 'ai-chat',
|
||||
name: 'AI-assistent',
|
||||
version: '1.0.0',
|
||||
settingsPanel: {
|
||||
label: 'AI-assistent',
|
||||
path: '/settings/extensions/ai-chat',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* Knowledge Base Ingestion Script
|
||||
*
|
||||
* Loads markdown files from dev_docs/ai_knowledge_base/,
|
||||
* chunks them by sections, generates embeddings, and stores in Supabase.
|
||||
*
|
||||
* Run with: npx tsx lib/ai/ingestion/ingest.ts
|
||||
*/
|
||||
|
||||
import * as dotenv from 'dotenv'
|
||||
dotenv.config({ path: '.env.local' })
|
||||
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { OpenAIEmbeddings } from '@langchain/openai'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
// Configuration
|
||||
const DOCS_DIR = path.join(process.cwd(), 'dev_docs', 'ai_knowledge_base')
|
||||
const CHUNK_SIZE = 1000
|
||||
const CHUNK_OVERLAP = 200
|
||||
const EMBEDDING_MODEL = 'text-embedding-ada-002'
|
||||
|
||||
// Initialize clients
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
)
|
||||
|
||||
const embeddings = new OpenAIEmbeddings({
|
||||
modelName: EMBEDDING_MODEL,
|
||||
openAIApiKey: process.env.OPENAI_API_KEY,
|
||||
})
|
||||
|
||||
interface DocumentChunk {
|
||||
source_file: string
|
||||
title: string
|
||||
section_title: string | null
|
||||
content: string
|
||||
content_hash: string
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface Section {
|
||||
title: string
|
||||
content: string
|
||||
level: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a markdown file into sections based on headings
|
||||
*/
|
||||
function parseMarkdownSections(content: string): Section[] {
|
||||
const lines = content.split('\n')
|
||||
const sections: Section[] = []
|
||||
let currentSection: Section | null = null
|
||||
let contentBuffer: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
// Check for headings (H1, H2, H3)
|
||||
const h1Match = line.match(/^# (.+)$/)
|
||||
const h2Match = line.match(/^## (.+)$/)
|
||||
const h3Match = line.match(/^### (.+)$/)
|
||||
|
||||
if (h1Match || h2Match || h3Match) {
|
||||
// Save previous section
|
||||
if (currentSection && contentBuffer.length > 0) {
|
||||
currentSection.content = contentBuffer.join('\n').trim()
|
||||
if (currentSection.content) {
|
||||
sections.push(currentSection)
|
||||
}
|
||||
}
|
||||
|
||||
// Start new section
|
||||
const title = h1Match?.[1] || h2Match?.[1] || h3Match?.[1] || ''
|
||||
const level = h1Match ? 1 : h2Match ? 2 : 3
|
||||
currentSection = { title, content: '', level }
|
||||
contentBuffer = []
|
||||
} else {
|
||||
contentBuffer.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last section
|
||||
if (currentSection && contentBuffer.length > 0) {
|
||||
currentSection.content = contentBuffer.join('\n').trim()
|
||||
if (currentSection.content) {
|
||||
sections.push(currentSection)
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into chunks while preserving context
|
||||
*/
|
||||
function chunkText(text: string, maxSize: number, overlap: number): string[] {
|
||||
if (text.length <= maxSize) {
|
||||
return [text]
|
||||
}
|
||||
|
||||
const chunks: string[] = []
|
||||
let start = 0
|
||||
|
||||
while (start < text.length) {
|
||||
let end = start + maxSize
|
||||
|
||||
// Try to break at a natural point (paragraph, sentence, or word)
|
||||
if (end < text.length) {
|
||||
// Look for paragraph break
|
||||
const paragraphBreak = text.lastIndexOf('\n\n', end)
|
||||
if (paragraphBreak > start + maxSize / 2) {
|
||||
end = paragraphBreak
|
||||
} else {
|
||||
// Look for sentence break
|
||||
const sentenceBreak = text.lastIndexOf('. ', end)
|
||||
if (sentenceBreak > start + maxSize / 2) {
|
||||
end = sentenceBreak + 1
|
||||
} else {
|
||||
// Look for word break
|
||||
const wordBreak = text.lastIndexOf(' ', end)
|
||||
if (wordBreak > start + maxSize / 2) {
|
||||
end = wordBreak
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chunks.push(text.slice(start, end).trim())
|
||||
start = end - overlap
|
||||
if (start < 0) start = 0
|
||||
if (end >= text.length) break
|
||||
}
|
||||
|
||||
return chunks.filter((c) => c.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract metadata from content (trigger words, categories, etc.)
|
||||
*/
|
||||
function extractMetadata(
|
||||
content: string,
|
||||
sectionTitle: string
|
||||
): Record<string, unknown> {
|
||||
const metadata: Record<string, unknown> = {}
|
||||
|
||||
// Extract scenario IDs (e.g., "Scenario 001")
|
||||
const scenarioMatches = content.match(/Scenario\s+(\d{3})/gi)
|
||||
if (scenarioMatches) {
|
||||
metadata.scenarios = scenarioMatches.map((m) =>
|
||||
m.replace(/Scenario\s+/i, '')
|
||||
)
|
||||
}
|
||||
|
||||
// Extract mentioned thresholds/amounts
|
||||
const amountMatches = content.match(/(\d+[\s\d]*)\s*(kr|SEK|kronor)/gi)
|
||||
if (amountMatches) {
|
||||
metadata.amounts = amountMatches.slice(0, 5) // Limit to first 5
|
||||
}
|
||||
|
||||
// Extract platform mentions
|
||||
const platforms = [
|
||||
'YouTube',
|
||||
'Twitch',
|
||||
'Instagram',
|
||||
'TikTok',
|
||||
'Patreon',
|
||||
'Spotify',
|
||||
'Adtraction',
|
||||
]
|
||||
const mentionedPlatforms = platforms.filter((p) =>
|
||||
content.toLowerCase().includes(p.toLowerCase())
|
||||
)
|
||||
if (mentionedPlatforms.length > 0) {
|
||||
metadata.platforms = mentionedPlatforms
|
||||
}
|
||||
|
||||
// Extract categories based on keywords
|
||||
const categories: string[] = []
|
||||
if (/moms|vat/i.test(content)) categories.push('moms')
|
||||
if (/skatt|deklaration/i.test(content)) categories.push('skatt')
|
||||
if (/avdrag/i.test(content)) categories.push('avdrag')
|
||||
if (/bokför|konto|bas/i.test(content)) categories.push('bokföring')
|
||||
if (/sgi|sjuk|föräldra|pension/i.test(content))
|
||||
categories.push('socialförsäkring')
|
||||
if (/ef|enskild firma/i.test(content)) categories.push('enskild_firma')
|
||||
if (/ab|aktiebolag/i.test(content)) categories.push('aktiebolag')
|
||||
|
||||
if (categories.length > 0) {
|
||||
metadata.categories = categories
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate content hash for deduplication
|
||||
*/
|
||||
function generateHash(content: string): string {
|
||||
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single markdown file
|
||||
*/
|
||||
function processFile(filePath: string): DocumentChunk[] {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
const fileName = path.basename(filePath)
|
||||
const sections = parseMarkdownSections(content)
|
||||
|
||||
const chunks: DocumentChunk[] = []
|
||||
|
||||
// Get document title from first H1
|
||||
const documentTitle =
|
||||
sections.find((s) => s.level === 1)?.title || fileName.replace('.md', '')
|
||||
|
||||
for (const section of sections) {
|
||||
// Skip empty sections
|
||||
if (!section.content || section.content.length < 50) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Chunk large sections
|
||||
const textChunks = chunkText(section.content, CHUNK_SIZE, CHUNK_OVERLAP)
|
||||
|
||||
for (let i = 0; i < textChunks.length; i++) {
|
||||
const chunkContent = textChunks[i]
|
||||
const sectionTitle =
|
||||
section.level === 1
|
||||
? null
|
||||
: textChunks.length > 1
|
||||
? `${section.title} (del ${i + 1}/${textChunks.length})`
|
||||
: section.title
|
||||
|
||||
chunks.push({
|
||||
source_file: fileName,
|
||||
title: documentTitle,
|
||||
section_title: sectionTitle,
|
||||
content: chunkContent,
|
||||
content_hash: generateHash(chunkContent),
|
||||
metadata: extractMetadata(chunkContent, section.title),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
/**
|
||||
* Main ingestion function
|
||||
*/
|
||||
async function ingest() {
|
||||
console.log('Starting knowledge base ingestion...')
|
||||
console.log(`Reading files from: ${DOCS_DIR}`)
|
||||
|
||||
// Get all markdown files
|
||||
const files = fs
|
||||
.readdirSync(DOCS_DIR)
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.sort()
|
||||
|
||||
console.log(`Found ${files.length} markdown files`)
|
||||
|
||||
// Process all files
|
||||
const allChunks: DocumentChunk[] = []
|
||||
for (const file of files) {
|
||||
const filePath = path.join(DOCS_DIR, file)
|
||||
console.log(`Processing: ${file}`)
|
||||
const chunks = processFile(filePath)
|
||||
allChunks.push(...chunks)
|
||||
console.log(` -> ${chunks.length} chunks`)
|
||||
}
|
||||
|
||||
console.log(`Total chunks: ${allChunks.length}`)
|
||||
|
||||
// Clear existing documents (optional - comment out for incremental updates)
|
||||
console.log('Clearing existing documents...')
|
||||
const { error: deleteError } = await supabase
|
||||
.from('knowledge_documents')
|
||||
.delete()
|
||||
.neq('id', '00000000-0000-0000-0000-000000000000') // Delete all
|
||||
|
||||
if (deleteError) {
|
||||
console.error('Error clearing documents:', deleteError)
|
||||
// Continue anyway
|
||||
}
|
||||
|
||||
// Generate embeddings in batches
|
||||
const BATCH_SIZE = 20
|
||||
let processed = 0
|
||||
|
||||
for (let i = 0; i < allChunks.length; i += BATCH_SIZE) {
|
||||
const batch = allChunks.slice(i, i + BATCH_SIZE)
|
||||
const contents = batch.map((c) => c.content)
|
||||
|
||||
console.log(
|
||||
`Generating embeddings for batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(allChunks.length / BATCH_SIZE)}...`
|
||||
)
|
||||
|
||||
// Generate embeddings
|
||||
const embeddingVectors = await embeddings.embedDocuments(contents)
|
||||
|
||||
// Prepare records for insertion
|
||||
const records = batch.map((chunk, idx) => ({
|
||||
source_file: chunk.source_file,
|
||||
title: chunk.title,
|
||||
section_title: chunk.section_title,
|
||||
content: chunk.content,
|
||||
content_hash: chunk.content_hash,
|
||||
embedding: embeddingVectors[idx],
|
||||
metadata: chunk.metadata,
|
||||
}))
|
||||
|
||||
// Insert into Supabase
|
||||
const { error: insertError } = await supabase
|
||||
.from('knowledge_documents')
|
||||
.insert(records)
|
||||
|
||||
if (insertError) {
|
||||
console.error('Error inserting batch:', insertError)
|
||||
throw insertError
|
||||
}
|
||||
|
||||
processed += batch.length
|
||||
console.log(` Inserted ${processed}/${allChunks.length} documents`)
|
||||
}
|
||||
|
||||
console.log('\nIngestion complete!')
|
||||
console.log(`Total documents inserted: ${allChunks.length}`)
|
||||
|
||||
// Verify
|
||||
const { count } = await supabase
|
||||
.from('knowledge_documents')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
|
||||
console.log(`Documents in database: ${count}`)
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
ingest().catch((error) => {
|
||||
console.error('Ingestion failed:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client'
|
||||
import {
|
||||
CreditCard,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import type { BankConnection } from '@/types'
|
||||
|
||||
interface BankConnectionStatusProps {
|
||||
connection: BankConnection
|
||||
onSync: (connectionId: string) => void
|
||||
onDisconnect: (connectionId: string) => void
|
||||
isSyncing?: boolean
|
||||
}
|
||||
|
||||
export function BankConnectionStatus({
|
||||
connection,
|
||||
onSync,
|
||||
onDisconnect,
|
||||
isSyncing = false,
|
||||
}: BankConnectionStatusProps) {
|
||||
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires)
|
||||
const isExpiring = isConsentExpiringSoon(connection.consent_expires)
|
||||
|
||||
const statusConfig = {
|
||||
active: {
|
||||
icon: CheckCircle,
|
||||
color: 'text-green-600',
|
||||
label: 'Aktiv',
|
||||
variant: 'success' as const,
|
||||
},
|
||||
pending: {
|
||||
icon: Loader2,
|
||||
color: 'text-yellow-600',
|
||||
label: 'Väntar',
|
||||
variant: 'warning' as const,
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
color: 'text-red-600',
|
||||
label: 'Fel',
|
||||
variant: 'destructive' as const,
|
||||
},
|
||||
revoked: {
|
||||
icon: XCircle,
|
||||
color: 'text-gray-600',
|
||||
label: 'Bortkopplad',
|
||||
variant: 'secondary' as const,
|
||||
},
|
||||
}
|
||||
|
||||
const status = statusConfig[connection.status as keyof typeof statusConfig] || statusConfig.error
|
||||
const StatusIcon = status.icon
|
||||
|
||||
// Parse accounts from connection
|
||||
const accounts = (connection.accounts_data as Array<{
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
}>) || []
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{connection.bank_name}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<StatusIcon className={`h-3 w-3 ${status.color}`} />
|
||||
<span>{status.label}</span>
|
||||
{connection.last_synced_at && (
|
||||
<>
|
||||
<span>-</span>
|
||||
<span>Synkad {formatDate(connection.last_synced_at)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{connection.status === 'active' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSync(connection.id)}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDisconnect(connection.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Consent expiry warning */}
|
||||
{isExpiring && daysUntilExpiry !== null && (
|
||||
<div className="flex items-center gap-2 p-3 bg-warning/10 rounded-lg">
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
<span className="text-sm">
|
||||
Samtycket går ut om {daysUntilExpiry} {daysUntilExpiry === 1 ? 'dag' : 'dagar'}.
|
||||
Förnya genom att ansluta igen.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accounts list */}
|
||||
{accounts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Konton</p>
|
||||
<div className="space-y-2">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.uid}
|
||||
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{account.name || account.iban || 'Okänt konto'}
|
||||
</p>
|
||||
{account.iban && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{account.balance !== undefined && (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">
|
||||
{new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: account.currency,
|
||||
}).format(account.balance)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import Script from 'next/script'
|
||||
|
||||
export interface Bank {
|
||||
name: string
|
||||
country: string
|
||||
logo?: string
|
||||
bic?: string
|
||||
}
|
||||
|
||||
interface BankSelectorProps {
|
||||
onSelect: (bank: Bank) => void
|
||||
selectedBank?: Bank | null
|
||||
isLoading?: boolean
|
||||
className?: string
|
||||
country?: string
|
||||
sandbox?: boolean
|
||||
}
|
||||
|
||||
export function BankSelector({
|
||||
onSelect,
|
||||
isLoading = false,
|
||||
className,
|
||||
country = 'SE',
|
||||
sandbox = true, // Default to sandbox for development
|
||||
}: BankSelectorProps) {
|
||||
const widgetRef = useRef<HTMLElement | null>(null)
|
||||
const [scriptLoaded, setScriptLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!scriptLoaded) return
|
||||
|
||||
const widget = widgetRef.current
|
||||
if (!widget) return
|
||||
|
||||
const handleSelected = (e: Event) => {
|
||||
const customEvent = e as CustomEvent
|
||||
const detail = customEvent.detail as {
|
||||
name: string
|
||||
country: string
|
||||
psuType: string
|
||||
sandbox: boolean
|
||||
}
|
||||
|
||||
onSelect({
|
||||
name: detail.name,
|
||||
country: detail.country,
|
||||
})
|
||||
}
|
||||
|
||||
widget.addEventListener('selected', handleSelected)
|
||||
|
||||
return () => {
|
||||
widget.removeEventListener('selected', handleSelected)
|
||||
}
|
||||
}, [onSelect, scriptLoaded])
|
||||
|
||||
const handleScriptLoad = () => {
|
||||
setScriptLoaded(true)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center p-8', className)}>
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Ansluter...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<Script
|
||||
src="https://tilisy.enablebanking.com/lib/widgets.umd.min.js"
|
||||
onLoad={handleScriptLoad}
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<link
|
||||
href="https://tilisy.enablebanking.com/lib/widgets.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
{/* @ts-expect-error - Enable Banking custom element */}
|
||||
<enablebanking-aspsp-list
|
||||
ref={widgetRef}
|
||||
country={country}
|
||||
psu-type="business"
|
||||
service="AIS"
|
||||
{...(sandbox ? { sandbox: true } : {})}
|
||||
style={{ minHeight: '200px' }}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{sandbox ? 'Sandbox-läge: Använd testbanker för utveckling' : 'Välj din bank för att fortsätta'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import {
|
||||
startAuthorization,
|
||||
getASPSPs,
|
||||
createSession,
|
||||
getAccountBalance,
|
||||
isConsentExpiringSoon,
|
||||
getDaysUntilExpiry,
|
||||
type ASPSP,
|
||||
type AccountInfo,
|
||||
} from './lib/api-client'
|
||||
import { syncAccountTransactions } from './lib/sync'
|
||||
import type { StoredAccount } from './types'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
/**
|
||||
* Enable Banking (PSD2) extension
|
||||
*
|
||||
* Provides automatic bank transaction sync via PSD2 open banking.
|
||||
* This is an opt-in extension — uncomment the import in loader.ts to activate.
|
||||
*
|
||||
* Required environment variables:
|
||||
* - ENABLE_BANKING_APP_ID
|
||||
* - ENABLE_BANKING_PRIVATE_KEY (base64-encoded PEM)
|
||||
* - ENABLE_BANKING_SANDBOX (optional, for sandbox mode)
|
||||
*/
|
||||
export const enableBankingExtension: Extension = {
|
||||
id: 'enable-banking',
|
||||
name: 'Enable Banking (PSD2)',
|
||||
version: '1.0.0',
|
||||
|
||||
settingsPanel: {
|
||||
label: 'Bankintegration (PSD2)',
|
||||
path: '/settings?tab=banking',
|
||||
},
|
||||
|
||||
apiRoutes: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/banks',
|
||||
handler: async () => {
|
||||
try {
|
||||
const aspsps = await getASPSPs('SE')
|
||||
const banks = aspsps.map((aspsp: ASPSP) => ({
|
||||
name: aspsp.name,
|
||||
country: aspsp.country,
|
||||
logo: aspsp.logo,
|
||||
bic: aspsp.bic,
|
||||
}))
|
||||
return NextResponse.json({ banks })
|
||||
} catch (error) {
|
||||
console.error('Error fetching banks:', error)
|
||||
return NextResponse.json({
|
||||
banks: [
|
||||
{ name: 'Nordea', country: 'SE', bic: 'NDEASESS' },
|
||||
{ name: 'SEB', country: 'SE', bic: 'ESSESESS' },
|
||||
{ name: 'Swedbank', country: 'SE', bic: 'SWEDSESS' },
|
||||
{ name: 'Handelsbanken', country: 'SE', bic: 'HANDSESS' },
|
||||
]
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/connect',
|
||||
handler: async (request: Request) => {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { aspsp_name, aspsp_country } = await request.json()
|
||||
|
||||
if (!aspsp_name || !aspsp_country) {
|
||||
return NextResponse.json(
|
||||
{ error: 'aspsp_name and aspsp_country are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/extensions/enable-banking/callback`
|
||||
|
||||
const { url, authorization_id } = await startAuthorization(
|
||||
aspsp_name,
|
||||
aspsp_country,
|
||||
redirectUrl,
|
||||
user.id,
|
||||
'personal'
|
||||
)
|
||||
|
||||
const { data: connection, error } = await supabase
|
||||
.from('bank_connections')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
provider: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
|
||||
bank_name: aspsp_name,
|
||||
authorization_id,
|
||||
status: 'pending',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Database error:', error)
|
||||
throw new Error('Failed to store connection')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
connection_id: connection.id,
|
||||
authorization_url: url,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Bank connection error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Connection failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/sync',
|
||||
handler: async (request: Request) => {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { connection_id, days_back = 30 } = await request.json()
|
||||
|
||||
const { data: connection, error: connectionError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('*')
|
||||
.eq('id', connection_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (connectionError || !connection) {
|
||||
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (connection.status !== 'active') {
|
||||
return NextResponse.json({ error: 'Connection is not active' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
|
||||
|
||||
const toDate = new Date().toISOString().split('T')[0]
|
||||
const fromDate = new Date(Date.now() - days_back * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
|
||||
let totalImported = 0
|
||||
let totalDuplicates = 0
|
||||
|
||||
for (const account of accounts) {
|
||||
const result = await syncAccountTransactions(
|
||||
supabase,
|
||||
user.id,
|
||||
connection.id,
|
||||
account,
|
||||
fromDate,
|
||||
toDate
|
||||
)
|
||||
|
||||
totalImported += result.imported
|
||||
totalDuplicates += result.duplicates
|
||||
}
|
||||
|
||||
const syncedAt = new Date().toISOString()
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
accounts_data: accounts,
|
||||
last_synced_at: syncedAt,
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
|
||||
if (totalImported > 0) {
|
||||
const { data: syncedTransactions } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('bank_connection_id', connection.id)
|
||||
.gte('created_at', fromDate)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(totalImported)
|
||||
|
||||
if (syncedTransactions && syncedTransactions.length > 0) {
|
||||
await eventBus.emit({
|
||||
type: 'transaction.synced',
|
||||
payload: { transactions: syncedTransactions as Transaction[], userId: user.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
imported: totalImported,
|
||||
duplicates: totalDuplicates,
|
||||
last_synced_at: syncedAt,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Sync failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
eventHandlers: [],
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* Enable Banking API integration for PSD2 bank connections
|
||||
*
|
||||
* Documentation: https://enablebanking.com/docs/api/reference/
|
||||
*
|
||||
* Flow:
|
||||
* 1. POST /auth → { url, authorization_id }
|
||||
* 2. User redirects to URL, authenticates with bank
|
||||
* 3. Callback receives ?code=XXX&state=YYY
|
||||
* 4. POST /sessions { code } → { session_id, accounts }
|
||||
* 5. GET /accounts/{uid}/balances
|
||||
* 6. GET /accounts/{uid}/transactions
|
||||
*/
|
||||
|
||||
import { getAuthorizationHeader } from './jwt'
|
||||
|
||||
const ENABLE_BANKING_API_URL = 'https://api.enablebanking.com'
|
||||
|
||||
// Types
|
||||
|
||||
export interface ASPSP {
|
||||
name: string
|
||||
country: string
|
||||
logo?: string
|
||||
bic?: string
|
||||
beta?: boolean
|
||||
max_consent_validity?: number
|
||||
available_auth_methods?: AuthMethod[]
|
||||
}
|
||||
|
||||
export interface AuthMethod {
|
||||
name: string
|
||||
title?: string
|
||||
psu_types?: ('personal' | 'business')[]
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
url: string
|
||||
authorization_id: string
|
||||
}
|
||||
|
||||
export interface SessionResponse {
|
||||
session_id: string
|
||||
access: {
|
||||
valid_until: string
|
||||
}
|
||||
accounts: AccountInfo[]
|
||||
aspsp: {
|
||||
name: string
|
||||
country: string
|
||||
}
|
||||
psu_type: string
|
||||
}
|
||||
|
||||
export interface AccountInfo {
|
||||
uid: string
|
||||
account_id?: {
|
||||
iban?: string
|
||||
bban?: string
|
||||
other?: string
|
||||
}
|
||||
name?: string
|
||||
product?: string
|
||||
currency: string
|
||||
identification_hash?: string
|
||||
}
|
||||
|
||||
export interface Balance {
|
||||
balance_amount: {
|
||||
amount: string
|
||||
currency: string
|
||||
}
|
||||
balance_type: string
|
||||
reference_date?: string
|
||||
last_change_date_time?: string
|
||||
}
|
||||
|
||||
export interface BalanceResponse {
|
||||
balances: Balance[]
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
entry_reference?: string
|
||||
transaction_id?: string
|
||||
booking_date?: string
|
||||
value_date?: string
|
||||
transaction_amount: {
|
||||
amount: string
|
||||
currency: string
|
||||
}
|
||||
credit_debit_indicator?: 'CRDT' | 'DBIT' // CRDT = credit (income), DBIT = debit (expense)
|
||||
creditor_name?: string
|
||||
creditor_account?: {
|
||||
iban?: string
|
||||
bban?: string
|
||||
}
|
||||
creditor?: {
|
||||
name?: string
|
||||
}
|
||||
debtor_name?: string
|
||||
debtor_account?: {
|
||||
iban?: string
|
||||
bban?: string
|
||||
}
|
||||
debtor?: {
|
||||
name?: string
|
||||
}
|
||||
remittance_information?: string[]
|
||||
merchant_category_code?: string
|
||||
bank_transaction_code?: string
|
||||
proprietary_bank_transaction_code?: string
|
||||
}
|
||||
|
||||
export interface TransactionsResponse {
|
||||
transactions: Transaction[]
|
||||
continuation_key?: string
|
||||
}
|
||||
|
||||
// Legacy types for backward compatibility
|
||||
export interface Bank {
|
||||
id: string
|
||||
name: string
|
||||
bic?: string
|
||||
countries: string[]
|
||||
logo_url?: string
|
||||
}
|
||||
|
||||
export interface BankTransaction {
|
||||
id: string
|
||||
date: string
|
||||
booking_date: string
|
||||
amount: number
|
||||
currency: string
|
||||
description: string
|
||||
counterparty_name?: string
|
||||
counterparty_account?: string
|
||||
reference?: string
|
||||
merchant_category_code?: string
|
||||
}
|
||||
|
||||
// API Helper
|
||||
|
||||
async function authenticatedFetch(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
const url = `${ENABLE_BANKING_API_URL}${endpoint}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': getAuthorizationHeader(),
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// API Functions
|
||||
|
||||
/**
|
||||
* Get list of supported banks (ASPSPs) for a country
|
||||
*/
|
||||
export async function getASPSPs(country: string = 'SE'): Promise<ASPSP[]> {
|
||||
const response = await authenticatedFetch(`/aspsps?country=${country}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
console.error('Failed to fetch ASPSPs:', error)
|
||||
throw new Error('Failed to fetch banks')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.aspsps || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of supported banks (legacy format for backward compatibility)
|
||||
*/
|
||||
export async function getSupportedBanks(): Promise<Bank[]> {
|
||||
try {
|
||||
const aspsps = await getASPSPs('SE')
|
||||
|
||||
return aspsps.map((aspsp) => ({
|
||||
id: `${aspsp.name.toLowerCase().replace(/\s+/g, '-')}-se`,
|
||||
name: aspsp.name,
|
||||
bic: aspsp.bic,
|
||||
countries: [aspsp.country],
|
||||
logo_url: aspsp.logo,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Error fetching banks:', error)
|
||||
// Return fallback list
|
||||
return [
|
||||
{ id: 'nordea-se', name: 'Nordea', bic: 'NDEASESS', countries: ['SE'] },
|
||||
{ id: 'seb-se', name: 'SEB', bic: 'ESSESESS', countries: ['SE'] },
|
||||
{ id: 'swedbank-se', name: 'Swedbank', bic: 'SWEDSESS', countries: ['SE'] },
|
||||
{ id: 'handelsbanken-se', name: 'Handelsbanken', bic: 'HANDSESS', countries: ['SE'] },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start bank authorization flow
|
||||
*
|
||||
* @param aspspName - The name of the ASPSP (bank) exactly as returned from /aspsps
|
||||
* @param aspspCountry - The country code (e.g., 'SE')
|
||||
* @param redirectUrl - URL to redirect user after bank authorization
|
||||
* @param state - State parameter returned in callback (e.g., user ID)
|
||||
* @param psuType - Type of user: 'personal' or 'business'
|
||||
*/
|
||||
export async function startAuthorization(
|
||||
aspspName: string,
|
||||
aspspCountry: string,
|
||||
redirectUrl: string,
|
||||
state: string,
|
||||
psuType: 'personal' | 'business' = 'personal'
|
||||
): Promise<AuthResponse> {
|
||||
// Calculate consent validity (90 days)
|
||||
const validUntil = new Date()
|
||||
validUntil.setDate(validUntil.getDate() + 90)
|
||||
|
||||
const requestBody = {
|
||||
access: {
|
||||
valid_until: validUntil.toISOString()
|
||||
},
|
||||
aspsp: {
|
||||
name: aspspName,
|
||||
country: aspspCountry
|
||||
},
|
||||
state,
|
||||
redirect_url: redirectUrl,
|
||||
psu_type: psuType
|
||||
}
|
||||
|
||||
const response = await authenticatedFetch('/auth', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
console.error('Failed to start authorization:', error)
|
||||
throw new Error('Failed to start bank connection')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session after user completes bank authorization
|
||||
*
|
||||
* @param code - The authorization code from callback
|
||||
*/
|
||||
export async function createSession(code: string): Promise<SessionResponse> {
|
||||
const response = await authenticatedFetch('/sessions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
console.error('Failed to create session:', error)
|
||||
throw new Error('Failed to create bank session')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session details
|
||||
*
|
||||
* @param sessionId - The session ID
|
||||
*/
|
||||
export async function getSession(sessionId: string): Promise<SessionResponse> {
|
||||
const response = await authenticatedFetch(`/sessions/${sessionId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get session')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete/revoke a session
|
||||
*
|
||||
* @param sessionId - The session ID to revoke
|
||||
*/
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
const response = await authenticatedFetch(`/sessions/${sessionId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to revoke session')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account balances
|
||||
*
|
||||
* @param accountUid - The account UID (from session.accounts[].uid)
|
||||
*/
|
||||
export async function getAccountBalances(accountUid: string): Promise<Balance[]> {
|
||||
const response = await authenticatedFetch(`/accounts/${accountUid}/balances`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get account balances')
|
||||
}
|
||||
|
||||
const data: BalanceResponse = await response.json()
|
||||
return data.balances || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account balance (returns booked balance amount)
|
||||
*/
|
||||
export async function getAccountBalance(
|
||||
accountUid: string
|
||||
): Promise<{ amount: number; date: string }> {
|
||||
const balances = await getAccountBalances(accountUid)
|
||||
|
||||
// Prefer closingBooked, then expected, then first available
|
||||
const balance =
|
||||
balances.find(b => b.balance_type === 'closingBooked') ||
|
||||
balances.find(b => b.balance_type === 'expected') ||
|
||||
balances[0]
|
||||
|
||||
if (!balance) {
|
||||
return { amount: 0, date: new Date().toISOString().split('T')[0] }
|
||||
}
|
||||
|
||||
return {
|
||||
amount: parseFloat(balance.balance_amount.amount),
|
||||
date: balance.reference_date || new Date().toISOString().split('T')[0]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account transactions
|
||||
*
|
||||
* @param accountUid - The account UID
|
||||
* @param dateFrom - Start date (YYYY-MM-DD)
|
||||
* @param dateTo - End date (YYYY-MM-DD)
|
||||
* @param continuationKey - Pagination key from previous response
|
||||
*/
|
||||
export async function getAccountTransactions(
|
||||
accountUid: string,
|
||||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
continuationKey?: string
|
||||
): Promise<TransactionsResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (dateFrom) params.set('date_from', dateFrom)
|
||||
if (dateTo) params.set('date_to', dateTo)
|
||||
if (continuationKey) params.set('continuation_key', continuationKey)
|
||||
|
||||
const queryString = params.toString()
|
||||
const endpoint = `/accounts/${accountUid}/transactions${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
const response = await authenticatedFetch(endpoint)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get transactions')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all transactions with pagination
|
||||
*/
|
||||
export async function getAllTransactions(
|
||||
accountUid: string,
|
||||
dateFrom?: string,
|
||||
dateTo?: string
|
||||
): Promise<Transaction[]> {
|
||||
const allTransactions: Transaction[] = []
|
||||
let continuationKey: string | undefined
|
||||
|
||||
do {
|
||||
const response = await getAccountTransactions(
|
||||
accountUid,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
continuationKey
|
||||
)
|
||||
|
||||
allTransactions.push(...response.transactions)
|
||||
continuationKey = response.continuation_key
|
||||
} while (continuationKey)
|
||||
|
||||
return allTransactions
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Enable Banking transaction to legacy format
|
||||
*/
|
||||
export function convertTransaction(tx: Transaction, accountCurrency: string): BankTransaction {
|
||||
const rawAmount = parseFloat(tx.transaction_amount.amount)
|
||||
|
||||
// Use credit_debit_indicator to determine sign
|
||||
// CRDT = credit (money in) = positive
|
||||
// DBIT = debit (money out) = negative
|
||||
const isCredit = tx.credit_debit_indicator === 'CRDT'
|
||||
const amount = isCredit ? Math.abs(rawAmount) : -Math.abs(rawAmount)
|
||||
|
||||
// Get counterparty name from creditor/debtor objects or direct fields
|
||||
const creditorName = tx.creditor?.name || tx.creditor_name
|
||||
const debtorName = tx.debtor?.name || tx.debtor_name
|
||||
|
||||
return {
|
||||
id: tx.entry_reference || tx.transaction_id || `${tx.booking_date}_${rawAmount}`,
|
||||
date: tx.value_date || tx.booking_date || new Date().toISOString().split('T')[0],
|
||||
booking_date: tx.booking_date || tx.value_date || new Date().toISOString().split('T')[0],
|
||||
amount,
|
||||
currency: tx.transaction_amount.currency || accountCurrency,
|
||||
description: tx.remittance_information?.filter(r => r.trim()).join(' ') ||
|
||||
(isCredit ? debtorName : creditorName) ||
|
||||
'Unknown',
|
||||
counterparty_name: isCredit ? debtorName : creditorName,
|
||||
counterparty_account: isCredit
|
||||
? tx.debtor_account?.iban || tx.debtor_account?.bban
|
||||
: tx.creditor_account?.iban || tx.creditor_account?.bban,
|
||||
merchant_category_code: tx.merchant_category_code
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transactions in legacy format
|
||||
*/
|
||||
export async function getTransactions(
|
||||
accountUid: string,
|
||||
fromDate?: string,
|
||||
toDate?: string,
|
||||
accountCurrency: string = 'SEK'
|
||||
): Promise<BankTransaction[]> {
|
||||
const transactions = await getAllTransactions(accountUid, fromDate, toDate)
|
||||
return transactions.map(tx => convertTransaction(tx, accountCurrency))
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
|
||||
/**
|
||||
* Check if consent is expiring soon (within 7 days)
|
||||
*/
|
||||
export function isConsentExpiringSoon(expiresAt: string | null): boolean {
|
||||
if (!expiresAt) return false
|
||||
|
||||
const expiryDate = new Date(expiresAt)
|
||||
const warningDate = new Date()
|
||||
warningDate.setDate(warningDate.getDate() + 7)
|
||||
|
||||
return expiryDate <= warningDate
|
||||
}
|
||||
|
||||
/**
|
||||
* Get days until consent expires
|
||||
*/
|
||||
export function getDaysUntilExpiry(expiresAt: string | null): number | null {
|
||||
if (!expiresAt) return null
|
||||
|
||||
const expiryDate = new Date(expiresAt)
|
||||
const now = new Date()
|
||||
const diffTime = expiryDate.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
return Math.max(0, diffDays)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* JWT generation for Enable Banking API authentication
|
||||
*
|
||||
* Enable Banking requires JWT tokens signed with RS256 using your private key.
|
||||
* The JWT is included in the Authorization header for all API calls.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
const APP_ID = process.env.ENABLE_BANKING_APP_ID
|
||||
const PRIVATE_KEY_BASE64 = process.env.ENABLE_BANKING_PRIVATE_KEY
|
||||
|
||||
interface JWTHeader {
|
||||
typ: string
|
||||
alg: string
|
||||
kid: string
|
||||
}
|
||||
|
||||
interface JWTPayload {
|
||||
iss: string
|
||||
aud: string
|
||||
iat: number
|
||||
exp: number
|
||||
}
|
||||
|
||||
function base64UrlEncode(data: Buffer | string): string {
|
||||
const str = typeof data === 'string' ? data : data.toString('base64')
|
||||
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function getPrivateKey(): string {
|
||||
if (!PRIVATE_KEY_BASE64) {
|
||||
throw new Error('ENABLE_BANKING_PRIVATE_KEY environment variable is not set')
|
||||
}
|
||||
|
||||
// Decode base64 to get PEM format private key
|
||||
const privateKeyPem = Buffer.from(PRIVATE_KEY_BASE64, 'base64').toString('utf-8')
|
||||
return privateKeyPem
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT token for Enable Banking API authentication
|
||||
*
|
||||
* @param expiresInSeconds - Token validity in seconds (default: 3600 = 1 hour)
|
||||
* @returns Signed JWT token
|
||||
*/
|
||||
export function generateJWT(expiresInSeconds: number = 3600): string {
|
||||
if (!APP_ID) {
|
||||
throw new Error('ENABLE_BANKING_APP_ID environment variable is not set')
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
const header: JWTHeader = {
|
||||
typ: 'JWT',
|
||||
alg: 'RS256',
|
||||
kid: APP_ID
|
||||
}
|
||||
|
||||
const payload: JWTPayload = {
|
||||
iss: 'enablebanking.com',
|
||||
aud: 'api.enablebanking.com',
|
||||
iat: now,
|
||||
exp: now + expiresInSeconds
|
||||
}
|
||||
|
||||
// Encode header and payload
|
||||
const headerBase64 = base64UrlEncode(Buffer.from(JSON.stringify(header)))
|
||||
const payloadBase64 = base64UrlEncode(Buffer.from(JSON.stringify(payload)))
|
||||
|
||||
// Create signature
|
||||
const signatureInput = `${headerBase64}.${payloadBase64}`
|
||||
const privateKey = getPrivateKey()
|
||||
|
||||
const sign = crypto.createSign('RSA-SHA256')
|
||||
sign.update(signatureInput)
|
||||
sign.end()
|
||||
|
||||
const signature = sign.sign(privateKey)
|
||||
const signatureBase64 = base64UrlEncode(signature)
|
||||
|
||||
return `${headerBase64}.${payloadBase64}.${signatureBase64}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Authorization header value for Enable Banking API
|
||||
*/
|
||||
export function getAuthorizationHeader(): string {
|
||||
const token = generateJWT()
|
||||
return `Bearer ${token}`
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getTransactions, getAccountBalance } from './api-client'
|
||||
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
|
||||
import type { StoredAccount } from '../types'
|
||||
|
||||
export interface SyncResult {
|
||||
imported: number
|
||||
duplicates: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync transactions for a single bank account via Enable Banking PSD2.
|
||||
*
|
||||
* Fetches transactions from the Enable Banking API, converts to RawTransaction
|
||||
* format, and delegates to the shared ingestion pipeline.
|
||||
*/
|
||||
export async function syncAccountTransactions(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
connectionId: string,
|
||||
account: StoredAccount,
|
||||
fromDate: string,
|
||||
toDate: string
|
||||
): Promise<SyncResult> {
|
||||
const bankTransactions = await getTransactions(
|
||||
account.uid,
|
||||
fromDate,
|
||||
toDate,
|
||||
account.currency
|
||||
)
|
||||
|
||||
// Convert Enable Banking format to generic RawTransaction
|
||||
const rawTransactions: RawTransaction[] = bankTransactions.map((tx) => ({
|
||||
date: tx.booking_date || tx.date,
|
||||
description: tx.description || tx.counterparty_name || 'Unknown',
|
||||
amount: tx.amount,
|
||||
currency: tx.currency || account.currency,
|
||||
external_id: `${connectionId}_${tx.id}`,
|
||||
mcc_code: tx.merchant_category_code ? parseInt(tx.merchant_category_code, 10) : null,
|
||||
merchant_name: tx.counterparty_name || null,
|
||||
reference: tx.reference || null,
|
||||
bank_connection_id: connectionId,
|
||||
import_source: 'enable_banking',
|
||||
}))
|
||||
|
||||
const ingestResult = await ingestTransactions(supabase, userId, rawTransactions)
|
||||
|
||||
// Update account balance
|
||||
try {
|
||||
const balance = await getAccountBalance(account.uid)
|
||||
account.balance = balance.amount
|
||||
} catch {
|
||||
// Ignore balance fetch errors
|
||||
}
|
||||
|
||||
return {
|
||||
imported: ingestResult.imported,
|
||||
duplicates: ingestResult.duplicates,
|
||||
errors: ingestResult.errors,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Enable Banking extension types
|
||||
|
||||
export interface StoredAccount {
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
}
|
||||
|
||||
// Re-export API types from the client
|
||||
export type {
|
||||
ASPSP,
|
||||
AuthMethod,
|
||||
AuthResponse,
|
||||
SessionResponse,
|
||||
AccountInfo,
|
||||
Balance,
|
||||
BalanceResponse,
|
||||
Transaction as EnableBankingTransaction,
|
||||
TransactionsResponse,
|
||||
Bank,
|
||||
BankTransaction,
|
||||
} from './lib/api-client'
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
|
||||
/**
|
||||
* Example Logger Extension
|
||||
*
|
||||
* Minimal reference implementation that logs events to the console.
|
||||
* Not wired into the loader by default — add to FIRST_PARTY_EXTENSIONS
|
||||
* in lib/extensions/loader.ts to activate.
|
||||
*/
|
||||
export const exampleLoggerExtension: Extension = {
|
||||
id: 'example-logger',
|
||||
name: 'Example Logger',
|
||||
version: '0.1.0',
|
||||
|
||||
eventHandlers: [
|
||||
{
|
||||
eventType: 'journal_entry.committed',
|
||||
handler: async (payload: EventPayload<'journal_entry.committed'>) => {
|
||||
console.log(
|
||||
`[example-logger] Journal entry committed: ${payload.entry.voucher_series}${payload.entry.voucher_number} — ${payload.entry.description}`
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
eventType: 'document.uploaded',
|
||||
handler: async (payload: EventPayload<'document.uploaded'>) => {
|
||||
console.log(
|
||||
`[example-logger] Document uploaded: ${payload.document.file_name} (${payload.document.sha256_hash.slice(0, 12)}…)`
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Bell, BellOff, Loader2, Moon } from 'lucide-react'
|
||||
import type { NotificationSettings as NotificationSettingsType } from '@/types'
|
||||
|
||||
interface NotificationSettingsProps {
|
||||
onSettingsChange?: () => void
|
||||
}
|
||||
|
||||
export function NotificationSettings({ onSettingsChange }: NotificationSettingsProps) {
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [settings, setSettings] = useState<NotificationSettingsType | null>(null)
|
||||
const [isPushSupported, setIsPushSupported] = useState(false)
|
||||
const [isPushSubscribed, setIsPushSubscribed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
checkPushSupport()
|
||||
}, [])
|
||||
|
||||
const checkPushSupport = async () => {
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
setIsPushSupported(true)
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
setIsPushSubscribed(!!subscription)
|
||||
} catch (error) {
|
||||
console.error('Error checking push subscription:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSettings = async () => {
|
||||
setIsLoading(true)
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!error && data) {
|
||||
setSettings(data)
|
||||
} else {
|
||||
// Create default settings if not found
|
||||
const { data: newSettings, error: insertError } = await supabase
|
||||
.from('notification_settings')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
tax_deadlines_enabled: true,
|
||||
invoice_reminders_enabled: true,
|
||||
push_enabled: true,
|
||||
email_enabled: true,
|
||||
quiet_start: '21:00',
|
||||
quiet_end: '08:00',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (!insertError && newSettings) {
|
||||
setSettings(newSettings)
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const updateSetting = async (key: keyof NotificationSettingsType, value: boolean | string) => {
|
||||
if (!settings) return
|
||||
|
||||
setIsSaving(true)
|
||||
|
||||
const { error } = await supabase
|
||||
.from('notification_settings')
|
||||
.update({ [key]: value })
|
||||
.eq('id', settings.id)
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte spara inställning',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
setSettings({ ...settings, [key]: value })
|
||||
onSettingsChange?.()
|
||||
}
|
||||
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
const subscribeToPush = async () => {
|
||||
try {
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
toast({
|
||||
title: 'Aviseringar avslagna',
|
||||
description: 'Du behöver ge tillåtelse för push-aviseringar i webbläsaren.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const vapidResponse = await fetch('/api/extensions/push-notifications/subscribe')
|
||||
const { vapidPublicKey } = await vapidResponse.json()
|
||||
|
||||
if (!vapidPublicKey) {
|
||||
throw new Error('Push not configured')
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
})
|
||||
|
||||
const response = await fetch('/api/extensions/push-notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(subscription.toJSON()),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save subscription')
|
||||
}
|
||||
|
||||
setIsPushSubscribed(true)
|
||||
toast({
|
||||
title: 'Push-aviseringar aktiverade',
|
||||
description: 'Du kommer nu få push-aviseringar.',
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte aktivera push-aviseringar.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribeFromPush = async () => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
|
||||
if (subscription) {
|
||||
await subscription.unsubscribe()
|
||||
|
||||
await fetch('/api/extensions/push-notifications/subscribe', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint: subscription.endpoint }),
|
||||
})
|
||||
}
|
||||
|
||||
setIsPushSubscribed(false)
|
||||
toast({
|
||||
title: 'Push-aviseringar inaktiverade',
|
||||
description: 'Du kommer inte längre få push-aviseringar.',
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte inaktivera push-aviseringar.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Push notification status */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Push-aviseringar
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Få påminnelser direkt i din webbläsare
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isPushSupported ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{isPushSubscribed ? 'Aktiverade' : 'Inaktiverade'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isPushSubscribed
|
||||
? 'Du får push-aviseringar på denna enhet'
|
||||
: 'Aktivera för att få push-aviseringar'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={isPushSubscribed ? 'outline' : 'default'}
|
||||
onClick={isPushSubscribed ? unsubscribeFromPush : subscribeToPush}
|
||||
>
|
||||
{isPushSubscribed ? (
|
||||
<>
|
||||
<BellOff className="mr-2 h-4 w-4" />
|
||||
Inaktivera
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
Aktivera
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Din webbläsare stöder inte push-aviseringar.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Category settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Aviseringskategorier</CardTitle>
|
||||
<CardDescription>
|
||||
Välj vilka typer av aviseringar du vill få
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="tax-deadlines">Skattedeadlines</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Moms, F-skatt, inkomstdeklaration m.m.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="tax-deadlines"
|
||||
checked={settings.tax_deadlines_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSetting('tax_deadlines_enabled', checked)
|
||||
}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="invoice-reminders">Fakturapåminnelser</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fakturor som förfaller eller är försenade
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="invoice-reminders"
|
||||
checked={settings.invoice_reminders_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSetting('invoice_reminders_enabled', checked)
|
||||
}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quiet hours */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Moon className="h-5 w-5" />
|
||||
Tysta timmar
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Inga aviseringar skickas under dessa timmar
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quiet-start">Från</Label>
|
||||
<Input
|
||||
id="quiet-start"
|
||||
type="time"
|
||||
value={settings.quiet_start}
|
||||
onChange={(e) => updateSetting('quiet_start', e.target.value)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quiet-end">Till</Label>
|
||||
<Input
|
||||
id="quiet-end"
|
||||
type="time"
|
||||
value={settings.quiet_end}
|
||||
onChange={(e) => updateSetting('quiet_end', e.target.value)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Standard: 21:00 - 08:00 (Sveriges tid)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = window.atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
|
||||
return outputArray.buffer
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Bell, BellOff, Loader2 } from 'lucide-react'
|
||||
|
||||
interface PushPromptProps {
|
||||
onSubscribed?: () => void
|
||||
onDismissed?: () => void
|
||||
}
|
||||
|
||||
export function PushPrompt({ onSubscribed, onDismissed }: PushPromptProps) {
|
||||
const { toast } = useToast()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSupported, setIsSupported] = useState(false)
|
||||
const [isSubscribed, setIsSubscribed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if push notifications are supported
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
setIsSupported(true)
|
||||
checkSubscription()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const checkSubscription = async () => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
setIsSubscribed(!!subscription)
|
||||
} catch (error) {
|
||||
console.error('Error checking push subscription:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const requestPermission = async () => {
|
||||
if (!isSupported) return
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Request notification permission
|
||||
const permission = await Notification.requestPermission()
|
||||
|
||||
if (permission !== 'granted') {
|
||||
toast({
|
||||
title: 'Aviseringar avslagna',
|
||||
description: 'Du kan aktivera aviseringar senare i inställningarna.',
|
||||
})
|
||||
setIsOpen(false)
|
||||
onDismissed?.()
|
||||
return
|
||||
}
|
||||
|
||||
// Get VAPID public key
|
||||
const vapidResponse = await fetch('/api/extensions/push-notifications/subscribe')
|
||||
const { vapidPublicKey } = await vapidResponse.json()
|
||||
|
||||
if (!vapidPublicKey) {
|
||||
throw new Error('Push notifications not configured')
|
||||
}
|
||||
|
||||
// Subscribe to push notifications
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
})
|
||||
|
||||
// Send subscription to server
|
||||
const response = await fetch('/api/extensions/push-notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(subscription.toJSON()),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save subscription')
|
||||
}
|
||||
|
||||
setIsSubscribed(true)
|
||||
setIsOpen(false)
|
||||
|
||||
toast({
|
||||
title: 'Aviseringar aktiverade',
|
||||
description: 'Du kommer få påminnelser om viktiga deadlines.',
|
||||
})
|
||||
|
||||
onSubscribed?.()
|
||||
} catch (error) {
|
||||
console.error('Error subscribing to push:', error)
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte aktivera aviseringar. Försök igen senare.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsOpen(false)
|
||||
// Store dismissal in localStorage to not show again
|
||||
localStorage.setItem('push-prompt-dismissed', 'true')
|
||||
onDismissed?.()
|
||||
}
|
||||
|
||||
// Don't show if not supported, already subscribed, or previously dismissed
|
||||
if (!isSupported || isSubscribed) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Show dialog after a delay or when triggered
|
||||
return (
|
||||
<>
|
||||
{/* Floating button to show prompt */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="fixed bottom-4 right-4 gap-2 shadow-lg z-50"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
Aktivera aviseringar
|
||||
</Button>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
Aktivera push-aviseringar
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Få påminnelser om viktiga skattedeadlines, fakturor som förfaller och
|
||||
kampanjleveranser direkt i din webbläsare.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||
<span>Skattedeadlines (7 och 1 dag innan)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||
<span>Fakturor som förfaller</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||
<span>Kampanjleveranser</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Du kan när som helst stänga av aviseringar i inställningarna.
|
||||
Vi respekterar tysta timmar (21:00-08:00).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="ghost" onClick={handleDismiss}>
|
||||
Inte nu
|
||||
</Button>
|
||||
<Button onClick={requestPermission} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Aktiverar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
Aktivera
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64 VAPID key to Uint8Array for subscription
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = window.atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
|
||||
return outputArray.buffer
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Push Notifications Extension
|
||||
*
|
||||
* Converts system events into instant push notifications.
|
||||
* Also provides cron-based scheduling for time-dependent checks
|
||||
* (tax deadlines, invoice due/overdue).
|
||||
*
|
||||
* Event handlers follow the gate pattern:
|
||||
* check extension setting -> build payload -> send via unified pipeline
|
||||
*/
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
import { sendNotificationToUser } from './notification-sender'
|
||||
import {
|
||||
createPeriodLockedPayload,
|
||||
createYearClosedPayload,
|
||||
createInvoiceSentPayload,
|
||||
createReceiptExtractedPayload,
|
||||
createReceiptMatchedPayload,
|
||||
} from './payload-builders'
|
||||
|
||||
// ============================================================
|
||||
// Settings
|
||||
// ============================================================
|
||||
|
||||
export interface PushNotificationSettings {
|
||||
periodLockedEnabled: boolean
|
||||
periodYearClosedEnabled: boolean
|
||||
invoiceSentEnabled: boolean
|
||||
receiptExtractedEnabled: boolean
|
||||
receiptMatchedEnabled: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: PushNotificationSettings = {
|
||||
periodLockedEnabled: true,
|
||||
periodYearClosedEnabled: true,
|
||||
invoiceSentEnabled: false,
|
||||
receiptExtractedEnabled: true,
|
||||
receiptMatchedEnabled: true,
|
||||
}
|
||||
|
||||
export async function getSettings(userId: string): Promise<PushNotificationSettings> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', userId)
|
||||
.eq('extension_id', 'push-notifications')
|
||||
.eq('key', 'settings')
|
||||
.single()
|
||||
|
||||
if (!data?.value) return { ...DEFAULT_SETTINGS }
|
||||
|
||||
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<PushNotificationSettings>) }
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
userId: string,
|
||||
partial: Partial<PushNotificationSettings>
|
||||
): Promise<PushNotificationSettings> {
|
||||
const current = await getSettings(userId)
|
||||
const merged = { ...current, ...partial }
|
||||
|
||||
const supabase = await createClient()
|
||||
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: 'push-notifications',
|
||||
key: 'settings',
|
||||
value: merged,
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event Handlers
|
||||
// ============================================================
|
||||
|
||||
async function handlePeriodLocked(
|
||||
payload: EventPayload<'period.locked'>
|
||||
): Promise<void> {
|
||||
const { period, userId } = payload
|
||||
|
||||
const settings = await getSettings(userId)
|
||||
if (!settings.periodLockedEnabled) return
|
||||
|
||||
const supabase = await createClient()
|
||||
const notificationPayload = createPeriodLockedPayload(period.name, period.id)
|
||||
|
||||
const result = await sendNotificationToUser(
|
||||
supabase,
|
||||
userId,
|
||||
notificationPayload,
|
||||
'period_locked',
|
||||
period.id
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
console.log(`[push-notifications] Period locked notification sent for ${period.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleYearClosed(
|
||||
payload: EventPayload<'period.year_closed'>
|
||||
): Promise<void> {
|
||||
const { period, userId } = payload
|
||||
|
||||
const settings = await getSettings(userId)
|
||||
if (!settings.periodYearClosedEnabled) return
|
||||
|
||||
const supabase = await createClient()
|
||||
const notificationPayload = createYearClosedPayload(period.name, period.id)
|
||||
|
||||
const result = await sendNotificationToUser(
|
||||
supabase,
|
||||
userId,
|
||||
notificationPayload,
|
||||
'period_year_closed',
|
||||
period.id
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
console.log(`[push-notifications] Year closed notification sent for ${period.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInvoiceSent(
|
||||
payload: EventPayload<'invoice.sent'>
|
||||
): Promise<void> {
|
||||
const { invoice, userId } = payload
|
||||
|
||||
const settings = await getSettings(userId)
|
||||
if (!settings.invoiceSentEnabled) return
|
||||
|
||||
const supabase = await createClient()
|
||||
const notificationPayload = createInvoiceSentPayload(
|
||||
invoice.invoice_number,
|
||||
invoice.id
|
||||
)
|
||||
|
||||
const result = await sendNotificationToUser(
|
||||
supabase,
|
||||
userId,
|
||||
notificationPayload,
|
||||
'invoice_sent',
|
||||
invoice.id
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
console.log(`[push-notifications] Invoice sent notification for #${invoice.invoice_number}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReceiptExtracted(
|
||||
payload: EventPayload<'receipt.extracted'>
|
||||
): Promise<void> {
|
||||
const { receipt, userId } = payload
|
||||
|
||||
const settings = await getSettings(userId)
|
||||
if (!settings.receiptExtractedEnabled) return
|
||||
|
||||
const supabase = await createClient()
|
||||
const notificationPayload = createReceiptExtractedPayload(
|
||||
receipt.merchant_name,
|
||||
receipt.id
|
||||
)
|
||||
|
||||
const result = await sendNotificationToUser(
|
||||
supabase,
|
||||
userId,
|
||||
notificationPayload,
|
||||
'receipt_extracted',
|
||||
receipt.id
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
console.log(`[push-notifications] Receipt extracted notification for ${receipt.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReceiptMatched(
|
||||
payload: EventPayload<'receipt.matched'>
|
||||
): Promise<void> {
|
||||
const { receipt, transaction, userId } = payload
|
||||
|
||||
const settings = await getSettings(userId)
|
||||
if (!settings.receiptMatchedEnabled) return
|
||||
|
||||
const supabase = await createClient()
|
||||
const notificationPayload = createReceiptMatchedPayload(receipt.id, transaction.id)
|
||||
|
||||
const result = await sendNotificationToUser(
|
||||
supabase,
|
||||
userId,
|
||||
notificationPayload,
|
||||
'receipt_matched',
|
||||
receipt.id
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
console.log(`[push-notifications] Receipt matched notification for ${receipt.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Extension Object
|
||||
// ============================================================
|
||||
|
||||
export const pushNotificationsExtension: Extension = {
|
||||
id: 'push-notifications',
|
||||
name: 'Push-notiser',
|
||||
version: '1.0.0',
|
||||
eventHandlers: [
|
||||
{ eventType: 'period.locked', handler: handlePeriodLocked },
|
||||
{ eventType: 'period.year_closed', handler: handleYearClosed },
|
||||
{ eventType: 'invoice.sent', handler: handleInvoiceSent },
|
||||
{ eventType: 'receipt.extracted', handler: handleReceiptExtracted },
|
||||
{ eventType: 'receipt.matched', handler: handleReceiptMatched },
|
||||
],
|
||||
settingsPanel: {
|
||||
label: 'Push-notiser',
|
||||
path: '/settings/extensions/push-notifications',
|
||||
},
|
||||
async onInstall(ctx) {
|
||||
await saveSettings(ctx.userId, DEFAULT_SETTINGS)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Cron-based notification scheduling.
|
||||
*
|
||||
* Handles time-dependent checks that cannot be event-driven:
|
||||
* - Tax deadlines approaching (7 days, 1 day, today)
|
||||
* - Invoice due/overdue reminders (3 days before, on due date, 3/7 days overdue)
|
||||
*
|
||||
* Both functions call `sendNotificationToUser()` from the sender module
|
||||
* instead of duplicating the send pipeline.
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { NotificationType } from '@/types'
|
||||
import { sendNotificationToUser } from './notification-sender'
|
||||
import {
|
||||
createTaxDeadlinePayload,
|
||||
createInvoiceOverduePayload,
|
||||
createInvoiceDuePayload,
|
||||
} from './payload-builders'
|
||||
|
||||
/**
|
||||
* Send tax deadline notifications.
|
||||
* Checks for deadlines due in 7 days, 1 day, or today.
|
||||
*/
|
||||
export async function sendTaxDeadlineNotifications(
|
||||
supabase: SupabaseClient
|
||||
): Promise<{ sent: number; skipped: number }> {
|
||||
let sent = 0
|
||||
let skipped = 0
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const todayStr = today.toISOString().split('T')[0]
|
||||
|
||||
const in7Days = new Date(today)
|
||||
in7Days.setDate(in7Days.getDate() + 7)
|
||||
const in7DaysStr = in7Days.toISOString().split('T')[0]
|
||||
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
const tomorrowStr = tomorrow.toISOString().split('T')[0]
|
||||
|
||||
const { data: deadlines } = await supabase
|
||||
.from('deadlines')
|
||||
.select('id, user_id, title, due_date')
|
||||
.eq('deadline_type', 'tax')
|
||||
.eq('is_completed', false)
|
||||
.in('status', ['upcoming', 'action_needed'])
|
||||
.in('due_date', [in7DaysStr, tomorrowStr, todayStr])
|
||||
|
||||
if (!deadlines || deadlines.length === 0) {
|
||||
return { sent: 0, skipped: 0 }
|
||||
}
|
||||
|
||||
// Group by user
|
||||
const userDeadlines = new Map<string, typeof deadlines>()
|
||||
for (const deadline of deadlines) {
|
||||
const list = userDeadlines.get(deadline.user_id) || []
|
||||
list.push(deadline)
|
||||
userDeadlines.set(deadline.user_id, list)
|
||||
}
|
||||
|
||||
for (const [userId, userDls] of userDeadlines) {
|
||||
// Check user-level tax_deadlines_enabled
|
||||
const { data: settings } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('tax_deadlines_enabled')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (settings && !settings.tax_deadlines_enabled) {
|
||||
skipped += userDls.length
|
||||
continue
|
||||
}
|
||||
|
||||
for (const deadline of userDls) {
|
||||
const daysUntil = Math.ceil(
|
||||
(new Date(deadline.due_date).getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
|
||||
const payload = createTaxDeadlinePayload(
|
||||
deadline.title,
|
||||
deadline.due_date,
|
||||
daysUntil,
|
||||
deadline.id
|
||||
)
|
||||
|
||||
const result = await sendNotificationToUser(
|
||||
supabase,
|
||||
userId,
|
||||
payload,
|
||||
'tax_deadline',
|
||||
deadline.id,
|
||||
daysUntil
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
sent++
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { sent, skipped }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send invoice reminder notifications.
|
||||
* Checks for invoices due in 3 days, today, or overdue by 3/7 days.
|
||||
*/
|
||||
export async function sendInvoiceNotifications(
|
||||
supabase: SupabaseClient
|
||||
): Promise<{ sent: number; skipped: number }> {
|
||||
let sent = 0
|
||||
let skipped = 0
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const todayStr = today.toISOString().split('T')[0]
|
||||
|
||||
const in3Days = new Date(today)
|
||||
in3Days.setDate(in3Days.getDate() + 3)
|
||||
const in3DaysStr = in3Days.toISOString().split('T')[0]
|
||||
|
||||
const daysAgo3 = new Date(today)
|
||||
daysAgo3.setDate(daysAgo3.getDate() - 3)
|
||||
const daysAgo3Str = daysAgo3.toISOString().split('T')[0]
|
||||
|
||||
const daysAgo7 = new Date(today)
|
||||
daysAgo7.setDate(daysAgo7.getDate() - 7)
|
||||
const daysAgo7Str = daysAgo7.toISOString().split('T')[0]
|
||||
|
||||
const { data: invoices } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, user_id, invoice_number, total, due_date, customer:customers(name)')
|
||||
.in('status', ['sent', 'overdue'])
|
||||
.in('due_date', [in3DaysStr, todayStr, daysAgo3Str, daysAgo7Str])
|
||||
|
||||
if (!invoices || invoices.length === 0) {
|
||||
return { sent: 0, skipped: 0 }
|
||||
}
|
||||
|
||||
// Group by user
|
||||
const userInvoices = new Map<string, typeof invoices>()
|
||||
for (const invoice of invoices) {
|
||||
const list = userInvoices.get(invoice.user_id) || []
|
||||
list.push(invoice)
|
||||
userInvoices.set(invoice.user_id, list)
|
||||
}
|
||||
|
||||
for (const [userId, userInvs] of userInvoices) {
|
||||
// Check user-level invoice_reminders_enabled
|
||||
const { data: settings } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('invoice_reminders_enabled')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (settings && !settings.invoice_reminders_enabled) {
|
||||
skipped += userInvs.length
|
||||
continue
|
||||
}
|
||||
|
||||
for (const invoice of userInvs) {
|
||||
const dueDate = new Date(invoice.due_date)
|
||||
const daysUntil = Math.ceil(
|
||||
(dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
const isOverdue = daysUntil < 0
|
||||
const notificationType: NotificationType = isOverdue ? 'invoice_overdue' : 'invoice_due'
|
||||
|
||||
const customer = invoice.customer as unknown as { name: string } | null
|
||||
const customerName = customer?.name || 'Okänd kund'
|
||||
|
||||
const payload = isOverdue
|
||||
? createInvoiceOverduePayload(
|
||||
invoice.invoice_number,
|
||||
customerName,
|
||||
invoice.total,
|
||||
invoice.due_date,
|
||||
invoice.id
|
||||
)
|
||||
: createInvoiceDuePayload(
|
||||
invoice.invoice_number,
|
||||
customerName,
|
||||
invoice.total,
|
||||
invoice.due_date,
|
||||
invoice.id
|
||||
)
|
||||
|
||||
const result = await sendNotificationToUser(
|
||||
supabase,
|
||||
userId,
|
||||
payload,
|
||||
notificationType,
|
||||
invoice.id,
|
||||
Math.abs(daysUntil)
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
sent++
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { sent, skipped }
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Unified push notification send pipeline.
|
||||
*
|
||||
* Both event handlers and the cron scheduler call `sendNotificationToUser()`.
|
||||
* This module owns the full chain:
|
||||
* settings check -> quiet hours -> duplicate check -> send -> log -> cleanup
|
||||
*/
|
||||
|
||||
import webpush from 'web-push'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { NotificationSettings, NotificationType } from '@/types'
|
||||
|
||||
// ============================================================
|
||||
// VAPID configuration
|
||||
// ============================================================
|
||||
|
||||
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY
|
||||
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY
|
||||
const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:support@erp-base.se'
|
||||
|
||||
if (vapidPublicKey && vapidPrivateKey) {
|
||||
webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export interface PushSubscriptionKeys {
|
||||
endpoint: string
|
||||
p256dh: string
|
||||
auth: string
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
title: string
|
||||
body: string
|
||||
icon?: string
|
||||
badge?: string
|
||||
tag?: string
|
||||
data?: {
|
||||
url?: string
|
||||
type?: string
|
||||
id?: string
|
||||
}
|
||||
actions?: Array<{
|
||||
action: string
|
||||
title: string
|
||||
icon?: string
|
||||
}>
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Public API
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get the public VAPID key for client-side subscription.
|
||||
*/
|
||||
export function getVapidPublicKey(): string | null {
|
||||
return vapidPublicKey || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to a specific user.
|
||||
*
|
||||
* Performs the full pipeline:
|
||||
* 1. Check push_enabled in notification_settings
|
||||
* 2. Check quiet hours
|
||||
* 3. Check duplicate via notification_log
|
||||
* 4. Get user's push subscriptions
|
||||
* 5. Send via web-push
|
||||
* 6. Log to notification_log
|
||||
* 7. Disable gone subscriptions (410)
|
||||
*
|
||||
* Returns { sent: boolean, reason?: string }
|
||||
*/
|
||||
export async function sendNotificationToUser(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
payload: NotificationPayload,
|
||||
notificationType: NotificationType,
|
||||
referenceId: string,
|
||||
daysBefore: number = 0
|
||||
): Promise<{ sent: boolean; reason?: string }> {
|
||||
try {
|
||||
// 1. Check notification settings
|
||||
const settings = await getUserNotificationSettings(supabase, userId)
|
||||
|
||||
if (settings && !settings.push_enabled) {
|
||||
return { sent: false, reason: 'push_disabled' }
|
||||
}
|
||||
|
||||
// 2. Check quiet hours
|
||||
if (settings && isWithinQuietHours(settings)) {
|
||||
return { sent: false, reason: 'quiet_hours' }
|
||||
}
|
||||
|
||||
// 3. Check duplicate
|
||||
const alreadySent = await wasNotificationSent(
|
||||
supabase,
|
||||
userId,
|
||||
notificationType,
|
||||
referenceId,
|
||||
daysBefore
|
||||
)
|
||||
if (alreadySent) {
|
||||
return { sent: false, reason: 'duplicate' }
|
||||
}
|
||||
|
||||
// 4. Get subscriptions
|
||||
const subscriptions = await getUserSubscriptions(supabase, userId)
|
||||
if (subscriptions.length === 0) {
|
||||
return { sent: false, reason: 'no_subscriptions' }
|
||||
}
|
||||
|
||||
// 5. Send
|
||||
const result = await sendPushToMultiple(subscriptions, payload)
|
||||
|
||||
// 6. Log
|
||||
await logNotification(supabase, userId, notificationType, referenceId, daysBefore)
|
||||
|
||||
// 7. Cleanup gone subscriptions
|
||||
if (result.goneSubscriptions.length > 0) {
|
||||
await disableGoneSubscriptions(supabase, result.goneSubscriptions)
|
||||
}
|
||||
|
||||
return result.successful > 0
|
||||
? { sent: true }
|
||||
: { sent: false, reason: 'send_failed' }
|
||||
} catch (error) {
|
||||
console.error(`[push-notifications] sendNotificationToUser failed for ${userId}:`, error)
|
||||
return { sent: false, reason: 'error' }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Internal helpers
|
||||
// ============================================================
|
||||
|
||||
async function sendPushNotification(
|
||||
subscription: PushSubscriptionKeys,
|
||||
payload: NotificationPayload
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!vapidPublicKey || !vapidPrivateKey) {
|
||||
console.error('VAPID keys not configured')
|
||||
return { success: false, error: 'VAPID keys not configured' }
|
||||
}
|
||||
|
||||
const pushSubscription = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(pushSubscription, JSON.stringify(payload), {
|
||||
TTL: 86400,
|
||||
urgency: 'normal',
|
||||
})
|
||||
return { success: true }
|
||||
} catch (error: unknown) {
|
||||
const err = error as { statusCode?: number; message?: string }
|
||||
if (err.statusCode === 410) {
|
||||
return { success: false, error: 'subscription_gone' }
|
||||
}
|
||||
console.error('Push notification error:', err)
|
||||
return { success: false, error: err.message || 'Unknown error' }
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPushToMultiple(
|
||||
subscriptions: PushSubscriptionKeys[],
|
||||
payload: NotificationPayload
|
||||
): Promise<{ successful: number; failed: number; goneSubscriptions: string[] }> {
|
||||
let successful = 0
|
||||
let failed = 0
|
||||
const goneSubscriptions: string[] = []
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map((sub) => sendPushNotification(sub, payload))
|
||||
)
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled' && result.value.success) {
|
||||
successful++
|
||||
} else {
|
||||
failed++
|
||||
if (result.status === 'fulfilled' && result.value.error === 'subscription_gone') {
|
||||
goneSubscriptions.push(subscriptions[index].endpoint)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { successful, failed, goneSubscriptions }
|
||||
}
|
||||
|
||||
export function isWithinQuietHours(
|
||||
settings: NotificationSettings,
|
||||
now: Date = new Date()
|
||||
): boolean {
|
||||
const swedenTime = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: 'Europe/Stockholm',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(now)
|
||||
|
||||
const [hours, minutes] = swedenTime.split(':').map(Number)
|
||||
const currentMinutes = hours * 60 + minutes
|
||||
|
||||
const [startHours, startMinutes] = settings.quiet_start.split(':').map(Number)
|
||||
const [endHours, endMinutes] = settings.quiet_end.split(':').map(Number)
|
||||
|
||||
const quietStartMinutes = startHours * 60 + startMinutes
|
||||
const quietEndMinutes = endHours * 60 + endMinutes
|
||||
|
||||
if (quietStartMinutes > quietEndMinutes) {
|
||||
return currentMinutes >= quietStartMinutes || currentMinutes < quietEndMinutes
|
||||
}
|
||||
|
||||
return currentMinutes >= quietStartMinutes && currentMinutes < quietEndMinutes
|
||||
}
|
||||
|
||||
async function wasNotificationSent(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
notificationType: NotificationType,
|
||||
referenceId: string,
|
||||
daysBefore: number
|
||||
): Promise<boolean> {
|
||||
const { data } = await supabase
|
||||
.from('notification_log')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('notification_type', notificationType)
|
||||
.eq('reference_id', referenceId)
|
||||
.eq('days_before', daysBefore)
|
||||
.single()
|
||||
|
||||
return !!data
|
||||
}
|
||||
|
||||
async function logNotification(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
notificationType: NotificationType,
|
||||
referenceId: string,
|
||||
daysBefore: number
|
||||
): Promise<void> {
|
||||
await supabase.from('notification_log').insert({
|
||||
user_id: userId,
|
||||
notification_type: notificationType,
|
||||
reference_id: referenceId,
|
||||
days_before: daysBefore,
|
||||
delivery_status: 'sent',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getUserSubscriptions(
|
||||
supabase: SupabaseClient,
|
||||
userId: string
|
||||
): Promise<PushSubscriptionKeys[]> {
|
||||
const { data } = await supabase
|
||||
.from('push_subscriptions')
|
||||
.select('endpoint, p256dh, auth')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_active', true)
|
||||
|
||||
return data || []
|
||||
}
|
||||
|
||||
export async function getUserNotificationSettings(
|
||||
supabase: SupabaseClient,
|
||||
userId: string
|
||||
): Promise<NotificationSettings | null> {
|
||||
const { data } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
async function disableGoneSubscriptions(
|
||||
supabase: SupabaseClient,
|
||||
endpoints: string[]
|
||||
): Promise<void> {
|
||||
if (endpoints.length === 0) return
|
||||
|
||||
await supabase
|
||||
.from('push_subscriptions')
|
||||
.update({ is_active: false })
|
||||
.in('endpoint', endpoints)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Notification payload constructors for all push notification types.
|
||||
*
|
||||
* Centralises every notification's title/body/icon/action in one place.
|
||||
* Both event handlers and the cron scheduler import from here.
|
||||
*/
|
||||
|
||||
import type { NotificationPayload } from './notification-sender'
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('sv-SE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event-driven payloads
|
||||
// ============================================================
|
||||
|
||||
export function createPeriodLockedPayload(
|
||||
periodName: string,
|
||||
periodId: string
|
||||
): NotificationPayload {
|
||||
return {
|
||||
title: 'Period låst',
|
||||
body: `${periodName} har låsts`,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `period-locked-${periodId}`,
|
||||
data: {
|
||||
url: '/periods',
|
||||
type: 'period_locked',
|
||||
id: periodId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createYearClosedPayload(
|
||||
periodName: string,
|
||||
periodId: string
|
||||
): NotificationPayload {
|
||||
return {
|
||||
title: 'Årsbokslut klart',
|
||||
body: `Årsbokslut klart för ${periodName}`,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `year-closed-${periodId}`,
|
||||
data: {
|
||||
url: '/periods',
|
||||
type: 'period_year_closed',
|
||||
id: periodId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createInvoiceSentPayload(
|
||||
invoiceNumber: string,
|
||||
invoiceId: string
|
||||
): NotificationPayload {
|
||||
return {
|
||||
title: 'Faktura skickad',
|
||||
body: `Faktura ${invoiceNumber} skickad`,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `invoice-sent-${invoiceId}`,
|
||||
data: {
|
||||
url: `/invoices/${invoiceId}`,
|
||||
type: 'invoice_sent',
|
||||
id: invoiceId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createReceiptExtractedPayload(
|
||||
merchantName: string | null,
|
||||
receiptId: string
|
||||
): NotificationPayload {
|
||||
const merchant = merchantName || 'Okänd butik'
|
||||
return {
|
||||
title: 'Kvitto analyserat',
|
||||
body: `Kvitto analyserat: ${merchant}`,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `receipt-extracted-${receiptId}`,
|
||||
data: {
|
||||
url: '/receipts',
|
||||
type: 'receipt_extracted',
|
||||
id: receiptId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createReceiptMatchedPayload(
|
||||
receiptId: string,
|
||||
transactionId: string
|
||||
): NotificationPayload {
|
||||
return {
|
||||
title: 'Kvitto matchat',
|
||||
body: 'Kvitto matchat mot transaktion',
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `receipt-matched-${receiptId}`,
|
||||
data: {
|
||||
url: '/receipts',
|
||||
type: 'receipt_matched',
|
||||
id: receiptId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Cron-based payloads (moved from lib/push/web-push.ts)
|
||||
// ============================================================
|
||||
|
||||
export function createTaxDeadlinePayload(
|
||||
title: string,
|
||||
dueDate: string,
|
||||
daysUntil: number,
|
||||
deadlineId: string
|
||||
): NotificationPayload {
|
||||
const urgencyText =
|
||||
daysUntil === 0 ? 'IDAG!' : daysUntil === 1 ? 'imorgon' : `om ${daysUntil} dagar`
|
||||
|
||||
return {
|
||||
title: `${title}`,
|
||||
body: `Deadline ${urgencyText} (${formatDate(dueDate)})`,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `tax-deadline-${deadlineId}`,
|
||||
data: {
|
||||
url: '/calendar',
|
||||
type: 'tax_deadline',
|
||||
id: deadlineId,
|
||||
},
|
||||
actions: [
|
||||
{ action: 'view', title: 'Visa' },
|
||||
{ action: 'dismiss', title: 'Avfärda' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function createInvoiceOverduePayload(
|
||||
invoiceNumber: string,
|
||||
customerName: string,
|
||||
amount: number,
|
||||
dueDate: string,
|
||||
invoiceId: string
|
||||
): NotificationPayload {
|
||||
return {
|
||||
title: `Obetald faktura #${invoiceNumber}`,
|
||||
body: `${customerName} - ${amount.toLocaleString('sv-SE')} kr (förföll ${formatDate(dueDate)})`,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `invoice-${invoiceId}`,
|
||||
data: {
|
||||
url: `/invoices/${invoiceId}`,
|
||||
type: 'invoice_overdue',
|
||||
id: invoiceId,
|
||||
},
|
||||
actions: [{ action: 'view', title: 'Visa faktura' }],
|
||||
}
|
||||
}
|
||||
|
||||
export function createInvoiceDuePayload(
|
||||
invoiceNumber: string,
|
||||
customerName: string,
|
||||
amount: number,
|
||||
dueDate: string,
|
||||
invoiceId: string
|
||||
): NotificationPayload {
|
||||
return {
|
||||
title: `Faktura #${invoiceNumber} förfaller`,
|
||||
body: `${customerName} - ${amount.toLocaleString('sv-SE')} kr (${formatDate(dueDate)})`,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `invoice-${invoiceId}`,
|
||||
data: {
|
||||
url: `/invoices/${invoiceId}`,
|
||||
type: 'invoice_due',
|
||||
id: invoiceId,
|
||||
},
|
||||
actions: [{ action: 'view', title: 'Visa faktura' }],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Push subscription for Web Push API
|
||||
export interface PushSubscription {
|
||||
id: string
|
||||
user_id: string
|
||||
endpoint: string
|
||||
p256dh: string
|
||||
auth: string
|
||||
user_agent: string | null
|
||||
is_active: boolean
|
||||
last_used_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Notification settings per user
|
||||
export interface NotificationSettings {
|
||||
id: string
|
||||
user_id: string
|
||||
tax_deadlines_enabled: boolean
|
||||
invoice_reminders_enabled: boolean
|
||||
quiet_start: string // time format "HH:MM"
|
||||
quiet_end: string // time format "HH:MM"
|
||||
email_enabled: boolean
|
||||
push_enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Notification type for logging
|
||||
export type NotificationType =
|
||||
| 'tax_deadline'
|
||||
| 'invoice_due'
|
||||
| 'invoice_overdue'
|
||||
| 'period_locked'
|
||||
| 'period_year_closed'
|
||||
| 'receipt_extracted'
|
||||
| 'receipt_matched'
|
||||
| 'invoice_sent'
|
||||
|
||||
// Notification log entry
|
||||
export interface NotificationLog {
|
||||
id: string
|
||||
user_id: string
|
||||
notification_type: NotificationType
|
||||
reference_id: string
|
||||
days_before: number
|
||||
sent_at: string
|
||||
delivery_status: 'sent' | 'delivered' | 'failed'
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user