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