Fix/balance inconsitency (#306)
* feat: implement fiscal period date fields component and validation logic * feat: update fiscal period validation and naming logic * feat: implement RPC for computing prior opening balances - Added `compute_prior_opening_balances` RPC to aggregate opening balances for balance-sheet accounts when no opening balance entry is set. - Updated tests across various reports to utilize the new RPC for fetching prior balances. - Refactored `getOpeningBalances` to call the RPC when necessary, improving performance and reliability. - Introduced a script to repair fiscal period chains for companies with broken periods, ensuring proper linking and continuity. - Enhanced error handling and validation in the repair script to ensure data integrity during the process. * feat: implement duplicate opening-balance repair for multi-year SIE imports * feat: enhance SIE entry listing and deduplication logic for opening balances * fix: refine companyHasPriorActivity logic to exclude storno entries and improve balance counting
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
importVouchers,
|
||||
computeVoucherNumberRanges,
|
||||
linkOpeningBalanceEntryToPeriod,
|
||||
companyHasPriorActivity,
|
||||
} from '../sie-import'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { ParsedSIEFile, AccountMapping } from '../types'
|
||||
@@ -500,6 +501,104 @@ describe('linkOpeningBalanceEntryToPeriod', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('companyHasPriorActivity', () => {
|
||||
// Guards multi-year SIE imports: when the company already has posted
|
||||
// non-IB journal entries, creating another IB entry would double-count
|
||||
// one year's movements against every balance-sheet account.
|
||||
type Supabase = Parameters<typeof companyHasPriorActivity>[0]
|
||||
|
||||
function buildCountingSupabase(count: number) {
|
||||
const capturedFilters: Record<string, unknown> = {}
|
||||
|
||||
const supabase = {
|
||||
from: (table: string) => {
|
||||
if (table !== 'journal_entries') {
|
||||
throw new Error(`Unexpected table: ${table}`)
|
||||
}
|
||||
const chain = {
|
||||
select: (_cols: string, opts?: { count?: string; head?: boolean }) => {
|
||||
capturedFilters['_opts'] = opts
|
||||
return chain
|
||||
},
|
||||
eq: (col: string, val: unknown) => {
|
||||
capturedFilters[`eq:${col}`] = val
|
||||
return chain
|
||||
},
|
||||
neq: (col: string, val: unknown) => {
|
||||
const key = `neq:${col}`
|
||||
const existing = capturedFilters[key]
|
||||
if (Array.isArray(existing)) {
|
||||
existing.push(val)
|
||||
} else if (existing !== undefined) {
|
||||
capturedFilters[key] = [existing, val]
|
||||
} else {
|
||||
capturedFilters[key] = val
|
||||
}
|
||||
return chain
|
||||
},
|
||||
in: (col: string, val: unknown) => {
|
||||
capturedFilters[`in:${col}`] = val
|
||||
return chain
|
||||
},
|
||||
then: (resolve: (v: { count: number; error: null }) => void) =>
|
||||
resolve({ count, error: null }),
|
||||
}
|
||||
return chain
|
||||
},
|
||||
}
|
||||
return { supabase, capturedFilters }
|
||||
}
|
||||
|
||||
it('returns false when the company has no prior posted entries', async () => {
|
||||
const { supabase } = buildCountingSupabase(0)
|
||||
|
||||
const result = await companyHasPriorActivity(supabase as unknown as Supabase, 'company-1')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when the company has prior posted non-IB entries', async () => {
|
||||
const { supabase } = buildCountingSupabase(42)
|
||||
|
||||
const result = await companyHasPriorActivity(supabase as unknown as Supabase, 'company-1')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes opening_balance and storno entries, and only counts posted', async () => {
|
||||
const { supabase, capturedFilters } = buildCountingSupabase(0)
|
||||
|
||||
await companyHasPriorActivity(supabase as unknown as Supabase, 'company-1')
|
||||
|
||||
expect(capturedFilters['neq:source_type']).toEqual(['opening_balance', 'storno'])
|
||||
expect(capturedFilters['eq:status']).toBe('posted')
|
||||
expect(capturedFilters['eq:company_id']).toBe('company-1')
|
||||
})
|
||||
|
||||
it('treats null/undefined count as zero', async () => {
|
||||
const supabase = {
|
||||
from: () => ({
|
||||
select: () => ({
|
||||
eq: () => ({
|
||||
neq: () => ({
|
||||
neq: () => ({
|
||||
eq: () => ({
|
||||
then: (resolve: (v: { count: null; error: null }) => void) =>
|
||||
resolve({ count: null, error: null }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
|
||||
const result = await companyHasPriorActivity(supabase as unknown as Supabase, 'company-1')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isBalanceSheetAccount', () => {
|
||||
it('returns true for class 1 (assets)', () => {
|
||||
expect(isBalanceSheetAccount('1510')).toBe(true)
|
||||
|
||||
@@ -459,6 +459,36 @@ async function createOpeningBalanceEntry(
|
||||
return entry.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the company already has at least one posted (or reversed)
|
||||
* non-IB journal entry — i.e. this is a continuation import, not the first
|
||||
* ever SIE upload for the company.
|
||||
*
|
||||
* Used to gate IB-entry creation: when a company is already live, each year's
|
||||
* #IB equals the prior year's UB, which is the sum of already-imported
|
||||
* journal lines. Creating a new IB entry would double-count one year's
|
||||
* movements against every balance-sheet account.
|
||||
*/
|
||||
export async function companyHasPriorActivity(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string
|
||||
): Promise<boolean> {
|
||||
// Only count currently-effective real activity. Excluding 'reversed' drops
|
||||
// cancelled originals; excluding source_type 'storno' drops their matching
|
||||
// reversal entries so a fully-cancelled pair contributes nothing. Without
|
||||
// this, repair scripts that storno duplicate IB entries would leave storno
|
||||
// artifacts that trip the guard on a freshly-repaired company.
|
||||
const { count } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.neq('source_type', 'opening_balance')
|
||||
.neq('source_type', 'storno')
|
||||
.eq('status', 'posted')
|
||||
|
||||
return (count ?? 0) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Link an opening-balance journal entry to its fiscal period so balance-sheet
|
||||
* reports use the explicit IB path in getOpeningBalances() (reads only that
|
||||
@@ -1621,6 +1651,24 @@ export async function executeSIEImport(
|
||||
if (period?.opening_balances_set || period?.opening_balance_entry_id) {
|
||||
result.warnings.push('Ingående balanser finns redan för denna period — hoppar över IB-import')
|
||||
} else {
|
||||
// Continuation-import guard: if the company already has any posted
|
||||
// non-IB journal entries from a prior import or manual bookkeeping,
|
||||
// do NOT create a new IB entry. Each year's #IB equals the prior
|
||||
// year's UB, which is already the sum of the prior year's posted
|
||||
// transactions — so importing another IB entry double-counts one
|
||||
// year of activity against every balance-sheet account. The
|
||||
// first-ever import creates the legitimate pre-system IB; subsequent
|
||||
// imports must rely on the prior entries to derive opening balances
|
||||
// on the fly (via getOpeningBalances() fallback).
|
||||
const isContinuationImport = await companyHasPriorActivity(supabase, companyId)
|
||||
|
||||
if (isContinuationImport) {
|
||||
result.warnings.push(
|
||||
'Ingående balanser hoppades över eftersom bolaget redan har bokförda verifikationer. ' +
|
||||
'Ingående balans för denna period härleds från föregående periods utgående balans. ' +
|
||||
'Stäm av mot SIE-filens #IB om du är osäker.'
|
||||
)
|
||||
} else {
|
||||
const ibValidation = validateIBBalance(parsed, accountMap)
|
||||
|
||||
if (ibValidation.lines.length > 0) {
|
||||
@@ -1676,6 +1724,7 @@ export async function executeSIEImport(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,14 @@ function makeBuilder(tableName: string) {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -66,9 +72,7 @@ describe('validateBalanceContinuity', () => {
|
||||
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null } },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
// Previous period OB (fallback — no OB entry)
|
||||
{ data: [] },
|
||||
// Previous period lines (trial balance)
|
||||
// Previous period lines (trial balance — prior OB comes from RPC, defaults empty)
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
|
||||
@@ -113,7 +117,6 @@ describe('validateBalanceContinuity', () => {
|
||||
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null } },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{ data: [] },
|
||||
// Previous UB: 1930 = 50000 debit
|
||||
{
|
||||
data: [
|
||||
@@ -157,7 +160,6 @@ describe('validateBalanceContinuity', () => {
|
||||
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null } },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{ data: [] },
|
||||
// Previous UB has 1510 and 2440
|
||||
{
|
||||
data: [
|
||||
@@ -199,7 +201,6 @@ describe('validateBalanceContinuity', () => {
|
||||
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null } },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{ data: [] },
|
||||
// Previous UB: only 1930
|
||||
{
|
||||
data: [
|
||||
@@ -240,7 +241,6 @@ describe('validateBalanceContinuity', () => {
|
||||
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null } },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{ data: [] },
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 50000.005, credit_amount: 0 },
|
||||
|
||||
@@ -23,8 +23,14 @@ function makeBuilder(tableName: string) {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -56,9 +62,7 @@ describe('generateGeneralLedger', () => {
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
// prior lines (from getOpeningBalances fallback) — empty
|
||||
{ data: [], error: null },
|
||||
// period lines — empty
|
||||
// period lines — empty (prior lines come from RPC, defaults to empty)
|
||||
{ data: [], error: null },
|
||||
],
|
||||
}
|
||||
@@ -74,8 +78,6 @@ describe('generateGeneralLedger', () => {
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
// prior lines — empty (first year)
|
||||
{ data: [], error: null },
|
||||
// period lines (joined with entry data)
|
||||
{
|
||||
data: [
|
||||
@@ -127,14 +129,13 @@ describe('generateGeneralLedger', () => {
|
||||
fiscal_periods: [
|
||||
{ data: { period_start: '2025-01-01', period_end: '2025-12-31', opening_balance_entry_id: null }, error: null },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
// prior lines (from getOpeningBalances fallback)
|
||||
'rpc:compute_prior_opening_balances': [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 10000, credit_amount: 0 },
|
||||
],
|
||||
data: [{ account_number: '1930', debit: 10000, credit: 0 }],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
journal_entry_lines: [
|
||||
// period lines
|
||||
{
|
||||
data: [
|
||||
@@ -169,8 +170,6 @@ describe('generateGeneralLedger', () => {
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
// prior lines — empty
|
||||
{ data: [], error: null },
|
||||
// period lines across multiple accounts
|
||||
{
|
||||
data: [
|
||||
@@ -198,8 +197,6 @@ describe('generateGeneralLedger', () => {
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
// prior lines — empty
|
||||
{ data: [], error: null },
|
||||
// period lines — out of order
|
||||
{
|
||||
data: [
|
||||
@@ -230,7 +227,6 @@ describe('generateGeneralLedger', () => {
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31', opening_balance_entry_id: null }, error: null },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{ data: [], error: null },
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0, journal_entries: { entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Precision', source_type: 'manual' } },
|
||||
|
||||
@@ -9,8 +9,13 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
|
||||
const mockFetchAllRows = vi.mocked(fetchAllRows)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supabase = {} as any
|
||||
function createSupabaseWithRpc(
|
||||
rpcImpl: (fn: string, args: Record<string, unknown>) => Promise<{ data: unknown; error: unknown }>
|
||||
) {
|
||||
const rpc = vi.fn(rpcImpl)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { rpc } as any
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -18,6 +23,8 @@ beforeEach(() => {
|
||||
|
||||
describe('getOpeningBalances', () => {
|
||||
it('returns empty map and null obEntryId when period is null', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supabase = {} as any
|
||||
const { balances, obEntryId } = await getOpeningBalances(supabase, 'company-1', null)
|
||||
|
||||
expect(balances.size).toBe(0)
|
||||
@@ -31,6 +38,8 @@ describe('getOpeningBalances', () => {
|
||||
}
|
||||
|
||||
it('returns balances from the OB entry lines', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supabase = {} as any
|
||||
mockFetchAllRows.mockResolvedValue([
|
||||
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
|
||||
{ account_number: '2440', debit_amount: 0, credit_amount: 10000 },
|
||||
@@ -44,6 +53,8 @@ describe('getOpeningBalances', () => {
|
||||
})
|
||||
|
||||
it('aggregates multiple lines for the same account', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supabase = {} as any
|
||||
mockFetchAllRows.mockResolvedValue([
|
||||
{ account_number: '1930', debit_amount: 30000, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 20000, credit_amount: 0 },
|
||||
@@ -55,6 +66,8 @@ describe('getOpeningBalances', () => {
|
||||
})
|
||||
|
||||
it('returns the obEntryId string', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supabase = {} as any
|
||||
mockFetchAllRows.mockResolvedValue([])
|
||||
|
||||
const { obEntryId } = await getOpeningBalances(supabase, 'company-1', period)
|
||||
@@ -63,77 +76,95 @@ describe('getOpeningBalances', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('without opening_balance_entry_id (fallback path)', () => {
|
||||
describe('without opening_balance_entry_id (fallback path via RPC)', () => {
|
||||
const period = {
|
||||
period_start: '2025-01-01',
|
||||
opening_balance_entry_id: null,
|
||||
}
|
||||
|
||||
it('carries forward balance sheet accounts (class 1-2) only', async () => {
|
||||
mockFetchAllRows.mockResolvedValue([
|
||||
{ account_number: '1930', debit_amount: 100000, credit_amount: 5000 },
|
||||
{ account_number: '2440', debit_amount: 0, credit_amount: 25000 },
|
||||
// P&L accounts (class 3-8) must NOT carry forward — they reset
|
||||
// to zero each fiscal year via årets resultat.
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 80000 },
|
||||
{ account_number: '5410', debit_amount: 12000, credit_amount: 0 },
|
||||
{ account_number: '8310', debit_amount: 0, credit_amount: 1500 },
|
||||
])
|
||||
it('calls compute_prior_opening_balances RPC with the right args', async () => {
|
||||
const supabase = createSupabaseWithRpc(async () => ({ data: [], error: null }))
|
||||
|
||||
await getOpeningBalances(supabase, 'company-1', period)
|
||||
|
||||
expect(supabase.rpc).toHaveBeenCalledTimes(1)
|
||||
expect(supabase.rpc).toHaveBeenCalledWith('compute_prior_opening_balances', {
|
||||
p_company_id: 'company-1',
|
||||
p_period_start: '2025-01-01',
|
||||
})
|
||||
})
|
||||
|
||||
it('does NOT call the RPC when an OB entry is present', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supabase = { rpc: vi.fn() } as any
|
||||
mockFetchAllRows.mockResolvedValue([])
|
||||
|
||||
await getOpeningBalances(supabase, 'company-1', {
|
||||
period_start: '2025-01-01',
|
||||
opening_balance_entry_id: 'ob-entry-999',
|
||||
})
|
||||
|
||||
expect(supabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps RPC rows to the balances map', async () => {
|
||||
const supabase = createSupabaseWithRpc(async () => ({
|
||||
data: [
|
||||
{ account_number: '1930', debit: 100000, credit: 5000 },
|
||||
{ account_number: '2440', debit: 0, credit: 25000 },
|
||||
{ account_number: '1510', debit: 8000, credit: 1000 },
|
||||
],
|
||||
error: null,
|
||||
}))
|
||||
|
||||
const { balances, obEntryId } = await getOpeningBalances(supabase, 'company-1', period)
|
||||
|
||||
expect(balances.get('1930')).toEqual({ debit: 100000, credit: 5000 })
|
||||
expect(balances.get('2440')).toEqual({ debit: 0, credit: 25000 })
|
||||
expect(balances.has('3001')).toBe(false)
|
||||
expect(balances.has('5410')).toBe(false)
|
||||
expect(balances.has('8310')).toBe(false)
|
||||
expect(obEntryId).toBeNull()
|
||||
})
|
||||
|
||||
it('does not accumulate P&L across multi-year SIE imports', async () => {
|
||||
// Simulates importing SIE files for 2022 and 2023, then opening 2024:
|
||||
// BS movements over both years should net to a single IB; P&L from
|
||||
// both years must be discarded.
|
||||
mockFetchAllRows.mockResolvedValue([
|
||||
// 2022 IB + activity on a BS account
|
||||
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 30000, credit_amount: 10000 },
|
||||
// 2023 activity on the same BS account
|
||||
{ account_number: '1930', debit_amount: 20000, credit_amount: 5000 },
|
||||
// 2022 + 2023 P&L activity that previously accumulated incorrectly
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 200000 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 250000 },
|
||||
{ account_number: '5410', debit_amount: 50000, credit_amount: 0 },
|
||||
])
|
||||
|
||||
const { balances } = await getOpeningBalances(supabase, 'company-1', period)
|
||||
|
||||
expect(balances.get('1930')).toEqual({ debit: 100000, credit: 15000 })
|
||||
expect(balances.has('3001')).toBe(false)
|
||||
expect(balances.has('5410')).toBe(false)
|
||||
})
|
||||
|
||||
it('aggregates multiple lines per account', async () => {
|
||||
mockFetchAllRows.mockResolvedValue([
|
||||
{ account_number: '1510', debit_amount: 5000, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 3000, credit_amount: 1000 },
|
||||
])
|
||||
|
||||
const { balances } = await getOpeningBalances(supabase, 'company-1', period)
|
||||
|
||||
expect(balances.get('1510')).toEqual({ debit: 8000, credit: 1000 })
|
||||
expect(obEntryId).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null obEntryId', async () => {
|
||||
mockFetchAllRows.mockResolvedValue([])
|
||||
it('returns empty map when RPC returns no rows', async () => {
|
||||
const supabase = createSupabaseWithRpc(async () => ({ data: [], error: null }))
|
||||
|
||||
const { obEntryId } = await getOpeningBalances(supabase, 'company-1', period)
|
||||
const { balances } = await getOpeningBalances(supabase, 'company-1', period)
|
||||
|
||||
expect(obEntryId).toBeNull()
|
||||
expect(balances.size).toBe(0)
|
||||
})
|
||||
|
||||
it('handles RPC returning null data', async () => {
|
||||
const supabase = createSupabaseWithRpc(async () => ({ data: null, error: null }))
|
||||
|
||||
const { balances } = await getOpeningBalances(supabase, 'company-1', period)
|
||||
|
||||
expect(balances.size).toBe(0)
|
||||
})
|
||||
|
||||
it('coerces string-typed numerics (Postgres numeric) to numbers', async () => {
|
||||
const supabase = createSupabaseWithRpc(async () => ({
|
||||
data: [{ account_number: '1930', debit: '12345.67', credit: '0' }],
|
||||
error: null,
|
||||
}))
|
||||
|
||||
const { balances } = await getOpeningBalances(supabase, 'company-1', period)
|
||||
|
||||
expect(balances.get('1930')).toEqual({ debit: 12345.67, credit: 0 })
|
||||
})
|
||||
|
||||
it('throws when the RPC returns an error', async () => {
|
||||
const supabase = createSupabaseWithRpc(async () => ({
|
||||
data: null,
|
||||
error: { message: 'boom' },
|
||||
}))
|
||||
|
||||
await expect(getOpeningBalances(supabase, 'company-1', period)).rejects.toThrow('boom')
|
||||
})
|
||||
})
|
||||
|
||||
it('coerces null/undefined debit/credit to 0', async () => {
|
||||
it('coerces null/undefined debit/credit to 0 on the OB entry path', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const supabase = {} as any
|
||||
const period = {
|
||||
period_start: '2025-01-01',
|
||||
opening_balance_entry_id: 'ob-entry-1',
|
||||
@@ -147,17 +178,4 @@ describe('getOpeningBalances', () => {
|
||||
|
||||
expect(balances.get('1930')).toEqual({ debit: 0, credit: 0 })
|
||||
})
|
||||
|
||||
it('returns empty map when no lines found', async () => {
|
||||
const period = {
|
||||
period_start: '2025-01-01',
|
||||
opening_balance_entry_id: null,
|
||||
}
|
||||
|
||||
mockFetchAllRows.mockResolvedValue([])
|
||||
|
||||
const { balances } = await getOpeningBalances(supabase, 'company-1', period)
|
||||
|
||||
expect(balances.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,8 +25,14 @@ function makeBuilder(tableName: string) {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -68,9 +74,7 @@ describe('generateTrialBalance', () => {
|
||||
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
// prior lines (from getOpeningBalances fallback) — empty for first year
|
||||
{ data: [], error: null },
|
||||
// period lines
|
||||
// period lines (prior lines now come from RPC — defaults to empty)
|
||||
{
|
||||
data: [
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
|
||||
@@ -114,15 +118,16 @@ describe('generateTrialBalance', () => {
|
||||
fiscal_periods: [
|
||||
{ data: { period_start: '2025-01-01', opening_balance_entry_id: null }, error: null },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
// prior lines (from getOpeningBalances fallback)
|
||||
'rpc:compute_prior_opening_balances': [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 10000, credit_amount: 0 },
|
||||
{ account_number: '2099', debit_amount: 0, credit_amount: 10000 },
|
||||
{ account_number: '1930', debit: 10000, credit: 0 },
|
||||
{ account_number: '2099', debit: 0, credit: 10000 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
journal_entry_lines: [
|
||||
// period lines
|
||||
{
|
||||
data: [
|
||||
@@ -232,7 +237,6 @@ describe('generateTrialBalance', () => {
|
||||
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{ data: [], error: null },
|
||||
{
|
||||
data: [
|
||||
{ account_number: '9999', debit_amount: 100, credit_amount: 0 },
|
||||
@@ -256,7 +260,6 @@ describe('generateTrialBalance', () => {
|
||||
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{ data: [], error: null },
|
||||
{
|
||||
data: [
|
||||
{ account_number: '5410', debit_amount: 200, credit_amount: 0 },
|
||||
@@ -280,7 +283,6 @@ describe('generateTrialBalance', () => {
|
||||
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{ data: [], error: null },
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
|
||||
@@ -316,7 +318,6 @@ describe('generateTrialBalance', () => {
|
||||
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{ data: [], error: null },
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
||||
@@ -349,7 +350,6 @@ describe('generateTrialBalance', () => {
|
||||
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{ data: [], error: null },
|
||||
{ data: null, error: { message: 'DB error' } },
|
||||
],
|
||||
}
|
||||
@@ -363,7 +363,6 @@ describe('generateTrialBalance', () => {
|
||||
{ data: { period_start: '2024-01-01', opening_balance_entry_id: null }, error: null },
|
||||
],
|
||||
journal_entry_lines: [
|
||||
{ data: [], error: null },
|
||||
{
|
||||
data: [
|
||||
{ account_number: '1930', debit_amount: 5000, credit_amount: 0 },
|
||||
|
||||
@@ -5,9 +5,10 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
* Get opening balances (ingående balans) for a fiscal period.
|
||||
*
|
||||
* Uses the opening_balance_entry set by year-end closing when available
|
||||
* (O(accounts) — typically ~50 rows). Falls back to summing all entries
|
||||
* prior to the period start date via a joined query (O(all_prior_lines) —
|
||||
* expensive for companies that haven't run year-end closing).
|
||||
* (O(accounts) — typically ~50 rows). Falls back to a server-side
|
||||
* aggregate via the compute_prior_opening_balances RPC when no OB entry
|
||||
* is set, which returns one row per balance-sheet account (class 1-2)
|
||||
* regardless of how many prior journal lines there are.
|
||||
*
|
||||
* Returns per-account debit/credit opening balances and the OB entry ID
|
||||
* (if any) so the caller can exclude it from period queries to prevent
|
||||
@@ -59,35 +60,28 @@ export async function getOpeningBalances(
|
||||
balances.set(line.account_number, existing)
|
||||
}
|
||||
} else {
|
||||
// Fallback: compute from all entries dated before this period's start.
|
||||
// This is expensive for multi-year companies that haven't run year-end
|
||||
// closing — consider prompting the user to close prior periods.
|
||||
const priorLines = await fetchAllRows<{
|
||||
// Fallback: server-side aggregate of all prior posted/reversed lines.
|
||||
// The RPC filters to balance-sheet accounts (class 1-2) and returns
|
||||
// one row per account. P&L accounts (class 3-8) reset to zero at each
|
||||
// year transition — their balances are absorbed into årets resultat
|
||||
// (2099) and rolled into equity, so carrying them forward as IB would
|
||||
// violate BFNAR 2013:2. Filtering them in SQL keeps the payload small
|
||||
// and the round-trip count at one regardless of history size.
|
||||
const { data: priorRows, error } = await supabase.rpc('compute_prior_opening_balances', {
|
||||
p_company_id: companyId,
|
||||
p_period_start: period.period_start,
|
||||
})
|
||||
if (error) throw new Error(error.message)
|
||||
|
||||
for (const row of (priorRows ?? []) as Array<{
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
}>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, debit_amount, credit_amount, journal_entries!inner(company_id, status, entry_date)')
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
.lt('journal_entries.entry_date', period.period_start)
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
for (const line of priorLines) {
|
||||
// P&L accounts (class 3-8) reset to zero at each year transition —
|
||||
// their balances are absorbed into årets resultat (2099) and rolled
|
||||
// into equity. Carrying them forward as IB causes resultatkonton to
|
||||
// accumulate across years (BFNAR 2013:2 violation).
|
||||
const cls = parseInt(line.account_number.charAt(0), 10)
|
||||
if (cls >= 3 && cls <= 8) continue
|
||||
|
||||
const existing = balances.get(line.account_number) || { debit: 0, credit: 0 }
|
||||
existing.debit += Number(line.debit_amount) || 0
|
||||
existing.credit += Number(line.credit_amount) || 0
|
||||
balances.set(line.account_number, existing)
|
||||
debit: number | string
|
||||
credit: number | string
|
||||
}>) {
|
||||
balances.set(row.account_number, {
|
||||
debit: Number(row.debit) || 0,
|
||||
credit: Number(row.credit) || 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user