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:
Mattsson
2026-08-31 16:01:11 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f216a60bf8
commit 2814d70cb4
13 changed files with 2502 additions and 30 deletions
@@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockBackfill = vi.fn().mockResolvedValue([])
vi.mock('@/lib/bookkeeping/account-backfill', () => ({
backfillStandardBASAccounts: (...args: unknown[]) => mockBackfill(...args),
}))
// The cascade has its own unit tests; here we verify the route wires it with
// mode 'inline' and the delta computed from struck vs new lines.
const mockCascade = vi.fn()
vi.mock('@/lib/import/opening-balance/cascade', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
cascadeOpeningBalanceCorrection: (...args: unknown[]) => mockCascade(...args),
}
})
import { POST } from '../route'
const PERIOD_ID = '550e8400-e29b-41d4-a716-446655440000'
const LINE_1930 = '11111111-1111-4111-8111-111111111111'
const ROUTE_PARAMS = { params: Promise.resolve({}) }
interface InlineResponse {
data: {
success: boolean
journal_entry_id: string
cascade?: { corrected: unknown[]; skipped: unknown[] }
}
error?: { code: string; message?: string }
}
function makeRequest(body: unknown) {
return createMockRequest('/api/import/opening-balance/correct-inline', {
method: 'POST',
body,
})
}
function openPeriod(overrides: Record<string, unknown> = {}) {
return {
id: PERIOD_ID,
period_start: '2024-01-01',
is_closed: false,
locked_at: null,
opening_balances_set: true,
opening_balance_entry_id: 'entry-ib',
...overrides,
}
}
const BODY = {
fiscal_period_id: PERIOD_ID,
strike_line_ids: [LINE_1930],
new_lines: [{ account_number: '1930', debit_amount: 55000, credit_amount: 0 }],
cascade: true,
}
describe('POST /api/import/opening-balance/correct-inline', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
mockCascade.mockResolvedValue({ corrected: [], skipped: [] })
})
it('returns 401 for unauthenticated requests', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const res = await POST(makeRequest(BODY), ROUTE_PARAMS)
const { status } = await parseJsonResponse(res)
expect(status).toBe(401)
})
it('returns 400 when nothing is struck or added', async () => {
const res = await POST(
makeRequest({ fiscal_period_id: PERIOD_ID, strike_line_ids: [], new_lines: [] }),
ROUTE_PARAMS,
)
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('returns 404 for an unknown fiscal period', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const res = await POST(makeRequest(BODY), ROUTE_PARAMS)
const { status, body } = await parseJsonResponse<InlineResponse>(res)
expect(status).toBe(404)
expect(body.error?.code).toBe('OB_PERIOD_NOT_FOUND')
})
it('refuses closed periods with the same code as the storno route', async () => {
enqueue({ data: openPeriod({ is_closed: true }) })
const res = await POST(makeRequest(BODY), ROUTE_PARAMS)
const { status, body } = await parseJsonResponse<InlineResponse>(res)
expect(status).toBe(400)
expect(body.error?.code).toBe('OB_PERIOD_CLOSED')
})
it('refuses periods with a posted bokslut', async () => {
enqueue({ data: openPeriod() }) // period
enqueue({ data: { bookkeeping_locked_through: null } }) // lock date
enqueue({ count: 1 }) // year-end check
const res = await POST(makeRequest(BODY), ROUTE_PARAMS)
const { status, body } = await parseJsonResponse<InlineResponse>(res)
expect(status).toBe(409)
expect(body.error?.code).toBe('OB_CORRECT_YEAR_END_EXISTS')
})
it('edits in place via the RPC and cascades inline with the delta from the rättelse log', async () => {
enqueue({ data: openPeriod() }) // period
enqueue({ data: { bookkeeping_locked_through: null } }) // lock date
enqueue({ count: 0 }) // year-end check
enqueue({ data: { log_id: 'log-1', struck_count: 1, added_count: 1 } }) // RPC
enqueue({
data: {
struck_lines: [{ account_number: '1930', debit_amount: 50000, credit_amount: 0 }],
added_lines: [{ account_number: '1930', debit_amount: 55000, credit_amount: 0 }],
},
}) // rättelse-log fetch (authoritative delta source)
const res = await POST(makeRequest(BODY), ROUTE_PARAMS)
const { status, body } = await parseJsonResponse<InlineResponse>(res)
expect(status).toBe(200)
expect(body.data.success).toBe(true)
expect(body.data.journal_entry_id).toBe('entry-ib')
expect(body.data.cascade).toEqual({ corrected: [], skipped: [] })
expect(mockSupabase.rpc).toHaveBeenCalledWith(
'correct_entry_lines_inline',
expect.objectContaining({
p_company_id: 'company-1',
p_entry_id: 'entry-ib',
p_strike_line_ids: [LINE_1930],
p_new_lines: [
expect.objectContaining({ account_number: '1930', debit_amount: 55000 }),
],
p_user_id: 'user-1',
}),
)
// Cascade runs in inline mode with delta = added (55000) minus struck
// (50000), sourced from the RPC's own rättelse-log row.
expect(mockCascade).toHaveBeenCalledTimes(1)
const opts = mockCascade.mock.calls[0][3] as {
basePeriodStart: string
mode: string
deltas: Map<string, number>
}
expect(opts.mode).toBe('inline')
expect(opts.basePeriodStart).toBe('2024-01-01')
expect(opts.deltas.get('1930')).toBe(5000)
})
it('does not cascade when the flag is omitted', async () => {
enqueue({ data: openPeriod() })
enqueue({ data: { bookkeeping_locked_through: null } })
enqueue({ count: 0 })
enqueue({ data: { log_id: 'log-1' } }) // RPC; no log fetch without cascade
const res = await POST(
makeRequest({ ...BODY, cascade: undefined }),
ROUTE_PARAMS,
)
const { status, body } = await parseJsonResponse<InlineResponse>(res)
expect(status).toBe(200)
expect(body.data.cascade).toBeUndefined()
expect(mockCascade).not.toHaveBeenCalled()
})
it('surfaces an RPC rule violation verbatim as 409 OB_INLINE_REFUSED', async () => {
enqueue({ data: openPeriod() })
enqueue({ data: { bookkeeping_locked_through: null } })
enqueue({ count: 0 })
enqueue({ data: null, error: { code: 'P0001', message: 'Verifikationen balanserar inte efter rättelsen (debet 55000, kredit 50000).' } })
const res = await POST(makeRequest(BODY), ROUTE_PARAMS)
const { status, body } = await parseJsonResponse<InlineResponse>(res)
expect(status).toBe(409)
expect(body.error?.code).toBe('OB_INLINE_REFUSED')
expect(body.error?.message).toContain('balanserar inte')
expect(mockCascade).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,209 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { OpeningBalanceCorrectInlineSchema } from '@/lib/api/schemas'
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
import {
cascadeOpeningBalanceCorrection,
computeAccountDeltas,
type CascadeResult,
} from '@/lib/import/opening-balance/cascade'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
/**
* POST /api/import/opening-balance/correct-inline
*
* Fortnox-style IB correction: strike changed lines and add replacements
* inside the SAME IB verifikat (BFL 5 kap 5 § track 2, rättelse in the same
* bokföringspost), via the correct_entry_lines_inline RPC. No storno, no new
* verifikat; the struck originals live on in journal_entry_rattelse_log and
* fiscal_periods.opening_balance_entry_id never changes.
*
* Only for open, unlocked years without a bokslut: the pre-flights below
* return the same OB_* codes as the storno-based /correct route (so the
* dialog's blocked-year guidance applies), and the RPC re-enforces the whole
* envelope transactionally.
*
* With `cascade: true` the per-account delta (added minus struck) is appended
* as labelled adjustment lines inside each subsequent year's own IB verifikat
* (cascade mode 'inline'): a multi-year correction with zero new verifikat.
* Locked/closed/bokslut years are skipped and reported, never forced.
*/
export const POST = withRouteContext(
'opening_balance.correct_inline',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
const result = await validateBody(request, OpeningBalanceCorrectInlineSchema, {
log,
operation: 'opening_balance.correct_inline',
})
if (!result.success) return result.response
const { fiscal_period_id, strike_line_ids, new_lines, cascade } = result.data
const opLog = log.child({ fiscalPeriodId: fiscal_period_id })
// Pre-flights mirror the storno-based /correct route (same OB_* codes so
// the client guidance is uniform). The RPC re-checks everything inside
// its transaction: these exist to give structured, actionable errors.
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id, period_start, is_closed, locked_at, opening_balances_set, opening_balance_entry_id')
.eq('id', fiscal_period_id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
return errorResponseFromCode('OB_PERIOD_NOT_FOUND', opLog, { requestId })
}
if (period.is_closed) {
return errorResponseFromCode('OB_PERIOD_CLOSED', opLog, { requestId })
}
if (period.locked_at) {
return errorResponseFromCode('OB_PERIOD_LOCKED', opLog, { requestId })
}
const { data: settings } = await supabase
.from('company_settings')
.select('bookkeeping_locked_through')
.eq('company_id', companyId)
.maybeSingle()
const lockDate = settings?.bookkeeping_locked_through as string | null
if (lockDate && period.period_start <= lockDate) {
return errorResponseFromCode('OB_COMPANY_LOCK_DATE', opLog, {
requestId,
details: { lockDate, entryDate: period.period_start },
})
}
if (!period.opening_balances_set || !period.opening_balance_entry_id) {
return errorResponseFromCode('OB_CORRECT_NO_EXISTING', opLog, { requestId })
}
const { count: yearEndCount } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscal_period_id)
.eq('source_type', 'year_end')
.eq('status', 'posted')
if ((yearEndCount ?? 0) > 0) {
return errorResponseFromCode('OB_CORRECT_YEAR_END_EXISTS', opLog, { requestId })
}
const entryId = period.opening_balance_entry_id
// Seed standard BAS accounts the replacement lines reference but the
// chart lacks (same courtesy as the storno flow); unknown numbers fail
// the RPC's chart check with a clear error.
const accountNumbers = [...new Set(new_lines.map((l) => l.account_number))]
if (accountNumbers.length > 0) {
await backfillStandardBASAccounts(supabase, companyId!, user.id, accountNumbers)
}
const { data: rpcData, error: rpcError } = await supabase.rpc('correct_entry_lines_inline', {
p_company_id: companyId,
p_entry_id: entryId,
p_strike_line_ids: strike_line_ids,
p_new_lines: new_lines.map((l) => ({
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
line_description: l.line_description ?? null,
dimensions: l.dimensions ?? {},
})),
p_user_id: user.id,
})
if (rpcError) {
// Rule violations are plain RAISE EXCEPTION (P0001) with user-facing
// Swedish messages: surface verbatim (same approach as the generic
// strike-lines route). Tenant guard raises 42501.
if (rpcError.code === 'P0001') {
return NextResponse.json(
{
error: {
code: 'OB_INLINE_REFUSED',
message: getUserErrorMessage(rpcError, { locale: 'sv' }),
message_en: getUserErrorMessage(rpcError, { locale: 'en' }),
requestId,
},
},
{ status: 409 },
)
}
if (rpcError.code === '42501') {
return NextResponse.json(
{ error: { code: 'FORBIDDEN', message: getUserErrorMessage(rpcError), requestId } },
{ status: 403 },
)
}
opLog.error('correct_entry_lines_inline failed for IB', new Error(rpcError.message), { entryId })
return errorResponseFromCode('OB_CORRECT_FAILED', opLog, {
requestId,
details: { reason: getUserErrorMessage(rpcError) },
})
}
// Base rättelse committed. Cascade is best-effort on top, one inline
// rättelse per later year; a failure there never errors this request.
//
// The delta comes from the RPC's OWN rättelse-log row (struck_lines /
// added_lines snapshotted inside the RPC transaction), never from a
// pre-RPC read: a concurrent edit between a route-side snapshot and the
// RPC could otherwise cascade a delta that no longer matches what was
// actually committed to the base year.
let cascadeResult: CascadeResult | null = null
if (cascade) {
try {
const logId = (rpcData as { log_id?: string } | null)?.log_id
const { data: logRow, error: logError } = await supabase
.from('journal_entry_rattelse_log')
.select('struck_lines, added_lines')
.eq('id', logId)
.eq('company_id', companyId)
.single()
if (logError || !logRow) {
throw new Error(`rättelse log fetch failed: ${logError?.message ?? 'not found'}`)
}
const toLines = (raw: unknown) =>
((raw ?? []) as Array<{ account_number: string; debit_amount: number | string; credit_amount: number | string }>).map(
(l) => ({
account_number: l.account_number,
debit_amount: Number(l.debit_amount) || 0,
credit_amount: Number(l.credit_amount) || 0,
}),
)
const deltas = computeAccountDeltas(toLines(logRow.struck_lines), toLines(logRow.added_lines))
cascadeResult = await cascadeOpeningBalanceCorrection(supabase, companyId!, user.id, {
basePeriodStart: period.period_start,
deltas,
lockDate,
mode: 'inline',
log: opLog,
})
} catch (cascadeErr) {
opLog.error('inline opening balance cascade failed', cascadeErr as Error)
cascadeResult = { corrected: [], skipped: [], failed: true }
}
}
return NextResponse.json({
data: {
success: true,
journal_entry_id: entryId,
rattelse: rpcData,
...(cascadeResult ? { cascade: cascadeResult } : {}),
},
})
},
{ requireWrite: true },
)
@@ -0,0 +1,193 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockCreateJournalEntry = vi.fn()
const mockReverseEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
}))
vi.mock('@/lib/bookkeeping/bas-reference', () => ({
getBASReference: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/supabase/fetch-all', () => ({
fetchAllRows: vi.fn().mockResolvedValue([
{ account_number: '1930' },
{ account_number: '2099' },
]),
}))
// The cascade itself has its own unit tests (lib/import/opening-balance/
// __tests__/cascade.test.ts); here we verify the route wires it correctly:
// flag → original-lines fetch → delta computation → cascade call → response.
const mockCascade = vi.fn()
const mockFetchOriginalLines = vi.fn()
vi.mock('@/lib/import/opening-balance/cascade', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
cascadeOpeningBalanceCorrection: (...args: unknown[]) => mockCascade(...args),
fetchEntryOpeningBalanceLines: (...args: unknown[]) => mockFetchOriginalLines(...args),
}
})
import { POST } from '../route'
const PERIOD_ID = '550e8400-e29b-41d4-a716-446655440000'
const CORRECTED_LINES = [
{ account_number: '1930', debit_amount: 40000, credit_amount: 0 },
{ account_number: '2099', debit_amount: 0, credit_amount: 40000 },
]
function makeRequest(body: unknown) {
return createMockRequest('/api/import/opening-balance/correct', {
method: 'POST',
body,
})
}
const ROUTE_PARAMS = { params: Promise.resolve({}) }
interface CorrectResponse {
data: {
success: boolean
cascade?: {
corrected: Array<Record<string, unknown>>
skipped: Array<Record<string, unknown>>
}
}
}
function enqueueHappyPath() {
enqueue({
data: {
id: PERIOD_ID,
company_id: 'company-1',
is_closed: false,
locked_at: null,
opening_balances_set: true,
opening_balance_entry_id: 'entry-old',
period_start: '2019-01-01',
opening_balance_entry: { voucher_series: 'A', voucher_number: 1 },
},
}) // period
enqueue({ data: { bookkeeping_locked_through: null } }) // lock-date pre-flight
enqueue({ count: 0 }) // year-end check
enqueue({ error: null }) // relink RPC
}
describe('POST /api/import/opening-balance/correct: cascade wiring', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 9 })
mockReverseEntry.mockResolvedValue({ id: 'entry-storno' })
mockFetchOriginalLines.mockResolvedValue([
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
{ account_number: '2099', debit_amount: 0, credit_amount: 50000 },
])
mockCascade.mockResolvedValue({
corrected: [
{
fiscal_period_id: 'period-2020',
period_name: '2020',
journal_entry_id: 'ib-2020-new',
reversed_entry_id: 'ib-2020',
},
],
skipped: [{ fiscal_period_id: 'period-2021', period_name: '2021', reason: 'closed' }],
})
})
it('runs the cascade with the correction deltas and returns its result', async () => {
enqueueHappyPath()
const res = await POST(
makeRequest({ fiscal_period_id: PERIOD_ID, lines: CORRECTED_LINES, cascade: true }),
ROUTE_PARAMS,
)
const { status, body } = await parseJsonResponse<CorrectResponse>(res)
expect(status).toBe(200)
expect(body.data.success).toBe(true)
expect(body.data.cascade).toEqual({
corrected: [expect.objectContaining({ fiscal_period_id: 'period-2020' })],
skipped: [expect.objectContaining({ fiscal_period_id: 'period-2021', reason: 'closed' })],
})
// Original lines are fetched from the OLD entry (before it is stornoed).
expect(mockFetchOriginalLines).toHaveBeenCalledWith(expect.anything(), 'company-1', 'entry-old')
// The cascade gets the base period start and the per-account delta
// (1930: 40000 - 50000 = -10000, 2099: -40000 - (-50000) = +10000).
expect(mockCascade).toHaveBeenCalledTimes(1)
const opts = mockCascade.mock.calls[0][3] as {
basePeriodStart: string
deltas: Map<string, number>
lockDate: string | null
}
expect(opts.basePeriodStart).toBe('2019-01-01')
expect(opts.lockDate).toBeNull()
expect(opts.deltas.get('1930')).toBe(-10000)
expect(opts.deltas.get('2099')).toBe(10000)
})
it('does not touch later periods when cascade is omitted', async () => {
enqueueHappyPath()
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: CORRECTED_LINES }), ROUTE_PARAMS)
const { status, body } = await parseJsonResponse<CorrectResponse>(res)
expect(status).toBe(200)
expect(body.data.success).toBe(true)
expect(body.data.cascade).toBeUndefined()
expect(mockFetchOriginalLines).not.toHaveBeenCalled()
expect(mockCascade).not.toHaveBeenCalled()
})
it('still returns success for the base correction when the cascade throws unexpectedly', async () => {
enqueueHappyPath()
mockCascade.mockRejectedValue(new Error('cascade boom'))
const res = await POST(
makeRequest({ fiscal_period_id: PERIOD_ID, lines: CORRECTED_LINES, cascade: true }),
ROUTE_PARAMS,
)
const { status, body } = await parseJsonResponse<CorrectResponse>(res)
// The base correction is already committed; the response must not flip to
// an error the caller would retry (double-correcting the base year), but
// the failure must be visible so the client can tell the user to check.
expect(status).toBe(200)
expect(body.data.success).toBe(true)
expect(body.data.cascade).toEqual({ corrected: [], skipped: [], failed: true })
})
})
@@ -1,14 +1,21 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { OpeningBalanceExecuteSchema } from '@/lib/api/schemas'
import { OpeningBalanceCorrectSchema } from '@/lib/api/schemas'
import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import {
validateOpeningBalanceLines,
activateMissingAccounts,
buildOpeningBalanceEntryLines,
type OpeningBalanceLine,
} from '@/lib/import/opening-balance/execute-helpers'
import {
cascadeOpeningBalanceCorrection,
computeAccountDeltas,
fetchEntryOpeningBalanceLines,
type CascadeResult,
} from '@/lib/import/opening-balance/cascade'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -32,19 +39,26 @@ ensureInitialized()
* Gated to the safe case only: the period must be open, unlocked, already have
* opening balances, and have no year-end close on top. Locked/closed periods or
* periods with a bokslut must be unwound first (assisted): we refuse here.
*
* With `cascade: true` the same per-account delta is then applied to every
* subsequent year's linked IB verifikat (storno + rebook + relink per year;
* see lib/import/opening-balance/cascade.ts). SIE migrations book one IB per
* imported year, so without the cascade a base-year correction leaves later
* years' IB verifikat carrying the stale figures. Uncorrectable years are
* skipped and reported in the response, never forced.
*/
export const POST = withRouteContext(
'opening_balance.correct',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
const result = await validateBody(request, OpeningBalanceExecuteSchema, {
const result = await validateBody(request, OpeningBalanceCorrectSchema, {
log,
operation: 'opening_balance.correct',
})
if (!result.success) return result.response
const { fiscal_period_id, lines } = result.data
const { fiscal_period_id, lines, cascade } = result.data
const opLog = log.child({ fiscalPeriodId: fiscal_period_id })
try {
@@ -143,6 +157,13 @@ export const POST = withRouteContext(
})
}
// Cascade needs the ORIGINAL lines to compute the per-account delta,
// so fetch them before the old entry is stornoed below.
let originalLines: OpeningBalanceLine[] | null = null
if (cascade) {
originalLines = await fetchEntryOpeningBalanceLines(supabase, companyId!, oldEntryId)
}
// BFL 5 kap 5§: reference the original verifikat so the correction is
// traceable to the entry it rättar. The embed above gave us the old IB's
// voucher label (e.g. "A123"). CreateJournalEntryInput exposes no dedicated
@@ -254,6 +275,26 @@ export const POST = withRouteContext(
})
}
// The base correction is committed at this point. The cascade to later
// years is best-effort on top: each year is corrected independently and
// an unexpected failure must not turn the whole request into an error
// (the base correction cannot be un-done here).
let cascadeResult: CascadeResult | null = null
if (cascade && originalLines) {
try {
const deltas = computeAccountDeltas(originalLines, validLines)
cascadeResult = await cascadeOpeningBalanceCorrection(supabase, companyId!, user.id, {
basePeriodStart: period.period_start,
deltas,
lockDate,
log: opLog,
})
} catch (cascadeErr) {
opLog.error('opening balance cascade failed', cascadeErr as Error)
cascadeResult = { corrected: [], skipped: [], failed: true }
}
}
return NextResponse.json({
data: {
success: true,
@@ -263,6 +304,7 @@ export const POST = withRouteContext(
lines_created: validLines.length,
total_debit: totalDebit,
total_credit: totalCredit,
...(cascadeResult ? { cascade: cascadeResult } : {}),
},
})
} catch (err) {