feat: BAS data overhaul, currency revaluation, expenses, UI polish, and cleanup

- Update BAS account catalog with comprehensive SRU codes and K2 flags
- Add currency revaluation service with tests and API route
- Add expenses page and account deletion API
- Enhance booking templates with new patterns and improved tests
- Improve transaction categorization with template picker and description matching
- Polish dashboard, onboarding, import, and transaction UIs
- Refactor year-end service for multi-step closing
- Move SRU generator to ne-bilaga, remove standalone SRU export
- Remove unused dev docs, mock data, and extension hooks
- Add invoice delivery note sequences migration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-04 14:19:56 +01:00
co-authored by Claude Opus 4.6
parent a9bf24ce0b
commit 66a4027f1e
104 changed files with 4964 additions and 5501 deletions
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById, buildMappingResultFromTemplate } from '@/lib/bookkeeping/booking-templates'
import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEntity } from '@/lib/bookkeeping/booking-templates'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
import { validateBody } from '@/lib/api/validate'
@@ -158,6 +158,12 @@ export async function POST(
if (body.template_id) {
const template = getTemplateById(body.template_id)
if (template) {
// Hard entity guard — reject templates that don't match the user's entity type
const entityValidation = validateTemplateForEntity(template, entityType)
if (!entityValidation.valid) {
return NextResponse.json({ error: entityValidation.error }, { status: 400 })
}
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 {
@@ -1,27 +1,8 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { extensionRegistry } from '@/lib/extensions/registry'
import type { Transaction, TransactionCategory, EntityType } from '@/types'
// Minimum confidence threshold — below this, suggestions are considered weak
// and we trigger on-demand AI categorization to get better results.
const WEAK_SUGGESTION_THRESHOLD = 0.55
/**
* Check if suggestions are "weak" — only history-based fallbacks
* with no strong rule/pattern/AI match.
*/
function hasWeakSuggestions(result: SuggestedCategory[]): boolean {
if (result.length === 0) return true
// Weak if the best suggestion is below threshold
const bestConfidence = Math.max(...result.map((s) => s.confidence))
if (bestConfidence < WEAK_SUGGESTION_THRESHOLD) return true
// Weak if all suggestions are from history only (no rule/pattern/ai match)
if (result.every((s) => s.source === 'history')) return true
return false
}
/**
* POST /api/transactions/suggest-categories
* Batch endpoint for getting category suggestions for multiple transactions
@@ -115,7 +96,6 @@ export async function POST(request: Request) {
// Generate initial suggestions for each transaction
const suggestions: Record<string, SuggestedCategory[]> = {}
const template_suggestions: Record<string, SuggestedTemplate[]> = {}
const needsAiIds: string[] = []
for (const tx of transactions) {
let result = getSuggestedCategories(
@@ -128,14 +108,10 @@ export async function POST(request: Request) {
const aiSuggestions = aiSuggestionsMap[tx.id]
if (aiSuggestions && aiSuggestions.length > 0) {
result = mergeAiSuggestions(result, aiSuggestions, tx.amount)
} else if (hasWeakSuggestions(result)) {
// No pre-computed AI suggestion AND rule-based suggestions are weak —
// mark this transaction for on-demand AI categorization
needsAiIds.push(tx.id)
}
suggestions[tx.id] = result
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType)
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined)
}
// Inject document template suggestions from matched inbox items
@@ -183,63 +159,5 @@ export async function POST(request: Request) {
// Non-blocking
}
// Trigger on-demand AI categorization for transactions with weak suggestions
if (needsAiIds.length > 0) {
console.log(
`[suggest-categories] ${needsAiIds.length} transactions have weak suggestions, triggering on-demand AI:`,
needsAiIds.map((id) => {
const tx = transactions.find((t) => t.id === id)
return tx
? { id, description: tx.description, merchant_name: tx.merchant_name, amount: tx.amount }
: { id }
})
)
try {
const aiExt = extensionRegistry.get('ai-categorization')
if (!aiExt?.services?.categorizeTransactions) {
console.log('[suggest-categories] AI categorization extension not loaded, skipping on-demand AI')
return NextResponse.json({ suggestions, template_suggestions })
}
const aiResults: Array<{ transactionId: string; category: string; basAccount: string; confidence: number; reasoning: string; isPrivate?: boolean; templateId?: string }> = await aiExt.services.categorizeTransactions(user.id, needsAiIds)
console.log(
'[suggest-categories] AI categorization results:',
aiResults.map((r) => ({
id: r.transactionId,
category: r.category,
basAccount: r.basAccount,
confidence: r.confidence,
reasoning: r.reasoning,
isPrivate: r.isPrivate,
templateId: r.templateId,
}))
)
// Group AI results by transactionId (AI now returns 2 per transaction)
const groupedAi: Record<string, { category: string; basAccount: string; confidence: number; reasoning: string }[]> = {}
for (const aiResult of aiResults) {
if (!groupedAi[aiResult.transactionId]) {
groupedAi[aiResult.transactionId] = []
}
groupedAi[aiResult.transactionId].push({
category: aiResult.category,
basAccount: aiResult.basAccount,
confidence: aiResult.confidence,
reasoning: aiResult.reasoning,
})
}
for (const [txId, aiSuggestions] of Object.entries(groupedAi)) {
const existing = suggestions[txId] || []
const tx = transactions.find((t) => t.id === txId)
suggestions[txId] = mergeAiSuggestions(existing, aiSuggestions, tx?.amount)
}
} catch (err) {
// AI categorization is non-blocking — log and continue with existing suggestions
console.error('[suggest-categories] On-demand AI categorization failed:', err)
}
}
return NextResponse.json({ suggestions, template_suggestions })
}