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:
Jakob Wennberg
2026-03-25 14:54:53 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent d8e0a22495
commit d9c95a7b59
22 changed files with 2022 additions and 388 deletions
@@ -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 } })
}
+27 -4
View File
@@ -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', () => ({
+35 -4
View File
@@ -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