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:
Jakob Wennberg
2026-08-24 13:19:50 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 62135c0c02
commit 8e3015e541
11 changed files with 470 additions and 20 deletions
@@ -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
+58 -6
View File
@@ -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,
@@ -121,8 +121,10 @@ describe('commit duplicate guard: categorize_transaction (reverse / book the ban
{ data: { id: 'tx-1', date: '2026-03-26', amount: 98565, cash_account_id: null, journal_entry_id: null } },
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
{ data: [] }, // no fiscal period yet
{ data: [] }, // pre-FY guard: earliest-period lookup (none yet)
{ data: null }, // fiscal-period upsert
{ data: null }, // journal-entry period lookup: partial categorization path
{ data: [] }, // pre-FY clamp: earliest-period lookup (none yet)
{
data: [{
id: 'tx-1',
@@ -169,8 +171,10 @@ describe('commit duplicate guard: categorize_transaction (reverse / book the ban
{ data: { id: 'tx-1', date: '2026-03-26', amount: 98565, cash_account_id: null, journal_entry_id: null } },
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
{ data: [] }, // no fiscal period yet
{ data: [] }, // pre-FY guard: earliest-period lookup (none yet)
{ data: null }, // fiscal-period upsert
{ data: null }, // journal-entry period lookup: partial categorization path
{ data: [] }, // pre-FY clamp: earliest-period lookup (none yet)
{
data: [{
id: 'tx-1',
@@ -0,0 +1,176 @@
/**
* Pre-FY booking (issue #1825): a bank transaction dated before the company's
* first rakenskapsar (the aktiekapital deposit paid in before the Bolagsverket
* registration date is the canonical case).
*
* Pins two behaviours:
* 1. ensureFiscalPeriod never mints a calendar-year period for a pre-FY
* date: neither in the overlap case (constraint bounce) nor in the
* non-overlap case (silent pre-registration year).
* 2. categorizeMatchedTransaction books the transaction (journal_entry_id
* set, no more "categorized without verifikat") via the clamp in
* createTransactionJournalEntry: entry lands in the first open period on
* its first day, with the real bank date in the verifikationstext.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import type { CreateJournalEntryInput } from '@/types'
// Real categorize-core + real transaction-entries; only the engine (DB commit
// path) and the side-channel helpers are stubbed.
const mockFindFiscalPeriod = vi.fn()
const mockCreateJournalEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
createJournalEntry: (
_supabase: unknown,
_companyId: string,
_userId: string,
input: CreateJournalEntryInput,
) => mockCreateJournalEntry(input),
}))
vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({
detectBookingDuplicate: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/lib/transactions/link-journal-entry', () => ({
hasLiveJournalEntryLink: vi.fn().mockResolvedValue(false),
}))
vi.mock('@/lib/transactions/inbox-underlag', () => ({
propagateUnderlagForBookedTransaction: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: vi.fn().mockResolvedValue(undefined),
}))
import { categorizeMatchedTransaction, ensureFiscalPeriod } from '../categorize-core'
const TX_ID = '00000000-0000-4000-8000-0000000000fe'
const txRow = (over: Record<string, unknown> = {}) => ({
id: TX_ID,
company_id: 'company-1',
date: '2026-03-10',
amount: 25000,
currency: 'SEK',
amount_sek: 25000,
exchange_rate: 1,
description: 'Insättning aktiekapital',
merchant_name: null,
cash_account_id: null,
document_id: null,
journal_entry_id: null,
...over,
})
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mockFindFiscalPeriod.mockResolvedValue(null)
mockCreateJournalEntry.mockImplementation(async (input: CreateJournalEntryInput) => ({
id: 'je-prefy-1',
...input,
}))
})
describe('ensureFiscalPeriod: pre-FY guard', () => {
it('performs no insert when the date predates the first period (overlap case)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [] }) // no open period covers the date
enqueue({ data: [{ period_start: '2026-05-12' }] }) // earliest period
// 2026-03-10 with fiscalYearStartMonth=1: the calendar period 2026-01-01 to
// 2026-12-31 would OVERLAP the real first period and bounce off the
// exclusion constraint. The guard must return before any upsert.
const ok = await ensureFiscalPeriod(supabase as never, 'user-1', 'company-1', '2026-03-10', 1)
expect(ok).toBe(true)
expect(findCalls('fiscal_periods', 'upsert')).toHaveLength(0)
})
it('performs no insert when the date predates the first period (non-overlap case)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [] })
enqueue({ data: [{ period_start: '2026-05-12' }] })
// Deposit dated the calendar year BEFORE registration: the computed
// 2025-01-01 to 2025-12-31 period would NOT overlap anything, and the old
// code silently created a bogus pre-registration rakenskapsar.
const ok = await ensureFiscalPeriod(supabase as never, 'user-1', 'company-1', '2025-12-15', 1)
expect(ok).toBe(true)
expect(findCalls('fiscal_periods', 'upsert')).toHaveLength(0)
})
it('still creates the next-year period for a date after the latest period', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [] })
enqueue({ data: [{ period_start: '2026-05-12' }] }) // earliest, before the date
enqueue({ data: null }) // upsert
const ok = await ensureFiscalPeriod(supabase as never, 'user-1', 'company-1', '2027-03-10', 1)
expect(ok).toBe(true)
expect(findCalls('fiscal_periods', 'upsert')).toHaveLength(1)
})
it('still creates a period when the company has none at all', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [] })
enqueue({ data: [] }) // no earliest period: brand-new company
enqueue({ data: null }) // upsert
const ok = await ensureFiscalPeriod(supabase as never, 'user-1', 'company-1', '2026-03-10', 1)
expect(ok).toBe(true)
expect(findCalls('fiscal_periods', 'upsert')).toHaveLength(1)
})
})
describe('categorizeMatchedTransaction: pre-FY transaction gets a journal entry', () => {
it('books via the clamp: journal_entry_id set, entry on the first day of the first period', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: txRow() }) // transactions select
enqueue({ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }) // settings
enqueue({ data: [] }) // ensureFiscalPeriod: no covering open period
enqueue({ data: [{ period_start: '2026-05-12' }] }) // ensureFiscalPeriod: earliest
// createTransactionJournalEntry clamp: earliest full row (open, unlocked)
enqueue({
data: [{ id: 'period-first', period_start: '2026-05-12', is_closed: false, locked_at: null }],
})
// guarded transactions update
enqueue({
data: [txRow({ is_business: false, category: 'private', journal_entry_id: 'je-prefy-1' })],
})
const result = await categorizeMatchedTransaction(
supabase as never,
'user-1',
'company-1',
TX_ID,
{ category: 'private' },
)
expect(result.error).toBeUndefined()
expect(result.data?.journal_entry_id).toBe('je-prefy-1')
// The clamp booked into the first period on its first day, keeping the
// real bank date in the verifikationstext.
expect(mockCreateJournalEntry).toHaveBeenCalledOnce()
const input = mockCreateJournalEntry.mock.calls[0][0] as CreateJournalEntryInput
expect(input.fiscal_period_id).toBe('period-first')
expect(input.entry_date).toBe('2026-05-12')
expect(input.description).toContain('Affärshändelse 2026-03-10')
// No bogus pre-registration rakenskapsar was minted along the way.
expect(findCalls('fiscal_periods', 'upsert')).toHaveLength(0)
// The transaction row carries the new verifikat (no more "Delvis bokförd").
expect(findCalls('transactions', 'update')).toContainEqual([
expect.objectContaining({ journal_entry_id: 'je-prefy-1' }),
])
})
})
+12
View File
@@ -29,6 +29,7 @@ import { applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { reverseOrphanedJournalEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { getEarliestFiscalPeriodStart } from '@/lib/core/bookkeeping/period-service'
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { renderChannelContextNotes } from '@/lib/documents/channel-context-notes'
@@ -155,6 +156,17 @@ export async function ensureFiscalPeriod(
if (existing && existing.length > 0) return true
// Pre-FY guard (issue #1825): a date before the company's first fiscal
// period must NEVER mint a calendar-year rakenskapsar. Depending on overlap
// with the real first period, the upsert below would either bounce off the
// no_overlapping_fiscal_periods exclusion constraint (log noise) or silently
// create a pre-registration year (legally wrong). Return true and let the
// pre-FY clamp in createTransactionJournalEntry book the event on the first
// fiscal year's first day. Dates AFTER the latest period (next-year
// auto-creation) pass through unchanged.
const earliestStart = await getEarliestFiscalPeriodStart(supabase, companyId)
if (earliestStart && date < earliestStart) return true
const txDate = new Date(date)
const txMonth = txDate.getMonth() + 1
const txYear = txDate.getFullYear()