Files
accounted/lib/bookkeeping/transaction-entries.ts
T
Mattsson ade4ad5971 Fiscal period and multi bank (#228)
* feat: add fiscal period backward chaining and entry date validation

Support creating fiscal periods before the earliest existing period
(backward chaining) for backfill scenarios, alongside the existing
forward chaining. The engine now validates that entry dates fall within
the selected fiscal period, with a Swedish error message. The journal
entry form auto-selects the matching period and shows a warning with
a CreatePeriodDialog when no period covers the entry date.


* feat: support multi-bank-account for imports and reconciliation

Plumb a configurable settlement account through the entire bank import
pipeline — mapping engine, transaction entries, ingest, and
reconciliation — so secondary bank accounts (e.g. 1931, 1932) work
correctly instead of hardcoding 1930. Adds a get_unlinked_bank_lines
RPC that generalizes the existing get_unlinked_1930_lines with a
fallback for backwards compatibility. The bank file import UI now shows
a bank account selector when multiple 19xx accounts exist. Also adds
default_vat_code/sru_code to account creation and fixes uploadDocument
argument order in enable-banking sync.
2026-04-13 11:13:02 +02:00

305 lines
9.6 KiB
TypeScript

import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { 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,
companyId: string,
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, companyId, 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 (mappingResult.all_lines_complete) {
// Multi-line pattern: vat_lines contains ALL non-settlement lines with correct amounts.
// Settlement line = full absAmount on the appropriate side.
const settlementAccount = isExpense
? (mappingResult.credit_account || '1930')
: (mappingResult.debit_account || '1930')
if (isExpense) {
// All non-settlement lines (business, VAT, tax, rounding)
for (const line of mappingResult.vat_lines) {
lines.push({
account_number: line.account_number,
debit_amount: line.debit_amount,
credit_amount: line.credit_amount,
line_description: line.description || transaction.description,
})
}
// Credit bank for full amount
lines.push({
account_number: settlementAccount,
debit_amount: 0,
credit_amount: absAmount,
line_description: transaction.description,
...(isForeign ? currencyMeta : {}),
})
} else {
// Debit bank for full amount
lines.push({
account_number: settlementAccount,
debit_amount: absAmount,
credit_amount: 0,
line_description: transaction.description,
...(isForeign ? currencyMeta : {}),
})
// All non-settlement lines
for (const line of mappingResult.vat_lines) {
lines.push({
account_number: line.account_number,
debit_amount: line.debit_amount,
credit_amount: line.credit_amount,
line_description: line.description || transaction.description,
})
}
}
} else if (isExpense) {
// Business expense (legacy single debit/credit path)
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,
...(isForeign ? currencyMeta : {}),
})
} else {
// Income (legacy single debit/credit path)
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,
...(isForeign ? currencyMeta : {}),
})
// 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,
...(isForeign ? currencyMeta : {}),
},
{
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, companyId, 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,
bankAccount: string = '1930'
): 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: bankAccount,
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: bankAccount,
debit_amount: 0,
credit_amount: absAmount,
line_description: description,
}
)
}
return lines
}