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
+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
)