d9c95a7b59
* 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>
161 lines
6.3 KiB
TypeScript
161 lines
6.3 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo } from 'react'
|
|
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, LinePatternEntry } from '@/types'
|
|
|
|
interface PreviewLine {
|
|
side: 'debet' | 'kredit'
|
|
account: string
|
|
amount: number
|
|
}
|
|
|
|
interface JournalEntryPreviewProps {
|
|
amount: number
|
|
currency?: string
|
|
category?: TransactionCategory
|
|
vatTreatment?: VatTreatment | 'none'
|
|
accountOverride?: string
|
|
entityType?: EntityType
|
|
/** For template-based bookings — overrides category mapping */
|
|
templateDebitAccount?: string
|
|
templateCreditAccount?: string
|
|
templateVatRate?: number
|
|
/** For multi-line counterparty template bookings */
|
|
linePattern?: LinePatternEntry[]
|
|
settlementAccount?: string
|
|
}
|
|
|
|
export default function JournalEntryPreview({
|
|
amount,
|
|
currency = 'SEK',
|
|
category,
|
|
vatTreatment,
|
|
accountOverride,
|
|
entityType = 'enskild_firma',
|
|
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
|
|
const vatAmt = extractVatAmount(absAmount, vatRate)
|
|
const netAmt = extractNetAmount(absAmount, vatRate)
|
|
|
|
result.push({ side: 'debet', account: templateDebitAccount, amount: netAmt })
|
|
if (vatAmt > 0) {
|
|
result.push({ side: 'debet', account: '2641', amount: vatAmt })
|
|
}
|
|
result.push({ side: 'kredit', account: templateCreditAccount, amount: absAmount })
|
|
return result
|
|
}
|
|
|
|
// Category-based preview
|
|
if (!category) return result
|
|
|
|
const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment
|
|
const mapping = getCategoryAccountMapping(category, amount, category !== 'private', entityType, resolvedVat)
|
|
|
|
const debitAccount = accountOverride && amount < 0 ? accountOverride : mapping.debitAccount
|
|
const creditAccount = accountOverride && amount > 0 ? accountOverride : mapping.creditAccount
|
|
|
|
const treatment = mapping.vatTreatment as VatTreatment | null
|
|
const vatRate = treatment ? getVatRate(treatment) : 0
|
|
const vatAmt = vatRate > 0 ? extractVatAmount(absAmount, vatRate) : 0
|
|
const netAmt = vatRate > 0 ? extractNetAmount(absAmount, vatRate) : absAmount
|
|
|
|
if (amount < 0) {
|
|
// Expense: Debit expense + VAT, Credit bank
|
|
result.push({ side: 'debet', account: debitAccount, amount: netAmt })
|
|
if (vatAmt > 0 && mapping.vatDebitAccount) {
|
|
result.push({ side: 'debet', account: mapping.vatDebitAccount, amount: vatAmt })
|
|
}
|
|
result.push({ side: 'kredit', account: creditAccount, amount: absAmount })
|
|
} else {
|
|
// Income: Debit bank, Credit revenue + VAT
|
|
result.push({ side: 'debet', account: debitAccount, amount: absAmount })
|
|
if (vatAmt > 0 && mapping.vatCreditAccount) {
|
|
result.push({ side: 'kredit', account: mapping.vatCreditAccount, amount: vatAmt })
|
|
}
|
|
result.push({ side: 'kredit', account: creditAccount, amount: netAmt })
|
|
}
|
|
|
|
// Reverse charge: add offsetting lines
|
|
if (treatment === 'reverse_charge' && amount < 0) {
|
|
const rcVatAmt = Math.round(absAmount * 0.25 * 100) / 100
|
|
result.push({ side: 'debet', account: '2645', amount: rcVatAmt })
|
|
result.push({ side: 'kredit', account: '2614', amount: rcVatAmt })
|
|
}
|
|
|
|
return result
|
|
}, [amount, category, vatTreatment, accountOverride, entityType, templateDebitAccount, templateCreditAccount, templateVatRate, linePattern, settlementAccount])
|
|
|
|
if (lines.length === 0) return null
|
|
|
|
return (
|
|
<div className="rounded-lg border bg-muted/30 px-3 py-2.5 overflow-hidden">
|
|
<p className="text-xs font-medium text-muted-foreground mb-1.5">Verifikation</p>
|
|
<div className="space-y-0.5 font-mono text-xs min-w-0">
|
|
{lines.map((line, i) => (
|
|
<div key={i} className="flex items-baseline gap-2 min-w-0">
|
|
<span className={`w-12 text-right flex-shrink-0 ${line.side === 'debet' ? 'text-foreground' : 'text-muted-foreground'}`}>
|
|
{line.side === 'debet' ? 'Debet' : 'Kredit'}
|
|
</span>
|
|
<span className="flex-1 truncate">{formatAccountWithName(line.account)}</span>
|
|
<span className="flex-shrink-0 tabular-nums">{formatCurrency(line.amount, currency)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|