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:
co-authored by
Claude Fable 5
parent
f91536c86c
commit
d182cf5d93
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user