From 2814d70cb4286bd1bd21953c7f2f102b109168e8 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:01:11 +0200 Subject: [PATCH] feat(bookkeeping): Fortnox-style inline IB correction + cascade to later years (#2076) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 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 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 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 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 Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 3 + .../correct-inline/__tests__/route.test.ts | 220 ++++++++ .../opening-balance/correct-inline/route.ts | 209 ++++++++ .../correct/__tests__/cascade-route.test.ts | 193 +++++++ .../import/opening-balance/correct/route.ts | 48 +- .../CorrectOpeningBalanceDialog.tsx | 265 +++++++++- lib/agent/intents/shared-rules.ts | 2 +- lib/api/schemas.ts | 22 + .../opening-balance/__tests__/cascade.test.ts | 457 +++++++++++++++++ lib/import/opening-balance/cascade.ts | 405 +++++++++++++++ scripts/checks/typecheck-baseline.json | 3 +- ...150000_inline_rattelse_opening_balance.sql | 481 ++++++++++++++++++ ...inline-rattelse-opening-balance.pg.test.ts | 224 ++++++++ 13 files changed, 2502 insertions(+), 30 deletions(-) create mode 100644 app/api/import/opening-balance/correct-inline/__tests__/route.test.ts create mode 100644 app/api/import/opening-balance/correct-inline/route.ts create mode 100644 app/api/import/opening-balance/correct/__tests__/cascade-route.test.ts create mode 100644 lib/import/opening-balance/__tests__/cascade.test.ts create mode 100644 lib/import/opening-balance/cascade.ts create mode 100644 supabase/migrations/20260831150000_inline_rattelse_opening_balance.sql create mode 100644 tests/pg/inline-rattelse-opening-balance.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 4df21adb..070cf734 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1384,6 +1384,9 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-31] Login/register methods come from GoTrue (/auth/v1/settings + admin customProviders) instead of app-side flags; NEXT_PUBLIC_GOOGLE_AUTH_ENABLED removed (PR #1869): the Supabase dashboard becomes the single switch, an allowlist of auth-js provider ids filters non-login entries like anonymous_users, and hosted rendering is unchanged because Google is enabled in prod GoTrue. The Vercel env var stays set for old-build rollback safety; delete it after a few deploys. [2026-08-31] Single prominent amount is PROMOTED into editable totals.total (totalSource='prominent') instead of living in a read-only Belopp row: Emil's call, an uncorrectable load-bearing value violated the prefill-override-editors rule. Provenance keeps matching fallback-grade (discount, date guard, hunt exclusion); a user edit of TOTALT clears the stamp. Multi-amount docs keep the Belopp row: promoting one of several figures would invent a total. [2026-08-31] Image-scan red fixed by bumping the node:22-alpine digest (alpine 3.23 to 3.24.1), not by widening the gate: the Dockerfile's apk-upgrade layer is frozen by the GHCR buildx layer cache, so a fix published after the last cache-busting change (libssl3 3.5.8-r0 for CVE-2026-14456) never reaches the published image until the FROM digest moves; the red scheduled scan is the designed alarm for exactly this bump. cron.Dockerfile gained the same apk upgrade (it had none). +[2026-08-31] Opening-balance cascade (later years' IB) is opt-in per request (cascade flag, dialog checkbox default-checked) and per-year best-effort: locked/closed/lock-dated/bokslut years are skipped and reported, never forced (DB triggers are the legal guard), and a cascade failure never errors the already-committed base correction. Blocked-year guidance is computed client-side from the fiscal-periods list instead of enriching the refusal responses: keeps the refusal paths' query order stable and adds zero server queries. +[2026-08-31] Cascade rewritten onto replaceOpeningBalanceEntry (commit_opening_balance_replacement RPC) after review findings on PR #2076: the create/reverse/relink+compensation sequence could leave a period linked to a reversed IB entry; the engine already had the atomic storno+rebook+pointer-swap primitive (built for SIE resync), so the cascade uses it, keeps original lines verbatim (dimensions included) and appends labelled IB-rättelse adjustment lines instead of collapsing per account. +[2026-08-31] Fortnox-style IB editing (founder-directed): correct_entry_lines_inline redefined (20260831150000) to admit opening_balance entries with three IB guards (current-linked-IB only, no posted bokslut, class 1-2 replacement lines); new /api/import/opening-balance/correct-inline route + dialog switched from storno-replace to diff-based inline strike/replace, and the cascade gained mode 'inline' (delta appended as IB-rättelse lines in later years' own verifikat: zero new verifikat). Storno endpoints kept untouched for locked years, the import replace flow, and API compat. Chosen over remodeling IB as editable saldon (Fortnox's storage model): that would be a data migration + verifikat-invariant break, while inline rättelse gives the same UX on the existing model. [2026-08-31] Per-line discount stores NET line_total via computeLineNet (roundOre(gross) minus roundOre(gross*d/100)) rather than round(gross*(1-d/100)): the subtracted-rounded-discount form keeps gross = discount + net exact in ore arithmetic, which the Peppol BG-27 line allowance (Amount + LineExtensionAmount = BaseAmount) and the PDF discount column both need; undiscounted lines keep the legacy unrounded qty*price so existing invoices and the Peppol LINE_TOTAL check stay byte-identical. ROT/RUT deducts on the discounted net (the customer pays that). invoice_marking is deliberately NOT copied by copy-invoice (recipient/PO-specific, same rule as your_reference) and NOT added to recurring schedules (follow-up if requested). [2026-08-31] Own-company-as-supplier guard nulls the supplier block instead of flagging or substituting the issuer: an empty LEVERANTOR is always safe, a guessed issuer is not; BYO/agent-supplied extraction paths are deliberately exempt (explicit input, not a model misread). [2026-08-31] gnubok-home-ok cache cookie is user-scoped (userId~host) instead of cleared on sign-out: sign-out happens client-side via supabase.auth.signOut so no server surface reliably sees it, while a value bound to the session's user makes any inherited verdict miss the cache by construction. Separator ~ because it is unreserved under encodeURIComponent AND a legal raw cookie octet, so the value round-trips identically whether or not the cookie layer percent-encodes. Old host-only cookies never match and self-heal; found via the amnas account-switch repro (two logins 9 s apart shared the verdict). diff --git a/app/api/import/opening-balance/correct-inline/__tests__/route.test.ts b/app/api/import/opening-balance/correct-inline/__tests__/route.test.ts new file mode 100644 index 00000000..46cac8d7 --- /dev/null +++ b/app/api/import/opening-balance/correct-inline/__tests__/route.test.ts @@ -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 + 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 = {}) { + 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(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(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(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(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 + } + 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(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(res) + + expect(status).toBe(409) + expect(body.error?.code).toBe('OB_INLINE_REFUSED') + expect(body.error?.message).toContain('balanserar inte') + expect(mockCascade).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/import/opening-balance/correct-inline/route.ts b/app/api/import/opening-balance/correct-inline/route.ts new file mode 100644 index 00000000..4dc3d62a --- /dev/null +++ b/app/api/import/opening-balance/correct-inline/route.ts @@ -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 }, +) diff --git a/app/api/import/opening-balance/correct/__tests__/cascade-route.test.ts b/app/api/import/opening-balance/correct/__tests__/cascade-route.test.ts new file mode 100644 index 00000000..1a9acf99 --- /dev/null +++ b/app/api/import/opening-balance/correct/__tests__/cascade-route.test.ts @@ -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 + 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> + skipped: Array> + } + } +} + +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(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 + 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(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(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 }) + }) +}) diff --git a/app/api/import/opening-balance/correct/route.ts b/app/api/import/opening-balance/correct/route.ts index 2ffb8bee..50536773 100644 --- a/app/api/import/opening-balance/correct/route.ts +++ b/app/api/import/opening-balance/correct/route.ts @@ -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) { diff --git a/components/bookkeeping/CorrectOpeningBalanceDialog.tsx b/components/bookkeeping/CorrectOpeningBalanceDialog.tsx index 9fd9e899..7eaa7522 100644 --- a/components/bookkeeping/CorrectOpeningBalanceDialog.tsx +++ b/components/bookkeeping/CorrectOpeningBalanceDialog.tsx @@ -1,6 +1,7 @@ 'use client' -import { useMemo, useState, useCallback } from 'react' +import { useEffect, useMemo, useState, useCallback } from 'react' +import Link from 'next/link' import { Dialog, DialogContent, @@ -10,7 +11,7 @@ import { DialogFooter, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' -import { AlertTriangle } from 'lucide-react' +import { Checkbox } from '@/components/ui/checkbox' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' @@ -20,6 +21,8 @@ import OpeningBalanceRowEditor, { type EditableRow, type OpeningBalanceEditorState, } from '@/components/import/OpeningBalanceRowEditor' +import { useFiscalPeriods } from '@/lib/reference-data/hooks' +import { invalidateReferenceData } from '@/lib/reference-data/invalidate' import type { JournalEntry, JournalEntryLine } from '@/types' interface Props { @@ -30,6 +33,22 @@ interface Props { onCorrected: () => void } +interface CascadeSummary { + corrected: Array<{ fiscal_period_id: string; period_name: string | null }> + skipped: Array<{ fiscal_period_id: string; period_name: string | null; reason: string }> + /** The cascade itself failed to run: later years are unverified. */ + failed?: boolean +} + +/** Error codes where the year itself blocks the correction: guide the user + * to the earliest open year instead of leaving them at a dead end. */ +const BLOCKED_PERIOD_CODES = new Set([ + 'OB_PERIOD_CLOSED', + 'OB_PERIOD_LOCKED', + 'OB_COMPANY_LOCK_DATE', + 'OB_CORRECT_YEAR_END_EXISTS', +]) + let seedIdCounter = 0 // Map the booked IB's lines into editable rows. account_name isn't stored on @@ -61,6 +80,11 @@ function seedRowsFromEntry(entry: JournalEntry): EditableRow[] { * /api/import/opening-balance/correct, which (BFL-compliant) stornoes the old * IB, books a corrected one, and relinks the period to it. Works regardless of * how the IB was created (SIE import, CSV/Excel import, or year-end carry). + * + * SIE migrations book one IB verifikat per imported year, so later years' + * saldon build on this one. When later years have their own IB verifikat the + * dialog offers to cascade the same change to them (checked by default); the + * server skips years that are locked, closed, or have a bokslut. */ export default function CorrectOpeningBalanceDialog({ entry, @@ -75,24 +99,130 @@ export default function CorrectOpeningBalanceDialog({ const initialRows = useMemo(() => seedRowsFromEntry(entry), [entry, basReady]) const [state, setState] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) + const [cascade, setCascade] = useState(true) + const [showBlockedGuidance, setShowBlockedGuidance] = useState(false) + + // Shared reference cache: drives the later-years checkbox and the + // blocked-year guidance. Best-effort: the dialog works while it loads + // (the server remains the authority on what is correctable). + const { periods } = useFiscalPeriods() + + // A fresh open starts without the server-refusal guidance from a prior try. + useEffect(() => { + if (open) setShowBlockedGuidance(false) + }, [open]) + + const currentPeriod = useMemo( + () => periods.find((p) => p.id === entry.fiscal_period_id) ?? null, + [periods, entry.fiscal_period_id], + ) + + /** Later years with their own linked IB verifikat: the cascade targets. */ + const laterPeriodsWithIB = useMemo(() => { + if (!currentPeriod) return [] + return periods + .filter( + (p) => + p.period_start > currentPeriod.period_start && p.opening_balance_entry_id !== null, + ) + .sort((a, b) => a.period_start.localeCompare(b.period_start)) + }, [periods, currentPeriod]) + + /** Earliest year that still accepts corrections: the guidance target when + * this year is locked. Client-side approximation; the server re-checks. */ + const earliestOpenPeriod = useMemo(() => { + return ( + [...periods] + .sort((a, b) => a.period_start.localeCompare(b.period_start)) + .find((p) => !p.is_closed && !p.locked_at && p.opening_balance_entry_id !== null) ?? null + ) + }, [periods]) + + const currentPeriodBlocked = + currentPeriod !== null && (currentPeriod.is_closed || currentPeriod.locked_at !== null) + + const guidancePeriod = + earliestOpenPeriod && earliestOpenPeriod.id !== entry.fiscal_period_id + ? earliestOpenPeriod + : null const handleSubmit = useCallback(async () => { if (!state?.canSubmit || isSubmitting) return setIsSubmitting(true) try { - const lines = state.rows - .filter((r) => r.debit_amount > 0 || r.credit_amount > 0) - .map((r) => ({ - account_number: r.account_number, - debit_amount: r.debit_amount, - credit_amount: r.credit_amount, - })) + // Diff the edited rows against the booked lines: only changed rows are + // struck and re-added (inline rättelse in the SAME verifikat, no + // storno). Untouched rows keep their ids, descriptions and dimensions. + const originalLines = ((entry.lines || []) as JournalEntryLine[]) + const rowById = new Map(state.rows.map((r) => [r.id, r])) - const res = await fetch('/api/import/opening-balance/correct', { + const strike_line_ids: string[] = [] + const new_lines: Array<{ + account_number: string + debit_amount: number + credit_amount: number + line_description?: string + dimensions?: Record + }> = [] + + for (const orig of originalLines) { + const row = orig.id ? rowById.get(orig.id) : undefined + if (!row || (row.debit_amount <= 0 && row.credit_amount <= 0)) { + // Row removed or zeroed out by the user: strike without replacement. + if (orig.id) strike_line_ids.push(orig.id) + continue + } + const changed = + row.account_number !== orig.account_number || + row.debit_amount !== (Number(orig.debit_amount) || 0) || + row.credit_amount !== (Number(orig.credit_amount) || 0) + if (changed && orig.id) { + strike_line_ids.push(orig.id) + new_lines.push({ + account_number: row.account_number, + debit_amount: row.debit_amount, + credit_amount: row.credit_amount, + line_description: + row.account_number === orig.account_number + ? orig.line_description ?? undefined + : `IB ${row.account_number}`, + dimensions: orig.dimensions, + }) + } + } + + const originalIds = new Set(originalLines.map((l) => l.id)) + for (const row of state.rows) { + if (originalIds.has(row.id)) continue + if (row.debit_amount <= 0 && row.credit_amount <= 0) continue + new_lines.push({ + account_number: row.account_number, + debit_amount: row.debit_amount, + credit_amount: row.credit_amount, + line_description: `IB ${row.account_number}`, + }) + } + + if (strike_line_ids.length === 0 && new_lines.length === 0) { + toast({ title: 'Inga ändringar att spara' }) + setIsSubmitting(false) + return + } + + const res = await fetch('/api/import/opening-balance/correct-inline', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fiscal_period_id: entry.fiscal_period_id, lines }), + // cascade is ALWAYS sent (default on): if the reference cache has not + // loaded yet the checkbox is simply not shown, and omitting the flag + // in that window would silently leave later years stale. The server + // returns an empty cascade result when no later period exists. + body: JSON.stringify({ + fiscal_period_id: entry.fiscal_period_id, + strike_line_ids, + new_lines, + cascade, + }), }) const result = await res.json() @@ -107,14 +237,48 @@ export default function CorrectOpeningBalanceDialog({ throw err } - toast({ - title: 'Ingående balanser korrigerade', - description: 'Den gamla IB-verifikationen stornades och en ny bokfördes.', - }) + const cascadeSummary = (result?.data?.cascade ?? null) as CascadeSummary | null + let description = 'Beloppen uppdaterades direkt i verifikationen. Ingen ny verifikation skapades.' + if (cascadeSummary) { + const done = cascadeSummary.corrected.length + // Blocked (locked/closed/bokslut) and failed skips are different + // situations for the user: blocked is expected and needs no action + // here; failed means the year was left untouched and needs a look. + const blocked = cascadeSummary.skipped.filter( + (s) => s.reason === 'closed' || s.reason === 'locked' || s.reason === 'lock_date' || s.reason === 'year_end', + ) + const failed = cascadeSummary.skipped.filter( + (s) => s.reason === 'correction_failed' || s.reason === 'validation_failed', + ) + if (done > 0) { + description += ` ${done} senare räkenskapsår uppdaterades också.` + } + if (blocked.length > 0) { + const names = blocked.map((s) => s.period_name).filter(Boolean).join(', ') + description += ` ${blocked.length} år hoppades över (låsta, stängda eller med bokslut)${names ? `: ${names}` : ''}.` + } + if (failed.length > 0) { + const names = failed.map((s) => s.period_name).filter(Boolean).join(', ') + description += ` ${failed.length} år kunde inte uppdateras och behöver kontrolleras${names ? `: ${names}` : ''}.` + } + if (cascadeSummary.failed) { + description += + ' Uppdateringen av senare räkenskapsår kunde inte genomföras: kontrollera deras ingående balanser.' + } + } + + toast({ title: 'Ingående balanser korrigerade', description }) + // The correction relinks fiscal_periods.opening_balance_entry_id (for + // every cascaded year too): refresh the shared reference cache. + await invalidateReferenceData('ref:fiscal-periods') onOpenChange(false) onCorrected() } catch (err) { const anyErr = err as { body?: unknown; status?: number } + const code = (anyErr.body as { error?: { code?: string } } | undefined)?.error?.code + if (code && BLOCKED_PERIOD_CODES.has(code)) { + setShowBlockedGuidance(true) + } toast({ title: 'Kunde inte korrigera ingående balanser', description: getErrorMessage(anyErr.body ?? err, { @@ -126,7 +290,7 @@ export default function CorrectOpeningBalanceDialog({ } finally { setIsSubmitting(false) } - }, [state, isSubmitting, entry.fiscal_period_id, toast, onOpenChange, onCorrected]) + }, [state, isSubmitting, entry.fiscal_period_id, entry.lines, cascade, toast, onOpenChange, onCorrected]) return ( @@ -134,23 +298,76 @@ export default function CorrectOpeningBalanceDialog({ Korrigera ingående balanser - Ändra beloppen nedan och spara. Den befintliga IB-verifikationen ( - {formatVoucher(entry)}) makuleras och en ny bokförs med - de korrigerade beloppen. + Ändra beloppen nedan och spara. Verifikationen ( + {formatVoucher(entry)}) uppdateras direkt: ingen ny + verifikation skapas. - {/* Storno explanation: a booked verifikat can't be edited in place */} + {/* Inline rättelse (BFL 5 kap 5 §): edited in place, original logged */}
- -

- En bokförd verifikation kan inte ändras direkt (Bokföringslagen). När du sparar stornas - den gamla IB-verifikationen och en ny bokförs: båda sparas som en spårbar rättelse. +

+ Ändringen sparas som en spårbar rättelse i samma verifikation (Bokföringslagen 5 kap + 5 §): de ursprungliga raderna bevaras i rättelseloggen.

+ {/* Blocked-year guidance: shown when this year is (or the server says + it is) locked, closed, or has a bokslut. Instead of a dead end, + point at the earliest year that still accepts corrections. */} + {(currentPeriodBlocked || showBlockedGuidance) && ( +
+

+ Det här räkenskapsåret är låst, stängt eller har ett bokslut, så dess ingående + balanser kan inte korrigeras här. +

+ {guidancePeriod ? ( +

+ Korrigera i stället ingående balansen för{' '} + + {guidancePeriod.name || guidancePeriod.period_start.slice(0, 4)} + + , det tidigaste öppna året. Då blir saldona rätt framåt. Tidigare, låsta år är + normalt redan deklarerade sedan tidigare; behöver ett sådant år ändå rättas + måste det först låsas upp. +

+ ) : ( +

+ För att korrigera behöver året först låsas upp (eller bokslutet återföras) under + Bokföring → Räkenskapsår. +

+ )} +
+ )} + + {/* Cascade opt-out: later years imported from SIE carry their own IB + verifikat with the old figures; without this they stay wrong. */} + {laterPeriodsWithIB.length > 0 && ( + + )} +