diff --git a/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts index 2bbd3949..6a3ec131 100644 --- a/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts +++ b/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts @@ -234,4 +234,114 @@ describe('POST /api/bookkeeping/fiscal-periods', () => { const res = await POST(req) expect(res.status).toBe(400) }) + + it('sets previous_period_id when chaining forward', async () => { + // Build a mock that captures the insert payload. + const insertSpy = vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { id: 'new-period', name: 'FY 2026' }, + error: null, + }), + }), + }) + + let fpCallIndex = 0 + const supabase = { + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }) }, + from: vi.fn().mockImplementation(() => { + fpCallIndex++ + const callNum = fpCallIndex + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + select: vi.fn().mockImplementation((_sel: string, opts?: any) => { + if (opts?.count === 'exact') { + return { eq: vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ count: 0 }) }) } + } + if (callNum === 1) { + return { + eq: vi.fn().mockReturnValue({ + order: vi.fn().mockResolvedValue({ + data: [{ id: 'prior-period-id', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: true, closing_entry_id: null }], + error: null, + }), + }), + } + } + return { + eq: vi.fn().mockReturnValue({ + lte: vi.fn().mockReturnValue({ + gte: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue({ data: [], error: null }) }), + }), + }), + } + }), + insert: insertSpy, + update: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ error: null }) }) }), + } + }), + } + ;(createClient as ReturnType).mockResolvedValue(supabase) + + const req = createMockRequest({ name: 'FY 2026', period_start: '2026-01-01', period_end: '2026-12-31' }) + const res = await POST(req) + + expect(res.status).toBe(200) + expect(insertSpy).toHaveBeenCalledTimes(1) + const insertArg = insertSpy.mock.calls[0][0] + expect(insertArg.previous_period_id).toBe('prior-period-id') + expect(insertArg.period_start).toBe('2026-01-01') + }) + + it('does not set previous_period_id for the first period', async () => { + const insertSpy = vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { id: 'new-period', name: 'FY 2025' }, + error: null, + }), + }), + }) + + let fpCallIndex = 0 + const supabase = { + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }) }, + from: vi.fn().mockImplementation(() => { + fpCallIndex++ + const callNum = fpCallIndex + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + select: vi.fn().mockImplementation((_sel: string, opts?: any) => { + if (opts?.count === 'exact') { + return { eq: vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ count: 0 }) }) } + } + if (callNum === 1) { + return { + eq: vi.fn().mockReturnValue({ + order: vi.fn().mockResolvedValue({ data: [], error: null }), + }), + } + } + return { + eq: vi.fn().mockReturnValue({ + lte: vi.fn().mockReturnValue({ + gte: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue({ data: [], error: null }) }), + }), + }), + } + }), + insert: insertSpy, + update: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ error: null }) }) }), + } + }), + } + ;(createClient as ReturnType).mockResolvedValue(supabase) + + const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }) + const res = await POST(req) + + expect(res.status).toBe(200) + expect(insertSpy).toHaveBeenCalledTimes(1) + expect(insertSpy.mock.calls[0][0].previous_period_id).toBeNull() + }) }) diff --git a/app/api/bookkeeping/fiscal-periods/route.ts b/app/api/bookkeeping/fiscal-periods/route.ts index 69425205..d31254f6 100644 --- a/app/api/bookkeeping/fiscal-periods/route.ts +++ b/app/api/bookkeeping/fiscal-periods/route.ts @@ -132,6 +132,19 @@ export async function POST(request: Request) { ) } + // Resolve previous_period_id for forward chaining so the new period is + // linked to the period it follows. Without this, balance-sheet/trial-balance + // reports fall back to scanning every prior journal line (BFNAR 2013:2 + // continuity chain is broken). Backward chaining sets previous_period_id + // on the old earliest period instead (see below), not on the new one. + let previousPeriodId: string | null = null + if (allPeriods && allPeriods.length > 0) { + const latest = allPeriods[allPeriods.length - 1] + if (body.period_start > latest.period_end) { + previousPeriodId = latest.id + } + } + const { data, error } = await supabase .from('fiscal_periods') .insert({ @@ -140,6 +153,7 @@ export async function POST(request: Request) { name: body.name, period_start: body.period_start, period_end: body.period_end, + previous_period_id: previousPeriodId, }) .select() .single() diff --git a/components/import/ImportReviewStep.tsx b/components/import/ImportReviewStep.tsx index 5feaf5e4..0870a381 100644 --- a/components/import/ImportReviewStep.tsx +++ b/components/import/ImportReviewStep.tsx @@ -82,7 +82,7 @@ export default function ImportReviewStep({ .from('company_settings') .select('default_voucher_series') .eq('company_id', company.id) - .single(), + .maybeSingle(), supabase .from('voucher_sequences') .select('voucher_series') @@ -91,8 +91,7 @@ export default function ImportReviewStep({ if (cancelled) return - // PGRST116 = no rows returned from .single(); expected when settings not yet created. - if (settingsError && settingsError.code !== 'PGRST116') { + if (settingsError) { console.error('Failed to load company settings for voucher series', settingsError) } if (sequencesError) { diff --git a/lib/import/__tests__/sie-import.test.ts b/lib/import/__tests__/sie-import.test.ts index b73f0826..1bbc8c55 100644 --- a/lib/import/__tests__/sie-import.test.ts +++ b/lib/import/__tests__/sie-import.test.ts @@ -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[0] + + function buildCountingSupabase(count: number) { + const capturedFilters: Record = {} + + 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) diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index 487af160..b93956a9 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -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 { + // 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( ) } } + } } } diff --git a/lib/reports/__tests__/continuity-check.test.ts b/lib/reports/__tests__/continuity-check.test.ts index 7ebec826..125197f9 100644 --- a/lib/reports/__tests__/continuity-check.test.ts +++ b/lib/reports/__tests__/continuity-check.test.ts @@ -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 }, diff --git a/lib/reports/__tests__/general-ledger.test.ts b/lib/reports/__tests__/general-ledger.test.ts index 525a7718..f789391c 100644 --- a/lib/reports/__tests__/general-ledger.test.ts +++ b/lib/reports/__tests__/general-ledger.test.ts @@ -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' } }, diff --git a/lib/reports/__tests__/opening-balances.test.ts b/lib/reports/__tests__/opening-balances.test.ts index 387944e0..5bd181a5 100644 --- a/lib/reports/__tests__/opening-balances.test.ts +++ b/lib/reports/__tests__/opening-balances.test.ts @@ -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) => 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) - }) }) diff --git a/lib/reports/__tests__/trial-balance.test.ts b/lib/reports/__tests__/trial-balance.test.ts index e5ddd023..4290b099 100644 --- a/lib/reports/__tests__/trial-balance.test.ts +++ b/lib/reports/__tests__/trial-balance.test.ts @@ -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 }, diff --git a/lib/reports/opening-balances.ts b/lib/reports/opening-balances.ts index 9f0f8a24..7e552ba4 100644 --- a/lib/reports/opening-balances.ts +++ b/lib/reports/opening-balances.ts @@ -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, + }) } } diff --git a/scripts/repair-company-ib-duplicates.ts b/scripts/repair-company-ib-duplicates.ts new file mode 100644 index 00000000..b18767b3 --- /dev/null +++ b/scripts/repair-company-ib-duplicates.ts @@ -0,0 +1,295 @@ +#!/usr/bin/env npx tsx +/** + * Repair redundant opening-balance (IB) entries from multi-year SIE imports. + * + * Problem: the pre-fix SIE import created one posted journal entry with + * source_type='opening_balance' for every year imported. Year N+1's IB + * equals year N's UB, which is already the sum of year N's transactions — + * so summing prior lines to derive a cumulative balance double-counts one + * year of movements per extra IB. Result: cash and other balance-sheet + * accounts drift upward each year. + * + * Fix (per-company): keep the earliest IB entry as the company's pre-system + * starting capital; storno (reverse) every later IB entry. The immutability + * trigger on journal_entries blocks DELETE of posted entries, so storno is + * the only legally-compliant path (BFL / BFNAR 2013:2). + * + * Side effect: the storno'd period's fiscal_periods.opening_balance_entry_id + * link is cleared so getOpeningBalances() falls through to the duplicate- + * safe compute_prior_opening_balances RPC for that period. + * + * Also handles the "start over" case via --purge-imports: + * - Storno every SIE-origin journal entry for the company. + * - Delete sie_imports rows so their (company_id, file_hash) pairs free up. + * - Leaves fiscal periods in place (they may host manual entries too). + * + * Usage: + * # Preview IB dedup only + * npx tsx scripts/repair-company-ib-duplicates.ts \ + * --company-id --user-id + * + * # Apply IB dedup + * npx tsx scripts/repair-company-ib-duplicates.ts \ + * --company-id --user-id --commit + * + * # Preview full purge (IBs + all SIE-origin entries + sie_imports rows) + * npx tsx scripts/repair-company-ib-duplicates.ts \ + * --company-id --user-id --purge-imports + */ + +import { config } from 'dotenv' +config({ path: '.env.local' }) +import { createClient, type SupabaseClient } from '@supabase/supabase-js' +import { reverseEntry } from '../lib/bookkeeping/engine' + +// ────────────────────────────────────────────────────────────────── +// Args +// ────────────────────────────────────────────────────────────────── + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : undefined +} + +const COMPANY_ID = arg('company-id') +const USER_ID = arg('user-id') +const COMMIT = process.argv.includes('--commit') +const PURGE_IMPORTS = process.argv.includes('--purge-imports') + +if (!COMPANY_ID || !USER_ID) { + console.error( + 'Usage: npx tsx scripts/repair-company-ib-duplicates.ts --company-id --user-id [--purge-imports] [--commit]' + ) + process.exit(1) +} + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL +const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY + +if (!supabaseUrl || !serviceRoleKey) { + console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local') + process.exit(1) +} + +const supabase = createClient(supabaseUrl, serviceRoleKey) as SupabaseClient + +// ────────────────────────────────────────────────────────────────── +// Banner +// ────────────────────────────────────────────────────────────────── + +console.log('─────────────────────────────────────────────────────────') +console.log('IB Duplicate Repair') +console.log('─────────────────────────────────────────────────────────') +console.log('Supabase URL :', supabaseUrl) +console.log('Company :', COMPANY_ID) +console.log('User :', USER_ID) +console.log('Mode :', COMMIT ? 'COMMIT (writes)' : 'DRY RUN (no writes)') +console.log('Purge imports:', PURGE_IMPORTS ? 'YES (also storno SIE-origin entries)' : 'NO') +console.log('─────────────────────────────────────────────────────────\n') + +// ────────────────────────────────────────────────────────────────── +// IB dedup +// ────────────────────────────────────────────────────────────────── + +interface IbRow { + id: string + fiscal_period_id: string | null + entry_date: string + created_at: string + voucher_series: string | null + voucher_number: number | null +} + +async function listPostedIbEntries(): Promise { + const { data, error } = await supabase + .from('journal_entries') + .select('id, fiscal_period_id, entry_date, created_at, voucher_series, voucher_number') + .eq('company_id', COMPANY_ID!) + .eq('source_type', 'opening_balance') + .eq('status', 'posted') + .order('entry_date', { ascending: true }) + .order('created_at', { ascending: true }) + + if (error) throw new Error(`Failed to list IB entries: ${error.message}`) + return (data as IbRow[]) ?? [] +} + +async function unlinkFromFiscalPeriod(entryId: string): Promise { + const { error } = await supabase + .from('fiscal_periods') + .update({ opening_balance_entry_id: null, opening_balances_set: false }) + .eq('company_id', COMPANY_ID!) + .eq('opening_balance_entry_id', entryId) + + if (error) throw new Error(`Failed to unlink fiscal_periods.opening_balance_entry_id: ${error.message}`) +} + +async function stornoIbDuplicates(): Promise<{ kept: IbRow | null; stornoed: number; failed: number }> { + console.log('[1/2] IB dedup') + const ibs = await listPostedIbEntries() + console.log(` · Found ${ibs.length} posted opening_balance entries`) + + if (ibs.length === 0) { + console.log(' · Nothing to do.') + return { kept: null, stornoed: 0, failed: 0 } + } + + const [earliest, ...redundant] = ibs + + console.log( + ` · Keeping earliest IB: ${earliest.id} on ${earliest.entry_date} ` + + `(series ${earliest.voucher_series ?? '?'} #${earliest.voucher_number ?? '?'})` + ) + for (const r of redundant) { + console.log( + ` · Will storno: ${r.id} on ${r.entry_date} ` + + `(series ${r.voucher_series ?? '?'} #${r.voucher_number ?? '?'})` + ) + } + + if (!COMMIT) { + console.log(' · [dry-run] skipping storno and unlink') + return { kept: earliest, stornoed: 0, failed: 0 } + } + + let stornoed = 0 + let failed = 0 + for (const r of redundant) { + try { + await unlinkFromFiscalPeriod(r.id) + await reverseEntry(supabase, COMPANY_ID!, USER_ID!, r.id) + console.log(` · Stornoed ${r.id}`) + stornoed++ + } catch (err) { + console.error(` · FAILED to storno ${r.id}:`, err instanceof Error ? err.message : err) + failed++ + } + } + + return { kept: earliest, stornoed, failed } +} + +// ────────────────────────────────────────────────────────────────── +// Optional full SIE purge (for "start over" scenario) +// ────────────────────────────────────────────────────────────────── + +interface SieImportRow { + id: string + filename: string | null + file_hash: string | null + fiscal_period_id: string | null + status: string | null + imported_at: string | null +} + +async function listSieImports(): Promise { + const { data, error } = await supabase + .from('sie_imports') + .select('id, filename, file_hash, fiscal_period_id, status, imported_at') + .eq('company_id', COMPANY_ID!) + .order('imported_at', { ascending: true }) + + if (error) throw new Error(`Failed to list sie_imports: ${error.message}`) + return (data as SieImportRow[]) ?? [] +} + +async function listSieOriginEntries(periodIds: string[]): Promise<{ id: string; voucher_number: number | null; entry_date: string }[]> { + if (periodIds.length === 0) return [] + const { data, error } = await supabase + .from('journal_entries') + .select('id, voucher_number, entry_date, fiscal_period_id, source_type, status') + .eq('company_id', COMPANY_ID!) + .in('fiscal_period_id', periodIds) + .eq('status', 'posted') + .in('source_type', ['import', 'opening_balance']) + + if (error) throw new Error(`Failed to list SIE-origin entries: ${error.message}`) + return (data as { id: string; voucher_number: number | null; entry_date: string }[]) ?? [] +} + +async function purgeSieImports(): Promise { + console.log('\n[2/2] SIE import purge') + const imports = await listSieImports() + console.log(` · Found ${imports.length} sie_imports rows`) + + if (imports.length === 0) { + console.log(' · Nothing to purge.') + return + } + + const periodIds = Array.from(new Set(imports.map((i) => i.fiscal_period_id).filter((p): p is string => !!p))) + const entries = await listSieOriginEntries(periodIds) + console.log(` · Found ${entries.length} posted entries in affected fiscal periods (${periodIds.length} periods)`) + + for (const imp of imports) { + console.log( + ` · Will remove sie_imports row ${imp.id} (${imp.filename ?? 'unnamed'}, file_hash ${imp.file_hash?.slice(0, 12) ?? '?'}…)` + ) + } + + if (!COMMIT) { + console.log(' · [dry-run] skipping storno and sie_imports delete') + return + } + + let stornoed = 0 + let failed = 0 + for (const e of entries) { + try { + await supabase + .from('fiscal_periods') + .update({ opening_balance_entry_id: null, opening_balances_set: false }) + .eq('company_id', COMPANY_ID!) + .eq('opening_balance_entry_id', e.id) + await reverseEntry(supabase, COMPANY_ID!, USER_ID!, e.id) + stornoed++ + } catch (err) { + console.error(` · FAILED to storno entry ${e.id}:`, err instanceof Error ? err.message : err) + failed++ + } + } + console.log(` · Stornoed ${stornoed}/${entries.length} entries (${failed} failed)`) + + const { error: delErr } = await supabase + .from('sie_imports') + .delete() + .eq('company_id', COMPANY_ID!) + + if (delErr) { + console.error(` · FAILED to delete sie_imports rows: ${delErr.message}`) + } else { + console.log(` · Deleted ${imports.length} sie_imports rows`) + } +} + +// ────────────────────────────────────────────────────────────────── +// Main +// ────────────────────────────────────────────────────────────────── + +async function main() { + try { + const ibResult = await stornoIbDuplicates() + + if (PURGE_IMPORTS) { + await purgeSieImports() + } + + console.log('\n─────────────────────────────────────────────────────────') + console.log('Summary') + console.log('─────────────────────────────────────────────────────────') + if (ibResult.kept) { + console.log(`Kept IB entry : ${ibResult.kept.id} (${ibResult.kept.entry_date})`) + } + console.log(`IBs stornoed : ${ibResult.stornoed}`) + console.log(`IB storno fails : ${ibResult.failed}`) + console.log(`Mode : ${COMMIT ? 'COMMIT' : 'DRY RUN'}`) + if (!COMMIT) { + console.log('\nRe-run with --commit to apply.') + } + } catch (err) { + console.error('\nFATAL:', err instanceof Error ? err.message : err) + process.exit(1) + } +} + +main() diff --git a/scripts/repair-fiscal-period-chain.ts b/scripts/repair-fiscal-period-chain.ts new file mode 100644 index 00000000..0740e70d --- /dev/null +++ b/scripts/repair-fiscal-period-chain.ts @@ -0,0 +1,340 @@ +#!/usr/bin/env npx tsx +/** + * One-off repair for a company whose fiscal period chain was broken by + * the pre-fix "change fiscal year" flow: a newer period was created + * without previous_period_id and without an opening_balance_entry_id, + * so the balance sheet falls back to a full-history scan (and times out + * on production with 8k+ prior lines). + * + * Target state expected (validated before running): + * + * Prior period (e.g. 2024/2025): open, has entries, no closing_entry_id. + * Gap: Sep-Dec 2025, zero entries. + * Orphan period (e.g. 2026): previous_period_id=NULL, + * opening_balance_entry_id=NULL, + * zero entries. + * + * End state: + * + * Prior period: year-end-closed (locked, closing_entry_id, is_closed). + * Short period: Sep-Dec 2025, previous_period_id=prior, + * opening_balance_entry_id set, locked. + * Orphan period: previous_period_id=short, opening_balance_entry_id set. + * + * Usage: + * npx tsx scripts/repair-fiscal-period-chain.ts \ + * --company-id --user-id --prior --orphan \ + * [--short-start 2025-09-01] [--short-end 2025-12-31] \ + * [--commit] # default is --dry-run + */ + +import { config } from 'dotenv' +config({ path: '.env.local' }) +import { createClient } from '@supabase/supabase-js' +import { + previewYearEndClosing, + generateOpeningBalances, +} from '../lib/core/bookkeeping/year-end-service' +import { validateBalanceContinuity } from '../lib/reports/continuity-check' +import { lockPeriod } from '../lib/core/bookkeeping/period-service' +import { executeCurrencyRevaluation } from '../lib/bookkeeping/currency-revaluation' +import { createJournalEntry } from '../lib/bookkeeping/engine' + +// ──────────────────────────────────────────────────────────────────── +// Args +// ──────────────────────────────────────────────────────────────────── + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : undefined +} + +const COMPANY_ID = arg('company-id') +const USER_ID = arg('user-id') +const PRIOR_ID = arg('prior') +const ORPHAN_ID = arg('orphan') +const SHORT_START = arg('short-start') ?? '2025-09-01' +const SHORT_END = arg('short-end') ?? '2025-12-31' +const COMMIT = process.argv.includes('--commit') + +if (!COMPANY_ID || !USER_ID || !PRIOR_ID || !ORPHAN_ID) { + console.error( + 'Usage: npx tsx scripts/repair-fiscal-period-chain.ts --company-id --user-id --prior --orphan [--short-start 2025-09-01] [--short-end 2025-12-31] [--commit]' + ) + process.exit(1) +} + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL +const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY + +if (!supabaseUrl || !serviceRoleKey) { + console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local') + process.exit(1) +} + +const supabase = createClient(supabaseUrl, serviceRoleKey) + +// ──────────────────────────────────────────────────────────────────── +// Banner +// ──────────────────────────────────────────────────────────────────── + +console.log('─────────────────────────────────────────────────────────') +console.log('Fiscal Period Chain Repair') +console.log('─────────────────────────────────────────────────────────') +console.log('Supabase URL :', supabaseUrl) +console.log('Company :', COMPANY_ID) +console.log('User :', USER_ID) +console.log('Prior period :', PRIOR_ID) +console.log('Orphan period:', ORPHAN_ID) +console.log('Short period :', `${SHORT_START} → ${SHORT_END}`) +console.log('Mode :', COMMIT ? 'COMMIT (writes)' : 'DRY RUN (no writes)') +console.log('─────────────────────────────────────────────────────────\n') + +// ──────────────────────────────────────────────────────────────────── +// Validate state +// ──────────────────────────────────────────────────────────────────── + +async function validateState() { + const { data: prior } = await supabase + .from('fiscal_periods') + .select('*') + .eq('id', PRIOR_ID) + .eq('company_id', COMPANY_ID) + .single() + + if (!prior) throw new Error(`Prior period ${PRIOR_ID} not found for company ${COMPANY_ID}`) + if (prior.is_closed) throw new Error('Prior period is already closed') + if (prior.closing_entry_id) throw new Error('Prior period already has closing_entry_id') + + const { data: orphan } = await supabase + .from('fiscal_periods') + .select('*') + .eq('id', ORPHAN_ID) + .eq('company_id', COMPANY_ID) + .single() + + if (!orphan) throw new Error(`Orphan period ${ORPHAN_ID} not found for company ${COMPANY_ID}`) + if (orphan.previous_period_id) + throw new Error(`Orphan period already has previous_period_id = ${orphan.previous_period_id}`) + if (orphan.opening_balance_entry_id) + throw new Error('Orphan period already has opening_balance_entry_id') + + // Gap check: zero entries between prior end and short end + const { count: gapCount } = await supabase + .from('journal_entries') + .select('id', { count: 'exact', head: true }) + .eq('company_id', COMPANY_ID) + .gt('entry_date', prior.period_end) + .lte('entry_date', SHORT_END) + .in('status', ['posted', 'reversed']) + + if ((gapCount ?? 0) > 0) + throw new Error( + `Gap between ${prior.period_end} and ${SHORT_END} has ${gapCount} entries — repair assumes zero activity in gap` + ) + + // Orphan must be empty + const { count: orphanEntries } = await supabase + .from('journal_entries') + .select('id', { count: 'exact', head: true }) + .eq('company_id', COMPANY_ID) + .eq('fiscal_period_id', ORPHAN_ID) + .in('status', ['posted', 'reversed']) + + if ((orphanEntries ?? 0) > 0) + throw new Error(`Orphan period has ${orphanEntries} posted entries — not safe to repair automatically`) + + // Short period dates must be contiguous with prior + const nextAfterPrior = new Date(prior.period_end + 'T12:00:00Z') + nextAfterPrior.setUTCDate(nextAfterPrior.getUTCDate() + 1) + const expected = nextAfterPrior.toISOString().split('T')[0] + if (expected !== SHORT_START) + throw new Error(`--short-start ${SHORT_START} must equal ${expected} (day after prior.period_end)`) + + // Short end must be day before orphan start + const beforeOrphan = new Date(orphan.period_start + 'T12:00:00Z') + beforeOrphan.setUTCDate(beforeOrphan.getUTCDate() - 1) + const expectedEnd = beforeOrphan.toISOString().split('T')[0] + if (expectedEnd !== SHORT_END) + throw new Error(`--short-end ${SHORT_END} must equal ${expectedEnd} (day before orphan.period_start)`) + + return { prior, orphan } +} + +// ──────────────────────────────────────────────────────────────────── +// Steps +// ──────────────────────────────────────────────────────────────────── + +async function step1YearEndPrior(priorEnd: string) { + console.log('\n[1/7] Year-end closing on prior period') + + console.log(' · Currency revaluation preview') + if (COMMIT) { + await executeCurrencyRevaluation(supabase, COMPANY_ID!, priorEnd, PRIOR_ID!, USER_ID!) + } + + console.log(' · Building closing entry preview') + const preview = await previewYearEndClosing(supabase, COMPANY_ID!, USER_ID!, PRIOR_ID!) + console.log(` net result: ${preview.netResult} → ${preview.closingAccount}`) + console.log(` ${preview.closingLines.length} closing lines`) + + if (preview.closingLines.length === 0) { + throw new Error('No result accounts to close — prior period has no activity') + } + + if (!COMMIT) { + console.log(' · [dry-run] skipping createJournalEntry, update, lock, close') + return { closingEntryId: '' } + } + + console.log(' · Creating closing entry') + const closingEntry = await createJournalEntry(supabase, COMPANY_ID!, USER_ID!, { + fiscal_period_id: PRIOR_ID!, + entry_date: priorEnd, + description: 'Årsbokslut (repair)', + source_type: 'year_end', + voucher_series: 'A', + lines: preview.closingLines, + }) + + await supabase + .from('fiscal_periods') + .update({ closing_entry_id: closingEntry.id }) + .eq('id', PRIOR_ID!) + .eq('company_id', COMPANY_ID!) + + console.log(' · Locking and closing prior period') + await lockPeriod(supabase, COMPANY_ID!, USER_ID!, PRIOR_ID!) + await supabase + .from('fiscal_periods') + .update({ is_closed: true, closed_at: new Date().toISOString() }) + .eq('id', PRIOR_ID!) + .eq('company_id', COMPANY_ID!) + + return { closingEntryId: closingEntry.id } +} + +async function step2InsertShortPeriod() { + console.log('\n[2/7] Inserting short transition period') + console.log(` · ${SHORT_START} → ${SHORT_END}`) + + if (!COMMIT) { + console.log(' · [dry-run] skipping insert') + return '' + } + + const { data, error } = await supabase + .from('fiscal_periods') + .insert({ + company_id: COMPANY_ID!, + user_id: USER_ID!, + name: `Transition ${SHORT_START.slice(0, 7)}–${SHORT_END.slice(0, 7)}`, + period_start: SHORT_START, + period_end: SHORT_END, + previous_period_id: PRIOR_ID!, + }) + .select() + .single() + + if (error || !data) throw new Error(`Failed to insert short period: ${error?.message}`) + console.log(` · Short period id: ${data.id}`) + return data.id as string +} + +async function step3GenerateShortOb(shortPeriodId: string) { + console.log('\n[3/7] Generating OB entry on short period (carries forward from prior)') + if (!COMMIT) { + console.log(' · [dry-run] skipping generateOpeningBalances') + return '' + } + const ob = await generateOpeningBalances(supabase, COMPANY_ID!, USER_ID!, PRIOR_ID!, shortPeriodId) + console.log(` · OB entry id: ${ob.id}`) + return ob.id +} + +async function step4LockShort(shortPeriodId: string) { + console.log('\n[4/7] Locking short period (no period activity to close)') + if (!COMMIT) { + console.log(' · [dry-run] skipping lockPeriod') + return + } + await lockPeriod(supabase, COMPANY_ID!, USER_ID!, shortPeriodId) +} + +async function step5LinkOrphan(shortPeriodId: string) { + console.log('\n[5/7] Linking orphan period to short period') + if (!COMMIT) { + console.log(' · [dry-run] skipping update orphan.previous_period_id') + return + } + const { error } = await supabase + .from('fiscal_periods') + .update({ previous_period_id: shortPeriodId }) + .eq('id', ORPHAN_ID!) + .eq('company_id', COMPANY_ID!) + if (error) throw new Error(`Failed to link orphan: ${error.message}`) +} + +async function step6GenerateOrphanOb(shortPeriodId: string) { + console.log('\n[6/7] Generating OB entry on orphan period (carries forward from short)') + if (!COMMIT) { + console.log(' · [dry-run] skipping generateOpeningBalances') + return '' + } + const ob = await generateOpeningBalances(supabase, COMPANY_ID!, USER_ID!, shortPeriodId, ORPHAN_ID!) + console.log(` · OB entry id: ${ob.id}`) + return ob.id +} + +async function step7Continuity() { + console.log('\n[7/7] Validating IB/UB continuity on orphan period') + if (!COMMIT) { + console.log(' · [dry-run] skipping continuity check') + return + } + const result = await validateBalanceContinuity(supabase, COMPANY_ID!, ORPHAN_ID!) + console.log(` · valid: ${result.valid}, checked: ${result.checked_accounts} accounts`) + if (!result.valid) { + console.log(' · discrepancies:') + for (const d of result.discrepancies) { + console.log(` ${d.account_number}: UB=${d.previous_ub_net}, IB=${d.current_ib_net}, diff=${d.difference}`) + } + } + await supabase + .from('fiscal_periods') + .update({ continuity_verified: result.valid }) + .eq('id', ORPHAN_ID!) + .eq('company_id', COMPANY_ID!) +} + +// ──────────────────────────────────────────────────────────────────── +// Run +// ──────────────────────────────────────────────────────────────────── + +async function main() { + const { prior } = await validateState() + console.log(`✓ Validated state — prior '${prior.name}' (${prior.period_start} → ${prior.period_end})`) + + const { closingEntryId } = await step1YearEndPrior(prior.period_end) + const shortPeriodId = await step2InsertShortPeriod() + const shortObId = await step3GenerateShortOb(shortPeriodId) + await step4LockShort(shortPeriodId) + await step5LinkOrphan(shortPeriodId) + const orphanObId = await step6GenerateOrphanOb(shortPeriodId) + await step7Continuity() + + console.log('\n─────────────────────────────────────────────────────────') + console.log('Summary') + console.log('─────────────────────────────────────────────────────────') + console.log('Prior closing entry :', closingEntryId) + console.log('Short period id :', shortPeriodId) + console.log('Short OB entry id :', shortObId) + console.log('Orphan OB entry id :', orphanObId) + console.log('Mode :', COMMIT ? 'COMMITTED' : 'DRY RUN (no writes)') + console.log('─────────────────────────────────────────────────────────') +} + +main().catch((err) => { + console.error('\n✗ Repair failed:', err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/supabase/migrations/20260421160000_opening_balances_rpc.sql b/supabase/migrations/20260421160000_opening_balances_rpc.sql new file mode 100644 index 00000000..1b78ee51 --- /dev/null +++ b/supabase/migrations/20260421160000_opening_balances_rpc.sql @@ -0,0 +1,93 @@ +-- compute_prior_opening_balances(company_id, period_start) +-- +-- Server-side aggregate for the opening-balances fallback used when a fiscal +-- period has no opening_balance_entry_id set (i.e. year-end closing never ran +-- for the prior period). Returns one row per balance-sheet account +-- (class 1-2) with the summed debit and credit of every posted/reversed +-- journal line dated before the period start. +-- +-- Replaces a paginated PostgREST scan that fetched every prior line via +-- journal_entry_lines with an !inner join on journal_entries. At ~8k lines +-- that scan would tip over the 8s statement_timeout on the authenticated +-- role because the RLS EXISTS subquery on journal_entry_lines re-evaluates +-- user_company_ids() per row on every .range() page. This RPC pushes the +-- filter + SUM into the planner and returns ~50 rows in a single round trip. +-- +-- Class 3-8 accounts are intentionally excluded: their balances reset at +-- each year transition and are absorbed into equity via the closing entry; +-- carrying them forward as IB would violate BFNAR 2013:2. +-- +-- Duplicate-IB guard: multi-year SIE imports create one opening_balance +-- journal entry per imported year (the #IB records from each SIE file). +-- Each year N+1's IB equals year N's UB, which is already the sum of +-- year N's journal lines — so blindly summing every prior IB double-counts +-- by one year's worth of movements per duplicate. Only the earliest IB per +-- account is kept (pre-system starting capital); later IBs are excluded. + +CREATE OR REPLACE FUNCTION compute_prior_opening_balances( + p_company_id uuid, + p_period_start date +) +RETURNS TABLE (account_number text, debit numeric, credit numeric) +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path = public +AS $$ + -- Dedup is per-account, not per-entry. If the same account appears in multiple + -- IB entries (duplicate opening balances from multi-year imports), we keep only + -- the earliest line for that account. Accounts that appear only in a later IB + -- (e.g. a new account introduced in year N with no prior-year IB) are still + -- included — they represent a genuine pre-system starting balance for that + -- account, not a duplicate. + WITH ib_lines_ranked AS ( + SELECT + jel.account_number, + jel.debit_amount, + jel.credit_amount, + ROW_NUMBER() OVER ( + PARTITION BY jel.account_number + ORDER BY je.entry_date ASC, je.created_at ASC, je.id ASC + ) AS rn + FROM journal_entry_lines jel + JOIN journal_entries je ON je.id = jel.journal_entry_id + WHERE je.company_id = p_company_id + AND je.status IN ('posted', 'reversed') + AND je.entry_date < p_period_start + AND je.source_type = 'opening_balance' + AND substr(jel.account_number, 1, 1) BETWEEN '1' AND '2' + ), + earliest_ib AS ( + SELECT account_number, debit_amount, credit_amount + FROM ib_lines_ranked + WHERE rn = 1 + ), + non_ib_lines AS ( + SELECT + jel.account_number, + jel.debit_amount, + jel.credit_amount + FROM journal_entry_lines jel + JOIN journal_entries je ON je.id = jel.journal_entry_id + WHERE je.company_id = p_company_id + AND je.status IN ('posted', 'reversed') + AND je.entry_date < p_period_start + AND je.source_type IS DISTINCT FROM 'opening_balance' + AND substr(jel.account_number, 1, 1) BETWEEN '1' AND '2' + ), + all_lines AS ( + SELECT account_number, debit_amount, credit_amount FROM earliest_ib + UNION ALL + SELECT account_number, debit_amount, credit_amount FROM non_ib_lines + ) + SELECT + account_number, + SUM(debit_amount)::numeric AS debit, + SUM(credit_amount)::numeric AS credit + FROM all_lines + GROUP BY account_number; +$$; + +GRANT EXECUTE ON FUNCTION compute_prior_opening_balances(uuid, date) TO authenticated; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260421170000_commit_journal_entry_user_id_fallback.sql b/supabase/migrations/20260421170000_commit_journal_entry_user_id_fallback.sql new file mode 100644 index 00000000..6c3417b7 --- /dev/null +++ b/supabase/migrations/20260421170000_commit_journal_entry_user_id_fallback.sql @@ -0,0 +1,64 @@ +-- commit_journal_entry: fall back to the draft entry's user_id when +-- auth.uid() is NULL. +-- +-- Context: when this RPC is invoked via the service role (one-off repair +-- scripts, cron jobs, internal maintenance flows), auth.uid() returns NULL. +-- The INSERT into voucher_sequences then fails its user_id NOT NULL check +-- *before* ON CONFLICT can resolve to DO UPDATE (PostgreSQL evaluates NOT +-- NULL on the candidate tuple ahead of conflict arbitration). That made it +-- impossible to commit journal entries from any non-interactive context. +-- +-- Fix: read user_id from the draft journal entry (which is always set by +-- createJournalEntry) and use it as the fallback attribution on the +-- voucher sequence row. Normal interactive flows still record auth.uid(); +-- only the service-role path changes. + +CREATE OR REPLACE FUNCTION public.commit_journal_entry( + p_company_id uuid, + p_entry_id uuid, + p_commit_method text DEFAULT NULL, + p_rubric_version text DEFAULT NULL +) +RETURNS TABLE (voucher_number integer) +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_next integer; + v_fiscal_period_id uuid; + v_series text; + v_entry_user_id uuid; +BEGIN + SELECT je.fiscal_period_id, COALESCE(je.voucher_series, 'A'), je.user_id + INTO v_fiscal_period_id, v_series, v_entry_user_id + FROM public.journal_entries je + WHERE je.id = p_entry_id + AND je.company_id = p_company_id + AND je.status = 'draft' + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Draft journal entry not found: %', p_entry_id; + END IF; + + INSERT INTO public.voucher_sequences (company_id, user_id, fiscal_period_id, voucher_series, last_number) + VALUES (p_company_id, COALESCE(auth.uid(), v_entry_user_id), v_fiscal_period_id, v_series, 1) + ON CONFLICT (company_id, fiscal_period_id, voucher_series) + DO UPDATE SET + last_number = public.voucher_sequences.last_number + 1, + updated_at = now() + RETURNING last_number INTO v_next; + + UPDATE public.journal_entries + SET voucher_number = v_next, + status = 'posted', + commit_method = p_commit_method, + rubric_version = p_rubric_version + WHERE id = p_entry_id + AND company_id = p_company_id; + + RETURN QUERY SELECT v_next; +END; +$$; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260421180000_opening_balances_rpc_fix_reversed_and_new_accounts.sql b/supabase/migrations/20260421180000_opening_balances_rpc_fix_reversed_and_new_accounts.sql new file mode 100644 index 00000000..4bb6cca8 --- /dev/null +++ b/supabase/migrations/20260421180000_opening_balances_rpc_fix_reversed_and_new_accounts.sql @@ -0,0 +1,110 @@ +-- compute_prior_opening_balances — correctness fixes +-- +-- Supersedes the function defined in 20260421160000. Addresses two bugs +-- surfaced in Swedish accounting compliance review: +-- +-- 1. Reversed entries were previously included in ib_lines_ranked via +-- status IN ('posted', 'reversed'). Because the per-account ROW_NUMBER +-- dedup picks the earliest IB line (rn = 1), a cancelled (reversed) IB +-- could be carried forward as the pre-system starting balance while its +-- matching storno entry (source_type = 'storno') landed in non_ib_lines +-- with flipped amounts — producing a net negative skew equal to the +-- cancelled IB. Now ib_lines_ranked only considers currently-posted IB +-- entries, and non_ib_lines excludes 'storno' source_type so a cancelled +-- pair contributes zero on both sides. Per BFL 5:5, the computed IB must +-- reflect the legally effective net position, not a cancelled entry. +-- +-- 2. The per-account dedup rule ("keep earliest IB line, drop the rest") +-- double-counted balances for accounts that first appeared in a later +-- year's IB but already had prior-year non-IB activity. In a multi-year +-- SIE import, a year-N IB line equals year-(N-1) UB, which is already +-- captured in the prior-year transaction lines. The correct rule is: +-- keep the earliest IB line for an account only if there is no non-IB +-- activity on that account dated before the IB itself. Otherwise the +-- IB is a restatement of a UB already derivable from non-IB lines. +-- This preserves genuine pre-system starting balances for accounts +-- introduced later (BFNAR 2013:2) while preventing phantom balances. + +CREATE OR REPLACE FUNCTION compute_prior_opening_balances( + p_company_id uuid, + p_period_start date +) +RETURNS TABLE (account_number text, debit numeric, credit numeric) +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path = public +AS $$ + WITH ib_lines_ranked AS ( + -- Currently-effective IB lines only. Reversed originals and their stornos + -- are both excluded (originals by status, stornos by source_type below). + SELECT + jel.account_number, + jel.debit_amount, + jel.credit_amount, + je.entry_date, + ROW_NUMBER() OVER ( + PARTITION BY jel.account_number + ORDER BY je.entry_date ASC, je.created_at ASC, je.id ASC + ) AS rn + FROM journal_entry_lines jel + JOIN journal_entries je ON je.id = jel.journal_entry_id + WHERE je.company_id = p_company_id + AND je.status = 'posted' + AND je.entry_date < p_period_start + AND je.source_type = 'opening_balance' + AND substr(jel.account_number, 1, 1) BETWEEN '1' AND '2' + ), + earliest_ib AS ( + SELECT account_number, debit_amount, credit_amount, entry_date + FROM ib_lines_ranked + WHERE rn = 1 + ), + non_ib_lines AS ( + -- Non-IB, non-storno posted lines. Excluding source_type = 'storno' + -- pairs with the status = 'posted' filter on reversed originals so a + -- cancelled entry contributes zero on both sides. Regular posted + -- transactions contribute their amounts. + SELECT + jel.account_number, + jel.debit_amount, + jel.credit_amount, + je.entry_date + FROM journal_entry_lines jel + JOIN journal_entries je ON je.id = jel.journal_entry_id + WHERE je.company_id = p_company_id + AND je.status = 'posted' + AND je.entry_date < p_period_start + AND je.source_type NOT IN ('opening_balance', 'storno') + AND substr(jel.account_number, 1, 1) BETWEEN '1' AND '2' + ), + effective_ib AS ( + -- Keep earliest IB for an account only if no non-IB activity predates it. + -- A later-year IB for an account with prior-year transactions is just a + -- restatement of the prior UB — already summed in non_ib_lines. + SELECT eib.account_number, eib.debit_amount, eib.credit_amount + FROM earliest_ib eib + WHERE NOT EXISTS ( + SELECT 1 + FROM non_ib_lines nil + WHERE nil.account_number = eib.account_number + AND nil.entry_date < eib.entry_date + ) + ), + all_lines AS ( + SELECT account_number, debit_amount, credit_amount FROM effective_ib + UNION ALL + SELECT account_number, debit_amount, credit_amount + FROM non_ib_lines + ) + SELECT + account_number, + SUM(debit_amount)::numeric AS debit, + SUM(credit_amount)::numeric AS credit + FROM all_lines + GROUP BY account_number; +$$; + +GRANT EXECUTE ON FUNCTION compute_prior_opening_balances(uuid, date) TO authenticated; + +NOTIFY pgrst, 'reload schema';