Files
accounted/components/transactions/JournalEntryPreview.tsx
T
MattssonandClaude Opus 4.7 da39eb2d43 Bug/momsdek overflow (#465)
* 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>
2026-05-13 15:17:32 +02:00

205 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 2024.
// 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>
)
}