2814d70cb4
* feat(bookkeeping): cascade opening-balance corrections to later years Fortnox/SIE migrations book one IB verifikat per imported year, so correcting one year's ingaende balans left every later year's linked IB carrying the stale figures (support case: a 2019 IB fixed in Fortnox after export never reached Accounted, skewing all subsequent saldon). - POST /api/import/opening-balance/correct accepts cascade: true and applies the correction's per-account delta to each subsequent year's IB via storno + rebook + relink (lib/import/opening-balance/cascade.ts). Locked/closed/lock-dated/bokslut years are skipped and reported, never forced; a failed year is compensated and the cascade continues. - CorrectOpeningBalanceDialog offers the cascade as a default-checked checkbox when later years have their own IB verifikat, and when the current year is blocked it points at the earliest open year's IB verifikat instead of dead-ending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(bookkeeping): atomic cascade replacement + review findings for PR #2076 - Cascade now books each later year through replaceOpeningBalanceEntry (one RPC transaction: storno + corrected voucher + pointer swap, CAS on the expected old entry), removing the create/reverse/relink window that could leave a period linked to a reversed IB entry. - Cascaded verifikat keep the original lines verbatim (descriptions and dimensions) and append labelled IB-rättelse adjustment lines per changed account instead of collapsing per-account nets. - Year-end lookup fails closed: a query error skips the period instead of reading as 'no bokslut'. - Dialog always sends the cascade flag (a cold reference cache no longer silently disables the default-on cascade), the success toast separates blocked years from failed years needing review, and the checkbox notes that a resultat correction may still need an omforing to 2091. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * feat(bookkeeping): Fortnox-style inline IB correction without storno Founder decision 2026-08-31: IB edits in open unlocked years should feel like Fortnox (change the number, no extra verifikat) instead of always producing a storno + rebook pair in serie A. - Migration 20260831150000 redefines correct_entry_lines_inline to admit source_type 'opening_balance' with three IB guards: only the period's current linked IB, no posted bokslut on the period, and replacement lines restricted to balance-sheet accounts (class 1-2). The entry id never changes, so fiscal_periods.opening_balance_entry_id stays valid and every report reads the corrected lines automatically. Storno, year_end and vat_settlement stay excluded; locked/closed/lock-dated periods are still refused (BFL 5 kap 5 par: storno is the only track there). - New POST /api/import/opening-balance/correct-inline: diff-based strike and replace inside the same IB verifikat, same OB_* pre-flight codes as the storno route, RPC rule violations surfaced verbatim as 409 OB_INLINE_REFUSED. With cascade: true the per-account delta is appended as labelled IB-rattelse lines inside each later open year's own IB verifikat (cascade mode 'inline'): a multi-year correction with zero new verifikat. - CorrectOpeningBalanceDialog computes the row diff (untouched lines keep ids, descriptions and dimensions) and posts to the inline route; copy updated (no storno language), toast reports inline updates. - In-app agent guidance (shared-rules) updated to describe the inline flow and the cascade checkbox. - Tests: pg-real suite for the redefined RPC (IB accept, linked-IB guard, bokslut guard, P&L guard, structural types still refused, non-IB unaffected), route tests, cascade inline-mode unit tests. The storno-based /correct route and engine paths are untouched: they remain for the import replace flow and API compatibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(agent): avoid the BFL 5 kap 5 par marker string in IB guidance The verifikation-draft period-lock gate test uses the literal 'BFL 5 kap 5 §' as a marker for locked-period-only guidance; the new IB bullet in shared-rules carried the same string in every prompt and broke the open-period assertion. Reference Bokföringslagen generically instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(bookkeeping): derive inline cascade delta from the rattelse log Swedish-review finding on PR #2076: the cascade delta was computed from a route-side line snapshot read before the RPC, which a concurrent edit could theoretically desync from what the RPC actually committed. The delta now comes from the RPC's own journal_entry_rattelse_log row (struck_lines/added_lines snapshotted inside the RPC transaction), so the cascade always matches the committed base correction. Also softened the blocked-year guidance copy (declared-status is an assumption, not a verified fact). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(bookkeeping): visible cascade failure + dimensions-aware no-op check CodeRabbit round-2 findings on PR #2076: - A cascade that failed to run (log fetch error, unexpected throw) was returned as an empty successful summary, so the dialog reported nothing wrong while later years stayed unverified. Both routes now mark it failed: true and the dialog tells the user to check later years' opening balances. - The RPC's no-op guard compared account/amount/description only, so a dimensions-only rattelse raised 'Rattelsen andrar ingenting'. The comparison keys now include canonical dimensions jsonb text (fixed in the unmerged 20260831150000 migration). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
225 lines
9.6 KiB
TypeScript
225 lines
9.6 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { getPool } from '@/tests/pg/setup'
|
|
import { seedCompany, insertDraftJournalEntry } from '@/tests/pg/fixtures'
|
|
|
|
// Migration 20260831150000_inline_rattelse_opening_balance.sql: inline
|
|
// rättelse (BFL 5 kap 5 § track 2) now admits opening-balance verifikat, so
|
|
// IB edits in open unlocked years work like Fortnox: the number changes in
|
|
// place, no storno, the original lines survive in journal_entry_rattelse_log.
|
|
//
|
|
// The suite:
|
|
// 1. the period's linked IB accepts strike+replace, lines update in place,
|
|
// the period link never moves, a log row is written
|
|
// 2. an IB that is NOT the period's current linked entry is refused
|
|
// 3. a posted bokslut (year_end) on the period refuses the rättelse
|
|
// 4. P&L accounts (class 3-8) are refused in IB replacement lines
|
|
// 5. storno / year_end / vat_settlement stay refused (unchanged)
|
|
// 6. non-IB entries still accept P&L accounts (guard is IB-scoped)
|
|
|
|
async function insertPostedIb(params: {
|
|
companyId: string
|
|
userId: string
|
|
fiscalPeriodId: string
|
|
voucherNumber?: number
|
|
link?: boolean
|
|
sourceType?: string
|
|
}): Promise<{ entryId: string; bankLineId: string; equityLineId: string }> {
|
|
const entryId = await insertDraftJournalEntry({
|
|
userId: params.userId,
|
|
companyId: params.companyId,
|
|
fiscalPeriodId: params.fiscalPeriodId,
|
|
sourceType: params.sourceType ?? 'opening_balance',
|
|
status: 'draft',
|
|
voucherNumber: params.voucherNumber ?? 1,
|
|
})
|
|
const { rows: bank } = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.journal_entry_lines
|
|
(journal_entry_id, account_number, debit_amount, credit_amount, sort_order, line_description)
|
|
VALUES ($1, '1930', 50000, 0, 1, 'IB 1930')
|
|
RETURNING id`,
|
|
[entryId],
|
|
)
|
|
const { rows: equity } = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.journal_entry_lines
|
|
(journal_entry_id, account_number, debit_amount, credit_amount, sort_order, line_description)
|
|
VALUES ($1, '2091', 0, 50000, 2, 'IB 2091')
|
|
RETURNING id`,
|
|
[entryId],
|
|
)
|
|
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [entryId])
|
|
if (params.link !== false) {
|
|
await getPool().query(
|
|
`UPDATE public.fiscal_periods
|
|
SET opening_balance_entry_id = $2, opening_balances_set = true
|
|
WHERE id = $1`,
|
|
[params.fiscalPeriodId, entryId],
|
|
)
|
|
}
|
|
return { entryId, bankLineId: bank[0].id, equityLineId: equity[0].id }
|
|
}
|
|
|
|
async function insertChartAccount(companyId: string, userId: string, accountNumber: string): Promise<void> {
|
|
await getPool().query(
|
|
`INSERT INTO public.chart_of_accounts
|
|
(user_id, company_id, account_number, account_name, account_class, account_type, normal_balance)
|
|
VALUES ($1, $2, $3, 'Testkonto ' || $3, left($3, 1)::int, 'asset', 'debit')
|
|
ON CONFLICT DO NOTHING`,
|
|
[userId, companyId, accountNumber],
|
|
)
|
|
}
|
|
|
|
async function callStrike(
|
|
companyId: string,
|
|
entryId: string,
|
|
strikeIds: string[],
|
|
newLines: unknown[],
|
|
actor: string,
|
|
) {
|
|
return getPool().query<{ result: { struck_count: number; added_count: number; log_id: string } }>(
|
|
`SELECT public.correct_entry_lines_inline($1::uuid, $2::uuid, $3::uuid[], $4::jsonb, $5::uuid) AS result`,
|
|
[companyId, entryId, strikeIds, JSON.stringify(newLines), actor],
|
|
)
|
|
}
|
|
|
|
describe('inline rättelse: opening-balance verifikat', () => {
|
|
it('edits the linked IB in place: lines replaced, link untouched, rättelse logged', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
await insertChartAccount(companyId, userId, '1930')
|
|
await insertChartAccount(companyId, userId, '2091')
|
|
const { entryId, bankLineId } = await insertPostedIb({ companyId, userId, fiscalPeriodId })
|
|
|
|
const res = await callStrike(
|
|
companyId,
|
|
entryId,
|
|
[bankLineId],
|
|
[
|
|
{ account_number: '1930', debit_amount: 55000, credit_amount: 0, line_description: 'IB 1930' },
|
|
{ account_number: '2091', debit_amount: 0, credit_amount: 5000, line_description: 'IB-rättelse 2091' },
|
|
],
|
|
userId,
|
|
)
|
|
expect(res.rows[0].result.struck_count).toBe(1)
|
|
expect(res.rows[0].result.added_count).toBe(2)
|
|
expect(res.rows[0].result.log_id).toBeTruthy()
|
|
|
|
// Same entry id, still posted, still the period's linked IB.
|
|
const { rows: period } = await getPool().query(
|
|
`SELECT opening_balance_entry_id FROM public.fiscal_periods WHERE id = $1`,
|
|
[fiscalPeriodId],
|
|
)
|
|
expect(period[0].opening_balance_entry_id).toBe(entryId)
|
|
|
|
const { rows: lines } = await getPool().query(
|
|
`SELECT account_number, debit_amount::numeric, credit_amount::numeric
|
|
FROM public.journal_entry_lines WHERE journal_entry_id = $1 ORDER BY sort_order`,
|
|
[entryId],
|
|
)
|
|
expect(lines).toHaveLength(3)
|
|
const net1930 = lines
|
|
.filter((l) => l.account_number === '1930')
|
|
.reduce((s, l) => s + Number(l.debit_amount) - Number(l.credit_amount), 0)
|
|
expect(net1930).toBe(55000)
|
|
|
|
const { rows: log } = await getPool().query(
|
|
`SELECT rattelse_type, struck_lines FROM public.journal_entry_rattelse_log WHERE journal_entry_id = $1`,
|
|
[entryId],
|
|
)
|
|
expect(log).toHaveLength(1)
|
|
expect(log[0].rattelse_type).toBe('lines')
|
|
expect(JSON.stringify(log[0].struck_lines)).toContain('1930')
|
|
})
|
|
|
|
it('refuses an IB entry that is not the period\'s current linked opening balance', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
await insertChartAccount(companyId, userId, '1930')
|
|
await insertChartAccount(companyId, userId, '2091')
|
|
const { entryId, bankLineId } = await insertPostedIb({
|
|
companyId, userId, fiscalPeriodId, link: false,
|
|
})
|
|
|
|
await expect(
|
|
callStrike(companyId, entryId, [bankLineId], [
|
|
{ account_number: '1930', debit_amount: 55000, credit_amount: 0 },
|
|
{ account_number: '2091', debit_amount: 0, credit_amount: 5000 },
|
|
], userId),
|
|
).rejects.toThrow(/inte periodens aktuella/)
|
|
})
|
|
|
|
it('refuses when the period carries a posted bokslut', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
await insertChartAccount(companyId, userId, '1930')
|
|
await insertChartAccount(companyId, userId, '2091')
|
|
const { entryId, bankLineId } = await insertPostedIb({ companyId, userId, fiscalPeriodId })
|
|
|
|
const yearEndId = await insertDraftJournalEntry({
|
|
userId, companyId, fiscalPeriodId, sourceType: 'year_end', status: 'draft', voucherNumber: 9,
|
|
})
|
|
await getPool().query(
|
|
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, sort_order)
|
|
VALUES ($1, '8999', 100, 0, 1), ($1, '2099', 0, 100, 2)`,
|
|
[yearEndId],
|
|
)
|
|
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [yearEndId])
|
|
|
|
await expect(
|
|
callStrike(companyId, entryId, [bankLineId], [
|
|
{ account_number: '1930', debit_amount: 55000, credit_amount: 0 },
|
|
{ account_number: '2091', debit_amount: 0, credit_amount: 5000 },
|
|
], userId),
|
|
).rejects.toThrow(/bokslut/)
|
|
})
|
|
|
|
it('refuses P&L accounts in IB replacement lines', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
await insertChartAccount(companyId, userId, '1930')
|
|
await insertChartAccount(companyId, userId, '3001')
|
|
const { entryId, bankLineId } = await insertPostedIb({ companyId, userId, fiscalPeriodId })
|
|
|
|
await expect(
|
|
callStrike(companyId, entryId, [bankLineId], [
|
|
{ account_number: '3001', debit_amount: 50000, credit_amount: 0 },
|
|
], userId),
|
|
).rejects.toThrow(/Resultatkonton/)
|
|
})
|
|
|
|
it('still refuses storno / year_end / vat_settlement entries', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
await insertChartAccount(companyId, userId, '1930')
|
|
const { entryId, bankLineId } = await insertPostedIb({
|
|
companyId, userId, fiscalPeriodId, sourceType: 'year_end', link: false, voucherNumber: 4,
|
|
})
|
|
|
|
await expect(
|
|
callStrike(companyId, entryId, [bankLineId], [
|
|
{ account_number: '1930', debit_amount: 55000, credit_amount: 0 },
|
|
], userId),
|
|
).rejects.toThrow(/kan inte rättas radvis/)
|
|
})
|
|
|
|
it('keeps allowing P&L accounts on ordinary (non-IB) verifikat', async () => {
|
|
const { companyId, userId, fiscalPeriodId } = await seedCompany()
|
|
await insertChartAccount(companyId, userId, '1930')
|
|
await insertChartAccount(companyId, userId, '5010')
|
|
const entryId = await insertDraftJournalEntry({
|
|
userId, companyId, fiscalPeriodId, sourceType: 'manual', status: 'draft', voucherNumber: 8,
|
|
})
|
|
const { rows: exp } = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, sort_order)
|
|
VALUES ($1, '5010', 1000, 0, 1) RETURNING id`,
|
|
[entryId],
|
|
)
|
|
await getPool().query(
|
|
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, sort_order)
|
|
VALUES ($1, '1930', 0, 1000, 2)`,
|
|
[entryId],
|
|
)
|
|
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [entryId])
|
|
|
|
const res = await callStrike(companyId, entryId, [exp[0].id], [
|
|
{ account_number: '5010', debit_amount: 0, credit_amount: 0, line_description: 'x' },
|
|
{ account_number: '5010', debit_amount: 1000, credit_amount: 0, line_description: 'Rättad kostnad' },
|
|
].filter((l) => l.debit_amount > 0 || l.credit_amount > 0), userId)
|
|
expect(res.rows[0].result.added_count).toBe(1)
|
|
})
|
|
})
|