* feat(settings): add option for company name position in invoice PDF * feat(migrations): add backfill for VAT account labels to correct bad seed data * feat(migrations): add backfill for VAT account labels to correct bad seed data * fix(ui): improve accessibility for company name position toggle in PDF settings * fix(bookkeeping): align BAS 2026 reference data with official PDF Reconciled lib/bookkeeping/bas-data/ against the BAS 2026 v1.1 official chart (1286 accounts). All real discrepancies fixed: - 2089 Fond för utvecklingsutgifter: k2_excluded → true - 8417 Räntekostnader för dold räntekompensation: k2_excluded → true - 1250, 1260 renamed to "(Fritt konto för Inventarier, verktyg och installationer)" — BAS 2026 freed these slots - Periodiseringsfond 2120-2139: added year suffixes (2120 = "...2020" etc.) and added 8 missing accounts (2121-2127, 2129) for years 2019, 2021-2027. Dropped phantom 2022/2024 prior-parser garbage. - 4075-4078: EUland → EU-land - 8411: förlagsoch → förlags- och Verified: 1282 of 1286 PDF accounts match exactly after edits (remaining 4 are PDF-parser artifacts, not real data). Build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(migrations): backfill BAS 2026 account labels in chart_of_accounts Companion to the TS reference fix. Updates existing companies' rows where they still carry seed-data typos or generic names that don't match BAS 2026: - 4075-4078: EUland → EU-land (hyphenation) - 8411: förlagsoch → förlags- och (hyphenation) - 2120, 2130-2137, 2139: rename "Periodiseringsfond" (generic, no year) to the BAS 2026 canonical name with year suffix Defensive: every WHERE clause matches an EXACT current value. Rows that have been manually renamed by users — including those with a wrong year that may reference legacy fonds from an earlier BAS numbering cycle — are left untouched. No row is deleted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(migrations): fix wrong-year labels on Periodiseringsfond accounts Follow-up to 20260513140000. The first backfill only renamed accounts whose name was the generic "Periodiseringsfond" (no year). Many customers were seeded from an older BAS numbering cycle where 2126 = "2016", 2127 = "2017", etc. — BAS 2026 reuses those account numbers for years 2026/2027. This migration aligns the year tag with the BAS 2026 meaning of each account number across 2120-2127, 2129, 2130-2137, 2139. Only rows whose name still starts with "Periodiseringsfond" are touched — customers who renamed the account to something custom keep their name. Verified on staging: all 18 accounts now carry BAS 2026 canonical names. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(data): update account names and descriptions for clarity and consistency * fix(migrations): backfill account names for BAS 2026 freed accounts and refine Periodiseringsfond name matching * feat: enhance VAT handling with reverse charge logic and supplier type support * feat: implement VAT declaration validation rules and enhance moms box mapping --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
205 lines
8.7 KiB
TypeScript
205 lines
8.7 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
|
||
templateVatTreatment?: VatTreatment | null
|
||
templateSupplierType?: 'eu_business' | 'non_eu_business' | 'swedish_business'
|
||
/** 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,
|
||
templateVatTreatment,
|
||
templateSupplierType,
|
||
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)
|
||
const isIncome = amount > 0
|
||
const isReverseCharge = templateVatTreatment === 'reverse_charge' && !isIncome
|
||
|
||
if (isIncome) {
|
||
// Income: debit bank gross, credit revenue net, credit output VAT
|
||
result.push({ side: 'debet', account: templateDebitAccount, amount: absAmount })
|
||
result.push({ side: 'kredit', account: templateCreditAccount, amount: netAmt })
|
||
if (vatAmt > 0) {
|
||
// Map rate → output VAT account (BAS 2611/2621/2631)
|
||
const outputVatAccount = vatRate === 0.06 ? '2631' : vatRate === 0.12 ? '2621' : '2611'
|
||
result.push({ side: 'kredit', account: outputVatAccount, amount: vatAmt })
|
||
}
|
||
} else if (isReverseCharge) {
|
||
// Expense with reverse charge: full reverse-charge verifikation
|
||
// (must match engine output in buildMappingResultFromTemplate).
|
||
const rcRate = 0.25
|
||
const rcVatAmt = Math.round(absAmount * rcRate * 100) / 100
|
||
const supplierType = templateSupplierType ?? 'eu_business'
|
||
const isDomestic = supplierType === 'swedish_business'
|
||
|
||
// Expense gross + bank
|
||
result.push({ side: 'debet', account: templateDebitAccount, amount: absAmount })
|
||
result.push({ side: 'kredit', account: templateCreditAccount, amount: absAmount })
|
||
|
||
// Fiktiv moms pair: 2645 (or 2647 domestic) / 2614
|
||
result.push({ side: 'debet', account: isDomestic ? '2647' : '2645', amount: rcVatAmt })
|
||
result.push({ side: 'kredit', account: '2614', amount: rcVatAmt })
|
||
|
||
// Basbelopp pair: 44xx|45xx / 4598 — populates rutor 20–24.
|
||
// Skip if the debit account is already a basis account.
|
||
if (!/^4[45]\d{2}$/.test(templateDebitAccount)) {
|
||
const basisAccount =
|
||
supplierType === 'eu_business' ? '4535'
|
||
: supplierType === 'non_eu_business' ? '4531'
|
||
: '4425'
|
||
result.push({ side: 'debet', account: basisAccount, amount: absAmount })
|
||
result.push({ side: 'kredit', account: '4598', amount: absAmount })
|
||
}
|
||
} else {
|
||
// Expense: debit expense net + input VAT, credit bank gross
|
||
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, templateVatTreatment, templateSupplierType, 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>
|
||
)
|
||
}
|