* fix(bokslut): årsredovisning for a klarmarkerad year, bokslutsbilagor under Bokslut in the menu, bilagor reminder on Kontroll A year closed in a previous system (Klarmarkera perioden) has no closing verifikat in these books by definition, so the statutory pre-closing guard in the trial balance has nothing to strip; it now lets that case through instead of failing the whole Årsredovisning page with a 500. Bokslutsbilagor now sits in the Bokslut fold of the menu (a byrå looks for the bilagor inside the bokslut module, not under Rapporter), and the wizard's Kontroll step says how many balance accounts are still unsigned per balansdagen with a link to the pärm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(nav): admit the bokslutsbilagor label key Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * feat(reconciliation): fold the manual accounts in the rail, with count and unsigned hint Twenty-odd balance accounts pushed the bank rows out of view on a migrated company. The group opens when a manual account is selected and otherwise remembers the last choice per browser. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
792 lines
28 KiB
TypeScript
792 lines
28 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
// ============================================================
|
|
// Mock: table-keyed result queues
|
|
// Each table has its own FIFO queue. Calls to the same table
|
|
// consume results in order, regardless of global query ordering.
|
|
// ============================================================
|
|
|
|
type MockResult = { data?: unknown; error?: unknown }
|
|
let mockResults: Record<string, MockResult[]>
|
|
|
|
function makeBuilder(tableName: string) {
|
|
const b: Record<string, unknown> = {}
|
|
for (const m of ['select', 'eq', 'in', 'lt', 'lte', 'gte', 'neq', 'or', 'order', 'range']) {
|
|
b[m] = vi.fn().mockReturnValue(b)
|
|
}
|
|
const consume = (): MockResult => {
|
|
const queue = mockResults[tableName]
|
|
if (!queue || queue.length === 0) {
|
|
// The two-step entry-lines fetch (lib/bookkeeping/entry-lines.ts) reads
|
|
// journal_entries before journal_entry_lines. Tests queue line rows
|
|
// directly, so default the entries step to one generic entry: the mock
|
|
// ignores filters and the reports under test only consume line rows.
|
|
if (tableName === 'journal_entries') {
|
|
return { data: [{ id: 'entry-1' }], error: null }
|
|
}
|
|
return { data: null, error: null }
|
|
}
|
|
return queue.shift()!
|
|
}
|
|
b.single = vi.fn().mockImplementation(async () => consume())
|
|
b.then = (resolve: (v: unknown) => void) => resolve(consume())
|
|
return b
|
|
}
|
|
|
|
function makeClient() {
|
|
const rpc = vi.fn().mockImplementation(async (fn: string) => {
|
|
const queue = mockResults[`rpc:${fn}`]
|
|
if (!queue || queue.length === 0) return { data: [], error: null }
|
|
return queue.shift()!
|
|
})
|
|
return {
|
|
from: vi.fn().mockImplementation((table: string) => makeBuilder(table)),
|
|
rpc,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any
|
|
}
|
|
|
|
import { generateTrialBalance } from '../trial-balance'
|
|
|
|
let supabase: ReturnType<typeof makeClient>
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockResults = {}
|
|
supabase = makeClient()
|
|
})
|
|
|
|
describe('generateTrialBalance', () => {
|
|
it('returns empty report when no lines exist', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: null, error: null },
|
|
],
|
|
// getOpeningBalances gets null period → returns empty
|
|
// period lines query → empty
|
|
journal_entry_lines: [
|
|
{ data: [], error: null },
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
|
|
|
expect(result.rows).toEqual([])
|
|
expect(result.totalDebit).toBe(0)
|
|
expect(result.totalCredit).toBe(0)
|
|
expect(result.isBalanced).toBe(true)
|
|
})
|
|
|
|
it('aggregates lines by account and sorts by account_number', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
|
],
|
|
journal_entry_lines: [
|
|
// period lines (prior lines now come from RPC: defaults to empty)
|
|
{
|
|
data: [
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
|
|
{ closingEntry: 'include', account_number: '1930', debit_amount: 300, credit_amount: 0 },
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 200 },
|
|
{ account_number: '1930', debit_amount: 450, credit_amount: 0 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
|
|
{ account_number: '3001', account_name: 'Försäljning', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
|
|
|
expect(result.rows).toHaveLength(2)
|
|
// Sorted by account number
|
|
expect(result.rows[0].account_number).toBe('1930')
|
|
expect(result.rows[1].account_number).toBe('3001')
|
|
|
|
// Aggregated correctly: opening is 0 (first year)
|
|
expect(result.rows[0].opening_debit).toBe(0)
|
|
expect(result.rows[0].opening_credit).toBe(0)
|
|
expect(result.rows[0].period_debit).toBe(750)
|
|
expect(result.rows[0].closing_debit).toBe(750)
|
|
expect(result.rows[0].closing_credit).toBe(0)
|
|
expect(result.rows[1].closing_debit).toBe(0)
|
|
expect(result.rows[1].closing_credit).toBe(700)
|
|
})
|
|
|
|
it('computes opening balances from prior period entries', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2025-01-01', opening_balance_entry_id: null }, error: null },
|
|
],
|
|
'rpc:compute_prior_opening_balances': [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit: 10000, credit: 0 },
|
|
{ closingEntry: 'include', account_number: '2099', debit: 0, credit: 10000 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
journal_entry_lines: [
|
|
// period lines
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 0, credit_amount: 500 },
|
|
{ account_number: '5410', debit_amount: 500, credit_amount: 0 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
|
|
{ account_number: '2099', account_name: 'Årets resultat', account_class: 2 },
|
|
{ account_number: '5410', account_name: 'Förbrukningsinventarier', account_class: 5 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-2', { closingEntry: 'include' })
|
|
|
|
// 1930: opening debit 10000, period credit 500 → closing debit 10000, credit 500
|
|
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
|
|
expect(acc1930.opening_debit).toBe(10000)
|
|
expect(acc1930.opening_credit).toBe(0)
|
|
expect(acc1930.period_debit).toBe(0)
|
|
expect(acc1930.period_credit).toBe(500)
|
|
expect(acc1930.closing_debit).toBe(10000)
|
|
expect(acc1930.closing_credit).toBe(500)
|
|
|
|
// 2099: opening credit 10000, no period activity → closing credit 10000
|
|
const acc2099 = result.rows.find((r) => r.account_number === '2099')!
|
|
expect(acc2099.opening_debit).toBe(0)
|
|
expect(acc2099.opening_credit).toBe(10000)
|
|
expect(acc2099.period_debit).toBe(0)
|
|
expect(acc2099.closing_debit).toBe(0)
|
|
expect(acc2099.closing_credit).toBe(10000)
|
|
|
|
// 5410: no opening, period debit 500
|
|
const acc5410 = result.rows.find((r) => r.account_number === '5410')!
|
|
expect(acc5410.opening_debit).toBe(0)
|
|
expect(acc5410.period_debit).toBe(500)
|
|
expect(acc5410.closing_debit).toBe(500)
|
|
|
|
expect(result.isBalanced).toBe(true)
|
|
})
|
|
|
|
it('uses opening_balance_entry when available', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2025-01-01', opening_balance_entry_id: 'ob-entry-1' }, error: null },
|
|
],
|
|
journal_entry_lines: [
|
|
// OB entry lines (from getOpeningBalances)
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 8000, credit_amount: 0 },
|
|
{ closingEntry: 'include', account_number: '2099', debit_amount: 0, credit_amount: 8000 },
|
|
],
|
|
error: null,
|
|
},
|
|
// period lines (OB entry excluded via .neq)
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
|
|
{ account_number: '2099', account_name: 'Årets resultat', account_class: 2 },
|
|
{ account_number: '3001', account_name: 'Försäljning 25%', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-2', { closingEntry: 'include' })
|
|
|
|
// 1930: opening 8000 debit + period 1000 debit = closing 9000 debit
|
|
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
|
|
expect(acc1930.opening_debit).toBe(8000)
|
|
expect(acc1930.closing_debit).toBe(9000)
|
|
expect(acc1930.closing_credit).toBe(0)
|
|
|
|
// 2099: opening 8000 credit, no period activity
|
|
const acc2099 = result.rows.find((r) => r.account_number === '2099')!
|
|
expect(acc2099.opening_credit).toBe(8000)
|
|
expect(acc2099.closing_credit).toBe(8000)
|
|
|
|
// 3001: no opening, period 1000 credit
|
|
const acc3001 = result.rows.find((r) => r.account_number === '3001')!
|
|
expect(acc3001.opening_debit).toBe(0)
|
|
expect(acc3001.closing_credit).toBe(1000)
|
|
})
|
|
|
|
it('falls back to "Konto {number}" when account not in chart_of_accounts', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
|
],
|
|
journal_entry_lines: [
|
|
{
|
|
data: [
|
|
{ account_number: '9999', debit_amount: 100, credit_amount: 0 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{ data: [], error: null },
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
|
|
|
expect(result.rows[0].account_name).toBe('Konto 9999')
|
|
})
|
|
|
|
it('derives account_class from first digit when account not in chart', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
|
],
|
|
journal_entry_lines: [
|
|
{
|
|
data: [
|
|
{ account_number: '5410', debit_amount: 200, credit_amount: 0 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{ data: [], error: null },
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
|
|
|
expect(result.rows[0].account_class).toBe(5)
|
|
})
|
|
|
|
it('uses Math.round for monetary precision', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
|
],
|
|
journal_entry_lines: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
|
|
{ closingEntry: 'include', account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
|
|
{ account_number: '1930', debit_amount: 33.34, credit_amount: 0 },
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 100 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
|
|
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
|
|
|
expect(result.rows[0].closing_debit).toBe(100)
|
|
expect(result.totalDebit).toBe(100)
|
|
expect(result.totalCredit).toBe(100)
|
|
expect(result.isBalanced).toBe(true)
|
|
})
|
|
|
|
it('detects unbalanced entries (isBalanced=false)', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
|
],
|
|
journal_entry_lines: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
|
{ closingEntry: 'include', account_number: '3001', debit_amount: 0, credit_amount: 999 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
|
|
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
|
|
|
expect(result.totalDebit).toBe(1000)
|
|
expect(result.totalCredit).toBe(999)
|
|
expect(result.isBalanced).toBe(false)
|
|
})
|
|
|
|
it('throws when lines query errors', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
|
],
|
|
journal_entry_lines: [
|
|
{ data: null, error: { message: 'DB error' } },
|
|
],
|
|
}
|
|
|
|
await expect(generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })).rejects.toThrow('DB error')
|
|
})
|
|
|
|
it('handles balanced two-account entry', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
|
],
|
|
journal_entry_lines: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 5000, credit_amount: 0 },
|
|
{ closingEntry: 'include', account_number: '3001', debit_amount: 0, credit_amount: 5000 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
|
|
{ account_number: '3001', account_name: 'Försäljning 25%', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
|
|
|
expect(result.rows).toHaveLength(2)
|
|
expect(result.totalDebit).toBe(5000)
|
|
expect(result.totalCredit).toBe(5000)
|
|
expect(result.isBalanced).toBe(true)
|
|
})
|
|
|
|
// ── Date-range tests ─────────────────────────────────────────────
|
|
// The 4 reports (resultatrapport/balansrapport/income-statement/balance-
|
|
// sheet) thread an optional { fromDate, toDate } through to the trial
|
|
// balance. The engine must (a) skip the roll-forward query when fromDate
|
|
// equals period_start, (b) roll prior in-period lines into IB when
|
|
// fromDate is later, and (c) clamp period activity to the window.
|
|
|
|
it('treats omitted range as parity with the full period', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{
|
|
data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null },
|
|
error: null,
|
|
},
|
|
],
|
|
journal_entry_lines: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
|
{ closingEntry: 'include', account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
|
|
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'include' })
|
|
|
|
// Same as the existing "balanced two-account" case: no roll-forward query
|
|
// is consumed because no range is requested.
|
|
expect(result.rows).toHaveLength(2)
|
|
expect(result.totalDebit).toBe(1000)
|
|
expect(result.totalCredit).toBe(1000)
|
|
expect(result.isBalanced).toBe(true)
|
|
})
|
|
|
|
it('skips the roll-forward query when fromDate equals period_start', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{
|
|
data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null },
|
|
error: null,
|
|
},
|
|
],
|
|
journal_entry_lines: [
|
|
// Only the period query: no roll-forward fetch should be triggered.
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 500, credit_amount: 0 },
|
|
{ closingEntry: 'include', account_number: '3001', debit_amount: 0, credit_amount: 500 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
|
|
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
|
closingEntry: 'include',
|
|
fromDate: '2024-01-01',
|
|
toDate: '2024-06-30',
|
|
})
|
|
|
|
expect(result.rows[0].opening_debit).toBe(0)
|
|
expect(result.rows[0].closing_debit).toBe(500)
|
|
})
|
|
|
|
it('rolls prior in-period lines into IB when fromDate is after period_start', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{
|
|
data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null },
|
|
error: null,
|
|
},
|
|
],
|
|
journal_entry_lines: [
|
|
// 1st consumption: roll-forward query for [2024-01-01, 2024-04-01).
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 2000, credit_amount: 0 },
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 2000 },
|
|
],
|
|
error: null,
|
|
},
|
|
// 2nd consumption: period activity for [2024-04-01, 2024-06-30].
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 500, credit_amount: 0 },
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
|
|
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
|
closingEntry: 'include',
|
|
fromDate: '2024-04-01',
|
|
toDate: '2024-06-30',
|
|
})
|
|
|
|
// 1930: IB carries 2000 from Q1, period adds 500 → UB 2500
|
|
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
|
|
expect(acc1930.opening_debit).toBe(2000)
|
|
expect(acc1930.period_debit).toBe(500)
|
|
expect(acc1930.closing_debit).toBe(2500)
|
|
|
|
// 3001: IB carries 2000 from Q1, period adds 500 → UB 2500
|
|
const acc3001 = result.rows.find((r) => r.account_number === '3001')!
|
|
expect(acc3001.opening_credit).toBe(2000)
|
|
expect(acc3001.period_credit).toBe(500)
|
|
expect(acc3001.closing_credit).toBe(2500)
|
|
|
|
expect(result.isBalanced).toBe(true)
|
|
})
|
|
|
|
// ── final-closing precision ──────────────────────────────────────
|
|
// Tax and appropriations are also source_type='year_end'. The statutory
|
|
// pre-closing report must exclude only fiscal_periods.closing_entry_id.
|
|
|
|
it('excludes only the fiscal period closing entry', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{
|
|
data: {
|
|
period_start: '2025-01-01',
|
|
period_end: '2025-12-31',
|
|
opening_balance_entry_id: null,
|
|
closing_entry_id: 'closing-1',
|
|
},
|
|
error: null,
|
|
},
|
|
],
|
|
journal_entries: [{ data: [{ id: 'tax-1' }, { id: 'appropriation-1' }], error: null }],
|
|
journal_entry_lines: [
|
|
{
|
|
data: [
|
|
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
|
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
chart_of_accounts: [{ data: [], error: null }],
|
|
}
|
|
|
|
await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
|
closingEntry: 'exclude-final',
|
|
})
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const neqCalls = builders.flatMap((b: any) => (b.neq ? b.neq.mock.calls : []))
|
|
expect(neqCalls).not.toContainEqual(['source_type', 'year_end'])
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const orCalls = builders.flatMap((b: any) => (b.or ? b.or.mock.calls : []))
|
|
expect(orCalls).toContainEqual(['id.neq.closing-1,status.neq.posted'])
|
|
})
|
|
|
|
it('fails closed when a closed period has no linked final closing entry', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{
|
|
data: {
|
|
period_start: '2025-01-01',
|
|
period_end: '2025-12-31',
|
|
opening_balance_entry_id: null,
|
|
closing_entry_id: null,
|
|
is_closed: true,
|
|
},
|
|
error: null,
|
|
},
|
|
],
|
|
journal_entries: [{ data: [{ id: 'tax-1' }, { closingEntry: 'include', id: 'appropriation-1' }], error: null }],
|
|
journal_entry_lines: [{ data: [], error: null }],
|
|
chart_of_accounts: [{ data: [], error: null }],
|
|
}
|
|
|
|
await expect(
|
|
generateTrialBalance(supabase, 'company-1', 'period-1', {
|
|
closingEntry: 'exclude-final',
|
|
}),
|
|
).rejects.toThrow(/missing closing_entry_id/i)
|
|
|
|
// The guard throws before any journal data is read. The chart of
|
|
// accounts is part of the first parallel wave (alongside the period
|
|
// fetch), so it may have been queried; the entry/line tables must not be.
|
|
const tables = supabase.from.mock.calls.map((c: unknown[]) => c[0])
|
|
expect(tables).not.toContain('journal_entries')
|
|
expect(tables).not.toContain('journal_entry_lines')
|
|
})
|
|
|
|
it('does not fail closed for a period klarmarkerad in a previous system (closed_externally)', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{
|
|
data: {
|
|
period_start: '2024-01-01',
|
|
period_end: '2024-12-31',
|
|
opening_balance_entry_id: null,
|
|
closing_entry_id: null,
|
|
is_closed: true,
|
|
closed_externally: true,
|
|
},
|
|
error: null,
|
|
},
|
|
],
|
|
journal_entries: [{ data: [], error: null }],
|
|
journal_entry_lines: [{ data: [], error: null }],
|
|
chart_of_accounts: [{ data: [], error: null }],
|
|
}
|
|
|
|
// The closing verifikat lives in the old software: nothing to strip, the
|
|
// booked balances are the statutory pre-closing balances.
|
|
await expect(
|
|
generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'exclude-final' }),
|
|
).resolves.toMatchObject({ isBalanced: true })
|
|
})
|
|
|
|
it('keeps year-end adjustments for an open period without a final closing entry', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{
|
|
data: {
|
|
period_start: '2025-01-01',
|
|
period_end: '2025-12-31',
|
|
opening_balance_entry_id: null,
|
|
closing_entry_id: null,
|
|
is_closed: false,
|
|
},
|
|
error: null,
|
|
},
|
|
],
|
|
journal_entries: [{ data: [{ id: 'tax-1' }], error: null }],
|
|
journal_entry_lines: [{ data: [], error: null }],
|
|
chart_of_accounts: [{ data: [], error: null }],
|
|
}
|
|
|
|
await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
|
closingEntry: 'exclude-final',
|
|
})
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const neqCalls = builders.flatMap((b: any) => (b.neq ? b.neq.mock.calls : []))
|
|
expect(neqCalls).not.toContainEqual(['source_type', 'year_end'])
|
|
})
|
|
|
|
it('keeps a linked reversed closing together with its storno', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{
|
|
data: {
|
|
period_start: '2025-01-01',
|
|
period_end: '2025-12-31',
|
|
opening_balance_entry_id: null,
|
|
closing_entry_id: 'closing-1',
|
|
},
|
|
error: null,
|
|
},
|
|
],
|
|
journal_entries: [{ data: [{ id: 'closing-1' }, { closingEntry: 'include', id: 'storno-1' }], error: null }],
|
|
journal_entry_lines: [{ data: [], error: null }],
|
|
chart_of_accounts: [{ data: [], error: null }],
|
|
}
|
|
|
|
await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
|
closingEntry: 'exclude-final',
|
|
})
|
|
|
|
// The OR excludes closing-1 only while status is posted. If it is
|
|
// reversed during an administrative undo, both it and its storno remain.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const orCalls = builders.flatMap((b: any) => (b.or ? b.or.mock.calls : []))
|
|
expect(orCalls).toContainEqual(['id.neq.closing-1,status.neq.posted'])
|
|
})
|
|
|
|
it('preserves the broad year-end exclusion for operational reports', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{
|
|
data: {
|
|
period_start: '2025-01-01',
|
|
period_end: '2025-12-31',
|
|
opening_balance_entry_id: null,
|
|
closing_entry_id: 'closing-1',
|
|
},
|
|
error: null,
|
|
},
|
|
],
|
|
journal_entries: [
|
|
{ data: [{ id: 'reversed-year-end-1' }], error: null },
|
|
{ data: [{ id: 'ordinary-1' }], error: null },
|
|
],
|
|
journal_entry_lines: [{ data: [], error: null }],
|
|
chart_of_accounts: [{ data: [], error: null }],
|
|
}
|
|
|
|
await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
|
closingEntry: 'exclude-all-year-end',
|
|
})
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const builders = supabase.from.mock.results.map((r: { value: any }) => r.value)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const neqCalls = builders.flatMap((b: any) => (b.neq ? b.neq.mock.calls : []))
|
|
expect(neqCalls).toContainEqual(['source_type', 'year_end'])
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const orCalls = builders.flatMap((b: any) => (b.or ? b.or.mock.calls : []))
|
|
expect(orCalls).toContainEqual([
|
|
'reverses_id.is.null,reverses_id.not.in.(reversed-year-end-1)',
|
|
])
|
|
expect(orCalls).toContainEqual([
|
|
'correction_of_id.is.null,correction_of_id.not.in.(reversed-year-end-1)',
|
|
])
|
|
})
|
|
|
|
it('returns empty period activity when the range matches no lines', async () => {
|
|
mockResults = {
|
|
fiscal_periods: [
|
|
{
|
|
data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null },
|
|
error: null,
|
|
},
|
|
],
|
|
journal_entry_lines: [
|
|
// Roll-forward query: has prior activity
|
|
{
|
|
data: [
|
|
{ account_number: '1930', debit_amount: 750, credit_amount: 0 },
|
|
{ closingEntry: 'include', account_number: '3001', debit_amount: 0, credit_amount: 750 },
|
|
],
|
|
error: null,
|
|
},
|
|
// Period query: no lines inside [2024-11-01, 2024-11-30]
|
|
{ data: [], error: null },
|
|
],
|
|
chart_of_accounts: [
|
|
{
|
|
data: [
|
|
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
|
|
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
|
|
],
|
|
error: null,
|
|
},
|
|
],
|
|
}
|
|
|
|
const result = await generateTrialBalance(supabase, 'company-1', 'period-1', {
|
|
closingEntry: 'include',
|
|
fromDate: '2024-11-01',
|
|
toDate: '2024-11-30',
|
|
})
|
|
|
|
const acc1930 = result.rows.find((r) => r.account_number === '1930')!
|
|
expect(acc1930.opening_debit).toBe(750)
|
|
expect(acc1930.period_debit).toBe(0)
|
|
expect(acc1930.closing_debit).toBe(750)
|
|
})
|
|
})
|