feat(bookkeeping): Fortnox-style inline IB correction + cascade to later years (#2076)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f216a60bf8
commit
2814d70cb4
@@ -44,7 +44,7 @@ export const AGENT_GROUND_RULES: string[] = [
|
||||
// standard-BAS account backfill in the engine/storno service.
|
||||
'- RÄTTA FEL I BOKFÖRDA VERIFIKATIONER: så fungerar det i Accounted (beskriv aldrig andra vägar än dessa):',
|
||||
' • En bokförd verifikation kan aldrig redigeras direkt (Bokföringslagen). Rättelse görs från verifikationens egen sida: Bokföring → öppna verifikationen → knappen "Rätta". "Rätta rader" skapar automatiskt en storno som nollställer originalet plus en ny rättelseverifikation med de rätta raderna, båda i originalets period. "Rätta datum" flyttar verifikationen till rätt datum/år (storno + ombokning under huven). Hela kedjan original → storno → rättelse länkas och visas på verifikationssidan.',
|
||||
' • INGÅENDE BALANSER (IB) rättas på sitt eget sätt: INTE via "Rätta rader". Gå till Bokföring, öppna IB-verifikationen (beskrivning "Ingående balanser", serie A) och klicka "Korrigera ingående balanser". Då öppnas IB-raderna så att beloppen kan ändras direkt; när man sparar stornas den gamla IB-verifikationen och en korrigerad bokförs, och periodens ingående balans pekas om till den nya. Detta gäller oavsett om IB kom från SIE-import, CSV/Excel-import eller föregående års bokslut. IB finns alltså INTE under Inställningar eller Kontoplan: korrigeringen görs på själva verifikationen.',
|
||||
' • INGÅENDE BALANSER (IB) rättas på sitt eget sätt: INTE via "Rätta rader". Gå till Bokföring, öppna IB-verifikationen (beskrivning "Ingående balanser", serie A) och klicka "Korrigera ingående balanser". Då öppnas IB-raderna så att beloppen kan ändras direkt; i ett öppet, olåst år uppdateras verifikationen på plats (ingen storno, inga nya verifikat: originalraderna bevaras i rättelseloggen enligt Bokföringslagen). Har företaget senare räkenskapsår med egna IB-verifikat kan samma ändring föras in i dem automatiskt (kryssrutan "Uppdatera även senare räkenskapsår"); låsta år eller år med bokslut hoppas över. Detta gäller oavsett om IB kom från SIE-import, CSV/Excel-import eller föregående års bokslut. IB finns alltså INTE under Inställningar eller Kontoplan: korrigeringen görs på själva verifikationen.',
|
||||
' • Är verifikationen den SENASTE i sin serie kan den även raderas helt ("Radera verifikat"): då återanvänds löpnumret och ingen lucka uppstår.',
|
||||
' • Konton som finns i BAS-kontoplanen men saknas i företagets kontoplan läggs till AUTOMATISKT vid bokföring och rättelse. Be aldrig användaren registrera standardkonton manuellt innan de bokför: bara okända kontonummer eller avaktiverade konton stoppar.',
|
||||
' • När en bokning makuleras (storno utan rättelse) släpps den kopplade banktransaktionen och blir bokföringsbar igen i transaktionsvyn: användaren kan alltid klicka på transaktionen och bokföra om. Vid en rättelse följer transaktionen och underlaget med till rättelseverifikationen.',
|
||||
|
||||
@@ -2680,6 +2680,28 @@ export const OpeningBalanceExecuteSchema = z.object({
|
||||
})).min(2, 'At least two lines are required for double-entry'),
|
||||
})
|
||||
|
||||
export const OpeningBalanceCorrectSchema = OpeningBalanceExecuteSchema.extend({
|
||||
// Also apply the correction's per-account delta to subsequent years' linked
|
||||
// IB verifikat (Fortnox/SIE migrations book one IB per imported year).
|
||||
cascade: z.boolean().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Inline (no-storno) IB correction: strike changed lines and add replacements
|
||||
* inside the SAME verifikat, BFL 5 kap 5 § track 2. Only for open, unlocked
|
||||
* years; the correct_entry_lines_inline RPC enforces the full envelope.
|
||||
*/
|
||||
export const OpeningBalanceCorrectInlineSchema = z
|
||||
.object({
|
||||
fiscal_period_id: uuid,
|
||||
strike_line_ids: z.array(uuid).max(200).default([]),
|
||||
new_lines: z.array(InlineRattelseLineSchema).max(100).default([]),
|
||||
cascade: z.boolean().optional(),
|
||||
})
|
||||
.refine((body) => body.strike_line_ids.length > 0 || body.new_lines.length > 0, {
|
||||
message: 'Rättelsen måste stryka eller lägga till minst en rad',
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Register import schemas (customers, suppliers)
|
||||
// ============================================================
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { createTestLogger } from '@/lib/logger'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const mockReplaceOpeningBalanceEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
replaceOpeningBalanceEntry: (...args: unknown[]) => mockReplaceOpeningBalanceEntry(...args),
|
||||
}))
|
||||
|
||||
const mockFetchEntryLines = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/entry-lines', () => ({
|
||||
fetchEntryLines: (...args: unknown[]) => mockFetchEntryLines(...args),
|
||||
}))
|
||||
|
||||
import {
|
||||
computeAccountDeltas,
|
||||
buildCascadedLines,
|
||||
cascadeOpeningBalanceCorrection,
|
||||
type CascadeSourceLine,
|
||||
} from '../cascade'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const D = (account_number: string, debit_amount: number) => ({
|
||||
account_number,
|
||||
debit_amount,
|
||||
credit_amount: 0,
|
||||
})
|
||||
const K = (account_number: string, credit_amount: number) => ({
|
||||
account_number,
|
||||
debit_amount: 0,
|
||||
credit_amount,
|
||||
})
|
||||
const src = (
|
||||
line: { account_number: string; debit_amount: number; credit_amount: number },
|
||||
extra: Partial<CascadeSourceLine> = {},
|
||||
): CascadeSourceLine => ({
|
||||
line_description: null,
|
||||
dimensions: null,
|
||||
...line,
|
||||
...extra,
|
||||
})
|
||||
|
||||
describe('computeAccountDeltas', () => {
|
||||
it('returns the per-account net change, omitting unchanged accounts', () => {
|
||||
const oldLines = [D('1930', 50000), K('2099', 50000)]
|
||||
const newLines = [D('1930', 40000), D('1630', 10000), K('2099', 50000)]
|
||||
|
||||
const deltas = computeAccountDeltas(oldLines, newLines)
|
||||
|
||||
expect(deltas.get('1930')).toBe(-10000)
|
||||
expect(deltas.get('1630')).toBe(10000)
|
||||
expect(deltas.has('2099')).toBe(false)
|
||||
expect(deltas.size).toBe(2)
|
||||
})
|
||||
|
||||
it('handles accounts removed entirely from the corrected entry', () => {
|
||||
const oldLines = [D('1930', 30000), D('1630', 20000), K('2099', 50000)]
|
||||
const newLines = [D('1930', 50000), K('2099', 50000)]
|
||||
|
||||
const deltas = computeAccountDeltas(oldLines, newLines)
|
||||
|
||||
expect(deltas.get('1630')).toBe(-20000)
|
||||
expect(deltas.get('1930')).toBe(20000)
|
||||
})
|
||||
|
||||
it('rounds öre correctly (no floating point drift)', () => {
|
||||
const oldLines = [D('1930', 100.1), K('2099', 100.1)]
|
||||
const newLines = [D('1930', 100.3), K('2099', 100.3)]
|
||||
|
||||
const deltas = computeAccountDeltas(oldLines, newLines)
|
||||
|
||||
expect(deltas.get('1930')).toBe(0.2)
|
||||
expect(deltas.get('2099')).toBe(-0.2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCascadedLines', () => {
|
||||
it('keeps original lines verbatim (descriptions and dimensions) and appends sorted adjustment lines', () => {
|
||||
const existing = [
|
||||
src(D('1930', 80000), { line_description: 'IB 1930', dimensions: { '1': 'STHLM' } }),
|
||||
src(K('2099', 80000), { line_description: 'IB 2099' }),
|
||||
]
|
||||
const deltas = new Map([
|
||||
['1930', -10000],
|
||||
['1630', 10000],
|
||||
])
|
||||
|
||||
const result = buildCascadedLines(existing, deltas)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
account_number: '1930',
|
||||
debit_amount: 80000,
|
||||
credit_amount: 0,
|
||||
line_description: 'IB 1930',
|
||||
dimensions: { '1': 'STHLM' },
|
||||
},
|
||||
{
|
||||
account_number: '2099',
|
||||
debit_amount: 0,
|
||||
credit_amount: 80000,
|
||||
line_description: 'IB 2099',
|
||||
dimensions: undefined,
|
||||
},
|
||||
{
|
||||
account_number: '1630',
|
||||
debit_amount: 10000,
|
||||
credit_amount: 0,
|
||||
line_description: 'IB-rättelse 1630',
|
||||
},
|
||||
{
|
||||
account_number: '1930',
|
||||
debit_amount: 0,
|
||||
credit_amount: 10000,
|
||||
line_description: 'IB-rättelse 1930',
|
||||
},
|
||||
])
|
||||
|
||||
const totalDebit = result.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const totalCredit = result.reduce((s, l) => s + l.credit_amount, 0)
|
||||
expect(totalDebit).toBe(totalCredit)
|
||||
})
|
||||
|
||||
it('emits a credit adjustment line for a negative delta and drops zero-amount source rows', () => {
|
||||
const existing = [
|
||||
src(D('1930', 5000)),
|
||||
src(K('2099', 5000)),
|
||||
src({ account_number: '1510', debit_amount: 0, credit_amount: 0 }),
|
||||
]
|
||||
const deltas = new Map([
|
||||
['1930', -8000],
|
||||
['2099', 8000],
|
||||
])
|
||||
|
||||
const result = buildCascadedLines(existing, deltas)
|
||||
|
||||
expect(result.map((l) => l.account_number)).toEqual(['1930', '2099', '1930', '2099'])
|
||||
expect(result[2]).toEqual({
|
||||
account_number: '1930',
|
||||
debit_amount: 0,
|
||||
credit_amount: 8000,
|
||||
line_description: 'IB-rättelse 1930',
|
||||
})
|
||||
expect(result[3]).toEqual({
|
||||
account_number: '2099',
|
||||
debit_amount: 8000,
|
||||
credit_amount: 0,
|
||||
line_description: 'IB-rättelse 2099',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cascadeOpeningBalanceCorrection', () => {
|
||||
const sink: Parameters<typeof createTestLogger>[1] = []
|
||||
const log = createTestLogger('cascade-test', sink)
|
||||
const supabase = mockSupabase as unknown as SupabaseClient
|
||||
const DELTAS = new Map([
|
||||
['1930', -10000],
|
||||
['1630', 10000],
|
||||
])
|
||||
|
||||
const periodRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'period-2020',
|
||||
name: '2020',
|
||||
period_start: '2020-01-01',
|
||||
is_closed: false,
|
||||
locked_at: null,
|
||||
opening_balance_entry_id: 'ib-2020',
|
||||
opening_balance_entry: { voucher_series: 'A', voucher_number: 4 },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
sink.length = 0
|
||||
mockFetchEntryLines.mockResolvedValue([
|
||||
{ id: 'l1', account_number: '1930', debit_amount: 80000, credit_amount: 0, line_description: 'IB 1930', dimensions: null },
|
||||
{ id: 'l2', account_number: '2099', debit_amount: 0, credit_amount: 80000, line_description: 'IB 2099', dimensions: null },
|
||||
])
|
||||
mockReplaceOpeningBalanceEntry.mockResolvedValue({
|
||||
newEntryId: 'ib-new',
|
||||
stornoEntryId: 'ib-storno',
|
||||
newVoucherNumber: 9,
|
||||
stornoVoucherNumber: 10,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns immediately without queries when there are no deltas', async () => {
|
||||
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
|
||||
basePeriodStart: '2019-01-01',
|
||||
deltas: new Map(),
|
||||
lockDate: null,
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ corrected: [], skipped: [] })
|
||||
expect(mockSupabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replaces an open later period atomically and skips a closed one', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
periodRow(),
|
||||
periodRow({ id: 'period-2021', name: '2021', period_start: '2021-01-01', is_closed: true, opening_balance_entry_id: 'ib-2021' }),
|
||||
],
|
||||
}) // subsequent periods
|
||||
enqueue({ count: 0 }) // year-end check for period-2020
|
||||
|
||||
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
|
||||
basePeriodStart: '2019-01-01',
|
||||
deltas: DELTAS,
|
||||
lockDate: null,
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result.corrected).toEqual([
|
||||
{
|
||||
fiscal_period_id: 'period-2020',
|
||||
period_name: '2020',
|
||||
journal_entry_id: 'ib-new',
|
||||
reversed_entry_id: 'ib-2020',
|
||||
},
|
||||
])
|
||||
expect(result.skipped).toEqual([
|
||||
{ fiscal_period_id: 'period-2021', period_name: '2021', reason: 'closed' },
|
||||
])
|
||||
|
||||
// One atomic engine replacement, CAS-guarded on the old entry, carrying
|
||||
// the original lines verbatim plus labelled adjustment lines and the BFL
|
||||
// reference to the verifikat being rättat.
|
||||
expect(mockReplaceOpeningBalanceEntry).toHaveBeenCalledTimes(1)
|
||||
expect(mockReplaceOpeningBalanceEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
'ib-2020',
|
||||
expect.objectContaining({
|
||||
fiscal_period_id: 'period-2020',
|
||||
entry_date: '2020-01-01',
|
||||
source_type: 'opening_balance',
|
||||
voucher_series: 'A',
|
||||
description: 'Ingående balanser (korrigerade, rättelse av A4)',
|
||||
lines: [
|
||||
expect.objectContaining({ account_number: '1930', debit_amount: 80000, line_description: 'IB 1930' }),
|
||||
expect.objectContaining({ account_number: '2099', credit_amount: 80000 }),
|
||||
expect.objectContaining({ account_number: '1630', debit_amount: 10000, line_description: 'IB-rättelse 1630' }),
|
||||
expect.objectContaining({ account_number: '1930', credit_amount: 10000, line_description: 'IB-rättelse 1930' }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('skips locked, lock-dated, and bokslut periods without touching them', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
periodRow({ id: 'p-locked', name: '2020', locked_at: '2021-05-01T00:00:00Z' }),
|
||||
periodRow({ id: 'p-lockdate', name: '2021', period_start: '2021-01-01' }),
|
||||
periodRow({ id: 'p-yearend', name: '2022', period_start: '2022-01-01' }),
|
||||
],
|
||||
})
|
||||
// p-lockdate: period_start 2021-01-01 <= lockDate 2021-12-31 → skipped
|
||||
// before any further query. p-yearend reaches the year-end check:
|
||||
enqueue({ count: 2 }) // year-end check for p-yearend
|
||||
|
||||
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
|
||||
basePeriodStart: '2019-01-01',
|
||||
deltas: DELTAS,
|
||||
lockDate: '2021-12-31',
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result.corrected).toEqual([])
|
||||
expect(result.skipped).toEqual([
|
||||
{ fiscal_period_id: 'p-locked', period_name: '2020', reason: 'locked' },
|
||||
{ fiscal_period_id: 'p-lockdate', period_name: '2021', reason: 'lock_date' },
|
||||
{ fiscal_period_id: 'p-yearend', period_name: '2022', reason: 'year_end' },
|
||||
])
|
||||
expect(mockReplaceOpeningBalanceEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails CLOSED when the year-end lookup errors: the period is skipped, not rewritten', async () => {
|
||||
enqueue({ data: [periodRow()] })
|
||||
enqueue({ count: null, error: { message: 'transient boom' } }) // year-end check fails
|
||||
|
||||
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
|
||||
basePeriodStart: '2019-01-01',
|
||||
deltas: DELTAS,
|
||||
lockDate: null,
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result.corrected).toEqual([])
|
||||
expect(result.skipped).toEqual([
|
||||
{ fiscal_period_id: 'period-2020', period_name: '2020', reason: 'correction_failed' },
|
||||
])
|
||||
expect(mockReplaceOpeningBalanceEntry).not.toHaveBeenCalled()
|
||||
|
||||
const audit = sink.filter((r) => String(r.msg).includes('cascade correction failed'))
|
||||
expect(audit.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('reports a period without a linked IB verifikat as skipped', async () => {
|
||||
enqueue({ data: [periodRow({ opening_balance_entry_id: null, opening_balance_entry: null })] })
|
||||
|
||||
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
|
||||
basePeriodStart: '2019-01-01',
|
||||
deltas: DELTAS,
|
||||
lockDate: null,
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result.skipped).toEqual([
|
||||
{ fiscal_period_id: 'period-2020', period_name: '2020', reason: 'no_opening_balance' },
|
||||
])
|
||||
expect(mockReplaceOpeningBalanceEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a failed replacement as skipped and continues with the next year', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
periodRow(),
|
||||
periodRow({ id: 'period-2021', name: '2021', period_start: '2021-01-01', opening_balance_entry_id: 'ib-2021' }),
|
||||
],
|
||||
})
|
||||
enqueue({ count: 0 }) // year-end check period-2020
|
||||
enqueue({ count: 0 }) // year-end check period-2021
|
||||
|
||||
mockReplaceOpeningBalanceEntry
|
||||
.mockRejectedValueOnce(new Error('replacement boom')) // period-2020
|
||||
.mockResolvedValueOnce({
|
||||
newEntryId: 'ib-2021-new',
|
||||
stornoEntryId: 'ib-2021-storno',
|
||||
newVoucherNumber: 12,
|
||||
stornoVoucherNumber: 13,
|
||||
})
|
||||
|
||||
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
|
||||
basePeriodStart: '2019-01-01',
|
||||
deltas: DELTAS,
|
||||
lockDate: null,
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result.skipped).toEqual([
|
||||
{ fiscal_period_id: 'period-2020', period_name: '2020', reason: 'correction_failed' },
|
||||
])
|
||||
expect(result.corrected).toEqual([
|
||||
expect.objectContaining({ fiscal_period_id: 'period-2021', journal_entry_id: 'ib-2021-new' }),
|
||||
])
|
||||
|
||||
// The failed period got exactly one atomic attempt (RPC rolls back all of
|
||||
// it); no compensation writes exist in this design.
|
||||
expect(mockReplaceOpeningBalanceEntry).toHaveBeenCalledTimes(2)
|
||||
expect(mockReplaceOpeningBalanceEntry).toHaveBeenNthCalledWith(
|
||||
1, expect.anything(), 'company-1', 'user-1', 'ib-2020', expect.anything(),
|
||||
)
|
||||
expect(mockReplaceOpeningBalanceEntry).toHaveBeenNthCalledWith(
|
||||
2, expect.anything(), 'company-1', 'user-1', 'ib-2021', expect.anything(),
|
||||
)
|
||||
|
||||
const audit = sink.filter((r) => String(r.msg).includes('cascade correction failed'))
|
||||
expect(audit.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('inline mode: appends adjustment lines in the same verifikat, no storno, no line fetch', async () => {
|
||||
enqueue({ data: [periodRow()] }) // subsequent periods
|
||||
enqueue({ count: 0 }) // year-end check
|
||||
enqueue({ data: { log_id: 'log-1', struck_count: 0, added_count: 2 } }) // inline RPC
|
||||
|
||||
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
|
||||
basePeriodStart: '2019-01-01',
|
||||
deltas: DELTAS,
|
||||
lockDate: null,
|
||||
mode: 'inline',
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result.corrected).toEqual([
|
||||
{
|
||||
fiscal_period_id: 'period-2020',
|
||||
period_name: '2020',
|
||||
journal_entry_id: 'ib-2020',
|
||||
reversed_entry_id: null,
|
||||
},
|
||||
])
|
||||
expect(result.skipped).toEqual([])
|
||||
|
||||
expect(mockSupabase.rpc).toHaveBeenCalledWith(
|
||||
'correct_entry_lines_inline',
|
||||
expect.objectContaining({
|
||||
p_company_id: 'company-1',
|
||||
p_entry_id: 'ib-2020',
|
||||
p_strike_line_ids: [],
|
||||
p_new_lines: [
|
||||
expect.objectContaining({ account_number: '1630', debit_amount: 10000, line_description: 'IB-rättelse 1630' }),
|
||||
expect.objectContaining({ account_number: '1930', credit_amount: 10000, line_description: 'IB-rättelse 1930' }),
|
||||
],
|
||||
p_user_id: 'user-1',
|
||||
}),
|
||||
)
|
||||
expect(mockReplaceOpeningBalanceEntry).not.toHaveBeenCalled()
|
||||
expect(mockFetchEntryLines).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('inline mode: a refused rättelse skips the year and is audited', async () => {
|
||||
enqueue({ data: [periodRow()] })
|
||||
enqueue({ count: 0 }) // year-end check
|
||||
enqueue({ data: null, error: { message: 'Perioden är stängd eller låst: använd rättelseverifikat (storno).' } })
|
||||
|
||||
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
|
||||
basePeriodStart: '2019-01-01',
|
||||
deltas: DELTAS,
|
||||
lockDate: null,
|
||||
mode: 'inline',
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result.corrected).toEqual([])
|
||||
expect(result.skipped).toEqual([
|
||||
{ fiscal_period_id: 'period-2020', period_name: '2020', reason: 'correction_failed' },
|
||||
])
|
||||
|
||||
const audit = sink.filter((r) => String(r.msg).includes('cascade correction failed'))
|
||||
expect(audit.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('skips a period whose shifted lines no longer validate', async () => {
|
||||
// The period's IB is a single line pair that the delta exactly cancels,
|
||||
// leaving zero-amount adjustments only... construct instead the <2 lines
|
||||
// case: the source entry is empty (all rows zero), so kept+adjustment
|
||||
// lines fail the P&L/two-line validation via a P&L delta account.
|
||||
mockFetchEntryLines.mockResolvedValue([
|
||||
{ id: 'l1', account_number: '1930', debit_amount: 10000, credit_amount: 0, line_description: null, dimensions: null },
|
||||
{ id: 'l2', account_number: '1630', debit_amount: 0, credit_amount: 10000, line_description: null, dimensions: null },
|
||||
])
|
||||
enqueue({ data: [periodRow()] })
|
||||
enqueue({ count: 0 }) // year-end check
|
||||
|
||||
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
|
||||
basePeriodStart: '2019-01-01',
|
||||
deltas: new Map([
|
||||
['3001', 10000],
|
||||
['1930', -10000],
|
||||
]),
|
||||
lockDate: null,
|
||||
log,
|
||||
})
|
||||
|
||||
expect(result.skipped).toEqual([
|
||||
{ fiscal_period_id: 'period-2020', period_name: '2020', reason: 'validation_failed' },
|
||||
])
|
||||
expect(mockReplaceOpeningBalanceEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,405 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
import type { CreateJournalEntryLineInput } from '@/types'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { replaceOpeningBalanceEntry } from '@/lib/bookkeeping/engine'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
import {
|
||||
validateOpeningBalanceLines,
|
||||
type OpeningBalanceLine,
|
||||
} from './execute-helpers'
|
||||
|
||||
/**
|
||||
* Cascade an opening-balance correction to subsequent fiscal years.
|
||||
*
|
||||
* Fortnox/SIE-migrated companies get one IB verifikat per imported year, each
|
||||
* linked via fiscal_periods.opening_balance_entry_id. Correcting one year's IB
|
||||
* therefore leaves every later year's linked IB verifikat carrying the stale
|
||||
* figures. This module applies the same per-account delta (corrected minus
|
||||
* original) to each subsequent period's IB the BFL-compliant way, via the
|
||||
* atomic replaceOpeningBalanceEntry engine primitive (one DB transaction owns
|
||||
* the storno, the corrected voucher, and the period pointer swap, with a CAS
|
||||
* on the expected old entry id), so a failure leaves the period untouched.
|
||||
*
|
||||
* The corrected verifikat keeps the original lines verbatim (descriptions and
|
||||
* dimensions included) and appends one adjustment line per changed account,
|
||||
* so years the user never opened stay reviewable line by line.
|
||||
*
|
||||
* Periods that cannot be touched (closed, locked, behind the company lock
|
||||
* date, or with a posted bokslut on top) are skipped and reported, never
|
||||
* forced: the DB triggers enforcing those states are legally required.
|
||||
*/
|
||||
|
||||
/** Net per-account change of a correction: (new debit-credit) minus (old debit-credit). */
|
||||
export type AccountDeltas = Map<string, number>
|
||||
|
||||
/** An existing IB line with the fields the rebooked verifikat must preserve. */
|
||||
export interface CascadeSourceLine extends OpeningBalanceLine {
|
||||
line_description: string | null
|
||||
dimensions: Record<string, string> | null
|
||||
}
|
||||
|
||||
export interface CascadeCorrectedPeriod {
|
||||
fiscal_period_id: string
|
||||
period_name: string | null
|
||||
journal_entry_id: string
|
||||
/** Storno mode: the replaced entry. Inline mode: null (same verifikat edited in place). */
|
||||
reversed_entry_id: string | null
|
||||
}
|
||||
|
||||
export type CascadeSkipReason =
|
||||
| 'closed'
|
||||
| 'locked'
|
||||
| 'lock_date'
|
||||
| 'year_end'
|
||||
| 'no_opening_balance'
|
||||
| 'validation_failed'
|
||||
| 'correction_failed'
|
||||
|
||||
export interface CascadeSkippedPeriod {
|
||||
fiscal_period_id: string
|
||||
period_name: string | null
|
||||
reason: CascadeSkipReason
|
||||
}
|
||||
|
||||
export interface CascadeResult {
|
||||
corrected: CascadeCorrectedPeriod[]
|
||||
skipped: CascadeSkippedPeriod[]
|
||||
/**
|
||||
* Set by the ROUTES when the cascade itself blew up before/while running
|
||||
* (log fetch failed, unexpected throw): the base correction stands, no
|
||||
* later year was verified, and the client must tell the user to check.
|
||||
*/
|
||||
failed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-account net deltas between the original IB lines and the corrected
|
||||
* lines. Accounts whose net balance did not change are omitted, so an empty
|
||||
* map means "nothing to cascade".
|
||||
*/
|
||||
export function computeAccountDeltas(
|
||||
oldLines: OpeningBalanceLine[],
|
||||
newLines: OpeningBalanceLine[],
|
||||
): AccountDeltas {
|
||||
const nets = new Map<string, { oldNet: number; newNet: number }>()
|
||||
|
||||
for (const line of oldLines) {
|
||||
const slot = nets.get(line.account_number) ?? { oldNet: 0, newNet: 0 }
|
||||
slot.oldNet = roundOre(slot.oldNet + (line.debit_amount - line.credit_amount))
|
||||
nets.set(line.account_number, slot)
|
||||
}
|
||||
for (const line of newLines) {
|
||||
const slot = nets.get(line.account_number) ?? { oldNet: 0, newNet: 0 }
|
||||
slot.newNet = roundOre(slot.newNet + (line.debit_amount - line.credit_amount))
|
||||
nets.set(line.account_number, slot)
|
||||
}
|
||||
|
||||
const deltas: AccountDeltas = new Map()
|
||||
for (const [account, { oldNet, newNet }] of nets) {
|
||||
const delta = roundOre(newNet - oldNet)
|
||||
if (Math.abs(delta) >= 0.01) deltas.set(account, delta)
|
||||
}
|
||||
return deltas
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the corrected line set for a subsequent period: the existing lines
|
||||
* verbatim (keeping description and dimensions), plus one labelled adjustment
|
||||
* line per delta account. Both the existing entry and the delta set balance
|
||||
* (deltas come from two balanced entries), so the result balances too.
|
||||
*/
|
||||
export function buildCascadedLines(
|
||||
existingLines: CascadeSourceLine[],
|
||||
deltas: AccountDeltas,
|
||||
): CreateJournalEntryLineInput[] {
|
||||
const kept: CreateJournalEntryLineInput[] = existingLines
|
||||
.filter((l) => l.debit_amount > 0 || l.credit_amount > 0)
|
||||
.map((l) => ({
|
||||
account_number: l.account_number,
|
||||
debit_amount: l.debit_amount,
|
||||
credit_amount: l.credit_amount,
|
||||
line_description: l.line_description ?? undefined,
|
||||
dimensions: l.dimensions ?? undefined,
|
||||
}))
|
||||
|
||||
return [...kept, ...buildAdjustmentLines(deltas)]
|
||||
}
|
||||
|
||||
/** One labelled adjustment line per delta account, sorted; balances to zero. */
|
||||
export function buildAdjustmentLines(deltas: AccountDeltas): CreateJournalEntryLineInput[] {
|
||||
return [...deltas.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([account_number, delta]) => ({
|
||||
account_number,
|
||||
debit_amount: delta > 0 ? delta : 0,
|
||||
credit_amount: delta < 0 ? roundOre(-delta) : 0,
|
||||
line_description: `IB-rättelse ${account_number}`,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Fetch an entry's lines with the fields a cascaded rebook must preserve. */
|
||||
export async function fetchEntryOpeningBalanceLines(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
entryId: string,
|
||||
): Promise<CascadeSourceLine[]> {
|
||||
// Two-step entry-lines fetch: verifies company_id ownership on the entry
|
||||
// side (defense in depth alongside RLS) and paginates. Same pattern as
|
||||
// lib/reports/opening-balances.ts.
|
||||
const rows = await fetchEntryLines<{
|
||||
id: string
|
||||
account_number: string
|
||||
debit_amount: number | string
|
||||
credit_amount: number | string
|
||||
line_description: string | null
|
||||
dimensions: Record<string, string> | null
|
||||
}>({
|
||||
supabase,
|
||||
lineColumns: 'id, account_number, debit_amount, credit_amount, line_description, dimensions',
|
||||
filterEntries: (q: EntryLinesQuery) => q.eq('id', entryId).eq('company_id', companyId),
|
||||
attachEntriesAs: null,
|
||||
})
|
||||
|
||||
return rows.map((r) => ({
|
||||
account_number: r.account_number,
|
||||
debit_amount: Number(r.debit_amount) || 0,
|
||||
credit_amount: Number(r.credit_amount) || 0,
|
||||
line_description: r.line_description ?? null,
|
||||
dimensions: r.dimensions ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
interface SubsequentPeriodRow {
|
||||
id: string
|
||||
name: string | null
|
||||
period_start: string
|
||||
is_closed: boolean
|
||||
locked_at: string | null
|
||||
opening_balance_entry_id: string | null
|
||||
opening_balance_entry: {
|
||||
voucher_series: string | null
|
||||
voucher_number: number | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface CascadeOptions {
|
||||
/** period_start of the period whose IB was just corrected. */
|
||||
basePeriodStart: string
|
||||
/** Per-account net deltas from the base correction. */
|
||||
deltas: AccountDeltas
|
||||
/** company_settings.bookkeeping_locked_through, already fetched by the caller. */
|
||||
lockDate: string | null
|
||||
/**
|
||||
* How each later year's IB is corrected:
|
||||
* - 'storno' (default): atomic replaceOpeningBalanceEntry (storno + rebook
|
||||
* + pointer swap), used by the storno-based /correct route.
|
||||
* - 'inline': append the labelled adjustment lines inside the SAME
|
||||
* verifikat via correct_entry_lines_inline (BFL 5 kap 5 § track 2): no
|
||||
* new verifikat at all. Used by the /correct-inline route.
|
||||
*/
|
||||
mode?: 'storno' | 'inline'
|
||||
log: Logger
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace each subsequent period's IB with the deltas applied, one atomic
|
||||
* engine replacement per period. Each period is independent: a failure is
|
||||
* reported as skipped (the RPC transaction leaves that period untouched) and
|
||||
* the cascade continues with the next year.
|
||||
*/
|
||||
export async function cascadeOpeningBalanceCorrection(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
options: CascadeOptions,
|
||||
): Promise<CascadeResult> {
|
||||
const { basePeriodStart, deltas, lockDate, log } = options
|
||||
const mode = options.mode ?? 'storno'
|
||||
const result: CascadeResult = { corrected: [], skipped: [] }
|
||||
|
||||
if (deltas.size === 0) return result
|
||||
|
||||
const { data: periods, error: periodsError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select(
|
||||
'id, name, period_start, is_closed, locked_at, opening_balance_entry_id, opening_balance_entry:journal_entries!opening_balance_entry_id(voucher_series, voucher_number)',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.gt('period_start', basePeriodStart)
|
||||
.order('period_start', { ascending: true })
|
||||
|
||||
if (periodsError) {
|
||||
// The base correction already succeeded; surface the cascade as fully
|
||||
// skipped rather than failing the request.
|
||||
log.error('opening balance cascade: could not list subsequent periods', {
|
||||
audit: true,
|
||||
event: 'opening_balance.cascade_list_failed',
|
||||
companyId,
|
||||
reason: periodsError.message,
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
for (const period of (periods ?? []) as unknown as SubsequentPeriodRow[]) {
|
||||
const skip = (reason: CascadeSkipReason) => {
|
||||
result.skipped.push({
|
||||
fiscal_period_id: period.id,
|
||||
period_name: period.name,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
const auditFailure = (fields: Record<string, unknown>) => {
|
||||
log.error('audit: opening balance cascade correction failed', {
|
||||
audit: true,
|
||||
event: 'opening_balance.cascade_period_failed',
|
||||
companyId,
|
||||
userId,
|
||||
fiscalPeriodId: period.id,
|
||||
oldEntryId: period.opening_balance_entry_id,
|
||||
...fields,
|
||||
})
|
||||
}
|
||||
|
||||
if (!period.opening_balance_entry_id) {
|
||||
// No linked IB verifikat: reports fall back to computing prior balances,
|
||||
// which already reflect the base correction. Reported for transparency.
|
||||
skip('no_opening_balance')
|
||||
continue
|
||||
}
|
||||
if (period.is_closed) {
|
||||
skip('closed')
|
||||
continue
|
||||
}
|
||||
if (period.locked_at) {
|
||||
skip('locked')
|
||||
continue
|
||||
}
|
||||
if (lockDate && period.period_start <= lockDate) {
|
||||
skip('lock_date')
|
||||
continue
|
||||
}
|
||||
|
||||
// Fail CLOSED: a failed lookup must not read as "no bokslut", or the
|
||||
// cascade would rewrite the IB of a period with a posted year-end.
|
||||
const { count: yearEndCount, error: yearEndError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', period.id)
|
||||
.eq('source_type', 'year_end')
|
||||
.eq('status', 'posted')
|
||||
|
||||
if (yearEndError) {
|
||||
auditFailure({ phase: 'year_end_check_failed', reason: yearEndError.message })
|
||||
skip('correction_failed')
|
||||
continue
|
||||
}
|
||||
if ((yearEndCount ?? 0) > 0) {
|
||||
skip('year_end')
|
||||
continue
|
||||
}
|
||||
|
||||
const oldEntryId = period.opening_balance_entry_id
|
||||
|
||||
if (mode === 'inline') {
|
||||
// Append the labelled adjustment lines inside the SAME verifikat: no
|
||||
// storno, no new verifikat. The RPC transaction enforces the whole
|
||||
// envelope (posted, current linked IB, no bokslut, class 1-2, balance
|
||||
// to the öre, rättelse log) and rolls the period back on any failure.
|
||||
const { error: inlineError } = await supabase.rpc('correct_entry_lines_inline', {
|
||||
p_company_id: companyId,
|
||||
p_entry_id: oldEntryId,
|
||||
p_strike_line_ids: [],
|
||||
p_new_lines: buildAdjustmentLines(deltas).map((l) => ({
|
||||
account_number: l.account_number,
|
||||
debit_amount: l.debit_amount,
|
||||
credit_amount: l.credit_amount,
|
||||
line_description: l.line_description ?? null,
|
||||
dimensions: {},
|
||||
})),
|
||||
p_user_id: userId,
|
||||
})
|
||||
|
||||
if (inlineError) {
|
||||
auditFailure({ phase: 'inline_rattelse_failed', reason: inlineError.message })
|
||||
skip('correction_failed')
|
||||
continue
|
||||
}
|
||||
|
||||
result.corrected.push({
|
||||
fiscal_period_id: period.id,
|
||||
period_name: period.name,
|
||||
journal_entry_id: oldEntryId,
|
||||
reversed_entry_id: null,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
let lines: CreateJournalEntryLineInput[]
|
||||
try {
|
||||
const existingLines = await fetchEntryOpeningBalanceLines(supabase, companyId, oldEntryId)
|
||||
lines = buildCascadedLines(existingLines, deltas)
|
||||
} catch (err) {
|
||||
auditFailure({
|
||||
phase: 'line_fetch_failed',
|
||||
reason: err instanceof Error ? err.message : 'unknown',
|
||||
})
|
||||
skip('correction_failed')
|
||||
continue
|
||||
}
|
||||
|
||||
const validation = validateOpeningBalanceLines(
|
||||
lines.map((l) => ({
|
||||
account_number: l.account_number,
|
||||
debit_amount: l.debit_amount,
|
||||
credit_amount: l.credit_amount,
|
||||
})),
|
||||
)
|
||||
if (!validation.ok) {
|
||||
skip('validation_failed')
|
||||
continue
|
||||
}
|
||||
|
||||
// BFL 5 kap 5§: reference the verifikat being rättat, same convention as
|
||||
// the base correction.
|
||||
const voucherLabel =
|
||||
period.opening_balance_entry?.voucher_series && period.opening_balance_entry?.voucher_number
|
||||
? `${period.opening_balance_entry.voucher_series}${period.opening_balance_entry.voucher_number}`
|
||||
: null
|
||||
const description = voucherLabel
|
||||
? `Ingående balanser (korrigerade, rättelse av ${voucherLabel})`
|
||||
: 'Ingående balanser (korrigerade)'
|
||||
|
||||
// One RPC transaction: storno the old IB, commit the corrected one, swap
|
||||
// fiscal_periods.opening_balance_entry_id, CAS-guarded on oldEntryId. Any
|
||||
// failure (including a period locked between our pre-check and the write)
|
||||
// rolls the whole period back: no compensation pass, no half states.
|
||||
try {
|
||||
const replacement = await replaceOpeningBalanceEntry(supabase, companyId, userId, oldEntryId, {
|
||||
fiscal_period_id: period.id,
|
||||
entry_date: period.period_start,
|
||||
description,
|
||||
source_type: 'opening_balance',
|
||||
voucher_series: 'A',
|
||||
lines,
|
||||
})
|
||||
|
||||
result.corrected.push({
|
||||
fiscal_period_id: period.id,
|
||||
period_name: period.name,
|
||||
journal_entry_id: replacement.newEntryId,
|
||||
reversed_entry_id: oldEntryId,
|
||||
})
|
||||
} catch (err) {
|
||||
auditFailure({
|
||||
phase: 'replacement_failed',
|
||||
reason: err instanceof Error ? err.message : 'unknown',
|
||||
})
|
||||
skip('correction_failed')
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user