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