Files
accounted/lib/bokslut/__tests__/periodiseringsfond-service.test.ts
T
MattssonandClaude Fable 5 46b8e2bfea Fix/fable design (#1063)
* fix(bokslut): make dispositions storno-safe and derive fond math from opening balances

A reversed year_end voucher kept its storno in the income statement while
the original was excluded (source_type asymmetry), inflating resultat fore
dispositioner by exactly the reversed amount, and the posted-only fond
balance produced a phantom negative 212X that leaked a bogus aterforing
proposal. Support case: a user double-booked periodiseringsfond, reversed
both correctly, and the dispositions page still showed wrong numbers.

- trial-balance excludeYearEndClosing now also excludes entries chained to
  reversed year_end entries via reverses_id/correction_of_id (grammar
  verified against staging PostgREST)
- listExistingPeriodiseringsfonder counts posted+reversed so storno pairs
  cancel, and returns opening balances per fond
- schablonintakt per IL 30 kap 6a: opening balance base, rate = SLR per
  closing year (1.96% FY2025, 2.55% FY2026), replacing the wrong SLR+1pp
  0.0355 constant
- avsattning 25% cap is year-total: already-provisioned current-cohort
  growth consumes headroom in both preview and commit, so re-running the
  flow can no longer double-book the fond
- SLP posts before avsattning (deductible, shrinks the cap base) and is
  posted-aware: no double proposal or double count on resumed runs
- sumPostedYearEndDispositions counts correction replacements of reversed
  year_end entries and exposes the SLP portion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(bokslut): use roundOre for new fond/disposition rounding

Satisfies the naive-ore-round ratchet that tightened on main; identical
arithmetic, pinned by the existing exact-value tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bokslut): address PR #1063 review findings

- computeProposal receives the already-validated period row: a transient
  DB failure can no longer silently skip a requested disposition (and two
  redundant per-item period fetches are gone)
- getSchablonintaktRate fails closed for unmapped years instead of
  falling back to the latest known rate: statutory rates are never
  guessed; POST rate override remains the escape hatch
- listExistingPeriodiseringsfonder is opening-balance-entry aware:
  a fond carried via the OB entry booked by year-end closing was counted
  twice (once from history, once from the OB entry); balances now derive
  from OB + current-period activity when an OB entry exists
- periodStart is validated as a real calendar date, not just a shape
- reversed year_end correction targets resolve company-wide in
  sumPostedYearEndDispositions, matching the trial balance exclusion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:06:41 +02:00

