6f4573f380
- 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>
257 lines
7.9 KiB
TypeScript
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
|
|
}
|