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:
co-authored by
Claude Opus 4.6
parent
6956a757f3
commit
39e407644d
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user