ec27228a8e
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>
472 lines
15 KiB
TypeScript
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')
|
|
})
|
|
})
|