diff --git a/lib/bokslut/__tests__/periodiseringsfond-service.test.ts b/lib/bokslut/__tests__/periodiseringsfond-service.test.ts index 4b42d4b7..2cb7522f 100644 --- a/lib/bokslut/__tests__/periodiseringsfond-service.test.ts +++ b/lib/bokslut/__tests__/periodiseringsfond-service.test.ts @@ -1,12 +1,19 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' import { proposeAvsattning, proposeAteforing, + listExistingPeriodiseringsfonder, getPeriodiseringsfondCohortAccount, PFOND_AB_RATE, PFOND_MAX_HOLD_YEARS, type ExistingFond, } from '../reserves/periodiseringsfond-service' +import { fetchEntryLines } from '@/lib/bookkeeping/entry-lines' + +vi.mock('@/lib/bookkeeping/entry-lines', () => ({ + fetchEntryLines: vi.fn(), +})) describe('getPeriodiseringsfondCohortAccount', () => { it('maps fiscal year to BAS 212X account', () => { @@ -186,3 +193,130 @@ describe('proposeAteforing', () => { expect(PFOND_MAX_HOLD_YEARS).toBe(6) }) }) + +describe('listExistingPeriodiseringsfonder', () => { + const supabase = {} as SupabaseClient + const mockFetchEntryLines = vi.mocked(fetchEntryLines) + + beforeEach(() => { + vi.clearAllMocks() + }) + + /** Chainable query spy that records every filter call in order. */ + function makeChainableQuery() { + const calls: Array<[string, ...unknown[]]> = [] + const q: Record = {} + for (const m of ['eq', 'gte', 'lte'] as const) { + q[m] = vi.fn((...args: unknown[]) => { + calls.push([m, ...args]) + return q + }) + } + return { q, calls } + } + + it('calls fetchEntryLines with line columns only and no entry reattachment', async () => { + mockFetchEntryLines.mockResolvedValue([]) + + const result = await listExistingPeriodiseringsfonder(supabase, 'company-1', '2025-12-31') + + expect(result).toEqual([]) + expect(mockFetchEntryLines).toHaveBeenCalledTimes(1) + expect(mockFetchEntryLines).toHaveBeenCalledWith( + expect.objectContaining({ + supabase, + lineColumns: 'account_number, debit_amount, credit_amount', + attachEntriesAs: null, + }), + ) + }) + + it('scopes entries to company/posted/closing date and lines to the 21xx range', async () => { + mockFetchEntryLines.mockResolvedValue([]) + + await listExistingPeriodiseringsfonder(supabase, 'company-1', '2025-12-31') + + const options = mockFetchEntryLines.mock.calls[0][0] + + const entries = makeChainableQuery() + options.filterEntries(entries.q) + expect(entries.calls).toEqual([ + ['eq', 'company_id', 'company-1'], + ['eq', 'status', 'posted'], + ['lte', 'entry_date', '2025-12-31'], + ]) + + expect(options.filterLines).toBeDefined() + const lines = makeChainableQuery() + options.filterLines?.(lines.q) + expect(lines.calls).toEqual([ + ['gte', 'account_number', '2110'], + ['lte', 'account_number', '2199'], + ]) + }) + + it('sums per-account balances (credit minus debit) and maps cohort years', async () => { + mockFetchEntryLines.mockResolvedValue([ + // 2125 across two rows: 100_000 credit, 20_000 debit -> 80_000 + { account_number: '2125', debit_amount: 0, credit_amount: 100_000 }, + { account_number: '2125', debit_amount: 20_000, credit_amount: 0 }, + // Numeric strings and nulls coerce like the embed rows did + { account_number: '2120', debit_amount: null, credit_amount: '50000' }, + // 2129 is the 2019 collision account per the BAS 2020 seed + { account_number: '2129', debit_amount: 0, credit_amount: 10_000 }, + // 2110 is a grouping account with no cohort: skipped + { account_number: '2110', debit_amount: 0, credit_amount: 5_000 }, + ]) + + const result = await listExistingPeriodiseringsfonder(supabase, 'company-1', '2026-12-31') + + // Sorted by cohort_year ascending + expect(result).toEqual([ + { + account_number: '2129', + cohort_year: 2019, + balance: 10_000, + must_return_this_year: true, // 2019 + 6 = 2025 <= 2026 + }, + { + account_number: '2120', + cohort_year: 2020, + balance: 50_000, + must_return_this_year: true, // 2020 + 6 = 2026 <= 2026 + }, + { + account_number: '2125', + cohort_year: 2025, + balance: 80_000, + must_return_this_year: false, // 2025 + 6 = 2031 > 2026 + }, + ]) + }) + + it('drops near-zero balances below the 0.005 threshold', async () => { + mockFetchEntryLines.mockResolvedValue([ + { account_number: '2123', debit_amount: 1_000, credit_amount: 1_000.004 }, + { account_number: '2124', debit_amount: 0, credit_amount: 30_000 }, + ]) + + const result = await listExistingPeriodiseringsfonder(supabase, 'company-1', '2026-12-31') + + expect(result).toHaveLength(1) + expect(result[0].account_number).toBe('2124') + }) + + it('wraps helper failures in the periodiseringsfond error contract', async () => { + mockFetchEntryLines.mockRejectedValue(new Error('boom')) + + await expect( + listExistingPeriodiseringsfonder(supabase, 'company-1', '2025-12-31'), + ).rejects.toThrow('Failed to fetch periodiseringsfond balances: boom') + }) + + it('rejects an unparseable closing date before querying', async () => { + await expect( + listExistingPeriodiseringsfonder(supabase, 'company-1', 'not-a-date'), + ).rejects.toThrow('Invalid closing date: not-a-date') + expect(mockFetchEntryLines).not.toHaveBeenCalled() + }) +}) diff --git a/lib/bokslut/reserves/periodiseringsfond-service.ts b/lib/bokslut/reserves/periodiseringsfond-service.ts index ef7c7c11..49c557e9 100644 --- a/lib/bokslut/reserves/periodiseringsfond-service.ts +++ b/lib/bokslut/reserves/periodiseringsfond-service.ts @@ -1,4 +1,5 @@ import type { SupabaseClient } from '@supabase/supabase-js' +import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' import type { ProposedDisposition } from '../types' /** Maximum periodiseringsfond avsättning for aktiebolag: 25 % of skattemässigt @@ -132,25 +133,33 @@ export async function listExistingPeriodiseringsfonder( } // Sum debit/credit per 21xx account up to and including the closing date. - // Use the journal_entry_lines table directly: RLS scopes to the company. - const { data, error } = await supabase - .from('journal_entry_lines') - .select( - 'account_number, debit_amount, credit_amount, journal_entries!inner(company_id, entry_date, status)', + // Two-step entry-lines fetch (see lib/bookkeeping/entry-lines.ts): drives + // the query from journal_entries and paginates, instead of the old + // journal_entries!inner embed that scanned all tenants' lines and silently + // truncated at PostgREST's 1000-row cap. + type Row = { account_number: string; debit_amount: number | string | null; credit_amount: number | string | null } + let data: Row[] + try { + data = await fetchEntryLines({ + supabase, + lineColumns: 'account_number, debit_amount, credit_amount', + filterEntries: (q: EntryLinesQuery) => + q + .eq('company_id', companyId) + .eq('status', 'posted') + .lte('entry_date', closingDate), + filterLines: (q: EntryLinesQuery) => + q.gte('account_number', '2110').lte('account_number', '2199'), + attachEntriesAs: null, + }) + } catch (err) { + throw new Error( + `Failed to fetch periodiseringsfond balances: ${err instanceof Error ? err.message : String(err)}`, ) - .eq('journal_entries.company_id', companyId) - .eq('journal_entries.status', 'posted') - .lte('journal_entries.entry_date', closingDate) - .gte('account_number', '2110') - .lte('account_number', '2199') - - if (error) { - throw new Error(`Failed to fetch periodiseringsfond balances: ${error.message}`) } - type Row = { account_number: string; debit_amount: number | string | null; credit_amount: number | string | null } const byAccount = new Map() - for (const row of (data ?? []) as Row[]) { + for (const row of data) { const balance = (Number(row.credit_amount) || 0) - (Number(row.debit_amount) || 0) byAccount.set(row.account_number, (byAccount.get(row.account_number) ?? 0) + balance)