Files
accounted/lib/reports/__tests__/balance-sheet.test.ts
T
Jakob WennbergandClaude Opus 4.6 b387a77bfd chore: remove Sentry, consolidate migrations, add test coverage (#244)
* chore: remove Sentry, consolidate migrations, add test coverage

Remove @sentry/nextjs and all Sentry integration code — error tracking
now handled by Recapt. Consolidate 22 incremental migrations into a
single schema sync migration. Add 6 new test suites (auth, invoice
matching, VAT rules, opening balances) and extend report tests with
edge cases. Update Docker image name to gnubok, sync crontabs and
extension presets, fix CSP missing space, simplify journal entry
missing-document dialog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove viewer bank import migration never applied to production

20260413150000_viewer_bank_import_permissions.sql (PR #234) was merged
to main but never applied to the production database. It references
current_active_company_id() which does not exist in production either.
This breaks fresh installs and Supabase preview branches because the
migration runs before the consolidated schema sync.

Remove it so the migration chain matches production. The viewer bank
import RLS policies should be re-added in a future migration alongside
the helper functions they depend on.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: correct delete policies for tables without company_id column

Seven tables in the generic delete-policy loop don't have a direct
company_id column, causing fresh installs to fail with "column
company_id does not exist". Fix by moving them out of the loop:

- invoice_items, journal_entry_lines, receipt_line_items,
  supplier_invoice_items → join through parent table
- extension_toggles, notification_settings, push_subscriptions →
  user-scoped (auth.uid() = user_id)

All policies match their existing production definitions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:52:00 +02:00

238 lines
9.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
import { generateBalanceSheet } from '../balance-sheet'
import { generateTrialBalance } from '../trial-balance'
import type { TrialBalanceRow } from '@/types'
const mockTrialBalance = vi.mocked(generateTrialBalance)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = {} as any
beforeEach(() => {
vi.clearAllMocks()
})
function makeRow(overrides: Partial<TrialBalanceRow>): TrialBalanceRow {
return {
account_number: '1930',
account_name: 'Test Account',
account_class: 1,
opening_debit: 0,
opening_credit: 0,
period_debit: 0,
period_credit: 0,
closing_debit: 0,
closing_credit: 0,
...overrides,
}
}
describe('generateBalanceSheet', () => {
it('returns empty report when no rows', async () => {
mockTrialBalance.mockResolvedValue({
rows: [],
totalDebit: 0,
totalCredit: 0,
isBalanced: true,
})
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
expect(report.asset_sections).toEqual([])
expect(report.equity_liability_sections).toEqual([])
expect(report.total_assets).toBe(0)
expect(report.total_equity_liabilities).toBe(0)
})
it('calculates assets (class 1) with debit-normal balance', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1510', account_name: 'Kundfordringar', account_class: 1, closing_debit: 15000, closing_credit: 0 }),
makeRow({ account_number: '1930', account_name: 'Företagskonto', account_class: 1, closing_debit: 50000, closing_credit: 0 }),
],
totalDebit: 65000,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
expect(report.asset_sections).toHaveLength(2)
expect(report.asset_sections[0].title).toBe('Kundfordringar')
expect(report.asset_sections[0].rows[0].amount).toBe(15000) // debit - credit
expect(report.asset_sections[1].title).toBe('Kassa och bank')
expect(report.asset_sections[1].rows[0].amount).toBe(50000)
expect(report.total_assets).toBe(65000)
})
it('calculates equity/liabilities (class 2) with credit-normal balance', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '2010', account_name: 'Eget kapital', account_class: 2, closing_credit: 30000, closing_debit: 0 }),
makeRow({ account_number: '2440', account_name: 'Leverantörsskulder', account_class: 2, closing_credit: 10000, closing_debit: 0 }),
],
totalDebit: 0,
totalCredit: 40000,
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
expect(report.equity_liability_sections).toHaveLength(2)
expect(report.equity_liability_sections[0].title).toBe('Eget kapital')
expect(report.equity_liability_sections[0].rows[0].amount).toBe(30000) // credit - debit
expect(report.equity_liability_sections[1].title).toBe('Kortfristiga skulder')
expect(report.total_equity_liabilities).toBe(40000)
})
it('filters rows with |amount| < 0.005', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 50000, closing_credit: 0 }),
// This row has amount = 0.003, should be filtered
makeRow({ account_number: '1940', account_name: 'Tiny', account_class: 1, closing_debit: 0.003, closing_credit: 0 }),
],
totalDebit: 50000.003,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
const bankSection = report.asset_sections.find(s => s.title === 'Kassa och bank')!
expect(bankSection.rows).toHaveLength(1)
expect(bankSection.rows[0].account_number).toBe('1930')
})
it('filters empty sections after zero-filtering', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 50000, closing_credit: 0 }),
// Section 10 (Immateriella) will be empty after filtering
makeRow({ account_number: '1010', account_name: 'Goodwill', account_class: 1, closing_debit: 0.001, closing_credit: 0 }),
],
totalDebit: 50000.001,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
expect(report.asset_sections).toHaveLength(1)
expect(report.asset_sections[0].title).toBe('Kassa och bank')
})
it('groups by two-digit prefix (1510 + 1520 under 15)', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1510', account_name: 'Kundfordringar', account_class: 1, closing_debit: 10000, closing_credit: 0 }),
makeRow({ account_number: '1520', account_name: 'Osäkra kundfordringar', account_class: 1, closing_debit: 2000, closing_credit: 0 }),
],
totalDebit: 12000,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
expect(report.asset_sections).toHaveLength(1)
expect(report.asset_sections[0].title).toBe('Kundfordringar')
expect(report.asset_sections[0].rows).toHaveLength(2)
expect(report.asset_sections[0].subtotal).toBe(12000)
})
it('ignores class 3-8 accounts (income statement)', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 50000, closing_credit: 0 }),
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 40000, closing_debit: 0 }),
makeRow({ account_number: '5010', account_name: 'Rent', account_class: 5, closing_debit: 8000, closing_credit: 0 }),
makeRow({ account_number: '8310', account_name: 'Interest', account_class: 8, closing_credit: 500, closing_debit: 0 }),
],
totalDebit: 58000,
totalCredit: 40500,
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
expect(report.asset_sections).toHaveLength(1) // Only 1930
// Class 3-8 accounts are not included as balance sheet rows, but their
// net result (credit - debit = 40000 + 500 - 8000 = 32500) appears as
// "Årets resultat" in equity so the balance sheet can balance.
expect(report.equity_liability_sections).toHaveLength(1)
expect(report.equity_liability_sections[0].title).toBe('Årets resultat')
expect(report.equity_liability_sections[0].subtotal).toBe(32500)
expect(report.total_assets).toBe(50000)
expect(report.total_equity_liabilities).toBe(32500)
})
it('handles negative asset balance (net credit on class 1 account)', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 50000, closing_credit: 0 }),
// Receivables with net credit (customer overpayment)
makeRow({ account_number: '1510', account_name: 'Kundfordringar', account_class: 1, closing_debit: 0, closing_credit: 5000 }),
],
totalDebit: 50000,
totalCredit: 5000,
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
const receivables = report.asset_sections.find(s => s.rows.some(r => r.account_number === '1510'))
expect(receivables).toBeDefined()
expect(receivables!.rows.find(r => r.account_number === '1510')!.amount).toBe(-5000)
expect(report.total_assets).toBe(45000) // 50000 - 5000
})
it('rounding boundary: 0.004 rounds to 0 and is excluded, 0.005 rounds to 0.01 and is included', async () => {
// Amounts go through Math.round(x * 100) / 100 before the > 0.005 filter.
// Due to IEEE 754, 0.005 * 100 is slightly above 0.5, so Math.round rounds UP to 1,
// giving 0.01 which passes > 0.005. Meanwhile 0.004 rounds to 0.
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 1000, closing_credit: 0 }),
makeRow({ account_number: '1940', account_name: 'Excluded', account_class: 1, closing_debit: 0.004, closing_credit: 0 }),
makeRow({ account_number: '1950', account_name: 'Included', account_class: 1, closing_debit: 0.005, closing_credit: 0 }),
],
totalDebit: 1000.009,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
const bankSection = report.asset_sections.find(s => s.title === 'Kassa och bank')!
// 1930 (1000) and 1950 (0.005 → rounds to 0.01) are included; 1940 (0.004 → rounds to 0) is excluded
expect(bankSection.rows).toHaveLength(2)
expect(bankSection.rows.map(r => r.account_number)).toEqual(['1930', '1950'])
})
it('uses Math.round for monetary precision on subtotals', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 33.33, closing_credit: 0 }),
makeRow({ account_number: '1940', account_name: 'Kassa', account_class: 1, closing_debit: 33.33, closing_credit: 0 }),
makeRow({ account_number: '1950', account_name: 'Annan bank', account_class: 1, closing_debit: 33.34, closing_credit: 0 }),
],
totalDebit: 100,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
// All three are in group '19' (Kassa och bank)
const section = report.asset_sections.find(s => s.title === 'Kassa och bank')!
expect(section.subtotal).toBe(100)
expect(report.total_assets).toBe(100)
})
})