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.
This commit is contained in:
Mattsson
2026-04-13 11:13:02 +02:00
committed by GitHub
parent 258a64a849
commit ade4ad5971
21 changed files with 1025 additions and 73 deletions
+185
View File
@@ -105,6 +105,20 @@ describe('createDraftEntry — cancelled status on line-insert failure', () => {
const supabase = {
from: vi.fn().mockImplementation((table: string) => {
if (table === 'fiscal_periods') {
return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' },
error: null,
}),
}),
}),
}),
}
}
if (table === 'journal_entries') {
return {
insert: vi.fn().mockReturnValue({
@@ -158,6 +172,177 @@ describe('createDraftEntry — cancelled status on line-insert failure', () => {
})
})
describe('createDraftEntry — date/period cross-validation', () => {
function buildSupabase(periodData: { name: string; period_start: string; period_end: string } | null) {
return {
from: vi.fn().mockImplementation((table: string) => {
if (table === 'fiscal_periods') {
return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: periodData,
error: periodData ? null : { message: 'Not found' },
}),
}),
}),
}),
}
}
if (table === 'chart_of_accounts') {
return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
in: vi.fn().mockResolvedValue({
data: [{ account_number: '1930', id: 'acc-1' }, { account_number: '3001', id: 'acc-2' }],
error: null,
}),
}),
}),
}
}
if (table === 'journal_entries') {
return {
insert: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { id: 'entry-1', status: 'draft' },
error: null,
}),
}),
}),
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { id: 'entry-1', status: 'draft', lines: [] },
error: null,
}),
}),
}),
}
}
if (table === 'journal_entry_lines') {
return {
insert: vi.fn().mockResolvedValue({ error: null }),
}
}
return createMockChain()
}),
}
}
const validLines = [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
]
it('rejects entry date before period start', async () => {
const supabase = buildSupabase({
name: 'FY 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
})
await expect(
createDraftEntry(supabase as never, 'company-1', 'user-1', {
fiscal_period_id: 'period-1',
entry_date: '2024-12-15',
description: 'Test',
source_type: 'manual',
lines: validLines,
})
).rejects.toThrow('Entry date 2024-12-15 is outside fiscal period "FY 2025"')
})
it('rejects entry date after period end', async () => {
const supabase = buildSupabase({
name: 'FY 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
})
await expect(
createDraftEntry(supabase as never, 'company-1', 'user-1', {
fiscal_period_id: 'period-1',
entry_date: '2026-01-15',
description: 'Test',
source_type: 'manual',
lines: validLines,
})
).rejects.toThrow('Entry date 2026-01-15 is outside fiscal period "FY 2025"')
})
it('accepts entry date within period', async () => {
const supabase = buildSupabase({
name: 'FY 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
})
const result = await createDraftEntry(supabase as never, 'company-1', 'user-1', {
fiscal_period_id: 'period-1',
entry_date: '2025-06-15',
description: 'Test',
source_type: 'manual',
lines: validLines,
})
expect(result).toBeDefined()
expect(result.id).toBe('entry-1')
})
it('accepts entry date on period start boundary', async () => {
const supabase = buildSupabase({
name: 'FY 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
})
const result = await createDraftEntry(supabase as never, 'company-1', 'user-1', {
fiscal_period_id: 'period-1',
entry_date: '2025-01-01',
description: 'Test',
source_type: 'manual',
lines: validLines,
})
expect(result).toBeDefined()
})
it('accepts entry date on period end boundary', async () => {
const supabase = buildSupabase({
name: 'FY 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
})
const result = await createDraftEntry(supabase as never, 'company-1', 'user-1', {
fiscal_period_id: 'period-1',
entry_date: '2025-12-31',
description: 'Test',
source_type: 'manual',
lines: validLines,
})
expect(result).toBeDefined()
})
it('throws when fiscal period not found', async () => {
const supabase = buildSupabase(null)
await expect(
createDraftEntry(supabase as never, 'company-1', 'user-1', {
fiscal_period_id: 'nonexistent',
entry_date: '2025-06-15',
description: 'Test',
source_type: 'manual',
lines: validLines,
})
).rejects.toThrow('Fiscal period not found')
})
})
describe('JournalEntryStatus type includes cancelled', () => {
it('cancelled is a valid JournalEntryStatus value', () => {
const status: JournalEntryStatus = 'cancelled'
+18
View File
@@ -153,6 +153,24 @@ export async function createDraftEntry(
)
}
// Validate that entry_date falls within the selected fiscal period
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('name, period_start, period_end')
.eq('id', input.fiscal_period_id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
throw new Error('Fiscal period not found')
}
if (input.entry_date < period.period_start || input.entry_date > period.period_end) {
throw new Error(
`Entry date ${input.entry_date} is outside fiscal period "${period.name}" (${period.period_start} - ${period.period_end})`
)
}
// Resolve account IDs
const accountIdMap = await resolveAccountIds(supabase, companyId, input.lines)
+28 -11
View File
@@ -50,8 +50,11 @@ export async function evaluateMappingRules(
supabase: SupabaseClient,
companyId: string,
transaction: Transaction,
entityType?: EntityType
entityType?: EntityType,
settlementAccount?: string
): Promise<MappingResult> {
const bankAccount = settlementAccount || '1930'
// Fetch all active rules (user-specific + system defaults), ordered by priority
const { data: rules, error } = await supabase
.from('mapping_rules')
@@ -63,29 +66,29 @@ export async function evaluateMappingRules(
if (error || !rules || rules.length === 0) {
// Try counterparty templates before static template fallback
const counterpartyResult = await evaluateCounterpartyTemplates(supabase, companyId, transaction, entityType)
if (counterpartyResult) return counterpartyResult
if (counterpartyResult) return applySettlementAccount(counterpartyResult, bankAccount)
const templateResult = evaluateTemplateRules(transaction, entityType)
if (templateResult) return templateResult
return getDefaultResult(transaction)
if (templateResult) return applySettlementAccount(templateResult, bankAccount)
return getDefaultResult(transaction, bankAccount)
}
// Evaluate each rule in priority order
for (const rule of rules as MappingRule[]) {
if (matchesRule(rule, transaction)) {
return buildResult(rule, transaction, entityType)
return applySettlementAccount(buildResult(rule, transaction, entityType), bankAccount)
}
}
// Try counterparty templates before static template fallback
const counterpartyResult = await evaluateCounterpartyTemplates(supabase, companyId, transaction, entityType)
if (counterpartyResult) return counterpartyResult
if (counterpartyResult) return applySettlementAccount(counterpartyResult, bankAccount)
// Try template-based matching before default fallback
const templateResult = evaluateTemplateRules(transaction, entityType)
if (templateResult) return templateResult
if (templateResult) return applySettlementAccount(templateResult, bankAccount)
return getDefaultResult(transaction)
return getDefaultResult(transaction, bankAccount)
}
/**
@@ -261,13 +264,13 @@ function buildResult(rule: MappingRule, transaction: Transaction, entityType?: E
/**
* Default result when no rule matches (uncategorized)
*/
function getDefaultResult(transaction: Transaction): MappingResult {
function getDefaultResult(transaction: Transaction, bankAccount = '1930'): MappingResult {
const isExpense = transaction.amount < 0
return {
rule: null,
debit_account: isExpense ? '6991' : '1930',
credit_account: isExpense ? '1930' : '3900',
debit_account: isExpense ? '6991' : bankAccount,
credit_account: isExpense ? bankAccount : '3900',
risk_level: 'MEDIUM',
confidence: 0.1,
requires_review: true,
@@ -277,6 +280,20 @@ function getDefaultResult(transaction: Transaction): MappingResult {
}
}
/**
* Replace any default 1930 references in a mapping result with the actual settlement account.
* This allows mapping rules and templates that don't explicitly set a bank account
* to work correctly with secondary bank accounts (e.g. 1931).
*/
function applySettlementAccount(result: MappingResult, bankAccount: string): MappingResult {
if (bankAccount === '1930') return result
return {
...result,
debit_account: result.debit_account === '1930' ? bankAccount : result.debit_account,
credit_account: result.credit_account === '1930' ? bankAccount : result.credit_account,
}
}
/**
* Save a user-level mapping rule learned from categorization.
*
+9 -8
View File
@@ -110,7 +110,7 @@ export async function createTransactionJournalEntry(
debit_amount: 0,
credit_amount: absAmount,
line_description: transaction.description,
...(settlementAccount === '1930' ? currencyMeta : {}),
...(isForeign ? currencyMeta : {}),
})
} else {
// Debit bank for full amount
@@ -119,7 +119,7 @@ export async function createTransactionJournalEntry(
debit_amount: absAmount,
credit_amount: 0,
line_description: transaction.description,
...(settlementAccount === '1930' ? currencyMeta : {}),
...(isForeign ? currencyMeta : {}),
})
// All non-settlement lines
for (const line of mappingResult.vat_lines) {
@@ -176,7 +176,7 @@ export async function createTransactionJournalEntry(
debit_amount: 0,
credit_amount: absAmount,
line_description: transaction.description,
...(creditAccount === '1930' ? currencyMeta : {}),
...(isForeign ? currencyMeta : {}),
})
} else {
// Income (legacy single debit/credit path)
@@ -196,7 +196,7 @@ export async function createTransactionJournalEntry(
debit_amount: absAmount,
credit_amount: 0,
line_description: transaction.description,
...(debitAccount === '1930' ? currencyMeta : {}),
...(isForeign ? currencyMeta : {}),
})
// Credit revenue for net amount
lines.push({
@@ -222,7 +222,7 @@ export async function createTransactionJournalEntry(
debit_amount: absAmount,
credit_amount: 0,
line_description: transaction.description,
...(debitAccount === '1930' ? currencyMeta : {}),
...(isForeign ? currencyMeta : {}),
},
{
account_number: creditAccount,
@@ -253,7 +253,8 @@ export function buildDomesticExpenseLines(
amount: number,
expenseAccount: string,
description: string,
vatRate: number = 0.25
vatRate: number = 0.25,
bankAccount: string = '1930'
): CreateJournalEntryLineInput[] {
const absAmount = Math.abs(amount)
const lines: CreateJournalEntryLineInput[] = []
@@ -276,7 +277,7 @@ export function buildDomesticExpenseLines(
line_description: `Ingående moms ${vatRate * 100}%`,
},
{
account_number: '1930', // Företagskonto
account_number: bankAccount,
debit_amount: 0,
credit_amount: absAmount,
line_description: description,
@@ -291,7 +292,7 @@ export function buildDomesticExpenseLines(
line_description: description,
},
{
account_number: '1930',
account_number: bankAccount,
debit_amount: 0,
credit_amount: absAmount,
line_description: description,