From 8e3015e54106632a828857fe26f17c9d2d5c4331 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 24 Aug 2026 13:19:50 +0200 Subject: [PATCH] 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 , 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 ()" 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 --- DECISIONS.md | 1 + .../[id]/categorize/__tests__/route.test.ts | 41 ++++ app/api/transactions/[id]/categorize/route.ts | 12 ++ components/bookkeeping/JournalEntryForm.tsx | 61 ++++-- .../__tests__/transaction-entries.test.ts | 113 ++++++++++- lib/bookkeeping/transaction-entries.ts | 64 ++++++- .../__tests__/commit-duplicate-guard.test.ts | 4 + .../__tests__/categorize-core.pre-fy.test.ts | 176 ++++++++++++++++++ lib/transactions/categorize-core.ts | 12 ++ messages/en.json | 3 + messages/sv.json | 3 + 11 files changed, 470 insertions(+), 20 deletions(-) create mode 100644 lib/transactions/__tests__/categorize-core.pre-fy.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 5b1ffa6e..108b0b73 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1171,6 +1171,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-22] Per-company invoice sending domains are gated by a manually granted capability (custom_sender_domain), deliberately NOT in PAID_CAPABILITIES: the opt-in must not be trial-seeded or written by the Stripe subscription sync, and non-grantees must see an unchanged invoicing settings page (the section hides on the 403 capability_blocked envelope). The sending-domain module has no Resend orphan-adoption path (a name that already exists is a 409), because the same Resend account holds the platform's own outbound domain. The delivery log was left untouched (no from_address column): adding it would re-open the hardened invoice_deliveries evidence triggers/redaction paths for a nice-to-have, and the log already measures delivered/bounced per send. [2026-08-22] company_sending_domains verification state (domain, status, resend_domain_id, dns_records, verified_at, last_checked_at) is service-role only via a BEFORE trigger keyed on the JWT role claim; tenant JWTs may only open a pending claim and edit sender_local_part/sender_name/enabled. Skeptic refutation: RLS alone let a granted admin insert {domain: platform sender domain, status: verified} through PostgREST and send invoice mail as the platform. The claim/verify helpers therefore take a separate service-role writer for those columns. Second refutation: a domain Resend later flips to failed made every invoice send for that company fail; the Resend adapter now retries once as the platform sender when an explicit company From is rejected (nothing was sent on the rejected attempt, so the retry cannot double-send). [2026-08-22] Sending-domain verification writes bind by (id, company_id, domain, resend_domain_id IS NULL) and verify/webhook compare Resend's domain name with the row before writing verified; resolveInvoiceSender additionally refuses reserved platform domains and non-hostnames at send time. Skeptic re-check: a tenant could delete and re-insert its pending row under the same id with a reserved domain during the claim's Resend round-trip (TOCTOU), and the service-role writer updated by id alone. Defense in depth over a single gate. +[2026-08-24] Pre-FY bank transactions (issue #1825, aktiekapital deposit dated before registration) auto-book into the first OPEN unlocked fiscal period with entry_date = period_start and the real bank date in the verifikationstext, instead of asking per booking or offering "Skapa rakenskapsar" (a pre-registration year is legally wrong). The clamp lives ONLY in createTransactionJournalEntry and fires strictly on date < earliest period_start, so interior period gaps and future dates keep the old null return; a closed/locked first year also keeps it (storno-safe). ensureFiscalPeriod copies got a matching guard so no calendar-year period is ever minted for pre-FY dates. [2026-08-24] #1717 öre settlement in match_batch_allocate replicates the ORE_ROUNDING_SETTLEMENT_MAX = 1.00 kr policy band in SQL (reject overshoot only at >= 1 kr; absorb sub-krona diffs to 3740 with full 1510/2440 clear) instead of routing the batch through planInvoicePayment: the RPC is one atomic transaction and must stay self-contained; the band constants are documented as mirrors of lib/money.ts. The mark-paid route and invoice-detail button now accept partially_paid (settleInvoicePayment's CAS guard always did) so a stuck invoice can be closed; the remaining-aware dialog proposal is deliberately scoped to SEK + accrual + no ROT/RUT deduction: cash partial completion is server-blocked (cashPartialBlockReason), FX needs a payment-day conversion the pure proposal lacks, and a ROT/RUT remainder is Skatteverket's 1513 share settled by the payout flow. [2026-08-24] Assistant empty answer is a typed failure (EmptyModelAnswerError, 502 'Assistenten gav inget svar. Försök igen.'), one manual retry, NO auto-retry: the silent 200 with an empty answer was the 'Tänker then nothing' bug after the RIP-3 cutover (1500-token cap on /api/agent/ask), and auto-retry would double model spend while hiding the regression. Related deliberate request-shape change: anthropic-family's step-exhausted fallback now keeps tools with tool_choice none, because replaying a transcript containing tool_use/tool_result without declaring tools is rejected by the Messages API (the old shape 400ed every step-exhausted answer). general.help stays on the single-call runtime (founder decision); the fix raises headroom to 5400 tokens and surfaces the failure instead. [2026-08-24] Issue #1820 self-billed credit fix: creditConfirmNumber()/originalRef fall back invoice_number -> external_invoice_number (typed 400 INVOICE_CREDIT_NO_NUMBER if both null) instead of relaxing the DB numbering constraint or dropping the type-the-number confirm step; the confirm step stays (dropping it is a founder call). The invoice-date Forval chip surfaces in ALL editor modes, not only self-billed: the silent today-default exists in every mode and the chip line already carries the due date. In self-billed mode fakturadatum + mottagningsdatum render uncollapsed next to the external number (transcription fields, not defaults); the panel rows are hidden there because registering the same RHF field twice desyncs the inputs. The v1 credit route's existing id-slice fallback was left unchanged (public API behavior). diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index 47742465..df7744b5 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -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', diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index 03585d2e..d45a3863 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -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() diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index 3aaa5318..600a53c9 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -328,6 +328,23 @@ export default function JournalEntryForm({ } }, [entryDate, periods]) + // Earliest fiscal period, for the pre-FY affordance in the no_period block: + // a date before the company's first rakenskapsar (e.g. the aktiekapital + // deposit paid in before the Bolagsverket registration) must not lead to + // creating a pre-registration year. The correct remedy per BFL is to book + // the event on the first fiscal year's first day, so we offer exactly that + // when the earliest period is open and unlocked (issue #1825). + const preFyClampTarget = useMemo(() => { + const earliest = periods.reduce( + (min, p) => (min === null || p.period_start < min.period_start ? p : min), + null, + ) + if (!earliest) return null + if (entryDate >= earliest.period_start) return null + if (earliest.is_closed || earliest.locked_at) return null + return earliest + }, [periods, entryDate]) + // Preview the upcoming voucher number for the selected period + series. // Read-only hint; the actual number is reserved atomically at commit time, // so this may shift by one if another entry lands first. @@ -1514,17 +1531,37 @@ export default function JournalEntryForm({

