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
@@ -310,6 +310,47 @@ describe('POST /api/transactions/[id]/categorize', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('books a transaction dated before the first fiscal year without minting a period (pre-FY, issue #1825)', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
date: '2026-03-10',
|
||||
amount: 25000,
|
||||
merchant_name: null,
|
||||
journal_entry_id: null,
|
||||
description: 'Insättning aktiekapital',
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null }) // fetch transaction
|
||||
enqueue({ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 }, error: null }) // settings
|
||||
enqueue({ data: [], error: null }) // ensureFiscalPeriod: no covering open period
|
||||
enqueue({ data: [{ period_start: '2026-05-12' }], error: null }) // ensureFiscalPeriod: earliest period
|
||||
|
||||
// The clamp inside createTransactionJournalEntry (unit-tested in
|
||||
// lib/bookkeeping/__tests__/transaction-entries.test.ts) books into the
|
||||
// first open period; here it is mocked to the successful outcome.
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
enqueue({ data: [{ id: 'tx-1' }], error: null }) // guarded update matched
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'income_other' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_created: boolean
|
||||
journal_entry_id: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_created).toBe(true)
|
||||
expect(body.journal_entry_id).toBe('je-1')
|
||||
// The pre-FY guard must not upsert a calendar-year (pre-registration) period.
|
||||
expect(findCalls('fiscal_periods', 'upsert')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('atomically unignores an ignored transaction when categorizing it', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mappi
|
||||
import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEntity } from '@/lib/bookkeeping/booking-templates'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { reverseOrphanedJournalEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { getEarliestFiscalPeriodStart } from '@/lib/core/bookkeeping/period-service'
|
||||
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
import { saveUserMappingRule, applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
|
||||
@@ -60,6 +61,17 @@ 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()
|
||||
|
||||
Reference in New Issue
Block a user