Files
accounted/lib/reports/__tests__/resultatrapport.test.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

607 lines
23 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
// Mocked rather than fed through the queued Supabase stub: the queue resolves
// in strict call order, so an extra real query inside the engine would shift
// every enqueued response in this file.
vi.mock('../latest-vouchers', () => ({
getLatestPostedVouchers: vi.fn(),
}))
import { generateResultatrapport, shiftDateOneYearBack } from '../resultatrapport'
import { generateTrialBalance } from '../trial-balance'
import { getLatestPostedVouchers } from '../latest-vouchers'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { TrialBalanceRow } from '@/types'
const mockTrialBalance = vi.mocked(generateTrialBalance)
const mockLatestVouchers = vi.mocked(getLatestPostedVouchers)
beforeEach(() => {
vi.clearAllMocks()
mockLatestVouchers.mockResolvedValue([])
})
function makeRow(overrides: Partial<TrialBalanceRow>): TrialBalanceRow {
const row: TrialBalanceRow = {
account_number: '3001',
account_name: 'Test',
account_class: 3,
opening_debit: 0,
opening_credit: 0,
period_debit: 0,
period_credit: 0,
closing_debit: 0,
closing_credit: 0,
...overrides,
}
// Full-period P&L reality: no opening balance, so window activity equals
// closing. Tests specify closing_*; mirror into period_* unless the test
// sets period activity explicitly.
if (row.period_debit === 0 && row.period_credit === 0) {
row.period_debit = row.closing_debit
row.period_credit = row.closing_credit
}
return row
}
function tb(rows: TrialBalanceRow[]) {
const totalDebit = rows.reduce((s, r) => s + r.closing_debit, 0)
const totalCredit = rows.reduce((s, r) => s + r.closing_credit, 0)
return {
rows,
totalDebit: Math.round(totalDebit * 100) / 100,
totalCredit: Math.round(totalCredit * 100) / 100,
isBalanced: Math.abs(totalDebit - totalCredit) < 0.01,
}
}
describe('generateResultatrapport', () => {
it('groups P&L accounts by class with current and prior period values', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null },
error: null,
})
mockTrialBalance.mockResolvedValueOnce(
tb([
makeRow({ account_number: '3001', account_name: 'Försäljning 25%', account_class: 3, closing_credit: 100000 }),
makeRow({ account_number: '5010', account_name: 'Lokalhyra', account_class: 5, closing_debit: 30000 }),
makeRow({ account_number: '7210', account_name: 'Löner', account_class: 7, closing_debit: 50000 }),
])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
expect(report.groups).toHaveLength(3)
expect(report.groups.map((g) => g.class)).toEqual([3, 5, 7])
expect(report.groups[0].rows[0]).toEqual({
account_number: '3001',
account_name: 'Försäljning 25%',
current_period: 100000,
prior_period: 0,
})
// Expense rows shown as negative (credit - debit)
expect(report.groups[1].rows[0].current_period).toBe(-30000)
expect(report.groups[2].rows[0].current_period).toBe(-50000)
// Net result = revenue - expenses = 100000 - 30000 - 50000 = 20000
expect(report.net_result_current).toBe(20000)
expect(report.net_result_prior).toBe(0)
expect(report.prior_period).toBeNull()
})
it('joins prior-period values onto current accounts', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: 'period-0' },
error: null,
})
q.enqueue({
data: { period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
mockTrialBalance
.mockResolvedValueOnce(
tb([
makeRow({ account_number: '3001', account_name: 'Försäljning', account_class: 3, closing_credit: 200000 }),
makeRow({ account_number: '5010', account_name: 'Lokalhyra', account_class: 5, closing_debit: 60000 }),
])
)
.mockResolvedValueOnce(
tb([
makeRow({ account_number: '3001', account_name: 'Försäljning', account_class: 3, closing_credit: 150000 }),
makeRow({ account_number: '5010', account_name: 'Lokalhyra', account_class: 5, closing_debit: 45000 }),
])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
const revenueRow = report.groups[0].rows[0]
expect(revenueRow.current_period).toBe(200000)
expect(revenueRow.prior_period).toBe(150000)
const expenseRow = report.groups[1].rows[0]
expect(expenseRow.current_period).toBe(-60000)
expect(expenseRow.prior_period).toBe(-45000)
expect(report.net_result_current).toBe(140000)
expect(report.net_result_prior).toBe(105000)
expect(report.prior_period).toEqual({ start: '2025-01-01', end: '2025-12-31' })
})
it('includes accounts that exist only in prior period (with current=0)', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: 'period-0' },
error: null,
})
q.enqueue({
data: { period_start: '2025-01-01', period_end: '2025-12-31' },
error: null,
})
mockTrialBalance
.mockResolvedValueOnce(
tb([
makeRow({ account_number: '3001', account_name: 'Försäljning', account_class: 3, closing_credit: 100000 }),
])
)
.mockResolvedValueOnce(
tb([
makeRow({ account_number: '3001', account_name: 'Försäljning', account_class: 3, closing_credit: 80000 }),
// Account discontinued this year
makeRow({ account_number: '3002', account_name: 'Gammal intäkt', account_class: 3, closing_credit: 5000 }),
])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
const class3 = report.groups.find((g) => g.class === 3)!
expect(class3.rows).toHaveLength(2)
const discontinued = class3.rows.find((r) => r.account_number === '3002')!
expect(discontinued.current_period).toBe(0)
expect(discontinued.prior_period).toBe(5000)
})
it('lists a booked 8999 as a class-8 row and nets it into beräknat resultat (#2455)', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null },
error: null,
})
mockTrialBalance.mockResolvedValueOnce(
tb([
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 100000 }),
makeRow({ account_number: '8999', account_name: 'Årets resultat', account_class: 8, closing_debit: 100000 }),
])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
const class8 = report.groups.find((g) => g.class === 8)
const row8999 = class8?.rows.find((r) => r.account_number === '8999')
expect(row8999?.current_period).toBe(-100000)
// The omföring of årets resultat zeroes the operational result, exactly
// as a Fortnox/Visma resultatrapport reads after bokslut.
expect(report.net_result_current).toBe(0)
})
it('ignores balance accounts (class 1-2)', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null },
error: null,
})
mockTrialBalance.mockResolvedValueOnce(
tb([
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 50000 }),
makeRow({ account_number: '2440', account_name: 'Lev.skuld', account_class: 2, closing_credit: 10000 }),
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 40000 }),
])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
expect(report.groups).toHaveLength(1)
expect(report.groups[0].class).toBe(3)
})
it('drops rows where both current and prior are zero', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null },
error: null,
})
mockTrialBalance.mockResolvedValueOnce(
tb([
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 50000 }),
makeRow({ account_number: '3002', account_name: 'Tom rad', account_class: 3, closing_credit: 0 }),
])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
expect(report.groups[0].rows).toHaveLength(1)
expect(report.groups[0].rows[0].account_number).toBe('3001')
})
it('falls back to the date-adjacent prior period when previous_period_id is null', async () => {
// Reproduces the multi-year-SIE bug: the continuity chain was never linked,
// so the comparison must resolve the prior year by date instead.
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null },
error: null,
})
// Date-range fallback finds the immediately-preceding period.
q.enqueue({ data: [{ id: 'period-0' }], error: null })
// Prior-period dates.
q.enqueue({ data: { period_start: '2025-01-01', period_end: '2025-12-31' }, error: null })
mockTrialBalance
.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 200000 })])
)
.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 150000 })])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
expect(report.groups[0].rows[0].current_period).toBe(200000)
expect(report.groups[0].rows[0].prior_period).toBe(150000)
expect(report.prior_period).toEqual({ start: '2025-01-01', end: '2025-12-31' })
// The fallback resolved 'period-0' and the prior TB was fetched for it.
expect(mockTrialBalance).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'period-0', {
closingEntry: 'exclude-all-year-end',
})
})
it('leaves the prior column empty when there is no earlier period at all', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null },
error: null,
})
q.enqueue({ data: [], error: null }) // no date-adjacent predecessor
mockTrialBalance.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 100000 })])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
expect(report.prior_period).toBeNull()
expect(report.net_result_prior).toBe(0)
expect(mockTrialBalance).toHaveBeenCalledTimes(1)
})
it('throws when fiscal period not found', async () => {
const q = createQueuedMockSupabase()
q.enqueue({ data: null, error: null })
await expect(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
generateResultatrapport(q.supabase as any, 'company-1', 'missing')
).rejects.toThrow('Fiscal period not found')
})
it('compares a date range against the same window shifted one year back', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: 'period-0' },
error: null,
})
// Fiscal periods covering the shifted window 2025-01-01..2025-03-31.
q.enqueue({
data: [{ id: 'period-0', period_start: '2025-01-01', period_end: '2025-12-31' }],
error: null,
})
mockTrialBalance
.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 90000 })])
)
.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 60000 })])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
fromDate: '2026-01-01',
toDate: '2026-03-31',
})
expect(report.groups[0].rows[0].current_period).toBe(90000)
expect(report.groups[0].rows[0].prior_period).toBe(60000)
expect(report.prior_period).toEqual({ start: '2025-01-01', end: '2025-03-31' })
expect(mockTrialBalance).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'period-0', {
closingEntry: 'exclude-all-year-end',
fromDate: '2025-01-01',
toDate: '2025-03-31',
})
})
it('merges the shifted window across two fiscal periods (brutet räkenskapsår)', async () => {
const q = createQueuedMockSupabase()
// Current period: brutet räkenskapsår Jul 2025 - Jun 2026; window is the
// calendar Q1 2026, so the shifted window Jan-Mar 2025 spans FY 24/25
// only. Use a window that straddles instead: Jun-Jul 2026 shifted to
// Jun-Jul 2025, split across FY 24/25 (ends Jun 30) and FY 25/26.
q.enqueue({
data: { period_start: '2025-07-01', period_end: '2026-06-30', previous_period_id: 'period-0' },
error: null,
})
q.enqueue({
data: [
{ id: 'period-old', period_start: '2024-07-01', period_end: '2025-06-30' },
{ id: 'period-1', period_start: '2025-07-01', period_end: '2026-06-30' },
],
error: null,
})
mockTrialBalance
// Current window
.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 50000 })])
)
// Prior part 1: 2025-06-01..2025-06-30 in period-old
.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 10000 })])
)
// Prior part 2: 2025-07-01..2025-07-31 in period-1 (current period is a
// legitimate source when the shifted window reaches into it)
.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 15000 })])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
fromDate: '2026-06-01',
toDate: '2026-07-31',
})
expect(report.groups[0].rows[0].prior_period).toBe(25000)
expect(report.prior_period).toEqual({ start: '2025-06-01', end: '2025-07-31' })
expect(mockTrialBalance).toHaveBeenNthCalledWith(2, expect.anything(), 'company-1', 'period-old', {
closingEntry: 'exclude-all-year-end',
fromDate: '2025-06-01',
toDate: '2025-06-30',
})
expect(mockTrialBalance).toHaveBeenNthCalledWith(3, expect.anything(), 'company-1', 'period-1', {
closingEntry: 'exclude-all-year-end',
fromDate: '2025-07-01',
toDate: '2025-07-31',
})
})
it('drops the comparison when the shifted window would overlap the current one', async () => {
const q = createQueuedMockSupabase()
// 18-month fiscal period with a 14-month window: shifting back one year
// overlaps the window itself, so no comparison is possible.
q.enqueue({
data: { period_start: '2025-01-01', period_end: '2026-06-30', previous_period_id: null },
error: null,
})
mockTrialBalance.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 100000 })])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
fromDate: '2025-01-01',
toDate: '2026-02-28',
})
expect(report.prior_period).toBeNull()
expect(mockTrialBalance).toHaveBeenCalledTimes(1)
})
it('still drops the comparison for dimension-filtered ranges', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: 'period-0' },
error: null,
})
mockTrialBalance.mockResolvedValueOnce(
tb([makeRow({ account_number: '3001', account_class: 3, closing_credit: 100000 })])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
fromDate: '2026-01-01',
toDate: '2026-03-31',
dimensions: { '6': 'P001' },
})
expect(report.prior_period).toBeNull()
expect(mockTrialBalance).toHaveBeenCalledTimes(1)
})
it('reports window activity, not rolled-forward YTD closing', async () => {
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null },
error: null,
})
q.enqueue({ data: [], error: null }) // no fiscal period covers the shifted window
// A June window: trial balance rolls Jan-May (60 000) into opening, so
// closing shows 100 000 YTD while the window's own activity is 40 000.
mockTrialBalance.mockResolvedValueOnce(
tb([
makeRow({
account_number: '3001',
account_class: 3,
opening_credit: 60000,
period_credit: 40000,
closing_credit: 100000,
}),
])
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1', {
fromDate: '2026-06-01',
toDate: '2026-06-30',
})
expect(report.groups[0].rows[0].current_period).toBe(40000)
expect(report.net_result_current).toBe(40000)
})
describe('latest_vouchers header line', () => {
function enqueuePeriod(q: ReturnType<typeof createQueuedMockSupabase>) {
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null },
error: null,
})
mockTrialBalance.mockResolvedValueOnce(tb([]))
}
it('carries the last posted voucher per series', async () => {
const q = createQueuedMockSupabase()
enqueuePeriod(q)
mockLatestVouchers.mockResolvedValueOnce([
{ series: 'A', last_number: 214 },
{ series: 'B', last_number: 37 },
])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
expect(report.latest_vouchers).toEqual([
{ series: 'A', last_number: 214 },
{ series: 'B', last_number: 37 },
])
})
it('omits the field entirely when the period has no vouchers', async () => {
const q = createQueuedMockSupabase()
enqueuePeriod(q)
mockLatestVouchers.mockResolvedValueOnce([])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
expect('latest_vouchers' in report).toBe(false)
})
it('still returns the report when the lookup fails', async () => {
const q = createQueuedMockSupabase()
enqueuePeriod(q)
mockLatestVouchers.mockRejectedValueOnce(new Error('boom'))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
expect(report.latest_vouchers).toBeUndefined()
expect(report.net_result_current).toBe(0)
})
it('scopes the window to the reported date range', async () => {
const q = createQueuedMockSupabase()
enqueuePeriod(q)
await generateResultatrapport(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
q.supabase as any,
'company-1',
'period-1',
{ fromDate: '2026-01-01', toDate: '2026-03-31' }
)
expect(mockLatestVouchers).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'period-1',
{ fromDate: '2026-01-01', toDate: '2026-03-31' }
)
})
it('skips the lookup entirely on a dimension-filtered report', async () => {
const q = createQueuedMockSupabase()
enqueuePeriod(q)
const report = await generateResultatrapport(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
q.supabase as any,
'company-1',
'period-1',
{ dimensions: { '6': 'P001' } }
)
// The report already discloses that it is partial; an unfiltered voucher
// range next to a filtered result would invite the wrong conclusion.
expect(mockLatestVouchers).not.toHaveBeenCalled()
expect(report.latest_vouchers).toBeUndefined()
})
})
})
describe('shiftDateOneYearBack', () => {
it('shifts a plain date one year back', () => {
expect(shiftDateOneYearBack('2026-03-15')).toBe('2025-03-15')
expect(shiftDateOneYearBack('2026-01-01')).toBe('2025-01-01')
expect(shiftDateOneYearBack('2026-12-31')).toBe('2025-12-31')
})
it('clamps leap day to the last day of February', () => {
expect(shiftDateOneYearBack('2028-02-29')).toBe('2027-02-28')
})
it('keeps Feb 29 when the target year is also a leap year divisible correctly', () => {
// 2001 -> 2000 is a leap year (divisible by 400)
expect(shiftDateOneYearBack('2001-02-28')).toBe('2000-02-28')
})
})
describe('closed fiscal year', () => {
it('excludes year-end closing entries on every trial-balance pass', async () => {
// Regression: the resultatavslut posts the mirror image of each P&L
// account into 2099 inside the same period, so without this exclusion the
// period movements this report sums cancel out and a closed year reads 0
// on every line. Reported against INK2R on the same ledger 2026-07-29.
const q = createQueuedMockSupabase()
q.enqueue({
data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: 'period-0' },
})
q.enqueue({ data: { period_start: '2025-01-01', period_end: '2025-12-31' } })
mockTrialBalance.mockResolvedValue({
rows: [makeRow({ account_number: '3001', period_credit: 500000 })],
totalDebit: 0,
totalCredit: 0,
isBalanced: true,
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1')
// Both the current pass and the prior-year comparison pass must exclude:
// a prior year is almost always closed.
for (const call of mockTrialBalance.mock.calls) {
expect(call[3]).toMatchObject({ closingEntry: 'exclude-all-year-end' })
}
expect(report.groups[0].rows[0].current_period).toBe(500000)
})
})