Files
accounted/components/transactions/JournalEntryPreview.tsx
T
MattssonandClaude Opus 4.7 3fa871c742 Bug/accounting suggestion (#456)
* feat: add bike benefit handling and optional vacation accrual

- Introduced bike benefit (cykelförmån) with calculations for annual market value and monthly taxable value.
- Updated schemas to include new benefit types and validation rules.
- Implemented API routes for creating, updating, and deleting employee benefits.
- Enhanced salary calculation logic to accommodate new vacation rule options, including a 'none' option for no accrual.
- Added UI components for managing employee benefits, including input for bike benefit specifics.
- Created database migrations for employee benefits and updated salary line items to support new benefit types.

* chore: remove Langfuse env var checks

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: enhance OAuth callback URL handling and update default scopes for Visma integration

* feat: remove trade_name field and simplify company naming in invoices

* refactor: destructure canWrite from useCanWrite for consistency across components

* feat: enhance PATCH endpoint to validate existing benefits and handle bike benefit updates

* feat: add missing label for bike benefit in salary line item types

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:26:04 +02:00

174 lines
7.0 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)
const isIncome = amount > 0
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 {
// 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, 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>
)
}