feat(bookkeeping): Fortnox-style inline IB correction + cascade to later years (#2076)

* feat(bookkeeping): cascade opening-balance corrections to later years

Fortnox/SIE migrations book one IB verifikat per imported year, so
correcting one year's ingaende balans left every later year's linked IB
carrying the stale figures (support case: a 2019 IB fixed in Fortnox
after export never reached Accounted, skewing all subsequent saldon).

- POST /api/import/opening-balance/correct accepts cascade: true and
  applies the correction's per-account delta to each subsequent year's
  IB via storno + rebook + relink (lib/import/opening-balance/cascade.ts).
  Locked/closed/lock-dated/bokslut years are skipped and reported, never
  forced; a failed year is compensated and the cascade continues.
- CorrectOpeningBalanceDialog offers the cascade as a default-checked
  checkbox when later years have their own IB verifikat, and when the
  current year is blocked it points at the earliest open year's IB
  verifikat instead of dead-ending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY

* fix(bookkeeping): atomic cascade replacement + review findings for PR #2076

- Cascade now books each later year through replaceOpeningBalanceEntry
  (one RPC transaction: storno + corrected voucher + pointer swap, CAS
  on the expected old entry), removing the create/reverse/relink window
  that could leave a period linked to a reversed IB entry.
- Cascaded verifikat keep the original lines verbatim (descriptions and
  dimensions) and append labelled IB-rättelse adjustment lines per
  changed account instead of collapsing per-account nets.
- Year-end lookup fails closed: a query error skips the period instead
  of reading as 'no bokslut'.
- Dialog always sends the cascade flag (a cold reference cache no longer
  silently disables the default-on cascade), the success toast separates
  blocked years from failed years needing review, and the checkbox notes
  that a resultat correction may still need an omforing to 2091.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY

* feat(bookkeeping): Fortnox-style inline IB correction without storno

Founder decision 2026-08-31: IB edits in open unlocked years should feel
like Fortnox (change the number, no extra verifikat) instead of always
producing a storno + rebook pair in serie A.

- Migration 20260831150000 redefines correct_entry_lines_inline to admit
  source_type 'opening_balance' with three IB guards: only the period's
  current linked IB, no posted bokslut on the period, and replacement
  lines restricted to balance-sheet accounts (class 1-2). The entry id
  never changes, so fiscal_periods.opening_balance_entry_id stays valid
  and every report reads the corrected lines automatically. Storno,
  year_end and vat_settlement stay excluded; locked/closed/lock-dated
  periods are still refused (BFL 5 kap 5 par: storno is the only track
  there).
- New POST /api/import/opening-balance/correct-inline: diff-based strike
  and replace inside the same IB verifikat, same OB_* pre-flight codes
  as the storno route, RPC rule violations surfaced verbatim as 409
  OB_INLINE_REFUSED. With cascade: true the per-account delta is
  appended as labelled IB-rattelse lines inside each later open year's
  own IB verifikat (cascade mode 'inline'): a multi-year correction
  with zero new verifikat.
- CorrectOpeningBalanceDialog computes the row diff (untouched lines
  keep ids, descriptions and dimensions) and posts to the inline route;
  copy updated (no storno language), toast reports inline updates.
- In-app agent guidance (shared-rules) updated to describe the inline
  flow and the cascade checkbox.
- Tests: pg-real suite for the redefined RPC (IB accept, linked-IB
  guard, bokslut guard, P&L guard, structural types still refused,
  non-IB unaffected), route tests, cascade inline-mode unit tests.

The storno-based /correct route and engine paths are untouched: they
remain for the import replace flow and API compatibility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY

* fix(agent): avoid the BFL 5 kap 5 par marker string in IB guidance

The verifikation-draft period-lock gate test uses the literal
'BFL 5 kap 5 §' as a marker for locked-period-only guidance; the new IB
bullet in shared-rules carried the same string in every prompt and broke
the open-period assertion. Reference Bokföringslagen generically instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY

* fix(bookkeeping): derive inline cascade delta from the rattelse log

Swedish-review finding on PR #2076: the cascade delta was computed from
a route-side line snapshot read before the RPC, which a concurrent edit
could theoretically desync from what the RPC actually committed. The
delta now comes from the RPC's own journal_entry_rattelse_log row
(struck_lines/added_lines snapshotted inside the RPC transaction), so
the cascade always matches the committed base correction. Also softened
the blocked-year guidance copy (declared-status is an assumption, not a
verified fact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY

* fix(bookkeeping): visible cascade failure + dimensions-aware no-op check

CodeRabbit round-2 findings on PR #2076:
- A cascade that failed to run (log fetch error, unexpected throw) was
  returned as an empty successful summary, so the dialog reported
  nothing wrong while later years stayed unverified. Both routes now
  mark it failed: true and the dialog tells the user to check later
  years' opening balances.
- The RPC's no-op guard compared account/amount/description only, so a
  dimensions-only rattelse raised 'Rattelsen andrar ingenting'. The
  comparison keys now include canonical dimensions jsonb text (fixed in
  the unmerged 20260831150000 migration).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-31 16:01:11 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f216a60bf8
commit 2814d70cb4
13 changed files with 2502 additions and 30 deletions
+3
View File
@@ -1384,6 +1384,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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).
@@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockBackfill = vi.fn().mockResolvedValue([])
vi.mock('@/lib/bookkeeping/account-backfill', () => ({
backfillStandardBASAccounts: (...args: unknown[]) => mockBackfill(...args),
}))
// The cascade has its own unit tests; here we verify the route wires it with
// mode 'inline' and the delta computed from struck vs new lines.
const mockCascade = vi.fn()
vi.mock('@/lib/import/opening-balance/cascade', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
cascadeOpeningBalanceCorrection: (...args: unknown[]) => mockCascade(...args),
}
})
import { POST } from '../route'
const PERIOD_ID = '550e8400-e29b-41d4-a716-446655440000'
const LINE_1930 = '11111111-1111-4111-8111-111111111111'
const ROUTE_PARAMS = { params: Promise.resolve({}) }
interface InlineResponse {
data: {
success: boolean
journal_entry_id: string
cascade?: { corrected: unknown[]; skipped: unknown[] }
}
error?: { code: string; message?: string }
}
function makeRequest(body: unknown) {
return createMockRequest('/api/import/opening-balance/correct-inline', {
method: 'POST',
body,
})
}
function openPeriod(overrides: Record<string, unknown> = {}) {
return {
id: PERIOD_ID,
period_start: '2024-01-01',
is_closed: false,
locked_at: null,
opening_balances_set: true,
opening_balance_entry_id: 'entry-ib',
...overrides,
}
}
const BODY = {
fiscal_period_id: PERIOD_ID,
strike_line_ids: [LINE_1930],
new_lines: [{ account_number: '1930', debit_amount: 55000, credit_amount: 0 }],
cascade: true,
}
describe('POST /api/import/opening-balance/correct-inline', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
mockCascade.mockResolvedValue({ corrected: [], skipped: [] })
})
it('returns 401 for unauthenticated requests', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const res = await POST(makeRequest(BODY), ROUTE_PARAMS)
const { status } = await parseJsonResponse(res)
expect(status).toBe(401)
})
it('returns 400 when nothing is struck or added', async () => {
const res = await POST(
makeRequest({ fiscal_period_id: PERIOD_ID, strike_line_ids: [], new_lines: [] }),
ROUTE_PARAMS,
)
const { status } = await parseJsonResponse(res)
expect(status).toBe(400)
})
it('returns 404 for an unknown fiscal period', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const res = await POST(makeRequest(BODY), ROUTE_PARAMS)
const { status, body } = await parseJsonResponse<InlineResponse>(res)
expect(status).toBe(404)
expect(body.error?.code).toBe('OB_PERIOD_NOT_FOUND')
})
it('refuses closed periods with the same code as the storno route', async () => {
enqueue({ data: openPeriod({ is_closed: true }) })
const res = await POST(makeRequest(BODY), ROUTE_PARAMS)
const { status, body } = await parseJsonResponse<InlineResponse>(res)
expect(status).toBe(400)
expect(body.error?.code).toBe('OB_PERIOD_CLOSED')
})
it('refuses periods with a posted bokslut', async () => {
enqueue({ data: openPeriod() }) // period
enqueue({ data: { bookkeeping_locked_through: null } }) // lock date
enqueue({ count: 1 }) // year-end check
const res = await POST(makeRequest(BODY), ROUTE_PARAMS)
const { status, body } = await parseJsonResponse<InlineResponse>(res)
expect(status).toBe(409)
expect(body.error?.code).toBe('OB_CORRECT_YEAR_END_EXISTS')
})
it('edits in place via the RPC and cascades inline with the delta from the rättelse log', async () => {
enqueue({ data: openPeriod() }) // period
enqueue({ data: { bookkeeping_locked_through: null } }) // lock date
enqueue({ count: 0 }) // year-end check
enqueue({ data: { log_id: 'log-1', struck_count: 1, added_count: 1 } }) // RPC
enqueue({
data: {
struck_lines: [{ account_number: '1930', debit_amount: 50000, credit_amount: 0 }],
added_lines: [{ account_number: '1930', debit_amount: 55000, credit_amount: 0 }],
},
}) // rättelse-log fetch (authoritative delta source)
const res = await POST(makeRequest(BODY), ROUTE_PARAMS)
const { status, body } = await parseJsonResponse<InlineResponse>(res)
expect(status).toBe(200)
expect(body.data.success).toBe(true)
expect(body.data.journal_entry_id).toBe('entry-ib')
expect(body.data.cascade).toEqual({ corrected: [], skipped: [] })
expect(mockSupabase.rpc).toHaveBeenCalledWith(
'correct_entry_lines_inline',
expect.objectContaining({
p_company_id: 'company-1',
p_entry_id: 'entry-ib',
p_strike_line_ids: [LINE_1930],
p_new_lines: [
expect.objectContaining({ account_number: '1930', debit_amount: 55000 }),
],
p_user_id: 'user-1',
}),
)
// Cascade runs in inline mode with delta = added (55000) minus struck
// (50000), sourced from the RPC's own rättelse-log row.
expect(mockCascade).toHaveBeenCalledTimes(1)
const opts = mockCascade.mock.calls[0][3] as {
basePeriodStart: string
mode: string
deltas: Map<string, number>
}
expect(opts.mode).toBe('inline')
expect(opts.basePeriodStart).toBe('2024-01-01')
expect(opts.deltas.get('1930')).toBe(5000)
})
it('does not cascade when the flag is omitted', async () => {
enqueue({ data: openPeriod() })
enqueue({ data: { bookkeeping_locked_through: null } })
enqueue({ count: 0 })
enqueue({ data: { log_id: 'log-1' } }) // RPC; no log fetch without cascade
const res = await POST(
makeRequest({ ...BODY, cascade: undefined }),
ROUTE_PARAMS,
)
const { status, body } = await parseJsonResponse<InlineResponse>(res)
expect(status).toBe(200)
expect(body.data.cascade).toBeUndefined()
expect(mockCascade).not.toHaveBeenCalled()
})
it('surfaces an RPC rule violation verbatim as 409 OB_INLINE_REFUSED', async () => {
enqueue({ data: openPeriod() })
enqueue({ data: { bookkeeping_locked_through: null } })
enqueue({ count: 0 })
enqueue({ data: null, error: { code: 'P0001', message: 'Verifikationen balanserar inte efter rättelsen (debet 55000, kredit 50000).' } })
const res = await POST(makeRequest(BODY), ROUTE_PARAMS)
const { status, body } = await parseJsonResponse<InlineResponse>(res)
expect(status).toBe(409)
expect(body.error?.code).toBe('OB_INLINE_REFUSED')
expect(body.error?.message).toContain('balanserar inte')
expect(mockCascade).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,209 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { OpeningBalanceCorrectInlineSchema } from '@/lib/api/schemas'
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
import {
cascadeOpeningBalanceCorrection,
computeAccountDeltas,
type CascadeResult,
} from '@/lib/import/opening-balance/cascade'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
/**
* POST /api/import/opening-balance/correct-inline
*
* Fortnox-style IB correction: strike changed lines and add replacements
* inside the SAME IB verifikat (BFL 5 kap 5 § track 2, rättelse in the same
* bokföringspost), via the correct_entry_lines_inline RPC. No storno, no new
* verifikat; the struck originals live on in journal_entry_rattelse_log and
* fiscal_periods.opening_balance_entry_id never changes.
*
* Only for open, unlocked years without a bokslut: the pre-flights below
* return the same OB_* codes as the storno-based /correct route (so the
* dialog's blocked-year guidance applies), and the RPC re-enforces the whole
* envelope transactionally.
*
* With `cascade: true` the per-account delta (added minus struck) is appended
* as labelled adjustment lines inside each subsequent year's own IB verifikat
* (cascade mode 'inline'): a multi-year correction with zero new verifikat.
* Locked/closed/bokslut years are skipped and reported, never forced.
*/
export const POST = withRouteContext(
'opening_balance.correct_inline',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
const result = await validateBody(request, OpeningBalanceCorrectInlineSchema, {
log,
operation: 'opening_balance.correct_inline',
})
if (!result.success) return result.response
const { fiscal_period_id, strike_line_ids, new_lines, cascade } = result.data
const opLog = log.child({ fiscalPeriodId: fiscal_period_id })
// Pre-flights mirror the storno-based /correct route (same OB_* codes so
// the client guidance is uniform). The RPC re-checks everything inside
// its transaction: these exist to give structured, actionable errors.
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id, period_start, is_closed, locked_at, opening_balances_set, opening_balance_entry_id')
.eq('id', fiscal_period_id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
return errorResponseFromCode('OB_PERIOD_NOT_FOUND', opLog, { requestId })
}
if (period.is_closed) {
return errorResponseFromCode('OB_PERIOD_CLOSED', opLog, { requestId })
}
if (period.locked_at) {
return errorResponseFromCode('OB_PERIOD_LOCKED', opLog, { requestId })
}
const { data: settings } = await supabase
.from('company_settings')
.select('bookkeeping_locked_through')
.eq('company_id', companyId)
.maybeSingle()
const lockDate = settings?.bookkeeping_locked_through as string | null
if (lockDate && period.period_start <= lockDate) {
return errorResponseFromCode('OB_COMPANY_LOCK_DATE', opLog, {
requestId,
details: { lockDate, entryDate: period.period_start },
})
}
if (!period.opening_balances_set || !period.opening_balance_entry_id) {
return errorResponseFromCode('OB_CORRECT_NO_EXISTING', opLog, { requestId })
}
const { count: yearEndCount } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscal_period_id)
.eq('source_type', 'year_end')
.eq('status', 'posted')
if ((yearEndCount ?? 0) > 0) {
return errorResponseFromCode('OB_CORRECT_YEAR_END_EXISTS', opLog, { requestId })
}
const entryId = period.opening_balance_entry_id
// Seed standard BAS accounts the replacement lines reference but the
// chart lacks (same courtesy as the storno flow); unknown numbers fail
// the RPC's chart check with a clear error.
const accountNumbers = [...new Set(new_lines.map((l) => l.account_number))]
if (accountNumbers.length > 0) {
await backfillStandardBASAccounts(supabase, companyId!, user.id, accountNumbers)
}
const { data: rpcData, error: rpcError } = await supabase.rpc('correct_entry_lines_inline', {
p_company_id: companyId,
p_entry_id: entryId,
p_strike_line_ids: strike_line_ids,
p_new_lines: new_lines.map((l) => ({
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
line_description: l.line_description ?? null,
dimensions: l.dimensions ?? {},
})),
p_user_id: user.id,
})
if (rpcError) {
// Rule violations are plain RAISE EXCEPTION (P0001) with user-facing
// Swedish messages: surface verbatim (same approach as the generic
// strike-lines route). Tenant guard raises 42501.
if (rpcError.code === 'P0001') {
return NextResponse.json(
{
error: {
code: 'OB_INLINE_REFUSED',
message: getUserErrorMessage(rpcError, { locale: 'sv' }),
message_en: getUserErrorMessage(rpcError, { locale: 'en' }),
requestId,
},
},
{ status: 409 },
)
}
if (rpcError.code === '42501') {
return NextResponse.json(
{ error: { code: 'FORBIDDEN', message: getUserErrorMessage(rpcError), requestId } },
{ status: 403 },
)
}
opLog.error('correct_entry_lines_inline failed for IB', new Error(rpcError.message), { entryId })
return errorResponseFromCode('OB_CORRECT_FAILED', opLog, {
requestId,
details: { reason: getUserErrorMessage(rpcError) },
})
}
// Base rättelse committed. Cascade is best-effort on top, one inline
// rättelse per later year; a failure there never errors this request.
//
// The delta comes from the RPC's OWN rättelse-log row (struck_lines /
// added_lines snapshotted inside the RPC transaction), never from a
// pre-RPC read: a concurrent edit between a route-side snapshot and the
// RPC could otherwise cascade a delta that no longer matches what was
// actually committed to the base year.
let cascadeResult: CascadeResult | null = null
if (cascade) {
try {
const logId = (rpcData as { log_id?: string } | null)?.log_id
const { data: logRow, error: logError } = await supabase
.from('journal_entry_rattelse_log')
.select('struck_lines, added_lines')
.eq('id', logId)
.eq('company_id', companyId)
.single()
if (logError || !logRow) {
throw new Error(`rättelse log fetch failed: ${logError?.message ?? 'not found'}`)
}
const toLines = (raw: unknown) =>
((raw ?? []) as Array<{ account_number: string; debit_amount: number | string; credit_amount: number | string }>).map(
(l) => ({
account_number: l.account_number,
debit_amount: Number(l.debit_amount) || 0,
credit_amount: Number(l.credit_amount) || 0,
}),
)
const deltas = computeAccountDeltas(toLines(logRow.struck_lines), toLines(logRow.added_lines))
cascadeResult = await cascadeOpeningBalanceCorrection(supabase, companyId!, user.id, {
basePeriodStart: period.period_start,
deltas,
lockDate,
mode: 'inline',
log: opLog,
})
} catch (cascadeErr) {
opLog.error('inline opening balance cascade failed', cascadeErr as Error)
cascadeResult = { corrected: [], skipped: [], failed: true }
}
}
return NextResponse.json({
data: {
success: true,
journal_entry_id: entryId,
rattelse: rpcData,
...(cascadeResult ? { cascade: cascadeResult } : {}),
},
})
},
{ requireWrite: true },
)
@@ -0,0 +1,193 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const mockCreateJournalEntry = vi.fn()
const mockReverseEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
}))
vi.mock('@/lib/bookkeeping/bas-reference', () => ({
getBASReference: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/supabase/fetch-all', () => ({
fetchAllRows: vi.fn().mockResolvedValue([
{ account_number: '1930' },
{ account_number: '2099' },
]),
}))
// The cascade itself has its own unit tests (lib/import/opening-balance/
// __tests__/cascade.test.ts); here we verify the route wires it correctly:
// flag → original-lines fetch → delta computation → cascade call → response.
const mockCascade = vi.fn()
const mockFetchOriginalLines = vi.fn()
vi.mock('@/lib/import/opening-balance/cascade', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
cascadeOpeningBalanceCorrection: (...args: unknown[]) => mockCascade(...args),
fetchEntryOpeningBalanceLines: (...args: unknown[]) => mockFetchOriginalLines(...args),
}
})
import { POST } from '../route'
const PERIOD_ID = '550e8400-e29b-41d4-a716-446655440000'
const CORRECTED_LINES = [
{ account_number: '1930', debit_amount: 40000, credit_amount: 0 },
{ account_number: '2099', debit_amount: 0, credit_amount: 40000 },
]
function makeRequest(body: unknown) {
return createMockRequest('/api/import/opening-balance/correct', {
method: 'POST',
body,
})
}
const ROUTE_PARAMS = { params: Promise.resolve({}) }
interface CorrectResponse {
data: {
success: boolean
cascade?: {
corrected: Array<Record<string, unknown>>
skipped: Array<Record<string, unknown>>
}
}
}
function enqueueHappyPath() {
enqueue({
data: {
id: PERIOD_ID,
company_id: 'company-1',
is_closed: false,
locked_at: null,
opening_balances_set: true,
opening_balance_entry_id: 'entry-old',
period_start: '2019-01-01',
opening_balance_entry: { voucher_series: 'A', voucher_number: 1 },
},
}) // period
enqueue({ data: { bookkeeping_locked_through: null } }) // lock-date pre-flight
enqueue({ count: 0 }) // year-end check
enqueue({ error: null }) // relink RPC
}
describe('POST /api/import/opening-balance/correct: cascade wiring', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
mockCreateJournalEntry.mockResolvedValue({ id: 'entry-new', voucher_series: 'A', voucher_number: 9 })
mockReverseEntry.mockResolvedValue({ id: 'entry-storno' })
mockFetchOriginalLines.mockResolvedValue([
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
{ account_number: '2099', debit_amount: 0, credit_amount: 50000 },
])
mockCascade.mockResolvedValue({
corrected: [
{
fiscal_period_id: 'period-2020',
period_name: '2020',
journal_entry_id: 'ib-2020-new',
reversed_entry_id: 'ib-2020',
},
],
skipped: [{ fiscal_period_id: 'period-2021', period_name: '2021', reason: 'closed' }],
})
})
it('runs the cascade with the correction deltas and returns its result', async () => {
enqueueHappyPath()
const res = await POST(
makeRequest({ fiscal_period_id: PERIOD_ID, lines: CORRECTED_LINES, cascade: true }),
ROUTE_PARAMS,
)
const { status, body } = await parseJsonResponse<CorrectResponse>(res)
expect(status).toBe(200)
expect(body.data.success).toBe(true)
expect(body.data.cascade).toEqual({
corrected: [expect.objectContaining({ fiscal_period_id: 'period-2020' })],
skipped: [expect.objectContaining({ fiscal_period_id: 'period-2021', reason: 'closed' })],
})
// Original lines are fetched from the OLD entry (before it is stornoed).
expect(mockFetchOriginalLines).toHaveBeenCalledWith(expect.anything(), 'company-1', 'entry-old')
// The cascade gets the base period start and the per-account delta
// (1930: 40000 - 50000 = -10000, 2099: -40000 - (-50000) = +10000).
expect(mockCascade).toHaveBeenCalledTimes(1)
const opts = mockCascade.mock.calls[0][3] as {
basePeriodStart: string
deltas: Map<string, number>
lockDate: string | null
}
expect(opts.basePeriodStart).toBe('2019-01-01')
expect(opts.lockDate).toBeNull()
expect(opts.deltas.get('1930')).toBe(-10000)
expect(opts.deltas.get('2099')).toBe(10000)
})
it('does not touch later periods when cascade is omitted', async () => {
enqueueHappyPath()
const res = await POST(makeRequest({ fiscal_period_id: PERIOD_ID, lines: CORRECTED_LINES }), ROUTE_PARAMS)
const { status, body } = await parseJsonResponse<CorrectResponse>(res)
expect(status).toBe(200)
expect(body.data.success).toBe(true)
expect(body.data.cascade).toBeUndefined()
expect(mockFetchOriginalLines).not.toHaveBeenCalled()
expect(mockCascade).not.toHaveBeenCalled()
})
it('still returns success for the base correction when the cascade throws unexpectedly', async () => {
enqueueHappyPath()
mockCascade.mockRejectedValue(new Error('cascade boom'))
const res = await POST(
makeRequest({ fiscal_period_id: PERIOD_ID, lines: CORRECTED_LINES, cascade: true }),
ROUTE_PARAMS,
)
const { status, body } = await parseJsonResponse<CorrectResponse>(res)
// The base correction is already committed; the response must not flip to
// an error the caller would retry (double-correcting the base year), but
// the failure must be visible so the client can tell the user to check.
expect(status).toBe(200)
expect(body.data.success).toBe(true)
expect(body.data.cascade).toEqual({ corrected: [], skipped: [], failed: true })
})
})
@@ -1,14 +1,21 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { OpeningBalanceExecuteSchema } from '@/lib/api/schemas'
import { OpeningBalanceCorrectSchema } from '@/lib/api/schemas'
import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import {
validateOpeningBalanceLines,
activateMissingAccounts,
buildOpeningBalanceEntryLines,
type OpeningBalanceLine,
} from '@/lib/import/opening-balance/execute-helpers'
import {
cascadeOpeningBalanceCorrection,
computeAccountDeltas,
fetchEntryOpeningBalanceLines,
type CascadeResult,
} from '@/lib/import/opening-balance/cascade'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -32,19 +39,26 @@ ensureInitialized()
* Gated to the safe case only: the period must be open, unlocked, already have
* opening balances, and have no year-end close on top. Locked/closed periods or
* periods with a bokslut must be unwound first (assisted): we refuse here.
*
* With `cascade: true` the same per-account delta is then applied to every
* subsequent year's linked IB verifikat (storno + rebook + relink per year;
* see lib/import/opening-balance/cascade.ts). SIE migrations book one IB per
* imported year, so without the cascade a base-year correction leaves later
* years' IB verifikat carrying the stale figures. Uncorrectable years are
* skipped and reported in the response, never forced.
*/
export const POST = withRouteContext(
'opening_balance.correct',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
const result = await validateBody(request, OpeningBalanceExecuteSchema, {
const result = await validateBody(request, OpeningBalanceCorrectSchema, {
log,
operation: 'opening_balance.correct',
})
if (!result.success) return result.response
const { fiscal_period_id, lines } = result.data
const { fiscal_period_id, lines, cascade } = result.data
const opLog = log.child({ fiscalPeriodId: fiscal_period_id })
try {
@@ -143,6 +157,13 @@ export const POST = withRouteContext(
})
}
// Cascade needs the ORIGINAL lines to compute the per-account delta,
// so fetch them before the old entry is stornoed below.
let originalLines: OpeningBalanceLine[] | null = null
if (cascade) {
originalLines = await fetchEntryOpeningBalanceLines(supabase, companyId!, oldEntryId)
}
// BFL 5 kap 5§: reference the original verifikat so the correction is
// traceable to the entry it rättar. The embed above gave us the old IB's
// voucher label (e.g. "A123"). CreateJournalEntryInput exposes no dedicated
@@ -254,6 +275,26 @@ export const POST = withRouteContext(
})
}
// The base correction is committed at this point. The cascade to later
// years is best-effort on top: each year is corrected independently and
// an unexpected failure must not turn the whole request into an error
// (the base correction cannot be un-done here).
let cascadeResult: CascadeResult | null = null
if (cascade && originalLines) {
try {
const deltas = computeAccountDeltas(originalLines, validLines)
cascadeResult = await cascadeOpeningBalanceCorrection(supabase, companyId!, user.id, {
basePeriodStart: period.period_start,
deltas,
lockDate,
log: opLog,
})
} catch (cascadeErr) {
opLog.error('opening balance cascade failed', cascadeErr as Error)
cascadeResult = { corrected: [], skipped: [], failed: true }
}
}
return NextResponse.json({
data: {
success: true,
@@ -263,6 +304,7 @@ export const POST = withRouteContext(
lines_created: validLines.length,
total_debit: totalDebit,
total_credit: totalCredit,
...(cascadeResult ? { cascade: cascadeResult } : {}),
},
})
} catch (err) {
@@ -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<OpeningBalanceEditorState | null>(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<string, string>
}> = []
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -134,23 +298,76 @@ export default function CorrectOpeningBalanceDialog({
<DialogHeader>
<DialogTitle>Korrigera ingående balanser</DialogTitle>
<DialogDescription>
Ändra beloppen nedan och spara. Den befintliga IB-verifikationen (
<span data-ph-mask="">{formatVoucher(entry)}</span>) makuleras och en ny bokförs med
de korrigerade beloppen.
Ändra beloppen nedan och spara. Verifikationen (
<span data-ph-mask="">{formatVoucher(entry)}</span>) uppdateras direkt: ingen ny
verifikation skapas.
</DialogDescription>
</DialogHeader>
{/* Storno explanation: a booked verifikat can't be edited in place */}
{/* Inline rättelse (BFL 5 kap 5 §): edited in place, original logged */}
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
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.
<p className="text-sm text-muted-foreground">
Ändringen sparas som en spårbar rättelse i samma verifikation (Bokföringslagen 5 kap
5 §): de ursprungliga raderna bevaras i rättelseloggen.
</p>
</div>
{/* 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) && (
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 space-y-1">
<p className="text-sm">
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.
</p>
{guidancePeriod ? (
<p className="text-sm">
Korrigera i stället ingående balansen för{' '}
<Link
href={`/bookkeeping/${guidancePeriod.opening_balance_entry_id}`}
className="underline underline-offset-2"
>
{guidancePeriod.name || guidancePeriod.period_start.slice(0, 4)}
</Link>
, 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.
</p>
) : (
<p className="text-sm text-muted-foreground">
För att korrigera behöver året först låsas upp (eller bokslutet återföras) under
Bokföring → Räkenskapsår.
</p>
)}
</div>
)}
<OpeningBalanceRowEditor initialRows={initialRows} onChange={setState} />
{/* 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 && (
<label className="flex items-start gap-3 rounded-lg border border-border px-4 py-3 cursor-pointer">
<Checkbox
checked={cascade}
onCheckedChange={(v) => setCascade(v === true)}
className="mt-0.5"
/>
<span className="space-y-1">
<span className="block text-sm">
Uppdatera även senare räkenskapsår ({laterPeriodsWithIB.length})
</span>
<span className="block text-sm text-muted-foreground">
Samma ändring förs in i senare års ingående balanser så att saldona stämmer
framåt, utan nya verifikat. År som är låsta eller har bokslut hoppas över.
Avser rättelsen ett tidigare års resultat (t.ex. konto 2099) kan en omföring
till balanserat resultat fortfarande behöva bokföras som vanligt.
</span>
</span>
</label>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Avbryt
+1 -1
View File
@@ -44,7 +44,7 @@ export const AGENT_GROUND_RULES: string[] = [
// standard-BAS account backfill in the engine/storno service.
'- RÄTTA FEL I BOKFÖRDA VERIFIKATIONER: så fungerar det i Accounted (beskriv aldrig andra vägar än dessa):',
' • En bokförd verifikation kan aldrig redigeras direkt (Bokföringslagen). Rättelse görs från verifikationens egen sida: Bokföring → öppna verifikationen → knappen "Rätta". "Rätta rader" skapar automatiskt en storno som nollställer originalet plus en ny rättelseverifikation med de rätta raderna, båda i originalets period. "Rätta datum" flyttar verifikationen till rätt datum/år (storno + ombokning under huven). Hela kedjan original → storno → rättelse länkas och visas på verifikationssidan.',
' • INGÅENDE BALANSER (IB) rättas på sitt eget sätt: INTE via "Rätta rader". Gå till Bokföring, öppna IB-verifikationen (beskrivning "Ingående balanser", serie A) och klicka "Korrigera ingående balanser". Då öppnas IB-raderna så att beloppen kan ändras direkt; när man sparar stornas den gamla IB-verifikationen och en korrigerad bokförs, och periodens ingående balans pekas om till den nya. Detta gäller oavsett om IB kom från SIE-import, CSV/Excel-import eller föregående års bokslut. IB finns alltså INTE under Inställningar eller Kontoplan: korrigeringen görs på själva verifikationen.',
' • INGÅENDE BALANSER (IB) rättas på sitt eget sätt: INTE via "Rätta rader". Gå till Bokföring, öppna IB-verifikationen (beskrivning "Ingående balanser", serie A) och klicka "Korrigera ingående balanser". Då öppnas IB-raderna så att beloppen kan ändras direkt; i ett öppet, olåst år uppdateras verifikationen på plats (ingen storno, inga nya verifikat: originalraderna bevaras i rättelseloggen enligt Bokföringslagen). Har företaget senare räkenskapsår med egna IB-verifikat kan samma ändring föras in i dem automatiskt (kryssrutan "Uppdatera även senare räkenskapsår"); låsta år eller år med bokslut hoppas över. Detta gäller oavsett om IB kom från SIE-import, CSV/Excel-import eller föregående års bokslut. IB finns alltså INTE under Inställningar eller Kontoplan: korrigeringen görs på själva verifikationen.',
' • Är verifikationen den SENASTE i sin serie kan den även raderas helt ("Radera verifikat"): då återanvänds löpnumret och ingen lucka uppstår.',
' • Konton som finns i BAS-kontoplanen men saknas i företagets kontoplan läggs till AUTOMATISKT vid bokföring och rättelse. Be aldrig användaren registrera standardkonton manuellt innan de bokför: bara okända kontonummer eller avaktiverade konton stoppar.',
' • När en bokning makuleras (storno utan rättelse) släpps den kopplade banktransaktionen och blir bokföringsbar igen i transaktionsvyn: användaren kan alltid klicka på transaktionen och bokföra om. Vid en rättelse följer transaktionen och underlaget med till rättelseverifikationen.',
+22
View File
@@ -2680,6 +2680,28 @@ export const OpeningBalanceExecuteSchema = z.object({
})).min(2, 'At least two lines are required for double-entry'),
})
export const OpeningBalanceCorrectSchema = OpeningBalanceExecuteSchema.extend({
// Also apply the correction's per-account delta to subsequent years' linked
// IB verifikat (Fortnox/SIE migrations book one IB per imported year).
cascade: z.boolean().optional(),
})
/**
* Inline (no-storno) IB correction: strike changed lines and add replacements
* inside the SAME verifikat, BFL 5 kap 5 § track 2. Only for open, unlocked
* years; the correct_entry_lines_inline RPC enforces the full envelope.
*/
export const OpeningBalanceCorrectInlineSchema = z
.object({
fiscal_period_id: uuid,
strike_line_ids: z.array(uuid).max(200).default([]),
new_lines: z.array(InlineRattelseLineSchema).max(100).default([]),
cascade: z.boolean().optional(),
})
.refine((body) => body.strike_line_ids.length > 0 || body.new_lines.length > 0, {
message: 'Rättelsen måste stryka eller lägga till minst en rad',
})
// ============================================================
// Register import schemas (customers, suppliers)
// ============================================================
@@ -0,0 +1,457 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { createTestLogger } from '@/lib/logger'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const mockReplaceOpeningBalanceEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
replaceOpeningBalanceEntry: (...args: unknown[]) => mockReplaceOpeningBalanceEntry(...args),
}))
const mockFetchEntryLines = vi.fn()
vi.mock('@/lib/bookkeeping/entry-lines', () => ({
fetchEntryLines: (...args: unknown[]) => mockFetchEntryLines(...args),
}))
import {
computeAccountDeltas,
buildCascadedLines,
cascadeOpeningBalanceCorrection,
type CascadeSourceLine,
} from '../cascade'
import type { SupabaseClient } from '@supabase/supabase-js'
const D = (account_number: string, debit_amount: number) => ({
account_number,
debit_amount,
credit_amount: 0,
})
const K = (account_number: string, credit_amount: number) => ({
account_number,
debit_amount: 0,
credit_amount,
})
const src = (
line: { account_number: string; debit_amount: number; credit_amount: number },
extra: Partial<CascadeSourceLine> = {},
): CascadeSourceLine => ({
line_description: null,
dimensions: null,
...line,
...extra,
})
describe('computeAccountDeltas', () => {
it('returns the per-account net change, omitting unchanged accounts', () => {
const oldLines = [D('1930', 50000), K('2099', 50000)]
const newLines = [D('1930', 40000), D('1630', 10000), K('2099', 50000)]
const deltas = computeAccountDeltas(oldLines, newLines)
expect(deltas.get('1930')).toBe(-10000)
expect(deltas.get('1630')).toBe(10000)
expect(deltas.has('2099')).toBe(false)
expect(deltas.size).toBe(2)
})
it('handles accounts removed entirely from the corrected entry', () => {
const oldLines = [D('1930', 30000), D('1630', 20000), K('2099', 50000)]
const newLines = [D('1930', 50000), K('2099', 50000)]
const deltas = computeAccountDeltas(oldLines, newLines)
expect(deltas.get('1630')).toBe(-20000)
expect(deltas.get('1930')).toBe(20000)
})
it('rounds öre correctly (no floating point drift)', () => {
const oldLines = [D('1930', 100.1), K('2099', 100.1)]
const newLines = [D('1930', 100.3), K('2099', 100.3)]
const deltas = computeAccountDeltas(oldLines, newLines)
expect(deltas.get('1930')).toBe(0.2)
expect(deltas.get('2099')).toBe(-0.2)
})
})
describe('buildCascadedLines', () => {
it('keeps original lines verbatim (descriptions and dimensions) and appends sorted adjustment lines', () => {
const existing = [
src(D('1930', 80000), { line_description: 'IB 1930', dimensions: { '1': 'STHLM' } }),
src(K('2099', 80000), { line_description: 'IB 2099' }),
]
const deltas = new Map([
['1930', -10000],
['1630', 10000],
])
const result = buildCascadedLines(existing, deltas)
expect(result).toEqual([
{
account_number: '1930',
debit_amount: 80000,
credit_amount: 0,
line_description: 'IB 1930',
dimensions: { '1': 'STHLM' },
},
{
account_number: '2099',
debit_amount: 0,
credit_amount: 80000,
line_description: 'IB 2099',
dimensions: undefined,
},
{
account_number: '1630',
debit_amount: 10000,
credit_amount: 0,
line_description: 'IB-rättelse 1630',
},
{
account_number: '1930',
debit_amount: 0,
credit_amount: 10000,
line_description: 'IB-rättelse 1930',
},
])
const totalDebit = result.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = result.reduce((s, l) => s + l.credit_amount, 0)
expect(totalDebit).toBe(totalCredit)
})
it('emits a credit adjustment line for a negative delta and drops zero-amount source rows', () => {
const existing = [
src(D('1930', 5000)),
src(K('2099', 5000)),
src({ account_number: '1510', debit_amount: 0, credit_amount: 0 }),
]
const deltas = new Map([
['1930', -8000],
['2099', 8000],
])
const result = buildCascadedLines(existing, deltas)
expect(result.map((l) => l.account_number)).toEqual(['1930', '2099', '1930', '2099'])
expect(result[2]).toEqual({
account_number: '1930',
debit_amount: 0,
credit_amount: 8000,
line_description: 'IB-rättelse 1930',
})
expect(result[3]).toEqual({
account_number: '2099',
debit_amount: 8000,
credit_amount: 0,
line_description: 'IB-rättelse 2099',
})
})
})
describe('cascadeOpeningBalanceCorrection', () => {
const sink: Parameters<typeof createTestLogger>[1] = []
const log = createTestLogger('cascade-test', sink)
const supabase = mockSupabase as unknown as SupabaseClient
const DELTAS = new Map([
['1930', -10000],
['1630', 10000],
])
const periodRow = (overrides: Record<string, unknown> = {}) => ({
id: 'period-2020',
name: '2020',
period_start: '2020-01-01',
is_closed: false,
locked_at: null,
opening_balance_entry_id: 'ib-2020',
opening_balance_entry: { voucher_series: 'A', voucher_number: 4 },
...overrides,
})
beforeEach(() => {
vi.clearAllMocks()
reset()
sink.length = 0
mockFetchEntryLines.mockResolvedValue([
{ id: 'l1', account_number: '1930', debit_amount: 80000, credit_amount: 0, line_description: 'IB 1930', dimensions: null },
{ id: 'l2', account_number: '2099', debit_amount: 0, credit_amount: 80000, line_description: 'IB 2099', dimensions: null },
])
mockReplaceOpeningBalanceEntry.mockResolvedValue({
newEntryId: 'ib-new',
stornoEntryId: 'ib-storno',
newVoucherNumber: 9,
stornoVoucherNumber: 10,
})
})
it('returns immediately without queries when there are no deltas', async () => {
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
basePeriodStart: '2019-01-01',
deltas: new Map(),
lockDate: null,
log,
})
expect(result).toEqual({ corrected: [], skipped: [] })
expect(mockSupabase.from).not.toHaveBeenCalled()
})
it('replaces an open later period atomically and skips a closed one', async () => {
enqueue({
data: [
periodRow(),
periodRow({ id: 'period-2021', name: '2021', period_start: '2021-01-01', is_closed: true, opening_balance_entry_id: 'ib-2021' }),
],
}) // subsequent periods
enqueue({ count: 0 }) // year-end check for period-2020
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
basePeriodStart: '2019-01-01',
deltas: DELTAS,
lockDate: null,
log,
})
expect(result.corrected).toEqual([
{
fiscal_period_id: 'period-2020',
period_name: '2020',
journal_entry_id: 'ib-new',
reversed_entry_id: 'ib-2020',
},
])
expect(result.skipped).toEqual([
{ fiscal_period_id: 'period-2021', period_name: '2021', reason: 'closed' },
])
// One atomic engine replacement, CAS-guarded on the old entry, carrying
// the original lines verbatim plus labelled adjustment lines and the BFL
// reference to the verifikat being rättat.
expect(mockReplaceOpeningBalanceEntry).toHaveBeenCalledTimes(1)
expect(mockReplaceOpeningBalanceEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'ib-2020',
expect.objectContaining({
fiscal_period_id: 'period-2020',
entry_date: '2020-01-01',
source_type: 'opening_balance',
voucher_series: 'A',
description: 'Ingående balanser (korrigerade, rättelse av A4)',
lines: [
expect.objectContaining({ account_number: '1930', debit_amount: 80000, line_description: 'IB 1930' }),
expect.objectContaining({ account_number: '2099', credit_amount: 80000 }),
expect.objectContaining({ account_number: '1630', debit_amount: 10000, line_description: 'IB-rättelse 1630' }),
expect.objectContaining({ account_number: '1930', credit_amount: 10000, line_description: 'IB-rättelse 1930' }),
],
}),
)
})
it('skips locked, lock-dated, and bokslut periods without touching them', async () => {
enqueue({
data: [
periodRow({ id: 'p-locked', name: '2020', locked_at: '2021-05-01T00:00:00Z' }),
periodRow({ id: 'p-lockdate', name: '2021', period_start: '2021-01-01' }),
periodRow({ id: 'p-yearend', name: '2022', period_start: '2022-01-01' }),
],
})
// p-lockdate: period_start 2021-01-01 <= lockDate 2021-12-31 → skipped
// before any further query. p-yearend reaches the year-end check:
enqueue({ count: 2 }) // year-end check for p-yearend
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
basePeriodStart: '2019-01-01',
deltas: DELTAS,
lockDate: '2021-12-31',
log,
})
expect(result.corrected).toEqual([])
expect(result.skipped).toEqual([
{ fiscal_period_id: 'p-locked', period_name: '2020', reason: 'locked' },
{ fiscal_period_id: 'p-lockdate', period_name: '2021', reason: 'lock_date' },
{ fiscal_period_id: 'p-yearend', period_name: '2022', reason: 'year_end' },
])
expect(mockReplaceOpeningBalanceEntry).not.toHaveBeenCalled()
})
it('fails CLOSED when the year-end lookup errors: the period is skipped, not rewritten', async () => {
enqueue({ data: [periodRow()] })
enqueue({ count: null, error: { message: 'transient boom' } }) // year-end check fails
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
basePeriodStart: '2019-01-01',
deltas: DELTAS,
lockDate: null,
log,
})
expect(result.corrected).toEqual([])
expect(result.skipped).toEqual([
{ fiscal_period_id: 'period-2020', period_name: '2020', reason: 'correction_failed' },
])
expect(mockReplaceOpeningBalanceEntry).not.toHaveBeenCalled()
const audit = sink.filter((r) => String(r.msg).includes('cascade correction failed'))
expect(audit.length).toBeGreaterThan(0)
})
it('reports a period without a linked IB verifikat as skipped', async () => {
enqueue({ data: [periodRow({ opening_balance_entry_id: null, opening_balance_entry: null })] })
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
basePeriodStart: '2019-01-01',
deltas: DELTAS,
lockDate: null,
log,
})
expect(result.skipped).toEqual([
{ fiscal_period_id: 'period-2020', period_name: '2020', reason: 'no_opening_balance' },
])
expect(mockReplaceOpeningBalanceEntry).not.toHaveBeenCalled()
})
it('reports a failed replacement as skipped and continues with the next year', async () => {
enqueue({
data: [
periodRow(),
periodRow({ id: 'period-2021', name: '2021', period_start: '2021-01-01', opening_balance_entry_id: 'ib-2021' }),
],
})
enqueue({ count: 0 }) // year-end check period-2020
enqueue({ count: 0 }) // year-end check period-2021
mockReplaceOpeningBalanceEntry
.mockRejectedValueOnce(new Error('replacement boom')) // period-2020
.mockResolvedValueOnce({
newEntryId: 'ib-2021-new',
stornoEntryId: 'ib-2021-storno',
newVoucherNumber: 12,
stornoVoucherNumber: 13,
})
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
basePeriodStart: '2019-01-01',
deltas: DELTAS,
lockDate: null,
log,
})
expect(result.skipped).toEqual([
{ fiscal_period_id: 'period-2020', period_name: '2020', reason: 'correction_failed' },
])
expect(result.corrected).toEqual([
expect.objectContaining({ fiscal_period_id: 'period-2021', journal_entry_id: 'ib-2021-new' }),
])
// The failed period got exactly one atomic attempt (RPC rolls back all of
// it); no compensation writes exist in this design.
expect(mockReplaceOpeningBalanceEntry).toHaveBeenCalledTimes(2)
expect(mockReplaceOpeningBalanceEntry).toHaveBeenNthCalledWith(
1, expect.anything(), 'company-1', 'user-1', 'ib-2020', expect.anything(),
)
expect(mockReplaceOpeningBalanceEntry).toHaveBeenNthCalledWith(
2, expect.anything(), 'company-1', 'user-1', 'ib-2021', expect.anything(),
)
const audit = sink.filter((r) => String(r.msg).includes('cascade correction failed'))
expect(audit.length).toBeGreaterThan(0)
})
it('inline mode: appends adjustment lines in the same verifikat, no storno, no line fetch', async () => {
enqueue({ data: [periodRow()] }) // subsequent periods
enqueue({ count: 0 }) // year-end check
enqueue({ data: { log_id: 'log-1', struck_count: 0, added_count: 2 } }) // inline RPC
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
basePeriodStart: '2019-01-01',
deltas: DELTAS,
lockDate: null,
mode: 'inline',
log,
})
expect(result.corrected).toEqual([
{
fiscal_period_id: 'period-2020',
period_name: '2020',
journal_entry_id: 'ib-2020',
reversed_entry_id: null,
},
])
expect(result.skipped).toEqual([])
expect(mockSupabase.rpc).toHaveBeenCalledWith(
'correct_entry_lines_inline',
expect.objectContaining({
p_company_id: 'company-1',
p_entry_id: 'ib-2020',
p_strike_line_ids: [],
p_new_lines: [
expect.objectContaining({ account_number: '1630', debit_amount: 10000, line_description: 'IB-rättelse 1630' }),
expect.objectContaining({ account_number: '1930', credit_amount: 10000, line_description: 'IB-rättelse 1930' }),
],
p_user_id: 'user-1',
}),
)
expect(mockReplaceOpeningBalanceEntry).not.toHaveBeenCalled()
expect(mockFetchEntryLines).not.toHaveBeenCalled()
})
it('inline mode: a refused rättelse skips the year and is audited', async () => {
enqueue({ data: [periodRow()] })
enqueue({ count: 0 }) // year-end check
enqueue({ data: null, error: { message: 'Perioden är stängd eller låst: använd rättelseverifikat (storno).' } })
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
basePeriodStart: '2019-01-01',
deltas: DELTAS,
lockDate: null,
mode: 'inline',
log,
})
expect(result.corrected).toEqual([])
expect(result.skipped).toEqual([
{ fiscal_period_id: 'period-2020', period_name: '2020', reason: 'correction_failed' },
])
const audit = sink.filter((r) => String(r.msg).includes('cascade correction failed'))
expect(audit.length).toBeGreaterThan(0)
})
it('skips a period whose shifted lines no longer validate', async () => {
// The period's IB is a single line pair that the delta exactly cancels,
// leaving zero-amount adjustments only... construct instead the <2 lines
// case: the source entry is empty (all rows zero), so kept+adjustment
// lines fail the P&L/two-line validation via a P&L delta account.
mockFetchEntryLines.mockResolvedValue([
{ id: 'l1', account_number: '1930', debit_amount: 10000, credit_amount: 0, line_description: null, dimensions: null },
{ id: 'l2', account_number: '1630', debit_amount: 0, credit_amount: 10000, line_description: null, dimensions: null },
])
enqueue({ data: [periodRow()] })
enqueue({ count: 0 }) // year-end check
const result = await cascadeOpeningBalanceCorrection(supabase, 'company-1', 'user-1', {
basePeriodStart: '2019-01-01',
deltas: new Map([
['3001', 10000],
['1930', -10000],
]),
lockDate: null,
log,
})
expect(result.skipped).toEqual([
{ fiscal_period_id: 'period-2020', period_name: '2020', reason: 'validation_failed' },
])
expect(mockReplaceOpeningBalanceEntry).not.toHaveBeenCalled()
})
})
+405
View File
@@ -0,0 +1,405 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Logger } from '@/lib/logger'
import type { CreateJournalEntryLineInput } from '@/types'
import { roundOre } from '@/lib/money'
import { replaceOpeningBalanceEntry } from '@/lib/bookkeeping/engine'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import {
validateOpeningBalanceLines,
type OpeningBalanceLine,
} from './execute-helpers'
/**
* Cascade an opening-balance correction to subsequent fiscal years.
*
* Fortnox/SIE-migrated companies get one IB verifikat per imported year, each
* linked via fiscal_periods.opening_balance_entry_id. Correcting one year's IB
* therefore leaves every later year's linked IB verifikat carrying the stale
* figures. This module applies the same per-account delta (corrected minus
* original) to each subsequent period's IB the BFL-compliant way, via the
* atomic replaceOpeningBalanceEntry engine primitive (one DB transaction owns
* the storno, the corrected voucher, and the period pointer swap, with a CAS
* on the expected old entry id), so a failure leaves the period untouched.
*
* The corrected verifikat keeps the original lines verbatim (descriptions and
* dimensions included) and appends one adjustment line per changed account,
* so years the user never opened stay reviewable line by line.
*
* Periods that cannot be touched (closed, locked, behind the company lock
* date, or with a posted bokslut on top) are skipped and reported, never
* forced: the DB triggers enforcing those states are legally required.
*/
/** Net per-account change of a correction: (new debit-credit) minus (old debit-credit). */
export type AccountDeltas = Map<string, number>
/** An existing IB line with the fields the rebooked verifikat must preserve. */
export interface CascadeSourceLine extends OpeningBalanceLine {
line_description: string | null
dimensions: Record<string, string> | null
}
export interface CascadeCorrectedPeriod {
fiscal_period_id: string
period_name: string | null
journal_entry_id: string
/** Storno mode: the replaced entry. Inline mode: null (same verifikat edited in place). */
reversed_entry_id: string | null
}
export type CascadeSkipReason =
| 'closed'
| 'locked'
| 'lock_date'
| 'year_end'
| 'no_opening_balance'
| 'validation_failed'
| 'correction_failed'
export interface CascadeSkippedPeriod {
fiscal_period_id: string
period_name: string | null
reason: CascadeSkipReason
}
export interface CascadeResult {
corrected: CascadeCorrectedPeriod[]
skipped: CascadeSkippedPeriod[]
/**
* Set by the ROUTES when the cascade itself blew up before/while running
* (log fetch failed, unexpected throw): the base correction stands, no
* later year was verified, and the client must tell the user to check.
*/
failed?: boolean
}
/**
* Per-account net deltas between the original IB lines and the corrected
* lines. Accounts whose net balance did not change are omitted, so an empty
* map means "nothing to cascade".
*/
export function computeAccountDeltas(
oldLines: OpeningBalanceLine[],
newLines: OpeningBalanceLine[],
): AccountDeltas {
const nets = new Map<string, { oldNet: number; newNet: number }>()
for (const line of oldLines) {
const slot = nets.get(line.account_number) ?? { oldNet: 0, newNet: 0 }
slot.oldNet = roundOre(slot.oldNet + (line.debit_amount - line.credit_amount))
nets.set(line.account_number, slot)
}
for (const line of newLines) {
const slot = nets.get(line.account_number) ?? { oldNet: 0, newNet: 0 }
slot.newNet = roundOre(slot.newNet + (line.debit_amount - line.credit_amount))
nets.set(line.account_number, slot)
}
const deltas: AccountDeltas = new Map()
for (const [account, { oldNet, newNet }] of nets) {
const delta = roundOre(newNet - oldNet)
if (Math.abs(delta) >= 0.01) deltas.set(account, delta)
}
return deltas
}
/**
* Build the corrected line set for a subsequent period: the existing lines
* verbatim (keeping description and dimensions), plus one labelled adjustment
* line per delta account. Both the existing entry and the delta set balance
* (deltas come from two balanced entries), so the result balances too.
*/
export function buildCascadedLines(
existingLines: CascadeSourceLine[],
deltas: AccountDeltas,
): CreateJournalEntryLineInput[] {
const kept: CreateJournalEntryLineInput[] = existingLines
.filter((l) => l.debit_amount > 0 || l.credit_amount > 0)
.map((l) => ({
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
line_description: l.line_description ?? undefined,
dimensions: l.dimensions ?? undefined,
}))
return [...kept, ...buildAdjustmentLines(deltas)]
}
/** One labelled adjustment line per delta account, sorted; balances to zero. */
export function buildAdjustmentLines(deltas: AccountDeltas): CreateJournalEntryLineInput[] {
return [...deltas.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([account_number, delta]) => ({
account_number,
debit_amount: delta > 0 ? delta : 0,
credit_amount: delta < 0 ? roundOre(-delta) : 0,
line_description: `IB-rättelse ${account_number}`,
}))
}
/** Fetch an entry's lines with the fields a cascaded rebook must preserve. */
export async function fetchEntryOpeningBalanceLines(
supabase: SupabaseClient,
companyId: string,
entryId: string,
): Promise<CascadeSourceLine[]> {
// Two-step entry-lines fetch: verifies company_id ownership on the entry
// side (defense in depth alongside RLS) and paginates. Same pattern as
// lib/reports/opening-balances.ts.
const rows = await fetchEntryLines<{
id: string
account_number: string
debit_amount: number | string
credit_amount: number | string
line_description: string | null
dimensions: Record<string, string> | null
}>({
supabase,
lineColumns: 'id, account_number, debit_amount, credit_amount, line_description, dimensions',
filterEntries: (q: EntryLinesQuery) => q.eq('id', entryId).eq('company_id', companyId),
attachEntriesAs: null,
})
return rows.map((r) => ({
account_number: r.account_number,
debit_amount: Number(r.debit_amount) || 0,
credit_amount: Number(r.credit_amount) || 0,
line_description: r.line_description ?? null,
dimensions: r.dimensions ?? null,
}))
}
interface SubsequentPeriodRow {
id: string
name: string | null
period_start: string
is_closed: boolean
locked_at: string | null
opening_balance_entry_id: string | null
opening_balance_entry: {
voucher_series: string | null
voucher_number: number | null
} | null
}
export interface CascadeOptions {
/** period_start of the period whose IB was just corrected. */
basePeriodStart: string
/** Per-account net deltas from the base correction. */
deltas: AccountDeltas
/** company_settings.bookkeeping_locked_through, already fetched by the caller. */
lockDate: string | null
/**
* How each later year's IB is corrected:
* - 'storno' (default): atomic replaceOpeningBalanceEntry (storno + rebook
* + pointer swap), used by the storno-based /correct route.
* - 'inline': append the labelled adjustment lines inside the SAME
* verifikat via correct_entry_lines_inline (BFL 5 kap 5 § track 2): no
* new verifikat at all. Used by the /correct-inline route.
*/
mode?: 'storno' | 'inline'
log: Logger
}
/**
* Replace each subsequent period's IB with the deltas applied, one atomic
* engine replacement per period. Each period is independent: a failure is
* reported as skipped (the RPC transaction leaves that period untouched) and
* the cascade continues with the next year.
*/
export async function cascadeOpeningBalanceCorrection(
supabase: SupabaseClient,
companyId: string,
userId: string,
options: CascadeOptions,
): Promise<CascadeResult> {
const { basePeriodStart, deltas, lockDate, log } = options
const mode = options.mode ?? 'storno'
const result: CascadeResult = { corrected: [], skipped: [] }
if (deltas.size === 0) return result
const { data: periods, error: periodsError } = await supabase
.from('fiscal_periods')
.select(
'id, name, period_start, is_closed, locked_at, opening_balance_entry_id, opening_balance_entry:journal_entries!opening_balance_entry_id(voucher_series, voucher_number)',
)
.eq('company_id', companyId)
.gt('period_start', basePeriodStart)
.order('period_start', { ascending: true })
if (periodsError) {
// The base correction already succeeded; surface the cascade as fully
// skipped rather than failing the request.
log.error('opening balance cascade: could not list subsequent periods', {
audit: true,
event: 'opening_balance.cascade_list_failed',
companyId,
reason: periodsError.message,
})
return result
}
for (const period of (periods ?? []) as unknown as SubsequentPeriodRow[]) {
const skip = (reason: CascadeSkipReason) => {
result.skipped.push({
fiscal_period_id: period.id,
period_name: period.name,
reason,
})
}
const auditFailure = (fields: Record<string, unknown>) => {
log.error('audit: opening balance cascade correction failed', {
audit: true,
event: 'opening_balance.cascade_period_failed',
companyId,
userId,
fiscalPeriodId: period.id,
oldEntryId: period.opening_balance_entry_id,
...fields,
})
}
if (!period.opening_balance_entry_id) {
// No linked IB verifikat: reports fall back to computing prior balances,
// which already reflect the base correction. Reported for transparency.
skip('no_opening_balance')
continue
}
if (period.is_closed) {
skip('closed')
continue
}
if (period.locked_at) {
skip('locked')
continue
}
if (lockDate && period.period_start <= lockDate) {
skip('lock_date')
continue
}
// Fail CLOSED: a failed lookup must not read as "no bokslut", or the
// cascade would rewrite the IB of a period with a posted year-end.
const { count: yearEndCount, error: yearEndError } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('fiscal_period_id', period.id)
.eq('source_type', 'year_end')
.eq('status', 'posted')
if (yearEndError) {
auditFailure({ phase: 'year_end_check_failed', reason: yearEndError.message })
skip('correction_failed')
continue
}
if ((yearEndCount ?? 0) > 0) {
skip('year_end')
continue
}
const oldEntryId = period.opening_balance_entry_id
if (mode === 'inline') {
// Append the labelled adjustment lines inside the SAME verifikat: no
// storno, no new verifikat. The RPC transaction enforces the whole
// envelope (posted, current linked IB, no bokslut, class 1-2, balance
// to the öre, rättelse log) and rolls the period back on any failure.
const { error: inlineError } = await supabase.rpc('correct_entry_lines_inline', {
p_company_id: companyId,
p_entry_id: oldEntryId,
p_strike_line_ids: [],
p_new_lines: buildAdjustmentLines(deltas).map((l) => ({
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
line_description: l.line_description ?? null,
dimensions: {},
})),
p_user_id: userId,
})
if (inlineError) {
auditFailure({ phase: 'inline_rattelse_failed', reason: inlineError.message })
skip('correction_failed')
continue
}
result.corrected.push({
fiscal_period_id: period.id,
period_name: period.name,
journal_entry_id: oldEntryId,
reversed_entry_id: null,
})
continue
}
let lines: CreateJournalEntryLineInput[]
try {
const existingLines = await fetchEntryOpeningBalanceLines(supabase, companyId, oldEntryId)
lines = buildCascadedLines(existingLines, deltas)
} catch (err) {
auditFailure({
phase: 'line_fetch_failed',
reason: err instanceof Error ? err.message : 'unknown',
})
skip('correction_failed')
continue
}
const validation = validateOpeningBalanceLines(
lines.map((l) => ({
account_number: l.account_number,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
})),
)
if (!validation.ok) {
skip('validation_failed')
continue
}
// BFL 5 kap 5§: reference the verifikat being rättat, same convention as
// the base correction.
const voucherLabel =
period.opening_balance_entry?.voucher_series && period.opening_balance_entry?.voucher_number
? `${period.opening_balance_entry.voucher_series}${period.opening_balance_entry.voucher_number}`
: null
const description = voucherLabel
? `Ingående balanser (korrigerade, rättelse av ${voucherLabel})`
: 'Ingående balanser (korrigerade)'
// One RPC transaction: storno the old IB, commit the corrected one, swap
// fiscal_periods.opening_balance_entry_id, CAS-guarded on oldEntryId. Any
// failure (including a period locked between our pre-check and the write)
// rolls the whole period back: no compensation pass, no half states.
try {
const replacement = await replaceOpeningBalanceEntry(supabase, companyId, userId, oldEntryId, {
fiscal_period_id: period.id,
entry_date: period.period_start,
description,
source_type: 'opening_balance',
voucher_series: 'A',
lines,
})
result.corrected.push({
fiscal_period_id: period.id,
period_name: period.name,
journal_entry_id: replacement.newEntryId,
reversed_entry_id: oldEntryId,
})
} catch (err) {
auditFailure({
phase: 'replacement_failed',
reason: err instanceof Error ? err.message : 'unknown',
})
skip('correction_failed')
}
}
return result
}
+1 -2
View File
@@ -1,5 +1,5 @@
{
"totalErrors": 539,
"totalErrors": 538,
"perFile": {
"app/api/assets/__tests__/id.test.ts": 9,
"app/api/auth/email-hook/__tests__/route.test.ts": 1,
@@ -67,7 +67,6 @@
"lib/auth/__tests__/require-write.test.ts": 15,
"lib/bokslut/__tests__/k3-framework-dispositions.test.ts": 2,
"lib/bokslut/__tests__/readiness-aggregator.test.ts": 6,
"lib/bookkeeping/__tests__/own-account-detector.test.ts": 1,
"lib/bookkeeping/__tests__/supplier-payment-lines.test.ts": 14,
"lib/branding/__tests__/public-brand.test.ts": 1,
"lib/email/__tests__/brand-mail-snapshots.test.ts": 1,
@@ -0,0 +1,481 @@
-- Migration: inline rättelse for opening-balance verifikat
--
-- Founder decision 2026-08-31: IB corrections should feel like Fortnox.
-- Fortnox stores ingående balanser as directly editable saldon; in Accounted
-- the IB is a posted verifikat, so every edit went through the storno flow
-- (särskild rättelsepost) and produced two extra verifikat in serie A even
-- for open, unlocked years. BFL 5 kap 5 § permits a second track while the
-- year is open and unlocked: rättelse inside the same verifikat with an
-- immutable who/when log, which correct_entry_lines_inline already
-- implements for regular verifikat (journal_entry_rattelse_log).
--
-- This redefinition admits source_type = 'opening_balance' with three
-- IB-specific guards on top of the existing ones:
-- 1. The entry must be the period's CURRENT linked IB
-- (fiscal_periods.opening_balance_entry_id = the entry): a superseded
-- or orphaned IB entry is not a meaningful rättelse target.
-- 2. The period must not carry a posted year-end verifikat: correcting IB
-- under a bokslut would leave the close inconsistent (same rule as the
-- storno-based /opening-balance/correct route).
-- 3. New lines must be balance-sheet accounts (class 1-2): an IB carrying
-- P&L accounts would violate BFNAR 2013:2 (P&L resets at year start).
--
-- The period's opening_balance_entry_id never changes (same entry id), so
-- enforce_opening_balance_immutability is untouched and every report reads
-- the corrected lines automatically. Storno ('storno'), year-end
-- ('year_end') and vat_settlement entries keep their dedicated flows, and
-- locked/closed/lock-dated periods are still refused: there the storno track
-- remains the only lawful rättelse.
--
-- Everything else is byte-identical to 20260819092408. Tested in
-- tests/pg/inline-rattelse-opening-balance.pg.test.ts.
CREATE OR REPLACE FUNCTION public.correct_entry_lines_inline(
p_company_id uuid,
p_entry_id uuid,
p_strike_line_ids uuid[],
p_new_lines jsonb DEFAULT '[]'::jsonb,
p_user_id uuid DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
v_actor uuid := COALESCE(p_user_id, auth.uid());
v_caller_role text;
v_entry record;
v_is_closed boolean;
v_locked_at timestamptz;
v_lock_date date;
v_strike_ids uuid[] := ARRAY(SELECT DISTINCT unnest(COALESCE(p_strike_line_ids, '{}'::uuid[])));
v_strike_count int := COALESCE(array_length(v_strike_ids, 1), 0);
v_owned_count int;
v_line jsonb;
v_acc text;
v_debit numeric;
v_credit numeric;
v_new_count int := 0;
v_new_debit numeric := 0;
v_new_credit numeric := 0;
v_rem_debit numeric;
v_rem_credit numeric;
v_rem_count int;
v_struck_json jsonb;
v_struck_keys text[];
v_added_keys text[];
v_sort int;
v_added_ids uuid[] := '{}';
v_added_json jsonb;
v_new_id uuid;
v_log_id uuid;
v_fin_debit numeric;
v_fin_credit numeric;
v_fin_count int;
v_bank_linked boolean;
v_invoice_linked boolean;
v_supplier_linked boolean;
v_delta numeric;
v_anchor numeric;
v_post_net numeric;
v_is_ob boolean := false;
v_linked_ob uuid;
BEGIN
IF v_jwt_role IN ('anon', 'authenticated') THEN
IF NOT public.caller_is_company_member(p_company_id) THEN
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
USING ERRCODE = '42501';
END IF;
-- A JWT caller can never act as someone else: p_user_id is only for
-- service-role paths, which authenticate the user application-side.
v_actor := auth.uid();
END IF;
SELECT cm.role INTO v_caller_role
FROM company_members cm
WHERE cm.company_id = p_company_id AND cm.user_id = v_actor;
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin', 'member') THEN
RAISE EXCEPTION 'Endast användare med skrivbehörighet kan rätta verifikat.';
END IF;
IF p_new_lines IS NULL OR jsonb_typeof(p_new_lines) <> 'array' THEN
RAISE EXCEPTION 'Nya rader måste vara en lista.';
END IF;
IF v_strike_count = 0 AND jsonb_array_length(p_new_lines) = 0 THEN
RAISE EXCEPTION 'Rättelsen måste stryka eller lägga till minst en rad.';
END IF;
IF jsonb_array_length(p_new_lines) > 100 THEN
RAISE EXCEPTION 'Högst 100 nya rader per rättelse.';
END IF;
SELECT je.id, je.status, je.entry_date, je.source_type,
je.fiscal_period_id, je.company_id AS entry_company_id
INTO v_entry
FROM public.journal_entries je
WHERE je.id = p_entry_id
FOR UPDATE OF je;
IF NOT FOUND OR v_entry.entry_company_id <> p_company_id THEN
RAISE EXCEPTION 'Verifikationen hittades inte.';
END IF;
IF v_entry.status <> 'posted' THEN
RAISE EXCEPTION 'Endast bokförda verifikat kan rättas (utkast redigeras direkt).';
END IF;
-- Structural entry types keep their dedicated flows: a storno mirrors its
-- original, year-end vouchers feed dispositions/idempotency checks. An IB
-- (source_type 'opening_balance') IS allowed since 20260831150000: the
-- entry id (and thus fiscal_periods.opening_balance_entry_id) never
-- changes, and the IB-specific guards below apply.
IF v_entry.source_type IN ('storno', 'year_end', 'vat_settlement') THEN
RAISE EXCEPTION 'Den här verifikationstypen kan inte rättas radvis: använd dess egen rättelsefunktion.';
END IF;
v_is_ob := v_entry.source_type = 'opening_balance';
SELECT fp.is_closed, fp.locked_at, fp.opening_balance_entry_id
INTO v_is_closed, v_locked_at, v_linked_ob
FROM public.fiscal_periods fp
WHERE fp.id = v_entry.fiscal_period_id;
IF v_is_closed OR v_locked_at IS NOT NULL THEN
RAISE EXCEPTION 'Perioden är stängd eller låst: använd rättelseverifikat (storno).';
END IF;
SELECT cs.bookkeeping_locked_through INTO v_lock_date
FROM public.company_settings cs
WHERE cs.company_id = p_company_id;
IF v_lock_date IS NOT NULL AND v_entry.entry_date <= v_lock_date THEN
RAISE EXCEPTION 'Bokföringen är låst t.o.m. %: använd rättelseverifikat (storno).', v_lock_date;
END IF;
IF v_is_ob THEN
-- Guard 1: only the period's CURRENT linked IB is a rättelse target.
IF v_linked_ob IS DISTINCT FROM p_entry_id THEN
RAISE EXCEPTION 'Verifikationen är inte periodens aktuella ingående balans.';
END IF;
-- Guard 2: a posted bokslut on the period must be unwound first, same
-- rule as the storno-based IB correction flow.
IF EXISTS (
SELECT 1 FROM public.journal_entries je2
WHERE je2.company_id = p_company_id
AND je2.fiscal_period_id = v_entry.fiscal_period_id
AND je2.source_type = 'year_end'
AND je2.status = 'posted'
) THEN
RAISE EXCEPTION 'Perioden har ett bokslut. Återför bokslutet innan ingående balanser kan rättas.';
END IF;
END IF;
-- Every struck id must be a line of THIS entry.
SELECT count(*) INTO v_owned_count
FROM public.journal_entry_lines jel
WHERE jel.journal_entry_id = p_entry_id
AND jel.id = ANY (v_strike_ids);
IF v_owned_count <> v_strike_count THEN
RAISE EXCEPTION 'En eller flera rader som ska strykas hör inte till verifikationen.';
END IF;
-- Foreign-currency lines carry conversion data (amount_in_currency /
-- exchange_rate) that replacement lines cannot reproduce: those
-- corrections stay on the storno flow.
IF EXISTS (
SELECT 1 FROM public.journal_entry_lines jel
WHERE jel.journal_entry_id = p_entry_id
AND jel.id = ANY (v_strike_ids)
AND jel.currency IS NOT NULL AND jel.currency <> 'SEK'
) THEN
RAISE EXCEPTION 'Rader i utländsk valuta kan inte strykas: använd rättelseverifikat (storno).';
END IF;
-- A struck line with a line-level underlag link would sever the document
-- coupling (document_attachments.journal_entry_line_id is ON DELETE
-- RESTRICT, so the DELETE would fail anyway: this gives a clear message).
IF EXISTS (
SELECT 1 FROM public.document_attachments da
WHERE da.journal_entry_line_id = ANY (v_strike_ids)
) THEN
RAISE EXCEPTION 'En rad som ska strykas har ett kopplat underlag: använd rättelseverifikat (storno).';
END IF;
-- Validate the replacement lines. SEK only: inline additions never carry
-- foreign-currency conversion data (that correction stays on the storno flow).
FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_lines)
LOOP
v_acc := btrim(COALESCE(v_line ->> 'account_number', ''));
v_debit := round(COALESCE((v_line ->> 'debit_amount')::numeric, 0), 2);
v_credit := round(COALESCE((v_line ->> 'credit_amount')::numeric, 0), 2);
IF v_acc !~ '^[0-9]{4}$' THEN
RAISE EXCEPTION 'Ogiltigt kontonummer: "%".', v_acc;
END IF;
IF NOT EXISTS (
SELECT 1 FROM public.chart_of_accounts coa
WHERE coa.company_id = p_company_id AND coa.account_number = v_acc
) THEN
RAISE EXCEPTION 'Kontot % finns inte i kontoplanen.', v_acc;
END IF;
-- Guard 3 (IB only): opening balances hold balance-sheet accounts
-- (class 1-2). P&L balances reset at year start (BFNAR 2013:2).
IF v_is_ob AND left(v_acc, 1) NOT IN ('1', '2') THEN
RAISE EXCEPTION 'Resultatkonton (klass 3-8) kan inte användas i ingående balanser (konto %).', v_acc;
END IF;
IF v_debit < 0 OR v_credit < 0 THEN
RAISE EXCEPTION 'Belopp kan inte vara negativa (konto %).', v_acc;
END IF;
IF v_debit > 0 AND v_credit > 0 THEN
RAISE EXCEPTION 'En rad kan inte ha både debet och kredit (konto %).', v_acc;
END IF;
IF v_debit = 0 AND v_credit = 0 THEN
RAISE EXCEPTION 'En rad måste ha ett belopp (konto %).', v_acc;
END IF;
v_new_count := v_new_count + 1;
v_new_debit := v_new_debit + v_debit;
v_new_credit := v_new_credit + v_credit;
END LOOP;
-- Effective post-state must balance and stay a real bokföringspost.
SELECT COALESCE(sum(jel.debit_amount), 0), COALESCE(sum(jel.credit_amount), 0), count(*)
INTO v_rem_debit, v_rem_credit, v_rem_count
FROM public.journal_entry_lines jel
WHERE jel.journal_entry_id = p_entry_id
AND NOT (jel.id = ANY (v_strike_ids));
IF (v_rem_count + v_new_count) < 2 THEN
RAISE EXCEPTION 'Verifikationen måste ha minst två rader efter rättelsen. Använd "Återför (storno)" för att makulera hela verifikationen.';
END IF;
IF abs((v_rem_debit + v_new_debit) - (v_rem_credit + v_new_credit)) >= 0.005 THEN
RAISE EXCEPTION 'Verifikationen balanserar inte efter rättelsen (debet %, kredit %).',
round(v_rem_debit + v_new_debit, 2), round(v_rem_credit + v_new_credit, 2);
END IF;
IF (v_rem_debit + v_new_debit) < 0.005 THEN
RAISE EXCEPTION 'Rättelsen skulle nollställa verifikationen. Använd "Återför (storno)" i stället.';
END IF;
-- A rättelse must change something: striking rows and re-adding an
-- identical set is a no-op in disguise. The key includes dimensions (as
-- canonical jsonb text) so a dimensions-only rättelse counts as a change
-- (CodeRabbit finding on PR #2076; the 20260819 version omitted it).
SELECT COALESCE(array_agg(k ORDER BY k), '{}'), COALESCE(jsonb_agg(to_jsonb(jel) ORDER BY jel.sort_order), '[]'::jsonb)
INTO v_struck_keys, v_struck_json
FROM public.journal_entry_lines jel,
LATERAL (SELECT jel.account_number || '|' || round(jel.debit_amount, 2)::text || '|'
|| round(jel.credit_amount, 2)::text || '|' || COALESCE(jel.line_description, '') || '|'
|| COALESCE(jel.dimensions, '{}'::jsonb)::text) AS key(k)
WHERE jel.journal_entry_id = p_entry_id
AND jel.id = ANY (v_strike_ids);
SELECT COALESCE(array_agg(k ORDER BY k), '{}')
INTO v_added_keys
FROM (
SELECT btrim(l ->> 'account_number') || '|'
|| round(COALESCE((l ->> 'debit_amount')::numeric, 0), 2)::text || '|'
|| round(COALESCE((l ->> 'credit_amount')::numeric, 0), 2)::text || '|'
|| COALESCE(NULLIF(btrim(COALESCE(l ->> 'line_description', '')), ''), '') || '|'
|| COALESCE(l -> 'dimensions', '{}'::jsonb)::text AS k
FROM jsonb_array_elements(p_new_lines) AS l
) keys;
IF v_struck_keys = v_added_keys THEN
RAISE EXCEPTION 'Rättelsen ändrar ingenting.';
END IF;
-- Reconciliation guard: when the entry is anchored to external records
-- (bank transactions, payment links), the anchored side must agree with
-- the external amount. The bank feed / payment amount is immutable, so a
-- strike that moves the 19xx/cash-account (or reskontra) net AWAY from it
-- would create a permanent unexplained reconciliation difference.
--
-- On the bank side two shapes are allowed:
-- 1. net-preserving strikes (e.g. fixing a line description);
-- 2. strikes whose post-state net on the account EQUALS the linked bank
-- amount. This is the "wrong contra line on the bank account itself"
-- case (1930 D / 1930 K against a deposit): the entry never matched
-- the feed, and the rättelse is exactly what makes it match again.
-- Anything else still needs a rättelseverifikat (storno). Reskontra sides
-- (15xx for customer payments, 24xx for supplier payments) stay strictly
-- net-preserving: their anchor is the payment row, not a bank amount.
v_bank_linked := EXISTS (SELECT 1 FROM public.transactions t WHERE t.journal_entry_id = p_entry_id)
OR EXISTS (SELECT 1 FROM public.transaction_voucher_links tvl WHERE tvl.journal_entry_id = p_entry_id);
v_invoice_linked := EXISTS (SELECT 1 FROM public.invoice_payments ip WHERE ip.journal_entry_id = p_entry_id);
v_supplier_linked := EXISTS (SELECT 1 FROM public.supplier_invoice_payments sp WHERE sp.journal_entry_id = p_entry_id);
IF v_bank_linked OR v_invoice_linked OR v_supplier_linked THEN
FOR v_acc, v_delta IN
SELECT x.acc, sum(x.delta)
FROM (
SELECT jel.account_number AS acc,
-(jel.debit_amount - jel.credit_amount) AS delta
FROM public.journal_entry_lines jel
WHERE jel.journal_entry_id = p_entry_id
AND jel.id = ANY (v_strike_ids)
UNION ALL
SELECT btrim(l ->> 'account_number'),
round(COALESCE((l ->> 'debit_amount')::numeric, 0), 2)
- round(COALESCE((l ->> 'credit_amount')::numeric, 0), 2)
FROM jsonb_array_elements(p_new_lines) AS l
) x
GROUP BY x.acc
LOOP
IF abs(v_delta) < 0.005 THEN
CONTINUE;
END IF;
IF v_bank_linked AND (v_acc LIKE '19%' OR v_acc IN (
SELECT ca.ledger_account FROM public.cash_accounts ca WHERE ca.company_id = p_company_id)) THEN
-- Signed bank amount anchored on this account across every linked
-- transaction, counted once per transaction: a split link
-- (transaction_voucher_links, bank_line role) carries the allocated
-- slice, otherwise the transaction's own amount (the 1:1 path sets
-- both the direct FK and a link row for the same transaction).
-- Positive = deposit = debit on the bank account, so it compares to
-- the post-state net debit - credit. A transaction without a
-- cash_account_id resolves to the company's primary cash account,
-- falling back to 1930 (the historical default ledger).
SELECT sum(x.amount) INTO v_anchor
FROM (
SELECT COALESCE(
(SELECT sum(tvl.allocated_amount)
FROM public.transaction_voucher_links tvl
WHERE tvl.transaction_id = t.id
AND tvl.journal_entry_id = p_entry_id
AND tvl.role = 'bank_line'),
t.amount) AS amount
FROM public.transactions t
WHERE t.company_id = p_company_id
AND (t.journal_entry_id = p_entry_id
OR EXISTS (SELECT 1 FROM public.transaction_voucher_links tvl
WHERE tvl.transaction_id = t.id
AND tvl.journal_entry_id = p_entry_id
AND tvl.role = 'bank_line'))
AND COALESCE(
(SELECT ca.ledger_account FROM public.cash_accounts ca WHERE ca.id = t.cash_account_id),
(SELECT ca.ledger_account FROM public.cash_accounts ca
WHERE ca.company_id = p_company_id AND ca.is_primary
ORDER BY ca.created_at LIMIT 1),
'1930') = v_acc
) x;
SELECT COALESCE(sum(jel.debit_amount - jel.credit_amount), 0) INTO v_post_net
FROM public.journal_entry_lines jel
WHERE jel.journal_entry_id = p_entry_id
AND jel.account_number = v_acc
AND NOT (jel.id = ANY (v_strike_ids));
v_post_net := v_post_net + COALESCE((
SELECT sum(round(COALESCE((l ->> 'debit_amount')::numeric, 0), 2)
- round(COALESCE((l ->> 'credit_amount')::numeric, 0), 2))
FROM jsonb_array_elements(p_new_lines) AS l
WHERE btrim(l ->> 'account_number') = v_acc), 0);
IF v_anchor IS NULL THEN
RAISE EXCEPTION 'Raden mot konto % kan inte ändras: verifikationen är kopplad till en banktransaktion eller betalning. Använd rättelseverifikat (storno).', v_acc;
END IF;
IF abs(v_post_net - v_anchor) >= 0.005 THEN
RAISE EXCEPTION 'Raden mot konto % kan inte ändras så: verifikationen är kopplad till en banktransaktion på % kr, och kontots belopp efter rättelsen skulle bli % kr. Rättelsen måste få bankkontot att stämma med banken, annars: använd rättelseverifikat (storno).',
v_acc, round(v_anchor, 2), round(v_post_net, 2);
END IF;
ELSIF (v_invoice_linked AND v_acc LIKE '15%')
OR (v_supplier_linked AND v_acc LIKE '24%') THEN
RAISE EXCEPTION 'Raden mot konto % kan inte ändras: verifikationen är kopplad till en banktransaktion eller betalning. Använd rättelseverifikat (storno).', v_acc;
END IF;
END LOOP;
END IF;
PERFORM set_config('gnubok.allow_line_rattelse', 'true', true);
DELETE FROM public.journal_entry_lines
WHERE journal_entry_id = p_entry_id
AND id = ANY (v_strike_ids);
SELECT COALESCE(max(jel.sort_order), 0) INTO v_sort
FROM public.journal_entry_lines jel
WHERE jel.journal_entry_id = p_entry_id;
FOR v_line IN SELECT * FROM jsonb_array_elements(p_new_lines)
LOOP
v_sort := v_sort + 1;
-- cost_center/project are GENERATED columns derived from dimensions:
-- never inserted explicitly, they recompute from the bag.
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, account_id, debit_amount, credit_amount,
line_description, sort_order, dimensions, currency)
VALUES
(p_entry_id,
btrim(v_line ->> 'account_number'),
(SELECT coa.id FROM public.chart_of_accounts coa
WHERE coa.company_id = p_company_id
AND coa.account_number = btrim(v_line ->> 'account_number')
ORDER BY (coa.is_active IS TRUE) DESC, coa.created_at
LIMIT 1),
round(COALESCE((v_line ->> 'debit_amount')::numeric, 0), 2),
round(COALESCE((v_line ->> 'credit_amount')::numeric, 0), 2),
NULLIF(btrim(COALESCE(v_line ->> 'line_description', '')), ''),
v_sort,
COALESCE(v_line -> 'dimensions', '{}'::jsonb),
'SEK')
RETURNING id INTO v_new_id;
v_added_ids := v_added_ids || v_new_id;
END LOOP;
PERFORM set_config('gnubok.allow_line_rattelse', 'false', true);
-- Authoritative post-state verification straight from the table: the entry
-- must still balance to the öre and hold at least two lines, or everything
-- rolls back.
SELECT COALESCE(sum(jel.debit_amount), 0), COALESCE(sum(jel.credit_amount), 0), count(*)
INTO v_fin_debit, v_fin_credit, v_fin_count
FROM public.journal_entry_lines jel
WHERE jel.journal_entry_id = p_entry_id;
IF abs(v_fin_debit - v_fin_credit) >= 0.005 OR v_fin_count < 2 OR v_fin_debit < 0.005 THEN
RAISE EXCEPTION 'Internt fel: verifikationen balanserar inte efter rättelsen: ändringen har återställts.';
END IF;
-- Close the check-then-write window on period locks: if a lock or close
-- committed while this rättelse was running, abort and roll back rather
-- than write into a period that is now locked.
SELECT fp.is_closed, fp.locked_at INTO v_is_closed, v_locked_at
FROM public.fiscal_periods fp
WHERE fp.id = v_entry.fiscal_period_id;
IF v_is_closed OR v_locked_at IS NOT NULL THEN
RAISE EXCEPTION 'Perioden är stängd eller låst: använd rättelseverifikat (storno).';
END IF;
SELECT COALESCE(jsonb_agg(to_jsonb(jel) ORDER BY jel.sort_order), '[]'::jsonb)
INTO v_added_json
FROM public.journal_entry_lines jel
WHERE jel.id = ANY (v_added_ids);
INSERT INTO public.journal_entry_rattelse_log
(company_id, journal_entry_id, rattelse_type, struck_lines, added_lines, actor)
VALUES
(p_company_id, p_entry_id, 'lines', v_struck_json, v_added_json, v_actor)
RETURNING id INTO v_log_id;
RETURN jsonb_build_object(
'log_id', v_log_id,
'struck_count', v_strike_count,
'added_count', v_new_count,
'total_debit', round(v_fin_debit, 2),
'total_credit', round(v_fin_credit, 2)
);
END;
$function$;
REVOKE ALL ON FUNCTION public.correct_entry_lines_inline(uuid, uuid, uuid[], jsonb, uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.correct_entry_lines_inline(uuid, uuid, uuid[], jsonb, uuid) TO authenticated, service_role;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,224 @@
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { seedCompany, insertDraftJournalEntry } from '@/tests/pg/fixtures'
// Migration 20260831150000_inline_rattelse_opening_balance.sql: inline
// rättelse (BFL 5 kap 5 § track 2) now admits opening-balance verifikat, so
// IB edits in open unlocked years work like Fortnox: the number changes in
// place, no storno, the original lines survive in journal_entry_rattelse_log.
//
// The suite:
// 1. the period's linked IB accepts strike+replace, lines update in place,
// the period link never moves, a log row is written
// 2. an IB that is NOT the period's current linked entry is refused
// 3. a posted bokslut (year_end) on the period refuses the rättelse
// 4. P&L accounts (class 3-8) are refused in IB replacement lines
// 5. storno / year_end / vat_settlement stay refused (unchanged)
// 6. non-IB entries still accept P&L accounts (guard is IB-scoped)
async function insertPostedIb(params: {
companyId: string
userId: string
fiscalPeriodId: string
voucherNumber?: number
link?: boolean
sourceType?: string
}): Promise<{ entryId: string; bankLineId: string; equityLineId: string }> {
const entryId = await insertDraftJournalEntry({
userId: params.userId,
companyId: params.companyId,
fiscalPeriodId: params.fiscalPeriodId,
sourceType: params.sourceType ?? 'opening_balance',
status: 'draft',
voucherNumber: params.voucherNumber ?? 1,
})
const { rows: bank } = await getPool().query<{ id: string }>(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, sort_order, line_description)
VALUES ($1, '1930', 50000, 0, 1, 'IB 1930')
RETURNING id`,
[entryId],
)
const { rows: equity } = await getPool().query<{ id: string }>(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, sort_order, line_description)
VALUES ($1, '2091', 0, 50000, 2, 'IB 2091')
RETURNING id`,
[entryId],
)
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [entryId])
if (params.link !== false) {
await getPool().query(
`UPDATE public.fiscal_periods
SET opening_balance_entry_id = $2, opening_balances_set = true
WHERE id = $1`,
[params.fiscalPeriodId, entryId],
)
}
return { entryId, bankLineId: bank[0].id, equityLineId: equity[0].id }
}
async function insertChartAccount(companyId: string, userId: string, accountNumber: string): Promise<void> {
await getPool().query(
`INSERT INTO public.chart_of_accounts
(user_id, company_id, account_number, account_name, account_class, account_type, normal_balance)
VALUES ($1, $2, $3, 'Testkonto ' || $3, left($3, 1)::int, 'asset', 'debit')
ON CONFLICT DO NOTHING`,
[userId, companyId, accountNumber],
)
}
async function callStrike(
companyId: string,
entryId: string,
strikeIds: string[],
newLines: unknown[],
actor: string,
) {
return getPool().query<{ result: { struck_count: number; added_count: number; log_id: string } }>(
`SELECT public.correct_entry_lines_inline($1::uuid, $2::uuid, $3::uuid[], $4::jsonb, $5::uuid) AS result`,
[companyId, entryId, strikeIds, JSON.stringify(newLines), actor],
)
}
describe('inline rättelse: opening-balance verifikat', () => {
it('edits the linked IB in place: lines replaced, link untouched, rättelse logged', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
await insertChartAccount(companyId, userId, '1930')
await insertChartAccount(companyId, userId, '2091')
const { entryId, bankLineId } = await insertPostedIb({ companyId, userId, fiscalPeriodId })
const res = await callStrike(
companyId,
entryId,
[bankLineId],
[
{ account_number: '1930', debit_amount: 55000, credit_amount: 0, line_description: 'IB 1930' },
{ account_number: '2091', debit_amount: 0, credit_amount: 5000, line_description: 'IB-rättelse 2091' },
],
userId,
)
expect(res.rows[0].result.struck_count).toBe(1)
expect(res.rows[0].result.added_count).toBe(2)
expect(res.rows[0].result.log_id).toBeTruthy()
// Same entry id, still posted, still the period's linked IB.
const { rows: period } = await getPool().query(
`SELECT opening_balance_entry_id FROM public.fiscal_periods WHERE id = $1`,
[fiscalPeriodId],
)
expect(period[0].opening_balance_entry_id).toBe(entryId)
const { rows: lines } = await getPool().query(
`SELECT account_number, debit_amount::numeric, credit_amount::numeric
FROM public.journal_entry_lines WHERE journal_entry_id = $1 ORDER BY sort_order`,
[entryId],
)
expect(lines).toHaveLength(3)
const net1930 = lines
.filter((l) => l.account_number === '1930')
.reduce((s, l) => s + Number(l.debit_amount) - Number(l.credit_amount), 0)
expect(net1930).toBe(55000)
const { rows: log } = await getPool().query(
`SELECT rattelse_type, struck_lines FROM public.journal_entry_rattelse_log WHERE journal_entry_id = $1`,
[entryId],
)
expect(log).toHaveLength(1)
expect(log[0].rattelse_type).toBe('lines')
expect(JSON.stringify(log[0].struck_lines)).toContain('1930')
})
it('refuses an IB entry that is not the period\'s current linked opening balance', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
await insertChartAccount(companyId, userId, '1930')
await insertChartAccount(companyId, userId, '2091')
const { entryId, bankLineId } = await insertPostedIb({
companyId, userId, fiscalPeriodId, link: false,
})
await expect(
callStrike(companyId, entryId, [bankLineId], [
{ account_number: '1930', debit_amount: 55000, credit_amount: 0 },
{ account_number: '2091', debit_amount: 0, credit_amount: 5000 },
], userId),
).rejects.toThrow(/inte periodens aktuella/)
})
it('refuses when the period carries a posted bokslut', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
await insertChartAccount(companyId, userId, '1930')
await insertChartAccount(companyId, userId, '2091')
const { entryId, bankLineId } = await insertPostedIb({ companyId, userId, fiscalPeriodId })
const yearEndId = await insertDraftJournalEntry({
userId, companyId, fiscalPeriodId, sourceType: 'year_end', status: 'draft', voucherNumber: 9,
})
await getPool().query(
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, sort_order)
VALUES ($1, '8999', 100, 0, 1), ($1, '2099', 0, 100, 2)`,
[yearEndId],
)
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [yearEndId])
await expect(
callStrike(companyId, entryId, [bankLineId], [
{ account_number: '1930', debit_amount: 55000, credit_amount: 0 },
{ account_number: '2091', debit_amount: 0, credit_amount: 5000 },
], userId),
).rejects.toThrow(/bokslut/)
})
it('refuses P&L accounts in IB replacement lines', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
await insertChartAccount(companyId, userId, '1930')
await insertChartAccount(companyId, userId, '3001')
const { entryId, bankLineId } = await insertPostedIb({ companyId, userId, fiscalPeriodId })
await expect(
callStrike(companyId, entryId, [bankLineId], [
{ account_number: '3001', debit_amount: 50000, credit_amount: 0 },
], userId),
).rejects.toThrow(/Resultatkonton/)
})
it('still refuses storno / year_end / vat_settlement entries', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
await insertChartAccount(companyId, userId, '1930')
const { entryId, bankLineId } = await insertPostedIb({
companyId, userId, fiscalPeriodId, sourceType: 'year_end', link: false, voucherNumber: 4,
})
await expect(
callStrike(companyId, entryId, [bankLineId], [
{ account_number: '1930', debit_amount: 55000, credit_amount: 0 },
], userId),
).rejects.toThrow(/kan inte rättas radvis/)
})
it('keeps allowing P&L accounts on ordinary (non-IB) verifikat', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
await insertChartAccount(companyId, userId, '1930')
await insertChartAccount(companyId, userId, '5010')
const entryId = await insertDraftJournalEntry({
userId, companyId, fiscalPeriodId, sourceType: 'manual', status: 'draft', voucherNumber: 8,
})
const { rows: exp } = await getPool().query<{ id: string }>(
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, sort_order)
VALUES ($1, '5010', 1000, 0, 1) RETURNING id`,
[entryId],
)
await getPool().query(
`INSERT INTO public.journal_entry_lines (journal_entry_id, account_number, debit_amount, credit_amount, sort_order)
VALUES ($1, '1930', 0, 1000, 2)`,
[entryId],
)
await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [entryId])
const res = await callStrike(companyId, entryId, [exp[0].id], [
{ account_number: '5010', debit_amount: 0, credit_amount: 0, line_description: 'x' },
{ account_number: '5010', debit_amount: 1000, credit_amount: 0, line_description: 'Rättad kostnad' },
].filter((l) => l.debit_amount > 0 || l.credit_amount > 0), userId)
expect(res.rows[0].result.added_count).toBe(1)
})
})