fix(bookkeeping): book pre-FY bank transactions on the fiscal year's first day (#1828)
A newly registered company whose first rakenskapsar starts on the
Bolagsverket registration date could not book the aktiekapital deposit,
because the bank transaction is dated BEFORE the registration. Every
surface dead-ended: the manual booking dialog hard-blocked with the date
locked and only offered creating a (legally wrong) pre-registration
fiscal year, and the categorize paths either marked the row categorized
WITHOUT a verifikat ("Delvis bokforda") or silently minted a bogus
calendar-year period before the company existed.
Root cause: entry_date was hard-wired to the bank date with no clamp
against the company's first fiscal period, and the duplicated
ensureFiscalPeriod helpers upserted a calendar-year period for any
uncovered date.
Fix, per BFL (the event belongs to the first fiscal year; the real
affarshaendelse date is preserved on the verifikat):
- createTransactionJournalEntry clamps a pre-FY date into the earliest
OPEN unlocked fiscal period with entry_date = period_start and appends
"Affarshaendelse <date>, bokford pa rakenskapsarets forsta dag" to the
verifikationstext. Interior gaps, future dates, and a closed/locked
first year keep the old null return.
- Both ensureFiscalPeriod copies (categorize core + web route) skip the
calendar-period upsert when the date predates the earliest period.
- JournalEntryForm's no_period block offers "Bokfor pa rakenskapsarets
forsta dag (<date>)" for pre-FY dates instead of proposing a
pre-registration year; "Skapa rakenskapsar" remains for the other
no_period cases.
No schema, RPC, or trigger changes: the /book route and the DB triggers
already accept the clamped booking.
Fixes #1825
Claude-Session: https://claude.ai/code/session_01SyDuePXxUFowaPBKpAv8SF
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
62135c0c02
commit
8e3015e541
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { makeTransaction } from '@/tests/helpers'
|
||||
import { createQueuedMockSupabase, makeTransaction } from '@/tests/helpers'
|
||||
import type { CreateJournalEntryInput, MappingResult, VatJournalLine } from '@/types'
|
||||
|
||||
// Mock engine
|
||||
@@ -136,12 +136,14 @@ describe('createTransactionJournalEntry', () => {
|
||||
|
||||
// --- Fiscal period ---
|
||||
|
||||
it('returns null when no fiscal period found', async () => {
|
||||
it('returns null when no fiscal period found and the company has no periods at all', async () => {
|
||||
mockedFindFiscalPeriod.mockResolvedValue(null)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [] }) // earliest-period lookup: nothing
|
||||
const tx = makeTransaction()
|
||||
const mapping = makeMappingResult()
|
||||
|
||||
const result = await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
||||
const result = await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, mapping)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockedCreateEntry).not.toHaveBeenCalled()
|
||||
@@ -477,6 +479,111 @@ describe('createTransactionJournalEntry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Pre-FY clamp (issue #1825): a bank event dated before the company's first
|
||||
// rakenskapsar (e.g. the aktiekapital deposit paid in before the Bolagsverket
|
||||
// registration date) books into the first OPEN unlocked fiscal period with
|
||||
// entry_date = period_start, and the verifikationstext carries the real
|
||||
// bank-event date. Everything else keeps the old null return.
|
||||
describe('createTransactionJournalEntry: pre-FY clamp', () => {
|
||||
const openFirstPeriod = {
|
||||
id: 'period-first',
|
||||
period_start: '2026-05-12',
|
||||
is_closed: false,
|
||||
locked_at: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockedFindFiscalPeriod.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('books a pre-FY date into the earliest open period on its first day', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [openFirstPeriod] })
|
||||
const tx = makeTransaction({ date: '2026-03-10', amount: 25000, description: 'Insättning aktiekapital' })
|
||||
const mapping = makeMappingResult({ debit_account: '1930', credit_account: '2081' })
|
||||
|
||||
const result = await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, mapping)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(mockedCreateEntry).toHaveBeenCalledOnce()
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(input.fiscal_period_id).toBe('period-first')
|
||||
expect(input.entry_date).toBe('2026-05-12')
|
||||
expect(input.description).toBe(
|
||||
'Insättning aktiekapital · Affärshändelse 2026-03-10, bokförd på räkenskapsårets första dag'
|
||||
)
|
||||
// Source linkage to the bank row is untouched by the clamp.
|
||||
expect(input.source_type).toBe('bank_transaction')
|
||||
expect(input.source_id).toBe(tx.id)
|
||||
})
|
||||
|
||||
it('keeps the notes AND the clamp note in the composed description', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [openFirstPeriod] })
|
||||
const tx = makeTransaction({ date: '2026-03-10', amount: 25000, description: 'Insättning' })
|
||||
const mapping = makeMappingResult({ debit_account: '1930', credit_account: '2081' })
|
||||
|
||||
await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, mapping, 'Aktiekapital enligt stiftelseurkund')
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(input.description).toBe(
|
||||
'Insättning · Aktiekapital enligt stiftelseurkund · Affärshändelse 2026-03-10, bokförd på räkenskapsårets första dag'
|
||||
)
|
||||
})
|
||||
|
||||
it('does NOT clamp when the date is inside or after existing periods (interior gap / future date)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// Earliest period starts BEFORE the transaction date: the missing period is
|
||||
// a gap or a not-yet-created later year, never a pre-FY case.
|
||||
enqueue({ data: [{ ...openFirstPeriod, period_start: '2025-01-01' }] })
|
||||
const tx = makeTransaction({ date: '2026-03-10', amount: -100 })
|
||||
const mapping = makeMappingResult()
|
||||
|
||||
const result = await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, mapping)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockedCreateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null when the earliest period is closed', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ ...openFirstPeriod, is_closed: true }] })
|
||||
const tx = makeTransaction({ date: '2026-03-10', amount: -100 })
|
||||
const mapping = makeMappingResult()
|
||||
|
||||
const result = await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, mapping)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockedCreateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null when the earliest period is locked', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ ...openFirstPeriod, locked_at: '2026-06-01T00:00:00Z' }] })
|
||||
const tx = makeTransaction({ date: '2026-03-10', amount: -100 })
|
||||
const mapping = makeMappingResult()
|
||||
|
||||
const result = await createTransactionJournalEntry(supabase as never, 'company-1', 'user-1', tx, mapping)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockedCreateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never queries fiscal_periods when the transaction date has an open period', async () => {
|
||||
mockedFindFiscalPeriod.mockResolvedValue('period-1')
|
||||
const tx = makeTransaction({ date: '2026-07-01', amount: -100 })
|
||||
const mapping = makeMappingResult()
|
||||
|
||||
// null supabase: any query would throw, proving the clamp path is dormant.
|
||||
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(input.entry_date).toBe('2026-07-01')
|
||||
expect(input.fiscal_period_id).toBe('period-1')
|
||||
})
|
||||
})
|
||||
|
||||
// The exported builder feeds the staged categorization preview (MCP
|
||||
// preview_data.lines and the pending-operations PATCH re-derive) — these
|
||||
// tests pin that what a user approves is the netted entry, not the
|
||||
|
||||
@@ -16,6 +16,33 @@ import type {
|
||||
|
||||
const log = createLogger('transaction-entries')
|
||||
|
||||
interface EarliestFiscalPeriodRow {
|
||||
id: string
|
||||
period_start: string
|
||||
is_closed: boolean
|
||||
locked_at: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The company's earliest fiscal period (full row, closed or not). Used by the
|
||||
* pre-FY clamp in createTransactionJournalEntry below to tell "this date
|
||||
* predates the company's first rakenskapsar" apart from an interior gap.
|
||||
*/
|
||||
async function findEarliestFiscalPeriod(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<EarliestFiscalPeriodRow | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, period_start, is_closed, locked_at')
|
||||
.eq('company_id', companyId)
|
||||
.order('period_start', { ascending: true })
|
||||
.limit(1)
|
||||
|
||||
if (error || !data || data.length === 0) return null
|
||||
return data[0] as EarliestFiscalPeriodRow
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the journal entry lines for a bank transaction from a mapping engine
|
||||
* result. Single source of truth for the gross→net split: the expense account
|
||||
@@ -269,10 +296,34 @@ export async function createTransactionJournalEntry(
|
||||
// before any period lookup, preserving the original validation order.
|
||||
const lines = buildTransactionEntryLines(transaction, mappingResult)
|
||||
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, transaction.date)
|
||||
let fiscalPeriodId = await findFiscalPeriod(supabase, companyId, transaction.date)
|
||||
let entryDate = transaction.date
|
||||
let preFyNote: string | null = null
|
||||
|
||||
if (!fiscalPeriodId) {
|
||||
log.warn('No open fiscal period found for transaction date:', transaction.date)
|
||||
return null
|
||||
// Pre-FY clamp (issue #1825): a bank event dated before the company's
|
||||
// first rakenskapsar (typically the aktiekapital deposit paid in before
|
||||
// the Bolagsverket registration date) has no covering period, and minting
|
||||
// a pre-registration year for it would be legally wrong. The correct
|
||||
// booking is on the first fiscal year's first day, with the real event
|
||||
// date preserved in the verifikationstext (BFL 5 kap 7 §). The clamp
|
||||
// fires ONLY when the date is strictly before the earliest period AND
|
||||
// that period is open and unlocked; interior gaps, future dates, and a
|
||||
// closed/locked first year keep the old null return.
|
||||
const earliest = await findEarliestFiscalPeriod(supabase, companyId)
|
||||
if (
|
||||
earliest &&
|
||||
transaction.date < earliest.period_start &&
|
||||
!earliest.is_closed &&
|
||||
!earliest.locked_at
|
||||
) {
|
||||
fiscalPeriodId = earliest.id
|
||||
entryDate = earliest.period_start
|
||||
preFyNote = `Affärshändelse ${transaction.date}, bokförd på räkenskapsårets första dag`
|
||||
} else {
|
||||
log.warn('No open fiscal period found for transaction date:', transaction.date)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Compose the verifikation's description (verifikationstext). journal_entries
|
||||
@@ -282,13 +333,14 @@ export async function createTransactionJournalEntry(
|
||||
// append when the note isn't already implied by the bank text.
|
||||
const trimmedNotes = notes?.trim()
|
||||
const baseDescription = (transaction.description ?? '').trim()
|
||||
const composedDescription = trimmedNotes
|
||||
? `${baseDescription} · ${trimmedNotes}`.trim().replace(/^· /, '').slice(0, 500)
|
||||
const extraParts = [trimmedNotes, preFyNote].filter((p): p is string => !!p)
|
||||
const composedDescription = extraParts.length > 0
|
||||
? [baseDescription, ...extraParts].filter(Boolean).join(' · ').slice(0, 500)
|
||||
: baseDescription
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: transaction.date,
|
||||
entry_date: entryDate,
|
||||
description: composedDescription,
|
||||
source_type: 'bank_transaction',
|
||||
source_id: transaction.id,
|
||||
|
||||
Reference in New Issue
Block a user