Merge remote-tracking branch 'origin/main' into fixing-extensions-bug

This commit is contained in:
Emil
2026-02-23 09:46:32 +01:00
58 changed files with 5693 additions and 860 deletions
+21 -13
View File
@@ -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)
},
}
+20 -19
View File
@@ -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 }
+17 -4
View File
@@ -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)
})
})
})
+223
View File
@@ -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]
}
+74
View File
@@ -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
}
+36 -39
View File
@@ -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)
},
}
+38 -26
View File
@@ -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)
},
}