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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user