* 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>
174 lines
7.0 KiB
TypeScript
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>
|
|
)
|
|
}
|