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 && ( + + )} +