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
@@ -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(