refactor(bokslut): convert periodiseringsfond balance query off the journal_entries!inner embed (#977) (#1047)

listExistingPeriodiseringsfonder still selected from journal_entry_lines
with a journal_entries!inner embed and put company_id/status/entry_date on
the embedded side: the shape PostgREST compiles to a correlated lateral
that scans all tenants' lines, and it silently truncated at the 1000-row
cap because it was unpaginated. Convert it to the shared two-step
fetchEntryLines helper (lib/bookkeeping/entry-lines.ts), mirroring
bolagsskatt-calculator.ts, and keep the existing wrapped error contract.

Adds unit coverage for listExistingPeriodiseringsfonder: helper call
shape, entry/line filter callbacks, per-account balance aggregation,
2129 cohort collision rule, 6-year must-return flag, near-zero drop,
sorting, and error wrapping.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-17 11:51:03 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f91536c86c
commit d182cf5d93
2 changed files with 159 additions and 16 deletions
@@ -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<string, unknown> = {}
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()
})
})
@@ -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<Row>({
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<string, number>()
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)