feat(bookkeeping): Fortnox-style inline IB correction + cascade to later years (#2076)
* feat(bookkeeping): cascade opening-balance corrections to later years Fortnox/SIE migrations book one IB verifikat per imported year, so correcting one year's ingaende balans left every later year's linked IB carrying the stale figures (support case: a 2019 IB fixed in Fortnox after export never reached Accounted, skewing all subsequent saldon). - POST /api/import/opening-balance/correct accepts cascade: true and applies the correction's per-account delta to each subsequent year's IB via storno + rebook + relink (lib/import/opening-balance/cascade.ts). Locked/closed/lock-dated/bokslut years are skipped and reported, never forced; a failed year is compensated and the cascade continues. - CorrectOpeningBalanceDialog offers the cascade as a default-checked checkbox when later years have their own IB verifikat, and when the current year is blocked it points at the earliest open year's IB verifikat instead of dead-ending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(bookkeeping): atomic cascade replacement + review findings for PR #2076 - Cascade now books each later year through replaceOpeningBalanceEntry (one RPC transaction: storno + corrected voucher + pointer swap, CAS on the expected old entry), removing the create/reverse/relink window that could leave a period linked to a reversed IB entry. - Cascaded verifikat keep the original lines verbatim (descriptions and dimensions) and append labelled IB-rättelse adjustment lines per changed account instead of collapsing per-account nets. - Year-end lookup fails closed: a query error skips the period instead of reading as 'no bokslut'. - Dialog always sends the cascade flag (a cold reference cache no longer silently disables the default-on cascade), the success toast separates blocked years from failed years needing review, and the checkbox notes that a resultat correction may still need an omforing to 2091. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * feat(bookkeeping): Fortnox-style inline IB correction without storno Founder decision 2026-08-31: IB edits in open unlocked years should feel like Fortnox (change the number, no extra verifikat) instead of always producing a storno + rebook pair in serie A. - Migration 20260831150000 redefines correct_entry_lines_inline to admit source_type 'opening_balance' with three IB guards: only the period's current linked IB, no posted bokslut on the period, and replacement lines restricted to balance-sheet accounts (class 1-2). The entry id never changes, so fiscal_periods.opening_balance_entry_id stays valid and every report reads the corrected lines automatically. Storno, year_end and vat_settlement stay excluded; locked/closed/lock-dated periods are still refused (BFL 5 kap 5 par: storno is the only track there). - New POST /api/import/opening-balance/correct-inline: diff-based strike and replace inside the same IB verifikat, same OB_* pre-flight codes as the storno route, RPC rule violations surfaced verbatim as 409 OB_INLINE_REFUSED. With cascade: true the per-account delta is appended as labelled IB-rattelse lines inside each later open year's own IB verifikat (cascade mode 'inline'): a multi-year correction with zero new verifikat. - CorrectOpeningBalanceDialog computes the row diff (untouched lines keep ids, descriptions and dimensions) and posts to the inline route; copy updated (no storno language), toast reports inline updates. - In-app agent guidance (shared-rules) updated to describe the inline flow and the cascade checkbox. - Tests: pg-real suite for the redefined RPC (IB accept, linked-IB guard, bokslut guard, P&L guard, structural types still refused, non-IB unaffected), route tests, cascade inline-mode unit tests. The storno-based /correct route and engine paths are untouched: they remain for the import replace flow and API compatibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(agent): avoid the BFL 5 kap 5 par marker string in IB guidance The verifikation-draft period-lock gate test uses the literal 'BFL 5 kap 5 §' as a marker for locked-period-only guidance; the new IB bullet in shared-rules carried the same string in every prompt and broke the open-period assertion. Reference Bokföringslagen generically instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(bookkeeping): derive inline cascade delta from the rattelse log Swedish-review finding on PR #2076: the cascade delta was computed from a route-side line snapshot read before the RPC, which a concurrent edit could theoretically desync from what the RPC actually committed. The delta now comes from the RPC's own journal_entry_rattelse_log row (struck_lines/added_lines snapshotted inside the RPC transaction), so the cascade always matches the committed base correction. Also softened the blocked-year guidance copy (declared-status is an assumption, not a verified fact). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY * fix(bookkeeping): visible cascade failure + dimensions-aware no-op check CodeRabbit round-2 findings on PR #2076: - A cascade that failed to run (log fetch error, unexpected throw) was returned as an empty successful summary, so the dialog reported nothing wrong while later years stayed unverified. Both routes now mark it failed: true and the dialog tells the user to check later years' opening balances. - The RPC's no-op guard compared account/amount/description only, so a dimensions-only rattelse raised 'Rattelsen andrar ingenting'. The comparison keys now include canonical dimensions jsonb text (fixed in the unmerged 20260831150000 migration). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Jn6sxPE3zMfpM4CQ24jGY --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f216a60bf8
commit
2814d70cb4
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user