Bug/year end failure (#575)

* feat: implement findNextPeriod function and integrate into year-end closing logic

* feat: add integrity check for PDF documents and enhance user feedback for corrupt files

* Refactor year-end service and period creation logic for improved UTC handling and error messaging

- Update `validateYearEndReadiness` to assert on stable warning messages without interpolating period names.
- Modify `createNextPeriod` to ensure date calculations are performed in UTC, preventing DST-related issues.
- Enhance error handling in `validateYearEndReadiness` and `executeYearEndClosing` to avoid exposing database details.
- Introduce structured error messages for year-end processes in `structured-errors.ts`.
- Add tests for document integrity checks, ensuring proper authentication and error handling.
- Implement GUC checks in document versioning to prevent unauthorized modifications and ensure company membership.
- Update migration scripts to reflect changes in document immutability enforcement.

* fix: add comment to clarify GUC behavior in document supersession logic
This commit is contained in:
Mattsson
2026-05-27 14:19:30 +02:00
committed by GitHub
parent a2a556d837
commit 8979f6eda3
14 changed files with 1214 additions and 70 deletions
@@ -29,7 +29,7 @@ function makeClient() {
}
}
import { lockPeriod, unlockPeriod, closePeriod, createNextPeriod } from '../period-service'
import { lockPeriod, unlockPeriod, closePeriod, createNextPeriod, findNextPeriod } from '../period-service'
beforeEach(() => {
vi.clearAllMocks()
@@ -230,3 +230,78 @@ describe('createNextPeriod', () => {
expect(result.period_end).toBe('2025-06-30')
})
})
describe('findNextPeriod', () => {
it('returns the period chained via previous_period_id', async () => {
const current = makeFiscalPeriod({
id: 'fp-2024',
period_start: '2024-01-01',
period_end: '2024-12-31',
})
const next = makeFiscalPeriod({
id: 'fp-2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
previous_period_id: 'fp-2024',
})
results = [
{ data: current, error: null }, // fetch current
{ data: next, error: null }, // chained lookup (.maybeSingle)
]
const supabase = makeClient()
const result = await findNextPeriod(supabase as never, 'company-1', 'fp-2024')
expect(result?.id).toBe('fp-2025')
})
it('falls back to period_start lookup when chain is missing', async () => {
const current = makeFiscalPeriod({
id: 'fp-2024',
period_start: '2024-01-01',
period_end: '2024-12-31',
})
const next = makeFiscalPeriod({
id: 'fp-2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
previous_period_id: null,
})
results = [
{ data: current, error: null }, // fetch current
{ data: null, error: null }, // chained lookup misses
{ data: next, error: null }, // date lookup hits
]
const supabase = makeClient()
const result = await findNextPeriod(supabase as never, 'company-1', 'fp-2024')
expect(result?.id).toBe('fp-2025')
})
it('returns null when no next period exists', async () => {
const current = makeFiscalPeriod({
id: 'fp-2024',
period_start: '2024-01-01',
period_end: '2024-12-31',
})
results = [
{ data: current, error: null },
{ data: null, error: null },
{ data: null, error: null },
]
const supabase = makeClient()
const result = await findNextPeriod(supabase as never, 'company-1', 'fp-2024')
expect(result).toBeNull()
})
it('returns null when current period not found', async () => {
results = [{ data: null, error: { message: 'not found' } }]
const supabase = makeClient()
const result = await findNextPeriod(supabase as never, 'company-1', 'missing')
expect(result).toBeNull()
})
})
@@ -56,11 +56,13 @@ vi.mock('../period-service', () => ({
lockPeriod: vi.fn(),
closePeriod: vi.fn(),
createNextPeriod: vi.fn(),
findNextPeriod: vi.fn().mockResolvedValue(null),
}))
import { validateYearEndReadiness, previewYearEndClosing } from '../year-end-service'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { findNextPeriod } from '../period-service'
beforeEach(() => {
vi.clearAllMocks()
@@ -345,6 +347,55 @@ describe('validateYearEndReadiness', () => {
expect(result.warnings.some((w: string) => w.includes('Sequence counter ahead'))).toBe(true)
expect(result.sequenceMismatches).toHaveLength(1)
})
it('warns (not errors) when next period already exists without IB', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
results = noGapResults(period)
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [],
isBalanced: true,
totalDebit: 10000,
totalCredit: 10000,
} as never)
vi.mocked(findNextPeriod).mockResolvedValueOnce({
id: 'fp-2',
name: 'FY 2025',
opening_balance_entry_id: null,
} as never)
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(true)
// Period name intentionally not interpolated into the warning — see
// year-end-service for rationale. We assert on the stable English
// substring instead.
expect(result.warnings.some((w: string) => w.includes('Next fiscal period already exists'))).toBe(true)
})
it('blocks when next period already has opening balances posted', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
results = noGapResults(period)
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [],
isBalanced: true,
totalDebit: 10000,
totalCredit: 10000,
} as never)
vi.mocked(findNextPeriod).mockResolvedValueOnce({
id: 'fp-2',
name: 'FY 2025',
opening_balance_entry_id: 'ib-1',
} as never)
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(false)
expect(result.errors.some((e: string) => e.includes('already has opening balances'))).toBe(true)
})
})
describe('previewYearEndClosing', () => {
+75 -14
View File
@@ -215,20 +215,22 @@ export async function createNextPeriod(
throw new Error('Current fiscal period not found')
}
// Compute next period start (day after current end)
const nextStart = new Date(current.period_end)
nextStart.setDate(nextStart.getDate() + 1)
// Compute next period start (day after current end) in pure UTC — see
// findNextPeriod for the DST off-by-one rationale.
const nextStart = new Date(current.period_end + 'T00:00:00Z')
nextStart.setUTCDate(nextStart.getUTCDate() + 1)
// After a broken first fiscal year, subsequent years should always be
// 12 months (standard fiscal year). The first year is the only one that
// can be longer/shorter than 12 months per BFL 3 kap.
const nextEnd = new Date(nextStart)
nextEnd.setMonth(nextEnd.getMonth() + 12)
// Go to last day of that month
nextEnd.setDate(0)
nextEnd.setUTCMonth(nextEnd.getUTCMonth() + 12)
// Go to last day of the previous month — setUTCDate(0) rolls back into
// the prior month's last day.
nextEnd.setUTCDate(0)
const nextStartStr = nextStart.toISOString().split('T')[0]
const nextEndStr = nextEnd.toISOString().split('T')[0]
const nextStartStr = nextStart.toISOString().slice(0, 10)
const nextEndStr = nextEnd.toISOString().slice(0, 10)
// Validate period duration — subsequent periods always start on 1st of month
const durationError = validatePeriodDuration(nextStartStr, nextEndStr, { isFirstPeriod: false })
@@ -250,8 +252,8 @@ export async function createNextPeriod(
}
// Generate name: e.g. "FY 2025" or "FY 2025/2026"
const startYear = nextStart.getFullYear()
const endYear = nextEnd.getFullYear()
const startYear = nextStart.getUTCFullYear()
const endYear = nextEnd.getUTCFullYear()
const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}`
const { data: newPeriod, error: insertError } = await supabase
@@ -274,6 +276,68 @@ export async function createNextPeriod(
return newPeriod as FiscalPeriod
}
/**
* Look up the next fiscal period after the given one without creating it.
*
* Used by year-end closing to handle the common case where the next period
* was already created (e.g. by SIE import, manual creation, or a previous
* partial year-end run). Returns null when no such period exists.
*
* Matches first on previous_period_id chain, then falls back to a
* period_start = (current.period_end + 1 day) lookup so periods created
* before the chain was wired up are still recognised.
*/
export async function findNextPeriod(
supabase: SupabaseClient,
companyId: string,
currentPeriodId: string
): Promise<FiscalPeriod | null> {
const { data: current, error: fetchError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', currentPeriodId)
.eq('company_id', companyId)
.single()
if (fetchError || !current) {
return null
}
const { data: chained } = await supabase
.from('fiscal_periods')
.select('*')
.eq('company_id', companyId)
.eq('previous_period_id', currentPeriodId)
.maybeSingle()
if (chained) {
return chained as FiscalPeriod
}
// UTC-only arithmetic: anchor the date string at UTC midnight, then
// advance via setUTCDate. Using Date(string) + setDate/getDate causes an
// off-by-one on servers in TZ+ when the day after period_end crosses a
// DST spring-forward, because setDate(local) writes local-time fields
// and toISOString() converts back through the shifted offset.
const expectedStartStr = addDaysUTC(current.period_end, 1)
const { data: byDate } = await supabase
.from('fiscal_periods')
.select('*')
.eq('company_id', companyId)
.eq('period_start', expectedStartStr)
.maybeSingle()
return (byDate as FiscalPeriod | null) ?? null
}
/** Add `days` to a YYYY-MM-DD string in pure UTC and return YYYY-MM-DD. */
function addDaysUTC(isoDate: string, days: number): string {
const d = new Date(isoDate + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
/**
* Create a previous fiscal period before the given one.
* Computes a 12-month period ending the day before the given period starts.
@@ -466,10 +530,7 @@ export async function getPeriodStatus(
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'draft')
// Check if next period exists
const nextStart = new Date(period.period_end)
nextStart.setDate(nextStart.getDate() + 1)
// Check if next period exists via the chain pointer
const { data: nextPeriod } = await supabase
.from('fiscal_periods')
.select('id')
+56 -12
View File
@@ -7,7 +7,7 @@ import { createLogger } from '@/lib/logger'
const log = createLogger('year-end-service')
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { lockPeriod, closePeriod, createNextPeriod } from './period-service'
import { lockPeriod, closePeriod, createNextPeriod, findNextPeriod } from './period-service'
import {
previewCurrencyRevaluation,
executeCurrencyRevaluation,
@@ -253,6 +253,25 @@ export async function validateYearEndReadiness(
errors.push('Opening balance continuity check failed for this period — resolve discrepancies before closing')
}
// Check: next period state. A pre-existing next period (from SIE import,
// manual creation, or a prior partial run) is fine — we'll reuse it — but
// one with opening balances already booked blocks closing because we
// can't post a second IB on top.
//
// The period name is not interpolated into the message — although the
// name is user-supplied at create time and confined to the company,
// surfacing DB-sourced strings through error paths is the kind of
// injection footgun we'd rather close at the source than rely on the UI
// to escape (text rendering and aria-label propagation differ).
const nextPeriod = await findNextPeriod(supabase, companyId, fiscalPeriodId)
if (nextPeriod) {
if (nextPeriod.opening_balance_entry_id) {
errors.push('Next fiscal period already has opening balances posted')
} else {
warnings.push('Next fiscal period already exists — opening balances will be booked into it')
}
}
return {
ready: errors.length === 0,
errors,
@@ -397,12 +416,15 @@ export async function previewYearEndClosing(
* Execute year-end closing for a fiscal period.
*
* 1. Validate readiness
* 2. Create closing entry (zeros class 3-8 accounts)
* 3. Set closing_entry_id on the period
* 4. Lock the period
* 5. Close the period
* 6. Create next fiscal period
* 7. Generate opening balances in next period
* 2. Run currency revaluation (FX gains/losses to 3960/7960)
* 3. Generate closing preview and check öre balance
* 4. Create closing entry (zeros class 3-8 accounts)
* 5. Set closing_entry_id on the period
* 6. Resolve next fiscal period (reuse existing or create new)
* 7. Lock the period
* 8. Close the period (irreversible — every guard must run before this)
* 9. Generate opening balances in next period
* 10. Validate IB/UB continuity
*/
export async function executeYearEndClosing(
supabase: SupabaseClient,
@@ -509,15 +531,37 @@ export async function executeYearEndClosing(
throw new Error(`Failed to set closing_entry_id: ${updateError.message}`)
}
// 6. Lock the period
// 6. Resolve the next period BEFORE locking/closing this one. A pre-existing
// next period is common (SIE import, manual creation, prior partial
// year-end run); reusing it is fine as long as no IB has been booked
// into it. Doing this check after closePeriod would leave the books in
// a half-closed state if a concurrent process posted IB into the next
// period between validateYearEndReadiness and step 8 (TOCTOU race).
//
// The thrown error is intentionally a stable English string with no
// DB-sourced data interpolated — the route layer maps it to a
// structured error code, and the next period name (if any) is surfaced
// only through the structured details payload after explicit checks.
const existingNextPeriod = await findNextPeriod(supabase, companyId, fiscalPeriodId)
let nextPeriod
if (existingNextPeriod) {
if (existingNextPeriod.opening_balance_entry_id) {
throw new Error(
'Next fiscal period already has opening balance entry posted; reverse it before re-running year-end'
)
}
nextPeriod = existingNextPeriod
} else {
nextPeriod = await createNextPeriod(supabase, companyId, userId, fiscalPeriodId)
}
// 7. Lock the period
await lockPeriod(supabase, companyId, userId, fiscalPeriodId)
// 7. Close the period
// 8. Close the period — irreversible per BFL. Every guard that can fail
// on prior state must run before this point.
await closePeriod(supabase, companyId, userId, fiscalPeriodId)
// 8. Create next period
const nextPeriod = await createNextPeriod(supabase, companyId, userId, fiscalPeriodId)
// 9. Generate opening balances in next period
const openingBalanceEntry = await generateOpeningBalances(
supabase,
@@ -1,8 +1,11 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { getPool, withUserContext } from '@/tests/pg/setup'
import {
insertAuthUser,
insertBalancedLines,
insertCompany,
insertCompanyMember,
insertDraftJournalEntry,
seedCompany,
} from '@/tests/pg/fixtures'
@@ -231,3 +234,266 @@ describe('document-immutability.pg — BFL retention bypass guards', () => {
}
})
})
// The supersession flow needs to flip the OLD row from is_current_version=true
// to false. The metadata-immutability trigger blocks that change for docs
// linked to posted entries unless the gnubok.allow_supersede GUC is set —
// which create_document_version sets before its UPDATE. Without this, users
// have no way to replace a corrupt underlag (e.g. a bad PDF uploaded via the
// MCP server before magic-byte validation).
describe('document-immutability.pg — version supersession on posted entries', () => {
it('allows create_document_version on a doc linked to a posted entry', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
// create_document_version is SECURITY DEFINER but enforces auth.uid()
// matches p_user_id and that the caller is a company member, so the
// call must run under withUserContext(userId).
const newId = await withUserContext(userId, async (client) => {
const result = await client.query<{ new_id: string }>(
`SELECT public.create_document_version(
$1::uuid, $2::uuid, $3::text, $4::text, $5::bigint, $6::text, $7::text
) AS new_id`,
[
userId,
docId,
`documents/${userId}/replacement.pdf`,
'replacement.pdf',
2048,
'application/pdf',
'b'.repeat(64),
],
)
const id = result.rows[0]!.new_id
// The transaction is rolled back by withUserContext, so we have to
// assert state from within the same client/transaction.
const oldRow = await client.query<{
is_current_version: boolean
superseded_by_id: string | null
journal_entry_id: string | null
}>(
`SELECT is_current_version, superseded_by_id, journal_entry_id
FROM public.document_attachments WHERE id = $1`,
[docId],
)
expect(oldRow.rows[0]!.is_current_version).toBe(false)
expect(oldRow.rows[0]!.superseded_by_id).toBe(id)
expect(oldRow.rows[0]!.journal_entry_id).toBe(entryId)
const newRow = await client.query<{
is_current_version: boolean
version: number
journal_entry_id: string | null
prev_version_hash: string | null
}>(
`SELECT is_current_version, version, journal_entry_id, prev_version_hash
FROM public.document_attachments WHERE id = $1`,
[id],
)
expect(newRow.rows[0]!.is_current_version).toBe(true)
expect(newRow.rows[0]!.version).toBe(2)
expect(newRow.rows[0]!.journal_entry_id).toBe(entryId)
expect(newRow.rows[0]!.prev_version_hash).toBe('a'.repeat(64))
return id
})
expect(newId).toBeDefined()
})
it('allows create_document_version on a doc linked to a reversed entry', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1, status: 'reversed',
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
await withUserContext(userId, async (client) => {
const result = await client.query(
`SELECT public.create_document_version(
$1::uuid, $2::uuid, $3::text, $4::text, $5::bigint, $6::text, $7::text
)`,
[
userId, docId,
`documents/${userId}/replacement.pdf`,
'replacement.pdf', 2048, 'application/pdf', 'c'.repeat(64),
],
)
expect(result.rows).toHaveLength(1)
})
})
// Cross-tenant attack: a member of company A calls create_document_version
// with a document id that belongs to company B. Before the auth+membership
// check landed, the SECURITY DEFINER function would happily mutate the
// foreign company's row because the GUC bypass disarmed the immutability
// trigger. The membership guard inside the function is the only line of
// defence — PostgREST exposes the RPC to all authenticated users.
it('rejects create_document_version when caller is not a member of the document company', async () => {
const { userId: aliceId, companyId: aliceCompanyId, fiscalPeriodId: aliceFp } =
await seedCompany()
const entryId = await insertEntryAtStatus({
userId: aliceId,
companyId: aliceCompanyId,
fiscalPeriodId: aliceFp,
voucherNumber: 1,
})
const docId = await insertDocument({
userId: aliceId,
companyId: aliceCompanyId,
journalEntryId: entryId,
})
// Bob has his own company; he is NOT a member of Alice's company.
const bobId = await insertAuthUser()
const bobCompany = await insertCompany({ createdBy: bobId })
await insertCompanyMember({ companyId: bobCompany, userId: bobId, role: 'owner' })
await withUserContext(bobId, async (client) => {
await expect(
client.query(
`SELECT public.create_document_version(
$1::uuid, $2::uuid, $3::text, $4::text, $5::bigint, $6::text, $7::text
)`,
[
bobId, docId,
`documents/${bobId}/exfil.pdf`,
'exfil.pdf', 2048, 'application/pdf', 'd'.repeat(64),
],
),
).rejects.toThrow(/not a member/i)
})
})
// Identity-spoofing attack: caller passes p_user_id ≠ auth.uid(). Allowing
// this would let any authenticated user manufacture supersession events
// attributed to a different user — useful for audit-log forgery even when
// the supersession itself is legitimate.
it('rejects create_document_version when p_user_id does not match auth.uid()', async () => {
const { userId: aliceId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId: aliceId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({
userId: aliceId, companyId, journalEntryId: entryId,
})
// Mallory is also a member of the company — so the membership check
// alone would not catch a spoofed p_user_id.
const malloryId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: malloryId, role: 'member' })
await withUserContext(malloryId, async (client) => {
await expect(
client.query(
`SELECT public.create_document_version(
$1::uuid, $2::uuid, $3::text, $4::text, $5::bigint, $6::text, $7::text
)`,
[
// Spoofing aliceId as the actor while auth.uid() is malloryId.
aliceId, docId,
`documents/${aliceId}/spoofed.pdf`,
'spoofed.pdf', 2048, 'application/pdf', 'e'.repeat(64),
],
),
).rejects.toThrow(/does not match authenticated user/i)
})
})
// The narrowed bypass: even with allow_supersede set, a direct UPDATE that
// tries to change sha256_hash, journal_entry_id, or any other audit field
// must still be blocked. Before the narrowing the GUC was a blanket
// bypass that disarmed the entire trigger.
it('keeps blocking sha256_hash mutation even when allow_supersede is set', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('gnubok.allow_supersede', 'true', true)`)
await expect(
client.query(
`UPDATE public.document_attachments SET sha256_hash = $2 WHERE id = $1`,
[docId, 'f'.repeat(64)],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
await client.query('ROLLBACK')
} finally {
client.release()
}
})
it('keeps blocking journal_entry_id mutation even when allow_supersede is set', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryA = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const entryB = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 2,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryA })
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('gnubok.allow_supersede', 'true', true)`)
await expect(
client.query(
`UPDATE public.document_attachments SET journal_entry_id = $1 WHERE id = $2`,
[entryB, docId],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
await client.query('ROLLBACK')
} finally {
client.release()
}
})
it('rejects direct UPDATE flipping is_current_version without the GUC', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
await expect(
getPool().query(
`UPDATE public.document_attachments SET is_current_version = false WHERE id = $1`,
[docId],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
})
it('respects gnubok.allow_supersede bypass on direct UPDATE', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('gnubok.allow_supersede', 'true', true)`)
await client.query(
`UPDATE public.document_attachments SET is_current_version = false WHERE id = $1`,
[docId],
)
const after = await client.query<{ is_current_version: boolean }>(
`SELECT is_current_version FROM public.document_attachments WHERE id = $1`,
[docId],
)
expect(after.rows[0]!.is_current_version).toBe(false)
await client.query('ROLLBACK')
} finally {
client.release()
}
})
})
+5
View File
@@ -770,6 +770,11 @@ const YEAR_END: Record<string, StructuredErrorEntry> = {
message_sv: 'Resultaträkningens debet och kredit balanserar inte. Granska verifikationerna innan bokslut.',
message_en: 'Trial balance does not balance.',
},
YEAR_END_NEXT_PERIOD_HAS_IB: {
httpStatus: 400,
message_sv: 'Nästa räkenskapsperiod har redan ingående balanser bokförda. Storno dem innan du kör om bokslutet.',
message_en: 'Next fiscal period already has opening balances posted; reverse them before re-running year-end.',
},
}
const OPENING_BAL: Record<string, StructuredErrorEntry> = {