* 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.
305 lines
9.6 KiB
TypeScript
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
|
|
}
|