Merge remote-tracking branch 'origin/main' into fixing-extensions-bug
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
import {
|
||||
@@ -26,7 +25,15 @@ const DEFAULT_SETTINGS: AiCategorizationSettings = {
|
||||
providerModel: 'claude-haiku-4-5-20251001',
|
||||
}
|
||||
|
||||
/** Get settings via ExtensionContext (preferred in event handlers) */
|
||||
async function getSettingsViaCtx(ctx: ExtensionContext): Promise<AiCategorizationSettings> {
|
||||
const stored = await ctx.settings.get<Partial<AiCategorizationSettings>>()
|
||||
return { ...DEFAULT_SETTINGS, ...(stored || {}) }
|
||||
}
|
||||
|
||||
/** Get settings for external callers (settings routes, on-demand API) */
|
||||
export async function getSettings(userId: string): Promise<AiCategorizationSettings> {
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data } = await supabase
|
||||
@@ -38,7 +45,6 @@ export async function getSettings(userId: string): Promise<AiCategorizationSetti
|
||||
.single()
|
||||
|
||||
if (!data?.value) return { ...DEFAULT_SETTINGS }
|
||||
|
||||
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<AiCategorizationSettings>) }
|
||||
}
|
||||
|
||||
@@ -49,6 +55,7 @@ export async function saveSettings(
|
||||
const current = await getSettings(userId)
|
||||
const merged = { ...current, ...partial }
|
||||
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
await supabase
|
||||
@@ -87,6 +94,7 @@ export async function categorizeTransactions(
|
||||
userId: string,
|
||||
transactionIds: string[]
|
||||
): Promise<CategorizationSuggestion[]> {
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
const settings = await getSettings(userId)
|
||||
|
||||
@@ -125,12 +133,14 @@ export async function categorizeTransactions(
|
||||
// ============================================================
|
||||
|
||||
async function handleTransactionSynced(
|
||||
payload: EventPayload<'transaction.synced'>
|
||||
payload: EventPayload<'transaction.synced'>,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<void> {
|
||||
const { transactions: syncedTransactions, userId } = payload
|
||||
const log = ctx?.log ?? console
|
||||
|
||||
// Gate: Is autoSuggestEnabled?
|
||||
const settings = await getSettings(userId)
|
||||
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
|
||||
if (!settings.autoSuggestEnabled) {
|
||||
return
|
||||
}
|
||||
@@ -143,12 +153,10 @@ async function handleTransactionSynced(
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ai-categorization] Auto-suggest triggered for ${uncategorized.length} uncategorized transactions`
|
||||
)
|
||||
log.info(`Auto-suggest triggered for ${uncategorized.length} uncategorized transactions`)
|
||||
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
|
||||
const batch: TransactionForCategorization[] = uncategorized.map((t: Transaction) => ({
|
||||
id: t.id,
|
||||
@@ -173,11 +181,11 @@ async function handleTransactionSynced(
|
||||
await storeSuggestions(userId, qualifiedSuggestions, supabase)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ai-categorization] Generated ${suggestions.length} suggestions, ${qualifiedSuggestions.length} above threshold (${settings.confidenceThreshold})`
|
||||
log.info(
|
||||
`Generated ${suggestions.length} suggestions, ${qualifiedSuggestions.length} above threshold (${settings.confidenceThreshold})`
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[ai-categorization] handleTransactionSynced failed:', error)
|
||||
log.error('handleTransactionSynced failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +259,6 @@ export const aiCategorizationExtension: Extension = {
|
||||
path: '/settings/extensions/ai-categorization',
|
||||
},
|
||||
async onInstall(ctx) {
|
||||
await saveSettings(ctx.userId, DEFAULT_SETTINGS)
|
||||
await ctx.settings.set('settings', DEFAULT_SETTINGS)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||
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'
|
||||
@@ -41,7 +34,8 @@ export const enableBankingExtension: Extension = {
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/banks',
|
||||
handler: async () => {
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
try {
|
||||
const aspsps = await getASPSPs('SE')
|
||||
const banks = aspsps.map((aspsp: ASPSP) => ({
|
||||
@@ -52,7 +46,7 @@ export const enableBankingExtension: Extension = {
|
||||
}))
|
||||
return NextResponse.json({ banks })
|
||||
} catch (error) {
|
||||
console.error('Error fetching banks:', error)
|
||||
log.error('Error fetching banks:', error)
|
||||
return NextResponse.json({
|
||||
banks: [
|
||||
{ name: 'Nordea', country: 'SE', bic: 'NDEASESS' },
|
||||
@@ -67,8 +61,9 @@ export const enableBankingExtension: Extension = {
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/connect',
|
||||
handler: async (request: Request) => {
|
||||
const supabase = await createClient()
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
@@ -108,7 +103,7 @@ export const enableBankingExtension: Extension = {
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Database error:', error)
|
||||
log.error('Database error:', error)
|
||||
throw new Error('Failed to store connection')
|
||||
}
|
||||
|
||||
@@ -117,7 +112,7 @@ export const enableBankingExtension: Extension = {
|
||||
authorization_url: url,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Bank connection error:', error)
|
||||
log.error('Bank connection error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Connection failed' },
|
||||
{ status: 500 }
|
||||
@@ -128,8 +123,9 @@ export const enableBankingExtension: Extension = {
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/sync',
|
||||
handler: async (request: Request) => {
|
||||
const supabase = await createClient()
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
const log = ctx?.log ?? console
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
@@ -161,6 +157,9 @@ export const enableBankingExtension: Extension = {
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
|
||||
// Use ctx.services.ingestTransactions when available
|
||||
const ingestFn = ctx?.services.ingestTransactions
|
||||
|
||||
let totalImported = 0
|
||||
let totalDuplicates = 0
|
||||
|
||||
@@ -171,7 +170,8 @@ export const enableBankingExtension: Extension = {
|
||||
connection.id,
|
||||
account,
|
||||
fromDate,
|
||||
toDate
|
||||
toDate,
|
||||
ingestFn
|
||||
)
|
||||
|
||||
totalImported += result.imported
|
||||
@@ -198,7 +198,8 @@ export const enableBankingExtension: Extension = {
|
||||
.limit(totalImported)
|
||||
|
||||
if (syncedTransactions && syncedTransactions.length > 0) {
|
||||
await eventBus.emit({
|
||||
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
|
||||
await emit({
|
||||
type: 'transaction.synced',
|
||||
payload: { transactions: syncedTransactions as Transaction[], userId: user.id },
|
||||
})
|
||||
@@ -211,7 +212,7 @@ export const enableBankingExtension: Extension = {
|
||||
last_synced_at: syncedAt,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error)
|
||||
log.error('Sync error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Sync failed' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getTransactions, getAccountBalance } from './api-client'
|
||||
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
|
||||
import { ingestTransactions as defaultIngest } from '@/lib/transactions/ingest'
|
||||
import type { RawTransaction, IngestResult } from '@/types'
|
||||
import type { StoredAccount } from '../types'
|
||||
|
||||
/** Ingest function signature — matches lib/transactions/ingest */
|
||||
export type IngestFn = (
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
raw: RawTransaction[]
|
||||
) => Promise<IngestResult>
|
||||
|
||||
export interface SyncResult {
|
||||
imported: number
|
||||
duplicates: number
|
||||
@@ -14,6 +22,10 @@ export interface SyncResult {
|
||||
*
|
||||
* Fetches transactions from the Enable Banking API, converts to RawTransaction
|
||||
* format, and delegates to the shared ingestion pipeline.
|
||||
*
|
||||
* @param ingest - Optional ingest function override (defaults to core ingestTransactions).
|
||||
* When called from an extension handler with ctx.services.ingestTransactions,
|
||||
* pass that function to avoid direct @/lib imports.
|
||||
*/
|
||||
export async function syncAccountTransactions(
|
||||
supabase: SupabaseClient,
|
||||
@@ -21,7 +33,8 @@ export async function syncAccountTransactions(
|
||||
connectionId: string,
|
||||
account: StoredAccount,
|
||||
fromDate: string,
|
||||
toDate: string
|
||||
toDate: string,
|
||||
ingest: IngestFn = defaultIngest
|
||||
): Promise<SyncResult> {
|
||||
const bankTransactions = await getTransactions(
|
||||
account.uid,
|
||||
@@ -44,7 +57,7 @@ export async function syncAccountTransactions(
|
||||
import_source: 'enable_banking',
|
||||
}))
|
||||
|
||||
const ingestResult = await ingestTransactions(supabase, userId, rawTransactions)
|
||||
const ingestResult = await ingest(supabase, userId, rawTransactions)
|
||||
|
||||
// Update account balance
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { createMockSupabase } from '@/tests/helpers'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/invoice-analyzer', () => ({
|
||||
analyzeInvoice: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/supplier-matcher', () => ({
|
||||
matchSupplier: vi.fn(),
|
||||
}))
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { invoiceInboxExtension, getSettings, saveSettings } from '../index'
|
||||
|
||||
const mockCreateClient = vi.mocked(createClient)
|
||||
|
||||
describe('Invoice Inbox Extension', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
describe('Extension metadata', () => {
|
||||
it('has correct id and version', () => {
|
||||
expect(invoiceInboxExtension.id).toBe('invoice-inbox')
|
||||
expect(invoiceInboxExtension.name).toBe('Invoice Inbox')
|
||||
expect(invoiceInboxExtension.version).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('has event handler for document.uploaded', () => {
|
||||
expect(invoiceInboxExtension.eventHandlers).toHaveLength(1)
|
||||
expect(invoiceInboxExtension.eventHandlers![0].eventType).toBe('document.uploaded')
|
||||
})
|
||||
|
||||
it('has settings panel', () => {
|
||||
expect(invoiceInboxExtension.settingsPanel).toEqual({
|
||||
label: 'Invoice Inbox',
|
||||
path: '/settings/extensions/invoice-inbox',
|
||||
})
|
||||
})
|
||||
|
||||
it('has onInstall hook', () => {
|
||||
expect(invoiceInboxExtension.onInstall).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSettings', () => {
|
||||
it('returns default settings when no data exists', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
mockResult({ data: null, error: null })
|
||||
|
||||
const settings = await getSettings('user-1')
|
||||
|
||||
expect(settings).toEqual({
|
||||
autoProcessEnabled: true,
|
||||
autoMatchSupplierEnabled: true,
|
||||
supplierMatchThreshold: 0.7,
|
||||
inboxEmail: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('merges stored settings with defaults', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
mockResult({
|
||||
data: { value: { inboxEmail: 'test@inbox.example.com' } },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const settings = await getSettings('user-1')
|
||||
|
||||
expect(settings.inboxEmail).toBe('test@inbox.example.com')
|
||||
expect(settings.autoProcessEnabled).toBe(true) // default
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveSettings', () => {
|
||||
it('merges partial settings with current', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
|
||||
// First call for getSettings (inside saveSettings)
|
||||
mockResult({ data: null, error: null })
|
||||
|
||||
const settings = await saveSettings('user-1', { inboxEmail: 'new@inbox.com' })
|
||||
|
||||
expect(settings.inboxEmail).toBe('new@inbox.com')
|
||||
expect(settings.autoProcessEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
import { analyzeInvoice } from './lib/invoice-analyzer'
|
||||
import { matchSupplier } from './lib/supplier-matcher'
|
||||
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
import type { InvoiceInboxSettings } from './types'
|
||||
|
||||
// ============================================================
|
||||
// Settings
|
||||
// ============================================================
|
||||
|
||||
const DEFAULT_SETTINGS: InvoiceInboxSettings = {
|
||||
autoProcessEnabled: true,
|
||||
autoMatchSupplierEnabled: true,
|
||||
supplierMatchThreshold: 0.7,
|
||||
inboxEmail: null,
|
||||
}
|
||||
|
||||
/** Get settings via ExtensionContext (preferred in event handlers) */
|
||||
async function getSettingsViaCtx(ctx: ExtensionContext): Promise<InvoiceInboxSettings> {
|
||||
const stored = await ctx.settings.get<Partial<InvoiceInboxSettings>>()
|
||||
return { ...DEFAULT_SETTINGS, ...(stored || {}) }
|
||||
}
|
||||
|
||||
/** Get settings for external callers (settings routes, API routes) */
|
||||
export async function getSettings(userId: string): Promise<InvoiceInboxSettings> {
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', userId)
|
||||
.eq('extension_id', 'invoice-inbox')
|
||||
.eq('key', 'settings')
|
||||
.single()
|
||||
|
||||
if (!data?.value) return { ...DEFAULT_SETTINGS }
|
||||
|
||||
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<InvoiceInboxSettings>) }
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
userId: string,
|
||||
partial: Partial<InvoiceInboxSettings>
|
||||
): Promise<InvoiceInboxSettings> {
|
||||
const current = await getSettings(userId)
|
||||
const merged = { ...current, ...partial }
|
||||
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: 'invoice-inbox',
|
||||
key: 'settings',
|
||||
value: merged,
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event Handlers
|
||||
// ============================================================
|
||||
|
||||
const INVOICE_MIME_TYPES = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
]
|
||||
|
||||
/**
|
||||
* When a PDF/image is uploaded via the document archive, check if it should
|
||||
* be auto-processed as a supplier invoice.
|
||||
*/
|
||||
async function handleDocumentUploaded(
|
||||
payload: EventPayload<'document.uploaded'>,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<void> {
|
||||
const { document, userId } = payload
|
||||
const log = ctx?.log ?? console
|
||||
|
||||
// Gate: Is it a supported file type?
|
||||
if (!document.mime_type || !INVOICE_MIME_TYPES.includes(document.mime_type)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Gate: Is autoProcessEnabled?
|
||||
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
|
||||
if (!settings.autoProcessEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Gate: Was this document already processed as an inbox item?
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const { data: existing } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('document_id', document.id)
|
||||
.limit(1)
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
log.info(`Auto-process triggered for document ${document.id}`)
|
||||
|
||||
try {
|
||||
// Create inbox item
|
||||
const { data: inboxItem, error: insertError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
status: 'processing',
|
||||
source: 'upload',
|
||||
document_id: document.id,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (insertError || !inboxItem) {
|
||||
log.error('Failed to create inbox item:', insertError)
|
||||
return
|
||||
}
|
||||
|
||||
// Download file from storage
|
||||
const { data: fileData, error: downloadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.download(document.storage_path)
|
||||
|
||||
if (downloadError || !fileData) {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ status: 'error', error_message: 'Failed to download document' })
|
||||
.eq('id', inboxItem.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
const arrayBuffer = await fileData.arrayBuffer()
|
||||
const base64 = Buffer.from(arrayBuffer).toString('base64')
|
||||
|
||||
// Analyze invoice
|
||||
const extraction = await analyzeInvoice(base64, document.mime_type)
|
||||
|
||||
// Supplier matching
|
||||
let matchedSupplierId: string | null = null
|
||||
if (settings.autoMatchSupplierEnabled) {
|
||||
const { data: suppliers } = await supabase
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (suppliers && suppliers.length > 0) {
|
||||
const match = matchSupplier(extraction, suppliers)
|
||||
if (match && match.confidence >= settings.supplierMatchThreshold) {
|
||||
matchedSupplierId = match.supplierId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update inbox item with extracted data
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
extracted_data: extraction as unknown as Record<string, unknown>,
|
||||
confidence: extraction.confidence,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
})
|
||||
.eq('id', inboxItem.id)
|
||||
|
||||
// Fetch updated item
|
||||
const { data: updatedItem } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('*')
|
||||
.eq('id', inboxItem.id)
|
||||
.single()
|
||||
|
||||
if (updatedItem) {
|
||||
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
|
||||
await emit({
|
||||
type: 'supplier_invoice.extracted',
|
||||
payload: {
|
||||
inboxItem: updatedItem,
|
||||
confidence: extraction.confidence,
|
||||
userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
log.info(`Invoice ${inboxItem.id} processed (confidence: ${extraction.confidence})`)
|
||||
} catch (error) {
|
||||
log.error('handleDocumentUploaded failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Extension Object
|
||||
// ============================================================
|
||||
|
||||
export const invoiceInboxExtension: Extension = {
|
||||
id: 'invoice-inbox',
|
||||
name: 'Invoice Inbox',
|
||||
version: '1.0.0',
|
||||
eventHandlers: [
|
||||
{ eventType: 'document.uploaded', handler: handleDocumentUploaded },
|
||||
],
|
||||
settingsPanel: {
|
||||
label: 'Invoice Inbox',
|
||||
path: '/settings/extensions/invoice-inbox',
|
||||
},
|
||||
async onInstall(ctx) {
|
||||
await ctx.settings.set('settings', DEFAULT_SETTINGS)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// Mock server-only
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '../email-handler'
|
||||
import type { ResendInboundPayload } from '../../types'
|
||||
|
||||
describe('Email Handler', () => {
|
||||
describe('parseInboundPayload', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(parseInboundPayload(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for non-object input', () => {
|
||||
expect(parseInboundPayload('string')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when from is missing', () => {
|
||||
expect(parseInboundPayload({ to: 'test@example.com' })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when to is missing', () => {
|
||||
expect(parseInboundPayload({ from: 'test@example.com' })).toBeNull()
|
||||
})
|
||||
|
||||
it('parses valid payload', () => {
|
||||
const result = parseInboundPayload({
|
||||
from: 'supplier@example.com',
|
||||
to: 'inbox@mycompany.com',
|
||||
subject: 'Faktura F-001',
|
||||
html: '<p>Attached</p>',
|
||||
text: 'Attached',
|
||||
attachments: [{ filename: 'invoice.pdf', content_type: 'application/pdf', content: 'base64data' }],
|
||||
created_at: '2024-06-15T10:00:00Z',
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.from).toBe('supplier@example.com')
|
||||
expect(result!.to).toBe('inbox@mycompany.com')
|
||||
expect(result!.subject).toBe('Faktura F-001')
|
||||
expect(result!.attachments).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('handles missing optional fields', () => {
|
||||
const result = parseInboundPayload({
|
||||
from: 'a@b.com',
|
||||
to: 'c@d.com',
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.subject).toBe('')
|
||||
expect(result!.html).toBeNull()
|
||||
expect(result!.text).toBeNull()
|
||||
expect(result!.attachments).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractAttachments', () => {
|
||||
it('filters to supported file types only', () => {
|
||||
const payload: ResendInboundPayload = {
|
||||
from: 'a@b.com',
|
||||
to: 'c@d.com',
|
||||
subject: 'Test',
|
||||
html: null,
|
||||
text: null,
|
||||
created_at: '2024-06-15T10:00:00Z',
|
||||
attachments: [
|
||||
{ filename: 'invoice.pdf', content_type: 'application/pdf', content: 'base64' },
|
||||
{ filename: 'photo.jpg', content_type: 'image/jpeg', content: 'base64' },
|
||||
{ filename: 'doc.docx', content_type: 'application/vnd.openxmlformats', content: 'base64' },
|
||||
{ filename: 'sheet.xlsx', content_type: 'application/vnd.ms-excel', content: 'base64' },
|
||||
{ filename: 'scan.png', content_type: 'image/png', content: 'base64' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = extractAttachments(payload)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map(a => a.content_type)).toEqual([
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
])
|
||||
})
|
||||
|
||||
it('filters out attachments without content', () => {
|
||||
const payload: ResendInboundPayload = {
|
||||
from: 'a@b.com',
|
||||
to: 'c@d.com',
|
||||
subject: 'Test',
|
||||
html: null,
|
||||
text: null,
|
||||
created_at: '2024-06-15T10:00:00Z',
|
||||
attachments: [
|
||||
{ filename: 'invoice.pdf', content_type: 'application/pdf', content: '' },
|
||||
{ filename: 'photo.jpg', content_type: 'image/jpeg', content: 'base64data' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = extractAttachments(payload)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveUserFromEmail', () => {
|
||||
it('returns null when no extension data found', async () => {
|
||||
const mockClient = {
|
||||
from: vi.fn().mockReturnValue({
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({ data: null, error: { message: 'not found' } }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
|
||||
const result = await resolveUserFromEmail('test@inbox.com', mockClient)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns user_id when email matches', async () => {
|
||||
const mockClient = {
|
||||
from: vi.fn().mockReturnValue({
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ user_id: 'user-1', value: { inboxEmail: 'test@inbox.com' } },
|
||||
{ user_id: 'user-2', value: { inboxEmail: 'other@inbox.com' } },
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
|
||||
const result = await resolveUserFromEmail('test@inbox.com', mockClient)
|
||||
expect(result).toBe('user-1')
|
||||
})
|
||||
|
||||
it('handles case-insensitive email matching', async () => {
|
||||
const mockClient = {
|
||||
from: vi.fn().mockReturnValue({
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ user_id: 'user-1', value: { inboxEmail: 'Test@Inbox.Com' } },
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
|
||||
const result = await resolveUserFromEmail('test@inbox.com', mockClient)
|
||||
expect(result).toBe('user-1')
|
||||
})
|
||||
|
||||
it('returns null when no matching email', async () => {
|
||||
const mockClient = {
|
||||
from: vi.fn().mockReturnValue({
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ user_id: 'user-1', value: { inboxEmail: 'other@inbox.com' } },
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
|
||||
const result = await resolveUserFromEmail('notfound@inbox.com', mockClient)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock server-only
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
// Mock Anthropic SDK - vi.hoisted ensures the variable is available before vi.mock hoisting
|
||||
const { mockCreate } = vi.hoisted(() => {
|
||||
const mockCreate = vi.fn()
|
||||
return { mockCreate }
|
||||
})
|
||||
|
||||
vi.mock('@anthropic-ai/sdk', () => {
|
||||
return {
|
||||
default: class MockAnthropic {
|
||||
messages = { create: mockCreate }
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { analyzeInvoice } from '../invoice-analyzer'
|
||||
|
||||
describe('Invoice Analyzer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const validExtractionJson = JSON.stringify({
|
||||
supplier: {
|
||||
name: 'Kontorsbolaget AB',
|
||||
orgNumber: '556123-4567',
|
||||
vatNumber: 'SE5561234567',
|
||||
address: 'Storgatan 1, 111 22 Stockholm',
|
||||
bankgiro: '123-4567',
|
||||
plusgiro: null,
|
||||
},
|
||||
invoice: {
|
||||
invoiceNumber: 'F-2024-001',
|
||||
invoiceDate: '2024-06-15',
|
||||
dueDate: '2024-07-15',
|
||||
paymentReference: '1234567890',
|
||||
currency: 'SEK',
|
||||
},
|
||||
lineItems: [
|
||||
{
|
||||
description: 'Kontorsmaterial',
|
||||
quantity: 10,
|
||||
unitPrice: 50,
|
||||
lineTotal: 500,
|
||||
vatRate: 25,
|
||||
accountSuggestion: '6100',
|
||||
},
|
||||
],
|
||||
totals: {
|
||||
subtotal: 500,
|
||||
vatAmount: 125,
|
||||
total: 625,
|
||||
},
|
||||
vatBreakdown: [
|
||||
{ rate: 25, base: 500, amount: 125 },
|
||||
],
|
||||
confidence: 0.92,
|
||||
})
|
||||
|
||||
it('parses valid AI response for PDF', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
content: [{ type: 'text', text: validExtractionJson }],
|
||||
})
|
||||
|
||||
const result = await analyzeInvoice('base64data', 'application/pdf')
|
||||
|
||||
expect(result.supplier.name).toBe('Kontorsbolaget AB')
|
||||
expect(result.supplier.orgNumber).toBe('556123-4567')
|
||||
expect(result.invoice.invoiceNumber).toBe('F-2024-001')
|
||||
expect(result.lineItems).toHaveLength(1)
|
||||
expect(result.lineItems[0].lineTotal).toBe(500)
|
||||
expect(result.totals.total).toBe(625)
|
||||
expect(result.confidence).toBe(0.92)
|
||||
})
|
||||
|
||||
it('parses valid AI response for image', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
content: [{ type: 'text', text: validExtractionJson }],
|
||||
})
|
||||
|
||||
const result = await analyzeInvoice('base64data', 'image/jpeg')
|
||||
|
||||
expect(result.supplier.name).toBe('Kontorsbolaget AB')
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('strips markdown code blocks from response', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
content: [{ type: 'text', text: '```json\n' + validExtractionJson + '\n```' }],
|
||||
})
|
||||
|
||||
const result = await analyzeInvoice('base64data', 'application/pdf')
|
||||
expect(result.supplier.name).toBe('Kontorsbolaget AB')
|
||||
})
|
||||
|
||||
it('validates org number format', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
supplier: { name: 'Test', orgNumber: '5561234567', vatNumber: null, address: null, bankgiro: null, plusgiro: null },
|
||||
invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
|
||||
lineItems: [],
|
||||
totals: { subtotal: 0, vatAmount: 0, total: 0 },
|
||||
vatBreakdown: [],
|
||||
confidence: 0.5,
|
||||
}) }],
|
||||
})
|
||||
|
||||
const result = await analyzeInvoice('base64data', 'application/pdf')
|
||||
expect(result.supplier.orgNumber).toBe('556123-4567') // Formatted with dash
|
||||
})
|
||||
|
||||
it('rejects invalid VAT numbers', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
supplier: { name: 'Test', orgNumber: null, vatNumber: 'DE123', address: null, bankgiro: null, plusgiro: null },
|
||||
invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
|
||||
lineItems: [],
|
||||
totals: { subtotal: 0, vatAmount: 0, total: 0 },
|
||||
vatBreakdown: [],
|
||||
confidence: 0.5,
|
||||
}) }],
|
||||
})
|
||||
|
||||
const result = await analyzeInvoice('base64data', 'application/pdf')
|
||||
expect(result.supplier.vatNumber).toBeNull() // Not SE-prefixed
|
||||
})
|
||||
|
||||
it('throws on JSON parse error without retrying', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
content: [{ type: 'text', text: 'not json at all' }],
|
||||
})
|
||||
|
||||
await expect(analyzeInvoice('base64data', 'application/pdf')).rejects.toThrow('Failed to parse AI response')
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1) // No retry for parse errors
|
||||
})
|
||||
|
||||
it('retries on API errors', async () => {
|
||||
mockCreate
|
||||
.mockRejectedValueOnce(new Error('API timeout'))
|
||||
.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: validExtractionJson }],
|
||||
})
|
||||
|
||||
const result = await analyzeInvoice('base64data', 'application/pdf')
|
||||
expect(result.supplier.name).toBe('Kontorsbolaget AB')
|
||||
expect(mockCreate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('throws after max retries', async () => {
|
||||
mockCreate.mockRejectedValue(new Error('API timeout'))
|
||||
|
||||
await expect(analyzeInvoice('base64data', 'application/pdf')).rejects.toThrow(
|
||||
'Invoice analysis failed after 3 attempts'
|
||||
)
|
||||
expect(mockCreate).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('rejects unsupported file types', async () => {
|
||||
await expect(analyzeInvoice('base64data', 'text/plain')).rejects.toThrow(
|
||||
'Unsupported file type'
|
||||
)
|
||||
})
|
||||
|
||||
it('validates account number suggestions', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
supplier: { name: 'Test', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
|
||||
invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
|
||||
lineItems: [
|
||||
{ description: 'Item', quantity: 1, unitPrice: 100, lineTotal: 100, vatRate: 25, accountSuggestion: '6100' },
|
||||
{ description: 'Bad', quantity: 1, unitPrice: 50, lineTotal: 50, vatRate: 25, accountSuggestion: 'abc' },
|
||||
],
|
||||
totals: { subtotal: 150, vatAmount: 37.5, total: 187.5 },
|
||||
vatBreakdown: [],
|
||||
confidence: 0.8,
|
||||
}) }],
|
||||
})
|
||||
|
||||
const result = await analyzeInvoice('base64data', 'application/pdf')
|
||||
expect(result.lineItems[0].accountSuggestion).toBe('6100')
|
||||
expect(result.lineItems[1].accountSuggestion).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
matchSupplier,
|
||||
normalizeOrgNumber,
|
||||
normalizeVatNumber,
|
||||
normalizeBankgiro,
|
||||
calculateNameSimilarity,
|
||||
normalizeCompanyName,
|
||||
levenshteinDistance,
|
||||
} from '../supplier-matcher'
|
||||
import { makeSupplier } from '@/tests/helpers'
|
||||
import type { InvoiceExtractionResult } from '../../types'
|
||||
|
||||
function makeExtraction(overrides: Partial<InvoiceExtractionResult['supplier']> = {}): InvoiceExtractionResult {
|
||||
return {
|
||||
supplier: {
|
||||
name: null,
|
||||
orgNumber: null,
|
||||
vatNumber: null,
|
||||
address: null,
|
||||
bankgiro: null,
|
||||
plusgiro: null,
|
||||
...overrides,
|
||||
},
|
||||
invoice: {
|
||||
invoiceNumber: null,
|
||||
invoiceDate: null,
|
||||
dueDate: null,
|
||||
paymentReference: null,
|
||||
currency: 'SEK',
|
||||
},
|
||||
lineItems: [],
|
||||
totals: { subtotal: null, vatAmount: null, total: null },
|
||||
vatBreakdown: [],
|
||||
confidence: 0.9,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Supplier Matcher', () => {
|
||||
describe('matchSupplier', () => {
|
||||
it('returns null for empty supplier list', () => {
|
||||
const result = matchSupplier(
|
||||
makeExtraction({ name: 'Test AB' }),
|
||||
[]
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('matches by exact org number (pass 1)', () => {
|
||||
const suppliers = [
|
||||
makeSupplier({ id: 's1', name: 'Supplier A', org_number: '5599887766' }),
|
||||
makeSupplier({ id: 's2', name: 'Supplier B', org_number: '1122334455' }),
|
||||
]
|
||||
|
||||
const result = matchSupplier(
|
||||
makeExtraction({ orgNumber: '559988-7766' }),
|
||||
suppliers
|
||||
)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.supplierId).toBe('s1')
|
||||
expect(result!.matchMethod).toBe('org_number')
|
||||
expect(result!.confidence).toBe(0.98)
|
||||
})
|
||||
|
||||
it('matches by org number with different formatting', () => {
|
||||
const suppliers = [
|
||||
makeSupplier({ id: 's1', org_number: '556123-4567' }),
|
||||
]
|
||||
|
||||
const result = matchSupplier(
|
||||
makeExtraction({ orgNumber: '5561234567' }),
|
||||
suppliers
|
||||
)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.matchMethod).toBe('org_number')
|
||||
})
|
||||
|
||||
it('matches by VAT number (pass 2)', () => {
|
||||
const suppliers = [
|
||||
makeSupplier({ id: 's1', vat_number: 'SE556123456701' }),
|
||||
]
|
||||
|
||||
const result = matchSupplier(
|
||||
makeExtraction({ vatNumber: 'SE 5561 2345 6701' }),
|
||||
suppliers
|
||||
)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.matchMethod).toBe('vat_number')
|
||||
expect(result!.confidence).toBe(0.95)
|
||||
})
|
||||
|
||||
it('matches by bankgiro (pass 3)', () => {
|
||||
const suppliers = [
|
||||
makeSupplier({ id: 's1', bankgiro: '123-4567' }),
|
||||
]
|
||||
|
||||
const result = matchSupplier(
|
||||
makeExtraction({ bankgiro: '1234567' }),
|
||||
suppliers
|
||||
)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.matchMethod).toBe('bankgiro')
|
||||
expect(result!.confidence).toBe(0.92)
|
||||
})
|
||||
|
||||
it('matches by plusgiro', () => {
|
||||
const suppliers = [
|
||||
makeSupplier({ id: 's1', plusgiro: '123456-7' }),
|
||||
]
|
||||
|
||||
const result = matchSupplier(
|
||||
makeExtraction({ plusgiro: '1234567' }),
|
||||
suppliers
|
||||
)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.matchMethod).toBe('bankgiro')
|
||||
})
|
||||
|
||||
it('matches by fuzzy name (pass 4)', () => {
|
||||
const suppliers = [
|
||||
makeSupplier({ id: 's1', name: 'Kontorsbolaget AB' }),
|
||||
makeSupplier({ id: 's2', name: 'Byggmaterial i Stockholm' }),
|
||||
]
|
||||
|
||||
const result = matchSupplier(
|
||||
makeExtraction({ name: 'Kontorsbolaget' }),
|
||||
suppliers
|
||||
)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.supplierId).toBe('s1')
|
||||
expect(result!.matchMethod).toBe('fuzzy_name')
|
||||
})
|
||||
|
||||
it('returns null for low-confidence fuzzy name match', () => {
|
||||
const suppliers = [
|
||||
makeSupplier({ id: 's1', name: 'Completely Different Name AB' }),
|
||||
]
|
||||
|
||||
const result = matchSupplier(
|
||||
makeExtraction({ name: 'XYZ Corp' }),
|
||||
suppliers
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers org number match over name match', () => {
|
||||
const suppliers = [
|
||||
makeSupplier({ id: 's1', name: 'Kontorsbolaget AB', org_number: '5599887766' }),
|
||||
]
|
||||
|
||||
const result = matchSupplier(
|
||||
makeExtraction({ name: 'Kontorsbolaget', orgNumber: '559988-7766' }),
|
||||
suppliers
|
||||
)
|
||||
|
||||
expect(result!.matchMethod).toBe('org_number')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeOrgNumber', () => {
|
||||
it('strips non-digits', () => {
|
||||
expect(normalizeOrgNumber('556123-4567')).toBe('5561234567')
|
||||
expect(normalizeOrgNumber('556123 4567')).toBe('5561234567')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeVatNumber', () => {
|
||||
it('uppercases and removes spaces', () => {
|
||||
expect(normalizeVatNumber('se 5561234567 01')).toBe('SE556123456701')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeBankgiro', () => {
|
||||
it('strips non-digits', () => {
|
||||
expect(normalizeBankgiro('123-4567')).toBe('1234567')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeCompanyName', () => {
|
||||
it('strips AB suffix', () => {
|
||||
expect(normalizeCompanyName('Kontorsbolaget AB')).toBe('kontorsbolaget')
|
||||
})
|
||||
|
||||
it('strips HB suffix', () => {
|
||||
expect(normalizeCompanyName('Bröderna Svensson HB')).toBe('bröderna svensson')
|
||||
})
|
||||
|
||||
it('strips Aktiebolag', () => {
|
||||
expect(normalizeCompanyName('Test Aktiebolag')).toBe('test')
|
||||
})
|
||||
|
||||
it('strips Enskild firma', () => {
|
||||
expect(normalizeCompanyName('Test Enskild firma')).toBe('test')
|
||||
})
|
||||
|
||||
it('normalizes whitespace', () => {
|
||||
expect(normalizeCompanyName(' Multiple Spaces ')).toBe('multiple spaces')
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateNameSimilarity', () => {
|
||||
it('returns 1 for identical names', () => {
|
||||
expect(calculateNameSimilarity('Test AB', 'Test AB')).toBe(1)
|
||||
})
|
||||
|
||||
it('returns high score when one contains the other', () => {
|
||||
// After normalization 'AB' is stripped, so they become identical → 1.0
|
||||
expect(calculateNameSimilarity('Kontorsbolaget', 'Kontorsbolaget AB')).toBe(1)
|
||||
// With an actual substring relationship (not suffix stripping):
|
||||
expect(calculateNameSimilarity('Kontor', 'Kontorsbolaget')).toBe(0.9)
|
||||
})
|
||||
|
||||
it('returns 0 for empty strings', () => {
|
||||
expect(calculateNameSimilarity('', 'Test')).toBe(0)
|
||||
expect(calculateNameSimilarity('Test', '')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('levenshteinDistance', () => {
|
||||
it('returns 0 for identical strings', () => {
|
||||
expect(levenshteinDistance('test', 'test')).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates correct distance', () => {
|
||||
expect(levenshteinDistance('kitten', 'sitting')).toBe(3)
|
||||
})
|
||||
|
||||
it('handles empty strings', () => {
|
||||
expect(levenshteinDistance('', 'abc')).toBe(3)
|
||||
expect(levenshteinDistance('abc', '')).toBe(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Email Handler - Parse Resend inbound webhook payloads
|
||||
*
|
||||
* SERVER-ONLY: Uses service role client for cross-user lookups.
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
import type { ResendInboundPayload, ResendAttachment } from '../types'
|
||||
|
||||
const SUPPORTED_MIME_TYPES = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
]
|
||||
|
||||
/**
|
||||
* Parse and validate a Resend inbound webhook payload
|
||||
*/
|
||||
export function parseInboundPayload(body: unknown): ResendInboundPayload | null {
|
||||
if (!body || typeof body !== 'object') return null
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = body as any
|
||||
|
||||
if (!data.from || !data.to) return null
|
||||
|
||||
return {
|
||||
from: String(data.from),
|
||||
to: String(data.to),
|
||||
subject: data.subject ? String(data.subject) : '',
|
||||
html: data.html || null,
|
||||
text: data.text || null,
|
||||
attachments: Array.isArray(data.attachments) ? data.attachments : [],
|
||||
created_at: data.created_at || new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract supported file attachments from the payload.
|
||||
* Returns only PDF and image attachments.
|
||||
*/
|
||||
export function extractAttachments(payload: ResendInboundPayload): ResendAttachment[] {
|
||||
return payload.attachments.filter(
|
||||
(att) => att.content_type && SUPPORTED_MIME_TYPES.includes(att.content_type) && att.content
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve user_id from the recipient email address.
|
||||
* Looks up the extension_data table where users store their inbox email setting.
|
||||
*
|
||||
* Uses a service role client (passed as parameter) since webhook requests
|
||||
* don't have user authentication.
|
||||
*/
|
||||
export async function resolveUserFromEmail(
|
||||
recipientEmail: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
serviceClient: any
|
||||
): Promise<string | null> {
|
||||
// Extract the local part (before @) to handle address variants
|
||||
const normalizedEmail = recipientEmail.toLowerCase().trim()
|
||||
|
||||
// Look up in extension_data where invoice-inbox settings store the inbox email
|
||||
const { data, error } = await serviceClient
|
||||
.from('extension_data')
|
||||
.select('user_id, value')
|
||||
.eq('extension_id', 'invoice-inbox')
|
||||
.eq('key', 'settings')
|
||||
|
||||
if (error || !data) return null
|
||||
|
||||
// Find the user whose inboxEmail matches the recipient
|
||||
for (const row of data) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const settings = row.value as any
|
||||
if (settings?.inboxEmail && settings.inboxEmail.toLowerCase().trim() === normalizedEmail) {
|
||||
return row.user_id
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Invoice 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 supplier invoice PDFs/images and extracts structured data
|
||||
* including supplier info, line items, VAT breakdown, and payment details.
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import type { InvoiceExtractionResult, ExtractedInvoiceLineItem, VatBreakdownItem } from '../types'
|
||||
|
||||
const anthropic = new Anthropic()
|
||||
|
||||
const MAX_RETRIES = 3
|
||||
const RETRY_DELAY_MS = 1000
|
||||
|
||||
type ImageMediaType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
|
||||
|
||||
/**
|
||||
* Analyze a supplier invoice using Claude Haiku Vision.
|
||||
* Supports both PDF (native document support) and images.
|
||||
*/
|
||||
export async function analyzeInvoice(
|
||||
fileBase64: string,
|
||||
mimeType: string
|
||||
): Promise<InvoiceExtractionResult> {
|
||||
const systemPrompt = `Du är expert på att extrahera data från svenska leverantörsfakturor.
|
||||
Din uppgift är att noggrant analysera fakturan och extrahera all relevant information.
|
||||
|
||||
VIKTIGT:
|
||||
- Extrahera leverantörens organisationsnummer (XXXXXX-XXXX format)
|
||||
- Extrahera bankgiro och/eller plusgiro
|
||||
- Extrahera varje fakturaradspost med belopp, moms
|
||||
- Identifiera momssatser (25%, 12%, 6%, 0%)
|
||||
- Extrahera OCR-nummer eller betalningsreferens
|
||||
- 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`
|
||||
|
||||
const userPrompt = `Analysera denna leverantörsfaktura och extrahera strukturerad data.
|
||||
|
||||
Returnera ett JSON-objekt med följande struktur:
|
||||
|
||||
{
|
||||
"supplier": {
|
||||
"name": "Leverantörens namn",
|
||||
"orgNumber": "XXXXXX-XXXX eller null",
|
||||
"vatNumber": "SE... eller null",
|
||||
"address": "Fullständig adress eller null",
|
||||
"bankgiro": "XXX-XXXX eller null",
|
||||
"plusgiro": "XXXXXX-X eller null"
|
||||
},
|
||||
"invoice": {
|
||||
"invoiceNumber": "Fakturanummer",
|
||||
"invoiceDate": "YYYY-MM-DD",
|
||||
"dueDate": "YYYY-MM-DD",
|
||||
"paymentReference": "OCR-nummer eller referens eller null",
|
||||
"currency": "SEK"
|
||||
},
|
||||
"lineItems": [
|
||||
{
|
||||
"description": "Beskrivning av rad",
|
||||
"quantity": 1,
|
||||
"unitPrice": 100.00,
|
||||
"lineTotal": 100.00,
|
||||
"vatRate": 25,
|
||||
"accountSuggestion": "BAS-kontonummer som 5410 eller null"
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"subtotal": 100.00,
|
||||
"vatAmount": 25.00,
|
||||
"total": 125.00
|
||||
},
|
||||
"vatBreakdown": [
|
||||
{
|
||||
"rate": 25,
|
||||
"base": 100.00,
|
||||
"amount": 25.00
|
||||
}
|
||||
],
|
||||
"confidence": 0.95
|
||||
}
|
||||
|
||||
KONTOKATEGORIER (BAS):
|
||||
- 4000-4999: Varuinköp, material
|
||||
- 5010: Lokalhyra
|
||||
- 5410: Förbrukningsinventarier
|
||||
- 5420: Programvaror
|
||||
- 5800-5899: Resekostnader
|
||||
- 6100-6199: Kontorsmaterial
|
||||
- 6200-6299: Telefon, internet
|
||||
- 6310: Företagsförsäkringar
|
||||
- 6530: Redovisningstjänster
|
||||
- 6570: Bankkostnader
|
||||
|
||||
Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
// Build content based on mime type
|
||||
const isPdf = mimeType === 'application/pdf'
|
||||
const isImage = mimeType.startsWith('image/')
|
||||
|
||||
if (!isPdf && !isImage) {
|
||||
throw new Error(`Unsupported file type: ${mimeType}`)
|
||||
}
|
||||
|
||||
const contentBlocks: Anthropic.MessageCreateParams['messages'][0]['content'] = isPdf
|
||||
? [
|
||||
{
|
||||
type: 'document' as const,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: 'application/pdf' as const,
|
||||
data: fileBase64,
|
||||
},
|
||||
},
|
||||
{ type: 'text' as const, text: userPrompt },
|
||||
]
|
||||
: [
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: mimeType as ImageMediaType,
|
||||
data: fileBase64,
|
||||
},
|
||||
},
|
||||
{ type: 'text' as const, text: userPrompt },
|
||||
]
|
||||
|
||||
const message = await anthropic.messages.create({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
max_tokens: 4096,
|
||||
messages: [{ role: 'user', content: contentBlocks }],
|
||||
system: systemPrompt,
|
||||
})
|
||||
|
||||
const content = message.content[0]
|
||||
if (content.type !== 'text') {
|
||||
throw new Error('Unexpected response type from AI')
|
||||
}
|
||||
|
||||
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 validateAndEnhanceResult(parsed)
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||
|
||||
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(`Invoice analysis failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
|
||||
}
|
||||
|
||||
function validateAndEnhanceResult(raw: unknown): InvoiceExtractionResult {
|
||||
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 supplier = data.supplier || {}
|
||||
const invoice = data.invoice || {}
|
||||
const totals = data.totals || {}
|
||||
|
||||
return {
|
||||
supplier: {
|
||||
name: validateString(supplier.name),
|
||||
orgNumber: validateOrgNumber(supplier.orgNumber),
|
||||
vatNumber: validateVatNumber(supplier.vatNumber),
|
||||
address: validateString(supplier.address),
|
||||
bankgiro: validateString(supplier.bankgiro),
|
||||
plusgiro: validateString(supplier.plusgiro),
|
||||
},
|
||||
invoice: {
|
||||
invoiceNumber: validateString(invoice.invoiceNumber),
|
||||
invoiceDate: validateDate(invoice.invoiceDate),
|
||||
dueDate: validateDate(invoice.dueDate),
|
||||
paymentReference: validateString(invoice.paymentReference),
|
||||
currency: validateString(invoice.currency) || 'SEK',
|
||||
},
|
||||
lineItems: validateLineItems(data.lineItems),
|
||||
totals: {
|
||||
subtotal: validateNumber(totals.subtotal),
|
||||
vatAmount: validateNumber(totals.vatAmount),
|
||||
total: validateNumber(totals.total),
|
||||
},
|
||||
vatBreakdown: validateVatBreakdown(data.vatBreakdown),
|
||||
confidence: validateNumber(data.confidence) || 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function validateLineItems(data: any): ExtractedInvoiceLineItem[] {
|
||||
if (!Array.isArray(data)) return []
|
||||
|
||||
return data
|
||||
.filter((item: unknown) => item && typeof item === 'object')
|
||||
.map((item: Record<string, unknown>) => ({
|
||||
description: String(item.description || '').trim(),
|
||||
quantity: (validateNumber(item.quantity) || 1),
|
||||
unitPrice: validateNumber(item.unitPrice),
|
||||
lineTotal: validateNumber(item.lineTotal) || 0,
|
||||
vatRate: validateNumber(item.vatRate),
|
||||
accountSuggestion: validateAccountNumber(item.accountSuggestion as string | undefined),
|
||||
}))
|
||||
.filter((item: ExtractedInvoiceLineItem) => item.lineTotal > 0 || item.description.length > 0)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function validateVatBreakdown(data: any): VatBreakdownItem[] {
|
||||
if (!Array.isArray(data)) return []
|
||||
|
||||
return data
|
||||
.filter((item: unknown) => item && typeof item === 'object')
|
||||
.map((item: Record<string, unknown>) => ({
|
||||
rate: validateNumber(item.rate) || 0,
|
||||
base: validateNumber(item.base) || 0,
|
||||
amount: validateNumber(item.amount) || 0,
|
||||
}))
|
||||
.filter((item: VatBreakdownItem) => item.amount > 0 || item.base > 0)
|
||||
}
|
||||
|
||||
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 validateOrgNumber(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const digits = value.replace(/\D/g, '')
|
||||
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 validateAccountNumber(value: string | undefined): string | null {
|
||||
if (!value) return null
|
||||
const digits = value.replace(/\D/g, '')
|
||||
if (digits.length === 4 && parseInt(digits) >= 1000 && parseInt(digits) <= 9999) {
|
||||
return digits
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Supplier Matcher - Fuzzy matching between extracted invoice data and existing suppliers
|
||||
*
|
||||
* 4-pass matching algorithm:
|
||||
* 1. Exact org number match
|
||||
* 2. Exact VAT number match
|
||||
* 3. Bankgiro/plusgiro match
|
||||
* 4. Fuzzy name match (Levenshtein + Swedish suffix normalization)
|
||||
*/
|
||||
|
||||
import type { Supplier } from '@/types'
|
||||
import type { InvoiceExtractionResult, SupplierMatchResult } from '../types'
|
||||
|
||||
/**
|
||||
* Find the best matching supplier for extracted invoice data
|
||||
*/
|
||||
export function matchSupplier(
|
||||
extraction: InvoiceExtractionResult,
|
||||
suppliers: Supplier[]
|
||||
): SupplierMatchResult | null {
|
||||
if (suppliers.length === 0) return null
|
||||
|
||||
// Pass 1: Exact org number match
|
||||
if (extraction.supplier.orgNumber) {
|
||||
const normalizedOrg = normalizeOrgNumber(extraction.supplier.orgNumber)
|
||||
for (const supplier of suppliers) {
|
||||
if (supplier.org_number && normalizeOrgNumber(supplier.org_number) === normalizedOrg) {
|
||||
return {
|
||||
supplierId: supplier.id,
|
||||
supplierName: supplier.name,
|
||||
confidence: 0.98,
|
||||
matchMethod: 'org_number',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Exact VAT number match
|
||||
if (extraction.supplier.vatNumber) {
|
||||
const normalizedVat = normalizeVatNumber(extraction.supplier.vatNumber)
|
||||
for (const supplier of suppliers) {
|
||||
if (supplier.vat_number && normalizeVatNumber(supplier.vat_number) === normalizedVat) {
|
||||
return {
|
||||
supplierId: supplier.id,
|
||||
supplierName: supplier.name,
|
||||
confidence: 0.95,
|
||||
matchMethod: 'vat_number',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Bankgiro/plusgiro match
|
||||
if (extraction.supplier.bankgiro) {
|
||||
const normalizedBg = normalizeBankgiro(extraction.supplier.bankgiro)
|
||||
for (const supplier of suppliers) {
|
||||
if (supplier.bankgiro && normalizeBankgiro(supplier.bankgiro) === normalizedBg) {
|
||||
return {
|
||||
supplierId: supplier.id,
|
||||
supplierName: supplier.name,
|
||||
confidence: 0.92,
|
||||
matchMethod: 'bankgiro',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (extraction.supplier.plusgiro) {
|
||||
const normalizedPg = normalizeBankgiro(extraction.supplier.plusgiro)
|
||||
for (const supplier of suppliers) {
|
||||
if (supplier.plusgiro && normalizeBankgiro(supplier.plusgiro) === normalizedPg) {
|
||||
return {
|
||||
supplierId: supplier.id,
|
||||
supplierName: supplier.name,
|
||||
confidence: 0.92,
|
||||
matchMethod: 'bankgiro',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4: Fuzzy name match
|
||||
if (extraction.supplier.name) {
|
||||
let bestMatch: SupplierMatchResult | null = null
|
||||
|
||||
for (const supplier of suppliers) {
|
||||
const similarity = calculateNameSimilarity(extraction.supplier.name, supplier.name)
|
||||
const confidence = Math.round(similarity * 0.85 * 100) / 100 // Cap at 0.85 for name matches
|
||||
|
||||
if (confidence > 0.6 && (!bestMatch || confidence > bestMatch.confidence)) {
|
||||
bestMatch = {
|
||||
supplierId: supplier.id,
|
||||
supplierName: supplier.name,
|
||||
confidence,
|
||||
matchMethod: 'fuzzy_name',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize org number to digits only
|
||||
*/
|
||||
export function normalizeOrgNumber(orgNumber: string): string {
|
||||
return orgNumber.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize VAT number to uppercase, no spaces
|
||||
*/
|
||||
export function normalizeVatNumber(vatNumber: string): string {
|
||||
return vatNumber.replace(/\s/g, '').toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize bankgiro/plusgiro to digits only
|
||||
*/
|
||||
export function normalizeBankgiro(value: string): string {
|
||||
return value.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate name similarity with Swedish company suffix normalization
|
||||
*/
|
||||
export function calculateNameSimilarity(name1: string, name2: string): number {
|
||||
if (!name1 || !name2) return 0
|
||||
|
||||
const n1 = normalizeCompanyName(name1)
|
||||
const n2 = normalizeCompanyName(name2)
|
||||
|
||||
if (n1 === n2) return 1
|
||||
|
||||
if (n1.includes(n2) || n2.includes(n1)) return 0.9
|
||||
|
||||
// Word overlap scoring
|
||||
const words1 = n1.split(/\s+/).filter(Boolean)
|
||||
const words2 = n2.split(/\s+/).filter(Boolean)
|
||||
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
|
||||
}
|
||||
|
||||
// Levenshtein similarity
|
||||
const distance = levenshteinDistance(n1, n2)
|
||||
const maxLength = Math.max(n1.length, n2.length)
|
||||
return maxLength > 0 ? 1 - distance / maxLength : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Swedish company name for comparison.
|
||||
* Strips common legal suffixes and normalizes whitespace.
|
||||
*/
|
||||
export function normalizeCompanyName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\såäöé]/g, '')
|
||||
.replace(
|
||||
/\b(ab|hb|kb|ek|ek\s*för|enskild\s*firma|aktiebolag|handelsbolag|kommanditbolag|ekonomisk\s*förening|stiftelse|ideell\s*förening|i\s*likvidation)\b/g,
|
||||
''
|
||||
)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein distance between two strings
|
||||
*/
|
||||
export function levenshteinDistance(str1: string, str2: string): number {
|
||||
const m = str1.length
|
||||
const n = str2.length
|
||||
|
||||
const dp: number[][] = Array(m + 1)
|
||||
.fill(null)
|
||||
.map(() => Array(n + 1).fill(0))
|
||||
|
||||
for (let i = 0; i <= m; i++) dp[i][0] = i
|
||||
for (let j = 0; j <= n; j++) dp[0][j] = j
|
||||
|
||||
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,
|
||||
dp[i][j - 1] + 1,
|
||||
dp[i - 1][j - 1] + cost
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return dp[m][n]
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Invoice Inbox extension-specific types
|
||||
*/
|
||||
|
||||
export interface InvoiceExtractionResult {
|
||||
supplier: {
|
||||
name: string | null
|
||||
orgNumber: string | null
|
||||
vatNumber: string | null
|
||||
address: string | null
|
||||
bankgiro: string | null
|
||||
plusgiro: string | null
|
||||
}
|
||||
invoice: {
|
||||
invoiceNumber: string | null
|
||||
invoiceDate: string | null
|
||||
dueDate: string | null
|
||||
paymentReference: string | null // OCR number or reference
|
||||
currency: string
|
||||
}
|
||||
lineItems: ExtractedInvoiceLineItem[]
|
||||
totals: {
|
||||
subtotal: number | null
|
||||
vatAmount: number | null
|
||||
total: number | null
|
||||
}
|
||||
vatBreakdown: VatBreakdownItem[]
|
||||
confidence: number
|
||||
}
|
||||
|
||||
export interface ExtractedInvoiceLineItem {
|
||||
description: string
|
||||
quantity: number
|
||||
unitPrice: number | null
|
||||
lineTotal: number
|
||||
vatRate: number | null
|
||||
accountSuggestion: string | null
|
||||
}
|
||||
|
||||
export interface VatBreakdownItem {
|
||||
rate: number
|
||||
base: number
|
||||
amount: number
|
||||
}
|
||||
|
||||
export interface SupplierMatchResult {
|
||||
supplierId: string
|
||||
supplierName: string
|
||||
confidence: number
|
||||
matchMethod: 'org_number' | 'vat_number' | 'bankgiro' | 'fuzzy_name'
|
||||
}
|
||||
|
||||
export interface InvoiceInboxSettings {
|
||||
autoProcessEnabled: boolean
|
||||
autoMatchSupplierEnabled: boolean
|
||||
supplierMatchThreshold: number
|
||||
inboxEmail: string | null
|
||||
}
|
||||
|
||||
export interface ResendInboundPayload {
|
||||
from: string
|
||||
to: string
|
||||
subject: string
|
||||
html: string | null
|
||||
text: string | null
|
||||
attachments: ResendAttachment[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ResendAttachment {
|
||||
filename: string
|
||||
content_type: string
|
||||
content: string // base64-encoded
|
||||
}
|
||||
@@ -9,8 +9,7 @@
|
||||
* check extension setting -> build payload -> send via unified pipeline
|
||||
*/
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
import { sendNotificationToUser } from './notification-sender'
|
||||
import {
|
||||
@@ -41,7 +40,15 @@ const DEFAULT_SETTINGS: PushNotificationSettings = {
|
||||
receiptMatchedEnabled: true,
|
||||
}
|
||||
|
||||
/** Get settings via ExtensionContext (preferred in event handlers) */
|
||||
async function getSettingsViaCtx(ctx: ExtensionContext): Promise<PushNotificationSettings> {
|
||||
const stored = await ctx.settings.get<Partial<PushNotificationSettings>>()
|
||||
return { ...DEFAULT_SETTINGS, ...(stored || {}) }
|
||||
}
|
||||
|
||||
/** Get settings for external callers (settings routes, cron jobs) */
|
||||
export async function getSettings(userId: string): Promise<PushNotificationSettings> {
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data } = await supabase
|
||||
@@ -53,7 +60,6 @@ export async function getSettings(userId: string): Promise<PushNotificationSetti
|
||||
.single()
|
||||
|
||||
if (!data) return { ...DEFAULT_SETTINGS }
|
||||
|
||||
return {
|
||||
periodLockedEnabled: data.period_locked_enabled ?? DEFAULT_SETTINGS.periodLockedEnabled,
|
||||
periodYearClosedEnabled: data.period_year_closed_enabled ?? DEFAULT_SETTINGS.periodYearClosedEnabled,
|
||||
@@ -70,6 +76,7 @@ export async function saveSettings(
|
||||
const current = await getSettings(userId)
|
||||
const merged = { ...current, ...partial }
|
||||
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
await supabase
|
||||
@@ -94,14 +101,15 @@ export async function saveSettings(
|
||||
// ============================================================
|
||||
|
||||
async function handlePeriodLocked(
|
||||
payload: EventPayload<'period.locked'>
|
||||
payload: EventPayload<'period.locked'>,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<void> {
|
||||
const { period, userId } = payload
|
||||
|
||||
const settings = await getSettings(userId)
|
||||
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
|
||||
if (!settings.periodLockedEnabled) return
|
||||
|
||||
const supabase = await createClient()
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const notificationPayload = createPeriodLockedPayload(period.name, period.id)
|
||||
|
||||
const result = await sendNotificationToUser(
|
||||
@@ -113,19 +121,20 @@ async function handlePeriodLocked(
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
console.log(`[push-notifications] Period locked notification sent for ${period.name}`)
|
||||
(ctx?.log ?? console).info(`Period locked notification sent for ${period.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleYearClosed(
|
||||
payload: EventPayload<'period.year_closed'>
|
||||
payload: EventPayload<'period.year_closed'>,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<void> {
|
||||
const { period, userId } = payload
|
||||
|
||||
const settings = await getSettings(userId)
|
||||
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
|
||||
if (!settings.periodYearClosedEnabled) return
|
||||
|
||||
const supabase = await createClient()
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const notificationPayload = createYearClosedPayload(period.name, period.id)
|
||||
|
||||
const result = await sendNotificationToUser(
|
||||
@@ -137,19 +146,20 @@ async function handleYearClosed(
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
console.log(`[push-notifications] Year closed notification sent for ${period.name}`)
|
||||
(ctx?.log ?? console).info(`Year closed notification sent for ${period.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInvoiceSent(
|
||||
payload: EventPayload<'invoice.sent'>
|
||||
payload: EventPayload<'invoice.sent'>,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<void> {
|
||||
const { invoice, userId } = payload
|
||||
|
||||
const settings = await getSettings(userId)
|
||||
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
|
||||
if (!settings.invoiceSentEnabled) return
|
||||
|
||||
const supabase = await createClient()
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const notificationPayload = createInvoiceSentPayload(
|
||||
invoice.invoice_number,
|
||||
invoice.id
|
||||
@@ -164,19 +174,20 @@ async function handleInvoiceSent(
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
console.log(`[push-notifications] Invoice sent notification for #${invoice.invoice_number}`)
|
||||
(ctx?.log ?? console).info(`Invoice sent notification for #${invoice.invoice_number}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReceiptExtracted(
|
||||
payload: EventPayload<'receipt.extracted'>
|
||||
payload: EventPayload<'receipt.extracted'>,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<void> {
|
||||
const { receipt, userId } = payload
|
||||
|
||||
const settings = await getSettings(userId)
|
||||
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
|
||||
if (!settings.receiptExtractedEnabled) return
|
||||
|
||||
const supabase = await createClient()
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const notificationPayload = createReceiptExtractedPayload(
|
||||
receipt.merchant_name,
|
||||
receipt.id
|
||||
@@ -191,19 +202,20 @@ async function handleReceiptExtracted(
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
console.log(`[push-notifications] Receipt extracted notification for ${receipt.id}`)
|
||||
(ctx?.log ?? console).info(`Receipt extracted notification for ${receipt.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReceiptMatched(
|
||||
payload: EventPayload<'receipt.matched'>
|
||||
payload: EventPayload<'receipt.matched'>,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<void> {
|
||||
const { receipt, transaction, userId } = payload
|
||||
|
||||
const settings = await getSettings(userId)
|
||||
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
|
||||
if (!settings.receiptMatchedEnabled) return
|
||||
|
||||
const supabase = await createClient()
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
const notificationPayload = createReceiptMatchedPayload(receipt.id, transaction.id)
|
||||
|
||||
const result = await sendNotificationToUser(
|
||||
@@ -215,7 +227,7 @@ async function handleReceiptMatched(
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
console.log(`[push-notifications] Receipt matched notification for ${receipt.id}`)
|
||||
(ctx?.log ?? console).info(`Receipt matched notification for ${receipt.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,21 +251,6 @@ export const pushNotificationsExtension: Extension = {
|
||||
path: '/settings/extensions/push-notifications',
|
||||
},
|
||||
async onInstall(ctx) {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Ensure a notification_settings row exists for this user
|
||||
await supabase
|
||||
.from('notification_settings')
|
||||
.upsert(
|
||||
{
|
||||
user_id: ctx.userId,
|
||||
period_locked_enabled: DEFAULT_SETTINGS.periodLockedEnabled,
|
||||
period_year_closed_enabled: DEFAULT_SETTINGS.periodYearClosedEnabled,
|
||||
invoice_sent_enabled: DEFAULT_SETTINGS.invoiceSentEnabled,
|
||||
receipt_extracted_enabled: DEFAULT_SETTINGS.receiptExtractedEnabled,
|
||||
receipt_matched_enabled: DEFAULT_SETTINGS.receiptMatchedEnabled,
|
||||
},
|
||||
{ onConflict: 'user_id' }
|
||||
)
|
||||
await ctx.settings.set('settings', DEFAULT_SETTINGS)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
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 { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
import type { Receipt, Transaction } from '@/types'
|
||||
import type { Receipt } from '@/types'
|
||||
|
||||
// ============================================================
|
||||
// Settings
|
||||
@@ -25,7 +23,15 @@ const DEFAULT_SETTINGS: ReceiptOcrSettings = {
|
||||
ocrConfidenceThreshold: 0.6,
|
||||
}
|
||||
|
||||
/** Get settings via ExtensionContext (preferred in event handlers) */
|
||||
async function getSettingsViaCtx(ctx: ExtensionContext): Promise<ReceiptOcrSettings> {
|
||||
const stored = await ctx.settings.get<Partial<ReceiptOcrSettings>>()
|
||||
return { ...DEFAULT_SETTINGS, ...(stored || {}) }
|
||||
}
|
||||
|
||||
/** Get settings for external callers (settings routes, API routes) */
|
||||
export async function getSettings(userId: string): Promise<ReceiptOcrSettings> {
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data } = await supabase
|
||||
@@ -49,6 +55,7 @@ export async function saveSettings(
|
||||
const current = await getSettings(userId)
|
||||
const merged = { ...current, ...partial }
|
||||
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
|
||||
await supabase
|
||||
@@ -76,9 +83,11 @@ 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'>
|
||||
payload: EventPayload<'document.uploaded'>,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<void> {
|
||||
const { document, userId } = payload
|
||||
const log = ctx?.log ?? console
|
||||
|
||||
// Gate: Is it an image?
|
||||
if (!document.mime_type || !IMAGE_MIME_TYPES.includes(document.mime_type)) {
|
||||
@@ -86,15 +95,15 @@ async function handleDocumentUploaded(
|
||||
}
|
||||
|
||||
// Gate: Is autoOcrEnabled?
|
||||
const settings = await getSettings(userId)
|
||||
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
|
||||
if (!settings.autoOcrEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[receipt-ocr] Auto-OCR triggered for document ${document.id}`)
|
||||
log.info(`Auto-OCR triggered for document ${document.id}`)
|
||||
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
|
||||
// Download image from storage
|
||||
const { data: fileData, error: downloadError } = await supabase.storage
|
||||
@@ -102,7 +111,7 @@ async function handleDocumentUploaded(
|
||||
.download(document.storage_path)
|
||||
|
||||
if (downloadError || !fileData) {
|
||||
console.error('[receipt-ocr] Failed to download document:', downloadError)
|
||||
log.error('Failed to download document:', downloadError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -116,8 +125,8 @@ async function handleDocumentUploaded(
|
||||
|
||||
// Gate: Is confidence high enough?
|
||||
if (extraction.confidence < settings.ocrConfidenceThreshold) {
|
||||
console.log(
|
||||
`[receipt-ocr] Confidence ${extraction.confidence} below threshold ${settings.ocrConfidenceThreshold}, skipping`
|
||||
log.info(
|
||||
`Confidence ${extraction.confidence} below threshold ${settings.ocrConfidenceThreshold}, skipping`
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -155,7 +164,7 @@ async function handleDocumentUploaded(
|
||||
.single()
|
||||
|
||||
if (insertError || !receipt) {
|
||||
console.error('[receipt-ocr] Failed to create receipt:', insertError)
|
||||
log.error('Failed to create receipt:', insertError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -190,7 +199,8 @@ async function handleDocumentUploaded(
|
||||
.single()
|
||||
|
||||
// Emit receipt.extracted
|
||||
await eventBus.emit({
|
||||
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
|
||||
await emit({
|
||||
type: 'receipt.extracted',
|
||||
payload: {
|
||||
receipt: (completeReceipt || receipt) as unknown as Receipt,
|
||||
@@ -200,9 +210,9 @@ async function handleDocumentUploaded(
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`[receipt-ocr] Receipt ${receipt.id} created from document ${document.id}`)
|
||||
log.info(`Receipt ${receipt.id} created from document ${document.id}`)
|
||||
} catch (error) {
|
||||
console.error('[receipt-ocr] handleDocumentUploaded failed:', error)
|
||||
log.error('handleDocumentUploaded failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,12 +220,14 @@ async function handleDocumentUploaded(
|
||||
* When new transactions arrive from banking sync, auto-match unmatched receipts.
|
||||
*/
|
||||
async function handleTransactionSynced(
|
||||
payload: EventPayload<'transaction.synced'>
|
||||
payload: EventPayload<'transaction.synced'>,
|
||||
ctx?: ExtensionContext
|
||||
): Promise<void> {
|
||||
const { transactions: syncedTransactions, userId } = payload
|
||||
const log = ctx?.log ?? console
|
||||
|
||||
// Gate: Is autoMatchEnabled?
|
||||
const settings = await getSettings(userId)
|
||||
const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId)
|
||||
if (!settings.autoMatchEnabled) {
|
||||
return
|
||||
}
|
||||
@@ -226,12 +238,10 @@ async function handleTransactionSynced(
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[receipt-ocr] Auto-match triggered for ${expenseTransactions.length} expense transactions`
|
||||
)
|
||||
log.info(`Auto-match triggered for ${expenseTransactions.length} expense transactions`)
|
||||
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
|
||||
|
||||
// Fetch unmatched receipts
|
||||
const { data: unmatchedReceipts, error: fetchError } = await supabase
|
||||
@@ -252,6 +262,8 @@ async function handleTransactionSynced(
|
||||
settings.autoMatchThreshold
|
||||
)
|
||||
|
||||
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
|
||||
|
||||
// Process each match
|
||||
for (const { receipt, match } of matches) {
|
||||
// Update receipt with match
|
||||
@@ -270,7 +282,7 @@ async function handleTransactionSynced(
|
||||
.eq('id', match.transaction.id)
|
||||
|
||||
// Emit receipt.matched
|
||||
await eventBus.emit({
|
||||
await emit({
|
||||
type: 'receipt.matched',
|
||||
payload: {
|
||||
receipt,
|
||||
@@ -281,12 +293,12 @@ async function handleTransactionSynced(
|
||||
},
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[receipt-ocr] Auto-matched receipt ${receipt.id} to transaction ${match.transaction.id} (confidence: ${match.confidence})`
|
||||
log.info(
|
||||
`Auto-matched receipt ${receipt.id} to transaction ${match.transaction.id} (confidence: ${match.confidence})`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[receipt-ocr] handleTransactionSynced failed:', error)
|
||||
log.error('handleTransactionSynced failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +331,6 @@ export const receiptOcrExtension: Extension = {
|
||||
path: '/settings/extensions/receipt-ocr',
|
||||
},
|
||||
async onInstall(ctx) {
|
||||
await saveSettings(ctx.userId, DEFAULT_SETTINGS)
|
||||
await ctx.settings.set('settings', DEFAULT_SETTINGS)
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user