fix: make out-of-order SIE opening balances atomic (#1334)
* fix: preserve SIE IB on out-of-order imports * fix: make SIE opening balance replacement atomic * test: seed accounts for atomic IB pg coverage * test: complete atomic IB pg fixtures * fix(import): avoid IB resync across fiscal-year gaps * test(import): mirror PostgREST date values in pg adapter * fix(import): address opening balance review feedback
This commit is contained in:
@@ -13,14 +13,14 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { executeSIEImport } from '../sie-import'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { createJournalEntry, replaceOpeningBalanceEntry } from '@/lib/bookkeeping/engine'
|
||||
import { findUntransferredResults } from '@/lib/reports/imbalance-diagnosis'
|
||||
import type { ParsedSIEFile, AccountMapping } from '../types'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: vi.fn(async () => ({ id: 'ob-entry-1' })),
|
||||
reverseEntry: vi.fn(),
|
||||
replaceOpeningBalanceEntry: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/reports/imbalance-diagnosis', () => ({
|
||||
@@ -131,7 +131,7 @@ function makeMapping(source: string, target: string): AccountMapping {
|
||||
}
|
||||
}
|
||||
|
||||
function standardQueues() {
|
||||
function standardQueues(): Record<string, QueuedResult[]> {
|
||||
return {
|
||||
sie_imports: [
|
||||
{ data: null }, // checkDuplicateImport: no duplicate
|
||||
@@ -280,6 +280,228 @@ describe('executeSIEImport: derived IB from #UB -1 (issue #675)', () => {
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
expect(result.openingBalanceEntryId).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an earlier-year IB and resyncs the already-imported successor', async () => {
|
||||
const queues = standardQueues()
|
||||
queues.fiscal_periods = [
|
||||
{ data: { id: 'fp-2025' } },
|
||||
{ data: { opening_balances_set: false, opening_balance_entry_id: null } },
|
||||
{}, // Link the new 2025 IB.
|
||||
{
|
||||
data: {
|
||||
id: 'fp-2026',
|
||||
name: 'Räkenskapsår 2026',
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
is_closed: false,
|
||||
locked_at: null,
|
||||
opening_balance_entry_id: 'ib-2026-old',
|
||||
opening_balances_set: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
queues.journal_entries = [
|
||||
// The chronological activity check excludes the already-imported 2026
|
||||
// entries when deciding whether the 2025 #IB is legitimate.
|
||||
{ count: 0 },
|
||||
]
|
||||
|
||||
vi.mocked(createJournalEntry).mockResolvedValueOnce({
|
||||
id: 'ib-2025',
|
||||
} as Awaited<ReturnType<typeof createJournalEntry>>)
|
||||
vi.mocked(replaceOpeningBalanceEntry).mockResolvedValueOnce({
|
||||
newEntryId: 'ib-2026-new',
|
||||
stornoEntryId: 'storno-2026-old',
|
||||
newVoucherNumber: 2,
|
||||
stornoVoucherNumber: 3,
|
||||
})
|
||||
|
||||
const parsed = makeParsedFile({
|
||||
header: {
|
||||
...makeParsedFile().header,
|
||||
fiscalYears: [{ yearIndex: 0, start: '2025-01-01', end: '2025-12-31' }],
|
||||
},
|
||||
openingBalances: [
|
||||
{ yearIndex: 0, account: '1930', amount: 100 },
|
||||
{ yearIndex: 0, account: '2010', amount: -100 },
|
||||
],
|
||||
closingBalances: [
|
||||
{ yearIndex: 0, account: '1930', amount: 150 },
|
||||
{ yearIndex: 0, account: '2010', amount: -150 },
|
||||
],
|
||||
stats: {
|
||||
totalAccounts: 2,
|
||||
totalVouchers: 0,
|
||||
totalTransactionLines: 0,
|
||||
fiscalYearStart: '2025-01-01',
|
||||
fiscalYearEnd: '2025-12-31',
|
||||
},
|
||||
})
|
||||
|
||||
const result = await executeSIEImport(
|
||||
buildRoutingSupabase(queues),
|
||||
'company-1',
|
||||
'user-1',
|
||||
parsed,
|
||||
standardMappings,
|
||||
standardOptions,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.openingBalanceEntryId).toBe('ib-2025')
|
||||
expect(result.nextPeriodIBResync).toEqual({
|
||||
nextPeriodId: 'fp-2026',
|
||||
nextPeriodName: 'Räkenskapsår 2026',
|
||||
stornoEntryId: 'storno-2026-old',
|
||||
newOpeningBalanceEntryId: 'ib-2026-new',
|
||||
})
|
||||
expect(result.warnings.join(' ')).toMatch(/Räkenskapsår 2026.*synkades om/)
|
||||
|
||||
expect(vi.mocked(createJournalEntry).mock.calls[0][3]).toMatchObject({
|
||||
fiscal_period_id: 'fp-2025',
|
||||
source_type: 'opening_balance',
|
||||
entry_date: '2025-01-01',
|
||||
})
|
||||
expect(replaceOpeningBalanceEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
'ib-2026-old',
|
||||
expect.objectContaining({
|
||||
fiscal_period_id: 'fp-2026',
|
||||
source_type: 'opening_balance',
|
||||
entry_date: '2026-01-01',
|
||||
}),
|
||||
)
|
||||
expect(vi.mocked(replaceOpeningBalanceEntry).mock.calls[0]?.[4]).not.toHaveProperty(
|
||||
'voucher_series',
|
||||
)
|
||||
})
|
||||
|
||||
it('warns without changing a locked successor opening balance', async () => {
|
||||
const queues = standardQueues()
|
||||
queues.fiscal_periods = [
|
||||
{ data: { id: 'fp-2025' } },
|
||||
{ data: { opening_balances_set: false, opening_balance_entry_id: null } },
|
||||
{}, // Link the new 2025 IB.
|
||||
{
|
||||
data: {
|
||||
id: 'fp-2026',
|
||||
name: 'Räkenskapsår 2026',
|
||||
period_start: '2026-01-01',
|
||||
period_end: '2026-12-31',
|
||||
is_closed: false,
|
||||
locked_at: '2026-07-01T00:00:00Z',
|
||||
opening_balance_entry_id: 'ib-2026-old',
|
||||
opening_balances_set: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
queues.journal_entries = [{ count: 0 }]
|
||||
vi.mocked(createJournalEntry).mockResolvedValueOnce({
|
||||
id: 'ib-2025',
|
||||
} as Awaited<ReturnType<typeof createJournalEntry>>)
|
||||
|
||||
const parsed = makeParsedFile({
|
||||
header: {
|
||||
...makeParsedFile().header,
|
||||
fiscalYears: [{ yearIndex: 0, start: '2025-01-01', end: '2025-12-31' }],
|
||||
},
|
||||
openingBalances: [
|
||||
{ yearIndex: 0, account: '1930', amount: 100 },
|
||||
{ yearIndex: 0, account: '2010', amount: -100 },
|
||||
],
|
||||
closingBalances: [
|
||||
{ yearIndex: 0, account: '1930', amount: 150 },
|
||||
{ yearIndex: 0, account: '2010', amount: -150 },
|
||||
],
|
||||
stats: {
|
||||
totalAccounts: 2,
|
||||
totalVouchers: 0,
|
||||
totalTransactionLines: 0,
|
||||
fiscalYearStart: '2025-01-01',
|
||||
fiscalYearEnd: '2025-12-31',
|
||||
},
|
||||
})
|
||||
|
||||
const result = await executeSIEImport(
|
||||
buildRoutingSupabase(queues),
|
||||
'company-1',
|
||||
'user-1',
|
||||
parsed,
|
||||
standardMappings,
|
||||
standardOptions,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.openingBalanceEntryId).toBe('ib-2025')
|
||||
expect(result.nextPeriodIBResync).toBeUndefined()
|
||||
expect(result.nextPeriodIBResyncSkipped).toEqual({
|
||||
reason: 'locked',
|
||||
nextPeriodName: 'Räkenskapsår 2026',
|
||||
})
|
||||
expect(result.warnings.join(' ')).toMatch(/Räkenskapsår 2026.*är låst/)
|
||||
expect(createJournalEntry).toHaveBeenCalledTimes(1)
|
||||
expect(replaceOpeningBalanceEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still rejects a file whose vouchers cross the target fiscal-period boundary', async () => {
|
||||
const queues = standardQueues()
|
||||
queues.fiscal_periods = [
|
||||
{ data: { id: 'fp-2025' } },
|
||||
{ data: { opening_balances_set: false, opening_balance_entry_id: null } },
|
||||
{}, // Link the 2025 IB before transaction-range validation.
|
||||
{ data: { period_start: '2025-01-01', period_end: '2025-12-31' } },
|
||||
]
|
||||
queues.journal_entries = [{ count: 0 }]
|
||||
vi.mocked(createJournalEntry).mockResolvedValueOnce({
|
||||
id: 'ib-2025',
|
||||
} as Awaited<ReturnType<typeof createJournalEntry>>)
|
||||
|
||||
const parsed = makeParsedFile({
|
||||
header: {
|
||||
...makeParsedFile().header,
|
||||
fiscalYears: [{ yearIndex: 0, start: '2025-01-01', end: '2025-12-31' }],
|
||||
},
|
||||
openingBalances: [
|
||||
{ yearIndex: 0, account: '1930', amount: 100 },
|
||||
{ yearIndex: 0, account: '2010', amount: -100 },
|
||||
],
|
||||
closingBalances: [],
|
||||
vouchers: [
|
||||
{
|
||||
series: 'A',
|
||||
number: 1,
|
||||
date: new Date(2026, 0, 2),
|
||||
description: 'Voucher from another fiscal year',
|
||||
lines: [
|
||||
{ account: '1930', amount: 10 },
|
||||
{ account: '2010', amount: -10 },
|
||||
],
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
totalAccounts: 2,
|
||||
totalVouchers: 1,
|
||||
totalTransactionLines: 2,
|
||||
fiscalYearStart: '2025-01-01',
|
||||
fiscalYearEnd: '2025-12-31',
|
||||
},
|
||||
})
|
||||
|
||||
const result = await executeSIEImport(
|
||||
buildRoutingSupabase(queues),
|
||||
'company-1',
|
||||
'user-1',
|
||||
parsed,
|
||||
standardMappings,
|
||||
standardOptions,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.errors.join(' ')).toMatch(/datum utanför räkenskapsåret/)
|
||||
expect(result.errors.join(' ')).toMatch(/flera år i samma fil stöds inte/)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Untransferred prior-year results (post-import walk) ---
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PoolClient } from 'pg'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getClient, getPool } from '@/tests/pg/setup'
|
||||
import { seedCompany } from '@/tests/pg/fixtures'
|
||||
import type { CreateJournalEntryInput } from '@/types'
|
||||
import type { ParsedSIEFile } from '../types'
|
||||
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: vi.fn(),
|
||||
replaceOpeningBalanceEntry: vi.fn(),
|
||||
}))
|
||||
|
||||
import { replaceOpeningBalanceEntry } from '@/lib/bookkeeping/engine'
|
||||
import {
|
||||
companyHasPriorActivity,
|
||||
resyncNextPeriodOpeningBalance,
|
||||
} from '../sie-import'
|
||||
|
||||
type EntryLine = {
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
line_description?: string | null
|
||||
}
|
||||
|
||||
async function insertPostedEntry(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
entryDate: string
|
||||
sourceType: 'opening_balance' | 'import' | 'storno'
|
||||
description: string
|
||||
lines: EntryLine[]
|
||||
reversesId?: string | null
|
||||
}): Promise<string> {
|
||||
const id = randomUUID()
|
||||
const voucher = await getPool().query<{ next_number: number }>(
|
||||
`SELECT COALESCE(MAX(voucher_number), 0) + 1 AS next_number
|
||||
FROM public.journal_entries
|
||||
WHERE company_id = $1
|
||||
AND fiscal_period_id = $2
|
||||
AND voucher_series = 'A'`,
|
||||
[params.companyId, params.fiscalPeriodId],
|
||||
)
|
||||
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entries
|
||||
(id, user_id, company_id, fiscal_period_id, voucher_number,
|
||||
voucher_series, entry_date, description, source_type, status, reverses_id)
|
||||
VALUES ($1, $2, $3, $4, $5, 'A', $6, $7, $8, 'posted', $9)`,
|
||||
[
|
||||
id,
|
||||
params.userId,
|
||||
params.companyId,
|
||||
params.fiscalPeriodId,
|
||||
voucher.rows[0]!.next_number,
|
||||
params.entryDate,
|
||||
params.description,
|
||||
params.sourceType,
|
||||
params.reversesId ?? null,
|
||||
],
|
||||
)
|
||||
|
||||
await getPool().query(
|
||||
`INSERT INTO public.voucher_sequences
|
||||
(company_id, user_id, fiscal_period_id, voucher_series, last_number)
|
||||
VALUES ($1, $2, $3, 'A', $4)
|
||||
ON CONFLICT (company_id, fiscal_period_id, voucher_series)
|
||||
DO UPDATE SET last_number = GREATEST(
|
||||
public.voucher_sequences.last_number,
|
||||
EXCLUDED.last_number
|
||||
)`,
|
||||
[
|
||||
params.companyId,
|
||||
params.userId,
|
||||
params.fiscalPeriodId,
|
||||
voucher.rows[0]!.next_number,
|
||||
],
|
||||
)
|
||||
|
||||
for (const line of params.lines) {
|
||||
await getPool().query(
|
||||
`INSERT INTO public.journal_entry_lines
|
||||
(journal_entry_id, account_number, debit_amount, credit_amount, line_description)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[
|
||||
id,
|
||||
line.account_number,
|
||||
line.debit_amount,
|
||||
line.credit_amount,
|
||||
line.line_description ?? null,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
async function runAsAuthenticated<T>(
|
||||
userId: string,
|
||||
operation: (client: PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await getClient()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(`SELECT set_config('request.jwt.claims', $1, true)`, [
|
||||
JSON.stringify({ sub: userId, role: 'authenticated' }),
|
||||
])
|
||||
await client.query(`SELECT set_config('request.jwt.claim.sub', $1, true)`, [userId])
|
||||
await client.query('SET LOCAL ROLE authenticated')
|
||||
const result = await operation(client)
|
||||
await client.query('COMMIT')
|
||||
return result
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => {})
|
||||
throw error
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
function makePgSupabase(_userId: string): SupabaseClient {
|
||||
const from = (table: string) => {
|
||||
if (table === 'journal_entries') {
|
||||
const filters: {
|
||||
companyId?: string
|
||||
status?: string
|
||||
excludedSourceTypes: string[]
|
||||
throughDate?: string
|
||||
} = { excludedSourceTypes: [] }
|
||||
|
||||
const chain = {
|
||||
select: () => chain,
|
||||
eq: (column: string, value: unknown) => {
|
||||
if (column === 'company_id') filters.companyId = String(value)
|
||||
else if (column === 'status') filters.status = String(value)
|
||||
else throw new Error(`Unhandled eq filter in journal entry pg adapter: ${column}`)
|
||||
return chain
|
||||
},
|
||||
neq: (column: string, value: unknown) => {
|
||||
if (column === 'source_type') filters.excludedSourceTypes.push(String(value))
|
||||
else throw new Error(`Unhandled neq filter in journal entry pg adapter: ${column}`)
|
||||
return chain
|
||||
},
|
||||
lte: (column: string, value: unknown) => {
|
||||
if (column === 'entry_date') filters.throughDate = String(value)
|
||||
else throw new Error(`Unhandled lte filter in journal entry pg adapter: ${column}`)
|
||||
return chain
|
||||
},
|
||||
then: (
|
||||
resolve: (value: { data: null; error: null; count: number }) => void,
|
||||
reject: (reason: unknown) => void,
|
||||
) => {
|
||||
getPool()
|
||||
.query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count
|
||||
FROM public.journal_entries
|
||||
WHERE company_id = $1
|
||||
AND status = $2
|
||||
AND source_type <> ALL($3::text[])
|
||||
AND entry_date <= $4::date`,
|
||||
[
|
||||
filters.companyId,
|
||||
filters.status,
|
||||
filters.excludedSourceTypes,
|
||||
filters.throughDate,
|
||||
],
|
||||
)
|
||||
.then((result) => resolve({
|
||||
data: null,
|
||||
error: null,
|
||||
count: Number(result.rows[0]!.count),
|
||||
}))
|
||||
.catch(reject)
|
||||
},
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
if (table === 'fiscal_periods') {
|
||||
const filters: { companyId?: string; afterDate?: string } = {}
|
||||
const chain = {
|
||||
select: () => chain,
|
||||
eq: (column: string, value: unknown) => {
|
||||
if (column === 'company_id') filters.companyId = String(value)
|
||||
else throw new Error(`Unhandled eq filter in fiscal period pg adapter: ${column}`)
|
||||
return chain
|
||||
},
|
||||
gt: (column: string, value: unknown) => {
|
||||
if (column === 'period_start') filters.afterDate = String(value)
|
||||
else throw new Error(`Unhandled gt filter in fiscal period pg adapter: ${column}`)
|
||||
return chain
|
||||
},
|
||||
order: () => chain,
|
||||
limit: () => chain,
|
||||
maybeSingle: async () => {
|
||||
const result = await getPool().query(
|
||||
`SELECT id, name,
|
||||
period_start::text AS period_start,
|
||||
period_end::text AS period_end,
|
||||
is_closed, locked_at,
|
||||
opening_balance_entry_id, opening_balances_set
|
||||
FROM public.fiscal_periods
|
||||
WHERE company_id = $1
|
||||
AND period_start > $2::date
|
||||
ORDER BY period_start ASC
|
||||
LIMIT 1`,
|
||||
[filters.companyId, filters.afterDate],
|
||||
)
|
||||
return { data: result.rows[0] ?? null, error: null }
|
||||
},
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected table in pg adapter: ${table}`)
|
||||
}
|
||||
|
||||
return { from } as unknown as SupabaseClient
|
||||
}
|
||||
|
||||
function closingBalances(amount = 150): ParsedSIEFile {
|
||||
return {
|
||||
closingBalances: [
|
||||
{ yearIndex: 0, account: '1930', amount },
|
||||
{ yearIndex: 0, account: '2010', amount: -amount },
|
||||
],
|
||||
} as ParsedSIEFile
|
||||
}
|
||||
|
||||
async function insertPeriod(
|
||||
companyId: string,
|
||||
name: string,
|
||||
periodStart: string,
|
||||
periodEnd: string,
|
||||
): Promise<string> {
|
||||
const result = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.fiscal_periods
|
||||
(company_id, name, period_start, period_end, is_closed, opening_balances_set)
|
||||
VALUES ($1, $2, $3, $4, false, false)
|
||||
RETURNING id`,
|
||||
[companyId, name, periodStart, periodEnd],
|
||||
)
|
||||
return result.rows[0]!.id
|
||||
}
|
||||
|
||||
function installAtomicReplacementMock(): void {
|
||||
vi.mocked(replaceOpeningBalanceEntry).mockImplementation(async (
|
||||
_client: SupabaseClient,
|
||||
targetCompanyId: string,
|
||||
targetUserId: string,
|
||||
expectedOldEntryId: string,
|
||||
input: CreateJournalEntryInput,
|
||||
) => {
|
||||
const accounts = await getPool().query<{ id: string; account_number: string }>(
|
||||
`SELECT id, account_number
|
||||
FROM public.chart_of_accounts
|
||||
WHERE company_id = $1
|
||||
AND account_number = ANY($2::text[])`,
|
||||
[targetCompanyId, input.lines.map((line) => line.account_number)],
|
||||
)
|
||||
const accountIds = new Map(accounts.rows.map((account) => [account.account_number, account.id]))
|
||||
const lines = input.lines.map((line, sortOrder) => ({
|
||||
account_number: line.account_number,
|
||||
account_id: accountIds.get(line.account_number),
|
||||
debit_amount: line.debit_amount,
|
||||
credit_amount: line.credit_amount,
|
||||
currency: line.currency ?? 'SEK',
|
||||
amount_in_currency: line.amount_in_currency ?? null,
|
||||
exchange_rate: line.exchange_rate ?? null,
|
||||
line_description: line.line_description ?? null,
|
||||
tax_code: line.tax_code ?? null,
|
||||
dimensions: line.dimensions ?? {},
|
||||
sort_order: sortOrder,
|
||||
}))
|
||||
|
||||
const outcome = await runAsAuthenticated(targetUserId, async (client) => {
|
||||
const result = await client.query<{
|
||||
new_entry_id: string
|
||||
storno_entry_id: string
|
||||
new_voucher_number: number
|
||||
storno_voucher_number: number
|
||||
}>(
|
||||
`SELECT * FROM public.commit_opening_balance_replacement(
|
||||
$1::uuid, $2::uuid, $3::uuid, $4::uuid, $5::date,
|
||||
$6::text, $7::text, $8::jsonb, NULL, NULL
|
||||
)`,
|
||||
[
|
||||
targetCompanyId,
|
||||
input.fiscal_period_id,
|
||||
expectedOldEntryId,
|
||||
targetUserId,
|
||||
input.entry_date,
|
||||
input.description,
|
||||
input.voucher_series ?? 'A',
|
||||
JSON.stringify(lines),
|
||||
],
|
||||
)
|
||||
return result.rows[0]!
|
||||
})
|
||||
|
||||
return {
|
||||
newEntryId: outcome.new_entry_id,
|
||||
stornoEntryId: outcome.storno_entry_id,
|
||||
newVoucherNumber: outcome.new_voucher_number,
|
||||
stornoVoucherNumber: outcome.storno_voucher_number,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const oldIBLines: EntryLine[] = [
|
||||
{ account_number: '1930', debit_amount: 100, credit_amount: 0 },
|
||||
{ account_number: '2010', debit_amount: 0, credit_amount: 100 },
|
||||
]
|
||||
|
||||
describe('out-of-order SIE opening balances', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(replaceOpeningBalanceEntry).mockReset()
|
||||
})
|
||||
|
||||
it('keeps the 2025 IB and replaces the imported-first 2026 IB without duplication', async () => {
|
||||
const { userId, companyId, fiscalPeriodId: period2026Id } = await seedCompany()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.chart_of_accounts
|
||||
(user_id, company_id, account_number, account_name, account_class,
|
||||
account_type, normal_balance, is_active)
|
||||
VALUES
|
||||
($1, $2, '1930', 'Bankkonto', 1, 'asset', 'debit', true),
|
||||
($1, $2, '2010', 'Eget kapital', 2, 'equity', 'credit', true),
|
||||
($1, $2, '3001', 'Forsaljning', 3, 'revenue', 'credit', true)
|
||||
ON CONFLICT (company_id, account_number) DO NOTHING`,
|
||||
[userId, companyId],
|
||||
)
|
||||
const old2026IBId = await insertPostedEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId: period2026Id,
|
||||
entryDate: '2026-01-01',
|
||||
sourceType: 'opening_balance',
|
||||
description: 'Old 2026 IB',
|
||||
lines: oldIBLines,
|
||||
})
|
||||
await insertPostedEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId: period2026Id,
|
||||
entryDate: '2026-06-01',
|
||||
sourceType: 'import',
|
||||
description: 'Imported 2026 activity',
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 25, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 25 },
|
||||
],
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods
|
||||
SET opening_balance_entry_id = $1, opening_balances_set = true
|
||||
WHERE id = $2`,
|
||||
[old2026IBId, period2026Id],
|
||||
)
|
||||
|
||||
const supabase = makePgSupabase(userId)
|
||||
expect(await companyHasPriorActivity(supabase, companyId, '2025-12-31')).toBe(false)
|
||||
// Inclusive end-date filtering preserves the same-period continuation guard.
|
||||
expect(await companyHasPriorActivity(supabase, companyId, '2026-12-31')).toBe(true)
|
||||
|
||||
const period2025Id = await insertPeriod(
|
||||
companyId,
|
||||
'Räkenskapsår 2025',
|
||||
'2025-01-01',
|
||||
'2025-12-31',
|
||||
)
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods SET previous_period_id = $1 WHERE id = $2`,
|
||||
[period2025Id, period2026Id],
|
||||
)
|
||||
const ib2025Id = await insertPostedEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId: period2025Id,
|
||||
entryDate: '2025-01-01',
|
||||
sourceType: 'opening_balance',
|
||||
description: 'Imported 2025 IB',
|
||||
lines: oldIBLines,
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods
|
||||
SET opening_balance_entry_id = $1, opening_balances_set = true
|
||||
WHERE id = $2`,
|
||||
[ib2025Id, period2025Id],
|
||||
)
|
||||
|
||||
installAtomicReplacementMock()
|
||||
|
||||
const resync = await resyncNextPeriodOpeningBalance(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
'2025-12-31',
|
||||
closingBalances(),
|
||||
new Map([['1930', '1930'], ['2010', '2010']]),
|
||||
)
|
||||
|
||||
expect(resync.resynced).toBe(true)
|
||||
if (!resync.resynced) throw new Error(`Unexpected resync failure: ${resync.reason}`)
|
||||
|
||||
const periods = await getPool().query<{
|
||||
id: string
|
||||
previous_period_id: string | null
|
||||
opening_balance_entry_id: string | null
|
||||
}>(
|
||||
`SELECT id, previous_period_id, opening_balance_entry_id
|
||||
FROM public.fiscal_periods
|
||||
WHERE id = ANY($1::uuid[])
|
||||
ORDER BY period_start`,
|
||||
[[period2025Id, period2026Id]],
|
||||
)
|
||||
expect(periods.rows[0]).toMatchObject({
|
||||
id: period2025Id,
|
||||
opening_balance_entry_id: ib2025Id,
|
||||
})
|
||||
expect(periods.rows[1]).toMatchObject({
|
||||
id: period2026Id,
|
||||
previous_period_id: period2025Id,
|
||||
opening_balance_entry_id: resync.newOpeningBalanceEntryId,
|
||||
})
|
||||
|
||||
const replaced = await getPool().query<{
|
||||
id: string
|
||||
status: string
|
||||
source_type: string
|
||||
reverses_id: string | null
|
||||
}>(
|
||||
`SELECT id, status, source_type, reverses_id
|
||||
FROM public.journal_entries
|
||||
WHERE id = ANY($1::uuid[])`,
|
||||
[[old2026IBId, resync.stornoEntryId, resync.newOpeningBalanceEntryId]],
|
||||
)
|
||||
expect(replaced.rows).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: old2026IBId, status: 'reversed', source_type: 'opening_balance' }),
|
||||
expect.objectContaining({
|
||||
id: resync.stornoEntryId,
|
||||
status: 'posted',
|
||||
source_type: 'storno',
|
||||
reverses_id: old2026IBId,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: resync.newOpeningBalanceEntryId,
|
||||
status: 'posted',
|
||||
source_type: 'opening_balance',
|
||||
}),
|
||||
]))
|
||||
|
||||
const net = await getPool().query<{ account_number: string; amount: number }>(
|
||||
`SELECT l.account_number,
|
||||
SUM(l.debit_amount - l.credit_amount)::float8 AS amount
|
||||
FROM public.journal_entry_lines l
|
||||
JOIN public.journal_entries e ON e.id = l.journal_entry_id
|
||||
WHERE e.id = ANY($1::uuid[])
|
||||
AND e.status IN ('posted', 'reversed')
|
||||
GROUP BY l.account_number
|
||||
ORDER BY l.account_number`,
|
||||
[[old2026IBId, resync.stornoEntryId, resync.newOpeningBalanceEntryId]],
|
||||
)
|
||||
expect(net.rows).toEqual([
|
||||
{ account_number: '1930', amount: 150 },
|
||||
{ account_number: '2010', amount: -150 },
|
||||
])
|
||||
})
|
||||
|
||||
it('waits for a missing middle year before resyncing a later opening balance', async () => {
|
||||
const { userId, companyId, fiscalPeriodId: period2026Id } = await seedCompany()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.chart_of_accounts
|
||||
(user_id, company_id, account_number, account_name, account_class,
|
||||
account_type, normal_balance, is_active)
|
||||
VALUES
|
||||
($1, $2, '1930', 'Bankkonto', 1, 'asset', 'debit', true),
|
||||
($1, $2, '2010', 'Eget kapital', 2, 'equity', 'credit', true),
|
||||
($1, $2, '3001', 'Forsaljning', 3, 'revenue', 'credit', true)
|
||||
ON CONFLICT (company_id, account_number) DO NOTHING`,
|
||||
[userId, companyId],
|
||||
)
|
||||
const old2026IBId = await insertPostedEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId: period2026Id,
|
||||
entryDate: '2026-01-01',
|
||||
sourceType: 'opening_balance',
|
||||
description: 'Imported-first 2026 IB',
|
||||
lines: oldIBLines,
|
||||
})
|
||||
await insertPostedEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId: period2026Id,
|
||||
entryDate: '2026-06-01',
|
||||
sourceType: 'import',
|
||||
description: 'Imported 2026 activity',
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 25, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 25 },
|
||||
],
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods
|
||||
SET opening_balance_entry_id = $1, opening_balances_set = true
|
||||
WHERE id = $2`,
|
||||
[old2026IBId, period2026Id],
|
||||
)
|
||||
|
||||
const supabase = makePgSupabase(userId)
|
||||
expect(await companyHasPriorActivity(supabase, companyId, '2024-12-31')).toBe(false)
|
||||
|
||||
const period2024Id = await insertPeriod(
|
||||
companyId,
|
||||
'Räkenskapsår 2024',
|
||||
'2024-01-01',
|
||||
'2024-12-31',
|
||||
)
|
||||
const ib2024Id = await insertPostedEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId: period2024Id,
|
||||
entryDate: '2024-01-01',
|
||||
sourceType: 'opening_balance',
|
||||
description: 'Imported 2024 IB',
|
||||
lines: oldIBLines,
|
||||
})
|
||||
await insertPostedEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId: period2024Id,
|
||||
entryDate: '2024-06-01',
|
||||
sourceType: 'import',
|
||||
description: 'Imported 2024 activity',
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 50, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 50 },
|
||||
],
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods
|
||||
SET opening_balance_entry_id = $1, opening_balances_set = true
|
||||
WHERE id = $2`,
|
||||
[ib2024Id, period2024Id],
|
||||
)
|
||||
|
||||
installAtomicReplacementMock()
|
||||
const gapResync = await resyncNextPeriodOpeningBalance(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
'2024-12-31',
|
||||
closingBalances(150),
|
||||
new Map([['1930', '1930'], ['2010', '2010']]),
|
||||
)
|
||||
|
||||
expect(gapResync).toEqual({
|
||||
resynced: false,
|
||||
reason: 'next_period_not_adjacent',
|
||||
nextPeriodName: '2026',
|
||||
})
|
||||
expect(replaceOpeningBalanceEntry).not.toHaveBeenCalled()
|
||||
|
||||
const afterGap = await getPool().query<{ opening_balance_entry_id: string | null }>(
|
||||
`SELECT opening_balance_entry_id FROM public.fiscal_periods WHERE id = $1`,
|
||||
[period2026Id],
|
||||
)
|
||||
expect(afterGap.rows[0]!.opening_balance_entry_id).toBe(old2026IBId)
|
||||
|
||||
expect(await companyHasPriorActivity(supabase, companyId, '2025-12-31')).toBe(true)
|
||||
const period2025Id = await insertPeriod(
|
||||
companyId,
|
||||
'Räkenskapsår 2025',
|
||||
'2025-01-01',
|
||||
'2025-12-31',
|
||||
)
|
||||
await insertPostedEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId: period2025Id,
|
||||
entryDate: '2025-06-01',
|
||||
sourceType: 'import',
|
||||
description: 'Imported 2025 activity',
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 100, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 100 },
|
||||
],
|
||||
})
|
||||
|
||||
const adjacentResync = await resyncNextPeriodOpeningBalance(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
'2025-12-31',
|
||||
closingBalances(250),
|
||||
new Map([['1930', '1930'], ['2010', '2010']]),
|
||||
)
|
||||
|
||||
expect(adjacentResync.resynced).toBe(true)
|
||||
if (!adjacentResync.resynced) {
|
||||
throw new Error(`Unexpected resync failure: ${adjacentResync.reason}`)
|
||||
}
|
||||
|
||||
const periods = await getPool().query<{
|
||||
id: string
|
||||
opening_balance_entry_id: string | null
|
||||
}>(
|
||||
`SELECT id, opening_balance_entry_id
|
||||
FROM public.fiscal_periods
|
||||
WHERE id = ANY($1::uuid[])
|
||||
ORDER BY period_start`,
|
||||
[[period2024Id, period2025Id, period2026Id]],
|
||||
)
|
||||
expect(periods.rows).toEqual([
|
||||
{ id: period2024Id, opening_balance_entry_id: ib2024Id },
|
||||
{ id: period2025Id, opening_balance_entry_id: null },
|
||||
{ id: period2026Id, opening_balance_entry_id: adjacentResync.newOpeningBalanceEntryId },
|
||||
])
|
||||
|
||||
const effective2026IB = await getPool().query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count
|
||||
FROM public.journal_entries
|
||||
WHERE fiscal_period_id = $1
|
||||
AND source_type = 'opening_balance'
|
||||
AND status = 'posted'`,
|
||||
[period2026Id],
|
||||
)
|
||||
expect(effective2026IB.rows[0]!.count).toBe('1')
|
||||
|
||||
const net = await getPool().query<{ account_number: string; amount: number }>(
|
||||
`SELECT l.account_number,
|
||||
SUM(l.debit_amount - l.credit_amount)::float8 AS amount
|
||||
FROM public.journal_entry_lines l
|
||||
JOIN public.journal_entries e ON e.id = l.journal_entry_id
|
||||
WHERE e.id = ANY($1::uuid[])
|
||||
AND e.status IN ('posted', 'reversed')
|
||||
GROUP BY l.account_number
|
||||
ORDER BY l.account_number`,
|
||||
[[
|
||||
old2026IBId,
|
||||
adjacentResync.stornoEntryId,
|
||||
adjacentResync.newOpeningBalanceEntryId,
|
||||
]],
|
||||
)
|
||||
expect(net.rows).toEqual([
|
||||
{ account_number: '1930', amount: 250 },
|
||||
{ account_number: '2010', amount: -250 },
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a locked successor unchanged', async () => {
|
||||
const { userId, companyId, fiscalPeriodId: period2026Id } = await seedCompany()
|
||||
const old2026IBId = await insertPostedEntry({
|
||||
userId,
|
||||
companyId,
|
||||
fiscalPeriodId: period2026Id,
|
||||
entryDate: '2026-01-01',
|
||||
sourceType: 'opening_balance',
|
||||
description: 'Locked 2026 IB',
|
||||
lines: oldIBLines,
|
||||
})
|
||||
await getPool().query(
|
||||
`UPDATE public.fiscal_periods
|
||||
SET opening_balance_entry_id = $1,
|
||||
opening_balances_set = true,
|
||||
locked_at = now()
|
||||
WHERE id = $2`,
|
||||
[old2026IBId, period2026Id],
|
||||
)
|
||||
const before = await getPool().query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count FROM public.journal_entries WHERE company_id = $1`,
|
||||
[companyId],
|
||||
)
|
||||
|
||||
const result = await resyncNextPeriodOpeningBalance(
|
||||
makePgSupabase(userId),
|
||||
companyId,
|
||||
userId,
|
||||
'2025-12-31',
|
||||
closingBalances(),
|
||||
new Map([['1930', '1930'], ['2010', '2010']]),
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
resynced: false,
|
||||
reason: 'next_period_locked',
|
||||
nextPeriodName: '2026',
|
||||
})
|
||||
expect(replaceOpeningBalanceEntry).not.toHaveBeenCalled()
|
||||
|
||||
const after = await getPool().query<{
|
||||
opening_balance_entry_id: string | null
|
||||
count: string
|
||||
}>(
|
||||
`SELECT fp.opening_balance_entry_id,
|
||||
(SELECT count(*)::text FROM public.journal_entries WHERE company_id = $1) AS count
|
||||
FROM public.fiscal_periods fp
|
||||
WHERE fp.id = $2`,
|
||||
[companyId, period2026Id],
|
||||
)
|
||||
expect(after.rows[0]).toEqual({
|
||||
opening_balance_entry_id: old2026IBId,
|
||||
count: before.rows[0]!.count,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -565,6 +565,30 @@ describe('ensureFiscalPeriod validation', () => {
|
||||
).rejects.toThrow(/överlappar men matchar inte/)
|
||||
})
|
||||
|
||||
it('relinks the immediate successor when an earlier period is imported later', async () => {
|
||||
const { supabase, enqueueMany, findCalls } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: null, error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: [], error: null },
|
||||
{ data: { id: 'fp-2025' }, error: null },
|
||||
{ data: [{ id: 'fp-2026' }], error: null },
|
||||
{ data: null, error: null },
|
||||
])
|
||||
|
||||
const id = await ensureFiscalPeriod(
|
||||
supabase as unknown as Supabase,
|
||||
'company-id',
|
||||
'2025-01-01',
|
||||
'2025-12-31',
|
||||
)
|
||||
|
||||
expect(id).toBe('fp-2025')
|
||||
expect(findCalls('fiscal_periods', 'update')).toContainEqual([
|
||||
{ previous_period_id: 'fp-2025' },
|
||||
])
|
||||
})
|
||||
|
||||
// BFL 3 kap. caps any räkenskapsår at 18 months (12 is the norm; 18 is the
|
||||
// ceiling for a förlängt/omlagt year). #RAR used to be validated for start
|
||||
// and end DAY only, so a 24-month räkenskapsår from a foreign system
|
||||
@@ -841,6 +865,10 @@ describe('companyHasPriorActivity', () => {
|
||||
capturedFilters[`in:${col}`] = val
|
||||
return chain
|
||||
},
|
||||
lte: (col: string, val: unknown) => {
|
||||
capturedFilters[`lte:${col}`] = val
|
||||
return chain
|
||||
},
|
||||
then: (resolve: (v: { count: number; error: null }) => void) =>
|
||||
resolve({ count, error: null }),
|
||||
}
|
||||
@@ -853,7 +881,11 @@ describe('companyHasPriorActivity', () => {
|
||||
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')
|
||||
const result = await companyHasPriorActivity(
|
||||
supabase as unknown as Supabase,
|
||||
'company-1',
|
||||
'2025-12-31',
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
@@ -861,7 +893,11 @@ describe('companyHasPriorActivity', () => {
|
||||
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')
|
||||
const result = await companyHasPriorActivity(
|
||||
supabase as unknown as Supabase,
|
||||
'company-1',
|
||||
'2025-12-31',
|
||||
)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
@@ -869,11 +905,16 @@ describe('companyHasPriorActivity', () => {
|
||||
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')
|
||||
await companyHasPriorActivity(
|
||||
supabase as unknown as Supabase,
|
||||
'company-1',
|
||||
'2025-12-31',
|
||||
)
|
||||
|
||||
expect(capturedFilters['neq:source_type']).toEqual(['opening_balance', 'storno'])
|
||||
expect(capturedFilters['eq:status']).toBe('posted')
|
||||
expect(capturedFilters['eq:company_id']).toBe('company-1')
|
||||
expect(capturedFilters['lte:entry_date']).toBe('2025-12-31')
|
||||
})
|
||||
|
||||
it('treats null/undefined count as zero', async () => {
|
||||
@@ -884,8 +925,10 @@ describe('companyHasPriorActivity', () => {
|
||||
neq: () => ({
|
||||
neq: () => ({
|
||||
eq: () => ({
|
||||
then: (resolve: (v: { count: null; error: null }) => void) =>
|
||||
resolve({ count: null, error: null }),
|
||||
lte: () => ({
|
||||
then: (resolve: (v: { count: null; error: null }) => void) =>
|
||||
resolve({ count: null, error: null }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -894,7 +937,11 @@ describe('companyHasPriorActivity', () => {
|
||||
}),
|
||||
}
|
||||
|
||||
const result = await companyHasPriorActivity(supabase as unknown as Supabase, 'company-1')
|
||||
const result = await companyHasPriorActivity(
|
||||
supabase as unknown as Supabase,
|
||||
'company-1',
|
||||
'2025-12-31',
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
+50
-43
@@ -9,7 +9,7 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { normalizeLineDimensions } from '@/lib/bookkeeping/dimension-resolver'
|
||||
import { importDimensionRegistry } from './sie-dimensions'
|
||||
import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { createJournalEntry, replaceOpeningBalanceEntry } from '@/lib/bookkeeping/engine'
|
||||
import type {
|
||||
ParsedSIEFile,
|
||||
AccountMapping,
|
||||
@@ -735,9 +735,10 @@ async function createOpeningBalanceEntry(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns true when the company already has at least one posted non-IB
|
||||
* journal entry dated no later than the target fiscal period, i.e. this is a
|
||||
* continuation import rather than the first SIE upload for that point in the
|
||||
* company's chronology.
|
||||
*
|
||||
* 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
|
||||
@@ -746,7 +747,8 @@ async function createOpeningBalanceEntry(
|
||||
*/
|
||||
export async function companyHasPriorActivity(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string
|
||||
companyId: string,
|
||||
targetPeriodEnd: string,
|
||||
): Promise<boolean> {
|
||||
// Only count currently-effective real activity. Excluding 'reversed' drops
|
||||
// cancelled originals; excluding source_type 'storno' drops their matching
|
||||
@@ -760,6 +762,9 @@ export async function companyHasPriorActivity(
|
||||
.neq('source_type', 'opening_balance')
|
||||
.neq('source_type', 'storno')
|
||||
.eq('status', 'posted')
|
||||
// Keep same-period activity in the continuation guard, but ignore a
|
||||
// later year that happened to be imported first.
|
||||
.lte('entry_date', targetPeriodEnd)
|
||||
|
||||
return (count ?? 0) > 0
|
||||
}
|
||||
@@ -839,6 +844,21 @@ export async function resyncNextPeriodOpeningBalance(
|
||||
return { resynced: false, reason: 'no_next_period' }
|
||||
}
|
||||
|
||||
const importedPeriodEnd = new Date(`${justImportedPeriodEnd}T00:00:00Z`)
|
||||
importedPeriodEnd.setUTCDate(importedPeriodEnd.getUTCDate() + 1)
|
||||
const expectedNextPeriodStart = importedPeriodEnd.toISOString().slice(0, 10)
|
||||
|
||||
if (nextPeriod.period_start !== expectedNextPeriodStart) {
|
||||
// A later period separated by a gap has its own authoritative IB. It must
|
||||
// not be replaced with a non-adjacent year's UB while the middle year is
|
||||
// still missing.
|
||||
return {
|
||||
resynced: false,
|
||||
reason: 'next_period_not_adjacent',
|
||||
nextPeriodName: nextPeriod.name,
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextPeriod.opening_balance_entry_id) {
|
||||
// No existing IB on the next period: caller has nothing to resync; the
|
||||
// user's first IB for the next period will be derived from the import
|
||||
@@ -909,52 +929,26 @@ export async function resyncNextPeriodOpeningBalance(
|
||||
}
|
||||
}
|
||||
|
||||
// Ordering note: create the new IB FIRST, then storno the old one. If we
|
||||
// stornoed first and the createJournalEntry call failed, the next period
|
||||
// would be left with a reversed IB and nothing to replace it, and
|
||||
// executeSIEImport swallows our error as a non-fatal warning. By creating
|
||||
// first we guarantee the worst case is "new IB exists but not yet linked",
|
||||
// which getOpeningBalances() can still reason about.
|
||||
|
||||
// Build the new IB entry on the next period.
|
||||
const newEntry = await createJournalEntry(supabase, companyId, userId, {
|
||||
fiscal_period_id: nextPeriod.id,
|
||||
entry_date: nextPeriod.period_start as string,
|
||||
description: 'Ingående balanser (resynk efter prior-year SIE-import)',
|
||||
source_type: 'opening_balance',
|
||||
voucher_series: 'A',
|
||||
lines: newLines,
|
||||
})
|
||||
|
||||
// Atomically swap the period FK pointer (two-step around the
|
||||
// immutability trigger).
|
||||
const { error: relinkError } = await supabase.rpc('replace_period_opening_balance_link', {
|
||||
p_company_id: companyId,
|
||||
p_period_id: nextPeriod.id,
|
||||
p_new_entry_id: newEntry.id,
|
||||
})
|
||||
|
||||
if (relinkError) {
|
||||
throw new Error(`Failed to relink opening balance on next period: ${relinkError.message}`)
|
||||
}
|
||||
|
||||
// Now that the period points at the new IB, storno the old one. If this
|
||||
// throws, the period is already on the correct entry: the orphaned old
|
||||
// entry shows up as a stray verifikat but the FK stays consistent.
|
||||
const storno = await reverseEntry(
|
||||
const replacement = await replaceOpeningBalanceEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
nextPeriod.opening_balance_entry_id,
|
||||
nextPeriod.period_start as string,
|
||||
{
|
||||
fiscal_period_id: nextPeriod.id,
|
||||
entry_date: nextPeriod.period_start as string,
|
||||
description: 'Ingående balanser (resynk efter prior-year SIE-import)',
|
||||
source_type: 'opening_balance',
|
||||
lines: newLines,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
resynced: true,
|
||||
nextPeriodId: nextPeriod.id,
|
||||
nextPeriodName: nextPeriod.name,
|
||||
stornoEntryId: storno.id,
|
||||
newOpeningBalanceEntryId: newEntry.id,
|
||||
stornoEntryId: replacement.stornoEntryId,
|
||||
newOpeningBalanceEntryId: replacement.newEntryId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2199,7 +2193,11 @@ export async function executeSIEImport(
|
||||
// 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)
|
||||
const isContinuationImport = await companyHasPriorActivity(
|
||||
supabase,
|
||||
companyId,
|
||||
fiscalYearEnd,
|
||||
)
|
||||
|
||||
if (isContinuationImport) {
|
||||
result.warnings.push(
|
||||
@@ -2464,7 +2462,16 @@ export async function executeSIEImport(
|
||||
// exists with its own opening_balance entry, the customer is doing a
|
||||
// prior-year backfill. Sync the next period's IB to match the UB we
|
||||
// just imported so reports stay consistent.
|
||||
if (result.success && fiscalYearEnd && result.fiscalPeriodId && parsed.closingBalances.length > 0) {
|
||||
// result.success is finalized below, after diagnostics and documentation.
|
||||
// Use the same error condition here so this block is reachable, but require
|
||||
// a target-period entry so a no-op file cannot succeed through resync alone.
|
||||
if (
|
||||
result.errors.length === 0 &&
|
||||
result.journalEntriesCreated > 0 &&
|
||||
fiscalYearEnd &&
|
||||
result.fiscalPeriodId &&
|
||||
parsed.closingBalances.length > 0
|
||||
) {
|
||||
try {
|
||||
const resync = await resyncNextPeriodOpeningBalance(
|
||||
supabase,
|
||||
|
||||
Reference in New Issue
Block a user