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(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user