feat: unified document inbox, full BAS 2026, and document-transaction matching

- Expand BAS reference from ~180 to ~1,276 accounts (full BAS Kontoplan 2026)
  with K2 exclusion flags, per-class data files, and computed SRU codes
- Evolve invoice inbox into unified document inbox handling invoices, receipts,
  and government letters with AI-powered classification (Claude Haiku Vision)
- Add multi-pass document-to-transaction matching engine with greedy assignment
  for both supplier invoices (reference/amount/date/name) and receipts
  (weighted amount/merchant/date scoring)
- Add supplier invoice matching in transaction ingest pipeline
- Inject booking template suggestions into AI extraction prompts
- Surface matched documents in swipe categorization UI with one-tap booking
- Auto-activate missing BAS accounts during SIE import against full reference
- Add K2 filter toggle in Chart of Accounts manager
- Add receipt confirmation route with BFNAR representation fields
- Add database migrations for K2 support and document matching columns
- Remove obsolete extension migration scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-25 16:59:02 +01:00
co-authored by Claude Opus 4.6
parent 6956a757f3
commit 39e407644d
68 changed files with 19234 additions and 2412 deletions
+38 -2
View File
@@ -23,7 +23,7 @@ import DescribeTransactionDialog from '@/components/transactions/DescribeTransac
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment } from '@/types'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem } from '@/types'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
export default function TransactionsPage() {
@@ -114,9 +114,32 @@ export default function TransactionsPage() {
}
}
// Fetch matched inbox items for unbooked transactions
const unbookedTxIds = (txData || [])
.filter((t) => !t.journal_entry_id && t.is_business === null)
.map((t) => t.id)
let inboxItemMap: Record<string, InvoiceInboxItem> = {}
if (unbookedTxIds.length > 0) {
const { data: inboxItems } = await supabase
.from('invoice_inbox_items')
.select('*')
.in('matched_transaction_id', unbookedTxIds)
.in('status', ['ready', 'processing'])
if (inboxItems) {
inboxItemMap = inboxItems.reduce((acc, item) => {
if (item.matched_transaction_id) {
acc[item.matched_transaction_id] = item as InvoiceInboxItem
}
return acc
}, {} as Record<string, InvoiceInboxItem>)
}
}
const transactionsWithInvoices: TransactionWithInvoice[] = (txData || []).map((t) => ({
...t,
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
matched_inbox_item: inboxItemMap[t.id] || undefined,
}))
setTransactions(transactionsWithInvoices)
@@ -176,7 +199,7 @@ export default function TransactionsPage() {
}
}, [transactions.length])
const handleCategorize: CategorizeHandler = async (id, isBusiness, category, vatTreatment, accountOverride) => {
const handleCategorize: CategorizeHandler = async (id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId) => {
try {
setProcessingId(id)
const response = await fetch(`/api/transactions/${id}/categorize`, {
@@ -187,6 +210,8 @@ export default function TransactionsPage() {
category,
vat_treatment: vatTreatment,
account_override: accountOverride,
template_id: templateId,
inbox_item_id: inboxItemId,
}),
})
@@ -446,6 +471,7 @@ export default function TransactionsPage() {
async function openSwipeView() {
try {
// Match invoices to transactions
await fetch('/api/transactions/batch-match-invoices', { method: 'POST' })
.then((r) => r.json())
.then((data) => {
@@ -454,6 +480,16 @@ export default function TransactionsPage() {
} catch {
// Non-critical
}
try {
// Run document matching sweep for latest inbox matches
await fetch('/api/documents/match-sweep', { method: 'POST' })
.then((r) => r.json())
.then((data) => {
if (data.data?.matched > 0) fetchTransactions()
})
} catch {
// Non-critical
}
const uncatIds = uncategorizedTransactions.map((t) => t.id)
await fetchCategorySuggestions(uncatIds)
setShowSwipeView(true)
+31
View File
@@ -0,0 +1,31 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { runDocumentMatchingSweep } from '@/lib/documents/batch-match'
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Optional: pass specific inbox item IDs to match
let inboxItemIds: string[] | undefined
try {
const body = await request.json()
if (Array.isArray(body?.inboxItemIds)) {
inboxItemIds = body.inboxItemIds
}
} catch {
// No body or invalid JSON — sweep all unmatched items
}
try {
const result = await runDocumentMatchingSweep(supabase, user.id, inboxItemIds)
return NextResponse.json({ data: result })
} catch (error) {
console.error('[match-sweep] Failed:', error)
return NextResponse.json({ error: 'Match sweep failed' }, { status: 500 })
}
}
@@ -0,0 +1,148 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { eventBus } from '@/lib/events/bus'
ensureInitialized()
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
// Fetch inbox item
const { data: inboxItem, error: findError } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (findError || !inboxItem) {
return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
}
if (inboxItem.document_type !== 'receipt') {
return NextResponse.json({ error: 'Inbox item is not a receipt' }, { status: 400 })
}
if (!inboxItem.linked_receipt_id) {
return NextResponse.json({ error: 'No linked receipt found' }, { status: 400 })
}
const body = await request.json()
const {
line_items,
matched_transaction_id,
representation_persons,
representation_purpose,
representation_business_connection,
} = body
// Update receipt line items (business/private classification)
if (Array.isArray(line_items)) {
for (const item of line_items) {
if (!item.id) continue
await supabase
.from('receipt_line_items')
.update({
is_business: item.is_business,
...(item.category ? { category: item.category } : {}),
...(item.bas_account ? { bas_account: item.bas_account } : {}),
})
.eq('id', item.id)
.eq('receipt_id', inboxItem.linked_receipt_id)
}
}
// Calculate business/private totals
const { data: updatedLineItems } = await supabase
.from('receipt_line_items')
.select('*')
.eq('receipt_id', inboxItem.linked_receipt_id)
let businessTotal = 0
let privateTotal = 0
if (updatedLineItems) {
for (const li of updatedLineItems) {
if (li.is_business === true) {
businessTotal += li.line_total
} else if (li.is_business === false) {
privateTotal += li.line_total
}
}
}
businessTotal = Math.round(businessTotal * 100) / 100
privateTotal = Math.round(privateTotal * 100) / 100
// Update receipt with match and representation data
const receiptUpdate: Record<string, unknown> = {
status: 'confirmed',
}
if (matched_transaction_id) {
receiptUpdate.matched_transaction_id = matched_transaction_id
}
if (representation_persons != null) {
receiptUpdate.representation_persons = representation_persons
}
if (representation_purpose) {
receiptUpdate.representation_purpose = representation_purpose
}
if (representation_business_connection) {
receiptUpdate.representation_business_connection = representation_business_connection
}
await supabase
.from('receipts')
.update(receiptUpdate)
.eq('id', inboxItem.linked_receipt_id)
// Link transaction to receipt if provided
if (matched_transaction_id) {
await supabase
.from('transactions')
.update({ receipt_id: inboxItem.linked_receipt_id })
.eq('id', matched_transaction_id)
.eq('user_id', user.id)
}
// Update inbox item status
await supabase
.from('invoice_inbox_items')
.update({ status: 'confirmed' })
.eq('id', id)
// Emit event (non-blocking)
try {
const { data: receipt } = await supabase
.from('receipts')
.select('*')
.eq('id', inboxItem.linked_receipt_id)
.single()
if (receipt) {
await eventBus.emit({
type: 'receipt.confirmed',
payload: {
receipt,
businessTotal,
privateTotal,
userId: user.id,
},
})
}
} catch {
// Non-blocking
}
return NextResponse.json({ data: { confirmed: true, businessTotal, privateTotal } })
}
@@ -49,7 +49,7 @@ export async function POST(
if (!supplierId) {
// Create new supplier from extracted data
const supplierName = extraction.supplier.name
const supplierName = extraction.supplier?.name
if (!supplierName) {
return NextResponse.json({ error: 'Supplier name is required' }, { status: 400 })
}
@@ -60,13 +60,13 @@ export async function POST(
user_id: user.id,
name: supplierName,
supplier_type: 'swedish_business',
org_number: extraction.supplier.orgNumber || null,
vat_number: extraction.supplier.vatNumber || null,
bankgiro: extraction.supplier.bankgiro || null,
plusgiro: extraction.supplier.plusgiro || null,
org_number: extraction.supplier?.orgNumber || null,
vat_number: extraction.supplier?.vatNumber || null,
bankgiro: extraction.supplier?.bankgiro || null,
plusgiro: extraction.supplier?.plusgiro || null,
default_expense_account: '6200',
default_payment_terms: 30,
default_currency: extraction.invoice.currency || 'SEK',
default_currency: extraction.invoice?.currency || 'SEK',
})
.select()
.single()
@@ -99,7 +99,7 @@ export async function POST(
}
// Build line items from extraction
const items = extraction.lineItems.map((item, index) => {
const items = (extraction.lineItems || []).map((item, index) => {
const vatRate = item.vatRate != null ? item.vatRate / 100 : 0.25
const lineTotal = Math.round(item.lineTotal * 100) / 100
const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
@@ -118,7 +118,7 @@ export async function POST(
})
// If no line items, create a single item from totals
if (items.length === 0 && extraction.totals.total) {
if (items.length === 0 && extraction.totals?.total) {
const total = extraction.totals.total
const vatAmount = extraction.totals.vatAmount || 0
const subtotal = extraction.totals.subtotal || total - vatAmount
@@ -155,13 +155,13 @@ export async function POST(
user_id: user.id,
supplier_id: supplierId,
arrival_number: arrivalNum,
supplier_invoice_number: extraction.invoice.invoiceNumber || `INBOX-${Date.now()}`,
invoice_date: extraction.invoice.invoiceDate || new Date().toISOString().split('T')[0],
due_date: extraction.invoice.dueDate || new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0],
supplier_invoice_number: extraction.invoice?.invoiceNumber || `INBOX-${Date.now()}`,
invoice_date: extraction.invoice?.invoiceDate || new Date().toISOString().split('T')[0],
due_date: extraction.invoice?.dueDate || new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0],
status: 'registered',
currency: extraction.invoice.currency || 'SEK',
currency: extraction.invoice?.currency || 'SEK',
vat_treatment: vatTreatment,
payment_reference: extraction.invoice.paymentReference || null,
payment_reference: extraction.invoice?.paymentReference || null,
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
total: Math.round(total * 100) / 100,
@@ -5,6 +5,8 @@ import { eventBus } from '@/lib/events/bus'
import { analyzeInvoice } from '@/extensions/general/invoice-inbox/lib/invoice-analyzer'
import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher'
import { getSettings } from '@/extensions/general/invoice-inbox'
import { matchDocumentToTransactions } from '@/lib/documents/document-matcher'
import type { InvoiceInboxItem } from '@/types'
ensureInitialized()
@@ -87,16 +89,27 @@ export async function POST(
}
}
// Update inbox item
// Update inbox item with extraction + template suggestion
const updateData: Record<string, unknown> = {
status: 'ready',
extracted_data: extraction as unknown as Record<string, unknown>,
confidence: extraction.confidence,
matched_supplier_id: matchedSupplierId,
error_message: null,
// Reset previous match on re-process
matched_transaction_id: null,
match_confidence: null,
match_method: null,
}
if (extraction.suggestedTemplateId) {
updateData.suggested_template_id = extraction.suggestedTemplateId
updateData.suggested_template_confidence = extraction.confidence
}
const { data: updatedItem, error: updateError } = 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,
error_message: null,
})
.update(updateData)
.eq('id', id)
.select()
.single()
@@ -114,6 +127,28 @@ export async function POST(
userId: user.id,
},
})
// Document-to-transaction matching (non-blocking)
try {
const matchResult = await matchDocumentToTransactions(
supabase,
user.id,
updatedItem as InvoiceInboxItem
)
if (matchResult) {
await supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: matchResult.transactionId,
match_confidence: matchResult.confidence,
match_method: matchResult.method,
})
.eq('id', id)
}
} catch (matchError) {
console.error('[invoice-inbox] Transaction matching failed:', matchError)
}
}
return NextResponse.json({ data: updatedItem })
+151 -71
View File
@@ -5,6 +5,8 @@ import { eventBus } from '@/lib/events/bus'
import { analyzeInvoice } from '@/extensions/general/invoice-inbox/lib/invoice-analyzer'
import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher'
import { getSettings } from '@/extensions/general/invoice-inbox'
import { matchDocumentToTransactions } from '@/lib/documents/document-matcher'
import type { InvoiceInboxItem, InvoiceExtractionResult } from '@/types'
import crypto from 'crypto'
ensureInitialized()
@@ -19,16 +21,21 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const status = searchParams.get('status')
const documentType = searchParams.get('document_type')
let query = supabase
.from('invoice_inbox_items')
.select('*, document:document_attachments(id, file_name, mime_type, storage_path), supplier:suppliers(id, name)')
.select('*, document:document_attachments(id, file_name, mime_type, storage_path), supplier:suppliers(id, name), receipt:receipts(id, merchant_name, total_amount, receipt_date, status, matched_transaction_id)')
.eq('user_id', user.id)
if (status && status !== 'all') {
query = query.eq('status', status)
}
if (documentType && documentType !== 'all') {
query = query.eq('document_type', documentType)
}
const { data, error } = await query.order('created_at', { ascending: false })
if (error) {
@@ -47,85 +54,113 @@ export async function POST(request: Request) {
}
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
// Support batch upload: multiple `files` entries, fallback to single `file`
const files: File[] = []
const multiFiles = formData.getAll('files')
if (multiFiles.length > 0) {
for (const f of multiFiles) {
if (f instanceof File) files.push(f)
}
} else {
const single = formData.get('file') as File | null
if (single) files.push(single)
}
if (files.length === 0) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
const supportedTypes = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']
if (!supportedTypes.includes(file.type)) {
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 })
const items: Array<Record<string, unknown>> = []
const errors: string[] = []
for (const file of files) {
if (!supportedTypes.includes(file.type)) {
errors.push(`${file.name}: unsupported file type`)
continue
}
try {
const result = await uploadAndCreateInboxItem(supabase, user.id, file)
items.push(result.inboxItem)
// Process asynchronously
processInboxItem(result.inboxItem.id as string, user.id, result.base64, file.type).catch((err) =>
console.error('[invoice-inbox] Background processing failed:', err)
)
} catch (error) {
const message = error instanceof Error ? error.message : 'Upload failed'
errors.push(`${file.name}: ${message}`)
}
}
try {
// Read file
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const base64 = buffer.toString('base64')
const hash = crypto.createHash('sha256').update(buffer).digest('hex')
// Return array for batch, single item for backward compat
if (files.length === 1 && items.length === 1) {
return NextResponse.json({ data: items[0] })
}
// Upload to storage
const storagePath = `documents/${user.id}/inbox/${Date.now()}-${file.name}`
const { error: uploadError } = await supabase.storage
.from('documents')
.upload(storagePath, buffer, { contentType: file.type })
return NextResponse.json({ data: items, errors: errors.length > 0 ? errors : undefined })
}
if (uploadError) {
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 })
}
async function uploadAndCreateInboxItem(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
file: File
): Promise<{ inboxItem: Record<string, unknown>; base64: string }> {
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const base64 = buffer.toString('base64')
const hash = crypto.createHash('sha256').update(buffer).digest('hex')
// Create document attachment record
const { data: document, error: docError } = await supabase
.from('document_attachments')
.insert({
user_id: user.id,
storage_path: storagePath,
file_name: file.name,
file_size_bytes: buffer.length,
mime_type: file.type,
sha256_hash: hash,
upload_source: 'file_upload',
})
.select()
.single()
const storagePath = `documents/${userId}/inbox/${Date.now()}-${file.name}`
const { error: uploadError } = await supabase.storage
.from('documents')
.upload(storagePath, buffer, { contentType: file.type })
if (docError || !document) {
return NextResponse.json({ error: 'Failed to create document record' }, { status: 500 })
}
if (uploadError) {
throw new Error('Failed to upload file')
}
// Create inbox item
const { data: inboxItem, error: itemError } = await supabase
.from('invoice_inbox_items')
.insert({
user_id: user.id,
status: 'processing',
source: 'upload',
document_id: document.id,
})
.select()
.single()
if (itemError || !inboxItem) {
return NextResponse.json({ error: 'Failed to create inbox item' }, { status: 500 })
}
// Emit received event
await eventBus.emit({
type: 'supplier_invoice.received',
payload: { inboxItem, userId: user.id },
const { data: document, error: docError } = await supabase
.from('document_attachments')
.insert({
user_id: userId,
storage_path: storagePath,
file_name: file.name,
file_size_bytes: buffer.length,
mime_type: file.type,
sha256_hash: hash,
upload_source: 'file_upload',
})
.select()
.single()
// Process asynchronously - analyze and match
processInboxItem(inboxItem.id, user.id, base64, file.type).catch((err) =>
console.error('[invoice-inbox] Background processing failed:', err)
)
return NextResponse.json({ data: inboxItem })
} catch (error) {
console.error('[invoice-inbox] Upload failed:', error)
return NextResponse.json({ error: 'Upload failed' }, { status: 500 })
if (docError || !document) {
throw new Error('Failed to create document record')
}
const { data: inboxItem, error: itemError } = await supabase
.from('invoice_inbox_items')
.insert({
user_id: userId,
status: 'processing',
source: 'upload',
document_id: document.id,
})
.select()
.single()
if (itemError || !inboxItem) {
throw new Error('Failed to create inbox item')
}
await eventBus.emit({
type: 'supplier_invoice.received',
payload: { inboxItem, userId },
})
return { inboxItem, base64 }
}
async function processInboxItem(
@@ -137,8 +172,19 @@ async function processInboxItem(
const supabase = await createClient()
try {
console.log(`[invoice-inbox] Processing item=${itemId}: starting AI extraction (${mimeType})`)
const extraction = await analyzeInvoice(base64, mimeType)
console.log(`[invoice-inbox] item=${itemId} extraction complete:`, {
confidence: extraction.confidence,
suggestedTemplateId: extraction.suggestedTemplateId || null,
supplier: extraction.supplier?.name || null,
total: extraction.totals?.total || null,
invoiceDate: extraction.invoice?.invoiceDate || null,
dueDate: extraction.invoice?.dueDate || null,
paymentRef: extraction.invoice?.paymentReference || null,
})
// Supplier matching
const settings = await getSettings(userId)
let matchedSupplierId: string | null = null
@@ -153,20 +199,31 @@ async function processInboxItem(
const match = matchSupplier(extraction, suppliers)
if (match && match.confidence >= settings.supplierMatchThreshold) {
matchedSupplierId = match.supplierId
console.log(`[invoice-inbox] item=${itemId} supplier matched: id=${match.supplierId} confidence=${match.confidence}`)
}
}
}
// Store extraction result with template suggestion
const updateData: Record<string, unknown> = {
status: 'ready',
extracted_data: extraction as unknown as Record<string, unknown>,
confidence: extraction.confidence,
matched_supplier_id: matchedSupplierId,
}
if (extraction.suggestedTemplateId) {
updateData.suggested_template_id = extraction.suggestedTemplateId
updateData.suggested_template_confidence = extraction.confidence
console.log(`[invoice-inbox] item=${itemId} template suggestion: ${extraction.suggestedTemplateId} (confidence=${extraction.confidence})`)
}
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,
})
.update(updateData)
.eq('id', itemId)
// Fetch the updated item for event emission and matching
const { data: updatedItem } = await supabase
.from('invoice_inbox_items')
.select('*')
@@ -182,6 +239,29 @@ async function processInboxItem(
userId,
},
})
// Document-to-transaction matching
try {
const matchResult = await matchDocumentToTransactions(
supabase,
userId,
updatedItem as InvoiceInboxItem
)
if (matchResult) {
await supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: matchResult.transactionId,
match_confidence: matchResult.confidence,
match_method: matchResult.method,
})
.eq('id', itemId)
}
} catch (matchError) {
// Non-blocking: log but don't fail the item
console.error('[invoice-inbox] Transaction matching failed:', matchError)
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
+128 -29
View File
@@ -4,6 +4,8 @@ import { Webhook } from 'svix'
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler'
import { analyzeInvoice } from '@/extensions/general/invoice-inbox/lib/invoice-analyzer'
import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher'
import { classifyDocument } from '@/lib/documents/classifier'
import { processReceiptFromDocument } from '@/extensions/general/receipt-ocr/lib/receipt-pipeline'
import crypto from 'crypto'
function createServiceClient() {
@@ -19,11 +21,31 @@ function createServiceClient() {
)
}
/**
* Build raw email payload for BFL 7 kap. 2§ archiving.
* Includes full email headers and body — excludes binary attachment content.
*/
function buildRawEmailPayload(body: Record<string, unknown>, payload: { from: string; to: string; subject: string; created_at: string }): Record<string, unknown> {
return {
from: payload.from,
to: payload.to,
subject: payload.subject,
created_at: payload.created_at,
text: body.text ?? null,
html: body.html ?? null,
headers: body.headers ?? null,
message_id: body.message_id ?? null,
in_reply_to: body.in_reply_to ?? null,
references: body.references ?? null,
archived_at: new Date().toISOString(),
}
}
export async function POST(request: Request) {
// Verify webhook signature
const webhookSecret = process.env.RESEND_WEBHOOK_SECRET
if (!webhookSecret) {
console.error('[invoice-inbox] RESEND_WEBHOOK_SECRET not configured')
console.error('[document-inbox] RESEND_WEBHOOK_SECRET not configured')
return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 })
}
@@ -61,15 +83,17 @@ export async function POST(request: Request) {
const userId = await resolveUserFromEmail(payload.to, supabase)
if (!userId) {
console.warn(`[invoice-inbox] No user found for email: ${payload.to}`)
console.warn(`[document-inbox] No user found for email: ${payload.to}`)
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
// Build raw email payload for BFL 7:2 archiving (no binary attachment content)
const rawEmailPayload = buildRawEmailPayload(body, payload)
// Extract file attachments
const attachments = extractAttachments(payload)
if (attachments.length === 0) {
// Create inbox item with error status (no attachments)
await supabase
.from('invoice_inbox_items')
.insert({
@@ -80,6 +104,7 @@ export async function POST(request: Request) {
email_subject: payload.subject,
email_received_at: payload.created_at,
error_message: 'No supported attachments found',
raw_email_payload: rawEmailPayload,
})
return NextResponse.json({ data: { processed: 0, message: 'No attachments' } })
@@ -99,7 +124,7 @@ export async function POST(request: Request) {
.upload(storagePath, buffer, { contentType: attachment.content_type })
if (uploadError) {
console.error('[invoice-inbox] Upload failed:', uploadError)
console.error('[document-inbox] Upload failed:', uploadError)
continue
}
@@ -120,7 +145,19 @@ export async function POST(request: Request) {
if (docError || !document) continue
// Create inbox item
// Classify document type
let documentType: 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown' = 'supplier_invoice'
let isReverseCharge = false
try {
const classification = await classifyDocument(attachment.content, attachment.content_type)
documentType = classification.type
isReverseCharge = classification.isReverseCharge ?? false
console.log(`[document-inbox] Classified as ${documentType} (confidence: ${classification.confidence})`)
} catch (classifyErr) {
console.error('[document-inbox] Classification failed, defaulting to supplier_invoice:', classifyErr)
}
// Create inbox item with document type and raw email payload
const { data: inboxItem, error: itemError } = await supabase
.from('invoice_inbox_items')
.insert({
@@ -131,41 +168,103 @@ export async function POST(request: Request) {
email_subject: payload.subject,
email_received_at: payload.created_at,
document_id: document.id,
document_type: documentType,
raw_email_payload: rawEmailPayload,
})
.select()
.single()
if (itemError || !inboxItem) continue
// Process: analyze invoice
// Route based on document type
try {
const extraction = await analyzeInvoice(attachment.content, attachment.content_type)
switch (documentType) {
case 'supplier_invoice': {
// Existing flow: analyze invoice + supplier match
const extraction = await analyzeInvoice(attachment.content, attachment.content_type)
// Supplier matching
let matchedSupplierId: string | null = null
const { data: suppliers } = await supabase
.from('suppliers')
.select('*')
.eq('user_id', userId)
// Store reverse charge flag from classifier in extracted data
const extractedData = {
...(extraction as unknown as Record<string, unknown>),
isReverseCharge,
}
if (suppliers && suppliers.length > 0) {
const match = matchSupplier(extraction, suppliers)
if (match && match.confidence >= 0.7) {
matchedSupplierId = match.supplierId
// Supplier matching
let matchedSupplierId: string | null = null
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 >= 0.7) {
matchedSupplierId = match.supplierId
}
}
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
extracted_data: extractedData,
confidence: extraction.confidence,
matched_supplier_id: matchedSupplierId,
})
.eq('id', inboxItem.id)
break
}
case 'receipt': {
// Receipt pipeline: extract + categorize + match transactions
const { data: urlData } = supabase.storage.from('documents').getPublicUrl(storagePath)
const result = await processReceiptFromDocument(supabase, userId, attachment.content, attachment.content_type, {
documentId: document.id,
source: 'email',
emailFrom: payload.from,
storageUrl: urlData.publicUrl,
})
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
linked_receipt_id: result.receipt.id,
confidence: result.receipt.extraction_confidence,
})
.eq('id', inboxItem.id)
break
}
case 'government_letter': {
// Store with status ready for manual review
await supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
extracted_data: {
sender: payload.from,
subject: payload.subject,
body: typeof body.text === 'string' ? body.text : null,
},
})
.eq('id', inboxItem.id)
break
}
case 'unknown':
default: {
// Store with status ready for manual handling
await supabase
.from('invoice_inbox_items')
.update({ status: 'ready' })
.eq('id', inboxItem.id)
break
}
}
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)
} catch (err) {
const message = err instanceof Error ? err.message : 'Analysis failed'
const message = err instanceof Error ? err.message : 'Processing failed'
await supabase
.from('invoice_inbox_items')
.update({ status: 'error', error_message: message })
@@ -174,7 +273,7 @@ export async function POST(request: Request) {
processed.push(inboxItem.id)
} catch (err) {
console.error('[invoice-inbox] Processing attachment failed:', err)
console.error('[document-inbox] Processing attachment failed:', err)
}
}
+92 -19
View File
@@ -4,6 +4,8 @@ import { NextResponse } from 'next/server'
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
import { suggestMappings } from '@/lib/import/account-mapper'
import { executeSIEImport } from '@/lib/import/sie-import'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import type { AccountMapping, SIEAccountMappingRecord } from '@/lib/import/types'
/**
@@ -54,24 +56,7 @@ export async function POST(request: Request) {
if (mappingsJson) {
mappings = JSON.parse(mappingsJson)
} else {
// Fetch user's full chart of accounts (paginated to avoid 1000-row limit)
const basAccounts = await fetchAllRows(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.eq('is_active', true)
.order('account_number')
.range(from, to)
)
if (basAccounts.length === 0) {
return NextResponse.json({
error: 'No chart of accounts found. Please complete onboarding first.',
}, { status: 400 })
}
// Load stored mappings
// Match against full BAS reference (not just user's active chart)
const { data: storedMappings } = await supabase
.from('sie_account_mappings')
.select('*')
@@ -79,7 +64,7 @@ export async function POST(request: Request) {
mappings = suggestMappings(
parsed.accounts,
basAccounts,
BAS_REFERENCE,
(storedMappings as SIEAccountMappingRecord[]) || undefined
)
}
@@ -97,6 +82,94 @@ export async function POST(request: Request) {
}, { status: 400 })
}
// Auto-activate any mapped BAS accounts not yet in the user's chart
const mappedAccountNumbers = [
...new Set(mappings.filter((m) => m.targetAccount).map((m) => m.targetAccount)),
]
const existingAccounts = await fetchAllRows(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number')
.eq('user_id', user.id)
.in('account_number', mappedAccountNumbers)
.range(from, to)
)
// Build a lookup from SIE mappings for account names (used for bas_range accounts)
const mappingNameLookup = new Map<string, string>()
for (const m of mappings) {
if (m.targetAccount) {
mappingNameLookup.set(m.targetAccount, m.targetName || m.sourceName)
}
}
const existingNumbers = new Set(existingAccounts.map((a) => a.account_number))
const accountsToActivate = mappedAccountNumbers
.filter((num) => !existingNumbers.has(num))
.map((num) => {
const ref = getBASReference(num)
if (ref) {
// Account exists in BAS reference — use full metadata
return {
user_id: user.id,
account_number: ref.account_number,
account_name: ref.account_name,
account_class: ref.account_class,
account_group: ref.account_group,
account_type: ref.account_type,
normal_balance: ref.normal_balance,
plan_type: 'full_bas' as const,
is_active: true,
is_system_account: false,
description: ref.description,
sru_code: ref.sru_code,
sort_order: parseInt(ref.account_number),
}
}
// Account not in BAS reference (sub-account like 1241 Personbilar).
// Derive metadata from the account number.
const accountClass = parseInt(num.charAt(0), 10)
const accountGroup = num.substring(0, 2)
const accountName = mappingNameLookup.get(num) || `Konto ${num}`
const accountType =
accountClass === 1 ? 'asset'
: accountClass === 2 ? 'liability'
: accountClass === 3 ? 'revenue'
: 'expense'
const normalBalance =
accountClass <= 1 || accountClass >= 4 ? 'debit' : 'credit'
return {
user_id: user.id,
account_number: num,
account_name: accountName,
account_class: accountClass,
account_group: accountGroup,
account_type: accountType,
normal_balance: normalBalance,
plan_type: 'full_bas' as const,
is_active: true,
is_system_account: false,
description: accountName,
sru_code: null,
sort_order: parseInt(num),
}
})
if (accountsToActivate.length > 0) {
const { error: activateError } = await supabase
.from('chart_of_accounts')
.insert(accountsToActivate)
if (activateError) {
return NextResponse.json({
error: `Failed to activate accounts: ${activateError.message}`,
}, { status: 500 })
}
}
// Execute the import
const result = await executeSIEImport(
user.id,
+6 -21
View File
@@ -1,5 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
import {
parseSIEFile,
@@ -10,7 +9,8 @@ import {
} from '@/lib/import/sie-parser'
import { suggestMappings, getMappingStats } from '@/lib/import/account-mapper'
import { generateImportPreview, checkDuplicateImport } from '@/lib/import/sie-import'
import type { SIEAccountMappingRecord, SIEAccount } from '@/lib/import/types'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import type { SIEAccountMappingRecord } from '@/lib/import/types'
/**
* POST /api/import/sie/parse
@@ -78,33 +78,18 @@ export async function POST(request: Request) {
}, { status: 400 })
}
// Fetch user's full chart of accounts (paginated to avoid 1000-row limit)
const basAccounts = await fetchAllRows(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.eq('is_active', true)
.order('account_number')
.range(from, to)
)
if (basAccounts.length === 0) {
return NextResponse.json({
error: 'No chart of accounts found. Please complete onboarding first.',
}, { status: 400 })
}
// Fetch stored mappings from database
const { data: storedMappings } = await supabase
.from('sie_account_mappings')
.select('*')
.eq('user_id', user.id)
// Suggest account mappings
// Match against the full BAS reference (1,276 accounts) instead of only
// the user's active chart (~40 accounts). Accounts that match will be
// auto-activated during the execute step.
const mappings = suggestMappings(
parsed.accounts,
basAccounts,
BAS_REFERENCE,
(storedMappings as SIEAccountMappingRecord[]) || undefined
)
@@ -159,11 +159,17 @@ export async function POST(
const template = getTemplateById(body.template_id)
if (template) {
finalCategory = is_business ? template.fallback_category : 'private'
console.log(`[categorize] tx=${id} using template="${body.template_id}" (${template.name_sv}) → category=${finalCategory}, debit=${template.debit_account}, credit=${template.credit_account}, vat=${template.vat_treatment}`)
} else {
return NextResponse.json({ error: 'Invalid template_id' }, { status: 400 })
}
} else {
finalCategory = is_business ? (category || 'uncategorized') : 'private'
console.log(`[categorize] tx=${id} using category="${finalCategory}" vat=${body.vat_treatment || 'default'} account_override=${body.account_override || 'none'}`)
}
if (body.inbox_item_id) {
console.log(`[categorize] tx=${id} will confirm inbox item=${body.inbox_item_id} and link document`)
}
// Build mapping result from template or category
@@ -185,6 +191,12 @@ export async function POST(
)
}
console.log(`[categorize] tx=${id} mapping result:`, {
debit: mappingResult.debit_account,
credit: mappingResult.credit_account,
vatLines: mappingResult.vat_lines.map((v) => `${v.account_number} debit=${v.debit_amount} credit=${v.credit_amount}`),
})
// Apply account override if provided (only for business transactions)
if (is_business && body.account_override) {
// Validate the account exists in the user's chart of accounts
@@ -287,6 +299,36 @@ export async function POST(
}
}
// Confirm matched inbox item and link its document to the journal entry
if (body.inbox_item_id) {
try {
await supabase
.from('invoice_inbox_items')
.update({ status: 'confirmed' })
.eq('id', body.inbox_item_id)
.eq('user_id', user.id)
// Link inbox item's document to the journal entry
if (journalEntryId) {
const { data: inboxItem } = await supabase
.from('invoice_inbox_items')
.select('document_id')
.eq('id', body.inbox_item_id)
.single()
if (inboxItem?.document_id) {
await supabase
.from('document_attachments')
.update({ journal_entry_id: journalEntryId })
.eq('id', inboxItem.document_id)
.eq('user_id', user.id)
}
}
} catch (inboxErr) {
console.error('[categorize] Failed to update inbox item:', inboxErr)
}
}
// Update the transaction
const { error: updateError } = await supabase
.from('transactions')
@@ -137,6 +137,51 @@ export async function POST(request: Request) {
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType)
}
// Inject document template suggestions from matched inbox items
try {
const { data: matchedInboxItems } = await supabase
.from('invoice_inbox_items')
.select('matched_transaction_id, suggested_template_id, suggested_template_confidence')
.eq('user_id', user.id)
.in('matched_transaction_id', ids)
.not('suggested_template_id', 'is', null)
if (matchedInboxItems && matchedInboxItems.length > 0) {
console.log(`[suggest-categories] Found ${matchedInboxItems.length} matched inbox items with template suggestions`)
const { getTemplateById } = await import('@/lib/bookkeeping/booking-templates')
for (const item of matchedInboxItems) {
const txId = item.matched_transaction_id as string
const templateId = item.suggested_template_id as string
const template = getTemplateById(templateId)
if (!template) {
console.log(`[suggest-categories] Template "${templateId}" not found, skipping`)
continue
}
console.log(`[suggest-categories] Injecting document template: tx=${txId} → ${templateId} (${template.name_sv}, debit=${template.debit_account}, confidence=${item.suggested_template_confidence})`)
// Add to template_suggestions at the top with boosted confidence
const existing = template_suggestions[txId] || []
const docTemplate: SuggestedTemplate = {
template_id: templateId,
name_sv: template.name_sv,
name_en: template.name_en,
group: template.group,
debit_account: template.debit_account,
credit_account: template.credit_account,
confidence: Math.min((item.suggested_template_confidence as number) || 0.8, 1),
description_sv: template.description_sv,
risk_level: template.risk_level,
requires_review: template.requires_review,
}
template_suggestions[txId] = [docTemplate, ...existing.filter((t) => t.template_id !== templateId)]
}
}
} catch {
// Non-blocking
}
// Trigger on-demand AI categorization for transactions with weak suggestions
if (needsAiIds.length > 0) {
console.log(
@@ -56,6 +56,7 @@ const TYPE_LABELS: Record<string, string> = {
equity: 'EK',
revenue: 'Intakt',
expense: 'Kostnad',
untaxed_reserves: 'Ob. reserver',
}
// ---------------------------------------------------------------------------
@@ -69,6 +70,7 @@ export default function ChartOfAccountsManager() {
const [view, setView] = useState<'my-accounts' | 'bas-catalog'>('my-accounts')
const [searchQuery, setSearchQuery] = useState('')
const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set())
const [hideK2Excluded, setHideK2Excluded] = useState<boolean | null>(null)
// Data state
const [accounts, setAccounts] = useState<BASAccount[]>([])
@@ -104,10 +106,25 @@ export default function ChartOfAccountsManager() {
async function load() {
setLoading(true)
await Promise.all([fetchAccounts(), fetchReference()])
// Set K2 filter default based on company settings (plan_type)
if (hideK2Excluded === null) {
try {
const res = await fetch('/api/settings')
if (res.ok) {
const { data } = await res.json()
// Default to hiding K2-excluded accounts if the company uses K2 (plan_type === 'k1')
setHideK2Excluded(data?.plan_type === 'k1')
} else {
setHideK2Excluded(false)
}
} catch {
setHideK2Excluded(false)
}
}
setLoading(false)
}
load()
}, [fetchAccounts, fetchReference])
}, [fetchAccounts, fetchReference, hideK2Excluded])
const refreshAll = useCallback(async () => {
await Promise.all([fetchAccounts(), fetchReference()])
@@ -221,12 +238,18 @@ export default function ChartOfAccountsManager() {
}, [filteredAccounts])
const filteredReference = useMemo(() => {
if (!searchQuery) return referenceAccounts
const q = searchQuery.toLowerCase()
return referenceAccounts.filter(
(a) => a.account_number.includes(q) || a.account_name.toLowerCase().includes(q)
)
}, [referenceAccounts, searchQuery])
let filtered = referenceAccounts
if (hideK2Excluded) {
filtered = filtered.filter((a) => !a.k2_excluded)
}
if (searchQuery) {
const q = searchQuery.toLowerCase()
filtered = filtered.filter(
(a) => a.account_number.includes(q) || a.account_name.toLowerCase().includes(q)
)
}
return filtered
}, [referenceAccounts, searchQuery, hideK2Excluded])
const groupedReference = useMemo(() => {
const grouped: Record<number, ReferenceAccount[]> = {}
@@ -284,6 +307,17 @@ export default function ChartOfAccountsManager() {
Eget konto
</Button>
)}
{view === 'bas-catalog' && (
<label className="flex items-center gap-2 text-sm">
<Switch
checked={hideK2Excluded ?? false}
onCheckedChange={setHideK2Excluded}
className="scale-75"
/>
<span className="text-muted-foreground">Dolj K2-undantagna</span>
</label>
)}
</div>
{/* Search */}
@@ -0,0 +1,371 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import type { InvoiceInboxItem, Supplier, DocumentClassificationType } from '@/types'
import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types'
import { PageHeader } from '@/components/ui/page-header'
import { Card, CardContent } from '@/components/ui/card'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Settings, Inbox, CheckCircle2, AlertTriangle, Receipt, FileText, RefreshCw } from 'lucide-react'
import DocumentInboxCard from '@/components/extensions/general/document-inbox/DocumentInboxCard'
import ReceiptInboxDetail from '@/components/extensions/general/document-inbox/ReceiptInboxDetail'
import InboxUploadZone from '@/components/extensions/general/invoice-inbox/InboxUploadZone'
import InboxDetailDialog from '@/components/extensions/general/invoice-inbox/InboxDetailDialog'
import InboxSettingsDialog from '@/components/extensions/general/invoice-inbox/InboxSettingsDialog'
type TabValue = 'all' | DocumentClassificationType
const TABS: { value: TabValue; label: string }[] = [
{ value: 'all', label: 'Alla' },
{ value: 'supplier_invoice', label: 'Fakturor' },
{ value: 'receipt', label: 'Kvitton' },
{ value: 'government_letter', label: 'Myndighetspost' },
{ value: 'unknown', label: 'Övrigt' },
]
const DEFAULT_SETTINGS: InvoiceInboxSettings = {
autoProcessEnabled: true,
autoMatchSupplierEnabled: true,
supplierMatchThreshold: 0.7,
inboxEmail: null,
}
export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentProps) {
const [items, setItems] = useState<InvoiceInboxItem[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<TabValue>('all')
const [selectedItem, setSelectedItem] = useState<InvoiceInboxItem | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [settings, setSettings] = useState<InvoiceInboxSettings>(DEFAULT_SETTINGS)
const [settingsOpen, setSettingsOpen] = useState(false)
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const fetchItems = useCallback(async () => {
try {
const res = await fetch('/api/extensions/invoice-inbox/inbox')
if (res.ok) {
const { data } = await res.json()
setItems(data ?? [])
}
} catch {
// Silently fail
} finally {
setLoading(false)
}
}, [])
const fetchSettings = useCallback(async () => {
try {
const res = await fetch('/api/extensions/invoice-inbox/settings')
if (res.ok) {
const { data } = await res.json()
if (data) setSettings(data)
}
} catch {
// Use defaults
}
}, [])
const fetchSuppliers = useCallback(async () => {
try {
const res = await fetch('/api/suppliers')
if (res.ok) {
const { data } = await res.json()
setSuppliers(data ?? [])
}
} catch {
// ok
}
}, [])
useEffect(() => {
fetchItems()
fetchSettings()
fetchSuppliers()
}, [fetchItems, fetchSettings, fetchSuppliers])
function handleUploadComplete(result: InvoiceInboxItem | InvoiceInboxItem[]) {
const newItems = Array.isArray(result) ? result : [result]
setItems((prev) => [...newItems, ...prev])
for (const item of newItems) {
pollItem(item.id)
}
}
const [isMatching, setIsMatching] = useState(false)
async function handleMatchSweep() {
setIsMatching(true)
try {
const res = await fetch('/api/documents/match-sweep', { method: 'POST' })
if (res.ok) {
await fetchItems()
}
} catch {
// Non-critical
} finally {
setIsMatching(false)
}
}
async function pollItem(itemId: string) {
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 3000))
try {
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`)
if (!res.ok) continue
const { data } = await res.json()
if (data && data.status !== 'processing') {
setItems((prev) =>
prev.map((it) => (it.id === itemId ? data : it))
)
setSelectedItem((current) =>
current?.id === itemId ? data : current
)
return
}
} catch {
// continue
}
}
}
function handleItemClick(item: InvoiceInboxItem) {
setSelectedItem(item)
}
async function handleConfirm(itemId: string, supplierId?: string) {
const body: Record<string, string> = {}
if (supplierId) body.supplier_id = supplierId
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
setItems((prev) =>
prev.map((it) => (it.id === itemId ? { ...it, status: 'confirmed' as const } : it))
)
setSelectedItem(null)
fetchSuppliers()
}
}
async function handleReject(itemId: string) {
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`, {
method: 'DELETE',
})
if (res.ok) {
setItems((prev) =>
prev.map((it) => (it.id === itemId ? { ...it, status: 'rejected' as const } : it))
)
setSelectedItem(null)
}
}
async function handleReprocess(itemId: string) {
setItems((prev) =>
prev.map((it) => (it.id === itemId ? { ...it, status: 'processing' as const } : it))
)
setSelectedItem(null)
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/process`, {
method: 'POST',
})
if (res.ok) {
const { data } = await res.json()
if (data) {
setItems((prev) => prev.map((it) => (it.id === itemId ? data : it)))
}
} else {
fetchItems()
}
}
function handleReceiptConfirm() {
fetchItems()
setSelectedItem(null)
}
async function handleSaveSettings(updated: InvoiceInboxSettings) {
const res = await fetch('/api/extensions/invoice-inbox/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updated),
})
if (res.ok) {
const { data } = await res.json()
if (data) setSettings(data)
}
}
const filteredItems =
activeTab === 'all'
? items
: items.filter((it) => (it.document_type ?? 'supplier_invoice') === activeTab)
const totalPending = items.filter((it) => it.status === 'ready' || it.status === 'pending').length
const receiptCount = items.filter((it) => it.document_type === 'receipt' && it.status === 'ready').length
const invoiceCount = items.filter((it) => (it.document_type ?? 'supplier_invoice') === 'supplier_invoice' && it.status === 'ready').length
// Determine which detail dialog to show
const isReceiptSelected = selectedItem?.document_type === 'receipt'
return (
<div className="space-y-6">
<PageHeader
title="Dokumentinkorg"
description="Alla inkommande dokument — fakturor, kvitton och myndighetspost"
action={
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleMatchSweep}
disabled={isMatching}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isMatching ? 'animate-spin' : ''}`} />
Matcha alla
</Button>
<Button
variant="outline"
size="icon"
onClick={() => setSettingsOpen(true)}
>
<Settings className="h-4 w-4" />
</Button>
</div>
}
/>
{/* KPI cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Inbox className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-2xl font-medium">{totalPending}</p>
<p className="text-xs text-muted-foreground">Att granska</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning/15">
<FileText className="h-5 w-5 text-warning-foreground" />
</div>
<div>
<p className="text-2xl font-medium">{invoiceCount}</p>
<p className="text-xs text-muted-foreground">Fakturor att granska</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-secondary/50">
<Receipt className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="text-2xl font-medium">{receiptCount}</p>
<p className="text-xs text-muted-foreground">Kvitton att granska</p>
</div>
</CardContent>
</Card>
</div>
{/* Upload zone */}
<InboxUploadZone
onUploadComplete={handleUploadComplete}
isUploading={isUploading}
setIsUploading={setIsUploading}
/>
{/* Tabs by document type */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
<TabsList>
{TABS.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{TABS.map((tab) => (
<TabsContent key={tab.value} value={tab.value}>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full rounded-xl" />
))}
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Inbox className="h-10 w-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
{activeTab === 'all'
? 'Inga dokument ännu. Ladda upp ett dokument ovan eller skicka via e-post.'
: 'Inga dokument av denna typ.'}
</p>
</div>
) : (
<div className="space-y-2">
{filteredItems.map((item) => (
<DocumentInboxCard
key={item.id}
item={item}
onClick={() => handleItemClick(item)}
/>
))}
</div>
)}
</TabsContent>
))}
</Tabs>
{/* Receipt detail dialog */}
{isReceiptSelected && (
<ReceiptInboxDetail
item={selectedItem}
open={selectedItem != null && isReceiptSelected}
onOpenChange={(open) => {
if (!open) setSelectedItem(null)
}}
onConfirm={handleReceiptConfirm}
/>
)}
{/* Invoice/other detail dialog (existing) */}
{!isReceiptSelected && (
<InboxDetailDialog
item={selectedItem}
open={selectedItem != null && !isReceiptSelected}
onOpenChange={(open) => {
if (!open) setSelectedItem(null)
}}
onConfirm={handleConfirm}
onReject={handleReject}
onReprocess={handleReprocess}
suppliers={suppliers}
/>
)}
{/* Settings dialog */}
<InboxSettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
settings={settings}
onSave={handleSaveSettings}
/>
</div>
)
}
@@ -87,10 +87,12 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
fetchSuppliers()
}, [fetchItems, fetchSettings, fetchSuppliers])
function handleUploadComplete(newItem: InvoiceInboxItem) {
setItems((prev) => [newItem, ...prev])
// Poll for processing completion
pollItem(newItem.id)
function handleUploadComplete(result: InvoiceInboxItem | InvoiceInboxItem[]) {
const newItems = Array.isArray(result) ? result : [result]
setItems((prev) => [...newItems, ...prev])
for (const item of newItems) {
pollItem(item.id)
}
}
async function pollItem(itemId: string) {
@@ -0,0 +1,147 @@
'use client'
import type { InvoiceInboxItem, DocumentClassificationType } from '@/types'
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
getStatusLabel,
getStatusVariant,
getConfidenceLabel,
formatExtractionSummary,
getDocumentTypeLabel,
getDocumentTypeVariant,
} from '@/lib/extensions/invoice-inbox-utils'
import { Mail, Upload, FileText, Receipt, Landmark } from 'lucide-react'
interface DocumentInboxCardProps {
item: InvoiceInboxItem
onClick: () => void
}
function formatRelativeTime(dateStr: string): string {
const now = new Date()
const date = new Date(dateStr)
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return 'Just nu'
if (diffMin < 60) return `${diffMin} min sedan`
const diffH = Math.floor(diffMin / 60)
if (diffH < 24) return `${diffH} tim sedan`
const diffD = Math.floor(diffH / 24)
if (diffD === 1) return 'Igår'
return `${diffD} dagar sedan`
}
function formatSEK(amount: number): string {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: 'SEK',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
function getDocumentIcon(type: DocumentClassificationType) {
switch (type) {
case 'receipt':
return <Receipt className="h-5 w-5 text-muted-foreground" />
case 'government_letter':
return <Landmark className="h-5 w-5 text-muted-foreground" />
default:
return <FileText className="h-5 w-5 text-muted-foreground" />
}
}
function getSummaryText(item: InvoiceInboxItem): { label: string; total: number } {
const type = item.document_type ?? 'supplier_invoice'
switch (type) {
case 'supplier_invoice': {
const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null
const summary = formatExtractionSummary(extraction)
return {
label: (item.supplier as { name?: string } | undefined)?.name ?? (summary.supplierName || 'Okänd leverantör'),
total: summary.total,
}
}
case 'receipt': {
const receipt = item.receipt as { merchant_name?: string; total_amount?: number } | undefined
return {
label: receipt?.merchant_name ?? 'Okänd handlare',
total: receipt?.total_amount ?? 0,
}
}
case 'government_letter': {
return {
label: item.email_from ?? 'Okänd avsändare',
total: 0,
}
}
default: {
return { label: 'Granska manuellt', total: 0 }
}
}
}
export default function DocumentInboxCard({ item, onClick }: DocumentInboxCardProps) {
const confidence = getConfidenceLabel(item.confidence)
const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
const docType = (item.document_type ?? 'supplier_invoice') as DocumentClassificationType
const docTypeVariant = getDocumentTypeVariant(docType) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
const fileName = (item.document as { file_name?: string } | undefined)?.file_name ?? 'Okänd fil'
const { label: summaryLabel, total } = getSummaryText(item)
return (
<Card
className="cursor-pointer transition-colors hover:bg-accent/50"
onClick={onClick}
>
<CardContent className="flex items-center gap-4 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
{item.source === 'email' ? (
<Mail className="h-5 w-5 text-muted-foreground" />
) : (
<Upload className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{getDocumentIcon(docType)}
<span className="text-sm font-medium truncate">{fileName}</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className={`text-sm truncate ${summaryLabel === 'Granska manuellt' ? 'text-muted-foreground/60 italic' : 'text-muted-foreground'}`}>
{summaryLabel}
</span>
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
{total > 0 && (
<span className="text-sm font-medium">{formatSEK(total)}</span>
)}
<div className="flex items-center gap-1.5">
<Badge variant={docTypeVariant} className="text-[10px] px-1.5 py-0">
{getDocumentTypeLabel(docType)}
</Badge>
{item.confidence != null && (
<Badge variant={confidenceVariant} className="text-[10px] px-1.5 py-0">
{confidence.label}
</Badge>
)}
<Badge variant={statusVariant}>
{getStatusLabel(item.status)}
</Badge>
</div>
<span className="text-[11px] text-muted-foreground">
{formatRelativeTime(item.created_at)}
</span>
</div>
</CardContent>
</Card>
)
}
@@ -0,0 +1,311 @@
'use client'
import { useState } from 'react'
import type { InvoiceInboxItem } from '@/types'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { CheckCircle2, Receipt, LinkIcon } from 'lucide-react'
interface ReceiptLineItem {
id: string
description: string
line_total: number
vat_rate: number | null
is_business: boolean | null
category: string | null
bas_account: string | null
}
interface ReceiptInboxDetailProps {
item: InvoiceInboxItem | null
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
}
function formatSEK(amount: number): string {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: 'SEK',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
}
export default function ReceiptInboxDetail({
item,
open,
onOpenChange,
onConfirm,
}: ReceiptInboxDetailProps) {
const [lineItems, setLineItems] = useState<ReceiptLineItem[]>([])
const [loading, setLoading] = useState(false)
const [confirming, setConfirming] = useState(false)
const [representationPersons, setRepresentationPersons] = useState<number | null>(null)
const [representationPurpose, setRepresentationPurpose] = useState('')
const [representationBusinessConnection, setRepresentationBusinessConnection] = useState('')
const receipt = item?.receipt as {
id?: string
merchant_name?: string
total_amount?: number
receipt_date?: string
status?: string
matched_transaction_id?: string
} | undefined
// Fetch line items when dialog opens
async function fetchLineItems() {
if (!item?.linked_receipt_id) return
setLoading(true)
try {
const res = await fetch(`/api/extensions/receipt-ocr/${item.linked_receipt_id}`)
if (res.ok) {
const { data } = await res.json()
if (data?.line_items) {
setLineItems(data.line_items)
}
}
} catch {
// ok
} finally {
setLoading(false)
}
}
function handleOpenChange(isOpen: boolean) {
if (isOpen && item?.linked_receipt_id) {
fetchLineItems()
}
onOpenChange(isOpen)
}
function toggleBusiness(lineItemId: string) {
setLineItems((prev) =>
prev.map((li) =>
li.id === lineItemId
? { ...li, is_business: li.is_business === true ? false : true }
: li
)
)
}
async function handleConfirm() {
if (!item?.id || !item.linked_receipt_id) return
setConfirming(true)
try {
const body: Record<string, unknown> = {
line_items: lineItems.map((li) => ({
id: li.id,
is_business: li.is_business,
category: li.category,
bas_account: li.bas_account,
})),
}
if (receipt?.matched_transaction_id) {
body.matched_transaction_id = receipt.matched_transaction_id
}
if (representationPersons != null && representationPersons > 0) {
body.representation_persons = representationPersons
}
if (representationPurpose) {
body.representation_purpose = representationPurpose
}
if (representationBusinessConnection) {
body.representation_business_connection = representationBusinessConnection
}
const res = await fetch(
`/api/extensions/invoice-inbox/inbox/${item.id}/confirm-receipt`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
)
if (res.ok) {
onConfirm()
onOpenChange(false)
}
} catch {
// ok
} finally {
setConfirming(false)
}
}
const businessTotal = lineItems
.filter((li) => li.is_business === true)
.reduce((sum, li) => sum + li.line_total, 0)
const privateTotal = lineItems
.filter((li) => li.is_business === false)
.reduce((sum, li) => sum + li.line_total, 0)
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" />
Kvitto via e-post
</DialogTitle>
</DialogHeader>
{receipt && (
<div className="space-y-4">
{/* Summary */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">Handlare</span>
<p className="font-medium">{receipt.merchant_name ?? 'Okänd'}</p>
</div>
<div>
<span className="text-muted-foreground">Datum</span>
<p className="font-medium">{receipt.receipt_date ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Totalbelopp</span>
<p className="font-medium">
{receipt.total_amount ? formatSEK(receipt.total_amount) : '-'}
</p>
</div>
<div>
<span className="text-muted-foreground">Transaktionsmatch</span>
<p className="font-medium flex items-center gap-1">
{receipt.matched_transaction_id ? (
<>
<LinkIcon className="h-3 w-3 text-green-600" />
<span className="text-green-600">Matchad</span>
</>
) : (
<span className="text-muted-foreground">Ingen match</span>
)}
</p>
</div>
</div>
<Separator />
{/* Line items with business/private toggle */}
<div className="space-y-2">
<Label>Artikelrader</Label>
{loading ? (
<p className="text-sm text-muted-foreground">Laddar...</p>
) : lineItems.length === 0 ? (
<p className="text-sm text-muted-foreground">Inga rader extraherade</p>
) : (
<div className="space-y-2">
{lineItems.map((li) => (
<div
key={li.id}
className="flex items-center justify-between gap-3 rounded-lg border p-3"
>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{li.description}</p>
<p className="text-xs text-muted-foreground">
{formatSEK(li.line_total)}
{li.vat_rate != null && ` (${li.vat_rate}% moms)`}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-muted-foreground">Företag</span>
<Switch
checked={li.is_business === true}
onCheckedChange={() => toggleBusiness(li.id)}
/>
</div>
</div>
))}
</div>
)}
</div>
{/* Totals */}
{lineItems.length > 0 && (
<div className="flex gap-4 text-sm">
<Badge variant="default">Företag: {formatSEK(Math.round(businessTotal * 100) / 100)}</Badge>
<Badge variant="secondary">Privat: {formatSEK(Math.round(privateTotal * 100) / 100)}</Badge>
</div>
)}
<Separator />
{/* Representation fields */}
<div className="space-y-3">
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
Representation (vid restaurangkvitto)
</Label>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="rep-persons" className="text-sm">
Antal personer
</Label>
<Input
id="rep-persons"
type="number"
min={0}
value={representationPersons ?? ''}
onChange={(e) =>
setRepresentationPersons(
e.target.value ? parseInt(e.target.value) : null
)
}
placeholder="0"
/>
</div>
<div>
<Label htmlFor="rep-purpose" className="text-sm">
Syfte
</Label>
<Input
id="rep-purpose"
value={representationPurpose}
onChange={(e) => setRepresentationPurpose(e.target.value)}
placeholder="T.ex. kundmöte"
/>
</div>
</div>
<div>
<Label htmlFor="rep-connection" className="text-sm">
Affärsmässig koppling (BFNAR)
</Label>
<Input
id="rep-connection"
value={representationBusinessConnection}
onChange={(e) =>
setRepresentationBusinessConnection(e.target.value)
}
placeholder="T.ex. potentiell kund, pågående projekt"
/>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button onClick={handleConfirm} disabled={confirming}>
<CheckCircle2 className="h-4 w-4 mr-1.5" />
{confirming ? 'Bekräftar...' : 'Bekräfta kvitto'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -2,15 +2,21 @@
import { useCallback, useRef, useState } from 'react'
import type { InvoiceInboxItem } from '@/types'
import { Upload, Loader2, FileUp } from 'lucide-react'
import { Upload, Loader2, FileUp, CheckCircle2, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
interface InboxUploadZoneProps {
onUploadComplete: (item: InvoiceInboxItem) => void
onUploadComplete: (item: InvoiceInboxItem | InvoiceInboxItem[]) => void
isUploading: boolean
setIsUploading: (v: boolean) => void
}
interface FileProgress {
name: string
status: 'pending' | 'uploading' | 'done' | 'error'
error?: string
}
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
@@ -21,27 +27,48 @@ export default function InboxUploadZone({
}: InboxUploadZoneProps) {
const [isDragOver, setIsDragOver] = useState(false)
const [error, setError] = useState<string | null>(null)
const [fileProgress, setFileProgress] = useState<FileProgress[]>([])
const inputRef = useRef<HTMLInputElement>(null)
const uploadFile = useCallback(
async (file: File) => {
const uploadFiles = useCallback(
async (files: File[]) => {
setError(null)
if (!ACCEPTED_TYPES.includes(file.type)) {
setError('Filtypen stöds inte. Välj PDF, JPEG, PNG eller WebP.')
return
// Validate all files first
const validFiles: File[] = []
for (const file of files) {
if (!ACCEPTED_TYPES.includes(file.type)) {
setError(`${file.name}: filtypen stöds inte. Välj PDF, JPEG, PNG eller WebP.`)
return
}
if (file.size > MAX_SIZE) {
setError(`${file.name}: filen är för stor. Max 10 MB.`)
return
}
validFiles.push(file)
}
if (file.size > MAX_SIZE) {
setError('Filen är för stor. Max 10 MB.')
return
}
if (validFiles.length === 0) return
setIsUploading(true)
// Show per-file progress for multi-file uploads
if (validFiles.length > 1) {
setFileProgress(validFiles.map((f) => ({ name: f.name, status: 'uploading' })))
}
try {
const formData = new FormData()
formData.append('file', file)
if (validFiles.length === 1) {
// Single file: use legacy `file` key for backward compat
formData.append('file', validFiles[0])
} else {
// Multiple files: use `files` key
for (const file of validFiles) {
formData.append('files', file)
}
}
const res = await fetch('/api/extensions/invoice-inbox/inbox', {
method: 'POST',
@@ -51,13 +78,43 @@ export default function InboxUploadZone({
if (!res.ok) {
const body = await res.json().catch(() => ({ error: 'Uppladdning misslyckades' }))
setError(body.error ?? 'Uppladdning misslyckades')
if (validFiles.length > 1) {
setFileProgress((prev) => prev.map((f) => ({ ...f, status: 'error' as const })))
}
return
}
const { data } = await res.json()
onUploadComplete(data)
const body = await res.json()
if (validFiles.length === 1) {
onUploadComplete(body.data)
setFileProgress([])
} else {
// Mark individual files
const items: InvoiceInboxItem[] = body.data || []
const errors: string[] = body.errors || []
setFileProgress((prev) =>
prev.map((fp, i) => {
// Check if this file had an error
const errMsg = errors.find((e) => e.startsWith(fp.name))
if (errMsg) {
return { ...fp, status: 'error' as const, error: errMsg }
}
return { ...fp, status: 'done' as const }
})
)
if (items.length > 0) {
onUploadComplete(items)
}
// Clear progress after a delay
setTimeout(() => setFileProgress([]), 3000)
}
} catch {
setError('Nätverksfel vid uppladdning')
setFileProgress([])
} finally {
setIsUploading(false)
}
@@ -69,10 +126,10 @@ export default function InboxUploadZone({
(e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
const file = e.dataTransfer.files[0]
if (file) uploadFile(file)
const files = Array.from(e.dataTransfer.files)
if (files.length > 0) uploadFiles(files)
},
[uploadFile]
[uploadFiles]
)
const handleDragOver = useCallback((e: React.DragEvent) => {
@@ -87,12 +144,11 @@ export default function InboxUploadZone({
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) uploadFile(file)
// Reset so same file can be re-selected
const files = Array.from(e.target.files || [])
if (files.length > 0) uploadFiles(files)
e.target.value = ''
},
[uploadFile]
[uploadFiles]
)
return (
@@ -114,6 +170,7 @@ export default function InboxUploadZone({
ref={inputRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png,.webp"
multiple
className="hidden"
onChange={handleFileSelect}
disabled={isUploading}
@@ -127,22 +184,40 @@ export default function InboxUploadZone({
) : isDragOver ? (
<>
<FileUp className="h-8 w-8 text-primary mb-2" />
<p className="text-sm font-medium text-primary">Släpp filen här</p>
<p className="text-sm font-medium text-primary">Släpp filerna här</p>
</>
) : (
<>
<Upload className="h-8 w-8 text-muted-foreground/60 mb-2" />
<p className="text-sm text-muted-foreground">
Dra och släpp en faktura, eller{' '}
<span className="font-medium text-primary">välj fil</span>
Dra och släpp fakturor, eller{' '}
<span className="font-medium text-primary">välj filer</span>
</p>
<p className="text-xs text-muted-foreground/60 mt-1">
PDF, JPEG, PNG eller WebP (max 10 MB)
PDF, JPEG, PNG eller WebP (max 10 MB per fil)
</p>
</>
)}
</div>
{fileProgress.length > 0 && (
<div className="mt-3 space-y-1">
{fileProgress.map((fp) => (
<div key={fp.name} className="flex items-center gap-2 text-sm">
{fp.status === 'uploading' && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
{fp.status === 'done' && <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />}
{fp.status === 'error' && <AlertCircle className="h-3.5 w-3.5 text-destructive" />}
<span className={cn(
'truncate',
fp.status === 'error' && 'text-destructive'
)}>
{fp.name}
</span>
</div>
))}
</div>
)}
{error && (
<p className="text-sm text-destructive mt-2">{error}</p>
)}
@@ -10,6 +10,7 @@ import VatTreatmentSelect from './VatTreatmentSelect'
import { formatCurrency, formatDate } from '@/lib/utils'
import { checkExpenseWarnings } from '@/lib/tax/expense-warnings'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
@@ -59,6 +60,8 @@ export default function SwipeCategorizationView({
const [showUploadZone, setShowUploadZone] = useState(false)
const [showDescribeDialog, setShowDescribeDialog] = useState(false)
const [showVatDropdown, setShowVatDropdown] = useState(false)
const [pendingTemplateId, setPendingTemplateId] = useState<string | null>(null)
const [pendingInboxItemId, setPendingInboxItemId] = useState<string | null>(null)
// Clear VAT treatment when switching to a liability/equity account (class 2)
useEffect(() => {
@@ -120,6 +123,23 @@ export default function SwipeCategorizationView({
setPendingCategory(category)
setAccountOverride(defaultAccount)
setVatTreatment(defaultVat ?? 'none')
setPendingTemplateId(null)
setPendingInboxItemId(null)
setShowVatDropdown(false)
setShowCategorySelect(false)
setShowReviewStep(true)
setError(null)
}, [])
const handleTemplateSelect = useCallback((templateId: string, inboxItemId?: string) => {
const template = getTemplateById(templateId)
if (!template) return
setPendingCategory(template.fallback_category)
setAccountOverride(template.debit_account)
setVatTreatment(template.vat_treatment ?? 'none')
setPendingTemplateId(templateId)
setPendingInboxItemId(inboxItemId ?? null)
setShowVatDropdown(false)
setShowCategorySelect(false)
setShowReviewStep(true)
@@ -180,7 +200,9 @@ export default function SwipeCategorizationView({
true,
pendingCategory,
resolvedVat,
override
override,
pendingTemplateId ?? undefined,
pendingInboxItemId ?? undefined
)
if (journalEntryId) {
// Link uploaded documents to the journal entry
@@ -210,6 +232,8 @@ export default function SwipeCategorizationView({
resetUploadState()
setShowReviewStep(false)
setPendingCategory(null)
setPendingTemplateId(null)
setPendingInboxItemId(null)
moveToNext()
} else {
setError('Kunde inte bokföra. Tryck "Hoppa över" för att gå vidare.')
@@ -248,6 +272,8 @@ export default function SwipeCategorizationView({
setShowCategorySelect(false)
setShowReviewStep(false)
setPendingCategory(null)
setPendingTemplateId(null)
setPendingInboxItemId(null)
resetUploadState()
moveToNext()
}, [moveToNext, resetUploadState])
@@ -424,38 +450,51 @@ export default function SwipeCategorizationView({
</div>
</div>
{/* Document upload */}
<div className="rounded-lg border">
<button
type="button"
onClick={() => setShowUploadZone(!showUploadZone)}
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
>
{/* Document upload / pre-attached document */}
{pendingInboxItemId && currentTransaction.matched_inbox_item?.document_id ? (
<div className="rounded-lg border bg-blue-500/5 border-blue-500/30 px-3 py-2.5">
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Underlag</span>
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
<span className="text-xs text-muted-foreground">
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
</span>
<Paperclip className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium">Underlag bifogat</span>
<Check className="h-4 w-4 text-blue-600" />
</div>
<p className="text-xs text-muted-foreground mt-1">
Dokumentet från inkorgen länkas automatiskt till verifikationen.
</p>
</div>
) : (
<div className="rounded-lg border">
<button
type="button"
onClick={() => setShowUploadZone(!showUploadZone)}
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Underlag</span>
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
<span className="text-xs text-muted-foreground">
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
</span>
)}
</div>
{showUploadZone ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
{showUploadZone ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</button>
{showUploadZone && (
<div className="px-3 pb-3">
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
compact
/>
</div>
)}
</button>
{showUploadZone && (
<div className="px-3 pb-3">
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
compact
/>
</div>
)}
</div>
</div>
)}
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
@@ -603,6 +642,55 @@ export default function SwipeCategorizationView({
</div>
)}
{/* Document Match from Inbox */}
{currentTransaction.matched_inbox_item && (
<div className="p-4 rounded-lg border-2 border-blue-500/40 bg-blue-500/5 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400">
<Paperclip className="h-5 w-5" />
<span className="font-semibold text-sm">
{currentTransaction.matched_inbox_item.document_type === 'receipt'
? 'Matchat kvitto'
: currentTransaction.matched_inbox_item.document_type === 'supplier_invoice'
? 'Matchad leverantörsfaktura'
: 'Matchat dokument'}
</span>
</div>
{currentTransaction.matched_inbox_item.match_confidence != null && (
<Badge variant="outline" className="text-blue-600 border-blue-500">
{Math.round(currentTransaction.matched_inbox_item.match_confidence * 100)}%
</Badge>
)}
</div>
<div className="text-sm">
{(() => {
const ext = currentTransaction.matched_inbox_item.extracted_data as Record<string, unknown> | null
if (!ext) return null
const supplierName = (ext as { supplier?: { name?: string } })?.supplier?.name
const merchantName = (ext as { merchant?: { name?: string } })?.merchant?.name
const totals = ext as { totals?: { total?: number } }
return (
<>
{(supplierName || merchantName) && (
<p className="font-medium">{supplierName || merchantName}</p>
)}
{totals?.totals?.total != null && (
<p className="text-muted-foreground">
{formatCurrency(totals.totals.total)}
</p>
)}
</>
)
})()}
{currentTransaction.matched_inbox_item.suggested_template_id && (
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
Mall: {currentTransaction.matched_inbox_item.suggested_template_id}
</p>
)}
</div>
</div>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="space-y-2 pt-4 border-t">
@@ -642,6 +730,23 @@ export default function SwipeCategorizationView({
</div>
)}
{/* Document template match — primary action when inbox item has a suggested template */}
{currentTransaction.matched_inbox_item?.suggested_template_id && (() => {
const tmplId = currentTransaction.matched_inbox_item!.suggested_template_id!
const template = getTemplateById(tmplId)
if (!template) return null
return (
<Button
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
onClick={() => handleTemplateSelect(tmplId, currentTransaction.matched_inbox_item!.id)}
disabled={isProcessing}
>
<Paperclip className="mr-2 h-4 w-4" />
Bokför som {template.name_sv}
</Button>
)
})()}
{/* Invoice match button - primary action when there's a match */}
{currentTransaction.potential_invoice && onMatchInvoice && (
<Button
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, FileText, Loader2, MessageSquareText } from 'lucide-react'
import { ArrowUpRight, ArrowDownRight, FileText, Loader2, MessageSquareText, Paperclip } from 'lucide-react'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
@@ -51,11 +51,13 @@ export default function TransactionInboxCard({
const isDisabled = processingId !== null && processingId !== transaction.id
const isIncome = transaction.amount > 0
const hasInvoiceMatch = !!transaction.potential_invoice && !transaction.invoice_id
const hasSupplierInvoiceMatch = !!transaction.potential_supplier_invoice && !transaction.supplier_invoice_id
const topSuggestion = suggestions?.[0]
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
const showCheckbox = isBatchMode && isUncategorized
const hasWeakSuggestions = !topSuggestion || topSuggestion.confidence < 0.55
const showTemplateFallback = hasWeakSuggestions && templateSuggestions && templateSuggestions.length > 0
const hasDocumentMatch = !!transaction.matched_inbox_item
function handleSuggestionClick(suggestion: SuggestedCategory) {
if (onOpenQuickReview) {
@@ -80,7 +82,7 @@ export default function TransactionInboxCard({
>
<Card
className={`transition-colors ${
hasInvoiceMatch ? 'border-blue-500/50' : 'border-warning/50'
hasInvoiceMatch || hasSupplierInvoiceMatch ? 'border-blue-500/50' : 'border-warning/50'
} ${isSelected ? 'border-primary bg-primary/[0.02]' : ''} ${
isDisabled ? 'opacity-50' : ''
}`}
@@ -113,7 +115,16 @@ export default function TransactionInboxCard({
</div>
<div className="min-w-0">
<p className="font-medium truncate">{transaction.description}</p>
<p className="text-sm text-muted-foreground">{formatDate(transaction.date)}</p>
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">{formatDate(transaction.date)}</p>
{hasDocumentMatch && (
<Badge variant="secondary" className="text-xs gap-1">
<Paperclip className="h-3 w-3" />
{transaction.matched_inbox_item!.document_type === 'receipt' ? 'Kvitto' :
transaction.matched_inbox_item!.document_type === 'supplier_invoice' ? 'Faktura' : 'Dokument'}
</Badge>
)}
</div>
</div>
</div>
@@ -150,6 +161,21 @@ export default function TransactionInboxCard({
)}
Matcha Faktura {transaction.potential_invoice!.invoice_number}
</Button>
) : hasSupplierInvoiceMatch ? (
<Button
size="sm"
variant="default"
className="h-8 text-xs"
onClick={() => onOpenMatchDialog(transaction)}
disabled={isProcessing || isDisabled}
>
{isProcessing ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<FileText className="mr-1.5 h-3 w-3" />
)}
Matcha Leverantörsfaktura {transaction.potential_supplier_invoice!.supplier_invoice_number}
</Button>
) : topSuggestion ? (
<Button
size="sm"
+6 -2
View File
@@ -1,8 +1,10 @@
import type { Transaction, TransactionCategory, Invoice, Customer, VatTreatment } from '@/types'
import type { Transaction, TransactionCategory, Invoice, Customer, SupplierInvoice, VatTreatment, InvoiceInboxItem } from '@/types'
// Shared transaction type with potential invoice data
export interface TransactionWithInvoice extends Transaction {
potential_invoice?: Invoice & { customer?: Customer }
potential_supplier_invoice?: SupplierInvoice
matched_inbox_item?: InvoiceInboxItem
}
// Page view modes
@@ -16,7 +18,9 @@ export type CategorizeHandler = (
isBusiness: boolean,
category?: TransactionCategory,
vatTreatment?: VatTreatment,
accountOverride?: string
accountOverride?: string,
templateId?: string,
inboxItemId?: string
) => Promise<string | null>
export type MatchInvoiceHandler = (
+2
View File
@@ -15,6 +15,7 @@ const TYPE_COLORS: Record<AccountType, string> = {
equity: 'bg-blue-500',
revenue: 'bg-purple-500',
expense: 'bg-red-500',
untaxed_reserves: 'bg-amber-500',
}
const TYPE_LABELS: Record<AccountType, string> = {
@@ -23,6 +24,7 @@ const TYPE_LABELS: Record<AccountType, string> = {
equity: 'Eget kapital',
revenue: 'Intäkt',
expense: 'Kostnad',
untaxed_reserves: 'Obeskattade reserver',
}
interface AccountNumberProps {
@@ -46,10 +46,19 @@ export interface CategorizationContext {
recentHistory: { description: string; category: string }[]
}
export interface DocumentEnrichment {
type: 'receipt' | 'supplier_invoice'
merchantName?: string
lineItems?: Array<{ description: string; amount: number; category?: string; accountSuggestion?: string }>
vatBreakdown?: Array<{ rate: number; amount: number }>
isReverseCharge?: boolean
}
export interface EnrichedCategorizationContext extends CategorizationContext {
candidateTemplates: BookingTemplate[]
userAccountUsage: AccountUsageEntry[]
merchantHistory: MerchantHistoryEntry[]
documentData?: DocumentEnrichment
}
export interface CategorizationSuggestion {
@@ -223,6 +232,11 @@ export class AnthropicCategorizationProvider implements CategorizationProvider {
.join('\n')}`
: ''
// Build document enrichment context (from linked receipt or supplier invoice)
const documentContext = enriched?.documentData
? buildDocumentContext(enriched.documentData)
: ''
const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
Din uppgift är att kategorisera varje transaktion till rätt mall-ID (templateId) och BAS-konto.
@@ -243,7 +257,7 @@ MOMSHANTERING:
- Intäkter: Normalt 25% moms (utgående moms, MP1)
${NON_DEDUCTIBLE_RULES}
${documentContext}
REGLER:
1. Negativa belopp = utgifter, positiva = intäkter
2. VIKTIGT: Dessa transaktioner kommer från företagets bankkonto/kort. Anta ALLTID att de är affärsrelaterade. Klassificera ALDRIG som "private" — det beslutet tar användaren själv.
@@ -382,6 +396,42 @@ ${transactionList}`
}
}
function buildDocumentContext(doc: DocumentEnrichment): string {
const parts: string[] = []
const typeLabel = doc.type === 'receipt' ? 'KVITTO' : 'LEVERANTÖRSFAKTURA'
parts.push(`LÄNKAT DOKUMENT (${typeLabel}):`)
if (doc.merchantName) {
parts.push(`Handlare/leverantör: ${doc.merchantName}`)
}
if (doc.lineItems && doc.lineItems.length > 0) {
parts.push('Rader:')
for (const item of doc.lineItems) {
let line = `- ${item.description}: ${item.amount} kr`
if (item.accountSuggestion) line += ` (föreslaget konto: ${item.accountSuggestion})`
if (item.category) line += ` [${item.category}]`
parts.push(line)
}
}
if (doc.vatBreakdown && doc.vatBreakdown.length > 0) {
parts.push('Momsfördelning:')
for (const vat of doc.vatBreakdown) {
parts.push(`- ${vat.rate}%: ${vat.amount} kr`)
}
}
if (doc.isReverseCharge) {
parts.push(`VIKTIGT: Omvänd skattskyldighet (reverse charge). Använd dubbelkontering:
- Debitera 2645 (beräknad ingående moms) OCH kreditera 2614 (utgående moms, omvänd skattskyldighet)
- Mallen "purchase_eu_service_reverse_charge" ska användas om tillgänglig`)
}
return parts.join('\n') + '\n'
}
function isEnrichedContext(
ctx: CategorizationContext | EnrichedCategorizationContext
): ctx is EnrichedCategorizationContext {
@@ -11,6 +11,7 @@
import 'server-only'
import Anthropic from '@anthropic-ai/sdk'
import type { InvoiceExtractionResult, ExtractedInvoiceLineItem, VatBreakdownItem } from '../types'
import { buildTemplatePromptSection } from '@/lib/bookkeeping/template-prompt'
const anthropic = new Anthropic()
@@ -40,6 +41,8 @@ VIKTIGT:
- Belopp ska vara numeriska värden utan valutasymboler
- Ange konfidenstal (0.0-1.0) för hela extraheringen`
const templateSection = buildTemplatePromptSection()
const userPrompt = `Analysera denna leverantörsfaktura och extrahera strukturerad data.
Returnera ett JSON-objekt med följande struktur:
@@ -67,7 +70,8 @@ Returnera ett JSON-objekt med följande struktur:
"unitPrice": 100.00,
"lineTotal": 100.00,
"vatRate": 25,
"accountSuggestion": "BAS-kontonummer som 5410 eller null"
"accountSuggestion": "BAS-kontonummer som 5410 eller null",
"suggestedTemplateId": "mall-id eller null"
}
],
"totals": {
@@ -82,10 +86,13 @@ Returnera ett JSON-objekt med följande struktur:
"amount": 25.00
}
],
"confidence": 0.95
"confidence": 0.95,
"suggestedTemplateId": "mall-id för hela fakturan eller null"
}
KONTOKATEGORIER (BAS):
${templateSection}
KONTOKATEGORIER (BAS, backup om ingen mall matchar):
- 4000-4999: Varuinköp, material
- 5010: Lokalhyra
- 5410: Förbrukningsinventarier
@@ -212,6 +219,7 @@ function validateAndEnhanceResult(raw: unknown): InvoiceExtractionResult {
},
vatBreakdown: validateVatBreakdown(data.vatBreakdown),
confidence: validateNumber(data.confidence) || 0.5,
suggestedTemplateId: validateString(data.suggestedTemplateId) || undefined,
}
}
@@ -228,6 +236,7 @@ function validateLineItems(data: any): ExtractedInvoiceLineItem[] {
lineTotal: validateNumber(item.lineTotal) || 0,
vatRate: validateNumber(item.vatRate),
accountSuggestion: validateAccountNumber(item.accountSuggestion as string | undefined),
suggestedTemplateId: validateString(item.suggestedTemplateId as string | undefined) || undefined,
}))
.filter((item: ExtractedInvoiceLineItem) => item.lineTotal > 0 || item.description.length > 0)
}
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
// Mock server-only
vi.mock('server-only', () => ({}))
// Mock receipt analyzer
vi.mock('../receipt-analyzer', () => ({
analyzeReceipt: vi.fn(),
}))
// Mock receipt categorizer
vi.mock('../receipt-categorizer', () => ({
processLineItems: vi.fn(),
}))
// Mock receipt matcher
vi.mock('../receipt-matcher', () => ({
autoMatchReceipts: vi.fn(),
}))
// Mock event bus
vi.mock('@/lib/events/bus', () => ({
eventBus: { emit: vi.fn(), clear: vi.fn() },
}))
import { processReceiptFromDocument } from '../receipt-pipeline'
import { analyzeReceipt } from '../receipt-analyzer'
import { processLineItems } from '../receipt-categorizer'
import { autoMatchReceipts } from '../receipt-matcher'
function createMockSupabase() {
const mockResult = { data: null, error: null }
const chain = {
insert: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
single: vi.fn().mockImplementation(() => Promise.resolve(mockResult)),
eq: vi.fn().mockReturnThis(),
is: vi.fn().mockReturnThis(),
lt: vi.fn().mockReturnThis(),
gte: vi.fn().mockReturnThis(),
lte: vi.fn().mockReturnThis(),
}
const supabase = {
from: vi.fn().mockReturnValue(chain),
}
return { supabase, chain, setResult: (data: unknown, error: unknown = null) => {
mockResult.data = data as null
mockResult.error = error as null
} }
}
const mockExtraction = {
merchant: {
name: 'ICA Maxi',
orgNumber: '556123-4567',
vatNumber: null,
isForeign: false,
},
receipt: {
date: '2024-06-15',
time: '14:30',
currency: 'SEK',
},
lineItems: [
{ description: 'Mjölk', quantity: 1, unitPrice: 15, lineTotal: 15, vatRate: 12, suggestedCategory: 'other' },
],
totals: { subtotal: 13.39, vatAmount: 1.61, total: 15 },
flags: { isRestaurant: false, isSystembolaget: false, isForeignMerchant: false },
confidence: 0.92,
}
describe('processReceiptFromDocument', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
vi.mocked(analyzeReceipt).mockResolvedValue(mockExtraction)
vi.mocked(processLineItems).mockReturnValue([
{ ...mockExtraction.lineItems[0], category: 'expense_other' as const, basAccount: '6991', confidence: 0.8 },
])
vi.mocked(autoMatchReceipts).mockReturnValue([])
})
it('creates receipt record with extracted data', async () => {
const { supabase, chain, setResult } = createMockSupabase()
const receipt = {
id: 'receipt-1',
user_id: 'user-1',
status: 'extracted',
merchant_name: 'ICA Maxi',
total_amount: 15,
receipt_date: '2024-06-15',
}
// First from() = receipts insert
setResult(receipt)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
documentId: 'doc-1',
source: 'email',
emailFrom: 'sender@example.com',
storageUrl: 'https://storage.example.com/file.jpg',
})
expect(result.receipt.id).toBe('receipt-1')
expect(analyzeReceipt).toHaveBeenCalledWith('base64data', 'image/jpeg')
expect(processLineItems).toHaveBeenCalledWith(mockExtraction.lineItems)
// Verify receipt insert includes source and email_from
expect(chain.insert).toHaveBeenCalledWith(
expect.objectContaining({
source: 'email',
email_from: 'sender@example.com',
document_id: 'doc-1',
})
)
})
it('emits receipt.extracted event', async () => {
const { supabase, setResult } = createMockSupabase()
const receipt = { id: 'receipt-1', user_id: 'user-1' }
setResult(receipt)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
documentId: 'doc-1',
source: 'upload',
storageUrl: 'https://storage.example.com/file.jpg',
})
expect(eventBus.emit).toHaveBeenCalledWith({
type: 'receipt.extracted',
payload: expect.objectContaining({
receipt,
documentId: 'doc-1',
confidence: 0.92,
userId: 'user-1',
}),
})
})
it('throws when receipt insert fails', async () => {
const { supabase, setResult } = createMockSupabase()
setResult(null, { message: 'insert failed' })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await expect(processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
documentId: null,
source: 'upload',
storageUrl: 'https://storage.example.com/file.jpg',
})).rejects.toThrow('Failed to create receipt')
})
it('sets email_from to null when not provided', async () => {
const { supabase, chain, setResult } = createMockSupabase()
setResult({ id: 'receipt-1', user_id: 'user-1' })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await processReceiptFromDocument(supabase as any, 'user-1', 'base64data', 'image/jpeg', {
documentId: null,
source: 'upload',
storageUrl: 'https://storage.example.com/file.jpg',
})
expect(chain.insert).toHaveBeenCalledWith(
expect.objectContaining({
source: 'upload',
email_from: null,
})
)
})
})
@@ -14,6 +14,7 @@ import { ChatAnthropic } from '@langchain/anthropic'
import { HumanMessage, SystemMessage } from '@langchain/core/messages'
import { z } from 'zod'
import type { ReceiptExtractionResult, ExtractedLineItem } from '@/types'
import { buildTemplatePromptSection } from '@/lib/bookkeeping/template-prompt'
import {
SYSTEMBOLAGET_PATTERNS,
RESTAURANT_PATTERNS,
@@ -54,6 +55,8 @@ VIKTIGT:
- Belopp ska vara numeriska värden utan valutasymboler
- Ange konfidenstal (0.0-1.0) för hela extraheringen baserat på bildkvalitet`
const templateSection = buildTemplatePromptSection()
const userPrompt = `Analysera detta kvitto och extrahera strukturerad data.
Returnera ett JSON-objekt med följande struktur:
@@ -77,7 +80,8 @@ Returnera ett JSON-objekt med följande struktur:
"unitPrice": 100.00,
"lineTotal": 100.00,
"vatRate": 25,
"suggestedCategory": "equipment|software|travel|office|marketing|professional_services|education|other"
"suggestedCategory": "equipment|software|travel|office|marketing|professional_services|education|other",
"suggestedTemplateId": "mall-id eller null"
}
],
"totals": {
@@ -90,10 +94,13 @@ Returnera ett JSON-objekt med följande struktur:
"isSystembolaget": false,
"isForeignMerchant": false
},
"confidence": 0.95
"confidence": 0.95,
"suggestedTemplateId": "mall-id för hela kvittot eller null"
}
KATEGORIER för suggestedCategory:
${templateSection}
KATEGORIER för suggestedCategory (backup om ingen mall matchar):
- equipment: Datorer, telefoner, kameror, teknikprylar
- software: Program, appar, molntjänster, prenumerationer
- travel: Flyg, tåg, hotell, taxi
@@ -224,6 +231,7 @@ function validateAndEnhanceResult(raw: unknown): ReceiptExtractionResult {
isForeignMerchant: isForeign,
},
confidence: validateNumber(data.confidence) || 0.5,
suggestedTemplateId: validateString(data.suggestedTemplateId) || undefined,
}
return result
@@ -245,6 +253,7 @@ function validateLineItems(data: any): ExtractedLineItem[] {
lineTotal: validateNumber(item.lineTotal) || 0,
vatRate: validateNumber(item.vatRate),
suggestedCategory: validateCategory(item.suggestedCategory),
suggestedTemplateId: validateString(item.suggestedTemplateId) || undefined,
confidence: validateNumber(item.confidence) || undefined,
}))
.filter((item) => item.lineTotal > 0 || item.description.length > 0)
@@ -6,6 +6,7 @@
*/
import type { TransactionCategory, ReceiptLineItem, ExtractedLineItem } from '@/types'
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
// Category mappings from suggested category to TransactionCategory
const CATEGORY_MAPPING: Record<string, TransactionCategory> = {
@@ -108,6 +109,16 @@ export function mapSuggestedCategory(suggestedCategory: string | null): Transact
return CATEGORY_MAPPING[suggestedCategory] || null
}
/**
* Map a booking template ID to a TransactionCategory.
* Falls back to the template's `fallback_category` field.
*/
export function mapTemplateIdToCategory(templateId: string | null | undefined): TransactionCategory | null {
if (!templateId) return null
const template = getTemplateById(templateId)
return template?.fallback_category ?? null
}
/**
* Get BAS account for a category
*/
@@ -148,9 +159,15 @@ export function processLineItems(
lineItems: ExtractedLineItem[]
): Array<ExtractedLineItem & { category: TransactionCategory | null; basAccount: string | null }> {
return lineItems.map((item) => {
// First try the AI-suggested category
let category = mapSuggestedCategory(item.suggestedCategory)
let confidence = item.confidence || 0.8
// First try the AI-suggested template ID
let category = mapTemplateIdToCategory(item.suggestedTemplateId)
let confidence = category ? (item.confidence || 0.85) : 0
// Then try the AI-suggested category
if (!category) {
category = mapSuggestedCategory(item.suggestedCategory)
confidence = item.confidence || 0.8
}
// If no AI suggestion, try pattern matching
if (!category) {
@@ -3,14 +3,24 @@
*
* Uses date variance, amount tolerance, and merchant name similarity
* to find potential transaction matches for receipts.
*
* Core matching utilities (levenshtein, merchant similarity) are imported
* from @/lib/documents/core-receipt-matcher and re-exported for backward compat.
*/
import type { Transaction, Receipt, ReceiptMatchCandidate } from '@/types'
import {
calculateMerchantSimilarity,
calculateMatchConfidence,
levenshteinDistance,
normalizeMerchantName,
DATE_TOLERANCE_DAYS,
AMOUNT_TOLERANCE_PERCENT,
MIN_MATCH_CONFIDENCE,
} from '@/lib/documents/core-receipt-matcher'
// Matching configuration
const DATE_TOLERANCE_DAYS = 3 // Allow ±3 days variance
const AMOUNT_TOLERANCE_PERCENT = 0.05 // Allow ±5% variance (for currency conversion)
const MIN_MATCH_CONFIDENCE = 0.4 // Minimum confidence to consider a match
// Re-export core functions for backward compatibility
export { calculateMerchantSimilarity, levenshteinDistance, normalizeMerchantName }
/**
* Find potential transaction matches for a receipt
@@ -59,7 +69,7 @@ export function findTransactionMatches(
const merchantSimilarity = calculateMerchantSimilarity(merchantName, transactionMerchant)
// Calculate overall confidence
const { confidence, matchReasons } = calculateMatchConfidence(
const { confidence, matchReasons } = calculateReceiptMatchConfidence(
dateVariance,
amountVariance,
merchantSimilarity,
@@ -83,140 +93,28 @@ export function findTransactionMatches(
}
/**
* Calculate merchant name similarity using Levenshtein distance
* Wrapper around core calculateMatchConfidence that adds receipt-specific
* MCC bonus logic for backward compatibility.
*/
function calculateMerchantSimilarity(name1: string, name2: string): number {
if (!name1 || !name2) return 0
// Normalize names
const n1 = normalizeMerchantName(name1)
const n2 = normalizeMerchantName(name2)
// Check for exact match
if (n1 === n2) return 1
// Check if one contains the other
if (n1.includes(n2) || n2.includes(n1)) return 0.9
// Check for word overlap
const words1 = n1.split(/\s+/)
const words2 = n2.split(/\s+/)
const commonWords = words1.filter((w) => words2.includes(w))
if (commonWords.length > 0) {
const overlapScore = commonWords.length / Math.max(words1.length, words2.length)
if (overlapScore >= 0.5) return 0.7 + overlapScore * 0.2
}
// Calculate Levenshtein similarity
const distance = levenshteinDistance(n1, n2)
const maxLength = Math.max(n1.length, n2.length)
const similarity = 1 - distance / maxLength
return similarity
}
/**
* Normalize merchant name for comparison
*/
function normalizeMerchantName(name: string): string {
return name
.toLowerCase()
.replace(/[^\w\såäöé]/g, '') // Remove special chars except Swedish letters
.replace(/\b(ab|hb|kb|ek|för|stiftelse)\b/g, '') // Remove company suffixes
.replace(/\s+/g, ' ')
.trim()
}
/**
* Calculate Levenshtein distance between two strings
*/
function levenshteinDistance(str1: string, str2: string): number {
const m = str1.length
const n = str2.length
// Create matrix
const dp: number[][] = Array(m + 1)
.fill(null)
.map(() => Array(n + 1).fill(0))
// Initialize first row and column
for (let i = 0; i <= m; i++) dp[i][0] = i
for (let j = 0; j <= n; j++) dp[0][j] = j
// Fill the matrix
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1
dp[i][j] = Math.min(
dp[i - 1][j] + 1, // deletion
dp[i][j - 1] + 1, // insertion
dp[i - 1][j - 1] + cost // substitution
)
}
}
return dp[m][n]
}
/**
* Calculate overall match confidence and reasons
*/
function calculateMatchConfidence(
function calculateReceiptMatchConfidence(
dateVariance: number,
amountVariance: number,
merchantSimilarity: number,
receipt: Receipt,
transaction: Transaction
): { confidence: number; matchReasons: string[] } {
const matchReasons: string[] = []
let totalWeight = 0
let weightedScore = 0
const result = calculateMatchConfidence(dateVariance, amountVariance, merchantSimilarity)
// Date score (weight: 25%)
const dateScore = Math.max(0, 1 - dateVariance / DATE_TOLERANCE_DAYS)
if (dateScore >= 0.8) {
matchReasons.push(dateVariance === 0 ? 'Exakt datum' : `Datum ±${Math.round(dateVariance)} dagar`)
}
weightedScore += dateScore * 0.25
totalWeight += 0.25
// Amount score (weight: 40%)
const amountScore = Math.max(0, 1 - amountVariance / AMOUNT_TOLERANCE_PERCENT)
if (amountVariance < 0.01) {
matchReasons.push('Exakt belopp')
} else if (amountVariance < AMOUNT_TOLERANCE_PERCENT) {
matchReasons.push(`Belopp ±${Math.round(amountVariance * 100)}%`)
}
weightedScore += amountScore * 0.4
totalWeight += 0.4
// Merchant score (weight: 35%)
if (merchantSimilarity > 0) {
if (merchantSimilarity >= 0.9) {
matchReasons.push('Handlare matchar')
} else if (merchantSimilarity >= 0.6) {
matchReasons.push('Trolig handlarmatch')
}
weightedScore += merchantSimilarity * 0.35
totalWeight += 0.35
}
// Bonus for MCC match (if transaction has MCC and receipt is flagged)
// Bonus for MCC match (receipt-specific: restaurant MCC codes)
if (receipt.is_restaurant && transaction.mcc_code) {
const restaurantMCCs = [5812, 5813, 5814]
if (restaurantMCCs.includes(transaction.mcc_code)) {
matchReasons.push('Restaurang MCC matchar')
weightedScore += 0.1
result.matchReasons.push('Restaurang MCC matchar')
result.confidence = Math.round((result.confidence + 0.1) * 100) / 100
}
}
const confidence = totalWeight > 0 ? weightedScore / totalWeight : 0
return {
confidence: Math.round(confidence * 100) / 100,
matchReasons,
}
return result
}
/**
@@ -0,0 +1,164 @@
/**
* Receipt Pipeline — shared, reusable receipt processing function.
*
* SERVER-ONLY: uses receipt-analyzer (Anthropic SDK).
*
* Extracts receipt data, categorizes line items, inserts records,
* and attempts auto-matching against bank transactions.
*/
import 'server-only'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Receipt, ReceiptMatchCandidate } from '@/types'
import { analyzeReceipt } from './receipt-analyzer'
import { processLineItems } from './receipt-categorizer'
import { autoMatchReceipts } from './receipt-matcher'
import { eventBus } from '@/lib/events/bus'
export interface ReceiptPipelineOptions {
documentId: string | null
source: 'upload' | 'camera' | 'email'
emailFrom?: string
storageUrl: string
}
export interface ProcessedReceipt {
receipt: Receipt
lineItems: unknown[]
matchedTransaction?: { transactionId: string; confidence: number }
}
/**
* Process a document through the receipt pipeline:
* 1. Analyze with Claude Vision
* 2. Categorize line items
* 3. Insert receipt + line items
* 4. Auto-match against unmatched transactions
* 5. Emit receipt.extracted event
*/
export async function processReceiptFromDocument(
supabase: SupabaseClient,
userId: string,
base64: string,
mimeType: string,
opts: ReceiptPipelineOptions
): Promise<ProcessedReceipt> {
// 1. Analyze receipt with Claude Vision
const validImageType = mimeType as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
const extraction = await analyzeReceipt(base64, validImageType)
// 2. Categorize line items
const processedLineItems = processLineItems(extraction.lineItems)
// 3. Insert receipt record
const { data: receipt, error: insertError } = await supabase
.from('receipts')
.insert({
user_id: userId,
image_url: opts.storageUrl,
status: 'extracted',
extraction_confidence: extraction.confidence,
merchant_name: extraction.merchant.name,
merchant_org_number: extraction.merchant.orgNumber,
merchant_vat_number: extraction.merchant.vatNumber,
receipt_date: extraction.receipt.date,
receipt_time: extraction.receipt.time,
total_amount: extraction.totals.total,
currency: extraction.receipt.currency,
vat_amount: extraction.totals.vatAmount,
is_restaurant: extraction.flags.isRestaurant,
is_systembolaget: extraction.flags.isSystembolaget,
is_foreign_merchant: extraction.flags.isForeignMerchant,
raw_extraction: extraction,
document_id: opts.documentId,
source: opts.source,
email_from: opts.emailFrom ?? null,
})
.select()
.single()
if (insertError || !receipt) {
throw new Error(`Failed to create receipt: ${insertError?.message}`)
}
// 4. Insert line items
if (processedLineItems.length > 0) {
const lineItemsToInsert = processedLineItems.map((item, index) => ({
receipt_id: receipt.id,
description: item.description,
quantity: item.quantity,
unit_price: item.unitPrice,
line_total: item.lineTotal,
vat_rate: item.vatRate,
vat_amount:
item.vatRate && item.lineTotal
? Math.round((item.lineTotal * item.vatRate) / (100 + item.vatRate) * 100) / 100
: null,
extraction_confidence: item.confidence,
suggested_category: item.suggestedCategory,
category: item.category,
bas_account: item.basAccount,
sort_order: index,
}))
await supabase.from('receipt_line_items').insert(lineItemsToInsert)
}
// 5. Auto-match against unmatched expense transactions (±7 days from receipt date)
let matchedTransaction: ProcessedReceipt['matchedTransaction'] | undefined
if (extraction.receipt.date && extraction.totals.total) {
const receiptDate = new Date(extraction.receipt.date)
const dateFrom = new Date(receiptDate)
dateFrom.setDate(dateFrom.getDate() - 7)
const dateTo = new Date(receiptDate)
dateTo.setDate(dateTo.getDate() + 7)
const { data: transactions } = await supabase
.from('transactions')
.select('*')
.eq('user_id', userId)
.is('receipt_id', null)
.lt('amount', 0)
.gte('date', dateFrom.toISOString().split('T')[0])
.lte('date', dateTo.toISOString().split('T')[0])
if (transactions && transactions.length > 0) {
const matches = autoMatchReceipts([receipt], transactions, 0.8)
if (matches.length > 0) {
const best = matches[0]
matchedTransaction = {
transactionId: best.match.transaction.id,
confidence: best.match.confidence,
}
// Link receipt to transaction
await supabase
.from('receipts')
.update({
matched_transaction_id: best.match.transaction.id,
match_confidence: best.match.confidence,
})
.eq('id', receipt.id)
await supabase
.from('transactions')
.update({ receipt_id: receipt.id })
.eq('id', best.match.transaction.id)
}
}
}
// 6. Emit event
await eventBus.emit({
type: 'receipt.extracted',
payload: {
receipt,
documentId: opts.documentId,
confidence: extraction.confidence,
userId,
},
})
return { receipt, lineItems: processedLineItems, matchedTransaction }
}
+1
View File
@@ -311,6 +311,7 @@ export const CategorizeTransactionSchema = z.object({
vat_treatment: VatTreatmentSchema.optional(),
account_override: accountNumber.optional(),
user_description: z.string().max(500).optional(),
inbox_item_id: z.string().uuid().optional(),
})
export const BookTransactionSchema = z.object({
@@ -0,0 +1,269 @@
import { describe, it, expect } from 'vitest'
import {
BAS_REFERENCE,
ACCOUNT_CLASS_LABELS,
ACCOUNT_GROUP_LABELS,
getBASReference,
getBASReferenceByClass,
isStandardBASAccount,
} from '../bas-reference'
describe('BAS_REFERENCE data integrity', () => {
it('contains the expected number of accounts (~1,276)', () => {
expect(BAS_REFERENCE.length).toBeGreaterThanOrEqual(1250)
expect(BAS_REFERENCE.length).toBeLessThanOrEqual(1300)
})
it('has no duplicate account numbers', () => {
const numbers = BAS_REFERENCE.map((a) => a.account_number)
const uniqueNumbers = new Set(numbers)
expect(uniqueNumbers.size).toBe(numbers.length)
})
it('account_class matches the first digit of account_number', () => {
for (const account of BAS_REFERENCE) {
const firstDigit = parseInt(account.account_number[0], 10)
expect(account.account_class).toBe(firstDigit)
}
})
it('account_group matches the first two digits of account_number', () => {
for (const account of BAS_REFERENCE) {
const firstTwo = account.account_number.substring(0, 2)
expect(account.account_group).toBe(firstTwo)
}
})
it('every account has a non-null sru_code', () => {
const withoutSru = BAS_REFERENCE.filter((a) => a.sru_code === null)
expect(withoutSru).toEqual([])
})
it('every account has a non-empty description', () => {
const withoutDesc = BAS_REFERENCE.filter((a) => !a.description || a.description.trim() === '')
expect(withoutDesc).toEqual([])
})
it('every account has a valid account_type', () => {
const validTypes = ['asset', 'liability', 'equity', 'revenue', 'expense', 'untaxed_reserves']
for (const account of BAS_REFERENCE) {
expect(validTypes).toContain(account.account_type)
}
})
it('every account has a valid normal_balance', () => {
for (const account of BAS_REFERENCE) {
expect(['debit', 'credit']).toContain(account.normal_balance)
}
})
it('all account numbers are 4 digits', () => {
for (const account of BAS_REFERENCE) {
expect(account.account_number).toMatch(/^\d{4}$/)
}
})
})
describe('Non-standard accounts removed', () => {
const nonStandard = ['1249', '1259', '1400', '1580', '3109', '4100', '4990', '7834', '7835', '7910', '8710']
for (const num of nonStandard) {
it(`${num} is not in the catalog`, () => {
expect(isStandardBASAccount(num)).toBe(false)
})
}
})
describe('Class 2 account_type correctness', () => {
it('20xx accounts are equity', () => {
const group20 = BAS_REFERENCE.filter((a) => a.account_group === '20')
expect(group20.length).toBeGreaterThan(0)
for (const a of group20) {
expect(a.account_type).toBe('equity')
}
})
it('21xx accounts are untaxed_reserves', () => {
const group21 = BAS_REFERENCE.filter((a) => a.account_group === '21')
expect(group21.length).toBeGreaterThan(0)
for (const a of group21) {
expect(a.account_type).toBe('untaxed_reserves')
}
})
it('22xx-29xx accounts are liability', () => {
const liabilityGroups = BAS_REFERENCE.filter(
(a) => a.account_class === 2 && parseInt(a.account_group) >= 22
)
expect(liabilityGroups.length).toBeGreaterThan(0)
for (const a of liabilityGroups) {
expect(a.account_type).toBe('liability')
}
})
})
describe('Class 8 normal_balance correctness', () => {
it('8310 (Ränteintäkter) has credit normal_balance', () => {
const account = getBASReference('8310')
expect(account).toBeDefined()
expect(account!.normal_balance).toBe('credit')
})
it('8410 (Räntekostnader) has debit normal_balance', () => {
const account = getBASReference('8410')
expect(account).toBeDefined()
expect(account!.normal_balance).toBe('debit')
})
it('8910 (Skatt) has debit normal_balance', () => {
const account = getBASReference('8910')
expect(account).toBeDefined()
expect(account!.normal_balance).toBe('debit')
})
it('8810 (Bokslutsdispositioner) has credit normal_balance', () => {
const account = getBASReference('8810')
expect(account).toBeDefined()
expect(account!.normal_balance).toBe('credit')
})
})
describe('Contra accounts have opposite normal_balance', () => {
it('1119 (Ack. avskrivningar byggnader) has credit balance', () => {
const account = getBASReference('1119')
expect(account).toBeDefined()
expect(account!.normal_balance).toBe('credit')
})
it('1229 (Ack. avskrivningar inventarier) has credit balance', () => {
const account = getBASReference('1229')
expect(account).toBeDefined()
expect(account!.normal_balance).toBe('credit')
})
it('2011 (Egna varuuttag) has debit balance', () => {
const account = getBASReference('2011')
expect(account).toBeDefined()
expect(account!.normal_balance).toBe('debit')
})
it('3740 (Öres- och kronutjämning) has debit balance', () => {
const account = getBASReference('3740')
expect(account).toBeDefined()
expect(account!.normal_balance).toBe('debit')
})
})
describe('K2-excluded accounts', () => {
const k2Excluded = [
'1010', '1011', '1012', '1018', '1019',
'1370', '1518',
'2092', '2096', '2240', '2448',
'3940', '7940',
'8290', '8291', '8295',
'8320', '8321', '8325',
'8450', '8451', '8455',
'8480', '8940',
]
it('known K2-excluded accounts are marked correctly', () => {
for (const num of k2Excluded) {
const account = getBASReference(num)
expect(account).toBeDefined()
expect(account!.k2_excluded).toBe(true)
}
})
it('common non-K2-excluded accounts are not marked', () => {
const normalAccounts = ['1510', '1930', '2440', '3001', '4010', '5010', '7010', '8310']
for (const num of normalAccounts) {
const account = getBASReference(num)
expect(account).toBeDefined()
expect(account!.k2_excluded).toBe(false)
}
})
it('total K2-excluded count matches expected (24)', () => {
const k2Count = BAS_REFERENCE.filter((a) => a.k2_excluded).length
expect(k2Count).toBe(24)
})
})
describe('ACCOUNT_GROUP_LABELS coverage', () => {
it('all groups present in BAS_REFERENCE have labels', () => {
const groups = new Set(BAS_REFERENCE.map((a) => a.account_group))
for (const group of groups) {
expect(ACCOUNT_GROUP_LABELS[group]).toBeDefined()
}
})
it('covers at least 70 groups', () => {
expect(Object.keys(ACCOUNT_GROUP_LABELS).length).toBeGreaterThanOrEqual(70)
})
})
describe('ACCOUNT_CLASS_LABELS', () => {
it('has labels for all 8 classes', () => {
for (let i = 1; i <= 8; i++) {
expect(ACCOUNT_CLASS_LABELS[i]).toBeDefined()
}
})
})
describe('Helper functions', () => {
it('getBASReference returns correct account', () => {
const account = getBASReference('1930')
expect(account).toBeDefined()
expect(account!.account_name).toBe('Företagskonto')
expect(account!.account_type).toBe('asset')
})
it('getBASReference returns undefined for non-existent account', () => {
expect(getBASReference('9999')).toBeUndefined()
})
it('getBASReferenceByClass returns accounts for each class', () => {
for (let cls = 1; cls <= 8; cls++) {
const accounts = getBASReferenceByClass(cls)
expect(accounts.length).toBeGreaterThan(0)
for (const a of accounts) {
expect(a.account_class).toBe(cls)
}
}
})
it('getBASReferenceByClass returns empty array for non-existent class', () => {
expect(getBASReferenceByClass(9)).toEqual([])
})
it('isStandardBASAccount returns true for standard accounts', () => {
expect(isStandardBASAccount('1510')).toBe(true)
expect(isStandardBASAccount('3001')).toBe(true)
expect(isStandardBASAccount('8999')).toBe(true)
})
it('isStandardBASAccount returns false for non-standard accounts', () => {
expect(isStandardBASAccount('9999')).toBe(false)
expect(isStandardBASAccount('0000')).toBe(false)
})
})
describe('Account class distribution', () => {
it('class 1 (assets) has ~230 accounts', () => {
const cls = getBASReferenceByClass(1)
expect(cls.length).toBeGreaterThanOrEqual(220)
expect(cls.length).toBeLessThanOrEqual(240)
})
it('class 2 (equity & liabilities) has ~265 accounts', () => {
const cls = getBASReferenceByClass(2)
expect(cls.length).toBeGreaterThanOrEqual(255)
expect(cls.length).toBeLessThanOrEqual(275)
})
it('class 3 (revenue) has ~100 accounts', () => {
const cls = getBASReferenceByClass(3)
expect(cls.length).toBeGreaterThanOrEqual(90)
expect(cls.length).toBeLessThanOrEqual(110)
})
})
+1 -1
View File
@@ -1,4 +1,4 @@
export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense'
export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense' | 'untaxed_reserves'
export interface AccountDescription {
name: string
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,917 @@
import type { BASReferenceAccount } from '../bas-reference'
export const CLASS_4_ACCOUNTS: BASReferenceAccount[] = [
{
account_number: '4000',
account_name: 'Inköp av handelsvaror (gruppkonto)',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av handelsvaror (gruppkonto)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4010',
account_name: 'Inköp av handelsvaror i Sverige',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader for inkop av varor avsedda for vidareforssaljning.',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4060',
account_name: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4065',
account_name: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 25 % moms',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 25 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4066',
account_name: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 12 % moms',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 12 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4067',
account_name: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 6 % moms',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av handelsvaror i Sverige, omvänd betalningsskyldighet, 6 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4070',
account_name: 'Inköp av handelsvaror från annat EU-land',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av handelsvaror från annat EU-land',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4075',
account_name: 'Inköp av handelsvaror från annat EUland, 25 % moms',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av handelsvaror från annat EUland, 25 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4076',
account_name: 'Inköp av handelsvaror från annat EUland, 12 % moms',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av handelsvaror från annat EUland, 12 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4077',
account_name: 'Inköp av handelsvaror från annat EUland, 6 % moms',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av handelsvaror från annat EUland, 6 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4078',
account_name: 'Inköp av handelsvaror från annat EUland, momsfri',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av handelsvaror från annat EUland, momsfri',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4080',
account_name: 'Import av handelsvaror',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'debit',
description: 'Import av handelsvaror',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4085',
account_name: 'Import av handelsvaror, 25 % moms',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'debit',
description: 'Import av handelsvaror, 25 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4086',
account_name: 'Import av handelsvaror, 12 % moms',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'debit',
description: 'Import av handelsvaror, 12 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4087',
account_name: 'Import av handelsvaror, 6 % moms',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'debit',
description: 'Import av handelsvaror, 6 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4090',
account_name: 'Erhållna rabatter (Handelsvaror)',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'credit',
description: 'Erhållna rabatter (Handelsvaror)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4091',
account_name: 'Erhållna kassarabatter (Handelsvaror)',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'credit',
description: 'Erhållna kassarabatter (Handelsvaror)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4092',
account_name: 'Erhållna mängdrabatter (inkl. bonus) (Handelsvaror)',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'credit',
description: 'Erhållna mängdrabatter (inkl. bonus) (Handelsvaror)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4099',
account_name: 'Övriga reduktioner av inköpspriser (Handelsvaror) 42 SÅLDA HANDELSVAROR VMB',
account_class: 4,
account_group: '40',
account_type: 'expense',
normal_balance: 'credit',
description: 'Övriga reduktioner av inköpspriser (Handelsvaror) 42 SÅLDA HANDELSVAROR VMB',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4200',
account_name: 'Sålda handelsvaror VMB (gruppkonto)',
account_class: 4,
account_group: '42',
account_type: 'expense',
normal_balance: 'debit',
description: 'Sålda handelsvaror VMB (gruppkonto)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4210',
account_name: 'Sålda handelsvaror VMB',
account_class: 4,
account_group: '42',
account_type: 'expense',
normal_balance: 'debit',
description: 'Sålda handelsvaror VMB',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4211',
account_name: 'Sålda handelsvaror positiv VMB 25 %',
account_class: 4,
account_group: '42',
account_type: 'expense',
normal_balance: 'debit',
description: 'Sålda handelsvaror positiv VMB 25 %',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4212',
account_name: 'Sålda handelsvaror negativ VMB 25 % 43 INKÖP AV RÅVAROR OCH MATERIAL I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)',
account_class: 4,
account_group: '42',
account_type: 'expense',
normal_balance: 'debit',
description: 'Sålda handelsvaror negativ VMB 25 % 43 INKÖP AV RÅVAROR OCH MATERIAL I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4300',
account_name: 'Inköp av råvaror och material i Sverige (gruppkonto)',
account_class: 4,
account_group: '43',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av råvaror och material i Sverige (gruppkonto)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4310',
account_name: 'Inköp av råvaror och material i Sverige 44 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. I SVERIGE, OMVÄND BETALNINGSSKYLDIGHET (RÅVAROR OCH FÖRNÖDENHETER)',
account_class: 4,
account_group: '43',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av råvaror och material i Sverige 44 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. I SVERIGE, OMVÄND BETALNINGSSKYLDIGHET (RÅVAROR OCH FÖRNÖDENHETER)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4400',
account_name: 'Inköp av råvaror och material, tjänster m.m. i Sverige, omvänd betalningsskyldighet (gruppkonto)',
account_class: 4,
account_group: '44',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av råvaror och material, tjänster m.m. i Sverige, omvänd betalningsskyldighet (gruppkonto)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4410',
account_name: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet',
account_class: 4,
account_group: '44',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4415',
account_name: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 25 % moms',
account_class: 4,
account_group: '44',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 25 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4416',
account_name: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 12 % moms',
account_class: 4,
account_group: '44',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 12 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4417',
account_name: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 6 % moms',
account_class: 4,
account_group: '44',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av råvaror och material i Sverige, omvänd betalningsskyldighet, 6 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4420',
account_name: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet',
account_class: 4,
account_group: '44',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4425',
account_name: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 25 % moms',
account_class: 4,
account_group: '44',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 25 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4426',
account_name: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 12 % moms',
account_class: 4,
account_group: '44',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 12 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4427',
account_name: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 6 % moms 45 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. FRÅN UTLANDET (RÅVAROR OCH FÖRNÖDENHETER)',
account_class: 4,
account_group: '44',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av tjänster i Sverige, omvänd betalningsskyldighet, 6 % moms 45 INKÖP AV RÅVAROR OCH MATERIAL, TJÄNSTER M.M. FRÅN UTLANDET (RÅVAROR OCH FÖRNÖDENHETER)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4500',
account_name: 'Inköp av råvaror och material, tjänster m.m. från utlandet (gruppkonto)',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Varuinkop fran utlandet (ravaror och fornodenheter).',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4510',
account_name: 'Inköp av råvaror och material från annat EU-land',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av råvaror och material från annat EU-land',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4515',
account_name: 'Inköp av råvaror och material från annat EU-land, 25 %',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av råvaror och material från annat EU-land, 25 %',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4516',
account_name: 'Inköp av råvaror och material från annat EU-land, 12 %',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av råvaror och material från annat EU-land, 12 %',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4517',
account_name: 'Inköp av råvaror och material från annat EU-land, 6 %',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av råvaror och material från annat EU-land, 6 %',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4518',
account_name: 'Inköp av råvaror och material från annat EU-land, momsfri',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av råvaror och material från annat EU-land, momsfri',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4530',
account_name: 'Inköp av tjänster m.m. från utlandet',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av tjänster m.m. från utlandet',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4531',
account_name: 'Inköp av tjänster från ett land utanför EU, 25 % moms',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av tjänster från ett land utanför EU, 25 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4532',
account_name: 'Inköp av tjänster från ett land utanför EU, 12 % moms',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av tjänster från ett land utanför EU, 12 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4533',
account_name: 'Inköp av tjänster från ett land utanför EU, 6 % moms',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av tjänster från ett land utanför EU, 6 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4535',
account_name: 'Inköp av tjänster från annat EU-land, 25 %',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av tjänster från annat EU-land, 25 %',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4536',
account_name: 'Inköp av tjänster från annat EU-land, 12 %',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av tjänster från annat EU-land, 12 %',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4537',
account_name: 'Inköp av tjänster från annat EU-land, 6 %',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av tjänster från annat EU-land, 6 %',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4538',
account_name: 'Inköp av tjänster från annat EU-land, momsfri',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av tjänster från annat EU-land, momsfri',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4540',
account_name: 'Import av råvaror och material',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Import av råvaror och material',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4545',
account_name: 'Import av råvaror och material, 25 % moms',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Import av råvaror och material, 25 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4546',
account_name: 'Import av råvaror och material, 12 % moms',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Import av råvaror och material, 12 % moms',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4547',
account_name: 'Import av råvaror och material, 6 % moms 46 INKÖP AV TJÄNSTER, UNDERENTREPRENADER OCH LEGOARBETEN I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)',
account_class: 4,
account_group: '45',
account_type: 'expense',
normal_balance: 'debit',
description: 'Import av råvaror och material, 6 % moms 46 INKÖP AV TJÄNSTER, UNDERENTREPRENADER OCH LEGOARBETEN I SVERIGE (RÅVAROR OCH FÖRNÖDENHETER)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4600',
account_name: 'Inköp av tjänster, underentreprenader och legoarbeten i Sverige (gruppkonto)',
account_class: 4,
account_group: '46',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader for arbete utfort av underleverantorer som del av leverans till kund.',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4610',
account_name: 'Inköp av tjänster och underentreprenader',
account_class: 4,
account_group: '46',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av tjänster och underentreprenader',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4670',
account_name: 'Inköp av legoarbeten 47 REDUKTION AV INKÖPSPRISER (RÅVAROR OCH FÖRNÖDENHETER)',
account_class: 4,
account_group: '46',
account_type: 'expense',
normal_balance: 'debit',
description: 'Inköp av legoarbeten 47 REDUKTION AV INKÖPSPRISER (RÅVAROR OCH FÖRNÖDENHETER)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4700',
account_name: 'Reduktion av inköpspriser (gruppkonto)',
account_class: 4,
account_group: '47',
account_type: 'expense',
normal_balance: 'credit',
description: 'Reduktion av inköpspriser (gruppkonto)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4730',
account_name: 'Erhållna rabatter (Råvaror och förnödenheter)',
account_class: 4,
account_group: '47',
account_type: 'expense',
normal_balance: 'credit',
description: 'Erhållna rabatter (Råvaror och förnödenheter)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4731',
account_name: 'Erhållna kassarabatter (Råvaror och förnödenheter)',
account_class: 4,
account_group: '47',
account_type: 'expense',
normal_balance: 'credit',
description: 'Erhållna kassarabatter (Råvaror och förnödenheter)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4732',
account_name: 'Erhållna mängdrabatter (inkl. bonus) (Råvaror och förnödenheter)',
account_class: 4,
account_group: '47',
account_type: 'expense',
normal_balance: 'credit',
description: 'Erhållna mängdrabatter (inkl. bonus) (Råvaror och förnödenheter)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4739',
account_name: 'Övriga reduktioner av inköpspriser (Råvaror och förnödenheter) 48 ANDRA PRODUKTIONSKOSTNADER (RÅVAROR OCH FÖRNÖDENHETER)',
account_class: 4,
account_group: '47',
account_type: 'expense',
normal_balance: 'credit',
description: 'Övriga reduktioner av inköpspriser (Råvaror och förnödenheter) 48 ANDRA PRODUKTIONSKOSTNADER (RÅVAROR OCH FÖRNÖDENHETER)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4800',
account_name: 'Andra produktionskostnader (gruppkonto)',
account_class: 4,
account_group: '48',
account_type: 'expense',
normal_balance: 'debit',
description: 'Andra produktionskostnader (gruppkonto)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4810',
account_name: 'Kostnader för energi (Råvaror och förnödenheter)',
account_class: 4,
account_group: '48',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader för energi (Råvaror och förnödenheter)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4820',
account_name: 'Kostnader för drivmedel (Råvaror och förnödenheter)',
account_class: 4,
account_group: '48',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader för drivmedel (Råvaror och förnödenheter)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4830',
account_name: 'Kostnader för resor (Råvaror och förnödenheter)',
account_class: 4,
account_group: '48',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader för resor (Råvaror och förnödenheter)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4840',
account_name: 'Kostnader för hyra av utrustning (Råvaror och förnödenheter)',
account_class: 4,
account_group: '48',
account_type: 'expense',
normal_balance: 'debit',
description: 'Kostnader för hyra av utrustning (Råvaror och förnödenheter)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4890',
account_name: 'Övriga produktionskostnader (Råvaror och förnödenheter) 49 FÖRÄNDRING AV LAGER, PRODUKTER I ARBETE OCH PÅGÅENDE ARBETEN',
account_class: 4,
account_group: '48',
account_type: 'expense',
normal_balance: 'debit',
description: 'Övriga produktionskostnader (Råvaror och förnödenheter) 49 FÖRÄNDRING AV LAGER, PRODUKTER I ARBETE OCH PÅGÅENDE ARBETEN',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4900',
account_name: 'Förändring av lager (gruppkonto)',
account_class: 4,
account_group: '49',
account_type: 'expense',
normal_balance: 'credit',
description: 'Förändring av lager (gruppkonto)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4910',
account_name: 'Förändring av lager av råvaror',
account_class: 4,
account_group: '49',
account_type: 'expense',
normal_balance: 'credit',
description: 'Förändring av lager av råvaror',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4920',
account_name: 'Förändring av lager av tillsatsmaterial och förnödenheter',
account_class: 4,
account_group: '49',
account_type: 'expense',
normal_balance: 'credit',
description: 'Förändring av lager av tillsatsmaterial och förnödenheter',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4940',
account_name: 'Förändring av produkter i arbete',
account_class: 4,
account_group: '49',
account_type: 'expense',
normal_balance: 'credit',
description: 'Förändring av produkter i arbete',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4944',
account_name: 'Förändring av produkter i arbete, material och utlägg',
account_class: 4,
account_group: '49',
account_type: 'expense',
normal_balance: 'credit',
description: 'Förändring av produkter i arbete, material och utlägg',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4945',
account_name: 'Förändring av produkter i arbete, omkostnader',
account_class: 4,
account_group: '49',
account_type: 'expense',
normal_balance: 'credit',
description: 'Förändring av produkter i arbete, omkostnader',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4947',
account_name: 'Förändring av produkter i arbete, personalkostnader',
account_class: 4,
account_group: '49',
account_type: 'expense',
normal_balance: 'credit',
description: 'Förändring av produkter i arbete, personalkostnader',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4950',
account_name: 'Förändring av lager av färdiga varor',
account_class: 4,
account_group: '49',
account_type: 'expense',
normal_balance: 'credit',
description: 'Förändring av lager av färdiga varor',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4960',
account_name: 'Förändring av lager av handelsvaror',
account_class: 4,
account_group: '49',
account_type: 'expense',
normal_balance: 'credit',
description: 'Förändring av lager av handelsvaror',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4970',
account_name: 'Förändring av pågående arbeten, nedlagda kostnader',
account_class: 4,
account_group: '49',
account_type: 'expense',
normal_balance: 'credit',
description: 'Förändring av pågående arbeten, nedlagda kostnader',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4974',
account_name: 'Förändring av pågående arbeten, material och utlägg',
account_class: 4,
account_group: '49',
account_type: 'expense',
normal_balance: 'credit',
description: 'Förändring av pågående arbeten, material och utlägg',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4975',
account_name: 'Förändring av pågående arbeten, omkostnader',
account_class: 4,
account_group: '49',
account_type: 'expense',
normal_balance: 'credit',
description: 'Förändring av pågående arbeten, omkostnader',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4977',
account_name: 'Förändring av pågående arbeten, personalkostnader',
account_class: 4,
account_group: '49',
account_type: 'expense',
normal_balance: 'credit',
description: 'Förändring av pågående arbeten, personalkostnader',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4980',
account_name: 'Förändring av lager av värdepapper (Handelsvaror)',
account_class: 4,
account_group: '49',
account_type: 'expense',
normal_balance: 'credit',
description: 'Förändring av lager av värdepapper (Handelsvaror)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4981',
account_name: 'Sålda värdepappers anskaffningsvärde (Handelsvaror)',
account_class: 4,
account_group: '49',
account_type: 'expense',
normal_balance: 'credit',
description: 'Sålda värdepappers anskaffningsvärde (Handelsvaror)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4987',
account_name: 'Nedskrivning av värdepapper (Handelsvaror)',
account_class: 4,
account_group: '49',
account_type: 'expense',
normal_balance: 'credit',
description: 'Nedskrivning av värdepapper (Handelsvaror)',
sru_code: '7320',
k2_excluded: false,
},
{
account_number: '4988',
account_name: 'Återföring av nedskrivning av värdepapper (Handelsvaror) 50 LOKALKOSTNADER',
account_class: 4,
account_group: '49',
account_type: 'expense',
normal_balance: 'credit',
description: 'Återföring av nedskrivning av värdepapper (Handelsvaror) 50 LOKALKOSTNADER',
sru_code: '7320',
k2_excluded: false,
},
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
import { CLASS_1_ACCOUNTS } from './class-1-assets'
import { CLASS_2_ACCOUNTS } from './class-2-equity-liabilities'
import { CLASS_3_ACCOUNTS } from './class-3-revenue'
import { CLASS_4_ACCOUNTS } from './class-4-purchases'
import { CLASS_5_ACCOUNTS } from './class-5-external-expenses'
import { CLASS_6_ACCOUNTS } from './class-6-other-external'
import { CLASS_7_ACCOUNTS } from './class-7-personnel'
import { CLASS_8_ACCOUNTS } from './class-8-financial'
import type { BASReferenceAccount } from '../bas-reference'
export const BAS_REFERENCE: BASReferenceAccount[] = [
...CLASS_1_ACCOUNTS,
...CLASS_2_ACCOUNTS,
...CLASS_3_ACCOUNTS,
...CLASS_4_ACCOUNTS,
...CLASS_5_ACCOUNTS,
...CLASS_6_ACCOUNTS,
...CLASS_7_ACCOUNTS,
...CLASS_8_ACCOUNTS,
]
+108
View File
@@ -0,0 +1,108 @@
/**
* SRU Code Computation
*
* Replicates the range-based SRU code assignment logic from
* supabase/migrations/20240101000021_sru_codes.sql.
*
* SRU codes are used for NE (enskild firma) and INK2 (aktiebolag) tax forms
* filed with Skatteverket.
*/
/**
* Compute the SRU code for a given BAS account number.
*
* The logic applies NE-form codes first (higher priority for revenue/expense
* accounts), then falls back to INK2 balance sheet codes.
*/
export function computeSRUCode(accountNumber: string): string | null {
const num = accountNumber
// ---------------------------------------------------------------------------
// NE form codes (enskild firma) — fields 7310-7325
// ---------------------------------------------------------------------------
// NE: R1 - Försäljning med moms (3000-3499 excl 3100)
if (num >= '3000' && num <= '3499' && num !== '3100') return '7310'
// NE: R2 - Momsfria intäkter (3100, 3900, 3970-3980)
if (num === '3100' || num === '3900' || (num >= '3970' && num <= '3980')) return '7311'
// NE: R3 - Bil/bostadsförmån (3200-3299) — overlaps with R1, R1 wins
if (num >= '3200' && num <= '3299') return '7312'
// NE: R4 - Ränteintäkter (8310-8330)
if (num >= '8310' && num <= '8330') return '7313'
// NE: R5 - Varuinköp (4000-4990)
if (num >= '4000' && num <= '4990') return '7320'
// NE: R6 - Övriga kostnader (5000-6990, 7970)
if ((num >= '5000' && num <= '6990') || num === '7970') return '7321'
// NE: R7 - Lönekostnader (7000-7699)
if (num >= '7000' && num <= '7699') return '7322'
// NE: R8 - Räntekostnader (8400-8499)
if (num >= '8400' && num <= '8499') return '7323'
// NE: R9 - Avskrivningar fastighet (7820)
if (num === '7820') return '7324'
// NE: R10 - Avskrivningar övrigt (7700-7899 excl 7820)
if (num >= '7700' && num <= '7899' && num !== '7820') return '7325'
// ---------------------------------------------------------------------------
// INK2 form codes (aktiebolag) — fields 7201-7380
// ---------------------------------------------------------------------------
// INK2: Immateriella anläggningstillgångar (1000-1099)
if (num >= '1000' && num <= '1099') return '7201'
// INK2: Materiella anläggningstillgångar (1100-1299)
if (num >= '1100' && num <= '1299') return '7202'
// INK2: Finansiella anläggningstillgångar (1300-1399)
if (num >= '1300' && num <= '1399') return '7203'
// INK2: Varulager (1400-1499)
if (num >= '1400' && num <= '1499') return '7210'
// INK2: Kundfordringar (1500-1599)
if (num >= '1500' && num <= '1599') return '7211'
// INK2: Övriga omsättningstillgångar (1600-1999)
if (num >= '1600' && num <= '1999') return '7212'
// INK2: Aktiekapital (2081)
if (num === '2081') return '7220'
// INK2: Övrigt eget kapital (2085-2098)
if (num >= '2085' && num <= '2098') return '7221'
// INK2: Årets resultat (2099)
if (num === '2099') return '7222'
// INK2: Skulder (2100-2499)
if (num >= '2100' && num <= '2499') return '7230'
// INK2: Övriga skulder (2500-2999)
if (num >= '2500' && num <= '2999') return '7231'
// ---------------------------------------------------------------------------
// INK2 remaining income statement (fallback for class 3-8 not covered by NE)
// ---------------------------------------------------------------------------
if (num >= '3000' && num <= '3999') return '7310'
if (num >= '4000' && num <= '4999') return '7320'
if (num >= '5000' && num <= '6999') return '7330'
if (num >= '7000' && num <= '7699') return '7340'
if (num >= '7700' && num <= '7899') return '7350'
if (num >= '7900' && num <= '7999') return '7360'
if (num >= '8000' && num <= '8499') return '7370'
if (num >= '8500' && num <= '8999') return '7380'
// Equity accounts not covered above (2000-2084)
if (num >= '2000' && num <= '2084') return '7221'
return null
}
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
/**
* Template Prompt Builder
*
* Generates the booking template list section for AI extraction prompts.
* Used by both receipt-analyzer and invoice-analyzer to stay in sync.
*/
import { BOOKING_TEMPLATES } from './booking-templates'
/**
* Build the template list section for AI prompts.
* Lists all expense templates with their Swedish name, primary debit account, and VAT rate.
*/
export function buildTemplatePromptSection(): string {
const expenseTemplates = BOOKING_TEMPLATES.filter((t) => t.direction === 'expense')
const lines = expenseTemplates.map((t) => {
const vatInfo = t.vat_rate > 0 ? `moms ${t.vat_rate * 100}%` : 'momsfri'
return `- ${t.id}: ${t.name_sv} (konto ${t.debit_account}, ${vatInfo})`
})
return `BOKFÖRINGSMALLAR (välj den mest passande suggestedTemplateId):
${lines.join('\n')}`
}
/**
* Build a compact template ID list for validation.
*/
export function getValidTemplateIds(): string[] {
return BOOKING_TEMPLATES.map((t) => t.id)
}
+238
View File
@@ -0,0 +1,238 @@
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 { classifyDocument } from '../classifier'
function makeResponse(json: Record<string, unknown>) {
return {
content: [{ type: 'text', text: JSON.stringify(json) }],
}
}
describe('classifyDocument', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('classifies a supplier invoice', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'supplier_invoice',
confidence: 0.95,
reasoning: 'Contains invoice number, bankgiro, and supplier details',
isReverseCharge: false,
})
)
const result = await classifyDocument('base64data', 'application/pdf')
expect(result.type).toBe('supplier_invoice')
expect(result.confidence).toBe(0.95)
expect(result.isReverseCharge).toBe(false)
})
it('classifies a receipt', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'receipt',
confidence: 0.92,
reasoning: 'Store receipt with line items and total',
})
)
const result = await classifyDocument('base64data', 'image/jpeg')
expect(result.type).toBe('receipt')
expect(result.confidence).toBe(0.92)
expect(result.isReverseCharge).toBeUndefined()
})
it('classifies a government letter', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'government_letter',
confidence: 0.88,
reasoning: 'Letter from Skatteverket',
})
)
const result = await classifyDocument('base64data', 'application/pdf')
expect(result.type).toBe('government_letter')
expect(result.confidence).toBe(0.88)
expect(result.isReverseCharge).toBeUndefined()
})
it('classifies unknown documents', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'unknown',
confidence: 0.5,
reasoning: 'Cannot determine document type',
})
)
const result = await classifyDocument('base64data', 'image/png')
expect(result.type).toBe('unknown')
expect(result.confidence).toBe(0.5)
})
it('detects reverse charge on EU invoices', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'supplier_invoice',
confidence: 0.93,
reasoning: 'EU invoice with reverse charge',
isReverseCharge: true,
})
)
const result = await classifyDocument('base64data', 'application/pdf')
expect(result.type).toBe('supplier_invoice')
expect(result.isReverseCharge).toBe(true)
})
it('retries on API error', async () => {
mockCreate
.mockRejectedValueOnce(new Error('API timeout'))
.mockResolvedValueOnce(
makeResponse({
type: 'receipt',
confidence: 0.9,
reasoning: 'Receipt',
})
)
const result = await classifyDocument('base64data', 'image/jpeg')
expect(result.type).toBe('receipt')
expect(mockCreate).toHaveBeenCalledTimes(2)
})
it('throws on JSON parse error without retry', async () => {
mockCreate.mockResolvedValueOnce({
content: [{ type: 'text', text: 'not valid json' }],
})
await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
'Failed to parse AI response'
)
expect(mockCreate).toHaveBeenCalledTimes(1)
})
it('throws on unsupported MIME type', async () => {
await expect(classifyDocument('base64data', 'text/plain')).rejects.toThrow(
'Unsupported file type: text/plain'
)
expect(mockCreate).not.toHaveBeenCalled()
})
it('falls back to unknown for invalid type values', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'invalid_type',
confidence: 0.8,
reasoning: 'Test',
})
)
const result = await classifyDocument('base64data', 'image/jpeg')
expect(result.type).toBe('unknown')
})
it('handles PDF content blocks correctly', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'supplier_invoice',
confidence: 0.95,
reasoning: 'PDF invoice',
isReverseCharge: false,
})
)
await classifyDocument('base64data', 'application/pdf')
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{
role: 'user',
content: expect.arrayContaining([
expect.objectContaining({ type: 'document' }),
]),
},
],
})
)
})
it('handles image content blocks correctly', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'receipt',
confidence: 0.9,
reasoning: 'Image receipt',
})
)
await classifyDocument('base64data', 'image/png')
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{
role: 'user',
content: expect.arrayContaining([
expect.objectContaining({ type: 'image' }),
]),
},
],
})
)
})
it('strips markdown code blocks from response', async () => {
mockCreate.mockResolvedValueOnce({
content: [
{
type: 'text',
text: '```json\n{"type":"receipt","confidence":0.9,"reasoning":"Test"}\n```',
},
],
})
const result = await classifyDocument('base64data', 'image/jpeg')
expect(result.type).toBe('receipt')
})
it('throws after max retries', async () => {
mockCreate
.mockRejectedValueOnce(new Error('API error 1'))
.mockRejectedValueOnce(new Error('API error 2'))
.mockRejectedValueOnce(new Error('API error 3'))
await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
'Document classification failed after 3 attempts'
)
expect(mockCreate).toHaveBeenCalledTimes(3)
})
})
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest'
import {
levenshteinDistance,
normalizeMerchantName,
calculateMerchantSimilarity,
calculateMatchConfidence,
} from '../core-receipt-matcher'
describe('levenshteinDistance', () => {
it('returns 0 for identical strings', () => {
expect(levenshteinDistance('abc', 'abc')).toBe(0)
})
it('returns length of other string for empty string', () => {
expect(levenshteinDistance('', 'abc')).toBe(3)
expect(levenshteinDistance('abc', '')).toBe(3)
})
it('calculates correct edit distance', () => {
expect(levenshteinDistance('kitten', 'sitting')).toBe(3)
expect(levenshteinDistance('saturday', 'sunday')).toBe(3)
})
})
describe('normalizeMerchantName', () => {
it('lowercases and trims', () => {
expect(normalizeMerchantName(' ICA MAXI ')).toBe('ica maxi')
})
it('removes Swedish company suffixes', () => {
expect(normalizeMerchantName('Telia AB')).toBe('telia')
})
it('removes special characters but keeps Swedish letters', () => {
expect(normalizeMerchantName('Café Överkås!')).toBe('café överkås')
})
it('collapses whitespace', () => {
expect(normalizeMerchantName('ica maxi stockholm')).toBe('ica maxi stockholm')
})
})
describe('calculateMerchantSimilarity', () => {
it('returns 1 for exact match', () => {
expect(calculateMerchantSimilarity('ICA Maxi', 'ICA Maxi')).toBe(1)
})
it('returns 1 for match after normalization', () => {
expect(calculateMerchantSimilarity('Telia AB', 'telia')).toBe(1)
})
it('returns 0.9 when one contains the other', () => {
expect(calculateMerchantSimilarity('ICA', 'ICA MAXI STOCKHOLM')).toBe(0.9)
})
it('returns 0 for empty strings', () => {
expect(calculateMerchantSimilarity('', 'abc')).toBe(0)
expect(calculateMerchantSimilarity('abc', '')).toBe(0)
})
it('returns score between 0 and 1 for partial matches', () => {
const score = calculateMerchantSimilarity('ICA Maxi', 'Coop Forum')
expect(score).toBeGreaterThanOrEqual(0)
expect(score).toBeLessThanOrEqual(1)
})
it('gives high score for word overlap', () => {
const score = calculateMerchantSimilarity('ICA Maxi Stockholm', 'ICA Maxi Solna')
expect(score).toBeGreaterThan(0.7)
})
})
describe('calculateMatchConfidence', () => {
it('gives high confidence for exact date + amount + merchant', () => {
const { confidence, matchReasons } = calculateMatchConfidence(0, 0, 1.0)
expect(confidence).toBeGreaterThan(0.9)
expect(matchReasons).toContain('Exakt datum')
expect(matchReasons).toContain('Exakt belopp')
expect(matchReasons).toContain('Handlare matchar')
})
it('gives lower confidence when date is off', () => {
const exact = calculateMatchConfidence(0, 0, 1.0)
const dateOff = calculateMatchConfidence(2, 0, 1.0)
expect(dateOff.confidence).toBeLessThan(exact.confidence)
})
it('gives lower confidence when amount is off', () => {
const exact = calculateMatchConfidence(0, 0, 1.0)
const amountOff = calculateMatchConfidence(0, 0.03, 1.0)
expect(amountOff.confidence).toBeLessThan(exact.confidence)
})
it('gives lower confidence with no merchant similarity when other signals are imperfect', () => {
// With imperfect date/amount, missing merchant signal lowers overall confidence
const withMerchant = calculateMatchConfidence(1, 0.02, 0.8)
const noMerchant = calculateMatchConfidence(1, 0.02, 0)
expect(noMerchant.confidence).toBeLessThan(withMerchant.confidence)
})
it('respects custom tolerances', () => {
// With wider tolerance, same variance should give higher score
const narrow = calculateMatchConfidence(2, 0.03, 0.5, 3, 0.05)
const wide = calculateMatchConfidence(2, 0.03, 0.5, 7, 0.10)
expect(wide.confidence).toBeGreaterThan(narrow.confidence)
})
})
@@ -0,0 +1,352 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { matchDocumentToTransactions } from '../document-matcher'
import { makeInvoiceInboxItem, makeTransaction } from '@/tests/helpers'
import type { InvoiceExtractionResult, ReceiptExtractionResult, Transaction } from '@/types'
describe('matchDocumentToTransactions', () => {
const mockSupabase = {} as never // Not used when candidateTransactions is provided
beforeEach(() => {
vi.clearAllMocks()
})
describe('supplier_invoice matching', () => {
const baseExtraction: InvoiceExtractionResult = {
supplier: {
name: 'Telia AB',
orgNumber: '556103-4249',
vatNumber: 'SE556103424901',
address: 'Stockholm',
bankgiro: '5820-5093',
plusgiro: null,
},
invoice: {
invoiceNumber: 'INV-2024-001',
invoiceDate: '2024-06-10',
dueDate: '2024-06-20',
paymentReference: '73401284756',
currency: 'SEK',
},
lineItems: [],
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
vatBreakdown: [],
confidence: 0.95,
}
it('returns null for government_letter type', async () => {
const item = makeInvoiceInboxItem({
document_type: 'government_letter',
extracted_data: baseExtraction as unknown as Record<string, unknown>,
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
expect(result).toBeNull()
})
it('returns null when no extracted_data', async () => {
const item = makeInvoiceInboxItem({ extracted_data: null })
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
expect(result).toBeNull()
})
it('returns null when no candidate transactions', async () => {
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: baseExtraction as unknown as Record<string, unknown>,
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [])
expect(result).toBeNull()
})
it('pass 1: matches by payment reference with 0.98 confidence', async () => {
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: baseExtraction as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -1000,
reference: '73401284756',
date: '2024-06-20',
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.98)
expect(result!.method).toBe('payment_reference')
expect(result!.transactionId).toBe(tx.id)
})
it('pass 1: matches with whitespace/dash-normalized references', async () => {
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: baseExtraction as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -1000,
reference: '734 012 847 56',
date: '2024-06-20',
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.98)
expect(result!.method).toBe('payment_reference')
})
it('pass 2: matches by exact amount + bankgiro with 0.92 confidence', async () => {
const extractionNoRef = {
...baseExtraction,
invoice: { ...baseExtraction.invoice, paymentReference: null },
}
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: extractionNoRef as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -1000,
reference: null,
description: 'BETALNING 58205093',
date: '2024-06-20',
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.92)
expect(result!.method).toBe('payment_reference')
})
it('pass 3: matches by exact amount + date proximity with 0.85 confidence', async () => {
const extractionNoBg = {
...baseExtraction,
invoice: { ...baseExtraction.invoice, paymentReference: null },
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
}
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -1000,
reference: null,
description: 'PAYMENT',
date: '2024-06-22',
merchant_name: null,
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.85)
expect(result!.method).toBe('amount_date')
})
it('pass 3: matches with lower confidence at 6–14 days', async () => {
const extractionNoBg = {
...baseExtraction,
invoice: { ...baseExtraction.invoice, paymentReference: null },
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
}
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -1000,
reference: null,
description: 'PAYMENT',
date: '2024-06-28', // 8 days after due date
merchant_name: null,
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.75)
expect(result!.method).toBe('amount_date')
})
it('pass 3: does not match if date is >14 days away', async () => {
const extractionNoBg = {
...baseExtraction,
invoice: { ...baseExtraction.invoice, paymentReference: null },
supplier: { ...baseExtraction.supplier, bankgiro: null, plusgiro: null },
}
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: extractionNoBg as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -1000,
reference: null,
description: 'PAYMENT',
date: '2024-07-06', // 16 days after due date
merchant_name: null,
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).toBeNull()
})
it('pass 4: matches by fuzzy amount + supplier name with 0.70 confidence', async () => {
const extractionMinimal = {
...baseExtraction,
invoice: {
...baseExtraction.invoice,
paymentReference: null,
dueDate: null,
invoiceDate: null,
},
supplier: {
...baseExtraction.supplier,
bankgiro: null,
plusgiro: null,
name: 'Telia Sverige',
},
}
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: extractionMinimal as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -1000,
reference: null,
description: 'telia faktura april',
date: '2024-06-15',
merchant_name: null,
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.70)
expect(result!.method).toBe('amount_merchant')
})
it('prefers higher confidence matches', async () => {
const item = makeInvoiceInboxItem({
status: 'ready',
extracted_data: baseExtraction as unknown as Record<string, unknown>,
})
const txWithRef = makeTransaction({
amount: -1000,
reference: '73401284756',
date: '2024-06-20',
})
const txWithAmount = makeTransaction({
amount: -1000,
reference: null,
description: 'BETALNING',
date: '2024-06-20',
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [txWithAmount, txWithRef])
expect(result!.confidence).toBe(0.98)
expect(result!.transactionId).toBe(txWithRef.id)
})
})
describe('receipt matching', () => {
const receiptExtraction: ReceiptExtractionResult = {
merchant: {
name: 'ICA Maxi',
orgNumber: null,
vatNumber: null,
isForeign: false,
},
receipt: {
date: '2024-06-15',
time: '14:30',
currency: 'SEK',
},
lineItems: [],
totals: { subtotal: 239.2, vatAmount: 59.8, total: 299 },
flags: {
isRestaurant: false,
isSystembolaget: false,
isForeignMerchant: false,
},
confidence: 0.92,
}
it('matches receipt to transaction with high confidence', async () => {
const item = makeInvoiceInboxItem({
document_type: 'receipt',
status: 'ready',
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -299,
date: '2024-06-15',
merchant_name: 'ICA Maxi',
description: 'ICA MAXI STOCKHOLM',
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).not.toBeNull()
expect(result!.method).toBe('receipt_match')
expect(result!.confidence).toBeGreaterThanOrEqual(0.60)
})
it('returns null when amount is too different', async () => {
const item = makeInvoiceInboxItem({
document_type: 'receipt',
status: 'ready',
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -500,
date: '2024-06-15',
merchant_name: 'ICA Maxi',
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).toBeNull()
})
it('returns null when date is too far away', async () => {
const item = makeInvoiceInboxItem({
document_type: 'receipt',
status: 'ready',
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -299,
date: '2024-06-25', // 10 days after
merchant_name: 'ICA Maxi',
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).toBeNull()
})
it('skips transactions with existing receipt_id', async () => {
const item = makeInvoiceInboxItem({
document_type: 'receipt',
status: 'ready',
extracted_data: receiptExtraction as unknown as Record<string, unknown>,
})
const tx = makeTransaction({
amount: -299,
date: '2024-06-15',
merchant_name: 'ICA Maxi',
receipt_id: 'existing-receipt',
})
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).toBeNull()
})
it('returns null when total is 0 or null', async () => {
const zeroExtraction = {
...receiptExtraction,
totals: { ...receiptExtraction.totals, total: 0 },
}
const item = makeInvoiceInboxItem({
document_type: 'receipt',
status: 'ready',
extracted_data: zeroExtraction as unknown as Record<string, unknown>,
})
const tx = makeTransaction({ amount: -299, date: '2024-06-15' })
const result = await matchDocumentToTransactions(mockSupabase, 'user-1', item, [tx])
expect(result).toBeNull()
})
})
})
+127
View File
@@ -0,0 +1,127 @@
/**
* Batch Document Matching
*
* Orchestrates matching multiple inbox items to transactions in a single sweep.
* Fetches all unbooked transactions once, then runs per-item matching with
* greedy assignment to prevent double-matching.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { InvoiceInboxItem, Transaction } from '@/types'
import { matchDocumentToTransactions, type DocumentMatchResult } from './document-matcher'
export interface BatchMatchResult {
matched: number
total: number
matches: Array<{ inboxItemId: string; result: DocumentMatchResult }>
}
/**
* Run a matching sweep for all ready unmatched inbox items.
*
* 1. Fetches all ready/processing inbox items without a matched_transaction_id
* 2. Fetches all unbooked expense transactions
* 3. Runs matching per item, greedily assigning (highest confidence first)
* 4. Persists matches back to inbox items
*/
export async function runDocumentMatchingSweep(
supabase: SupabaseClient,
userId: string,
inboxItemIds?: string[]
): Promise<BatchMatchResult> {
// 1. Fetch unmatched inbox items
let query = supabase
.from('invoice_inbox_items')
.select('*')
.eq('user_id', userId)
.is('matched_transaction_id', null)
.in('status', ['ready', 'processing'])
if (inboxItemIds && inboxItemIds.length > 0) {
query = query.in('id', inboxItemIds)
}
const { data: inboxItems, error: itemsError } = await query
if (itemsError || !inboxItems || inboxItems.length === 0) {
console.log(`[batch-match] No unmatched inbox items found`)
return { matched: 0, total: 0, matches: [] }
}
console.log(`[batch-match] Starting sweep: ${inboxItems.length} unmatched inbox items`)
// 2. Fetch all unbooked expense transactions (broad window: last 90 days)
const ninetyDaysAgo = new Date()
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('user_id', userId)
.is('journal_entry_id', null)
.is('is_business', null)
.lt('amount', 0)
.gte('date', ninetyDaysAgo.toISOString().split('T')[0])
.order('date', { ascending: false })
if (txError || !transactions || transactions.length === 0) {
console.log(`[batch-match] No candidate transactions found (last 90 days)`)
return { matched: 0, total: inboxItems.length, matches: [] }
}
console.log(`[batch-match] ${transactions.length} candidate transactions (last 90 days)`)
// 3. Run matching for each item and collect results
const pendingMatches: Array<{
inboxItemId: string
result: DocumentMatchResult
}> = []
for (const item of inboxItems as InvoiceInboxItem[]) {
const result = await matchDocumentToTransactions(
supabase,
userId,
item,
transactions as Transaction[]
)
if (result) {
pendingMatches.push({ inboxItemId: item.id, result })
}
}
// 4. Greedy assignment: sort by confidence desc, assign each transaction at most once
pendingMatches.sort((a, b) => b.result.confidence - a.result.confidence)
const assignedTransactionIds = new Set<string>()
const finalMatches: typeof pendingMatches = []
for (const match of pendingMatches) {
if (assignedTransactionIds.has(match.result.transactionId)) {
console.log(`[batch-match] Skipped item=${match.inboxItemId} → tx=${match.result.transactionId} (already assigned to higher-confidence match)`)
continue // Transaction already assigned to a higher-confidence match
}
assignedTransactionIds.add(match.result.transactionId)
finalMatches.push(match)
}
console.log(`[batch-match] Sweep complete: ${finalMatches.length}/${inboxItems.length} items matched, ${pendingMatches.length - finalMatches.length} skipped (greedy dedup)`)
// 5. Persist matches
for (const { inboxItemId, result } of finalMatches) {
await supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: result.transactionId,
match_confidence: result.confidence,
match_method: result.method,
})
.eq('id', inboxItemId)
.eq('user_id', userId)
}
return {
matched: finalMatches.length,
total: inboxItems.length,
matches: finalMatches,
}
}
+164
View File
@@ -0,0 +1,164 @@
/**
* Document Classifier using Claude Haiku Vision API
*
* SERVER-ONLY: This module uses the Anthropic SDK and must only be imported
* in server components or API routes.
*
* Classifies documents as supplier invoices, receipts, government letters,
* or unknown. Also detects EU reverse charge for supplier invoices.
*/
import 'server-only'
import Anthropic from '@anthropic-ai/sdk'
import type { DocumentClassificationType } 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'
export interface DocumentClassification {
type: DocumentClassificationType
confidence: number
reasoning: string
isReverseCharge?: boolean
}
/**
* Classify a document using Claude Haiku Vision.
* Determines if it's a supplier invoice, receipt, government letter, or unknown.
*/
export async function classifyDocument(
base64: string,
mimeType: string
): Promise<DocumentClassification> {
const systemPrompt = `Du är expert på att klassificera svenska affärsdokument.
Din uppgift är att avgöra vilken typ av dokument som visas.
DOKUMENTTYPER:
- supplier_invoice: Leverantörsfaktura (har fakturanummer, bankgiro/plusgiro, förfallodatum, leverantörsuppgifter)
- receipt: Kvitto (butiks-/restaurangkvitto, kort betalningsbevis med artikelrader)
- government_letter: Myndighetspost (från Skatteverket, Bolagsverket, Försäkringskassan, kommun, etc.)
- unknown: Annat dokument som inte passar ovan
FÖR LEVERANTÖRSFAKTUROR - kontrollera även:
- Är fakturan från en utländsk/EU-leverantör utan svensk moms?
- Nämner dokumentet "reverse charge", "omvänd skattskyldighet", eller "artikel 196"?
- Har leverantören ett VAT-nummer som INTE börjar med SE?
Om ja: flagga isReverseCharge = true`
const userPrompt = `Klassificera detta dokument. Returnera ENDAST ett JSON-objekt:
{
"type": "supplier_invoice" | "receipt" | "government_letter" | "unknown",
"confidence": 0.95,
"reasoning": "Kort förklaring",
"isReverseCharge": false
}
Returnera ENDAST JSON-objektet, ingen annan text.`
const isPdf = mimeType === 'application/pdf'
const isImage = mimeType.startsWith('image/')
if (!isPdf && !isImage) {
throw new Error(`Unsupported file type: ${mimeType}`)
}
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const contentBlocks: Anthropic.MessageCreateParams['messages'][0]['content'] = isPdf
? [
{
type: 'document' as const,
source: {
type: 'base64' as const,
media_type: 'application/pdf' as const,
data: base64,
},
},
{ type: 'text' as const, text: userPrompt },
]
: [
{
type: 'image' as const,
source: {
type: 'base64' as const,
media_type: mimeType as ImageMediaType,
data: base64,
},
},
{ type: 'text' as const, text: userPrompt },
]
const message = await anthropic.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 1024,
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 validateClassification(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(`Document classification failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
}
const VALID_TYPES: DocumentClassificationType[] = [
'supplier_invoice',
'receipt',
'government_letter',
'unknown',
]
function validateClassification(raw: unknown): DocumentClassification {
if (!raw || typeof raw !== 'object') {
throw new Error('Invalid classification result: not an object')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = raw as any
const type = VALID_TYPES.includes(data.type) ? data.type : 'unknown'
const confidence = typeof data.confidence === 'number' ? data.confidence : 0.5
const reasoning = typeof data.reasoning === 'string' ? data.reasoning : ''
const isReverseCharge = type === 'supplier_invoice' ? Boolean(data.isReverseCharge) : undefined
return { type, confidence, reasoning, isReverseCharge }
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Core Receipt Matcher — pure matching utility functions extracted from the
* receipt-ocr extension so they can be reused by the document matching engine.
*
* These are pure functions with no Supabase or extension dependencies.
*/
// Matching configuration (re-exported for consumers)
export const DATE_TOLERANCE_DAYS = 3
export const AMOUNT_TOLERANCE_PERCENT = 0.05
export const MIN_MATCH_CONFIDENCE = 0.4
/**
* Normalize a merchant name for comparison.
* Removes special characters, Swedish company suffixes, and extra whitespace.
*/
export function normalizeMerchantName(name: string): string {
return name
.toLowerCase()
.replace(/[^\w\såäöé]/g, '') // Remove special chars except Swedish letters
.replace(/\b(ab|hb|kb|ek|för|stiftelse)\b/g, '') // Remove company suffixes
.replace(/\s+/g, ' ')
.trim()
}
/**
* Calculate Levenshtein (edit) 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, // deletion
dp[i][j - 1] + 1, // insertion
dp[i - 1][j - 1] + cost // substitution
)
}
}
return dp[m][n]
}
/**
* Calculate merchant name similarity using Levenshtein distance and word overlap.
* Returns a value between 0 (no match) and 1 (exact match).
*/
export function calculateMerchantSimilarity(name1: string, name2: string): number {
if (!name1 || !name2) return 0
const n1 = normalizeMerchantName(name1)
const n2 = normalizeMerchantName(name2)
// Exact match
if (n1 === n2) return 1
// One contains the other
if (n1.includes(n2) || n2.includes(n1)) return 0.9
// Word overlap
const words1 = n1.split(/\s+/)
const words2 = n2.split(/\s+/)
const commonWords = words1.filter((w) => words2.includes(w))
if (commonWords.length > 0) {
const overlapScore = commonWords.length / Math.max(words1.length, words2.length)
if (overlapScore >= 0.5) return 0.7 + overlapScore * 0.2
}
// Levenshtein similarity
const distance = levenshteinDistance(n1, n2)
const maxLength = Math.max(n1.length, n2.length)
return 1 - distance / maxLength
}
/**
* Calculate a weighted match confidence score from date, amount, and merchant signals.
* Weights: amount 40%, merchant 35%, date 25%.
*
* When merchant similarity is 0, the merchant weight is excluded from the
* total weight so the confidence is normalized across the active signals only.
*/
export function calculateMatchConfidence(
dateVariance: number,
amountVariance: number,
merchantSimilarity: number,
dateTolerance: number = DATE_TOLERANCE_DAYS,
amountTolerance: number = AMOUNT_TOLERANCE_PERCENT
): { confidence: number; matchReasons: string[] } {
const matchReasons: string[] = []
let totalWeight = 0
let weightedScore = 0
// Date score (weight: 25%)
const dateScore = Math.max(0, 1 - dateVariance / dateTolerance)
if (dateScore >= 0.8) {
matchReasons.push(dateVariance === 0 ? 'Exakt datum' : `Datum ±${Math.round(dateVariance)} dagar`)
}
weightedScore += dateScore * 0.25
totalWeight += 0.25
// Amount score (weight: 40%)
const amountScore = Math.max(0, 1 - amountVariance / amountTolerance)
if (amountVariance < 0.01) {
matchReasons.push('Exakt belopp')
} else if (amountVariance < amountTolerance) {
matchReasons.push(`Belopp ±${Math.round(amountVariance * 100)}%`)
}
weightedScore += amountScore * 0.4
totalWeight += 0.4
// Merchant score (weight: 35%) — only counted when there's data
if (merchantSimilarity > 0) {
if (merchantSimilarity >= 0.9) {
matchReasons.push('Handlare matchar')
} else if (merchantSimilarity >= 0.6) {
matchReasons.push('Trolig handlarmatch')
}
weightedScore += merchantSimilarity * 0.35
totalWeight += 0.35
}
const confidence = totalWeight > 0 ? weightedScore / totalWeight : 0
return {
confidence: Math.round(confidence * 100) / 100,
matchReasons,
}
}
+322
View File
@@ -0,0 +1,322 @@
/**
* Document-to-Transaction Matcher
*
* Pure matching logic that works from extracted data already stored on inbox items.
* Zero AI or extension dependencies — works entirely from structured data.
*
* Matching passes by document type:
*
* Supplier invoices:
* 1. Payment reference exact match → 0.98
* 2. Exact amount + bankgiro → 0.92
* 3. Exact amount + date ±5 days → 0.85
* 4. Fuzzy amount + supplier name → 0.70
*
* Receipts:
* Weighted scoring (amount 40%, date 25%, merchant 35%), min confidence 0.60
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { InvoiceInboxItem, Transaction, InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
import {
calculateMerchantSimilarity,
calculateMatchConfidence,
} from './core-receipt-matcher'
export type DocumentMatchMethod =
| 'payment_reference'
| 'amount_date'
| 'amount_merchant'
| 'receipt_match'
export interface DocumentMatchResult {
transactionId: string
confidence: number
method: DocumentMatchMethod
matchReasons: string[]
}
/**
* Match a single inbox item to the best candidate transaction.
*
* If `candidateTransactions` is not provided, fetches unbooked expense
* transactions within ±7 days of the document date.
*/
export async function matchDocumentToTransactions(
supabase: SupabaseClient,
userId: string,
inboxItem: InvoiceInboxItem,
candidateTransactions?: Transaction[]
): Promise<DocumentMatchResult | null> {
const tag = `[document-matcher] item=${inboxItem.id} type=${inboxItem.document_type}`
// Only match supplier invoices and receipts
if (inboxItem.document_type === 'government_letter' || inboxItem.document_type === 'unknown') {
console.log(`${tag} — skipped (unsupported document type)`)
return null
}
if (!inboxItem.extracted_data) {
console.log(`${tag} — skipped (no extracted_data)`)
return null
}
const transactions = candidateTransactions ?? (await fetchCandidateTransactions(supabase, userId, inboxItem))
console.log(`${tag} — ${transactions.length} candidate transactions`)
if (transactions.length === 0) {
console.log(`${tag} — no candidates, aborting`)
return null
}
let result: DocumentMatchResult | null = null
if (inboxItem.document_type === 'supplier_invoice') {
result = matchSupplierInvoiceDocument(inboxItem, transactions)
} else if (inboxItem.document_type === 'receipt') {
result = matchReceiptDocument(inboxItem, transactions)
}
if (result) {
console.log(`${tag} — MATCHED tx=${result.transactionId} confidence=${result.confidence} method=${result.method} reasons=[${result.matchReasons.join(', ')}]`)
} else {
console.log(`${tag} — no match found`)
}
return result
}
/**
* Fetch unbooked expense transactions within ±7 days of the document date.
*/
async function fetchCandidateTransactions(
supabase: SupabaseClient,
userId: string,
inboxItem: InvoiceInboxItem
): Promise<Transaction[]> {
const docDate = getDocumentDate(inboxItem)
if (!docDate) return []
const startDate = new Date(docDate)
startDate.setDate(startDate.getDate() - 7)
const endDate = new Date(docDate)
endDate.setDate(endDate.getDate() + 7)
const { data, error } = await supabase
.from('transactions')
.select('*')
.eq('user_id', userId)
.is('journal_entry_id', null)
.is('is_business', null)
.lt('amount', 0)
.gte('date', startDate.toISOString().split('T')[0])
.lte('date', endDate.toISOString().split('T')[0])
.order('date', { ascending: false })
if (error || !data) return []
return data as Transaction[]
}
/**
* Extract the most relevant date from an inbox item's extracted data.
*/
function getDocumentDate(inboxItem: InvoiceInboxItem): string | null {
const data = inboxItem.extracted_data as Record<string, unknown> | null
if (!data) return null
if (inboxItem.document_type === 'supplier_invoice') {
const extraction = data as unknown as InvoiceExtractionResult
return extraction.invoice?.dueDate ?? extraction.invoice?.invoiceDate ?? null
}
if (inboxItem.document_type === 'receipt') {
const extraction = data as unknown as ReceiptExtractionResult
return extraction.receipt?.date ?? null
}
return null
}
/**
* Match a supplier invoice inbox item to transactions using a 4-pass algorithm.
*/
function matchSupplierInvoiceDocument(
inboxItem: InvoiceInboxItem,
transactions: Transaction[]
): DocumentMatchResult | null {
const tag = `[document-matcher:supplier] item=${inboxItem.id}`
const extraction = inboxItem.extracted_data as unknown as InvoiceExtractionResult
if (!extraction) return null
const invoiceTotal = extraction.totals?.total
if (invoiceTotal == null || invoiceTotal === 0) {
console.log(`${tag} — no invoice total in extracted data`)
return null
}
const paymentRef = extraction.invoice?.paymentReference
const bankgiro = extraction.supplier?.bankgiro
const plusgiro = extraction.supplier?.plusgiro
const supplierName = extraction.supplier?.name
const dueDate = extraction.invoice?.dueDate ?? extraction.invoice?.invoiceDate
console.log(`${tag} — extracted: total=${invoiceTotal}, supplier=${supplierName || '?'}, dueDate=${dueDate || '?'}, paymentRef=${paymentRef || '?'}, bankgiro=${bankgiro || '?'}, templateId=${extraction.suggestedTemplateId || '?'}`)
let bestMatch: DocumentMatchResult | null = null
for (const tx of transactions) {
const txAmount = Math.abs(tx.amount)
const txDesc = (tx.description || '').toLowerCase()
const txRef = tx.reference || ''
// Pass 1: Payment reference exact match → 0.98
if (paymentRef && txRef) {
const normTxRef = txRef.replace(/\D/g, '')
const normPayRef = paymentRef.replace(/\D/g, '')
if (normTxRef && normPayRef && normTxRef === normPayRef) {
console.log(`${tag} — Pass 1 HIT: tx=${tx.id} ref=${normPayRef}`)
return {
transactionId: tx.id,
confidence: 0.98,
method: 'payment_reference',
matchReasons: ['Betalningsreferens matchar'],
}
}
}
// Pass 2: Exact amount + bankgiro/plusgiro → 0.92
const amountMatch = Math.abs(txAmount - invoiceTotal) < 0.005
if (amountMatch) {
const bgNorm = bankgiro?.replace(/\D/g, '')
const pgNorm = plusgiro?.replace(/\D/g, '')
const hasBgMatch = bgNorm && txDesc.includes(bgNorm)
const hasPgMatch = pgNorm && txDesc.includes(pgNorm)
if (hasBgMatch || hasPgMatch) {
console.log(`${tag} — Pass 2 HIT: tx=${tx.id} amount=${txAmount} bg/pg match`)
return {
transactionId: tx.id,
confidence: 0.92,
method: 'payment_reference',
matchReasons: ['Exakt belopp', hasBgMatch ? 'Bankgiro matchar' : 'Plusgiro matchar'],
}
}
}
// Pass 3: Exact amount + date ±14 days → 0.85 (close) / 0.75 (wider)
// Invoices are often paid early or a few days late, so we use a 14-day window.
if (amountMatch && dueDate) {
const txDate = new Date(tx.date)
const docDate = new Date(dueDate)
const diffDays = Math.abs((txDate.getTime() - docDate.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays <= 14) {
// Higher confidence for close dates, lower for wider window
const confidence = diffDays <= 5 ? 0.85 : 0.75
console.log(`${tag} — Pass 3 HIT: tx=${tx.id} amount=${txAmount} date_diff=${diffDays.toFixed(1)}d → confidence=${confidence}`)
const candidate: DocumentMatchResult = {
transactionId: tx.id,
confidence,
method: 'amount_date',
matchReasons: ['Exakt belopp', diffDays === 0 ? 'Exakt datum' : `Datum ±${Math.round(diffDays)} dagar`],
}
if (!bestMatch || candidate.confidence > bestMatch.confidence) {
bestMatch = candidate
}
}
}
// Pass 4: Fuzzy amount (±1%) + supplier name in description → 0.70
const fuzzyAmountMatch = Math.abs(txAmount - invoiceTotal) / invoiceTotal <= 0.01
if (fuzzyAmountMatch && supplierName) {
const normalizedName = supplierName.toLowerCase().replace(/[^\w\såäöé]/g, '')
const nameWords = normalizedName.split(/\s+/).filter((w) => w.length >= 3)
const nameInDesc = nameWords.some((word) => txDesc.includes(word))
if (nameInDesc) {
console.log(`${tag} — Pass 4 HIT: tx=${tx.id} amount=${txAmount} (~${((Math.abs(txAmount - invoiceTotal) / invoiceTotal) * 100).toFixed(1)}%) name words=[${nameWords.join(',')}]`)
const candidate: DocumentMatchResult = {
transactionId: tx.id,
confidence: 0.70,
method: 'amount_merchant',
matchReasons: ['Belopp matchar (±1%)', 'Leverantörsnamn i beskrivning'],
}
if (!bestMatch || candidate.confidence > bestMatch.confidence) {
bestMatch = candidate
}
}
}
}
return bestMatch
}
/**
* Match a receipt inbox item to transactions using weighted scoring.
* Weights: amount 40%, date 25%, merchant 35%. Min confidence: 0.60.
*/
function matchReceiptDocument(
inboxItem: InvoiceInboxItem,
transactions: Transaction[]
): DocumentMatchResult | null {
const tag = `[document-matcher:receipt] item=${inboxItem.id}`
const extraction = inboxItem.extracted_data as unknown as ReceiptExtractionResult
if (!extraction) return null
const receiptTotal = extraction.totals?.total
const receiptDate = extraction.receipt?.date
const merchantName = extraction.merchant?.name
if (receiptTotal == null || receiptTotal === 0) {
console.log(`${tag} — no receipt total in extracted data`)
return null
}
console.log(`${tag} — extracted: total=${receiptTotal}, date=${receiptDate || '?'}, merchant=${merchantName || '?'}, templateId=${extraction.suggestedTemplateId || '?'}`)
let bestMatch: DocumentMatchResult | null = null
for (const tx of transactions) {
if (tx.receipt_id) continue // Skip already matched
const txAmount = Math.abs(tx.amount)
const txDate = new Date(tx.date)
// Calculate date variance
const dateVariance = receiptDate
? Math.abs((new Date(receiptDate).getTime() - txDate.getTime()) / (1000 * 60 * 60 * 24))
: 3 // Default to tolerance boundary if no date
if (dateVariance > 3) continue
// Calculate amount variance
const amountVariance = Math.abs(receiptTotal - txAmount) / receiptTotal
if (amountVariance > 0.05) continue // Skip if >5% off
// Calculate merchant similarity
const txMerchant = tx.merchant_name || tx.description || ''
const merchantSimilarity = merchantName
? calculateMerchantSimilarity(merchantName, txMerchant)
: 0
const { confidence, matchReasons } = calculateMatchConfidence(
dateVariance,
amountVariance,
merchantSimilarity
)
console.log(`${tag} — scoring tx=${tx.id} "${tx.description}": date_var=${dateVariance.toFixed(1)}d amount_var=${(amountVariance * 100).toFixed(1)}% merchant_sim=${merchantSimilarity.toFixed(2)} → confidence=${confidence}`)
if (confidence >= 0.60 && (!bestMatch || confidence > bestMatch.confidence)) {
bestMatch = {
transactionId: tx.id,
confidence,
method: 'receipt_match',
matchReasons,
}
}
}
return bestMatch
}
+41 -1
View File
@@ -1,4 +1,4 @@
import type { InboxItemStatus, InvoiceExtractionResult } from '@/types'
import type { InboxItemStatus, InvoiceExtractionResult, DocumentClassificationType } from '@/types'
const STATUS_LABELS: Record<InboxItemStatus, string> = {
pending: 'Väntar',
@@ -51,3 +51,43 @@ export function formatExtractionSummary(
lineCount: data.lineItems?.length ?? 0,
}
}
// Document type labels (Swedish)
const DOCUMENT_TYPE_LABELS: Record<DocumentClassificationType, string> = {
supplier_invoice: 'Faktura',
receipt: 'Kvitto',
government_letter: 'Myndighetspost',
unknown: 'Övrigt',
}
export function getDocumentTypeLabel(type: DocumentClassificationType): string {
return DOCUMENT_TYPE_LABELS[type] ?? type
}
const DOCUMENT_TYPE_VARIANTS: Record<DocumentClassificationType, string> = {
supplier_invoice: 'default',
receipt: 'secondary',
government_letter: 'outline',
unknown: 'outline',
}
export function getDocumentTypeVariant(type: DocumentClassificationType): string {
return DOCUMENT_TYPE_VARIANTS[type] ?? 'outline'
}
/**
* Format extraction summary for receipt documents
*/
export function formatReceiptSummary(
data: Record<string, unknown> | null | undefined
): { merchantName: string; total: number } {
if (!data) {
return { merchantName: '', total: 0 }
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any
return {
merchantName: d.merchant?.name ?? '',
total: d.totals?.total ?? 0,
}
}
+3 -3
View File
@@ -72,15 +72,15 @@ export const SECTORS: Sector[] = [
},
{
slug: 'invoice-inbox',
name: 'Leverantörsfaktura-inbox',
name: 'Dokumentinkorg',
sector: 'general',
category: 'import',
icon: 'Inbox',
dataPattern: 'manual',
hasOwnData: true,
description: 'Ta emot leverantörsfakturor via e-post eller uppladdning',
description: 'Ta emot alla dokument via e-post — fakturor, kvitton och myndighetspost',
longDescription:
'Skicka leverantörsfakturor till en dedikerad e-postadress eller ladda upp manuellt. AI extraherar automatiskt leverantörsdata, belopp och moms. Granska och bekräfta med ett klick för att skapa leverantörsfakturor.',
'Skicka alla affärsdokument till en dedikerad e-postadress. AI klassificerar automatiskt dokumenttyp (faktura, kvitto, myndighetspost), extraherar data och matchar mot transaktioner. En inkorg för alla dokument.',
},
{
slug: 'calendar',
+1 -1
View File
@@ -13,7 +13,7 @@ const WORKSPACES: Record<WorkspaceKey, ComponentType<WorkspaceComponentProps>> =
'general/ai-categorization': dynamic(() => import('@/components/extensions/general/AiCategorizationWorkspace')),
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
'general/push-notifications': dynamic(() => import('@/components/extensions/general/PushNotificationsWorkspace')),
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/InvoiceInboxWorkspace')),
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/DocumentInboxWorkspace')),
'general/calendar': dynamic(() => import('@/components/extensions/general/CalendarWorkspace')),
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
'general/user-description-match': dynamic(() => import('@/components/extensions/general/UserDescriptionMatchWorkspace')),
+114 -18
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import type { BASAccount } from '@/types'
import type { BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
import type { SIEAccount, SIEAccountMappingRecord } from '../types'
import {
suggestMappings,
@@ -36,6 +37,7 @@ function makeBASAccount(number: string, name: string): BASAccount {
default_vat_code: null,
description: null,
sru_code: null,
k2_excluded: false,
sort_order: parseInt(number, 10),
created_at: '2024-01-01',
updated_at: '2024-01-01',
@@ -73,7 +75,7 @@ describe('suggestMappings', () => {
expect(result[0].isOverride).toBe(false)
})
it('returns unmapped entry when no match exists', () => {
it('returns unmapped entry for out-of-range accounts', () => {
const source = [makeSIEAccount('9999', 'Okänt konto')]
const result = suggestMappings(source, basAccounts)
@@ -84,19 +86,40 @@ describe('suggestMappings', () => {
expect(result[0].matchType).toBe('manual')
})
it('does not fuzzy match accounts with similar names', () => {
// 3400 should NOT match 3001 or 3002 despite being in same class
it('self-maps valid BAS-range accounts not in reference via bas_range fallback', () => {
// 3400 is a valid BAS-range account (1000-8999) but not in the fixture list
const source = [makeSIEAccount('3400', 'Försäljning tjänster')]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('3400')
expect(result[0].targetName).toBe('Försäljning tjänster')
expect(result[0].confidence).toBe(0.9)
expect(result[0].matchType).toBe('bas_range')
})
it('self-maps sub-accounts not in reference (e.g. 1241 Personbilar)', () => {
const source = [makeSIEAccount('1241', 'Personbilar')]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('1241')
expect(result[0].targetName).toBe('Personbilar')
expect(result[0].confidence).toBe(0.9)
expect(result[0].matchType).toBe('bas_range')
})
it('does not self-map accounts outside BAS range (9000+)', () => {
const source = [makeSIEAccount('9100', 'Internt konto')]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('')
expect(result[0].confidence).toBe(0)
})
it('does not fuzzy match accounts with similar numbers', () => {
// 2510 should NOT match 2440 despite being in same class
const source = [makeSIEAccount('2510', 'Skatteskulder')]
it('does not self-map non-4-digit account numbers', () => {
const source = [makeSIEAccount('12345', 'Felaktigt kontonummer')]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(1)
@@ -128,21 +151,25 @@ describe('suggestMappings', () => {
expect(result[0].matchType).toBe('manual')
})
it('sorts unmapped accounts first (lowest confidence)', () => {
it('sorts by confidence (lowest first)', () => {
const source = [
makeSIEAccount('1510', 'Kundfordringar'),
makeSIEAccount('9999', 'Okänt konto'),
makeSIEAccount('3400', 'Försäljning tjänster'),
makeSIEAccount('1930', 'Företagskonto'),
]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(3)
expect(result).toHaveLength(4)
// Unmapped (confidence 0) should come first
expect(result[0].sourceAccount).toBe('9999')
expect(result[0].confidence).toBe(0)
// Exact matches (confidence 1.0) come after
expect(result[1].confidence).toBe(1.0)
// bas_range (confidence 0.9) next
expect(result[1].sourceAccount).toBe('3400')
expect(result[1].confidence).toBe(0.9)
// Exact matches (confidence 1.0) come last
expect(result[2].confidence).toBe(1.0)
expect(result[3].confidence).toBe(1.0)
})
it('handles multiple accounts with mixed results', () => {
@@ -155,10 +182,10 @@ describe('suggestMappings', () => {
expect(result).toHaveLength(3)
// All 3 should be mapped: 1510 and 5010 exact, 3400 bas_range
const mapped = result.filter((m) => m.targetAccount)
const unmapped = result.filter((m) => !m.targetAccount)
expect(mapped).toHaveLength(2)
expect(unmapped).toHaveLength(1)
expect(mapped).toHaveLength(3)
expect(mapped.find((m) => m.sourceAccount === '3400')?.matchType).toBe('bas_range')
})
it('handles empty source accounts', () => {
@@ -174,6 +201,48 @@ describe('suggestMappings', () => {
expect(result[0].targetAccount).toBe('')
expect(result[0].confidence).toBe(0)
})
it('accepts BASReferenceAccount objects (full BAS reference)', () => {
const refAccounts: BASReferenceAccount[] = [
{
account_number: '1510',
account_name: 'Kundfordringar',
account_class: 1,
account_group: '15',
account_type: 'asset',
normal_balance: 'debit',
description: 'Kundfordringar',
sru_code: null,
k2_excluded: false,
},
{
account_number: '2440',
account_name: 'Leverantörsskulder',
account_class: 2,
account_group: '24',
account_type: 'liability',
normal_balance: 'credit',
description: 'Leverantörsskulder',
sru_code: null,
k2_excluded: false,
},
]
const source = [
makeSIEAccount('1510', 'Kundfordringar'),
makeSIEAccount('2440', 'Leverantörsskulder'),
makeSIEAccount('9999', 'Okänt konto'),
]
const result = suggestMappings(source, refAccounts)
expect(result).toHaveLength(3)
const mapped = result.filter((m) => m.targetAccount)
const unmapped = result.filter((m) => !m.targetAccount)
expect(mapped).toHaveLength(2)
expect(unmapped).toHaveLength(1)
expect(mapped.find((m) => m.sourceAccount === '1510')?.confidence).toBe(1.0)
})
})
describe('validateMappings', () => {
@@ -188,7 +257,7 @@ describe('validateMappings', () => {
expect(validation.unmappedAccounts).toHaveLength(0)
})
it('returns invalid when accounts are unmapped', () => {
it('returns invalid when out-of-range accounts are unmapped', () => {
const mappings = suggestMappings(
[makeSIEAccount('1510', 'Kundfordringar'), makeSIEAccount('9999', 'Okänt konto')],
basAccounts
@@ -200,6 +269,17 @@ describe('validateMappings', () => {
expect(validation.unmappedAccounts).toHaveLength(1)
})
it('returns valid when all accounts mapped via exact + bas_range', () => {
const mappings = suggestMappings(
[makeSIEAccount('1510', 'Kundfordringar'), makeSIEAccount('1241', 'Personbilar')],
basAccounts
)
const validation = validateMappings(mappings)
expect(validation.valid).toBe(true)
expect(validation.unmappedAccounts).toHaveLength(0)
})
it('detects low confidence accounts', () => {
// With exact-match-only mapper, low confidence only comes from existing overrides
const mappings = [
@@ -236,18 +316,20 @@ describe('getMappingStats', () => {
expect(stats.unmapped).toBe(1)
})
it('counts match types correctly', () => {
it('counts match types correctly including bas_range', () => {
const mappings = suggestMappings(
[
makeSIEAccount('1510', 'Kundfordringar'),
makeSIEAccount('9999', 'Okänt konto'),
makeSIEAccount('1510', 'Kundfordringar'), // exact
makeSIEAccount('1241', 'Personbilar'), // bas_range
makeSIEAccount('9999', 'Okänt konto'), // manual (unmapped)
],
basAccounts
)
const stats = getMappingStats(mappings)
expect(stats.exact).toBe(1)
expect(stats.manual).toBe(1) // unmapped gets matchType 'manual'
expect(stats.basRange).toBe(1)
expect(stats.manual).toBe(1)
expect(stats.name).toBe(0)
expect(stats.class).toBe(0)
})
@@ -267,6 +349,20 @@ describe('getMappingStats', () => {
expect(stats.averageConfidence).toBe(1.0)
})
it('includes bas_range in average confidence calculation', () => {
const mappings = suggestMappings(
[
makeSIEAccount('1510', 'Kundfordringar'), // exact, confidence 1.0
makeSIEAccount('1241', 'Personbilar'), // bas_range, confidence 0.9
],
basAccounts
)
const stats = getMappingStats(mappings)
// Average of (1.0 + 0.9) / 2 = 0.95
expect(stats.averageConfidence).toBe(0.95)
})
it('returns 0 average confidence when nothing is mapped', () => {
const mappings = suggestMappings(
[makeSIEAccount('9999', 'Okänt konto')],
+46 -6
View File
@@ -2,12 +2,12 @@
* Account Mapping Engine
*
* Maps accounts from an imported SIE file to the user's BAS chart of accounts.
* Uses exact account number matching only — no fuzzy/heuristic matching.
* Uses exact account number matching against the BAS reference, with a fallback
* for valid BAS-range sub-accounts (1000-8999) not in the reference.
* This aligns with Swedish industry standard (e.g. Fortnox): exact match,
* create new, or let the user map manually.
*/
import type { BASAccount } from '@/types'
import type {
SIEAccount,
AccountMapping,
@@ -15,13 +15,35 @@ import type {
SIEAccountMappingRecord,
} from './types'
/**
* Minimal account shape needed for mapping.
* Both BASAccount (from user chart) and BASReferenceAccount (from reference data)
* satisfy this interface.
*/
export type MappableAccount = {
account_number: string
account_name: string
}
/**
* Check if an account number is in the valid BAS range (1000-8999).
* Standard Swedish BAS accounts are 4-digit numbers in classes 1-8.
*/
function isValidBASRange(accountNumber: string): boolean {
if (!/^\d{4}$/.test(accountNumber)) return false
const num = parseInt(accountNumber, 10)
return num >= 1000 && num <= 8999
}
/**
* Find the best matching BAS account for a source account.
* Only matches on exact account number — no fuzzy matching.
* First tries exact match against the reference, then falls back to
* self-mapping for valid BAS-range accounts not in the reference
* (common for sub-accounts like 1241 Personbilar under 1240).
*/
function findBestMatch(
source: SIEAccount,
basAccounts: BASAccount[],
basAccounts: MappableAccount[],
existingOverride?: AccountMapping
): AccountMapping | null {
// If there's a user override, use it
@@ -32,7 +54,7 @@ function findBestMatch(
}
}
// Exact account number match
// Exact account number match against reference
const exactMatch = basAccounts.find(
(target) => source.number === target.account_number
)
@@ -49,6 +71,21 @@ function findBestMatch(
}
}
// Fallback: if the account is a valid BAS-range number (1000-8999),
// self-map it using the name from the SIE file. These are standard
// BAS sub-accounts not in our reference (e.g. 1241 Personbilar).
if (isValidBASRange(source.number) && source.name) {
return {
sourceAccount: source.number,
sourceName: source.name,
targetAccount: source.number,
targetName: source.name,
confidence: 0.9,
matchType: 'bas_range',
isOverride: false,
}
}
// No match found
return null
}
@@ -58,7 +95,7 @@ function findBestMatch(
*/
export function suggestMappings(
sourceAccounts: SIEAccount[],
basAccounts: BASAccount[],
basAccounts: MappableAccount[],
existingMappings?: SIEAccountMappingRecord[]
): AccountMapping[] {
// Convert existing mappings to a lookup map
@@ -132,6 +169,7 @@ export function getMappingStats(mappings: AccountMapping[]): {
mapped: number
unmapped: number
exact: number
basRange: number
name: number
class: number
manual: number
@@ -143,6 +181,7 @@ export function getMappingStats(mappings: AccountMapping[]): {
const unmapped = total - mapped
const exact = mappings.filter((m) => m.matchType === 'exact').length
const basRange = mappings.filter((m) => m.matchType === 'bas_range').length
const name = mappings.filter((m) => m.matchType === 'name').length
const classMatch = mappings.filter((m) => m.matchType === 'class').length
const manual = mappings.filter((m) => m.matchType === 'manual').length
@@ -159,6 +198,7 @@ export function getMappingStats(mappings: AccountMapping[]): {
mapped,
unmapped,
exact,
basRange,
name,
class: classMatch,
manual,
+1 -1
View File
@@ -15,7 +15,7 @@ export type SIEEncoding = 'cp437' | 'utf8'
export type SIEImportStatus = 'pending' | 'mapped' | 'completed' | 'failed'
// Match type for account mapping
export type AccountMatchType = 'exact' | 'name' | 'class' | 'manual'
export type AccountMatchType = 'exact' | 'name' | 'class' | 'manual' | 'bas_range'
// Parse issue severity
export type ParseIssueSeverity = 'error' | 'warning' | 'info'
@@ -0,0 +1,197 @@
import { describe, it, expect } from 'vitest'
import { findSupplierInvoiceMatch } from '../supplier-invoice-matching'
import { makeTransaction, makeSupplierInvoice, makeSupplier } from '@/tests/helpers'
describe('findSupplierInvoiceMatch', () => {
const supplier = makeSupplier({
name: 'Kontorsbolaget AB',
bankgiro: '123-4567',
plusgiro: '987654-3',
})
it('returns null for empty invoice list', () => {
const tx = makeTransaction({ amount: -1000 })
expect(findSupplierInvoiceMatch(tx, [])).toBeNull()
})
it('returns null for zero-amount transactions', () => {
const tx = makeTransaction({ amount: 0 })
const inv = makeSupplierInvoice({ status: 'registered', remaining_amount: 1000 })
expect(findSupplierInvoiceMatch(tx, [inv])).toBeNull()
})
it('skips paid invoices (remaining_amount = 0)', () => {
const tx = makeTransaction({ amount: -1000, reference: '12345' })
const inv = makeSupplierInvoice({
status: 'registered',
remaining_amount: 0,
payment_reference: '12345',
})
expect(findSupplierInvoiceMatch(tx, [inv])).toBeNull()
})
it('skips invoices with non-matching status', () => {
const tx = makeTransaction({ amount: -1000, reference: '12345' })
const inv = makeSupplierInvoice({
status: 'paid',
remaining_amount: 1000,
payment_reference: '12345',
})
expect(findSupplierInvoiceMatch(tx, [inv])).toBeNull()
})
// Pass 1: Payment reference
it('matches by payment reference with confidence 0.98', () => {
const tx = makeTransaction({ amount: -5000, reference: '73100 12345 67890' })
const inv = makeSupplierInvoice({
status: 'registered',
remaining_amount: 5000,
payment_reference: '731001234567890',
})
const result = findSupplierInvoiceMatch(tx, [inv])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.98)
expect(result!.matchMethod).toBe('payment_reference')
})
// Pass 2: Amount + bankgiro
it('matches by exact amount + bankgiro in description with confidence 0.92', () => {
const tx = makeTransaction({
amount: -10000,
description: 'Betalning BG 1234567 Kontorsbolaget',
})
const inv = makeSupplierInvoice({
status: 'approved',
remaining_amount: 10000,
supplier: { ...supplier, bankgiro: '123-4567' },
})
const result = findSupplierInvoiceMatch(tx, [inv])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.92)
expect(result!.matchMethod).toBe('amount_bankgiro')
})
// Pass 3: Amount + date
it('matches by exact amount + due date within 5 days with confidence 0.85', () => {
const tx = makeTransaction({
amount: -10000,
date: '2024-07-03', // 2 days after due date
})
const inv = makeSupplierInvoice({
status: 'registered',
remaining_amount: 10000,
due_date: '2024-07-01',
})
const result = findSupplierInvoiceMatch(tx, [inv])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.85)
expect(result!.matchMethod).toBe('amount_date')
})
it('does not match when date difference exceeds 5 days', () => {
const tx = makeTransaction({
amount: -10000,
date: '2024-07-10', // 9 days after due date
description: 'random payment',
})
const inv = makeSupplierInvoice({
status: 'registered',
remaining_amount: 10000,
due_date: '2024-07-01',
})
const result = findSupplierInvoiceMatch(tx, [inv])
expect(result).toBeNull()
})
// Pass 4: Fuzzy amount + name
it('matches by fuzzy amount + supplier name in description with confidence 0.70', () => {
const tx = makeTransaction({
amount: -10000,
description: 'Betalning Kontorsbolaget',
})
const inv = makeSupplierInvoice({
status: 'registered',
remaining_amount: 10000,
due_date: '2024-01-01', // far away date — won't match pass 3
supplier: { ...supplier, name: 'Kontorsbolaget AB' },
})
const result = findSupplierInvoiceMatch(tx, [inv])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.70)
expect(result!.matchMethod).toBe('fuzzy_name')
})
it('prefers higher-confidence matches', () => {
const tx = makeTransaction({
amount: -5000,
date: '2024-07-02',
reference: '999888777',
})
const invoiceRef = makeSupplierInvoice({
status: 'registered',
remaining_amount: 5000,
payment_reference: '999888777',
due_date: '2024-07-01',
})
const invoiceDate = makeSupplierInvoice({
status: 'registered',
remaining_amount: 5000,
due_date: '2024-07-01',
})
// Payment reference match should win (0.98 > 0.85)
const result = findSupplierInvoiceMatch(tx, [invoiceDate, invoiceRef])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.98)
expect(result!.matchMethod).toBe('payment_reference')
})
it('handles öresavrundning (±0.01 fuzzy)', () => {
const tx = makeTransaction({
amount: -999.99,
description: 'Betalning Kontorsbolaget faktura',
})
const inv = makeSupplierInvoice({
status: 'registered',
remaining_amount: 1000,
due_date: '2024-01-01',
supplier: { ...supplier, name: 'Kontorsbolaget AB' },
})
const result = findSupplierInvoiceMatch(tx, [inv])
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.70)
})
it('ignores short words when matching supplier name', () => {
const tx = makeTransaction({
amount: -5000,
description: 'AB payment', // "AB" is only 2 chars, should be ignored
})
const inv = makeSupplierInvoice({
status: 'registered',
remaining_amount: 5000,
due_date: '2024-01-01',
supplier: { ...supplier, name: 'AB' },
})
const result = findSupplierInvoiceMatch(tx, [inv])
// "AB" is filtered out (length < 3), so no name match
expect(result).toBeNull()
})
})
+131
View File
@@ -0,0 +1,131 @@
/**
* Supplier Invoice Matching — auto-match expense transactions to unpaid supplier invoices.
*
* 4-pass matching algorithm (ordered by confidence):
* 1. Payment reference/OCR exact match → 0.98
* 2. Exact amount + bankgiro/plusgiro match → 0.92
* 3. Exact amount + date ±5 days → 0.85
* 4. Fuzzy amount (±0.01) + supplier name in description → 0.70
*
* Auto-match threshold: ≥0.85 → applied automatically
* Suggestion threshold: 0.70–0.85 → stored as potential_supplier_invoice_id
*/
import type { Transaction, SupplierInvoice } from '@/types'
export interface SupplierInvoiceMatch {
supplierInvoice: SupplierInvoice
confidence: number
matchMethod: 'payment_reference' | 'amount_bankgiro' | 'amount_date' | 'fuzzy_name'
}
/**
* Normalize payment reference for comparison (strip whitespace and non-digits).
*/
function normalizeReference(ref: string): string {
return ref.replace(/\D/g, '')
}
/**
* Find the best matching supplier invoice for an expense transaction.
* Expects invoices to have the `supplier` relation populated (for name/bankgiro matching).
* Only matches against invoices with status 'registered' or 'approved'
* and with remaining_amount > 0.
*/
export function findSupplierInvoiceMatch(
transaction: Transaction,
unpaidInvoices: SupplierInvoice[]
): SupplierInvoiceMatch | null {
if (unpaidInvoices.length === 0) return null
// Only match expense transactions
const txAmount = Math.abs(transaction.amount)
if (txAmount === 0) return null
let bestMatch: SupplierInvoiceMatch | null = null
for (const invoice of unpaidInvoices) {
// Only match against registered/approved invoices with remaining amount
if (!['registered', 'approved'].includes(invoice.status)) continue
const remaining = invoice.remaining_amount ?? invoice.total
if (remaining <= 0) continue
// Pass 1: Payment reference/OCR exact match → 0.98
if (transaction.reference && invoice.payment_reference) {
const txRef = normalizeReference(transaction.reference)
const invRef = normalizeReference(invoice.payment_reference)
if (txRef && invRef && txRef === invRef) {
return {
supplierInvoice: invoice,
confidence: 0.98,
matchMethod: 'payment_reference',
}
}
}
// Pass 2: Exact amount + bankgiro/plusgiro match → 0.92
const amountMatch = Math.abs(txAmount - remaining) < 0.005
if (amountMatch) {
const txDesc = (transaction.description || '').toLowerCase()
const supplierBg = invoice.supplier?.bankgiro
const supplierPg = invoice.supplier?.plusgiro
const bgMatch = supplierBg && txDesc.includes(normalizeReference(supplierBg))
const pgMatch = supplierPg && txDesc.includes(normalizeReference(supplierPg))
if (bgMatch || pgMatch) {
return {
supplierInvoice: invoice,
confidence: 0.92,
matchMethod: 'amount_bankgiro',
}
}
}
// Pass 3: Exact amount + date ±5 days → 0.85
if (amountMatch && invoice.due_date) {
const txDate = new Date(transaction.date)
const dueDate = new Date(invoice.due_date)
const diffDays = Math.abs((txDate.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays <= 5) {
const confidence = 0.85
if (!bestMatch || confidence > bestMatch.confidence) {
bestMatch = {
supplierInvoice: invoice,
confidence,
matchMethod: 'amount_date',
}
}
}
}
// Pass 4: Fuzzy amount (±0.01) + supplier name in description → 0.70
const fuzzyAmountMatch = Math.abs(txAmount - remaining) <= 0.01
const supplierName = invoice.supplier?.name
if (fuzzyAmountMatch && supplierName) {
const txDesc = (transaction.description || '').toLowerCase()
const normalizedName = supplierName.toLowerCase()
// Check if any significant word from the supplier name appears in the description
const nameWords = normalizedName
.replace(/[^\w\såäöé]/g, '')
.split(/\s+/)
.filter((w) => w.length >= 3)
const nameInDesc = nameWords.some((word) => txDesc.includes(word))
if (nameInDesc) {
const confidence = 0.70
if (!bestMatch || confidence > bestMatch.confidence) {
bestMatch = {
supplierInvoice: invoice,
confidence,
matchMethod: 'fuzzy_name',
}
}
}
}
}
return bestMatch
}
+38
View File
@@ -134,6 +134,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query (no booked transactions)
enqueue({ data: [], error: null })
// Supplier invoices fetch (no unpaid invoices)
enqueue({ data: [], error: null })
// Dedup check returns null (no existing row)
enqueue({ data: null, error: null })
// Insert returns the new transaction
@@ -158,6 +160,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Dedup check returns an existing record
enqueue({ data: { id: 'existing-tx-1' }, error: null })
@@ -177,6 +181,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Dedup check: no duplicate
enqueue({ data: null, error: null })
// Insert fails
@@ -203,6 +209,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert returns the new transaction
@@ -243,6 +251,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
@@ -272,6 +282,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
@@ -308,6 +320,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
@@ -337,6 +351,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
@@ -365,6 +381,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Transaction 1: dedup (no match), insert OK
enqueue({ data: null, error: null })
enqueue({ data: inserted1, error: null })
@@ -402,6 +420,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Transaction rawNew: dedup (no match), insert OK
enqueue({ data: null, error: null })
enqueue({ data: insertedNew, error: null })
@@ -474,6 +494,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
enqueue({ data: null, error: null })
enqueue({ data: inserted, error: null })
@@ -498,6 +520,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
enqueue({ data: null, error: null })
enqueue({ data: inserted, error: null })
@@ -548,6 +572,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
@@ -590,6 +616,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
@@ -617,6 +645,8 @@ describe('ingestTransactions', () => {
// Booked transaction map query
enqueue({ data: [], error: null })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
@@ -647,6 +677,8 @@ describe('ingestTransactions', () => {
data: [{ date: '2024-06-15', amount: -250 }],
error: null,
})
// Supplier invoices fetch
enqueue({ data: [], error: null })
// external_id dedup: no match (different source)
enqueue({ data: null, error: null })
@@ -670,6 +702,8 @@ describe('ingestTransactions', () => {
data: [{ date: '2024-06-15', amount: -250 }],
error: null,
})
// Supplier invoices fetch
enqueue({ data: [], error: null })
// external_id dedup: no match
enqueue({ data: null, error: null })
// Insert
@@ -702,6 +736,8 @@ describe('ingestTransactions', () => {
],
error: null,
})
// Supplier invoices fetch
enqueue({ data: [], error: null })
// raw1: external_id dedup (no match) -> content dedup matches (bookedCount=2 -> 1)
enqueue({ data: null, error: null })
@@ -726,6 +762,8 @@ describe('ingestTransactions', () => {
// Booked map query throws (caught by try/catch in buildBookedTransactionMap)
enqueue({ error: { message: 'Query failed' } })
// Supplier invoices fetch
enqueue({ data: [], error: null })
// external_id dedup: no match
enqueue({ data: null, error: null })
// Insert
+47 -1
View File
@@ -2,9 +2,10 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching'
import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching'
import { tryReconcileTransaction, fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
import type { UnlinkedGLLine } from '@/lib/reconciliation/bank-reconciliation'
import type { Transaction, RawTransaction, IngestResult } from '@/types'
import type { Transaction, RawTransaction, IngestResult, SupplierInvoice } from '@/types'
// Re-export types for backward compatibility
export type { RawTransaction, IngestResult } from '@/types'
@@ -92,6 +93,21 @@ export async function ingestTransactions(
// Non-critical — reconciliation will be skipped
}
// Pre-fetch unpaid supplier invoices for expense matching (non-critical)
let unpaidSupplierInvoices: SupplierInvoice[] = []
try {
const { data } = await supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(*)')
.eq('user_id', userId)
.in('status', ['registered', 'approved'])
.gt('remaining_amount', 0)
if (data) unpaidSupplierInvoices = data as SupplierInvoice[]
} catch {
// Non-critical — supplier invoice matching will be skipped
}
for (const raw of rawTransactions) {
// 1. Check for duplicates via external_id
const { data: existing } = await supabase
@@ -193,6 +209,36 @@ export async function ingestTransactions(
}
}
// 3b. For expense transactions, try supplier invoice matching
if (newTransaction.amount < 0 && unpaidSupplierInvoices.length > 0) {
try {
const match = findSupplierInvoiceMatch(
newTransaction as Transaction,
unpaidSupplierInvoices
)
if (match) {
if (match.confidence >= 0.85) {
// Auto-link at high confidence
await supabase
.from('transactions')
.update({ supplier_invoice_id: match.supplierInvoice.id })
.eq('id', newTransaction.id)
result.auto_matched_invoices++
} else {
// Store as suggestion at lower confidence (0.70–0.85)
await supabase
.from('transactions')
.update({ potential_supplier_invoice_id: match.supplierInvoice.id })
.eq('id', newTransaction.id)
}
}
} catch {
// Non-critical — continue processing
}
}
// 4. Evaluate mapping rules for auto-categorization
try {
const mappingResult = await evaluateMappingRules(
+36 -1
View File
@@ -15,7 +15,7 @@
DO $$
DECLARE
-- >>> SET THE TARGET USER EMAIL HERE <<<
target_email TEXT := 'user@example.com';
target_email TEXT := 'jakob.wennberg@arcim.io';
target_user_id UUID;
BEGIN
-- Resolve email to user ID
@@ -45,6 +45,17 @@ BEGIN
ALTER TABLE public.audit_log DISABLE TRIGGER USER;
ALTER TABLE public.company_settings DISABLE TRIGGER USER;
ALTER TABLE public.profiles DISABLE TRIGGER USER;
ALTER TABLE public.chat_sessions DISABLE TRIGGER USER;
ALTER TABLE public.invoice_inbox_items DISABLE TRIGGER USER;
-- Temporarily drop fiscal period constraints (migrations 042-043).
-- The NOT VALID CHECK constraints are still enforced on UPDATE, so
-- the circular-FK-breaking UPDATEs below will fail on rows with
-- non-conforming dates. The EXCLUDE constraint can also interfere.
-- We re-add them all after deletion.
ALTER TABLE public.fiscal_periods DROP CONSTRAINT IF EXISTS no_overlapping_fiscal_periods;
ALTER TABLE public.fiscal_periods DROP CONSTRAINT IF EXISTS fiscal_period_start_first_of_month;
ALTER TABLE public.fiscal_periods DROP CONSTRAINT IF EXISTS fiscal_period_end_last_of_month;
-- Break circular / self-referencing FK constraints
UPDATE public.fiscal_periods SET closing_entry_id = NULL, opening_balance_entry_id = NULL, previous_period_id = NULL WHERE user_id = target_user_id;
@@ -61,6 +72,7 @@ BEGIN
DELETE FROM public.invoice_reminders WHERE user_id = target_user_id;
DELETE FROM public.supplier_invoice_items WHERE supplier_invoice_id IN (SELECT id FROM public.supplier_invoices WHERE user_id = target_user_id);
DELETE FROM public.supplier_invoice_payments WHERE supplier_invoice_id IN (SELECT id FROM public.supplier_invoices WHERE user_id = target_user_id);
DELETE FROM public.invoice_inbox_items WHERE user_id = target_user_id;
DELETE FROM public.document_attachments WHERE user_id = target_user_id;
DELETE FROM public.journal_entry_lines WHERE journal_entry_id IN (SELECT id FROM public.journal_entries WHERE user_id = target_user_id);
DELETE FROM public.journal_entries WHERE user_id = target_user_id;
@@ -85,6 +97,9 @@ BEGIN
DELETE FROM public.cost_centers WHERE user_id = target_user_id;
DELETE FROM public.projects WHERE user_id = target_user_id;
DELETE FROM public.bank_file_imports WHERE user_id = target_user_id;
DELETE FROM public.chat_messages WHERE user_id = target_user_id;
DELETE FROM public.chat_sessions WHERE user_id = target_user_id;
DELETE FROM public.extension_data WHERE user_id = target_user_id;
DELETE FROM public.audit_log WHERE user_id = target_user_id;
DELETE FROM public.extension_toggles WHERE user_id = target_user_id;
DELETE FROM public.company_settings WHERE user_id = target_user_id;
@@ -107,6 +122,26 @@ BEGIN
ALTER TABLE public.audit_log ENABLE TRIGGER USER;
ALTER TABLE public.company_settings ENABLE TRIGGER USER;
ALTER TABLE public.profiles ENABLE TRIGGER USER;
ALTER TABLE public.chat_sessions ENABLE TRIGGER USER;
ALTER TABLE public.invoice_inbox_items ENABLE TRIGGER USER;
-- Re-add fiscal period constraints
ALTER TABLE public.fiscal_periods
ADD CONSTRAINT no_overlapping_fiscal_periods
EXCLUDE USING gist (
user_id WITH =,
daterange(period_start, period_end, '[]') WITH &&
);
ALTER TABLE public.fiscal_periods
ADD CONSTRAINT fiscal_period_start_first_of_month
CHECK (EXTRACT(DAY FROM period_start) = 1)
NOT VALID;
ALTER TABLE public.fiscal_periods
ADD CONSTRAINT fiscal_period_end_last_of_month
CHECK (period_end = (date_trunc('month', period_end) + interval '1 month - 1 day')::date)
NOT VALID;
-- Delete the auth user
DELETE FROM auth.users WHERE id = target_user_id;
-36
View File
@@ -1,36 +0,0 @@
import { cpSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
const root = process.cwd()
const extensions = ['receipt-ocr', 'ai-categorization', 'ai-chat', 'push-notifications', 'enable-banking', 'example-logger']
for (const ext of extensions) {
const src = join(root, 'extensions', ext)
const dest = join(root, 'extensions', 'general', ext)
if (!existsSync(src)) {
console.log(`SKIP: ${src} does not exist`)
continue
}
if (existsSync(dest)) {
console.log(`CLEAN: ${dest} already exists, removing`)
rmSync(dest, { recursive: true, force: true })
}
console.log(`COPY: ${src} -> ${dest}`)
cpSync(src, dest, { recursive: true })
}
console.log('Done copying extensions to general/')
// Now remove the old directories
for (const ext of extensions) {
const src = join(root, 'extensions', ext)
if (existsSync(src)) {
console.log(`REMOVE: ${src}`)
rmSync(src, { recursive: true, force: true })
}
}
console.log('Done removing old extension directories')
-33
View File
@@ -1,33 +0,0 @@
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const extsDir = path.join(root, 'extensions');
const generalDir = path.join(extsDir, 'general');
const dirs = [
'receipt-ocr',
'ai-categorization',
'ai-chat',
'push-notifications',
'enable-banking',
'example-logger',
];
if (!fs.existsSync(generalDir)) {
fs.mkdirSync(generalDir, { recursive: true });
}
for (const ext of dirs) {
const src = path.join(extsDir, ext);
const dest = path.join(generalDir, ext);
if (fs.existsSync(src)) {
fs.cpSync(src, dest, { recursive: true });
fs.rmSync(src, { recursive: true, force: true });
console.log('Done: ' + ext);
} else {
console.log('Skip: ' + ext);
}
}
console.log('All done.');
-1
View File
@@ -1 +0,0 @@
const fs=require('fs'),p=require('path'),r=p.resolve(__dirname,'..'),b=p.join(r,'extensions'),t=p.join(b,'general'),d=['receipt-ocr','ai-categorization','ai-chat','push-notifications','enable-banking','example-logger'];fs.mkdirSync(t,{recursive:!0});d.forEach(n=>{const s=p.join(b,n),e=p.join(t,n);fs.existsSync(s)?(fs.cpSync(s,e,{recursive:!0}),fs.rmSync(s,{recursive:!0,force:!0}),console.log(n)):console.log('!'+n)});
@@ -0,0 +1,165 @@
-- Migration 42: Full BAS 2026 support
-- Adds k2_excluded column and backfills K2-excluded account numbers.
-- =============================================================================
-- 1. Add k2_excluded column
-- =============================================================================
ALTER TABLE public.chart_of_accounts
ADD COLUMN IF NOT EXISTS k2_excluded boolean DEFAULT false;
-- =============================================================================
-- 2. Backfill k2_excluded = true for BAS 2026 K2-excluded accounts
-- These accounts are marked with # in BAS Kontoplan 2026 v1.0 and should
-- not be used when K2 accounting framework is applied.
-- =============================================================================
UPDATE public.chart_of_accounts
SET k2_excluded = true, updated_at = now()
WHERE account_number IN (
'1010', '1011', '1012', '1018', '1019',
'1370',
'1518',
'2092', '2096',
'2240',
'2448',
'3940',
'7940',
'8290', '8291', '8295',
'8320', '8321', '8325',
'8450', '8451', '8455',
'8480',
'8940'
)
AND k2_excluded = false;
-- =============================================================================
-- 3. Re-run SRU code backfill for any accounts with sru_code IS NULL
-- Uses the same range logic as migration 021.
-- =============================================================================
-- NE: R1 - Försäljning med moms (3000-3499 excl 3100)
UPDATE public.chart_of_accounts
SET sru_code = '7310', updated_at = now()
WHERE sru_code IS NULL
AND account_number >= '3000' AND account_number <= '3499'
AND account_number != '3100';
-- NE: R2 - Momsfria intäkter (3100, 3900, 3970-3980)
UPDATE public.chart_of_accounts
SET sru_code = '7311', updated_at = now()
WHERE sru_code IS NULL
AND (
account_number = '3100'
OR account_number = '3900'
OR (account_number >= '3970' AND account_number <= '3980')
);
-- NE: R4 - Ränteintäkter (8310-8330)
UPDATE public.chart_of_accounts
SET sru_code = '7313', updated_at = now()
WHERE sru_code IS NULL
AND account_number >= '8310' AND account_number <= '8330';
-- NE: R5 - Varuinköp (4000-4990)
UPDATE public.chart_of_accounts
SET sru_code = '7320', updated_at = now()
WHERE sru_code IS NULL
AND account_number >= '4000' AND account_number <= '4990';
-- NE: R6 - Övriga kostnader (5000-6990, 7970)
UPDATE public.chart_of_accounts
SET sru_code = '7321', updated_at = now()
WHERE sru_code IS NULL
AND (
(account_number >= '5000' AND account_number <= '6990')
OR account_number = '7970'
);
-- NE: R7 - Lönekostnader (7000-7699)
UPDATE public.chart_of_accounts
SET sru_code = '7322', updated_at = now()
WHERE sru_code IS NULL
AND account_number >= '7000' AND account_number <= '7699';
-- NE: R8 - Räntekostnader (8400-8499)
UPDATE public.chart_of_accounts
SET sru_code = '7323', updated_at = now()
WHERE sru_code IS NULL
AND account_number >= '8400' AND account_number <= '8499';
-- NE: R9 - Avskrivningar fastighet (7820)
UPDATE public.chart_of_accounts
SET sru_code = '7324', updated_at = now()
WHERE sru_code IS NULL
AND account_number = '7820';
-- NE: R10 - Avskrivningar övrigt (7700-7899 excl 7820)
UPDATE public.chart_of_accounts
SET sru_code = '7325', updated_at = now()
WHERE sru_code IS NULL
AND account_number >= '7700' AND account_number <= '7899'
AND account_number != '7820';
-- INK2: Balance sheet fallbacks
UPDATE public.chart_of_accounts SET sru_code = '7201', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '1000' AND account_number <= '1099';
UPDATE public.chart_of_accounts SET sru_code = '7202', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '1100' AND account_number <= '1299';
UPDATE public.chart_of_accounts SET sru_code = '7203', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '1300' AND account_number <= '1399';
UPDATE public.chart_of_accounts SET sru_code = '7210', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '1400' AND account_number <= '1499';
UPDATE public.chart_of_accounts SET sru_code = '7211', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '1500' AND account_number <= '1599';
UPDATE public.chart_of_accounts SET sru_code = '7212', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '1600' AND account_number <= '1999';
UPDATE public.chart_of_accounts SET sru_code = '7220', updated_at = now()
WHERE sru_code IS NULL AND account_number = '2081';
UPDATE public.chart_of_accounts SET sru_code = '7221', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '2085' AND account_number <= '2098';
UPDATE public.chart_of_accounts SET sru_code = '7222', updated_at = now()
WHERE sru_code IS NULL AND account_number = '2099';
UPDATE public.chart_of_accounts SET sru_code = '7230', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '2100' AND account_number <= '2499';
UPDATE public.chart_of_accounts SET sru_code = '7231', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '2500' AND account_number <= '2999';
-- INK2: Remaining income statement fallbacks
UPDATE public.chart_of_accounts SET sru_code = '7310', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '3000' AND account_number <= '3999';
UPDATE public.chart_of_accounts SET sru_code = '7320', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '4000' AND account_number <= '4999';
UPDATE public.chart_of_accounts SET sru_code = '7330', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '5000' AND account_number <= '6999';
UPDATE public.chart_of_accounts SET sru_code = '7340', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '7000' AND account_number <= '7699';
UPDATE public.chart_of_accounts SET sru_code = '7350', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '7700' AND account_number <= '7899';
UPDATE public.chart_of_accounts SET sru_code = '7360', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '7900' AND account_number <= '7999';
UPDATE public.chart_of_accounts SET sru_code = '7370', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '8000' AND account_number <= '8499';
UPDATE public.chart_of_accounts SET sru_code = '7380', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '8500' AND account_number <= '8999';
-- Equity accounts not covered above (2000-2084)
UPDATE public.chart_of_accounts SET sru_code = '7221', updated_at = now()
WHERE sru_code IS NULL AND account_number >= '2000' AND account_number <= '2084';
@@ -0,0 +1,23 @@
-- Document matching: add columns to invoice_inbox_items for transaction matching
-- and AI template suggestions.
-- Suggested booking template from AI extraction
ALTER TABLE public.invoice_inbox_items
ADD COLUMN suggested_template_id TEXT,
ADD COLUMN suggested_template_confidence NUMERIC;
-- Matched bank transaction
ALTER TABLE public.invoice_inbox_items
ADD COLUMN matched_transaction_id UUID REFERENCES public.transactions(id) ON DELETE SET NULL,
ADD COLUMN match_confidence NUMERIC,
ADD COLUMN match_method TEXT CHECK (match_method IN ('payment_reference', 'amount_date', 'amount_merchant', 'receipt_match'));
-- Index for looking up which inbox item is matched to a transaction
CREATE INDEX idx_inbox_items_matched_transaction
ON public.invoice_inbox_items (user_id, matched_transaction_id)
WHERE matched_transaction_id IS NOT NULL;
-- Index for finding unmatched ready items for sweep
CREATE INDEX idx_inbox_items_unmatched_ready
ON public.invoice_inbox_items (user_id, status)
WHERE matched_transaction_id IS NULL AND status IN ('ready', 'processing');
+12
View File
@@ -118,6 +118,9 @@ export function makeReceipt(overrides: Partial<Receipt> = {}): Receipt {
is_foreign_merchant: false,
representation_persons: null,
representation_purpose: null,
representation_business_connection: null,
source: 'upload',
email_from: null,
matched_transaction_id: null,
match_confidence: null,
raw_extraction: null,
@@ -145,6 +148,7 @@ export function makeTransaction(overrides: Partial<Transaction> = {}): Transacti
invoice_id: null,
supplier_invoice_id: null,
potential_invoice_id: null,
potential_supplier_invoice_id: null,
journal_entry_id: null,
mcc_code: null,
merchant_name: 'ICA Maxi',
@@ -467,6 +471,14 @@ export function makeInvoiceInboxItem(
matched_supplier_id: null,
created_supplier_invoice_id: null,
error_message: null,
document_type: 'supplier_invoice',
linked_receipt_id: null,
raw_email_payload: null,
suggested_template_id: null,
suggested_template_confidence: null,
matched_transaction_id: null,
match_confidence: null,
match_method: null,
created_at: '2024-06-15T14:30:00Z',
updated_at: '2024-06-15T14:30:00Z',
...overrides,
+33 -1
View File
@@ -223,6 +223,9 @@ export interface Transaction {
// Potential invoice match (suggested, not confirmed)
potential_invoice_id: string | null
// Potential supplier invoice match (suggested, not confirmed)
potential_supplier_invoice_id: string | null
// Bookkeeping
journal_entry_id: string | null
mcc_code: number | null
@@ -759,7 +762,7 @@ export interface TaxEstimate {
export type RiskLevel = 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'VERY_HIGH'
// Account types
export type AccountType = 'asset' | 'equity' | 'liability' | 'revenue' | 'expense'
export type AccountType = 'asset' | 'equity' | 'liability' | 'revenue' | 'expense' | 'untaxed_reserves'
export type NormalBalance = 'debit' | 'credit'
export type PlanType = 'k1' | 'full_bas'
@@ -810,6 +813,7 @@ export interface BASAccount {
default_vat_code: string | null
description: string | null
sru_code: string | null
k2_excluded: boolean
sort_order: number
created_at: string
updated_at: string
@@ -1344,6 +1348,9 @@ export interface SIEAccountMapping {
export type InboxItemStatus = 'pending' | 'processing' | 'ready' | 'confirmed' | 'rejected' | 'error'
export type InboxItemSource = 'email' | 'upload'
// Document classification type for unified inbox routing
export type DocumentClassificationType = 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown'
export interface InvoiceInboxItem {
id: string
user_id: string
@@ -1358,6 +1365,21 @@ export interface InvoiceInboxItem {
matched_supplier_id: string | null
created_supplier_invoice_id: string | null
error_message: string | null
// Unified document inbox fields
document_type: DocumentClassificationType
linked_receipt_id: string | null
raw_email_payload: Record<string, unknown> | null
// AI template suggestion
suggested_template_id: string | null
suggested_template_confidence: number | null
// Transaction matching
matched_transaction_id: string | null
match_confidence: number | null
match_method: 'payment_reference' | 'amount_date' | 'amount_merchant' | 'receipt_match' | null
created_at: string
updated_at: string
@@ -1365,6 +1387,7 @@ export interface InvoiceInboxItem {
document?: DocumentAttachment
supplier?: Supplier
supplier_invoice?: SupplierInvoice
receipt?: Receipt
}
// ============================================================
@@ -1405,6 +1428,11 @@ export interface Receipt {
// Restaurant representation data
representation_persons: number | null
representation_purpose: string | null
representation_business_connection: string | null
// Source tracking (for email-originated receipts)
source: 'upload' | 'camera' | 'email'
email_from: string | null
// Transaction matching
matched_transaction_id: string | null
@@ -1472,6 +1500,7 @@ export interface ReceiptExtractionResult {
isForeignMerchant: boolean
}
confidence: number
suggestedTemplateId?: string
}
// Extracted line item from AI
@@ -1482,6 +1511,7 @@ export interface ExtractedLineItem {
lineTotal: number
vatRate: number | null
suggestedCategory: string | null
suggestedTemplateId?: string
confidence?: number
}
@@ -1939,6 +1969,7 @@ export interface InvoiceExtractionResult {
}
vatBreakdown: VatBreakdownItem[]
confidence: number
suggestedTemplateId?: string
}
export interface ExtractedInvoiceLineItem {
@@ -1948,6 +1979,7 @@ export interface ExtractedInvoiceLineItem {
lineTotal: number
vatRate: number | null
accountSuggestion: string | null
suggestedTemplateId?: string
}
export interface VatBreakdownItem {