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:
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <uuid> --user-id <uuid>
|
||||
*
|
||||
* # Apply IB dedup
|
||||
* npx tsx scripts/repair-company-ib-duplicates.ts \
|
||||
* --company-id <uuid> --user-id <uuid> --commit
|
||||
*
|
||||
* # Preview full purge (IBs + all SIE-origin entries + sie_imports rows)
|
||||
* npx tsx scripts/repair-company-ib-duplicates.ts \
|
||||
* --company-id <uuid> --user-id <uuid> --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 <uuid> --user-id <uuid> [--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<IbRow[]> {
|
||||
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<void> {
|
||||
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<SieImportRow[]> {
|
||||
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<void> {
|
||||
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()
|
||||
@@ -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 <uuid> --user-id <uuid> --prior <uuid> --orphan <uuid> \
|
||||
* [--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 <uuid> --user-id <uuid> --prior <uuid> --orphan <uuid> [--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: '<dry-run>' }
|
||||
}
|
||||
|
||||
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 '<dry-run>'
|
||||
}
|
||||
|
||||
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 '<dry-run>'
|
||||
}
|
||||
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 '<dry-run>'
|
||||
}
|
||||
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)
|
||||
})
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
+110
@@ -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';
|
||||
Reference in New Issue
Block a user