* feat(reports): list a booked 8999 in Resultatrapport instead of hiding it Resultatrapport and dimension-pnl filtered account 8999 out and printed a computed result row, so a user who books or imports the omföring of årets resultat by hand saw huvudboken and the account-level report disagree. Why it occurred: the filter was copied from the formal Resultaträkning, where it is right (ÅRL's uppställningsform has no 8999 line). In the operational report it hid a real balance. Our own bokslut verifikat never posts 8999 (it zeroes each P&L account straight against 2099), so the only 8999 balances that exist are manual or SIE-imported ones, exactly the case the report suppressed. What was removed: the exclusion itself, in both operational reports, so they keep reconciling. The XLSX bottom row is renamed to "Beräknat resultat" to match the UI and PDF. Beräknat resultat now reads zero after such an omföring, the Fortnox/Visma resultatrapport convention. Why this and not the proposed shape: the user asked about Resultaträkning, which stays as is on purpose. The bigger version (Stage 2 of #1051, showing the bokslut verifikat via exclude-final) would zero every row of a closed year given our closing-entry shape and is a separate decision; recorded in DECISIONS.md. Fixes #2455 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0131jmfXGzSdyjaQoGiCoo1t * test(reports): pin the deliberate 8999 gap between Resultatrapport and Resultaträkning The cross-surface agreement test claimed the two operational reports are identical; after #2455 they differ by exactly a booked 8999 omföring, and the fixture had no such row so the invariant went silently false. Pin the gap explicitly, and note in DECISIONS.md that this supersedes the 2026-07-29 same-profit line. Refs #2455 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0131jmfXGzSdyjaQoGiCoo1t --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
607 lines
23 KiB
TypeScript
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)
|
|
})
|
|
})
|