549 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
proposeAvsattning,
proposeAteforing,
listExistingPeriodiseringsfonder,
getPeriodiseringsfondCohortAccount,
getSchablonintaktRate,
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', () => {
expect(getPeriodiseringsfondCohortAccount(2020)).toBe('2120')
expect(getPeriodiseringsfondCohortAccount(2025)).toBe('2125')
expect(getPeriodiseringsfondCohortAccount(2026)).toBe('2126')
expect(getPeriodiseringsfondCohortAccount(2027)).toBe('2127')
})
it('returns 2129 for 2019 per BAS collision rule', () => {
expect(getPeriodiseringsfondCohortAccount(2019)).toBe('2129')
})
})
describe('getSchablonintaktRate', () => {
it('returns the SLR for known closing years (IL 30 kap 6a §: SLR itself, not SLR + 1 pp)', () => {
expect(getSchablonintaktRate(2025)).toBe(0.0196)
expect(getSchablonintaktRate(2026)).toBe(0.0255)
})
it('fails closed for unmapped years: a statutory rate is never guessed', () => {
expect(() => getSchablonintaktRate(2030)).toThrow(/not configured/)
})
})
describe('proposeAvsattning', () => {
it('caps avsättning at 25% of base', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
desiredAmount: 200_000, // user asks for 50%: should be capped
fiscalYear: 2025,
})
expect(result).not.toBeNull()
expect(result!.amount).toBe(100_000) // 25% of 400_000
expect(result!.warnings).toHaveLength(1)
expect(result!.warnings[0]).toContain('25 %')
expect(result!.lines[0].account_number).toBe('8811')
expect(result!.lines[1].account_number).toBe('2125')
})
it('defaults to maximum when desiredAmount is omitted', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
fiscalYear: 2025,
})
expect(result!.amount).toBe(100_000)
expect(result!.warnings).toHaveLength(0)
})
it('honors a smaller desiredAmount', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
desiredAmount: 30_000,
fiscalYear: 2025,
})
expect(result!.amount).toBe(30_000)
expect(result!.warnings).toHaveLength(0)
})
it('returns null when base is negative (loss year)', () => {
expect(
proposeAvsattning({
skattemassigtResultatBeforeAvsattning: -100_000,
fiscalYear: 2025,
}),
).toBeNull()
})
it('returns null when desired is zero', () => {
expect(
proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
desiredAmount: 0,
fiscalYear: 2025,
}),
).toBeNull()
})
it('emits balanced lines (debit 8811 = credit 21XX)', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
fiscalYear: 2026,
})
expect(result!.lines).toHaveLength(2)
const totalDebit = result!.lines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = result!.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(totalDebit).toBe(totalCredit)
expect(result!.lines[1].account_number).toBe('2126')
})
it('uses fiscal year for cohort account number', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 100_000,
fiscalYear: 2027,
})
expect(result!.lines[1].account_number).toBe('2127')
})
it('reduces headroom by an already-provisioned current-year fond', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
fiscalYear: 2025,
alreadyProvisioned: 60_000,
})
// 25% of 400_000 = 100_000 cap; 60_000 already booked -> 40_000 left
expect(result!.amount).toBe(40_000)
})
it('returns null when the year cap is already fully provisioned', () => {
expect(
proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
fiscalYear: 2025,
alreadyProvisioned: 100_000,
}),
).toBeNull()
// Over-provisioned (legacy double booking) must not propose more either
expect(
proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
fiscalYear: 2025,
alreadyProvisioned: 150_000,
}),
).toBeNull()
})
it('caps an explicit desiredAmount to the remaining headroom with a warning', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
desiredAmount: 80_000,
fiscalYear: 2025,
alreadyProvisioned: 60_000,
})
expect(result!.amount).toBe(40_000)
expect(result!.warnings).toHaveLength(1)
expect(result!.warnings[0]).toContain('redan avsatt')
})
})
describe('proposeAteforing', () => {
it('forces full reversal of 6+ year old fonder and marks them required', () => {
const fonder: ExistingFond[] = [
{
account_number: '2120',
cohort_year: 2020,
balance: 50_000,
opening_balance: 50_000,
must_return_this_year: true,
},
]
const result = proposeAteforing(fonder, { schablonintaktRate: 0.03 })
expect(result.proposals).toHaveLength(1)
expect(result.proposals[0].amount).toBe(50_000)
expect(result.proposals[0].required).toBe(true)
expect(result.proposals[0].warnings[0]).toContain('6-årsgränsen')
// 50_000 × 0.03 = 1500
expect(result.schablonintaktAmount).toBe(1_500)
})
it('skips non-mandatory fonder when no return amount requested', () => {
const fonder: ExistingFond[] = [
{
account_number: '2122',
cohort_year: 2022,
balance: 100_000,
opening_balance: 100_000,
must_return_this_year: false,
},
]
const result = proposeAteforing(fonder, { schablonintaktRate: 0.03 })
expect(result.proposals).toHaveLength(0)
// Schablonintäkt is computed regardless of return decision
expect(result.schablonintaktAmount).toBe(3_000)
})
it('returns the requested optional amount when user opts in', () => {
const fonder: ExistingFond[] = [
{
account_number: '2123',
cohort_year: 2023,
balance: 80_000,
opening_balance: 80_000,
must_return_this_year: false,
},
]
const result = proposeAteforing(fonder, {
returns: { '2123': 50_000 },
schablonintaktRate: 0.03,
})
expect(result.proposals).toHaveLength(1)
expect(result.proposals[0].amount).toBe(50_000)
expect(result.proposals[0].required).toBeFalsy()
})
it('caps optional returns to the actual balance', () => {
const fonder: ExistingFond[] = [
{
account_number: '2124',
cohort_year: 2024,
balance: 30_000,
opening_balance: 30_000,
must_return_this_year: false,
},
]
const result = proposeAteforing(fonder, {
returns: { '2124': 100_000 },
schablonintaktRate: 0.03,
})
expect(result.proposals[0].amount).toBe(30_000) // capped to balance
})
it('emits balanced lines (debit 21XX = credit 8819)', () => {
const fonder: ExistingFond[] = [
{
account_number: '2120',
cohort_year: 2020,
balance: 50_000,
opening_balance: 50_000,
must_return_this_year: true,
},
]
const result = proposeAteforing(fonder, { schablonintaktRate: 0.03 })
const lines = result.proposals[0].lines
expect(lines).toHaveLength(2)
expect(lines[0].account_number).toBe('2120')
expect(lines[0].debit_amount).toBe(50_000)
expect(lines[1].account_number).toBe('8819')
expect(lines[1].credit_amount).toBe(50_000)
})
it('never proposes a return for zero or negative closing balances', () => {
// A negative balance is a data anomaly (e.g. a debit-only view of a
// storno pair): proposing a negative återföring would produce an
// uncommittable entry.
const fonder: ExistingFond[] = [
{
account_number: '2125',
cohort_year: 2025,
balance: -396_459,
opening_balance: 0,
must_return_this_year: false,
},
{
account_number: '2120',
cohort_year: 2020,
balance: 0,
opening_balance: 0,
must_return_this_year: true,
},
]
const result = proposeAteforing(fonder, { schablonintaktRate: 0.03 })
expect(result.proposals).toHaveLength(0)
expect(result.schablonintaktAmount).toBe(0)
})
it('computes schablonintäkt on opening balances per IL 30 kap 6a §', () => {
const fonder: ExistingFond[] = [
// Avsatt in THIS bokslut: opening 0, no schablonintäkt
{
account_number: '2125',
cohort_year: 2025,
balance: 200_000,
opening_balance: 0,
must_return_this_year: false,
},
// Held all year: full schablonintäkt
{
account_number: '2123',
cohort_year: 2023,
balance: 100_000,
opening_balance: 100_000,
must_return_this_year: false,
},
// Fully återförd during the year: schablonintäkt still due
{
account_number: '2120',
cohort_year: 2020,
balance: 0,
opening_balance: 50_000,
must_return_this_year: true,
},
]
const result = proposeAteforing(fonder, { schablonintaktRate: 0.03 })
// 0 + 100_000 × 0.03 + 50_000 × 0.03 = 4_500
expect(result.schablonintaktAmount).toBe(4_500)
// The 2125/2120 fonder yield no return proposal (2125 not requested,
// 2120 balance already zero despite the mandatory flag)
expect(result.proposals).toHaveLength(0)
})
it('exposes constants used by callers', () => {
expect(PFOND_AB_RATE).toBe(0.25)
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', 'in', 'gte', 'lte'] as const) {
q[m] = vi.fn((...args: unknown[]) => {
calls.push([m, ...args])
return q
})
}
return { q, calls }
}
it('calls fetchEntryLines with entry dates attached for the opening-balance split', async () => {
mockFetchEntryLines.mockResolvedValue([])
const result = await listExistingPeriodiseringsfonder(
supabase, 'company-1', '2025-12-31', '2025-01-01',
)
expect(result).toEqual([])
expect(mockFetchEntryLines).toHaveBeenCalledTimes(1)
expect(mockFetchEntryLines).toHaveBeenCalledWith(
expect.objectContaining({
supabase,
entryColumns: 'id, entry_date',
lineColumns: 'account_number, debit_amount, credit_amount',
}),
)
})
it('scopes entries to company/posted+reversed/closing date and lines to the 21xx range', async () => {
mockFetchEntryLines.mockResolvedValue([])
await listExistingPeriodiseringsfonder(supabase, 'company-1', '2025-12-31', '2025-01-01')
const options = mockFetchEntryLines.mock.calls[0][0]
const entries = makeChainableQuery()
options.filterEntries(entries.q)
// Both statuses: a reversed original and its storno must cancel; a
// posted-only view would show the storno's debit as a phantom negative
// fond balance.
expect(entries.calls).toEqual([
['eq', 'company_id', 'company-1'],
['in', 'status', ['posted', 'reversed']],
['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), splits opening vs closing, and maps cohort years', async () => {
mockFetchEntryLines.mockResolvedValue([
// 2125 avsatt during the period: closing 80_000, opening 0
{
account_number: '2125', debit_amount: 0, credit_amount: 100_000,
journal_entries: { entry_date: '2026-12-31' },
},
{
account_number: '2125', debit_amount: 20_000, credit_amount: 0,
journal_entries: { entry_date: '2026-12-31' },
},
// Numeric strings and nulls coerce like the embed rows did; booked in
// a prior period so it is part of the opening balance
{
account_number: '2120', debit_amount: null, credit_amount: '50000',
journal_entries: { entry_date: '2020-12-31' },
},
// 2129 is the 2019 collision account per the BAS 2020 seed
{
account_number: '2129', debit_amount: 0, credit_amount: 10_000,
journal_entries: { entry_date: '2019-12-31' },
},
// 2110 is a grouping account with no cohort: skipped
{
account_number: '2110', debit_amount: 0, credit_amount: 5_000,
journal_entries: { entry_date: '2020-12-31' },
},
])
const result = await listExistingPeriodiseringsfonder(
supabase, 'company-1', '2026-12-31', '2026-01-01',
)
// Sorted by cohort_year ascending
expect(result).toEqual([
{
account_number: '2129',
cohort_year: 2019,
balance: 10_000,
opening_balance: 10_000,
must_return_this_year: true, // 2019 + 6 = 2025 <= 2026
},
{
account_number: '2120',
cohort_year: 2020,
balance: 50_000,
opening_balance: 50_000,
must_return_this_year: true, // 2020 + 6 = 2026 <= 2026
},
{
account_number: '2125',
cohort_year: 2025,
balance: 80_000,
opening_balance: 0,
must_return_this_year: false, // 2025 + 6 = 2031 > 2026
},
])
})
it('does not double-count a fond carried via an opening-balance entry', async () => {
// Year-end closing of the previous period books an OB entry (dated at
// period_start) that carries the accumulated 21xx balances. Summing the
// OB entry together with the pre-period history would double the fond.
mockFetchEntryLines.mockResolvedValue([
// Prior-period avsättning: already carried inside the OB entry
{
account_number: '2124', debit_amount: 0, credit_amount: 60_000,
journal_entry_id: 'prior-1',
journal_entries: { entry_date: '2024-12-31' },
},
// The OB entry itself, dated exactly at period_start
{
account_number: '2124', debit_amount: 0, credit_amount: 60_000,
journal_entry_id: 'ob-1',
journal_entries: { entry_date: '2025-01-01' },
},
// Current-period partial återföring
{
account_number: '2124', debit_amount: 10_000, credit_amount: 0,
journal_entry_id: 'cur-1',
journal_entries: { entry_date: '2025-12-31' },
},
])
const result = await listExistingPeriodiseringsfonder(
supabase, 'company-1', '2025-12-31', '2025-01-01', 'ob-1',
)
expect(result).toEqual([
{
account_number: '2124',
cohort_year: 2024,
balance: 50_000, // 60_000 carried - 10_000 returned, NOT 110_000
opening_balance: 60_000, // the OB entry, despite being dated at period_start
must_return_this_year: false,
},
])
})
it('rejects a well-shaped but impossible period start date', async () => {
await expect(
listExistingPeriodiseringsfonder(supabase, 'company-1', '2025-12-31', '2025-02-31'),
).rejects.toThrow('Invalid period start: 2025-02-31')
expect(mockFetchEntryLines).not.toHaveBeenCalled()
})
it('keeps a fond fully återförd during the period (closing 0, opening > 0)', async () => {
mockFetchEntryLines.mockResolvedValue([
{
account_number: '2121', debit_amount: 0, credit_amount: 40_000,
journal_entries: { entry_date: '2021-12-31' },
},
{
account_number: '2121', debit_amount: 40_000, credit_amount: 0,
journal_entries: { entry_date: '2026-12-31' },
},
])
const result = await listExistingPeriodiseringsfonder(
supabase, 'company-1', '2026-12-31', '2026-01-01',
)
expect(result).toEqual([
{
account_number: '2121',
cohort_year: 2021,
balance: 0,
opening_balance: 40_000,
must_return_this_year: false, // 2021 + 6 = 2027 > 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', '2026-01-01',
)
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', '2025-01-01'),
).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', '2025-01-01'),
).rejects.toThrow('Invalid closing date: not-a-date')
expect(mockFetchEntryLines).not.toHaveBeenCalled()
})
it('rejects an unparseable period start before querying', async () => {
await expect(
listExistingPeriodiseringsfonder(supabase, 'company-1', '2025-12-31', 'not-a-date'),
).rejects.toThrow('Invalid period start: not-a-date')
expect(mockFetchEntryLines).not.toHaveBeenCalled()
})
})