feat: counterparty templates with multi-line patterns, batch matching, and settings cleanup (#118)
* feat: separate AR/AP/accounting into distinct nav groups (#92) Split the flat "Finans" sidebar group into three visually distinct sections — Försäljning (AR), Inköp (AP), and Redovisning — so users coming from Fortnox immediately find customer invoicing and supplier invoices as top-level concepts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: journal entry detail view, correction chain, and account name display - Add journal entry detail page at /bookkeeping/[id] with full entry view - Add correction chain API and component showing storno relationships - Add JournalEntryStatusBadge component for entry status display - Show debit/credit account names in template picker and review dialogs - Expand client-side BAS account name mapping with additional accounts - Show account codes on transaction inbox suggestion buttons Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback — N+1 query, duplicate name, nav dedup - Batch reverse-lookup into single query per BFS iteration (was N+1) - Differentiate account 2393 from 2893 in display names - Extract shared loop for desktop/mobile nav group rendering Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: counterparty templates, Skatteverket extension, VAT form completeness, and UI cleanup - Add counterparty-based categorization templates (learned from user approvals and auto-ingestion) with fuzzy matching in the mapping engine - Add Skatteverket extension for direct VAT declaration submission via API - Complete VAT declaration form with all 30 SKV 4700 boxes (ruta 08, 35-42, 50, 60-62) - Fix ruta 49 formula to include import VAT (ruta 60+61+62) - Simplify dashboard UI: remove redundant icons from stat cards, customer cards, invoice list, supplier invoices; use Badge variants consistently - Add SkatteverketPanel component to reports page - Add categorization_templates and skatteverket_tokens migrations - Update tests and helpers for new types Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review feedback — VAT detection, migration timestamps, dedup - Fix detectVatTreatment to derive actual rate (12%/6%) from VAT line description instead of hardcoding standard_25 - Rename skatteverket_tokens migration to 20260324120001 to avoid duplicate timestamp with categorization_templates (fixes Supabase deployment failure) - Make refreshAccessToken accept previousRefreshCount param to enforce refresh limit contract at the type level - Fix rate limiter TOCTOU by claiming slot before await - Extract formatRedovisare/formatRedovisningsperiod to shared lib/skatteverket/format.ts — eliminates duplication between mappers.ts and SkatteverketPanel.tsx Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: multi-line counterparty templates, batch matching, settings restructure Counterparty template engine: - Multi-line booking patterns (line_pattern JSONB) for complex entries with split VAT, tax accounts, and ratio-based allocation - Batch matching (findCounterpartyTemplatesBatch) — 1 DB query for all transactions instead of up to 3 per transaction - SIE voucher population (populateTemplatesFromSieVouchers) — extracts patterns from historical vouchers on import with dominance filtering - Source priority system (user_approved > sie_import > auto_learned) - Centralized counterparty: prefix helpers to prevent string fragility - Fix: re-approval path now updates line_pattern Transaction categorization: - /describe route returns counterparty_match in parallel with templates/AI - /categorize route accepts counterparty_template_id for direct booking - /suggest-categories uses batch matching, injects counterparty suggestions - transaction-entries supports all_lines_complete for multi-line patterns UI: - TemplatePicker shows "Tidigare motparter" section (no AI extension needed) - DescribeTransactionDialog shows counterparty match card with detail - QuickReviewDialog supports counterparty line patterns - JournalEntryPreview renders multi-line patterns with VAT/ratio math - Inline LinePatternEntry types replaced with shared import from @/types Settings restructure: - 8 tabs → 5: merged Säkerhet + Utseende + Kalender into Konto - Renamed "Motparter" → "Mallar" - CounterpartyTemplatesPanel: click-to-expand detail view with account lines, VAT, confidence, aliases, and delete Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — account_override guard, DELETE body parsing, stale test - Block account_override when counterparty_template_id is set (prevents corrupting stored template via override → upsert correction path) - Wrap DELETE request.json() in try-catch for malformed body (400 not 500) - Clean up stale 3-query mock enqueues in test for batch-based find Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d8e0a22495
commit
d9c95a7b59
@@ -0,0 +1,44 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('categorization_templates')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.order('occurrence_count', { ascending: false })
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
let id: string | undefined
|
||||
try {
|
||||
const body = await request.json()
|
||||
id = body?.id
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
|
||||
|
||||
const { error } = await supabase
|
||||
.from('categorization_templates')
|
||||
.update({ is_active: false })
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data: { success: true } })
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mappi
|
||||
import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEntity } from '@/lib/bookkeeping/booking-templates'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { upsertCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import type { CategorizationTemplate } from '@/types'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CategorizeTransactionSchema } from '@/lib/api/schemas'
|
||||
import type { Transaction, TransactionCategory, EntityType } from '@/types'
|
||||
@@ -179,9 +180,30 @@ export async function POST(
|
||||
console.log(`[categorize] tx=${id} will confirm inbox item=${body.inbox_item_id} and link document`)
|
||||
}
|
||||
|
||||
// Build mapping result from template or category
|
||||
// Build mapping result from template, counterparty template, or category
|
||||
let mappingResult
|
||||
if (body.template_id) {
|
||||
if (body.counterparty_template_id && is_business) {
|
||||
// Counterparty template — look up and build full multi-line MappingResult
|
||||
const { data: cpTemplate } = await supabase
|
||||
.from('categorization_templates')
|
||||
.select('*')
|
||||
.eq('id', body.counterparty_template_id)
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.maybeSingle()
|
||||
|
||||
if (!cpTemplate) {
|
||||
return NextResponse.json({ error: 'Counterparty template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const match = {
|
||||
template: cpTemplate as CategorizationTemplate,
|
||||
matchMethod: 'exact_alias' as const,
|
||||
confidence: Number(cpTemplate.confidence),
|
||||
}
|
||||
mappingResult = buildMappingResultFromCounterpartyTemplate(match, transaction as Transaction, entityType)
|
||||
console.log(`[categorize] tx=${id} using counterparty template="${cpTemplate.counterparty_name}" lines=${cpTemplate.line_pattern ? 'multi' : 'simple'}`)
|
||||
} else if (body.template_id) {
|
||||
const template = getTemplateById(body.template_id)!
|
||||
mappingResult = buildMappingResultFromTemplate(
|
||||
template,
|
||||
@@ -201,11 +223,12 @@ export async function POST(
|
||||
console.log(`[categorize] tx=${id} mapping result:`, {
|
||||
debit: mappingResult.debit_account,
|
||||
credit: mappingResult.credit_account,
|
||||
allLinesComplete: mappingResult.all_lines_complete || false,
|
||||
vatLines: mappingResult.vat_lines.map((v) => `${v.account_number} debit=${v.debit_amount} credit=${v.credit_amount}`),
|
||||
})
|
||||
|
||||
// Apply account override if provided (only for category-based booking, not templates)
|
||||
if (is_business && body.account_override && !body.template_id) {
|
||||
if (is_business && body.account_override && !body.template_id && !body.counterparty_template_id) {
|
||||
// Validate the account exists in the user's chart of accounts
|
||||
const { data: accountExists } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
// Mock init
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
// Mock counterparty template lookup
|
||||
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
|
||||
findCounterpartyTemplate: vi.fn().mockResolvedValue(null),
|
||||
buildMappingResultFromCounterpartyTemplate: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock extension registry
|
||||
const mockFindSimilarTemplates = vi.fn().mockResolvedValue([])
|
||||
vi.mock('@/lib/extensions/registry', () => ({
|
||||
|
||||
@@ -5,6 +5,7 @@ import { validateBody } from '@/lib/api/validate'
|
||||
import { DescribeTransactionSchema } from '@/lib/api/schemas'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
|
||||
import { findCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import type { Transaction, EntityType, VatTreatment } from '@/types'
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
|
||||
@@ -108,14 +109,43 @@ export async function POST(
|
||||
|
||||
const aiExt = extensionRegistry.get('ai-categorization')
|
||||
|
||||
// Run template matching and AI analysis in parallel
|
||||
const [templates, aiSuggestion] = await Promise.all([
|
||||
// Run template matching, counterparty lookup, and AI analysis in parallel
|
||||
const [templates, counterpartyMatch, aiSuggestion] = await Promise.all([
|
||||
getTemplateMatches(aiExt, transaction as Transaction, entityType, description),
|
||||
findCounterpartyTemplate(supabase, user.id, transaction as Transaction),
|
||||
getAiAnalysis(aiExt, transaction as Transaction, entityType, description),
|
||||
])
|
||||
|
||||
// AI rescues weak templates: needs_more_detail is false when AI provided a suggestion
|
||||
const needsMoreDetail = aiSuggestion
|
||||
// Build counterparty suggestion if matched
|
||||
let counterpartySuggestion: {
|
||||
id: string
|
||||
counterparty_name: string
|
||||
debit_account: string
|
||||
credit_account: string
|
||||
vat_treatment: string | null
|
||||
confidence: number
|
||||
occurrence_count: number
|
||||
source: string
|
||||
line_pattern: unknown[] | null
|
||||
} | null = null
|
||||
|
||||
if (counterpartyMatch) {
|
||||
const tmpl = counterpartyMatch.template
|
||||
counterpartySuggestion = {
|
||||
id: tmpl.id,
|
||||
counterparty_name: formatCounterpartyName(tmpl.counterparty_name),
|
||||
debit_account: tmpl.debit_account,
|
||||
credit_account: tmpl.credit_account,
|
||||
vat_treatment: tmpl.vat_treatment,
|
||||
line_pattern: tmpl.line_pattern ?? null,
|
||||
confidence: counterpartyMatch.confidence,
|
||||
occurrence_count: tmpl.occurrence_count,
|
||||
source: tmpl.source,
|
||||
}
|
||||
}
|
||||
|
||||
// AI or counterparty match rescues weak templates
|
||||
const needsMoreDetail = (aiSuggestion || counterpartySuggestion)
|
||||
? false
|
||||
: templates.length === 0 || templates[0].confidence < 0.55
|
||||
|
||||
@@ -151,6 +181,7 @@ export async function POST(
|
||||
special_rules_sv: m.template.special_rules_sv || null,
|
||||
risk_level: m.template.risk_level,
|
||||
})),
|
||||
counterparty_match: counterpartySuggestion,
|
||||
ai_suggestion: aiSuggestion ? {
|
||||
debit_account: aiSuggestion.debitAccount,
|
||||
credit_account: aiSuggestion.creditAccount,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { findCounterpartyTemplatesBatch, formatCounterpartyName, toCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -93,6 +94,9 @@ export async function POST(request: Request) {
|
||||
.single()
|
||||
const entityType = (settings?.entity_type as EntityType) || undefined
|
||||
|
||||
// Batch counterparty template matching (1 DB query, in-memory matching)
|
||||
const counterpartyMatches = await findCounterpartyTemplatesBatch(supabase, user.id, transactions as Transaction[])
|
||||
|
||||
// Generate initial suggestions for each transaction
|
||||
const suggestions: Record<string, SuggestedCategory[]> = {}
|
||||
const template_suggestions: Record<string, SuggestedTemplate[]> = {}
|
||||
@@ -114,6 +118,30 @@ export async function POST(request: Request) {
|
||||
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined)
|
||||
}
|
||||
|
||||
// Inject counterparty template matches as top suggestions
|
||||
for (const tx of transactions) {
|
||||
const cpMatch = counterpartyMatches.get(tx.id)
|
||||
if (!cpMatch) continue
|
||||
|
||||
const tmpl = cpMatch.template
|
||||
const cpSuggestion: SuggestedTemplate = {
|
||||
template_id: toCounterpartyTemplateId(tmpl.id),
|
||||
name_sv: formatCounterpartyName(tmpl.counterparty_name),
|
||||
name_en: formatCounterpartyName(tmpl.counterparty_name),
|
||||
group: 'counterparty',
|
||||
debit_account: tmpl.debit_account,
|
||||
credit_account: tmpl.credit_account,
|
||||
confidence: cpMatch.confidence,
|
||||
description_sv: `${tmpl.occurrence_count} tidigare bokföringar`,
|
||||
risk_level: 'NONE',
|
||||
requires_review: false,
|
||||
line_pattern: tmpl.line_pattern ?? null,
|
||||
}
|
||||
|
||||
const existing = template_suggestions[tx.id] || []
|
||||
template_suggestions[tx.id] = [cpSuggestion, ...existing]
|
||||
}
|
||||
|
||||
// Inject document template suggestions from matched inbox items
|
||||
try {
|
||||
const { data: matchedInboxItems } = await supabase
|
||||
|
||||
Reference in New Issue
Block a user