{t('no_period_warning', { date: entryDate })}

-

{t('no_period_help')}

+

+ {preFyClampTarget ? t('pre_fy_help') : t('no_period_help')} +

- + {/* Pre-FY: the date predates the first rakenskapsar. Offering + "Skapa räkenskapsår" here would propose a pre-registration year + (legally wrong); the correct remedy is booking on the first + fiscal year's first day, so offer that instead. Setting the + entry date state drives the /book payload even in embedded mode + where the date input is hidden. */} + {preFyClampTarget ? ( + + ) : ( + + )} )} @@ -1951,7 +1988,9 @@ export default function JournalEntryForm({
{!description &&

{t('validation_description')}

} {!selectedPeriod &&

{t('validation_period')}

} - {periodMismatch === 'no_period' &&

{t('validation_no_matching_period')}

} + {periodMismatch === 'no_period' && ( +

{preFyClampTarget ? t('validation_pre_fy') : t('validation_no_matching_period')}

+ )} {isUploading &&

{t('validation_uploading')}

} {incompleteLineCount > 0 && (

{t('validation_incomplete_lines')}

diff --git a/lib/bookkeeping/__tests__/transaction-entries.test.ts b/lib/bookkeeping/__tests__/transaction-entries.test.ts index ce27b7b8..08eaa04b 100644 --- a/lib/bookkeeping/__tests__/transaction-entries.test.ts +++ b/lib/bookkeeping/__tests__/transaction-entries.test.ts @@ -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 diff --git a/lib/bookkeeping/transaction-entries.ts b/lib/bookkeeping/transaction-entries.ts index 9d179df7..05c54713 100644 --- a/lib/bookkeeping/transaction-entries.ts +++ b/lib/bookkeeping/transaction-entries.ts @@ -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 { + 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, diff --git a/lib/pending-operations/__tests__/commit-duplicate-guard.test.ts b/lib/pending-operations/__tests__/commit-duplicate-guard.test.ts index d6f7cef3..f9975b5d 100644 --- a/lib/pending-operations/__tests__/commit-duplicate-guard.test.ts +++ b/lib/pending-operations/__tests__/commit-duplicate-guard.test.ts @@ -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', diff --git a/lib/transactions/__tests__/categorize-core.pre-fy.test.ts b/lib/transactions/__tests__/categorize-core.pre-fy.test.ts new file mode 100644 index 00000000..c324fba5 --- /dev/null +++ b/lib/transactions/__tests__/categorize-core.pre-fy.test.ts @@ -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 = {}) => ({ + 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' }), + ]) + }) +}) diff --git a/lib/transactions/categorize-core.ts b/lib/transactions/categorize-core.ts index 5606ce98..80a8d71a 100644 --- a/lib/transactions/categorize-core.ts +++ b/lib/transactions/categorize-core.ts @@ -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() diff --git a/messages/en.json b/messages/en.json index 578a4098..4c9fb0ee 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5210,6 +5210,8 @@ "series": "Series", "no_period_warning": "No fiscal year matches the date {date}", "no_period_help": "Create a fiscal year that covers this date so you can post it.", + "pre_fy_help": "The date is before the company's first fiscal year. Business events before the fiscal year's start are posted on its first day; the event date is noted in the voucher text.", + "pre_fy_book_on_first_day": "Post on the fiscal year's first day ({date})", "create_period": "Create fiscal year", "currency": "Currency", "exchange_rate_label": "Exchange rate (1 {currency} = ? SEK)", @@ -5247,6 +5249,7 @@ "validation_description": "Enter a description", "validation_period": "Select a fiscal period", "validation_no_matching_period": "Create a fiscal year that matches the date", + "validation_pre_fy": "The date is before the first fiscal year: use Post on the fiscal year's first day", "validation_uploading": "Wait until files have finished uploading", "validation_incomplete_lines": "All lines with an amount must have an account (and vice versa)", "validation_min_lines": "At least two lines with account and amount are required", diff --git a/messages/sv.json b/messages/sv.json index 41cb13a1..caf7ae45 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -5210,6 +5210,8 @@ "series": "Serie", "no_period_warning": "Inget räkenskapsår matchar datumet {date}", "no_period_help": "Skapa ett räkenskapsår som täcker detta datum för att kunna bokföra.", + "pre_fy_help": "Datumet ligger före företagets första räkenskapsår. Affärshändelser före räkenskapsårets start bokförs på årets första dag; händelsedatumet anges i verifikationstexten.", + "pre_fy_book_on_first_day": "Bokför på räkenskapsårets första dag ({date})", "create_period": "Skapa räkenskapsår", "currency": "Valuta", "exchange_rate_label": "Omräkningskurs (1 {currency} = ? SEK)", @@ -5247,6 +5249,7 @@ "validation_description": "Ange en beskrivning", "validation_period": "Välj en räkenskapsperiod", "validation_no_matching_period": "Skapa ett räkenskapsår som matchar datumet", + "validation_pre_fy": "Datumet ligger före första räkenskapsåret: välj Bokför på räkenskapsårets första dag", "validation_uploading": "Vänta tills filerna laddats upp", "validation_incomplete_lines": "Alla rader med belopp måste ha ett konto (och tvärtom)", "validation_min_lines": "Minst två rader med konto och belopp krävs",