Files
accounted/lib/bookkeeping/transaction-entries.ts
T
Jakob Wennberg 6f4573f380 feat: add foreign currency support, refactor bookkeeping engine, and improve invoice inbox document classification
- Add currency-utils module for SEK conversion with exchange rates
- Refactor createJournalEntry to use draft+commit flow preventing voucher number gaps (BFL 5 kap. 7§)
- Add foreign currency support to invoice entries with per-line SEK conversion
- Centralize category-to-account mapping into single source of truth
- Refactor invoice inbox to use shared document analyzer with document type classification (receipt, supplier invoice, government letter)
- Update mapping engine, supplier invoice entries, and transaction entries
- Fix report component rendering issues
- Add new validation schemas and tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:50:06 +01:00

257 lines
7.9 KiB
TypeScript

import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { generateInputVatLine, generateReverseChargeLines, extractNetAmount, extractVatAmount } from './vat-entries'
import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
JournalEntry,
MappingResult,
Transaction,
} from '@/types'
const log = createLogger('transaction-entries')
/**
* Create a journal entry from a bank transaction using mapping engine result
*
* Standard expense pattern (domestic purchase with 25% VAT):
* Debit 5xxx/6xxx Expense account [net amount]
* Debit 2641 Ingående moms [VAT amount]
* Credit 1930 Företagskonto [total]
*
* Standard expense pattern (no VAT deduction):
* Debit 5xxx/6xxx Expense account [total]
* Credit 1930 Företagskonto [total]
*
* Private expense pattern:
* Debit 2013 Eget uttag [total]
* Credit 1930 Företagskonto [total]
*
* EU reverse charge purchase pattern:
* Debit 5xxx/6xxx Expense account [total]
* Debit 2645 Beräknad ingående moms [fiktiv VAT]
* Credit 2614 Utgående moms omvänd [fiktiv VAT]
* Credit 1930 Företagskonto [total]
*
* Income pattern:
* Debit 1930 Företagskonto [total]
* Credit 3xxx Revenue account [total]
*/
export async function createTransactionJournalEntry(
supabase: SupabaseClient,
userId: string,
transaction: Transaction,
mappingResult: MappingResult
): Promise<JournalEntry | null> {
if (!mappingResult.debit_account || !mappingResult.credit_account) {
throw new Error(
`Invalid mapping result: debit_account="${mappingResult.debit_account}", credit_account="${mappingResult.credit_account}". Both must be non-empty.`
)
}
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, transaction.date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for transaction date:', transaction.date)
return null
}
const absAmountSek = Math.abs(resolveSekAmount(
transaction.amount, transaction.amount_sek, transaction.currency, transaction.exchange_rate
))
const absAmount = absAmountSek
const isExpense = transaction.amount < 0
const isForeign = transaction.currency !== 'SEK'
const currencyMeta = buildCurrencyMetadata(
transaction.currency,
isForeign ? Math.abs(transaction.amount) : undefined,
transaction.exchange_rate
)
const lines: CreateJournalEntryLineInput[] = []
if (mappingResult.default_private) {
// Private expense — use entity-specific account from mappingResult
lines.push(
{
account_number: mappingResult.debit_account,
debit_amount: absAmount,
credit_amount: 0,
line_description: `Privat: ${transaction.description}`,
},
{
account_number: mappingResult.credit_account || '1930',
debit_amount: 0,
credit_amount: absAmount,
line_description: transaction.description,
}
)
} else if (isExpense) {
// Business expense
const debitAccount = mappingResult.debit_account
const creditAccount = mappingResult.credit_account || '1930'
if (mappingResult.vat_lines.length > 0) {
// Has VAT handling (reverse charge or input VAT)
for (const vatLine of mappingResult.vat_lines) {
lines.push({
account_number: vatLine.account_number,
debit_amount: vatLine.debit_amount,
credit_amount: vatLine.credit_amount,
line_description: vatLine.description,
})
}
// Expense account gets the net amount (total minus VAT if applicable)
const vatDebit = mappingResult.vat_lines
.filter((l) => l.debit_amount > 0 && l.account_number === '2641')
.reduce((sum, l) => sum + l.debit_amount, 0)
// Round to 2 decimal places to avoid floating point issues
const netAmount = Math.round((absAmount - vatDebit) * 100) / 100
lines.push({
account_number: debitAccount,
debit_amount: netAmount,
credit_amount: 0,
line_description: transaction.description,
})
} else {
// No VAT handling - debit full amount to expense account
lines.push({
account_number: debitAccount,
debit_amount: absAmount,
credit_amount: 0,
line_description: transaction.description,
})
}
// Credit bank account
lines.push({
account_number: creditAccount,
debit_amount: 0,
credit_amount: absAmount,
line_description: transaction.description,
...(creditAccount === '1930' ? currencyMeta : {}),
})
} else {
// Income
const debitAccount = mappingResult.debit_account || '1930'
const creditAccount = mappingResult.credit_account
if (mappingResult.vat_lines.length > 0) {
// Has output VAT
const vatCredit = mappingResult.vat_lines
.filter(l => l.credit_amount > 0)
.reduce((sum, l) => sum + l.credit_amount, 0)
const netAmount = Math.round((absAmount - vatCredit) * 100) / 100
// Debit bank for gross amount
lines.push({
account_number: debitAccount,
debit_amount: absAmount,
credit_amount: 0,
line_description: transaction.description,
})
// Credit revenue for net amount
lines.push({
account_number: creditAccount,
debit_amount: 0,
credit_amount: netAmount,
line_description: transaction.description,
})
// Credit output VAT
for (const vatLine of mappingResult.vat_lines) {
lines.push({
account_number: vatLine.account_number,
debit_amount: vatLine.debit_amount,
credit_amount: vatLine.credit_amount,
line_description: vatLine.description,
})
}
} else {
// No VAT - simple two-line entry
lines.push(
{
account_number: debitAccount,
debit_amount: absAmount,
credit_amount: 0,
line_description: transaction.description,
},
{
account_number: creditAccount,
debit_amount: 0,
credit_amount: absAmount,
line_description: transaction.description,
}
)
}
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: transaction.description,
source_type: 'bank_transaction',
source_id: transaction.id,
lines,
}
return createJournalEntry(supabase, userId, input)
}
/**
* Create a standard domestic expense entry with input VAT deduction
*/
export function buildDomesticExpenseLines(
amount: number,
expenseAccount: string,
description: string,
vatRate: number = 0.25
): CreateJournalEntryLineInput[] {
const absAmount = Math.abs(amount)
const lines: CreateJournalEntryLineInput[] = []
if (vatRate > 0) {
const vatAmount = extractVatAmount(absAmount, vatRate)
const netAmount = extractNetAmount(absAmount, vatRate)
lines.push(
{
account_number: expenseAccount,
debit_amount: netAmount,
credit_amount: 0,
line_description: description,
},
{
account_number: '2641', // Ingående moms
debit_amount: vatAmount,
credit_amount: 0,
line_description: `Ingående moms ${vatRate * 100}%`,
},
{
account_number: '1930', // Företagskonto
debit_amount: 0,
credit_amount: absAmount,
line_description: description,
}
)
} else {
lines.push(
{
account_number: expenseAccount,
debit_amount: absAmount,
credit_amount: 0,
line_description: description,
},
{
account_number: '1930',
debit_amount: 0,
credit_amount: absAmount,
line_description: description,
}
)
}
return lines
}