Files
accounted/lib/reports/__tests__/kassaflodesanalys.test.ts
T
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

472 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock trial-balance and income-statement so we can plant deterministic
// inputs into the cash-flow generator. The generator is pure logic over
// (TB rows, IS totals): testing it in isolation avoids re-creating the
// Supabase mock surface for two layered generators.
vi.mock('../trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
vi.mock('../income-statement', () => ({
generateIncomeStatement: vi.fn(),
}))
import { generateKassaflodesanalys } from '../kassaflodesanalys'
import { generateTrialBalance } from '../trial-balance'
import { generateIncomeStatement } from '../income-statement'
import type { TrialBalanceRow, IncomeStatementReport } from '@/types'
const mockTrialBalance = vi.mocked(generateTrialBalance)
const mockIncomeStatement = vi.mocked(generateIncomeStatement)
function makeSupabase(period: { period_start: string; period_end: string } | null) {
// Lightweight chainable mock: kassaflodesanalys only calls
// supabase.from('fiscal_periods').select().eq().eq().single()
const builder: Record<string, unknown> = {}
for (const m of ['select', 'eq']) {
builder[m] = vi.fn().mockReturnValue(builder)
}
builder.single = vi.fn().mockResolvedValue(
period ? { data: period, error: null } : { data: null, error: null }
)
return {
from: vi.fn().mockReturnValue(builder),
} as unknown as Parameters<typeof generateKassaflodesanalys>[0]
}
function makeRow(overrides: Partial<TrialBalanceRow>): TrialBalanceRow {
return {
account_number: '0000',
account_name: 'X',
account_class: 0,
opening_debit: 0,
opening_credit: 0,
period_debit: 0,
period_credit: 0,
closing_debit: 0,
closing_credit: 0,
...overrides,
}
}
function makeIs(overrides: Partial<IncomeStatementReport> = {}): IncomeStatementReport {
return {
revenue_sections: [],
total_revenue: 0,
expense_sections: [],
total_expenses: 0,
financial_sections: [],
total_financial: 0,
net_result: 0,
period: { start: '2024-01-01', end: '2024-12-31' },
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('generateKassaflodesanalys', () => {
const PERIOD = { period_start: '2024-01-01', period_end: '2024-12-31' }
it('returns all-zero sections for an empty period', async () => {
mockTrialBalance.mockResolvedValue({
rows: [],
totalDebit: 0,
totalCredit: 0,
isBalanced: true,
})
mockIncomeStatement.mockResolvedValue(makeIs())
const report = await generateKassaflodesanalys(
makeSupabase(PERIOD),
'company-1',
'period-1'
)
expect(report.lopande.total).toBe(0)
expect(report.investerings.total).toBe(0)
expect(report.finansierings.total).toBe(0)
expect(report.total_cash_flow).toBe(0)
expect(report.reconciliation.is_reconciled).toBe(true)
expect(report.reconciliation.opening_cash_1xxx).toBe(0)
expect(report.reconciliation.closing_cash_1xxx).toBe(0)
expect(report.reconciliation.delta_actual).toBe(0)
expect(report.reconciliation.delta_calculated).toBe(0)
})
it('adds back avskrivningar (100 000 kr) to löpande verksamhet', async () => {
// Setup: 100 000 kr depreciation booked: debit 7832, credit 1219 (ack avskr).
// Expected: avskrivningar = 100 000, added back to resultat efter finansiella.
// For reconciliation: bank moved 0 because depreciation is non-cash; the
// fixed-asset NET delta is 1219 going up in credit (i.e. asset side down),
// which surfaces as avyttring (debit-side negative) -> +100 000 in investing.
//
// To keep this test focused on the "add back" behavior, we plant zero
// movement on classes 1-3,4-6,8 except 78xx (depreciation expense) and
// the offsetting 1219 (ack avskr). Result before fin: -100 000 (only
// expense). Add back +100 000. Net cash flow from operating: 0.
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({
account_number: '7832',
account_class: 7,
period_debit: 100000,
closing_debit: 100000,
}),
// 1219 = ack avskr inventarier (contra-asset, credit-normal). Increases
// by 100 000 over the period.
makeRow({
account_number: '1219',
account_class: 1,
period_credit: 100000,
closing_credit: 100000,
}),
],
totalDebit: 100000,
totalCredit: 100000,
isBalanced: true,
})
mockIncomeStatement.mockResolvedValue(
makeIs({
total_expenses: 100000,
net_result: -100000,
})
)
const report = await generateKassaflodesanalys(
makeSupabase(PERIOD),
'company-1',
'period-1'
)
expect(report.lopande.resultat_efter_finansiella_poster).toBe(-100000)
expect(report.lopande.avskrivningar).toBe(100000)
// Result + add-back depreciation = 0 löpande
expect(report.lopande.total).toBe(0)
// 1219 sits in 12xx range (investing), credit went up = debit-side delta
// is negative -> avyttring path. This is acceptable behavior; the
// reconciliation invariant is what protects us. Verify it holds:
// total_cash_flow should equal delta_actual on 19xx (which is 0).
expect(report.reconciliation.delta_actual).toBe(0)
// The mock setup ensures investing offsets to make the reconciliation
// balance against 0 cash movement.
expect(report.reconciliation.is_reconciled).toBe(true)
})
it('reconciles when a 50 000 kr deposit hits the bank (1930)', async () => {
// Setup: customer pays an invoice 50 000 net of VAT for simplicity.
// 1930 (bank) debit 50 000; 1510 (kundfordringar) credit 50 000.
// No P&L impact (already booked at invoice creation).
//
// Expected:
// Δ kortfristiga fordringar = -(-50 000) = +50 000 (receivables down → cash in)
// Result efter fin = 0
// Lopande total = +50 000
// delta_actual = 50 000 (closing 19xx = 50 000)
// delta_calculated = 50 000
// is_reconciled = true
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({
account_number: '1930',
account_class: 1,
period_debit: 50000,
closing_debit: 50000,
}),
makeRow({
account_number: '1510',
account_class: 1,
opening_debit: 50000,
period_credit: 50000,
closing_debit: 50000,
closing_credit: 50000,
}),
],
totalDebit: 100000,
totalCredit: 50000,
isBalanced: false,
})
mockIncomeStatement.mockResolvedValue(makeIs())
const report = await generateKassaflodesanalys(
makeSupabase(PERIOD),
'company-1',
'period-1'
)
// 1510 net debit-side delta = (50000-50000) - (50000-0) = -50000
// delta_kortfristiga_fordringar = -(-50000) = +50000
expect(report.lopande.delta_kortfristiga_fordringar).toBe(50000)
expect(report.lopande.total).toBe(50000)
expect(report.reconciliation.opening_cash_1xxx).toBe(0)
expect(report.reconciliation.closing_cash_1xxx).toBe(50000)
expect(report.reconciliation.delta_actual).toBe(50000)
expect(report.reconciliation.delta_calculated).toBe(50000)
expect(report.reconciliation.is_reconciled).toBe(true)
})
it('records asset purchase (200 000 kr) as investing outflow', async () => {
// Setup: buy inventarie for 200 000: debit 1220, credit 1930.
// 1930 (bank): credit 200 000 → closing -200 000
// 1220 (inventarier): debit 200 000 → closing +200 000
//
// Expected:
// forvarv_anlaggningar = -200 000
// delta_actual = -200 000 (bank went down)
// delta_calculated = -200 000
// is_reconciled = true
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({
account_number: '1930',
account_class: 1,
period_credit: 200000,
closing_credit: 200000,
}),
makeRow({
account_number: '1220',
account_class: 1,
period_debit: 200000,
closing_debit: 200000,
}),
],
totalDebit: 200000,
totalCredit: 200000,
isBalanced: true,
})
mockIncomeStatement.mockResolvedValue(makeIs())
const report = await generateKassaflodesanalys(
makeSupabase(PERIOD),
'company-1',
'period-1'
)
expect(report.investerings.forvarv_anlaggningar).toBe(-200000)
expect(report.investerings.avyttring_anlaggningar).toBe(0)
expect(report.investerings.total).toBe(-200000)
expect(report.reconciliation.delta_actual).toBe(-200000)
expect(report.reconciliation.delta_calculated).toBe(-200000)
expect(report.reconciliation.is_reconciled).toBe(true)
})
it('records loan increase (500 000 kr) as financing inflow', async () => {
// Setup: take out a 500 000 long-term loan: debit 1930, credit 2350.
// 1930 (bank): debit 500 000 → closing +500 000
// 2350 (långfristiga lån): credit 500 000 → closing -500 000 on debit side
//
// Expected:
// delta_lan = 500 000 (loans went up, cash in)
// delta_actual = 500 000
// is_reconciled = true
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({
account_number: '1930',
account_class: 1,
period_debit: 500000,
closing_debit: 500000,
}),
makeRow({
account_number: '2350',
account_class: 2,
period_credit: 500000,
closing_credit: 500000,
}),
],
totalDebit: 500000,
totalCredit: 500000,
isBalanced: true,
})
mockIncomeStatement.mockResolvedValue(makeIs())
const report = await generateKassaflodesanalys(
makeSupabase(PERIOD),
'company-1',
'period-1'
)
expect(report.finansierings.delta_lan).toBe(500000)
expect(report.finansierings.total).toBe(500000)
expect(report.reconciliation.delta_actual).toBe(500000)
expect(report.reconciliation.delta_calculated).toBe(500000)
expect(report.reconciliation.is_reconciled).toBe(true)
})
it('records erhållna aktieägartillskott (2093) as financing inflow and reconciles (#716)', async () => {
// Repro from issue #716: debit 1930, credit 2093 (10 000 kr shareholder
// contribution). Before the fix, 2093 was unmapped: financing showed 0
// and the reconciliation failed by exactly the contributed amount.
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({
account_number: '1930',
account_class: 1,
period_debit: 10000,
closing_debit: 10000,
}),
makeRow({
account_number: '2093',
account_class: 2,
period_credit: 10000,
closing_credit: 10000,
}),
],
totalDebit: 10000,
totalCredit: 10000,
isBalanced: true,
})
mockIncomeStatement.mockResolvedValue(makeIs())
const report = await generateKassaflodesanalys(
makeSupabase(PERIOD),
'company-1',
'period-1'
)
expect(report.finansierings.erhallna_aktieagartillskott).toBe(10000)
expect(report.finansierings.nyemission).toBe(0)
expect(report.finansierings.total).toBe(10000)
expect(report.reconciliation.delta_actual).toBe(10000)
expect(report.reconciliation.delta_calculated).toBe(10000)
expect(report.reconciliation.is_reconciled).toBe(true)
})
it('records nyemission premium on överkursfond (2097) as financing inflow and reconciles', async () => {
// A 100 000 kr emission: 25 000 to 2081 (aktiekapital), 75 000 premium to
// 2097 (fri överkursfond). 2097 was previously outside the nyemission
// prefixes: same reconciliation-failure class as #716.
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({
account_number: '1930',
account_class: 1,
period_debit: 100000,
closing_debit: 100000,
}),
makeRow({
account_number: '2081',
account_class: 2,
period_credit: 25000,
closing_credit: 25000,
}),
makeRow({
account_number: '2097',
account_class: 2,
period_credit: 75000,
closing_credit: 75000,
}),
],
totalDebit: 100000,
totalCredit: 100000,
isBalanced: true,
})
mockIncomeStatement.mockResolvedValue(makeIs())
const report = await generateKassaflodesanalys(
makeSupabase(PERIOD),
'company-1',
'period-1'
)
expect(report.finansierings.nyemission).toBe(100000)
expect(report.finansierings.total).toBe(100000)
expect(report.reconciliation.is_reconciled).toBe(true)
})
it('detects mismatch when a cash movement has no balancing classification', async () => {
// Plant an invariant violation: 1930 went up by 10 000 but no offsetting
// entry on any tracked account class. This is the kind of bug a real
// bookkeeping error would surface as.
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({
account_number: '1930',
account_class: 1,
period_debit: 10000,
closing_debit: 10000,
}),
// The "offset" is in account 9999 (out-of-range). The cash flow
// generator doesn't see it. is_reconciled must flag false.
makeRow({
account_number: '9999',
account_class: 9,
period_credit: 10000,
closing_credit: 10000,
}),
],
totalDebit: 10000,
totalCredit: 10000,
isBalanced: true,
})
mockIncomeStatement.mockResolvedValue(makeIs())
const report = await generateKassaflodesanalys(
makeSupabase(PERIOD),
'company-1',
'period-1'
)
expect(report.reconciliation.delta_actual).toBe(10000)
expect(report.reconciliation.delta_calculated).toBe(0)
expect(report.reconciliation.mismatch_amount).toBe(10000)
expect(report.reconciliation.is_reconciled).toBe(false)
})
it('throws when fiscal period is not found', async () => {
mockTrialBalance.mockResolvedValue({
rows: [],
totalDebit: 0,
totalCredit: 0,
isBalanced: true,
})
mockIncomeStatement.mockResolvedValue(makeIs())
await expect(
generateKassaflodesanalys(makeSupabase(null), 'company-1', 'period-1')
).rejects.toThrow('Fiscal period not found')
})
it('uses Math.round for monetary precision (no toFixed)', async () => {
// Plant fractional cents in the inputs; result must be rounded to 2dp,
// never via toFixed which would return a string.
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({
account_number: '1930',
account_class: 1,
period_debit: 33.337,
closing_debit: 33.337,
}),
makeRow({
account_number: '7832',
account_class: 7,
period_debit: 33.337,
closing_debit: 33.337,
}),
],
totalDebit: 66.674,
totalCredit: 0,
isBalanced: false,
})
mockIncomeStatement.mockResolvedValue(
makeIs({ total_expenses: 33.337, net_result: -33.337 })
)
const report = await generateKassaflodesanalys(
makeSupabase(PERIOD),
'company-1',
'period-1'
)
// resultat = revenue - expenses + non-tax-financial = 0 - 33.337 + 0 = -33.34
expect(report.lopande.resultat_efter_finansiella_poster).toBe(-33.34)
expect(report.lopande.avskrivningar).toBe(33.34)
// No bare toFixed return values: these must be numbers, not strings.
expect(typeof report.lopande.total).toBe('number')
})
})