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
@@ -39,6 +39,7 @@ import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings
|
||||
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
|
||||
import { SecuritySettings } from '@/components/settings/SecuritySettings'
|
||||
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
|
||||
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
const BankingPanel = getSettingsPanel('enable-banking')
|
||||
@@ -70,12 +71,10 @@ export default function SettingsPage() {
|
||||
|
||||
const settingsTabs = [
|
||||
{ value: 'company', label: 'Företag', show: true },
|
||||
{ value: 'banking', label: 'Bank (PSD2)', show: !settings?.is_sandbox },
|
||||
{ value: 'calendar', label: 'Kalender', show: hasCalendarExtension },
|
||||
{ value: 'security', label: 'Säkerhet', show: true },
|
||||
{ value: 'appearance', label: 'Utseende', show: true },
|
||||
{ value: 'api', label: 'API', show: hasMcpExtension },
|
||||
{ value: 'banking', label: 'Bank (PSD2)', show: !settings?.is_sandbox && hasBankingExtension },
|
||||
{ value: 'templates', label: 'Mallar', show: true },
|
||||
{ value: 'account', label: 'Konto', show: true },
|
||||
{ value: 'api', label: 'API', show: hasMcpExtension },
|
||||
].filter(t => t.show)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -670,113 +669,9 @@ export default function SettingsPage() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Calendar feed settings */}
|
||||
{hasCalendarExtension && (
|
||||
<TabsContent value="calendar">
|
||||
<CalendarFeedSettings />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Security settings */}
|
||||
<TabsContent value="security">
|
||||
<SecuritySettings />
|
||||
</TabsContent>
|
||||
|
||||
{/* Appearance settings */}
|
||||
<TabsContent value="appearance">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Utseende</CardTitle>
|
||||
<CardDescription>
|
||||
Välj hur applikationen ska se ut
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mounted && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Light */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme('light')}
|
||||
className={`group relative rounded-lg border-2 p-4 text-left transition-colors ${
|
||||
theme === 'light'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex h-20 items-end gap-1.5 rounded-md border bg-white p-2">
|
||||
<div className="h-full w-3 rounded-sm bg-[hsl(222,47%,35%)]" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,14%,96%)]" />
|
||||
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,14%,96%)]" />
|
||||
<div className="h-2 w-2/3 rounded-sm bg-[hsl(220,14%,96%)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sun className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Ljust</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Dark */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme('dark')}
|
||||
className={`group relative rounded-lg border-2 p-4 text-left transition-colors ${
|
||||
theme === 'dark'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex h-20 items-end gap-1.5 rounded-md border bg-[hsl(222,16%,10%)] p-2">
|
||||
<div className="h-full w-3 rounded-sm bg-[hsl(222,50%,55%)]" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,12%,20%)]" />
|
||||
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,12%,20%)]" />
|
||||
<div className="h-2 w-2/3 rounded-sm bg-[hsl(220,12%,20%)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Moon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Mörkt</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* System */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme('system')}
|
||||
className={`group relative rounded-lg border-2 p-4 text-left transition-colors ${
|
||||
theme === 'system'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex h-20 overflow-hidden rounded-md border">
|
||||
<div className="flex flex-1 items-end gap-1 bg-white p-2">
|
||||
<div className="h-full w-2 rounded-sm bg-[hsl(222,47%,35%)]" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,14%,96%)]" />
|
||||
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,14%,96%)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 items-end gap-1 bg-[hsl(222,16%,10%)] p-2">
|
||||
<div className="h-full w-2 rounded-sm bg-[hsl(222,50%,55%)]" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,12%,20%)]" />
|
||||
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,12%,20%)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">System</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Counterparty templates */}
|
||||
<TabsContent value="templates">
|
||||
<CounterpartyTemplatesPanel />
|
||||
</TabsContent>
|
||||
|
||||
{/* API keys */}
|
||||
@@ -786,8 +681,47 @@ export default function SettingsPage() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Account settings */}
|
||||
{/* Account — security, appearance, calendar, logout, delete */}
|
||||
<TabsContent value="account" className="space-y-6">
|
||||
{/* Appearance */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Utseende</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mounted && (
|
||||
<div className="flex gap-3">
|
||||
{([
|
||||
{ value: 'light', label: 'Ljust', icon: Sun },
|
||||
{ value: 'dark', label: 'Mörkt', icon: Moon },
|
||||
{ value: 'system', label: 'System', icon: Monitor },
|
||||
] as const).map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTheme(value)}
|
||||
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
theme === value
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security */}
|
||||
<SecuritySettings />
|
||||
|
||||
{/* Calendar feed */}
|
||||
{hasCalendarExtension && <CalendarFeedSettings />}
|
||||
|
||||
{/* Logout & delete */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kontoinställningar</CardTitle>
|
||||
|
||||
@@ -25,11 +25,22 @@ import TemplatePicker from '@/components/transactions/TemplatePicker'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types'
|
||||
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
|
||||
import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
|
||||
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType } from '@/types'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType, LinePatternEntry } from '@/types'
|
||||
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
interface QuickReviewState {
|
||||
transaction: TransactionWithInvoice
|
||||
category: TransactionCategory
|
||||
label: string
|
||||
template: BookingTemplate | null
|
||||
templateId: string | undefined
|
||||
linePattern: LinePatternEntry[] | null
|
||||
}
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const [transactions, setTransactions] = useState<TransactionWithInvoice[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -63,11 +74,7 @@ export default function TransactionsPage() {
|
||||
|
||||
// Quick review dialog (suggestion review before booking)
|
||||
const [quickReviewOpen, setQuickReviewOpen] = useState(false)
|
||||
const [quickReviewTransaction, setQuickReviewTransaction] = useState<TransactionWithInvoice | null>(null)
|
||||
const [quickReviewCategory, setQuickReviewCategory] = useState<TransactionCategory | null>(null)
|
||||
const [quickReviewLabel, setQuickReviewLabel] = useState('')
|
||||
const [quickReviewTemplate, setQuickReviewTemplate] = useState<BookingTemplate | null>(null)
|
||||
const [quickReviewTemplateId, setQuickReviewTemplateId] = useState<string | undefined>(undefined)
|
||||
const [quickReview, setQuickReview] = useState<QuickReviewState | null>(null)
|
||||
|
||||
// Describe dialog
|
||||
const [describeDialogOpen, setDescribeDialogOpen] = useState(false)
|
||||
@@ -615,9 +622,7 @@ export default function TransactionsPage() {
|
||||
function handleOpenQuickReview(transaction: TransactionWithInvoice, suggestion: SuggestedCategory) {
|
||||
const allCategories = [...EXPENSE_CATEGORIES, ...INCOME_CATEGORIES]
|
||||
const label = allCategories.find((c) => c.value === suggestion.category)?.label || suggestion.label
|
||||
setQuickReviewTransaction(transaction)
|
||||
setQuickReviewCategory(suggestion.category)
|
||||
setQuickReviewLabel(label)
|
||||
setQuickReview({ transaction, category: suggestion.category, label, template: null, templateId: undefined, linePattern: null })
|
||||
setQuickReviewOpen(true)
|
||||
}
|
||||
|
||||
@@ -625,29 +630,43 @@ export default function TransactionsPage() {
|
||||
setTemplatePickerOpen(false)
|
||||
const tx = templatePickerTransaction
|
||||
if (!tx) return
|
||||
setQuickReviewTransaction(tx)
|
||||
setQuickReviewCategory(template.fallback_category)
|
||||
setQuickReviewLabel(template.name_sv)
|
||||
setQuickReviewTemplate(template)
|
||||
setQuickReviewTemplateId(template.id)
|
||||
setQuickReview({ transaction: tx, category: template.fallback_category, label: template.name_sv, template, templateId: template.id, linePattern: null })
|
||||
setQuickReviewOpen(true)
|
||||
}
|
||||
|
||||
function handleOpenTemplateReview(transaction: TransactionWithInvoice, templateId: string) {
|
||||
if (isCounterpartyTemplateId(templateId)) {
|
||||
const cpSuggestion = templateSuggestions[transaction.id]?.find(ts => ts.template_id === templateId)
|
||||
if (!cpSuggestion) return
|
||||
setQuickReview({
|
||||
transaction,
|
||||
category: transaction.amount < 0 ? 'expense_other' : 'income_services',
|
||||
label: cpSuggestion.name_sv,
|
||||
template: { id: templateId, name_sv: cpSuggestion.name_sv } as BookingTemplate,
|
||||
templateId: undefined,
|
||||
linePattern: cpSuggestion.line_pattern ?? null,
|
||||
})
|
||||
setQuickReviewOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
const template = getTemplateById(templateId)
|
||||
if (!template) return
|
||||
setQuickReviewTransaction(transaction)
|
||||
setQuickReviewCategory(template.fallback_category)
|
||||
setQuickReviewLabel(template.name_sv)
|
||||
setQuickReviewTemplate(template)
|
||||
setQuickReviewTemplateId(template.id)
|
||||
setQuickReview({
|
||||
transaction,
|
||||
category: template.fallback_category,
|
||||
label: template.name_sv,
|
||||
template,
|
||||
templateId: template.id,
|
||||
linePattern: null,
|
||||
})
|
||||
setQuickReviewOpen(true)
|
||||
}
|
||||
|
||||
function handleChangeTemplate() {
|
||||
setQuickReviewOpen(false)
|
||||
if (quickReviewTransaction) {
|
||||
setTemplatePickerTransaction(quickReviewTransaction)
|
||||
if (quickReview?.transaction) {
|
||||
setTemplatePickerTransaction(quickReview.transaction)
|
||||
setTemplatePickerOpen(true)
|
||||
}
|
||||
}
|
||||
@@ -667,13 +686,30 @@ export default function TransactionsPage() {
|
||||
accountOverride: string | undefined,
|
||||
templateId?: string
|
||||
): Promise<string | null> {
|
||||
const journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride, templateId)
|
||||
let journalEntryId: string | null
|
||||
if (!templateId && quickReview?.template?.id && isCounterpartyTemplateId(quickReview.template.id)) {
|
||||
const cpTemplateId = extractCounterpartyId(quickReview.template.id)
|
||||
const response = await fetch(`/api/transactions/${id}/categorize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
is_business: true,
|
||||
counterparty_template_id: cpTemplateId,
|
||||
}),
|
||||
})
|
||||
const result = await response.json()
|
||||
if (!response.ok) {
|
||||
toast({ title: 'Kategorisering misslyckades', description: result.error || 'Försök igen.', variant: 'destructive' })
|
||||
return null
|
||||
}
|
||||
setExitingIds((prev) => new Set(prev).add(id))
|
||||
journalEntryId = result.journal_entry_id || null
|
||||
} else {
|
||||
journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride, templateId)
|
||||
}
|
||||
if (journalEntryId) {
|
||||
setQuickReviewOpen(false)
|
||||
setQuickReviewTransaction(null)
|
||||
setQuickReviewCategory(null)
|
||||
setQuickReviewTemplate(null)
|
||||
setQuickReviewTemplateId(undefined)
|
||||
setQuickReview(null)
|
||||
}
|
||||
return journalEntryId
|
||||
}
|
||||
@@ -854,11 +890,24 @@ export default function TransactionsPage() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Välj mall</DialogTitle>
|
||||
</DialogHeader>
|
||||
{templatePickerTransaction && (
|
||||
<div className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm">
|
||||
<span className="truncate text-muted-foreground">{templatePickerTransaction.description}</span>
|
||||
<span className="font-medium tabular-nums flex-shrink-0 ml-3">
|
||||
{templatePickerTransaction.amount > 0 ? '+' : ''}{formatCurrency(templatePickerTransaction.amount, templatePickerTransaction.currency)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<TemplatePicker
|
||||
direction={templatePickerTransaction && templatePickerTransaction.amount < 0 ? 'expense' : 'income'}
|
||||
entityType={entityType as EntityType}
|
||||
suggestedTemplates={templatePickerTransaction ? templateSuggestions[templatePickerTransaction.id] : undefined}
|
||||
onSelect={handleTemplateSelected}
|
||||
onSelectCounterparty={(templateId) => {
|
||||
if (!templatePickerTransaction) return
|
||||
setTemplatePickerOpen(false)
|
||||
handleOpenTemplateReview(templatePickerTransaction, templateId)
|
||||
}}
|
||||
/>
|
||||
<div className="pt-2 border-t">
|
||||
<Button variant="ghost" size="sm" className="w-full text-muted-foreground" onClick={handleManualBooking}>
|
||||
@@ -869,17 +918,18 @@ export default function TransactionsPage() {
|
||||
</Dialog>
|
||||
|
||||
<QuickReviewDialog
|
||||
key={quickReviewTransaction?.id ?? '' + String(quickReviewCategory) + String(quickReviewTemplateId)}
|
||||
key={quickReview?.transaction.id ?? '' + String(quickReview?.category) + String(quickReview?.templateId)}
|
||||
open={quickReviewOpen}
|
||||
onOpenChange={setQuickReviewOpen}
|
||||
transaction={quickReviewTransaction}
|
||||
category={quickReviewCategory}
|
||||
categoryLabel={quickReviewLabel}
|
||||
defaultAccount={quickReviewCategory ? getDefaultAccountForCategory(quickReviewCategory) : ''}
|
||||
defaultVat={quickReviewCategory ? (getDefaultVatTreatmentForCategory(quickReviewCategory) ?? 'none') : 'none'}
|
||||
transaction={quickReview?.transaction ?? null}
|
||||
category={quickReview?.category ?? null}
|
||||
categoryLabel={quickReview?.label ?? ''}
|
||||
defaultAccount={quickReview?.category ? getDefaultAccountForCategory(quickReview.category) : ''}
|
||||
defaultVat={quickReview?.category ? (getDefaultVatTreatmentForCategory(quickReview.category) ?? 'none') : 'none'}
|
||||
entityType={entityType as EntityType}
|
||||
template={quickReviewTemplate}
|
||||
templateId={quickReviewTemplateId}
|
||||
template={quickReview?.template ?? null}
|
||||
templateId={quickReview?.templateId}
|
||||
counterpartyLinePattern={quickReview?.linePattern ?? null}
|
||||
onConfirm={handleQuickReviewConfirm}
|
||||
onChangeTemplate={handleChangeTemplate}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Trash2, Users, ChevronDown } from 'lucide-react'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import type { CategorizationTemplate } from '@/types'
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
sie_import: 'SIE-import',
|
||||
user_approved: 'Godkänd',
|
||||
auto_learned: 'Automatisk',
|
||||
sni_default: 'Standard',
|
||||
}
|
||||
|
||||
const VAT_LABELS: Record<string, string> = {
|
||||
standard_25: '25%',
|
||||
reduced_12: '12%',
|
||||
reduced_6: '6%',
|
||||
reverse_charge: 'Omvänd',
|
||||
export: 'Export',
|
||||
exempt: 'Momsfri',
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('sv-SE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function confidenceColor(c: number): string {
|
||||
if (c >= 0.8) return 'text-emerald-600 dark:text-emerald-400'
|
||||
if (c >= 0.5) return 'text-amber-600 dark:text-amber-400'
|
||||
return 'text-muted-foreground'
|
||||
}
|
||||
|
||||
export function CounterpartyTemplatesPanel() {
|
||||
const { toast } = useToast()
|
||||
|
||||
const [templates, setTemplates] = useState<CategorizationTemplate[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settings/counterparty-templates')
|
||||
const json = await res.json()
|
||||
if (json.data) {
|
||||
setTemplates(json.data)
|
||||
}
|
||||
} catch {
|
||||
toast({ title: 'Fel', description: 'Kunde inte hämta mallar', variant: 'destructive' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates()
|
||||
}, [fetchTemplates])
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeletingId(id)
|
||||
try {
|
||||
const res = await fetch('/api/settings/counterparty-templates', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Fel', description: 'Kunde inte ta bort mall', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== id))
|
||||
if (expandedId === id) setExpandedId(null)
|
||||
toast({ title: 'Mall borttagen' })
|
||||
} catch {
|
||||
toast({ title: 'Fel', description: 'Kunde inte ta bort mall', variant: 'destructive' })
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bokföringsmallar</CardTitle>
|
||||
<CardDescription>
|
||||
Inlärda mönster som automatiskt föreslår kontering baserat på motpart.
|
||||
Mallar skapas från SIE-import och när du godkänner kategoriseringar.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Users className="h-8 w-8 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">Inga mallar ännu.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Mallar skapas automatiskt när du kategoriserar transaktioner eller importerar en SIE-fil.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{templates.map((t) => {
|
||||
const isExpanded = expandedId === t.id
|
||||
const isMultiLine = t.line_pattern && t.line_pattern.length > 0
|
||||
|
||||
return (
|
||||
<div key={t.id} className="rounded-md border overflow-hidden">
|
||||
{/* Clickable summary row */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedId(isExpanded ? null : t.id)}
|
||||
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">{formatCounterpartyName(t.counterparty_name)}</p>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
{SOURCE_LABELS[t.source] || t.source}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-muted-foreground">
|
||||
{isMultiLine ? (
|
||||
<span className="font-mono">
|
||||
{t.line_pattern!.filter(lp => lp.type === 'business').map(lp => lp.account).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-mono">{t.debit_account}</span>
|
||||
<span className="text-muted-foreground/50">→</span>
|
||||
<span className="font-mono">{t.credit_account}</span>
|
||||
</>
|
||||
)}
|
||||
{t.vat_treatment && (
|
||||
<>
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span>{VAT_LABELS[t.vat_treatment] || t.vat_treatment}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground/30">·</span>
|
||||
<span>{t.occurrence_count} ggr</span>
|
||||
<span className={`tabular-nums ${confidenceColor(Number(t.confidence))}`}>
|
||||
{Math.round(Number(t.confidence) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={`h-4 w-4 text-muted-foreground shrink-0 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{isExpanded && (
|
||||
<div className="border-t bg-muted/30 px-4 py-3 space-y-3">
|
||||
{/* Account lines */}
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">Kontering</p>
|
||||
{isMultiLine ? (
|
||||
<div className="space-y-1">
|
||||
{t.line_pattern!.map((lp, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">
|
||||
{lp.side === 'debit' ? 'Debet' : 'Kredit'}
|
||||
</Badge>
|
||||
<span className="font-mono">{formatAccountWithName(lp.account)}</span>
|
||||
{lp.type === 'vat' && lp.vat_rate && (
|
||||
<span className="text-muted-foreground">({Math.round(lp.vat_rate * 100)}% moms)</span>
|
||||
)}
|
||||
{lp.ratio !== undefined && (
|
||||
<span className="text-muted-foreground">({Math.round(lp.ratio * 100)}%)</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">Debet</Badge>
|
||||
<span className="font-mono">{formatAccountWithName(t.debit_account)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 w-14 justify-center">Kredit</Badge>
|
||||
<span className="font-mono">{formatAccountWithName(t.credit_account)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs">
|
||||
{t.vat_treatment && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms</span>
|
||||
<span>{VAT_LABELS[t.vat_treatment] || t.vat_treatment}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Antal bokföringar</span>
|
||||
<span className="tabular-nums">{t.occurrence_count}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Säkerhet</span>
|
||||
<span className={`tabular-nums ${confidenceColor(Number(t.confidence))}`}>
|
||||
{Math.round(Number(t.confidence) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Senast använd</span>
|
||||
<span>{formatDate(t.last_seen_date)}</span>
|
||||
</div>
|
||||
{t.counterparty_aliases && t.counterparty_aliases.length > 1 && (
|
||||
<div className="col-span-2 flex justify-between">
|
||||
<span className="text-muted-foreground">Alias</span>
|
||||
<span className="text-right truncate ml-4">{t.counterparty_aliases.slice(0, 3).join(', ')}{t.counterparty_aliases.length > 3 ? ` +${t.counterparty_aliases.length - 3}` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(t.id)}
|
||||
disabled={deletingId === t.id}
|
||||
className="text-destructive hover:text-destructive text-xs h-7"
|
||||
>
|
||||
{deletingId === t.id ? (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="mr-1.5 h-3 w-3" />
|
||||
)}
|
||||
Ta bort mall
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import JournalEntryPreview from './JournalEntryPreview'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import type { LinePatternEntry } from '@/types'
|
||||
|
||||
interface TemplateMatch {
|
||||
template_id: string
|
||||
@@ -46,6 +47,18 @@ interface TemplateMatch {
|
||||
risk_level: string
|
||||
}
|
||||
|
||||
interface CounterpartyMatch {
|
||||
id: string
|
||||
counterparty_name: string
|
||||
debit_account: string
|
||||
credit_account: string
|
||||
vat_treatment: string | null
|
||||
confidence: number
|
||||
occurrence_count: number
|
||||
source: string
|
||||
line_pattern: LinePatternEntry[] | null
|
||||
}
|
||||
|
||||
interface AiSuggestion {
|
||||
debit_account: string
|
||||
credit_account: string
|
||||
@@ -59,6 +72,7 @@ interface AiSuggestion {
|
||||
|
||||
interface DescribeResult {
|
||||
templates: TemplateMatch[]
|
||||
counterparty_match: CounterpartyMatch | null
|
||||
ai_suggestion: AiSuggestion | null
|
||||
needs_more_detail: boolean
|
||||
user_description: string
|
||||
@@ -75,7 +89,7 @@ interface DescribeTransactionDialogProps {
|
||||
}
|
||||
|
||||
type Step = 'describe' | 'pick' | 'batch'
|
||||
type Selection = { type: 'template'; templateId: string } | { type: 'ai' }
|
||||
type Selection = { type: 'template'; templateId: string } | { type: 'ai' } | { type: 'counterparty' }
|
||||
|
||||
function getExamplePrompts(transaction: TransactionWithInvoice): string[] {
|
||||
const desc = (transaction.description || '').toLowerCase()
|
||||
@@ -191,7 +205,14 @@ export default function DescribeTransactionDialog({
|
||||
// Build categorize request based on selection type
|
||||
let body: Record<string, unknown>
|
||||
|
||||
if (selection.type === 'template') {
|
||||
if (selection.type === 'counterparty') {
|
||||
const cp = describeResult.counterparty_match!
|
||||
body = {
|
||||
is_business: true,
|
||||
counterparty_template_id: cp.id,
|
||||
user_description: describeResult.user_description,
|
||||
}
|
||||
} else if (selection.type === 'template') {
|
||||
body = {
|
||||
is_business: true,
|
||||
template_id: selection.templateId,
|
||||
@@ -323,6 +344,8 @@ export default function DescribeTransactionDialog({
|
||||
if (!transaction) return null
|
||||
|
||||
const isIncome = transaction.amount > 0
|
||||
const counterpartyMatch = describeResult?.counterparty_match
|
||||
const isCounterpartySelected = selection?.type === 'counterparty'
|
||||
const aiSuggestion = describeResult?.ai_suggestion
|
||||
// Check if AI agrees with top template
|
||||
const topTemplate = describeResult?.templates[0]
|
||||
@@ -424,6 +447,66 @@ export default function DescribeTransactionDialog({
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto max-h-[40vh] space-y-2 pr-1">
|
||||
{/* Counterparty Match Card */}
|
||||
{counterpartyMatch && (
|
||||
<Card
|
||||
className={`cursor-pointer transition-colors hover:border-primary/50 ${
|
||||
isCounterpartySelected ? 'border-primary bg-primary/5' : ''
|
||||
}`}
|
||||
onClick={() => setSelection({ type: 'counterparty' })}
|
||||
>
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
|
||||
Tidigare bokföring
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm font-medium">{counterpartyMatch.counterparty_name}</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
|
||||
{counterpartyMatch.line_pattern && counterpartyMatch.line_pattern.length > 0 ? (
|
||||
counterpartyMatch.line_pattern.map((lp, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{formatAccountWithName(lp.account)}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
D: {formatAccountWithName(counterpartyMatch.debit_account)}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
K: {formatAccountWithName(counterpartyMatch.credit_account)}
|
||||
</Badge>
|
||||
{counterpartyMatch.vat_treatment && counterpartyMatch.vat_treatment !== 'exempt' && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
Moms {Math.round(getVatRateFromTreatment(counterpartyMatch.vat_treatment) * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{counterpartyMatch.occurrence_count} tidigare bokföringar
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Badge
|
||||
variant={counterpartyMatch.confidence >= 0.7 ? 'default' : 'outline'}
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{Math.round(counterpartyMatch.confidence * 100)}%
|
||||
</Badge>
|
||||
{isCounterpartySelected && (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* AI Suggestion Card */}
|
||||
{aiSuggestion && (
|
||||
<Card
|
||||
@@ -581,8 +664,20 @@ export default function DescribeTransactionDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Journal entry preview for selected template or AI suggestion */}
|
||||
{/* Journal entry preview for selected template, AI suggestion, or counterparty match */}
|
||||
{selection && (() => {
|
||||
if (selection.type === 'counterparty' && counterpartyMatch) {
|
||||
return (
|
||||
<JournalEntryPreview
|
||||
amount={transaction.amount}
|
||||
currency={transaction.currency}
|
||||
templateDebitAccount={counterpartyMatch.line_pattern ? undefined : counterpartyMatch.debit_account}
|
||||
templateCreditAccount={counterpartyMatch.line_pattern ? undefined : counterpartyMatch.credit_account}
|
||||
templateVatRate={counterpartyMatch.line_pattern ? undefined : getVatRateFromTreatment(counterpartyMatch.vat_treatment)}
|
||||
linePattern={counterpartyMatch.line_pattern ?? undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (selection.type === 'ai' && aiSuggestion) {
|
||||
return (
|
||||
<JournalEntryPreview
|
||||
|
||||
@@ -5,7 +5,7 @@ import { formatCurrency } from '@/lib/utils'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import { getVatRate, extractVatAmount, extractNetAmount } from '@/lib/bookkeeping/vat-entries'
|
||||
import { getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping'
|
||||
import type { TransactionCategory, VatTreatment, EntityType } from '@/types'
|
||||
import type { TransactionCategory, VatTreatment, EntityType, LinePatternEntry } from '@/types'
|
||||
|
||||
interface PreviewLine {
|
||||
side: 'debet' | 'kredit'
|
||||
@@ -24,6 +24,9 @@ interface JournalEntryPreviewProps {
|
||||
templateDebitAccount?: string
|
||||
templateCreditAccount?: string
|
||||
templateVatRate?: number
|
||||
/** For multi-line counterparty template bookings */
|
||||
linePattern?: LinePatternEntry[]
|
||||
settlementAccount?: string
|
||||
}
|
||||
|
||||
export default function JournalEntryPreview({
|
||||
@@ -36,11 +39,52 @@ export default function JournalEntryPreview({
|
||||
templateDebitAccount,
|
||||
templateCreditAccount,
|
||||
templateVatRate,
|
||||
linePattern,
|
||||
settlementAccount = '1930',
|
||||
}: JournalEntryPreviewProps) {
|
||||
const lines = useMemo(() => {
|
||||
const result: PreviewLine[] = []
|
||||
const absAmount = Math.abs(amount)
|
||||
|
||||
// Multi-line counterparty template preview
|
||||
if (linePattern && linePattern.length > 0) {
|
||||
const isIncome = amount > 0
|
||||
const settlementSide = isIncome ? 'debet' : 'kredit'
|
||||
|
||||
// Settlement line
|
||||
result.push({ side: settlementSide, account: settlementAccount, amount: absAmount })
|
||||
|
||||
// VAT lines first (from rate)
|
||||
let totalVat = 0
|
||||
for (const entry of linePattern) {
|
||||
if (entry.type === 'vat' && entry.vat_rate) {
|
||||
const vatAmt = Math.round(absAmount * entry.vat_rate / (1 + entry.vat_rate) * 100) / 100
|
||||
totalVat += vatAmt
|
||||
result.push({ side: entry.side === 'debit' ? 'debet' : 'kredit', account: entry.account, amount: vatAmt })
|
||||
}
|
||||
}
|
||||
|
||||
// Business/tax lines (from ratio against non-VAT amount)
|
||||
const nonVatAmt = Math.round((absAmount - totalVat) * 100) / 100
|
||||
let allocated = 0
|
||||
const ratioEntries = linePattern.filter(e => e.ratio !== undefined)
|
||||
for (const entry of ratioEntries) {
|
||||
const amt = Math.round(nonVatAmt * (entry.ratio ?? 0) * 100) / 100
|
||||
allocated += amt
|
||||
result.push({ side: entry.side === 'debit' ? 'debet' : 'kredit', account: entry.account, amount: amt })
|
||||
}
|
||||
|
||||
// Rounding difference to 3740
|
||||
const totalAllocated = Math.round((totalVat + allocated) * 100) / 100
|
||||
const diff = Math.round((absAmount - totalAllocated) * 100) / 100
|
||||
if (diff !== 0) {
|
||||
const businessSide = linePattern.find(e => e.type === 'business')?.side ?? 'credit'
|
||||
result.push({ side: businessSide === 'debit' ? 'debet' : 'kredit', account: '3740', amount: Math.abs(diff) })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Template-based preview
|
||||
if (templateDebitAccount && templateCreditAccount) {
|
||||
const vatRate = templateVatRate ?? 0
|
||||
@@ -93,7 +137,7 @@ export default function JournalEntryPreview({
|
||||
}
|
||||
|
||||
return result
|
||||
}, [amount, category, vatTreatment, accountOverride, entityType, templateDebitAccount, templateCreditAccount, templateVatRate])
|
||||
}, [amount, category, vatTreatment, accountOverride, entityType, templateDebitAccount, templateCreditAccount, templateVatRate, linePattern, settlementAccount])
|
||||
|
||||
if (lines.length === 0) return null
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import VatTreatmentSelect from './VatTreatmentSelect'
|
||||
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import type { TransactionCategory, VatTreatment, BASAccount, EntityType } from '@/types'
|
||||
import type { TransactionCategory, VatTreatment, BASAccount, EntityType, LinePatternEntry } from '@/types'
|
||||
|
||||
interface QuickReviewDialogProps {
|
||||
open: boolean
|
||||
@@ -30,6 +30,7 @@ interface QuickReviewDialogProps {
|
||||
entityType?: EntityType
|
||||
template?: BookingTemplate | null
|
||||
templateId?: string
|
||||
counterpartyLinePattern?: LinePatternEntry[] | null
|
||||
onConfirm: (
|
||||
id: string,
|
||||
category: TransactionCategory,
|
||||
@@ -51,6 +52,7 @@ export default function QuickReviewDialog({
|
||||
entityType,
|
||||
template,
|
||||
templateId,
|
||||
counterpartyLinePattern,
|
||||
onConfirm,
|
||||
onChangeTemplate,
|
||||
}: QuickReviewDialogProps) {
|
||||
@@ -91,6 +93,8 @@ export default function QuickReviewDialog({
|
||||
if (!transaction || !category) return null
|
||||
|
||||
const isIncome = transaction.amount > 0
|
||||
const isCounterpartyTemplate = !!(counterpartyLinePattern && counterpartyLinePattern.length > 0)
|
||||
const isTemplateBooking = !!templateId || isCounterpartyTemplate
|
||||
const isLiabilityAccount = accountOverride.startsWith('2')
|
||||
|
||||
async function handleConfirm() {
|
||||
@@ -151,7 +155,7 @@ export default function QuickReviewDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Granska bokföring</DialogTitle>
|
||||
<DialogDescription>
|
||||
Kontrollera konto och moms innan du bokför
|
||||
{isTemplateBooking ? 'Granska verifikationen innan du bokför' : 'Kontrollera konto och moms innan du bokför'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -179,13 +183,13 @@ export default function QuickReviewDialog({
|
||||
{/* Template or Category */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">
|
||||
{template ? 'Mall' : 'Kategori'}
|
||||
{isCounterpartyTemplate ? 'Motpartsmall' : template ? 'Mall' : 'Kategori'}
|
||||
</label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-sm py-1 px-3">
|
||||
{template ? template.name_sv : categoryLabel}
|
||||
</Badge>
|
||||
{onChangeTemplate && (
|
||||
{onChangeTemplate && !isCounterpartyTemplate && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary hover:underline"
|
||||
@@ -195,7 +199,7 @@ export default function QuickReviewDialog({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{template && (
|
||||
{template && !isCounterpartyTemplate && (
|
||||
<p className="mt-1.5 text-xs font-mono text-muted-foreground">
|
||||
D: {formatAccountWithName(template.debit_account)} → K: {formatAccountWithName(template.credit_account)}
|
||||
</p>
|
||||
@@ -236,52 +240,57 @@ export default function QuickReviewDialog({
|
||||
<JournalEntryPreview
|
||||
amount={transaction.amount}
|
||||
currency={transaction.currency}
|
||||
category={category}
|
||||
vatTreatment={isLiabilityAccount ? 'none' : vatTreatment}
|
||||
accountOverride={accountOverride}
|
||||
entityType={entityType}
|
||||
{...(isCounterpartyTemplate
|
||||
? { linePattern: counterpartyLinePattern ?? undefined }
|
||||
: templateId && template
|
||||
? { templateDebitAccount: template.debit_account, templateCreditAccount: template.credit_account, templateVatRate: template.vat_rate }
|
||||
: { category, vatTreatment: isLiabilityAccount ? 'none' : vatTreatment, accountOverride, entityType }
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Account */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Konto</label>
|
||||
<div className="mt-1">
|
||||
<AccountCombobox
|
||||
value={accountOverride}
|
||||
accounts={accounts}
|
||||
onChange={handleAccountChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Account & VAT — hidden for template bookings (accounts defined by the template) */}
|
||||
{!isTemplateBooking && (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Konto</label>
|
||||
<div className="mt-1">
|
||||
<AccountCombobox
|
||||
value={accountOverride}
|
||||
accounts={accounts}
|
||||
onChange={handleAccountChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VAT treatment */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
|
||||
<div className="mt-1">
|
||||
{isLiabilityAccount ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ingen moms för skuld-/eget kapital-konton
|
||||
</p>
|
||||
) : showVatDropdown ? (
|
||||
<VatTreatmentSelect
|
||||
value={vatTreatment}
|
||||
onValueChange={setVatTreatment}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm">
|
||||
{VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'}
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={() => setShowVatDropdown(true)}
|
||||
>
|
||||
Ändra
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
|
||||
<div className="mt-1">
|
||||
{isLiabilityAccount ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ingen moms för skuld-/eget kapital-konton
|
||||
</p>
|
||||
) : showVatDropdown ? (
|
||||
<VatTreatmentSelect
|
||||
value={vatTreatment}
|
||||
onValueChange={setVatTreatment}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm">
|
||||
{VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'}
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={() => setShowVatDropdown(true)}
|
||||
>
|
||||
Ändra
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Document upload */}
|
||||
<div className="rounded-lg border">
|
||||
@@ -335,7 +344,7 @@ export default function QuickReviewDialog({
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleConfirm}
|
||||
disabled={isProcessing || !accountOverride}
|
||||
disabled={isProcessing || (!isTemplateBooking && !accountOverride)}
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{isProcessing ? 'Bokför...' : 'Bokför'}
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
type TemplateGroup,
|
||||
} from '@/lib/bookkeeping/booking-templates'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { getAccountName } from '@/lib/bookkeeping/client-account-names'
|
||||
import type { EntityType } from '@/types'
|
||||
import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
|
||||
@@ -134,6 +136,7 @@ interface TemplatePickerProps {
|
||||
suggestedTemplates?: SuggestedTemplate[]
|
||||
recentTemplateIds?: string[]
|
||||
onSelect: (template: BookingTemplate) => void
|
||||
onSelectCounterparty?: (templateId: string) => void
|
||||
selectedTemplateId?: string
|
||||
}
|
||||
|
||||
@@ -142,6 +145,7 @@ export default function TemplatePicker({
|
||||
entityType,
|
||||
suggestedTemplates,
|
||||
onSelect,
|
||||
onSelectCounterparty,
|
||||
selectedTemplateId,
|
||||
}: TemplatePickerProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
@@ -196,8 +200,17 @@ export default function TemplatePicker({
|
||||
onSelect(template)
|
||||
}
|
||||
|
||||
// Suggested templates section
|
||||
const hasSuggestions = suggestedTemplates && suggestedTemplates.length > 0
|
||||
// Split suggestions: counterparty templates vs regular booking templates
|
||||
const counterpartySuggestions = useMemo(() => {
|
||||
if (!suggestedTemplates) return []
|
||||
return suggestedTemplates.filter(s => isCounterpartyTemplateId(s.template_id))
|
||||
}, [suggestedTemplates])
|
||||
const resolvedSuggestions = useMemo(() => {
|
||||
if (!suggestedTemplates) return []
|
||||
return suggestedTemplates.filter(s => !isCounterpartyTemplateId(s.template_id))
|
||||
}, [suggestedTemplates])
|
||||
const hasCounterparty = counterpartySuggestions.length > 0 && !!onSelectCounterparty
|
||||
const hasSuggestions = resolvedSuggestions.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -233,12 +246,51 @@ export default function TemplatePicker({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Counterparty templates — learned from history */}
|
||||
{hasCounterparty && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">Tidigare motparter</p>
|
||||
<div className="space-y-1.5">
|
||||
{counterpartySuggestions.slice(0, 3).map((s) => (
|
||||
<button
|
||||
key={s.template_id}
|
||||
type="button"
|
||||
onClick={() => onSelectCounterparty!(s.template_id)}
|
||||
className="w-full text-left rounded-lg border border-border px-3 py-2.5 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm leading-tight">{s.name_sv}</p>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
{s.line_pattern && s.line_pattern.length > 0 ? (
|
||||
s.line_pattern.filter(lp => lp.type === 'business').map((lp, i) => (
|
||||
<span key={i} className="text-xs font-mono text-muted-foreground">
|
||||
{formatAccountWithName(lp.account)}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
D: {getAccountName(s.debit_account)} · K: {getAccountName(s.credit_account)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground flex-shrink-0 mt-0.5">
|
||||
{s.description_sv}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggested templates */}
|
||||
{hasSuggestions && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">Föreslagna</p>
|
||||
<div className="space-y-1.5">
|
||||
{suggestedTemplates!.slice(0, 5).map((s) => {
|
||||
{resolvedSuggestions.slice(0, 5).map((s) => {
|
||||
// Find the full template object
|
||||
const fullTemplate = allCommon.find((t) => t.id === s.template_id) ||
|
||||
allAdvanced.find((t) => t.id === s.template_id)
|
||||
|
||||
@@ -8,8 +8,9 @@ import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
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 { getAccountName, formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
|
||||
import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
|
||||
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
|
||||
@@ -178,7 +179,8 @@ export default function TransactionInboxCard({
|
||||
) : templateSuggestions && templateSuggestions.length > 0 ? (
|
||||
<>
|
||||
{templateSuggestions.slice(0, 2).map((ts, idx) => {
|
||||
const tmpl = getTemplateById(ts.template_id)
|
||||
const isCounterparty = isCounterpartyTemplateId(ts.template_id)
|
||||
const tmpl = isCounterparty ? null : getTemplateById(ts.template_id)
|
||||
return (
|
||||
<Button
|
||||
key={ts.template_id}
|
||||
@@ -186,7 +188,7 @@ export default function TransactionInboxCard({
|
||||
variant={idx === 0 ? 'default' : 'outline'}
|
||||
className="h-auto py-1.5 text-xs"
|
||||
onClick={() => {
|
||||
if (onOpenTemplateReview && tmpl) {
|
||||
if (onOpenTemplateReview && (isCounterparty || tmpl)) {
|
||||
onOpenTemplateReview(transaction, ts.template_id)
|
||||
} else if (topSuggestion) {
|
||||
handleSuggestionClick(topSuggestion)
|
||||
@@ -201,8 +203,11 @@ export default function TransactionInboxCard({
|
||||
) : null}
|
||||
{ts.name_sv}
|
||||
</div>
|
||||
<span className="opacity-70 font-normal font-mono text-[10px]">
|
||||
D: {formatAccountWithName(ts.debit_account)} → K: {formatAccountWithName(ts.credit_account)}
|
||||
<span className="opacity-70 font-normal text-[10px]">
|
||||
{isCounterparty
|
||||
? `${ts.description_sv}`
|
||||
: getAccountName(tmpl?.debit_account || ts.debit_account)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -320,6 +320,7 @@ export const CategorizeTransactionSchema = z.object({
|
||||
template_id: z.string().optional(),
|
||||
vat_treatment: VatTreatmentSchema.optional(),
|
||||
account_override: accountNumber.optional(),
|
||||
counterparty_template_id: z.string().uuid().optional(),
|
||||
user_description: z.string().max(500).optional(),
|
||||
inbox_item_id: z.string().uuid().optional(),
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createQueuedMockSupabase,
|
||||
makeTransaction,
|
||||
makeCategorizationTemplate,
|
||||
makeSIEVoucher,
|
||||
} from '@/tests/helpers'
|
||||
import {
|
||||
normalizeCounterpartyName,
|
||||
@@ -11,7 +12,11 @@ import {
|
||||
findCounterpartyTemplate,
|
||||
buildMappingResultFromCounterpartyTemplate,
|
||||
upsertCounterpartyTemplate,
|
||||
resolveSource,
|
||||
insertOrUpdateTemplate,
|
||||
populateTemplatesFromSieVouchers,
|
||||
} from '../counterparty-templates'
|
||||
import type { TemplateUpsertParams } from '../counterparty-templates'
|
||||
|
||||
describe('counterparty-templates', () => {
|
||||
// ── Normalization ──────────────────────────────────────────
|
||||
@@ -107,11 +112,14 @@ describe('counterparty-templates', () => {
|
||||
})
|
||||
|
||||
it('returns exact alias match with full confidence', async () => {
|
||||
const template = makeCategorizationTemplate({ confidence: 0.8 })
|
||||
const template = makeCategorizationTemplate({
|
||||
confidence: 0.8,
|
||||
counterparty_aliases: ['telia sverige ab'],
|
||||
})
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
|
||||
// Alias match returns the template
|
||||
enqueue({ data: template })
|
||||
// Batch query returns all templates
|
||||
enqueue({ data: [template] })
|
||||
|
||||
const tx = makeTransaction({ merchant_name: 'Telia Sverige AB' })
|
||||
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
|
||||
@@ -122,11 +130,15 @@ describe('counterparty-templates', () => {
|
||||
})
|
||||
|
||||
it('falls through to exact normalized when alias misses', async () => {
|
||||
const template = makeCategorizationTemplate({ confidence: 0.8 })
|
||||
const template = makeCategorizationTemplate({
|
||||
counterparty_name: 'telia',
|
||||
confidence: 0.8,
|
||||
counterparty_aliases: ['telia ab'],
|
||||
})
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
|
||||
enqueue({ data: null }) // Alias miss
|
||||
enqueue({ data: template }) // Exact normalized hit
|
||||
// Batch query returns all templates (alias won't match 'Telia')
|
||||
enqueue({ data: [template] })
|
||||
|
||||
const tx = makeTransaction({ merchant_name: 'Telia' })
|
||||
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
|
||||
@@ -140,12 +152,12 @@ describe('counterparty-templates', () => {
|
||||
const template = makeCategorizationTemplate({
|
||||
counterparty_name: 'telia',
|
||||
confidence: 0.8,
|
||||
counterparty_aliases: ['telia ab'],
|
||||
})
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
|
||||
enqueue({ data: null }) // Alias miss
|
||||
enqueue({ data: null }) // Exact miss
|
||||
enqueue({ data: [template] }) // Fuzzy: all templates
|
||||
// Batch query returns all templates
|
||||
enqueue({ data: [template] })
|
||||
|
||||
// "teliq" has Levenshtein distance 1 from "telia"
|
||||
const tx = makeTransaction({ merchant_name: 'Teliq' })
|
||||
@@ -161,12 +173,12 @@ describe('counterparty-templates', () => {
|
||||
const template = makeCategorizationTemplate({
|
||||
counterparty_name: 'telia',
|
||||
confidence: 0.8,
|
||||
counterparty_aliases: ['telia ab'],
|
||||
})
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
|
||||
enqueue({ data: null }) // Alias miss
|
||||
enqueue({ data: null }) // Exact miss
|
||||
enqueue({ data: [template] }) // Fuzzy: all templates
|
||||
// Batch query returns all templates
|
||||
enqueue({ data: [template] })
|
||||
|
||||
// "xxxxx" has Levenshtein distance 5 from "telia"
|
||||
const tx = makeTransaction({ merchant_name: 'XXXXX' })
|
||||
@@ -178,9 +190,8 @@ describe('counterparty-templates', () => {
|
||||
it('returns null when no templates exist', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
|
||||
enqueue({ data: null }) // Alias miss
|
||||
enqueue({ data: null }) // Exact miss
|
||||
enqueue({ data: [] }) // Fuzzy: empty
|
||||
// Batch query returns empty
|
||||
enqueue({ data: [] })
|
||||
|
||||
const tx = makeTransaction({ merchant_name: 'Unknown Company' })
|
||||
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
|
||||
@@ -366,4 +377,356 @@ describe('counterparty-templates', () => {
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Source Priority ──────────────────────────────────────────
|
||||
|
||||
describe('resolveSource', () => {
|
||||
it('sie_import does not downgrade user_approved', () => {
|
||||
expect(resolveSource('user_approved', 'sie_import')).toBe('user_approved')
|
||||
})
|
||||
|
||||
it('user_approved upgrades auto_learned', () => {
|
||||
expect(resolveSource('auto_learned', 'user_approved')).toBe('user_approved')
|
||||
})
|
||||
|
||||
it('sie_import upgrades auto_learned', () => {
|
||||
expect(resolveSource('auto_learned', 'sie_import')).toBe('sie_import')
|
||||
})
|
||||
|
||||
it('auto_learned does not upgrade sie_import', () => {
|
||||
expect(resolveSource('sie_import', 'auto_learned')).toBe('sie_import')
|
||||
})
|
||||
|
||||
it('same source returns same source', () => {
|
||||
expect(resolveSource('user_approved', 'user_approved')).toBe('user_approved')
|
||||
expect(resolveSource('sie_import', 'sie_import')).toBe('sie_import')
|
||||
})
|
||||
})
|
||||
|
||||
// ── insertOrUpdateTemplate ───────────────────────────────────
|
||||
|
||||
describe('insertOrUpdateTemplate', () => {
|
||||
const baseParams: TemplateUpsertParams = {
|
||||
counterpartyName: 'telia',
|
||||
aliases: ['telia sverige ab'],
|
||||
debitAccount: '6200',
|
||||
creditAccount: '1930',
|
||||
vatTreatment: 'standard_25',
|
||||
vatAccount: '2641',
|
||||
category: null,
|
||||
occurrenceCount: 1,
|
||||
confidence: 0.45,
|
||||
lastSeenDate: '2024-06-15',
|
||||
source: 'sie_import',
|
||||
}
|
||||
|
||||
it('inserts new template when existingTemplate is null', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null }) // insert
|
||||
|
||||
await insertOrUpdateTemplate(supabase as never, 'user-1', baseParams, null)
|
||||
|
||||
expect(supabase.from).toHaveBeenCalledWith('categorization_templates')
|
||||
})
|
||||
|
||||
it('uses pre-fetched template without DB lookup', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const existing = makeCategorizationTemplate({
|
||||
debit_account: '6200',
|
||||
credit_account: '1930',
|
||||
occurrence_count: 5,
|
||||
source: 'auto_learned',
|
||||
})
|
||||
|
||||
enqueue({ data: null }) // update
|
||||
|
||||
await insertOrUpdateTemplate(supabase as never, 'user-1', baseParams, existing)
|
||||
|
||||
// Should NOT have queried for existing — only the update call
|
||||
// The from() call count indicates no select was made before update
|
||||
expect(supabase.from).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('accumulates occurrence count on re-approval', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const existing = makeCategorizationTemplate({
|
||||
debit_account: '6200',
|
||||
credit_account: '1930',
|
||||
occurrence_count: 5,
|
||||
})
|
||||
|
||||
enqueue({ data: null }) // update
|
||||
|
||||
const params = { ...baseParams, occurrenceCount: 10 }
|
||||
await insertOrUpdateTemplate(supabase as never, 'user-1', params, existing)
|
||||
|
||||
// Verify supabase.from was called (update happened)
|
||||
expect(supabase.from).toHaveBeenCalledWith('categorization_templates')
|
||||
})
|
||||
|
||||
it('respects source priority — sie_import does not overwrite user_approved', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const existing = makeCategorizationTemplate({
|
||||
debit_account: '5410', // Different accounts = correction
|
||||
credit_account: '1930',
|
||||
source: 'user_approved',
|
||||
})
|
||||
|
||||
enqueue({ data: null }) // update
|
||||
|
||||
await insertOrUpdateTemplate(supabase as never, 'user-1', baseParams, existing)
|
||||
|
||||
// The source should remain user_approved (not downgraded to sie_import)
|
||||
expect(supabase.from).toHaveBeenCalledWith('categorization_templates')
|
||||
})
|
||||
|
||||
it('does DB lookup when existingTemplate is undefined', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null }) // select returns null
|
||||
enqueue({ data: null }) // insert
|
||||
|
||||
await insertOrUpdateTemplate(supabase as never, 'user-1', baseParams)
|
||||
|
||||
// Two calls: select + insert
|
||||
expect(supabase.from).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ── populateTemplatesFromSieVouchers ─────────────────────────
|
||||
|
||||
describe('populateTemplatesFromSieVouchers', () => {
|
||||
it('returns 0 for empty vouchers', async () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', [])
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it('groups vouchers by normalized description', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const vouchers = [
|
||||
makeSIEVoucher({ description: 'Telia Sverige AB', number: 1 }),
|
||||
makeSIEVoucher({ description: 'TELIA SVERIGE', number: 2 }),
|
||||
makeSIEVoucher({ description: 'telia sverige ab', number: 3 }),
|
||||
]
|
||||
|
||||
enqueue({ data: [] }) // pre-fetch existing templates
|
||||
enqueue({ data: null }) // insert
|
||||
|
||||
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
||||
expect(result).toBe(1)
|
||||
})
|
||||
|
||||
it('skips groups with fewer than 2 occurrences', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const vouchers = [
|
||||
makeSIEVoucher({ description: 'Telia', number: 1 }),
|
||||
]
|
||||
|
||||
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts groups with sufficient dominance (75% > 60%)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const vouchers = [
|
||||
makeSIEVoucher({ description: 'Telia', number: 1, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }),
|
||||
makeSIEVoucher({ description: 'Telia', number: 2, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }),
|
||||
makeSIEVoucher({ description: 'Telia', number: 3, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }),
|
||||
makeSIEVoucher({ description: 'Telia', number: 4, lines: [{ account: '1930', amount: -1000 }, { account: '6230', amount: 1000 }] }),
|
||||
]
|
||||
|
||||
enqueue({ data: [] }) // pre-fetch
|
||||
enqueue({ data: null }) // insert
|
||||
|
||||
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
||||
expect(result).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects groups with insufficient dominance (50% < 60%)', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const vouchers = [
|
||||
makeSIEVoucher({ description: 'Telia', number: 1, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }),
|
||||
makeSIEVoucher({ description: 'Telia', number: 2, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }),
|
||||
makeSIEVoucher({ description: 'Telia', number: 3, lines: [{ account: '1930', amount: -1000 }, { account: '6230', amount: 1000 }] }),
|
||||
makeSIEVoucher({ description: 'Telia', number: 4, lines: [{ account: '1930', amount: -1000 }, { account: '6230', amount: 1000 }] }),
|
||||
]
|
||||
|
||||
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it('skips descriptions in skip set', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const vouchers = [
|
||||
makeSIEVoucher({ description: 'Lön', number: 1 }),
|
||||
makeSIEVoucher({ description: 'Lön', number: 2 }),
|
||||
makeSIEVoucher({ description: 'Lön', number: 3 }),
|
||||
]
|
||||
|
||||
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it('skips split bookings with more than 5 business accounts', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const vouchers = Array.from({ length: 3 }, (_, i) =>
|
||||
makeSIEVoucher({
|
||||
description: 'Complex booking',
|
||||
number: i + 1,
|
||||
lines: [
|
||||
{ account: '1930', amount: -6000 },
|
||||
{ account: '6200', amount: 1000 },
|
||||
{ account: '6210', amount: 1000 },
|
||||
{ account: '6220', amount: 1000 },
|
||||
{ account: '6230', amount: 1000 },
|
||||
{ account: '6240', amount: 1000 },
|
||||
{ account: '6250', amount: 1000 },
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it('extracts VAT treatment from 2641 line', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const vouchers = Array.from({ length: 3 }, (_, i) =>
|
||||
makeSIEVoucher({
|
||||
description: 'Kontorsmaterial AB',
|
||||
number: i + 1,
|
||||
lines: [
|
||||
{ account: '1930', amount: -1250 },
|
||||
{ account: '6100', amount: 1000 },
|
||||
{ account: '2641', amount: 250 },
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
enqueue({ data: [] }) // pre-fetch
|
||||
enqueue({ data: null }) // insert
|
||||
|
||||
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
||||
expect(result).toBe(1)
|
||||
})
|
||||
|
||||
it('filters out old vouchers beyond recency window', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const recentDate = new Date(2024, 5, 15)
|
||||
const oldDate = new Date(2021, 0, 1) // >24 months before recent
|
||||
|
||||
const vouchers = [
|
||||
makeSIEVoucher({ description: 'Old Company', date: oldDate, number: 1 }),
|
||||
makeSIEVoucher({ description: 'Old Company', date: oldDate, number: 2 }),
|
||||
makeSIEVoucher({ description: 'Old Company', date: oldDate, number: 3 }),
|
||||
makeSIEVoucher({ description: 'Recent Company', date: recentDate, number: 4 }),
|
||||
]
|
||||
|
||||
// Only 1 recent voucher for "Recent Company" → below min count → 0 templates
|
||||
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
it('computes SIE confidence formula correctly', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// 40 occurrences, 38 dominant (95% dominance)
|
||||
const vouchers: ReturnType<typeof makeSIEVoucher>[] = []
|
||||
for (let i = 0; i < 38; i++) {
|
||||
vouchers.push(makeSIEVoucher({
|
||||
description: 'Telia',
|
||||
number: i + 1,
|
||||
lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }],
|
||||
}))
|
||||
}
|
||||
for (let i = 0; i < 2; i++) {
|
||||
vouchers.push(makeSIEVoucher({
|
||||
description: 'Telia',
|
||||
number: 39 + i,
|
||||
lines: [{ account: '1930', amount: -1000 }, { account: '6230', amount: 1000 }],
|
||||
}))
|
||||
}
|
||||
|
||||
enqueue({ data: [] }) // pre-fetch
|
||||
enqueue({ data: null }) // insert
|
||||
|
||||
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
||||
expect(result).toBe(1)
|
||||
// dominance = 38/40 = 0.95, confidence = 0.95 * (1 - 1/38) ≈ 0.925 → rounded to 0.92
|
||||
})
|
||||
|
||||
it('recognizes 1510 (receivables) as settlement account', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const vouchers = Array.from({ length: 3 }, (_, i) =>
|
||||
makeSIEVoucher({
|
||||
description: 'Kund AB',
|
||||
number: i + 1,
|
||||
lines: [
|
||||
{ account: '1510', amount: 10000 },
|
||||
{ account: '3001', amount: -10000 },
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
enqueue({ data: [] }) // pre-fetch
|
||||
enqueue({ data: null }) // insert
|
||||
|
||||
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
||||
expect(result).toBe(1)
|
||||
})
|
||||
|
||||
it('recognizes 2890 (credit card) as settlement account', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const vouchers = Array.from({ length: 3 }, (_, i) =>
|
||||
makeSIEVoucher({
|
||||
description: 'Företaget AB',
|
||||
number: i + 1,
|
||||
lines: [
|
||||
{ account: '2890', amount: -1000 },
|
||||
{ account: '6200', amount: 1000 },
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
enqueue({ data: [] }) // pre-fetch
|
||||
enqueue({ data: null }) // insert
|
||||
|
||||
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
||||
expect(result).toBe(1)
|
||||
})
|
||||
|
||||
it('end-to-end: multiple counterparties, filters correctly', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const vouchers = [
|
||||
// Telia: 5 vouchers, all same pattern → should produce template
|
||||
...Array.from({ length: 5 }, (_, i) =>
|
||||
makeSIEVoucher({
|
||||
description: 'Telia',
|
||||
number: i + 1,
|
||||
lines: [{ account: '1930', amount: -500 }, { account: '6212', amount: 500 }],
|
||||
})
|
||||
),
|
||||
// ICA: 4 vouchers, all same pattern → should produce template
|
||||
...Array.from({ length: 4 }, (_, i) =>
|
||||
makeSIEVoucher({
|
||||
description: 'ICA Maxi',
|
||||
number: 10 + i,
|
||||
lines: [{ account: '1930', amount: -200 }, { account: '4010', amount: 200 }],
|
||||
})
|
||||
),
|
||||
// Rare: 1 voucher → below minimum, no template
|
||||
makeSIEVoucher({
|
||||
description: 'Rare Supplier',
|
||||
number: 20,
|
||||
lines: [{ account: '1930', amount: -100 }, { account: '6590', amount: 100 }],
|
||||
}),
|
||||
]
|
||||
|
||||
enqueue({ data: [] }) // pre-fetch
|
||||
enqueue({ data: null }) // insert 1
|
||||
enqueue({ data: null }) // insert 2
|
||||
|
||||
const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
|
||||
expect(result).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,11 +12,13 @@ import type {
|
||||
CategorizationTemplate,
|
||||
CategorizationTemplateSource,
|
||||
EntityType,
|
||||
LinePatternEntry,
|
||||
MappingResult,
|
||||
Transaction,
|
||||
VatJournalLine,
|
||||
VatTreatment,
|
||||
} from '@/types'
|
||||
import type { SIEVoucher } from '@/lib/import/types'
|
||||
|
||||
// ── Normalization ──────────────────────────────────────────────
|
||||
|
||||
@@ -45,6 +47,22 @@ export function normalizeCounterpartyName(raw: string): string {
|
||||
|
||||
// ── Confidence ─────────────────────────────────────────────────
|
||||
|
||||
// ── Display ───────────────────────────────────────────────────
|
||||
|
||||
/** Swedish company suffixes that should be uppercased */
|
||||
const UPPER_SUFFIXES = new Set(['ab', 'hb', 'kb', 'ek', 'ef', 'uf'])
|
||||
|
||||
/**
|
||||
* Capitalize a normalized counterparty name for display.
|
||||
* "telia sverige ab" → "Telia Sverige AB"
|
||||
*/
|
||||
export function formatCounterpartyName(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(w => UPPER_SUFFIXES.has(w) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Logarithmic confidence formula.
|
||||
* Starts low, grows slowly, caps at 0.95. Early corrections are cheap,
|
||||
@@ -55,6 +73,42 @@ export function calculateConfidence(occurrenceCount: number): number {
|
||||
return Math.round(Math.min(raw, 0.95) * 100) / 100
|
||||
}
|
||||
|
||||
// ── Source Priority ───────────────────────────────────────────
|
||||
|
||||
const SOURCE_PRIORITY: Record<CategorizationTemplateSource, number> = {
|
||||
sni_default: 0,
|
||||
auto_learned: 1,
|
||||
sie_import: 2,
|
||||
user_approved: 3,
|
||||
}
|
||||
|
||||
export function resolveSource(
|
||||
existing: CategorizationTemplateSource,
|
||||
incoming: CategorizationTemplateSource
|
||||
): CategorizationTemplateSource {
|
||||
return SOURCE_PRIORITY[incoming] >= SOURCE_PRIORITY[existing] ? incoming : existing
|
||||
}
|
||||
|
||||
// ── Counterparty Template ID Convention ──────────────────────
|
||||
|
||||
export const COUNTERPARTY_PREFIX = 'counterparty:'
|
||||
export function isCounterpartyTemplateId(id: string): boolean { return id.startsWith(COUNTERPARTY_PREFIX) }
|
||||
export function extractCounterpartyId(id: string): string { return id.slice(COUNTERPARTY_PREFIX.length) }
|
||||
export function toCounterpartyTemplateId(id: string): string { return COUNTERPARTY_PREFIX + id }
|
||||
|
||||
// ── VAT Account Mapping ──────────────────────────────────────
|
||||
|
||||
const VAT_ACCOUNT_TREATMENT: Record<string, string> = {
|
||||
'2611': 'standard_25',
|
||||
'2621': 'reduced_12',
|
||||
'2631': 'reduced_6',
|
||||
'2641': 'standard_25',
|
||||
'2645': 'reverse_charge',
|
||||
'2614': 'reverse_charge',
|
||||
'2624': 'reverse_charge',
|
||||
'2634': 'reverse_charge',
|
||||
}
|
||||
|
||||
// ── Lookup ─────────────────────────────────────────────────────
|
||||
|
||||
export interface CounterpartyTemplateMatch {
|
||||
@@ -66,9 +120,9 @@ export interface CounterpartyTemplateMatch {
|
||||
/**
|
||||
* Find a counterparty template matching a transaction.
|
||||
*
|
||||
* Three-tier matching:
|
||||
* 1. Exact alias match — GIN index on counterparty_aliases array
|
||||
* 2. Exact normalized name match — UNIQUE index
|
||||
* Three-tier matching (delegated to batch version with single-element array):
|
||||
* 1. Exact alias match
|
||||
* 2. Exact normalized name match
|
||||
* 3. Fuzzy Levenshtein — distance ≤2 for short names, ≤3 for long names
|
||||
*/
|
||||
export async function findCounterpartyTemplate(
|
||||
@@ -76,78 +130,96 @@ export async function findCounterpartyTemplate(
|
||||
userId: string,
|
||||
transaction: Transaction
|
||||
): Promise<CounterpartyTemplateMatch | null> {
|
||||
const rawName = transaction.merchant_name || transaction.description
|
||||
if (!rawName) return null
|
||||
const results = await findCounterpartyTemplatesBatch(supabase, userId, [transaction])
|
||||
return results.get(transaction.id) ?? null
|
||||
}
|
||||
|
||||
const normalized = normalizeCounterpartyName(rawName)
|
||||
if (!normalized || normalized.length < 2) return null
|
||||
/**
|
||||
* Batch counterparty template matching for multiple transactions.
|
||||
* One DB query, all matching done in memory.
|
||||
*/
|
||||
export async function findCounterpartyTemplatesBatch(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
transactions: Transaction[]
|
||||
): Promise<Map<string, CounterpartyTemplateMatch>> {
|
||||
const result = new Map<string, CounterpartyTemplateMatch>()
|
||||
|
||||
// 1. Exact alias match (highest confidence multiplier)
|
||||
const { data: aliasMatch } = await supabase
|
||||
.from('categorization_templates')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_active', true)
|
||||
.contains('counterparty_aliases', [rawName.toLowerCase()])
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (aliasMatch) {
|
||||
return {
|
||||
template: aliasMatch as CategorizationTemplate,
|
||||
matchMethod: 'exact_alias',
|
||||
confidence: Math.min(Number(aliasMatch.confidence) * 1.0, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Exact normalized name match
|
||||
const { data: exactMatch } = await supabase
|
||||
.from('categorization_templates')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_active', true)
|
||||
.eq('counterparty_name', normalized)
|
||||
.maybeSingle()
|
||||
|
||||
if (exactMatch) {
|
||||
return {
|
||||
template: exactMatch as CategorizationTemplate,
|
||||
matchMethod: 'exact_normalized',
|
||||
confidence: Math.round(Number(exactMatch.confidence) * 0.95 * 100) / 100,
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fuzzy match — fetch all active templates, compute Levenshtein
|
||||
const { data: allTemplates } = await supabase
|
||||
.from('categorization_templates')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_active', true)
|
||||
|
||||
if (!allTemplates || allTemplates.length === 0) return null
|
||||
if (!allTemplates || allTemplates.length === 0) return result
|
||||
|
||||
let bestMatch: CategorizationTemplate | null = null
|
||||
let bestDistance = Infinity
|
||||
const templates = allTemplates as CategorizationTemplate[]
|
||||
|
||||
for (const tmpl of allTemplates) {
|
||||
const dist = levenshteinDistance(normalized, tmpl.counterparty_name)
|
||||
const maxAllowed = normalized.length <= 10 ? 2 : 3
|
||||
if (dist <= maxAllowed && dist < bestDistance) {
|
||||
bestDistance = dist
|
||||
bestMatch = tmpl as CategorizationTemplate
|
||||
// Build alias lookup: lowercase alias → template
|
||||
const aliasMap = new Map<string, CategorizationTemplate>()
|
||||
for (const tmpl of templates) {
|
||||
for (const alias of tmpl.counterparty_aliases || []) {
|
||||
aliasMap.set(alias, tmpl)
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch) {
|
||||
const similarity = 1 - bestDistance / Math.max(normalized.length, bestMatch.counterparty_name.length)
|
||||
return {
|
||||
template: bestMatch,
|
||||
matchMethod: 'fuzzy',
|
||||
confidence: Math.round(Number(bestMatch.confidence) * similarity * 100) / 100,
|
||||
// Build normalized name lookup
|
||||
const nameMap = new Map<string, CategorizationTemplate>()
|
||||
for (const tmpl of templates) {
|
||||
nameMap.set(tmpl.counterparty_name, tmpl)
|
||||
}
|
||||
|
||||
for (const tx of transactions) {
|
||||
const rawName = tx.merchant_name || tx.description
|
||||
if (!rawName) continue
|
||||
|
||||
const normalized = normalizeCounterpartyName(rawName)
|
||||
if (!normalized || normalized.length < 2) continue
|
||||
|
||||
// 1. Exact alias match
|
||||
const aliasMatch = aliasMap.get(rawName.toLowerCase())
|
||||
if (aliasMatch) {
|
||||
result.set(tx.id, {
|
||||
template: aliasMatch,
|
||||
matchMethod: 'exact_alias',
|
||||
confidence: Math.min(Number(aliasMatch.confidence) * 1.0, 1),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. Exact normalized name match
|
||||
const exactMatch = nameMap.get(normalized)
|
||||
if (exactMatch) {
|
||||
result.set(tx.id, {
|
||||
template: exactMatch,
|
||||
matchMethod: 'exact_normalized',
|
||||
confidence: Math.round(Number(exactMatch.confidence) * 0.95 * 100) / 100,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 3. Fuzzy Levenshtein match
|
||||
let bestMatch: CategorizationTemplate | null = null
|
||||
let bestDistance = Infinity
|
||||
for (const tmpl of templates) {
|
||||
const dist = levenshteinDistance(normalized, tmpl.counterparty_name)
|
||||
const maxAllowed = normalized.length <= 10 ? 2 : 3
|
||||
if (dist <= maxAllowed && dist < bestDistance) {
|
||||
bestDistance = dist
|
||||
bestMatch = tmpl
|
||||
}
|
||||
}
|
||||
if (bestMatch) {
|
||||
const similarity = 1 - bestDistance / Math.max(normalized.length, bestMatch.counterparty_name.length)
|
||||
result.set(tx.id, {
|
||||
template: bestMatch,
|
||||
matchMethod: 'fuzzy',
|
||||
confidence: Math.round(Number(bestMatch.confidence) * similarity * 100) / 100,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Build MappingResult ────────────────────────────────────────
|
||||
@@ -159,13 +231,19 @@ export async function findCounterpartyTemplate(
|
||||
export function buildMappingResultFromCounterpartyTemplate(
|
||||
match: CounterpartyTemplateMatch,
|
||||
transaction: Transaction,
|
||||
entityType: EntityType
|
||||
_entityType: EntityType
|
||||
): MappingResult {
|
||||
const tmpl = match.template
|
||||
|
||||
// Multi-line pattern path
|
||||
if (tmpl.line_pattern && tmpl.line_pattern.length > 0) {
|
||||
return buildMultiLineMappingResult(tmpl, match, transaction)
|
||||
}
|
||||
|
||||
// Legacy single debit/credit path
|
||||
const absAmount = Math.abs(transaction.amount)
|
||||
const isExpense = transaction.amount < 0
|
||||
|
||||
// Generate VAT lines if applicable
|
||||
const vatLines: VatJournalLine[] = []
|
||||
if (isExpense && tmpl.vat_treatment) {
|
||||
const vatTreatment = tmpl.vat_treatment as VatTreatment
|
||||
@@ -195,7 +273,6 @@ export function buildMappingResultFromCounterpartyTemplate(
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if private (debit to private account means non-business)
|
||||
const privateAccounts = ['2013', '2893']
|
||||
const isPrivate = privateAccounts.includes(tmpl.debit_account)
|
||||
|
||||
@@ -212,15 +289,206 @@ export function buildMappingResultFromCounterpartyTemplate(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a MappingResult from a multi-line counterparty template pattern.
|
||||
*
|
||||
* VAT is computed from rate (exact), business/tax from ratio against non-VAT subtotal.
|
||||
* Rounding difference goes to 3740 (Öresutjämning).
|
||||
* Settlement line always equals the exact transaction amount.
|
||||
*/
|
||||
function buildMultiLineMappingResult(
|
||||
tmpl: CategorizationTemplate,
|
||||
match: CounterpartyTemplateMatch,
|
||||
transaction: Transaction
|
||||
): MappingResult {
|
||||
const pattern = tmpl.line_pattern!
|
||||
const absAmount = Math.abs(transaction.amount)
|
||||
|
||||
const allLines: VatJournalLine[] = []
|
||||
|
||||
// 1. Compute VAT lines first (from rate, exact)
|
||||
let totalVat = 0
|
||||
for (const entry of pattern) {
|
||||
if (entry.type === 'vat' && entry.vat_rate) {
|
||||
const vatAmount = Math.round(absAmount * entry.vat_rate / (1 + entry.vat_rate) * 100) / 100
|
||||
totalVat += vatAmount
|
||||
allLines.push({
|
||||
account_number: entry.account,
|
||||
debit_amount: entry.side === 'debit' ? vatAmount : 0,
|
||||
credit_amount: entry.side === 'credit' ? vatAmount : 0,
|
||||
description: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Compute non-VAT subtotal
|
||||
const nonVatAmount = Math.round((absAmount - totalVat) * 100) / 100
|
||||
|
||||
// 3. Compute business/tax lines from ratios against nonVatAmount
|
||||
let nonVatAllocated = 0
|
||||
for (const entry of pattern) {
|
||||
if ((entry.type === 'business' || entry.type === 'tax') && entry.ratio !== undefined) {
|
||||
const amount = Math.round(nonVatAmount * entry.ratio * 100) / 100
|
||||
nonVatAllocated += amount
|
||||
allLines.push({
|
||||
account_number: entry.account,
|
||||
debit_amount: entry.side === 'debit' ? amount : 0,
|
||||
credit_amount: entry.side === 'credit' ? amount : 0,
|
||||
description: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check for rounding difference → 3740
|
||||
const totalAllocated = Math.round((totalVat + nonVatAllocated) * 100) / 100
|
||||
const roundingDiff = Math.round((absAmount - totalAllocated) * 100) / 100
|
||||
if (roundingDiff !== 0) {
|
||||
// Determine the side for the rounding line (same side as business lines)
|
||||
const businessSide = pattern.find(e => e.type === 'business')?.side ?? 'credit'
|
||||
allLines.push({
|
||||
account_number: '3740',
|
||||
debit_amount: businessSide === 'debit' ? Math.abs(roundingDiff) : 0,
|
||||
credit_amount: businessSide === 'credit' ? Math.abs(roundingDiff) : 0,
|
||||
description: 'Öresutjämning',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
rule: null,
|
||||
debit_account: tmpl.debit_account,
|
||||
credit_account: tmpl.credit_account,
|
||||
risk_level: 'NONE',
|
||||
confidence: match.confidence,
|
||||
requires_review: false,
|
||||
default_private: false,
|
||||
vat_lines: allLines,
|
||||
all_lines_complete: true,
|
||||
description: `Motpart: ${tmpl.counterparty_name} (${tmpl.occurrence_count} ggr)`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feedback / Upsert ──────────────────────────────────────────
|
||||
|
||||
export interface TemplateUpsertParams {
|
||||
counterpartyName: string
|
||||
aliases: string[]
|
||||
debitAccount: string
|
||||
creditAccount: string
|
||||
vatTreatment: string | null
|
||||
vatAccount: string | null
|
||||
category: string | null
|
||||
occurrenceCount: number
|
||||
confidence: number
|
||||
lastSeenDate: string | null
|
||||
source: CategorizationTemplateSource
|
||||
linePattern?: LinePatternEntry[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level insert-or-update for a counterparty template.
|
||||
*
|
||||
* - existingTemplate undefined → DB lookup by (userId, counterpartyName)
|
||||
* - existingTemplate null → skip lookup (batch mode: caller knows none exists)
|
||||
* - existingTemplate object → use directly (batch mode: pre-fetched)
|
||||
*
|
||||
* Re-approval: accumulates occurrence_count, recalculates confidence from total.
|
||||
* Correction: uses params.occurrenceCount/confidence, updates accounts.
|
||||
* Both paths use resolveSource() so lower-priority sources never overwrite higher.
|
||||
*/
|
||||
export async function insertOrUpdateTemplate(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
params: TemplateUpsertParams,
|
||||
existingTemplate?: CategorizationTemplate | null
|
||||
): Promise<void> {
|
||||
// Resolve existing template
|
||||
let existing: CategorizationTemplate | null = null
|
||||
if (existingTemplate === undefined) {
|
||||
const { data } = await supabase
|
||||
.from('categorization_templates')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('counterparty_name', params.counterpartyName)
|
||||
.maybeSingle()
|
||||
existing = data as CategorizationTemplate | null
|
||||
} else {
|
||||
existing = existingTemplate
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const isCorrection =
|
||||
existing.debit_account !== params.debitAccount ||
|
||||
existing.credit_account !== params.creditAccount
|
||||
|
||||
// Merge aliases (deduplicated)
|
||||
const mergedAliases = [...(existing.counterparty_aliases || [])]
|
||||
for (const alias of params.aliases) {
|
||||
if (!mergedAliases.includes(alias)) {
|
||||
mergedAliases.push(alias)
|
||||
}
|
||||
}
|
||||
|
||||
const newSource = resolveSource(existing.source, params.source)
|
||||
|
||||
if (isCorrection) {
|
||||
await supabase
|
||||
.from('categorization_templates')
|
||||
.update({
|
||||
debit_account: params.debitAccount,
|
||||
credit_account: params.creditAccount,
|
||||
vat_treatment: params.vatTreatment ?? existing.vat_treatment,
|
||||
vat_account: params.vatAccount ?? existing.vat_account,
|
||||
category: params.category || existing.category,
|
||||
occurrence_count: params.occurrenceCount,
|
||||
confidence: params.confidence,
|
||||
last_seen_date: params.lastSeenDate,
|
||||
source: newSource,
|
||||
counterparty_aliases: mergedAliases,
|
||||
line_pattern: params.linePattern !== undefined ? params.linePattern : existing.line_pattern,
|
||||
})
|
||||
.eq('id', existing.id)
|
||||
} else {
|
||||
// Re-approval: accumulate count, recalculate confidence from total
|
||||
const newCount = existing.occurrence_count + params.occurrenceCount
|
||||
const newConfidence = calculateConfidence(newCount)
|
||||
|
||||
await supabase
|
||||
.from('categorization_templates')
|
||||
.update({
|
||||
occurrence_count: newCount,
|
||||
confidence: newConfidence,
|
||||
last_seen_date: params.lastSeenDate,
|
||||
source: newSource,
|
||||
counterparty_aliases: mergedAliases,
|
||||
category: params.category || existing.category,
|
||||
...(params.linePattern !== undefined ? { line_pattern: params.linePattern } : {}),
|
||||
})
|
||||
.eq('id', existing.id)
|
||||
}
|
||||
} else {
|
||||
await supabase
|
||||
.from('categorization_templates')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
counterparty_name: params.counterpartyName,
|
||||
counterparty_aliases: params.aliases,
|
||||
debit_account: params.debitAccount,
|
||||
credit_account: params.creditAccount,
|
||||
vat_treatment: params.vatTreatment,
|
||||
vat_account: params.vatAccount,
|
||||
category: params.category,
|
||||
line_pattern: params.linePattern ?? null,
|
||||
occurrence_count: params.occurrenceCount,
|
||||
confidence: params.confidence,
|
||||
last_seen_date: params.lastSeenDate,
|
||||
source: params.source,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a counterparty template from a categorization result.
|
||||
*
|
||||
* - New counterparty: insert with starting confidence based on source
|
||||
* - Re-approval (same accounts): increment occurrence_count, recalc confidence
|
||||
* - Correction (different accounts): update accounts, reset occurrence_count to 1
|
||||
* - Always: add raw name alias if not present, update last_seen_date
|
||||
* Thin wrapper around insertOrUpdateTemplate for single-transaction callers.
|
||||
*/
|
||||
export async function upsertCounterpartyTemplate(
|
||||
supabase: SupabaseClient,
|
||||
@@ -235,94 +503,23 @@ export async function upsertCounterpartyTemplate(
|
||||
const normalized = normalizeCounterpartyName(rawName)
|
||||
if (!normalized || normalized.length < 2) return
|
||||
|
||||
const rawNameLower = rawName.toLowerCase()
|
||||
const category = transaction.category !== 'uncategorized' ? transaction.category : null
|
||||
const txDate = transaction.date
|
||||
|
||||
// Check for existing template
|
||||
const { data: existing } = await supabase
|
||||
.from('categorization_templates')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('counterparty_name', normalized)
|
||||
.maybeSingle()
|
||||
|
||||
if (existing) {
|
||||
const isCorrection =
|
||||
existing.debit_account !== mappingResult.debit_account ||
|
||||
existing.credit_account !== mappingResult.credit_account
|
||||
|
||||
// Build updated aliases array (add raw name if not present)
|
||||
const aliases: string[] = existing.counterparty_aliases || []
|
||||
if (!aliases.includes(rawNameLower)) {
|
||||
aliases.push(rawNameLower)
|
||||
}
|
||||
|
||||
if (isCorrection) {
|
||||
// Correction: update accounts, reset occurrence to 1
|
||||
const newConfidence = calculateConfidence(1)
|
||||
await supabase
|
||||
.from('categorization_templates')
|
||||
.update({
|
||||
debit_account: mappingResult.debit_account,
|
||||
credit_account: mappingResult.credit_account,
|
||||
vat_treatment: mappingResult.vat_lines.length > 0
|
||||
? detectVatTreatment(mappingResult)
|
||||
: existing.vat_treatment,
|
||||
vat_account: mappingResult.vat_lines[0]?.account_number || existing.vat_account,
|
||||
category: category || existing.category,
|
||||
occurrence_count: 1,
|
||||
confidence: newConfidence,
|
||||
last_seen_date: txDate,
|
||||
source: source === 'auto_learned' ? existing.source : source,
|
||||
counterparty_aliases: aliases,
|
||||
})
|
||||
.eq('id', existing.id)
|
||||
} else {
|
||||
// Re-approval: increment count, recalc confidence
|
||||
const newCount = existing.occurrence_count + 1
|
||||
const newConfidence = calculateConfidence(newCount)
|
||||
// Upgrade source if human approves an auto-learned template
|
||||
const newSource =
|
||||
source === 'user_approved' && existing.source === 'auto_learned'
|
||||
? 'user_approved'
|
||||
: existing.source
|
||||
|
||||
await supabase
|
||||
.from('categorization_templates')
|
||||
.update({
|
||||
occurrence_count: newCount,
|
||||
confidence: newConfidence,
|
||||
last_seen_date: txDate,
|
||||
source: newSource,
|
||||
counterparty_aliases: aliases,
|
||||
category: category || existing.category,
|
||||
})
|
||||
.eq('id', existing.id)
|
||||
}
|
||||
} else {
|
||||
// New template
|
||||
const startingConfidence = source === 'auto_learned'
|
||||
? calculateConfidence(1) // ~0.45
|
||||
: calculateConfidence(1) // same formula, but source flag matters for threshold
|
||||
|
||||
await supabase
|
||||
.from('categorization_templates')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
counterparty_name: normalized,
|
||||
counterparty_aliases: [rawNameLower],
|
||||
debit_account: mappingResult.debit_account,
|
||||
credit_account: mappingResult.credit_account,
|
||||
vat_treatment: detectVatTreatment(mappingResult),
|
||||
vat_account: mappingResult.vat_lines[0]?.account_number || null,
|
||||
category: category || null,
|
||||
occurrence_count: 1,
|
||||
confidence: startingConfidence,
|
||||
last_seen_date: txDate,
|
||||
source,
|
||||
})
|
||||
}
|
||||
await insertOrUpdateTemplate(supabase, userId, {
|
||||
counterpartyName: normalized,
|
||||
aliases: [rawName.toLowerCase()],
|
||||
debitAccount: mappingResult.debit_account,
|
||||
creditAccount: mappingResult.credit_account,
|
||||
vatTreatment: mappingResult.vat_lines.length > 0
|
||||
? detectVatTreatment(mappingResult)
|
||||
: null,
|
||||
vatAccount: mappingResult.vat_lines[0]?.account_number || null,
|
||||
category,
|
||||
occurrenceCount: 1,
|
||||
confidence: calculateConfidence(1),
|
||||
lastSeenDate: transaction.date,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,3 +550,397 @@ function detectVatTreatment(result: MappingResult): string | null {
|
||||
}
|
||||
return 'standard_25'
|
||||
}
|
||||
|
||||
// ── SIE Voucher Template Population ──────────────────────────
|
||||
|
||||
const SIE_SKIP_DESCRIPTIONS = new Set([
|
||||
'lön', 'löner', 'löneutbetalning', 'arbetsgivaravgifter',
|
||||
'semesterlöneskuld', 'preliminärskatt', 'momsredovisning', 'moms',
|
||||
'bokslutsdisposition', 'bokslut', 'bokslutstransaktion', 'årsbokslut',
|
||||
'avskrivning', 'avskrivningar', 'periodisering',
|
||||
'upplupna', 'förutbetalda', 'skatteberäkning', 'skattebetalning',
|
||||
'resultatdisposition', 'årets resultat',
|
||||
'omföring', 'intern omföring', 'korrigering', 'rättelse', 'avslut',
|
||||
'öppningsbalans', 'ub', 'ib',
|
||||
])
|
||||
|
||||
function toDateString(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** Settlement accounts: bank/cash (19xx), receivables (1510), payables (2440), credit card (2890) */
|
||||
function isSettlementAccount(account: string): boolean {
|
||||
return account.startsWith('19') || account === '1510' || account === '2440' || account === '2890'
|
||||
}
|
||||
|
||||
/** Rounding account — excluded from pattern extraction */
|
||||
function isRoundingAccount(account: string): boolean {
|
||||
return account === '3740'
|
||||
}
|
||||
|
||||
/** Tax/duty accounts in 24xx range (except 2440 = AP settlement) */
|
||||
function isTaxAccount(account: string): boolean {
|
||||
return account.startsWith('24') && account !== '2440'
|
||||
}
|
||||
|
||||
/** Check if a 26xx account is a known VAT account */
|
||||
function isVatAccount(account: string): boolean {
|
||||
return account.startsWith('26') && account in VAT_ACCOUNT_TREATMENT
|
||||
}
|
||||
|
||||
/** Get the VAT rate (decimal) from a VAT account treatment string */
|
||||
function vatTreatmentToRate(treatment: string): number {
|
||||
if (treatment === 'standard_25') return 0.25
|
||||
if (treatment === 'reduced_12') return 0.12
|
||||
if (treatment === 'reduced_6') return 0.06
|
||||
return 0
|
||||
}
|
||||
|
||||
// ── Extracted voucher line pattern ───────────────────────────
|
||||
|
||||
interface VoucherLinePattern {
|
||||
entries: LinePatternEntry[]
|
||||
settlementAccount: string
|
||||
settlementSide: 'debit' | 'credit'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a line pattern from a single SIE voucher.
|
||||
* Returns null if the voucher can't be represented as a pattern.
|
||||
*/
|
||||
function extractVoucherLinePattern(
|
||||
lines: { account: string; amount: number }[]
|
||||
): VoucherLinePattern | null {
|
||||
const settlement: { account: string; amount: number }[] = []
|
||||
const vat: { account: string; amount: number }[] = []
|
||||
const business: { account: string; amount: number }[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (isSettlementAccount(line.account)) {
|
||||
settlement.push(line)
|
||||
} else if (isRoundingAccount(line.account)) {
|
||||
// Skip 3740 lines — rounding artifacts
|
||||
continue
|
||||
} else if (isVatAccount(line.account)) {
|
||||
vat.push(line)
|
||||
} else {
|
||||
business.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Need at least 1 business account and 1 settlement account
|
||||
if (business.length === 0 || settlement.length === 0) return null
|
||||
// Skip if too many distinct accounts (likely a complex/manual entry)
|
||||
const distinctBusiness = new Set(business.map(l => l.account))
|
||||
if (distinctBusiness.size > 5) return null
|
||||
|
||||
const settlementTotal = settlement.reduce((s, l) => s + Math.abs(l.amount), 0)
|
||||
if (settlementTotal === 0) return null
|
||||
|
||||
const vatTotal = vat.reduce((s, l) => s + Math.abs(l.amount), 0)
|
||||
const nonVatTotal = settlementTotal - vatTotal
|
||||
if (nonVatTotal <= 0) return null
|
||||
|
||||
// Determine settlement side from the first settlement line
|
||||
const settlementSide: 'debit' | 'credit' = settlement[0].amount >= 0 ? 'debit' : 'credit'
|
||||
|
||||
const entries: LinePatternEntry[] = []
|
||||
|
||||
// VAT lines: store vat_rate, not ratio
|
||||
for (const v of vat) {
|
||||
const treatment = VAT_ACCOUNT_TREATMENT[v.account]
|
||||
if (!treatment) continue
|
||||
const rate = vatTreatmentToRate(treatment)
|
||||
if (rate === 0) continue
|
||||
entries.push({
|
||||
account: v.account,
|
||||
type: 'vat',
|
||||
side: v.amount >= 0 ? 'debit' : 'credit',
|
||||
vat_rate: rate,
|
||||
})
|
||||
}
|
||||
|
||||
// Business/tax lines: compute ratio against nonVatTotal
|
||||
for (const b of business) {
|
||||
const ratio = Math.abs(b.amount) / nonVatTotal
|
||||
entries.push({
|
||||
account: b.account,
|
||||
type: isTaxAccount(b.account) ? 'tax' : 'business',
|
||||
side: b.amount >= 0 ? 'debit' : 'credit',
|
||||
ratio: Math.round(ratio * 10000) / 10000,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
settlementAccount: settlement[0].account,
|
||||
settlementSide,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Counterparty group types ─────────────────────────────────
|
||||
|
||||
interface CounterpartyGroup {
|
||||
normalizedName: string
|
||||
aliases: Set<string>
|
||||
patterns: Map<string, MultiLinePatternCount>
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
interface MultiLinePatternCount {
|
||||
accountSet: string // sorted accounts joined by +
|
||||
voucherPatterns: VoucherLinePattern[] // all individual patterns in this group
|
||||
count: number
|
||||
latestDate: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize non-VAT ratios in a line pattern to sum to exactly 1.0.
|
||||
*/
|
||||
function normalizeRatios(entries: LinePatternEntry[]): LinePatternEntry[] {
|
||||
const ratioEntries = entries.filter(e => e.ratio !== undefined)
|
||||
if (ratioEntries.length === 0) return entries
|
||||
|
||||
const ratioSum = ratioEntries.reduce((s, e) => s + (e.ratio ?? 0), 0)
|
||||
if (ratioSum === 0) return entries
|
||||
|
||||
const result = entries.map(e => {
|
||||
if (e.ratio === undefined) return { ...e }
|
||||
return { ...e, ratio: Math.round((e.ratio / ratioSum) * 10000) / 10000 }
|
||||
})
|
||||
|
||||
// Assign rounding remainder to the largest ratio entry
|
||||
const normalizedRatioEntries = result.filter(e => e.ratio !== undefined)
|
||||
const newSum = normalizedRatioEntries.reduce((s, e) => s + (e.ratio ?? 0), 0)
|
||||
const diff = Math.round((1.0 - newSum) * 10000) / 10000
|
||||
if (diff !== 0 && normalizedRatioEntries.length > 0) {
|
||||
const largest = normalizedRatioEntries.reduce((a, b) => ((a.ratio ?? 0) >= (b.ratio ?? 0) ? a : b))
|
||||
largest.ratio = Math.round(((largest.ratio ?? 0) + diff) * 10000) / 10000
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Average line patterns from multiple vouchers into a single normalized pattern.
|
||||
*/
|
||||
function averageLinePatterns(voucherPatterns: VoucherLinePattern[]): LinePatternEntry[] {
|
||||
if (voucherPatterns.length === 0) return []
|
||||
if (voucherPatterns.length === 1) return normalizeRatios(voucherPatterns[0].entries)
|
||||
|
||||
// Collect all accounts across all patterns
|
||||
const accountMap = new Map<string, { type: LinePatternEntry['type']; side: LinePatternEntry['side']; ratios: number[]; vat_rate?: number }>()
|
||||
|
||||
for (const vp of voucherPatterns) {
|
||||
// Normalize per-voucher ratios before averaging
|
||||
const normalized = normalizeRatios(vp.entries)
|
||||
for (const entry of normalized) {
|
||||
const existing = accountMap.get(entry.account)
|
||||
if (!existing) {
|
||||
accountMap.set(entry.account, {
|
||||
type: entry.type,
|
||||
side: entry.side,
|
||||
ratios: entry.ratio !== undefined ? [entry.ratio] : [],
|
||||
vat_rate: entry.vat_rate,
|
||||
})
|
||||
} else {
|
||||
if (entry.ratio !== undefined) {
|
||||
existing.ratios.push(entry.ratio)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entries: LinePatternEntry[] = []
|
||||
for (const [account, data] of accountMap) {
|
||||
const entry: LinePatternEntry = { account, type: data.type, side: data.side }
|
||||
if (data.vat_rate !== undefined) {
|
||||
entry.vat_rate = data.vat_rate
|
||||
}
|
||||
if (data.ratios.length > 0) {
|
||||
entry.ratio = Math.round((data.ratios.reduce((s, r) => s + r, 0) / data.ratios.length) * 10000) / 10000
|
||||
}
|
||||
entries.push(entry)
|
||||
}
|
||||
|
||||
return normalizeRatios(entries)
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze SIE voucher history and create counterparty templates.
|
||||
*
|
||||
* Groups vouchers by normalized description and account set, filters by
|
||||
* dominance and minimum occurrences. Supports both simple (single debit/credit)
|
||||
* and multi-line patterns (stored as line_pattern JSONB).
|
||||
*/
|
||||
export async function populateTemplatesFromSieVouchers(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
vouchers: SIEVoucher[],
|
||||
options?: { recencyMonths?: number }
|
||||
): Promise<number> {
|
||||
if (vouchers.length === 0) return 0
|
||||
|
||||
const recencyMonths = options?.recencyMonths ?? 24
|
||||
|
||||
// Step 0: Recency filter
|
||||
let maxDate = vouchers[0].date
|
||||
for (const v of vouchers) {
|
||||
if (v.date > maxDate) maxDate = v.date
|
||||
}
|
||||
const cutoff = new Date(maxDate)
|
||||
cutoff.setMonth(cutoff.getMonth() - recencyMonths)
|
||||
|
||||
const recentVouchers = vouchers.filter(v => v.date >= cutoff)
|
||||
if (recentVouchers.length === 0) return 0
|
||||
|
||||
// Step 1: Build counterparty groups
|
||||
const groups = new Map<string, CounterpartyGroup>()
|
||||
|
||||
for (const voucher of recentVouchers) {
|
||||
const desc = voucher.description?.trim()
|
||||
if (!desc) continue
|
||||
|
||||
const normalized = normalizeCounterpartyName(desc)
|
||||
if (!normalized || normalized.length < 2) continue
|
||||
if (SIE_SKIP_DESCRIPTIONS.has(normalized)) continue
|
||||
|
||||
// Extract line pattern from voucher
|
||||
const linePattern = extractVoucherLinePattern(voucher.lines)
|
||||
if (!linePattern) continue
|
||||
|
||||
// Group key: sorted set of non-settlement accounts
|
||||
const accountSet = linePattern.entries
|
||||
.map(e => e.account)
|
||||
.sort()
|
||||
.join('+')
|
||||
const groupKey = `${normalized}|${accountSet}`
|
||||
|
||||
let group = groups.get(normalized)
|
||||
if (!group) {
|
||||
group = { normalizedName: normalized, aliases: new Set(), patterns: new Map(), totalCount: 0 }
|
||||
groups.set(normalized, group)
|
||||
}
|
||||
|
||||
group.aliases.add(desc.toLowerCase())
|
||||
group.totalCount += 1
|
||||
|
||||
let pattern = group.patterns.get(groupKey)
|
||||
if (!pattern) {
|
||||
pattern = { accountSet, voucherPatterns: [], count: 0, latestDate: voucher.date }
|
||||
group.patterns.set(groupKey, pattern)
|
||||
}
|
||||
pattern.voucherPatterns.push(linePattern)
|
||||
pattern.count += 1
|
||||
if (voucher.date > pattern.latestDate) {
|
||||
pattern.latestDate = voucher.date
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2 & 3: Filter by dominance and compute confidence
|
||||
const accepted: {
|
||||
normalizedName: string
|
||||
aliases: string[]
|
||||
pattern: MultiLinePatternCount
|
||||
settlementAccount: string
|
||||
settlementSide: 'debit' | 'credit'
|
||||
confidence: number
|
||||
}[] = []
|
||||
|
||||
for (const group of groups.values()) {
|
||||
if (group.totalCount < 2) continue
|
||||
|
||||
// Find dominant pattern
|
||||
let dominant: MultiLinePatternCount | null = null
|
||||
for (const p of group.patterns.values()) {
|
||||
if (!dominant || p.count > dominant.count) {
|
||||
dominant = p
|
||||
}
|
||||
}
|
||||
if (!dominant) continue
|
||||
|
||||
const dominance = dominant.count / group.totalCount
|
||||
if (dominance < 0.6) continue
|
||||
|
||||
const confidence = Math.round(Math.min(0.95, dominance * (1 - 1 / dominant.count)) * 100) / 100
|
||||
|
||||
// Get settlement info from the first voucher pattern
|
||||
const firstVp = dominant.voucherPatterns[0]
|
||||
|
||||
accepted.push({
|
||||
normalizedName: group.normalizedName,
|
||||
aliases: [...group.aliases],
|
||||
pattern: dominant,
|
||||
settlementAccount: firstVp.settlementAccount,
|
||||
settlementSide: firstVp.settlementSide,
|
||||
confidence,
|
||||
})
|
||||
}
|
||||
|
||||
if (accepted.length === 0) return 0
|
||||
|
||||
// Step 4: Batch write
|
||||
const { data: existingTemplates } = await supabase
|
||||
.from('categorization_templates')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_active', true)
|
||||
|
||||
const templateMap = new Map<string, CategorizationTemplate>()
|
||||
if (existingTemplates) {
|
||||
for (const t of existingTemplates) {
|
||||
templateMap.set(t.counterparty_name, t as CategorizationTemplate)
|
||||
}
|
||||
}
|
||||
|
||||
let count = 0
|
||||
for (const item of accepted) {
|
||||
const existing = templateMap.get(item.normalizedName) ?? null
|
||||
|
||||
// Average the line patterns from all vouchers in the dominant group
|
||||
const avgPattern = averageLinePatterns(item.pattern.voucherPatterns)
|
||||
|
||||
// Decide: simple (1 business + 0-1 VAT) → legacy fields; otherwise → line_pattern
|
||||
const businessEntries = avgPattern.filter(e => e.type === 'business')
|
||||
const vatEntries = avgPattern.filter(e => e.type === 'vat')
|
||||
const taxEntries = avgPattern.filter(e => e.type === 'tax')
|
||||
const isSimple = businessEntries.length === 1 && taxEntries.length === 0 && vatEntries.length <= 1
|
||||
|
||||
// Determine primary business account and settlement for debit/credit fields
|
||||
const primaryBusiness = businessEntries.sort((a, b) => (b.ratio ?? 0) - (a.ratio ?? 0))[0]
|
||||
|
||||
let debitAccount: string
|
||||
let creditAccount: string
|
||||
if (item.settlementSide === 'debit') {
|
||||
debitAccount = item.settlementAccount
|
||||
creditAccount = primaryBusiness?.account ?? item.settlementAccount
|
||||
} else {
|
||||
debitAccount = primaryBusiness?.account ?? item.settlementAccount
|
||||
creditAccount = item.settlementAccount
|
||||
}
|
||||
|
||||
// VAT info from first VAT entry (for legacy fields)
|
||||
const firstVat = vatEntries[0]
|
||||
const vatAccount = firstVat?.account ?? null
|
||||
const vatTreatment = vatAccount ? (VAT_ACCOUNT_TREATMENT[vatAccount] ?? null) : null
|
||||
|
||||
await insertOrUpdateTemplate(supabase, userId, {
|
||||
counterpartyName: item.normalizedName,
|
||||
aliases: item.aliases,
|
||||
debitAccount,
|
||||
creditAccount,
|
||||
vatTreatment,
|
||||
vatAccount,
|
||||
category: null,
|
||||
occurrenceCount: item.pattern.count,
|
||||
confidence: item.confidence,
|
||||
lastSeenDate: toDateString(item.pattern.latestDate),
|
||||
source: 'sie_import',
|
||||
linePattern: isSimple ? null : avgPattern,
|
||||
}, existing)
|
||||
|
||||
count += 1
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -86,8 +86,52 @@ export async function createTransactionJournalEntry(
|
||||
line_description: transaction.description,
|
||||
}
|
||||
)
|
||||
} else if (mappingResult.all_lines_complete) {
|
||||
// Multi-line pattern: vat_lines contains ALL non-settlement lines with correct amounts.
|
||||
// Settlement line = full absAmount on the appropriate side.
|
||||
const settlementAccount = isExpense
|
||||
? (mappingResult.credit_account || '1930')
|
||||
: (mappingResult.debit_account || '1930')
|
||||
|
||||
if (isExpense) {
|
||||
// All non-settlement lines (business, VAT, tax, rounding)
|
||||
for (const line of mappingResult.vat_lines) {
|
||||
lines.push({
|
||||
account_number: line.account_number,
|
||||
debit_amount: line.debit_amount,
|
||||
credit_amount: line.credit_amount,
|
||||
line_description: line.description || transaction.description,
|
||||
})
|
||||
}
|
||||
// Credit bank for full amount
|
||||
lines.push({
|
||||
account_number: settlementAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: absAmount,
|
||||
line_description: transaction.description,
|
||||
...(settlementAccount === '1930' ? currencyMeta : {}),
|
||||
})
|
||||
} else {
|
||||
// Debit bank for full amount
|
||||
lines.push({
|
||||
account_number: settlementAccount,
|
||||
debit_amount: absAmount,
|
||||
credit_amount: 0,
|
||||
line_description: transaction.description,
|
||||
...(settlementAccount === '1930' ? currencyMeta : {}),
|
||||
})
|
||||
// All non-settlement lines
|
||||
for (const line of mappingResult.vat_lines) {
|
||||
lines.push({
|
||||
account_number: line.account_number,
|
||||
debit_amount: line.debit_amount,
|
||||
credit_amount: line.credit_amount,
|
||||
line_description: line.description || transaction.description,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (isExpense) {
|
||||
// Business expense
|
||||
// Business expense (legacy single debit/credit path)
|
||||
const debitAccount = mappingResult.debit_account
|
||||
const creditAccount = mappingResult.credit_account || '1930'
|
||||
|
||||
@@ -134,7 +178,7 @@ export async function createTransactionJournalEntry(
|
||||
...(creditAccount === '1930' ? currencyMeta : {}),
|
||||
})
|
||||
} else {
|
||||
// Income
|
||||
// Income (legacy single debit/credit path)
|
||||
const debitAccount = mappingResult.debit_account || '1930'
|
||||
const creditAccount = mappingResult.credit_account
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { mappingsToMap, getMappingStats } from './account-mapper'
|
||||
import { calculateFileHash } from './sie-parser'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
|
||||
import { populateTemplatesFromSieVouchers } from '@/lib/bookkeeping/counterparty-templates'
|
||||
|
||||
/**
|
||||
* Format a date to ISO date string (YYYY-MM-DD)
|
||||
@@ -1532,6 +1533,20 @@ export async function executeSIEImport(
|
||||
documentation
|
||||
)
|
||||
|
||||
// Populate counterparty templates from voucher patterns (non-blocking)
|
||||
if (result.success && parsed.vouchers.length > 0) {
|
||||
try {
|
||||
const templateCount = await populateTemplatesFromSieVouchers(
|
||||
supabase, userId, parsed.vouchers
|
||||
)
|
||||
if (templateCount > 0) {
|
||||
console.info(`[sie-import] ${templateCount} counterparty templates extracted from voucher history`)
|
||||
}
|
||||
} catch (templateError) {
|
||||
console.error('[sie-import] Failed to populate counterparty templates:', templateError)
|
||||
}
|
||||
}
|
||||
|
||||
// Add warnings for any issues
|
||||
for (const issue of parsed.issues) {
|
||||
if (issue.severity === 'warning') {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { suggestCategory } from '@/lib/tax/expense-warnings'
|
||||
import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { findMatchingTemplates, getTemplateById, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import type { Transaction, TransactionCategory, EntityType, MappingRule } from '@/types'
|
||||
import type { Transaction, TransactionCategory, EntityType, MappingRule, LinePatternEntry } from '@/types'
|
||||
|
||||
export interface SuggestedCategory {
|
||||
category: TransactionCategory
|
||||
@@ -244,6 +244,7 @@ export interface SuggestedTemplate {
|
||||
description_sv: string
|
||||
risk_level: string
|
||||
requires_review: boolean
|
||||
line_pattern?: LinePatternEntry[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Add multi-line booking pattern support to categorization templates.
|
||||
-- Null = legacy single debit/credit pair. Array of {account, type, side, ratio?, vat_rate?}.
|
||||
-- VAT lines store vat_rate (computed exactly), business/tax lines store ratio (of non-VAT amount).
|
||||
|
||||
ALTER TABLE public.categorization_templates
|
||||
ADD COLUMN line_pattern JSONB;
|
||||
|
||||
COMMENT ON COLUMN public.categorization_templates.line_pattern IS
|
||||
'Multi-line booking pattern. Null = use debit_account/credit_account. Array of {account, type, side, ratio?, vat_rate?}. Ratios are relative to the non-VAT subtotal.';
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
InvoiceInboxItem,
|
||||
CategorizationTemplate,
|
||||
} from '@/types'
|
||||
import type { SIEVoucher, SIETransactionLine } from '@/lib/import/types'
|
||||
|
||||
// ============================================================
|
||||
// Chainable Supabase mock
|
||||
@@ -664,9 +665,27 @@ export function makeCategorizationTemplate(
|
||||
confidence: 0.7,
|
||||
last_seen_date: '2024-06-15',
|
||||
source: 'user_approved',
|
||||
line_pattern: null,
|
||||
is_active: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-06-15T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function makeSIEVoucher(
|
||||
overrides: Partial<Omit<SIEVoucher, 'lines'>> & { lines?: SIETransactionLine[] } = {}
|
||||
): SIEVoucher {
|
||||
return {
|
||||
series: 'A',
|
||||
number: 1,
|
||||
date: new Date(2024, 5, 15), // June 15, 2024
|
||||
description: 'Faktura',
|
||||
lines: [
|
||||
{ account: '1930', amount: -1000 },
|
||||
{ account: '6200', amount: 800 },
|
||||
{ account: '2641', amount: 200 },
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -926,6 +926,7 @@ export interface MappingResult {
|
||||
requires_review: boolean
|
||||
default_private: boolean
|
||||
vat_lines: VatJournalLine[]
|
||||
all_lines_complete?: boolean // when true, vat_lines contains ALL non-settlement lines
|
||||
description: string
|
||||
}
|
||||
|
||||
@@ -940,6 +941,15 @@ export interface VatJournalLine {
|
||||
// Categorization template source
|
||||
export type CategorizationTemplateSource = 'sie_import' | 'user_approved' | 'sni_default' | 'auto_learned'
|
||||
|
||||
// Multi-line booking pattern entry
|
||||
export interface LinePatternEntry {
|
||||
account: string
|
||||
type: 'business' | 'vat' | 'tax'
|
||||
side: 'debit' | 'credit'
|
||||
ratio?: number // proportion of NON-VAT amount (business + tax ratios sum to ~1.0)
|
||||
vat_rate?: number // applied to FULL amount via rate/(1+rate) (vat type only)
|
||||
}
|
||||
|
||||
// Per-tenant counterparty-based categorization template
|
||||
export interface CategorizationTemplate {
|
||||
id: string
|
||||
@@ -951,6 +961,7 @@ export interface CategorizationTemplate {
|
||||
vat_treatment: VatTreatment | null
|
||||
vat_account: string | null
|
||||
category: TransactionCategory | null
|
||||
line_pattern: LinePatternEntry[] | null
|
||||
occurrence_count: number
|
||||
confidence: number
|
||||
last_seen_date: string | null
|
||||
|
||||
Reference in New Issue
Block a user