feat(bookkeeping): correction-chain depth guard + Bedrock stream retry (#1581)

* feat(bookkeeping): bypassable chain-depth guard on corrections and stornos

Correcting or reversing an entry that already sits 3+ links deep in a
rattelse chain (correction_of_id/reverses_id walked in the DB, never
description matching) now throws CORRECTION_CHAIN_TOO_DEEP, steering the
caller to book ONE correction expressing the chain's net effect. Agents
looped storno+rattelse 10 deep on a live company (63/193 vouchers noise).

The guard is advisory, never a dead end: allow_deep_chain bypasses it on
every surface (correctEntry/reverseEntry option, REST body, MCP tool arg
staged through pending_operations, and confirm dialogs with Ratta anda /
Aterfor anda in the web UI). MCP staging pre-flight fires the guard at
stage time so the agent reconsiders in the same turn, and the executor
re-checks at commit. tools/list payload ceiling bumped 59K -> 59.5K for
the two bypass properties (trimmed to one sentence first).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(agent): retry the Bedrock stream once on transient failures

A transient stream death (429/5xx, transport cut, or the two known
stream-corruption signatures: 'Unexpected event order' and 'request ended
without sending any chunks') killed the whole chat turn, stranding the
user mid-answer. The turn now retries once per turn after a short backoff:
safe because nothing is persisted until finalMessage() succeeds. A new
stream_restart event carries the pre-attempt text snapshot so the chat
client resets the partial bubble, drops uncompleted tool chips, and shows
'Forsoker igen...' until the retried stream produces text. Non-transient
errors (403, 400) keep the existing immediate-error path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): regenerate accounted-api skill and wire allow_deep_chain through v1

apiskill:check failed: CorrectJournalEntrySchema gained allow_deep_chain,
making references/journal-entries.md stale. Regenerated (hand-applied: the
generator output is deterministic from the registry). While wiring: the v1
correct route validated allow_deep_chain but dropped it, and the v1 reverse
route's strict body schema would have rejected it outright, leaving API
clients no bypass when the chain-depth guard fires. Both now forward the
flag to the engine and document CORRECTION_CHAIN_TOO_DEEP as a pitfall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: re-trigger CI after Vercel infra hang

The preview for e527e4044 compiled in 91s then hung 40 minutes in the
TypeScript phase and was killed with no error output; a CLI redeploy of
the identical code went Ready in 5m. Empty commit to refresh the git-
triggered deployment status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): address CodeRabbit review on the chain-depth guard

- correction-chain: report rootVoucher only when the walk reached a
  genuine parentless root; a broken link, cycle, or hop-cap now yields
  null instead of presenting an intermediate voucher as the chain root.
- recordate: propagate allow_deep_chain end-to-end (recordateEntry
  option, route schema, and a Flytta anda bypass confirm in the dialog);
  a date move is another storno+rattelse layer and carried the guard
  with no override path.
- v1 correct/reverse: run the chain-depth guard before the dry-run
  return so a dry run gives the same verdict as the real execution.
- dashboard reverse route: 400 on malformed JSON or a non-boolean
  allow_deep_chain instead of silently reversing without the override;
  empty body stays the supported no-body case. Tests added.
- AgentChat stream_restart: discard the dead attempt's reasoning and
  re-arm the post-tool paragraph break so a retried turn doesn't render
  thinking twice or glue its continuation onto restored text.
- v1 reverse route doc comment updated for allow_deep_chain.

Not changed: the journal-list reverse flow (flagged as a dead end) can
never receive CORRECTION_CHAIN_TOO_DEEP: the list renders Aterfor only
for entries that are neither storno nor correction, and such entries
have no backward chain links, so their depth is always 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(bookkeeping): recordate route test expects the new options arg

recordateEntry now takes { allowDeepChain } as a sixth argument; the
route test's called-with assertion predates it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-13 19:32:41 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e494662530
commit 05380ddf54
33 changed files with 1355 additions and 148 deletions
+4
View File
@@ -901,6 +901,10 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-13] Underlag failures resolve the response where it fails (getResponseErrorMessage + status) and an expired session announces itself on the existing session-timeout BroadcastChannel, instead of a global authenticated-fetch wrapper: `throw new Error(json.error)` printed "[object Object]" for the structured envelope, so the middleware 401 on a backgrounded mobile tab surfaced as the generic "Något gick fel" with no way back; a wrapper would have to be threaded through every call site to buy the same thing here. Upload failures also post metadata (status, size, mime, resolved reason) to /api/log, the one API path exempt from the timeout gate, because a request answered before the route runs leaves nothing in the function logs and the user-reported failures were invisible there.
[2026-08-13] Oversized phone photos are re-encoded in the browser (2400px long edge, JPEG q0.85 stepping down) rather than raising a platform limit or streaming straight to storage: hosted rejects any request body over 4.5 MB itself (measured against prod: 4.4 MB reaches the route, 4.6 MB returns a plain-text FUNCTION_PAYLOAD_TOO_LARGE), before the route runs and therefore invisibly in the function logs, while the route advertises 10 MB it can never receive. A downscaled photo is still a faithful, durably readable reproduction (BFL 7 kap), which a refusal is not. What cannot be shrunk (PDF, or HEIC where the browser will not decode it) is refused client-side with its actual size named, and 413 was added to the HTTP status map so a rejection in transit still says what happened. Direct-to-storage upload, which would remove the ceiling for PDFs too, is the follow-up, not this fix: it moves sha256/WORM integrity off the server.
[2026-08-13] The book-route underlag fix landed as a pinned-document leg inside propagateUnderlagForBookedTransaction rather than the planned "extract categorize-core's propagation block into a shared helper": PR #1547 had already done that extraction overnight and wired /book and bulk-book to the shared helper, but the helper only walked matched inbox items, so a document pinned via transactions.document_id with no unconsumed inbox item (direct upload, or item consumed elsewhere) still booked to "Underlag saknas". Anchoring the pin inside the helper fixes /book, categorize, bulk-book and attach-after-book in one place; the pin is read fresh (not from the caller's pre-booking snapshot) so a concurrent attach still anchors, and the bulk-book RPC's own atomic doc-linking makes the leg a no-op there.
[2026-08-13] Correction-chain depth guard measures depth by walking correction_of_id/reverses_id links (new lib/core/bookkeeping/correction-chain.ts), never by matching "Rättelse:" prefixes in descriptions: custom verifikationstext (allowed since #1031) would dodge any text-based check. Threshold 3, advisory with an explicit allow_deep_chain bypass on every surface (service option, REST body, MCP tool arg, UI confirm) per the standing soft-guard rule; Christoffer's agent chains hit 10 deep. The guard also fires at MCP staging time, not only at commit, so the agent reconsiders in the same turn instead of after approval.
[2026-08-13] The reverse (storno) UI on the journal detail page got the same catch-and-confirm bypass as the correction dialog even though the plan scoped UI work to the correction dialog: reverseEntry carries the same guard, and without a working "Återför ändå" the guard would dead-end in a toast, which the soft-guard rule forbids.
[2026-08-13] tools/list payload ceiling bumped 59K to 59.5K for the two allow_deep_chain schema properties (trimmed to one sentence first): the bypass is wire contract agents must discover to act on CORRECTION_CHAIN_TOO_DEEP, not trimmable prose.
[2026-08-13] Bedrock stream retry is once per TURN, not per iteration, and only for classified transient failures (429/5xx/transport cuts/the two known stream-corruption signatures; any other 4xx is permanent). The failed attempt persists nothing (persist happens after finalMessage()), so the retry re-sends identical messages; the client discards the partial bubble via the new stream_restart event carrying the pre-attempt text snapshot.
[2026-08-13] Kontantmetoden cut-off is a year-end readiness blocker with a staged MCP remedy, not a warning or a lock-time gate: BFL 5 kap 2 § requires all unpaid receivables and liabilities at fiscal year end, and a lock-time failure would occur after executeYearEndClosing has already posted its immutable closing entry. The staged preview freezes all cut-off and day-one reversal lines; approval re-collects the reskontra and refuses drift or any existing full or partial marker before posting through the bookkeeping engine.
[2026-08-13] v1 categorize/batch-categorize wire the shared underlag propagation after the CAS write rather than inlining anchoring logic, and the route tests mock the helper to assert wiring only (called once per booking the request owns; skipped on partial success and lost CAS races): the helper's own semantics (pin anchoring, never-steal, failure isolation) are unit-tested where they live, and duplicating them at route level is what let the v1 surface drift out of the #1560 fix in the first place. Salvaged from the closed duplicate PR #1559: the attach-after-bulk-book samlingsverifikat test.
[2026-08-13] Whole-krona skatteavdrag fix covers all four percentage paths, not just the plan's two exported functions: the inline flat-30% branches in calculation-engine.ts (fSkattStatus not_verified + no-table fallback) computed öre through r() under the same SFF 22 kap. 1 § rule, so leaving them would keep the defect alive in two live paths. Both now route through calculateSidoinkomstTax.
+40 -3
View File
@@ -115,6 +115,9 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showReverseConfirm, setShowReverseConfirm] = useState(false)
const [isReversing, setIsReversing] = useState(false)
// Non-null when the server refused the storno with CORRECTION_CHAIN_TOO_DEEP:
// holds the reported chain depth and opens the bypass confirm ("Återför ändå").
const [reverseDeepChainDepth, setReverseDeepChainDepth] = useState<number | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [isCommitting, setIsCommitting] = useState(false)
// Confirm-before-posting (convention 10). The list already gates this exact
@@ -285,10 +288,18 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
// no replacement, per BFL 5 kap 5§. Distinct from "Rätta", which always books
// a replacement entry. Routes through the engine's reverseEntry (storno +
// reverses_id link; original → 'reversed', never deleted).
const handleReverse = useCallback(async () => {
const handleReverse = useCallback(async (allowDeepChain = false) => {
setIsReversing(true)
try {
const res = await fetch(`/api/bookkeeping/journal-entries/${id}/reverse`, { method: 'POST' })
const res = await fetch(`/api/bookkeeping/journal-entries/${id}/reverse`, {
method: 'POST',
...(allowDeepChain
? {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ allow_deep_chain: true }),
}
: {}),
})
const result = await res.json()
if (res.ok) {
const storno = result.data
@@ -297,7 +308,13 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
description: t('toast_reverse_done_description', { voucher: formatVoucher(storno ?? {}) }),
})
setShowReverseConfirm(false)
setReverseDeepChainDepth(null)
await fetchData()
} else if (result?.error?.code === 'CORRECTION_CHAIN_TOO_DEEP') {
// Chain-depth guard: swap into the bypass confirm instead of a
// dead-end toast. "Återför ändå" resubmits with allow_deep_chain.
setShowReverseConfirm(false)
setReverseDeepChainDepth(result.error?.details?.depth ?? 3)
} else {
toast({ title: t('toast_reverse_failed'), description: getErrorMessage(result, { context: 'journal_entry' }), variant: 'destructive' })
}
@@ -1243,7 +1260,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
<ConfirmationDialog
open={showReverseConfirm}
onOpenChange={setShowReverseConfirm}
onConfirm={handleReverse}
onConfirm={() => handleReverse()}
isSubmitting={isReversing}
title={t('reverse_confirm_title')}
warningText={t('reverse_warning')}
@@ -1257,6 +1274,26 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
</div>
</div>
</ConfirmationDialog>
{/* Chain-depth guard confirm: the storno was refused because this entry
already sits deep in a rättelse chain. Advisory, never a dead end. */}
<ConfirmationDialog
open={reverseDeepChainDepth != null}
onOpenChange={(next) => { if (!next) setReverseDeepChainDepth(null) }}
onConfirm={() => handleReverse(true)}
isSubmitting={isReversing}
title={t('deep_chain_title')}
warningText={t('deep_chain_body', { depth: reverseDeepChainDepth ?? 3 })}
confirmLabel={t('deep_chain_reverse_anyway')}
>
<div className="flex items-start gap-3 rounded-lg border bg-muted/50 p-4">
<RotateCcw className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
<div className="text-sm">
<p className="font-medium mb-1">{t('deep_chain_reverse_heading', { voucher: formatVoucher(entry) })}</p>
<p className="text-muted-foreground">{t('deep_chain_reverse_body')}</p>
</div>
</div>
</ConfirmationDialog>
</div>
)
}
@@ -15,6 +15,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
if (!validation.success) return validation.response
const result = await correctEntry(supabase, companyId, user.id, id, validation.data.lines, {
description: validation.data.description,
allowDeepChain: validation.data.allow_deep_chain,
})
return NextResponse.json({ data: result })
},
@@ -109,7 +109,8 @@ describe('POST /api/bookkeeping/journal-entries/[id]/recordate', () => {
'company-1',
'user-1',
'entry-1',
'2025-07-03'
'2025-07-03',
{ allowDeepChain: undefined }
)
})
@@ -13,7 +13,9 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
const { id } = await params
const validation = await validateBody(request, RecordateJournalEntrySchema)
if (!validation.success) return validation.response
const result = await recordateEntry(supabase, companyId, user.id, id, validation.data.new_entry_date)
const result = await recordateEntry(supabase, companyId, user.id, id, validation.data.new_entry_date, {
allowDeepChain: validation.data.allow_deep_chain,
})
return NextResponse.json({ data: result })
},
{ requireWrite: true },
@@ -74,7 +74,69 @@ describe('POST /api/bookkeeping/journal-entries/[id]/reverse', () => {
expect(status).toBe(200)
expect(body.data).toEqual(reversalEntry)
expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-1')
expect(mockReverseEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'entry-1',
undefined,
{ allowDeepChain: false },
)
})
it('returns 400 for malformed JSON instead of silently reversing without the override', async () => {
// Raw Request: createMockRequest would JSON.stringify the body and turn
// the malformed payload into a valid JSON string literal.
const request = new Request('http://localhost:3000/api/bookkeeping/journal-entries/entry-1/reverse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{ allow_deep_chain: tru',
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(mockReverseEntry).not.toHaveBeenCalled()
})
it('returns 400 for a non-boolean allow_deep_chain', async () => {
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/reverse', {
method: 'POST',
body: { allow_deep_chain: 'yes' },
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(mockReverseEntry).not.toHaveBeenCalled()
})
it('forwards allow_deep_chain=true from the body (guard bypass)', async () => {
const reversalEntry = makeJournalEntry({
id: 'reversal-1',
reverses_id: 'entry-1',
source_type: 'storno',
})
mockReverseEntry.mockResolvedValue(reversalEntry)
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/reverse', {
method: 'POST',
body: { allow_deep_chain: true },
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockReverseEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'entry-1',
undefined,
{ allowDeepChain: true },
)
})
it('maps a typed concurrent-reversal error to the canonical envelope (409)', async () => {
@@ -1,15 +1,68 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
const ReverseJournalEntrySchema = z
.object({
allow_deep_chain: z.boolean().optional(),
})
.strict()
ensureInitialized()
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal-entry.reverse',
async (_request, { supabase, companyId, user }, { params }) => {
async (request, { supabase, companyId, user }, { params }) => {
const { id } = await params
const reversalEntry = await reverseEntry(supabase, companyId, user.id, id)
// Body is optional (existing callers POST with none): the only accepted
// field is the chain-depth guard override from the "Återför ändå" confirm.
// An empty body is the supported no-body case; malformed JSON or a
// non-boolean field is a caller bug and gets a 400 instead of silently
// reversing without the override the caller thought they sent.
let allowDeepChain = false
const rawText = await request.text()
if (rawText.trim()) {
let parsedJson: unknown
try {
parsedJson = JSON.parse(rawText)
} catch {
return NextResponse.json(
{
error: {
code: 'VALIDATION_ERROR',
message: 'Ogiltig JSON i förfrågan.',
message_en: 'Body is not valid JSON.',
},
},
{ status: 400 },
)
}
const parsed = ReverseJournalEntrySchema.safeParse(parsedJson)
if (!parsed.success) {
return NextResponse.json(
{
error: {
code: 'VALIDATION_ERROR',
message: 'Ogiltigt fält i förfrågan.',
message_en: 'Invalid request body.',
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
},
},
{ status: 400 },
)
}
allowDeepChain = parsed.data.allow_deep_chain === true
}
const reversalEntry = await reverseEntry(supabase, companyId, user.id, id, undefined, {
allowDeepChain,
})
return NextResponse.json({ data: reversalEntry })
},
{ requireWrite: true },
@@ -6,11 +6,12 @@
* posted with the new lines. All three remain in the verifikationsserie,
* linked via reverses_id, reversed_by_id, and correction_of_id.
*
* Body: `{ lines: [...], description? }`: the new balanced lines. The
* corrected entry inherits entry_date, fiscal_period_id, and voucher_series
* from the original. Its description defaults to "Rättelse: <original>";
* pass `description` to override it (e.g. when the original label named the
* wrong account).
* Body: `{ lines: [...], description?, allow_deep_chain? }`: the new balanced
* lines. The corrected entry inherits entry_date, fiscal_period_id, and
* voucher_series from the original. Its description defaults to
* "Rättelse: <original>"; pass `description` to override it (e.g. when the
* original label named the wrong account). `allow_deep_chain` bypasses the
* correction-chain depth guard (CORRECTION_CHAIN_TOO_DEEP at 3+ levels).
*
* Idempotent (mandatory Idempotency-Key). Dry-runnable.
*/
@@ -25,7 +26,8 @@ import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { CorrectJournalEntrySchema } from '@/lib/api/schemas'
import { validateBalance } from '@/lib/bookkeeping/engine'
import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { correctionChainDepth, CORRECTION_CHAIN_GUARD_DEPTH } from '@/lib/core/bookkeeping/correction-chain'
import { CorrectionChainTooDeepError, isBookkeepingError } from '@/lib/bookkeeping/errors'
const JournalEntryCorrected = z.object({
reversal_id: z.string().uuid(),
@@ -52,6 +54,7 @@ registerEndpoint({
'The new lines must balance. JOURNAL_ENTRY_NOT_BALANCED if not.',
'The original\'s entry_date and fiscal_period_id are inherited. If the original\'s period has been locked since posting, the call returns PERIOD_LOCKED.',
'Three voucher numbers are advanced in this call: the original (already burned), the reversal, and the corrected. The series stays unbroken.',
'A chain 3+ corrections deep returns CORRECTION_CHAIN_TOO_DEEP (409). Compute the net effect of the whole chain and book ONE correction, or pass allow_deep_chain=true to override.',
],
example: {
request: {
@@ -110,7 +113,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
const { lines, description } = parsed.data
const { lines, description, allow_deep_chain } = parsed.data
const balance = validateBalance(lines)
if (!balance.valid) {
@@ -124,7 +127,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// CANNOT_CORRECT_NON_POSTED otherwise but we want the structured envelope).
const { data: original, error: fetchErr } = await ctx.supabase
.from('journal_entries')
.select('id, status, entry_date, voucher_series')
.select('id, status, entry_date, voucher_series, correction_of_id, reverses_id')
.eq('company_id', ctx.companyId!)
.eq('id', entryId)
.maybeSingle()
@@ -133,7 +136,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
if (!original) {
return v1ErrorResponseFromCode('JOURNAL_ENTRY_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
const typed = original as { id: string; status: string; entry_date: string; voucher_series: string }
const typed = original as {
id: string
status: string
entry_date: string
voucher_series: string
correction_of_id: string | null
reverses_id: string | null
}
if (typed.status !== 'posted') {
return v1ErrorResponseFromCode('CANNOT_CORRECT_NON_POSTED', ctx.log, {
requestId: ctx.requestId,
@@ -141,6 +151,21 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Chain-depth guard BEFORE the dry-run return: a dry run must give the
// same verdict the real execution would, so a depth-3 chain without the
// override reports CORRECTION_CHAIN_TOO_DEEP here too. correctEntry
// re-checks on the real path.
if (!allow_deep_chain) {
const chain = await correctionChainDepth(ctx.supabase, ctx.companyId!, typed)
if (chain.depth >= CORRECTION_CHAIN_GUARD_DEPTH) {
return v1ErrorResponse(
new CorrectionChainTooDeepError(chain.depth, chain.rootVoucher),
ctx.log,
{ requestId: ctx.requestId },
)
}
}
// Period-lock pre-check on the INHERITED entry_date. /reverse already has
// this guard against its `reversal_date`; /correct must match because
// both the storno and the corrected entry land on typed.entry_date and
@@ -180,7 +205,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
ctx.userId,
entryId,
lines,
{ description },
{ description, allowDeepChain: allow_deep_chain },
)
return ok(
{
@@ -6,7 +6,9 @@
* the reversal carries `reverses_id` back to it and the original is annotated
* with `reversed_by_id`. Both entries remain visible in the verifikationsserie.
*
* Optional body: `{ reversal_date?: ISO date }`. Defaults to today.
* Optional body: `{ reversal_date?: ISO date, allow_deep_chain?: boolean }`.
* reversal_date defaults to today; allow_deep_chain overrides the
* correction-chain depth guard (CORRECTION_CHAIN_TOO_DEEP at 3+ levels).
*
* Idempotent (mandatory Idempotency-Key).
*/
@@ -19,11 +21,16 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { correctionChainDepth, CORRECTION_CHAIN_GUARD_DEPTH } from '@/lib/core/bookkeeping/correction-chain'
import { CorrectionChainTooDeepError, isBookkeepingError } from '@/lib/bookkeeping/errors'
const ReverseRequest = z
.object({
reversal_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'reversal_date must be ISO YYYY-MM-DD').optional(),
// Explicit override of the correction-chain depth guard: reversing an
// entry 3+ links deep in a rättelse chain returns
// CORRECTION_CHAIN_TOO_DEEP without it.
allow_deep_chain: z.boolean().optional(),
})
.strict()
@@ -51,6 +58,7 @@ registerEndpoint({
'Idempotency-Key is mandatory.',
'reversal_date defaults to today; the reversal is posted in the fiscal period covering that date. If today\'s period is locked the call returns PERIOD_LOCKED.',
'You cannot reverse a draft (status must be posted). Use /correct after commit if the original needs replacing.',
'Reversing an entry 3+ links deep in a correction chain returns CORRECTION_CHAIN_TOO_DEEP (409). Book ONE net-effect correction instead, or pass allow_deep_chain=true to override.',
],
example: {
request: { reversal_date: '2026-05-13' },
@@ -85,6 +93,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const entryId = idParse.data
let bodyReversalDate: string | undefined
let bodyAllowDeepChain = false
let rawBody: unknown = null
try {
const text = await request.text()
@@ -104,6 +113,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
bodyReversalDate = parsed.data.reversal_date
bodyAllowDeepChain = parsed.data.allow_deep_chain === true
}
const today = new Date().toISOString().split('T')[0]
@@ -122,7 +132,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// Pre-flight: confirm the original exists, is posted, and not already reversed.
const { data: original, error: fetchErr } = await ctx.supabase
.from('journal_entries')
.select('id, status, reversed_by_id, voucher_series, voucher_number')
.select('id, status, reversed_by_id, voucher_series, voucher_number, correction_of_id, reverses_id')
.eq('company_id', ctx.companyId!)
.eq('id', entryId)
.maybeSingle()
@@ -131,7 +141,13 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
if (!original) {
return v1ErrorResponseFromCode('JOURNAL_ENTRY_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
const typed = original as { id: string; status: string; reversed_by_id: string | null }
const typed = original as {
id: string
status: string
reversed_by_id: string | null
correction_of_id: string | null
reverses_id: string | null
}
if (typed.status !== 'posted') {
return v1ErrorResponseFromCode('CANNOT_REVERSE_NON_POSTED', ctx.log, {
requestId: ctx.requestId,
@@ -145,6 +161,20 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Chain-depth guard BEFORE the dry-run return: a dry run must give the
// same verdict the real execution would. reverseEntry re-checks on the
// real path.
if (!bodyAllowDeepChain) {
const chain = await correctionChainDepth(ctx.supabase, ctx.companyId!, typed)
if (chain.depth >= CORRECTION_CHAIN_GUARD_DEPTH) {
return v1ErrorResponse(
new CorrectionChainTooDeepError(chain.depth, chain.rootVoucher),
ctx.log,
{ requestId: ctx.requestId },
)
}
}
if (ctx.dryRun) {
return dryRunPreview(
{
@@ -157,7 +187,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
}
try {
const reversal = await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, entryId, reversalDate)
const reversal = await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, entryId, reversalDate, {
allowDeepChain: bodyAllowDeepChain,
})
return ok(
{
reversal_id: reversal.id,
+37
View File
@@ -256,6 +256,10 @@ export default function AgentChat({
}
}, [streaming, onStatus])
const [errorMessage, setErrorMessage] = useState<string | null>(null)
// True between a stream_restart event and the retried stream's first sign of
// life: renders a discreet "Försöker igen…" line so the reset bubble doesn't
// read as the assistant silently going blank.
const [retryNotice, setRetryNotice] = useState(false)
const scrollerRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Active turn's controller, kept in a ref (not state) so the stop button
@@ -377,6 +381,7 @@ export default function AgentChat({
setStreaming(true)
setErrorMessage(null)
setRetryNotice(false)
let response: Response
try {
@@ -598,7 +603,31 @@ export default function AgentChat({
})),
)
break
case 'stream_restart': {
// The server's model stream died on a transient error and is being
// retried. Nothing from the dead attempt was persisted: reset the
// in-progress bubble to the server's pre-attempt snapshot, drop tool
// chips that never completed (eager chips from the dead stream), and
// discard the dead attempt's reasoning so the retried attempt's
// thinking doesn't render twice. The post-tool paragraph break is
// re-armed only when restored text exists: the snapshot always ends
// at an iteration boundary (after tool results), so the retried
// continuation should open its own paragraph there.
setRetryNotice(true)
const restored = typeof ev.assistant_text === 'string' ? ev.assistant_text : ''
breakBeforeNextTextRef.current = restored.length > 0
setMessages((prev) =>
updateLastAssistant(prev, (m) => ({
...m,
text: restored,
reasoning: undefined,
toolCalls: m.toolCalls?.filter((tc) => tc.completed),
})),
)
break
}
case 'text_delta':
setRetryNotice(false)
// Insert a paragraph break ONCE when text resumes after a tool
// call, so post-tool narration starts on its own line instead of
// gluing onto the previous sentence ("kategoriseras.Inget historik").
@@ -706,9 +735,11 @@ export default function AgentChat({
break
}
case 'error':
setRetryNotice(false)
setErrorMessage(ev.message as string)
break
case 'turn_complete': {
setRetryNotice(false)
if (!firstTurnFiredRef.current && conversationIdRef.current) {
firstTurnFiredRef.current = true
onFirstTurnComplete?.(conversationIdRef.current)
@@ -811,6 +842,12 @@ export default function AgentChat({
</div>
))}
{retryNotice && !errorMessage && (
<div className="text-xs text-muted-foreground px-1">
Anslutningen bröts, försöker igen…
</div>
)}
{errorMessage && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{errorMessage}
@@ -58,6 +58,9 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
const [lines, setLines] = useState<CorrectionLine[]>([])
const [description, setDescription] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// Non-null when the server refused with CORRECTION_CHAIN_TOO_DEEP: holds the
// reported chain depth and opens the bypass confirm ("Rätta ändå").
const [deepChainDepth, setDeepChainDepth] = useState<number | null>(null)
// Index of the line whose combobox opened the create dialog, and the search
// string it was showing. Null index = the dialog is closed.
const [creatingAccountForLine, setCreatingAccountForLine] = useState<number | null>(null)
@@ -191,7 +194,7 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
const hasValidLines = lines.length >= 2 && lines.every((l) => l.account_number.length === 4)
async function handleSubmit() {
async function handleSubmit(allowDeepChain = false) {
if (!isBalanced || !hasValidLines) return
setIsSubmitting(true)
@@ -211,17 +214,26 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
// Only sent when the user changed the auto prefill: the server
// fallback ("Rättelse: <original>") stays the source of truth.
description: correctionDescriptionForSubmit(description, entry.description),
...(allowDeepChain ? { allow_deep_chain: true } : {}),
}),
})
const result = await res.json()
if (!res.ok) {
// Chain-depth guard: open the bypass confirm instead of a dead-end
// toast. "Rätta ändå" resubmits with allow_deep_chain=true.
const structured = (result as { error?: { code?: string; details?: { depth?: number } } })?.error
if (structured?.code === 'CORRECTION_CHAIN_TOO_DEEP') {
setDeepChainDepth(structured.details?.depth ?? 3)
return
}
const error = new Error('Failed to create correction') as Error & { body?: unknown; status?: number }
error.body = result
error.status = res.status
throw error
}
setDeepChainDepth(null)
const correctedId = result.data?.corrected?.id
@@ -411,12 +423,37 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
Avbryt
</Button>
<Button
onClick={handleSubmit}
onClick={() => handleSubmit()}
disabled={!isBalanced || !hasValidLines || isSubmitting}
>
{isSubmitting ? 'Skapar...' : 'Skapa ändringsverifikation'}
</Button>
</DialogFooter>
{/* Chain-depth guard confirm: the server refused because this entry
already sits deep in a rättelse chain. Advisory, never a dead end:
"Rätta ändå" resubmits with allow_deep_chain=true. */}
<Dialog open={deepChainDepth != null} onOpenChange={(next) => { if (!next) setDeepChainDepth(null) }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('deep_chain_title')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t('deep_chain_body', { depth: deepChainDepth ?? 3 })}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeepChainDepth(null)} disabled={isSubmitting}>
{t('deep_chain_cancel')}
</Button>
<Button
onClick={() => { setDeepChainDepth(null); void handleSubmit(true) }}
disabled={isSubmitting}
>
{t('deep_chain_correct_anyway')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
{/* Nested on purpose: closing this one (Esc, click-outside, Avbryt) must
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import {
Dialog,
DialogContent,
@@ -38,11 +39,15 @@ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
export default function RecordateEntryDialog({ entry, open, onOpenChange, onMoved }: Props) {
const { toast } = useToast()
const router = useRouter()
const t = useTranslations('journal_detail')
const [newDate, setNewDate] = useState(entry.entry_date)
const [preview, setPreview] = useState<PeriodStatus | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewError, setPreviewError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
// Non-null when the server refused with CORRECTION_CHAIN_TOO_DEEP: holds the
// reported chain depth and opens the bypass confirm ("Flytta ändå").
const [deepChainDepth, setDeepChainDepth] = useState<number | null>(null)
// Reset to the original date each time the dialog opens.
useEffect(() => {
@@ -100,22 +105,33 @@ export default function RecordateEntryDialog({ entry, open, onOpenChange, onMove
const canSubmit = dateChanged && targetOpen && !isSubmitting
async function handleSubmit() {
async function handleSubmit(allowDeepChain = false) {
if (!canSubmit) return
setIsSubmitting(true)
try {
const res = await fetch(`/api/bookkeeping/journal-entries/${entry.id}/recordate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_entry_date: newDate }),
body: JSON.stringify({
new_entry_date: newDate,
...(allowDeepChain ? { allow_deep_chain: true } : {}),
}),
})
const result = await res.json()
if (!res.ok) {
// Chain-depth guard: open the bypass confirm instead of a dead-end
// toast. "Flytta ändå" resubmits with allow_deep_chain=true.
const structured = (result as { error?: { code?: string; details?: { depth?: number } } })?.error
if (structured?.code === 'CORRECTION_CHAIN_TOO_DEEP') {
setDeepChainDepth(structured.details?.depth ?? 3)
return
}
const error = new Error('Failed to move entry') as Error & { body?: unknown; status?: number }
error.body = result
error.status = res.status
throw error
}
setDeepChainDepth(null)
const correctedId = result.data?.corrected?.id
toast({
title: 'Verifikationen flyttad',
@@ -238,10 +254,35 @@ export default function RecordateEntryDialog({ entry, open, onOpenChange, onMove
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Avbryt
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
<Button onClick={() => handleSubmit()} disabled={!canSubmit}>
{isSubmitting ? 'Flyttar…' : 'Flytta verifikationen'}
</Button>
</DialogFooter>
{/* Chain-depth guard confirm: the server refused because this entry
already sits deep in a rättelse chain. Advisory, never a dead end:
"Flytta ändå" resubmits with allow_deep_chain=true. */}
<Dialog open={deepChainDepth != null} onOpenChange={(next) => { if (!next) setDeepChainDepth(null) }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('deep_chain_title')}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{t('deep_chain_body', { depth: deepChainDepth ?? 3 })}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeepChainDepth(null)} disabled={isSubmitting}>
{t('deep_chain_cancel')}
</Button>
<Button
onClick={() => { setDeepChainDepth(null); void handleSubmit(true) }}
disabled={isSubmitting}
>
{t('deep_chain_move_anyway')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
)
@@ -162,9 +162,15 @@ describe('tools/list payload size guard', () => {
// upload_id/upload_url/expires_at (method, size cap and echo fields
// moved to description prose) and mime_type made optional on complete;
// the ~360-token remainder is the two tools' wire contract.
// * 59K → 59.5K with the correction-chain depth guard: allow_deep_chain on
// gnubok_correct_entry + gnubok_reverse_journal_entry (the explicit
// bypass agents must discover to override CORRECTION_CHAIN_TOO_DEEP).
// Both property descriptions trimmed to one sentence first; headroom
// before the change was under 20 tokens, so even the trimmed wire
// contract crossed.
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(59_000)
expect(approxTokens).toBeLessThan(59_500)
})
})
@@ -587,6 +587,94 @@ describe('gnubok_correct_entry: registration', () => {
})
})
describe('gnubok_correct_entry / gnubok_reverse_journal_entry: chain-depth guard', () => {
// Base row for a posted correction that already sits 3 links deep:
// c3 → c2 → c1 → orig-0 (root, voucher A7).
const deepTarget = {
id: '22222222-2222-4222-8222-222222222222',
status: 'posted',
entry_date: '2026-05-12',
description: 'Rättelse: Rättelse: Rättelse: Köp',
voucher_number: 15,
voucher_series: 'A',
fiscal_period_id: 'fp-1',
correction_of_id: 'c2',
reverses_id: null,
fiscal_periods: { name: '2026', is_closed: false, locked_at: null },
lines: [
{ account_number: '5420', debit_amount: 1000, credit_amount: 0, line_description: null, currency: null, amount_in_currency: null, exchange_rate: null, tax_code: null, dimensions: null, cost_center: null, project: null },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000, line_description: null, currency: null, amount_in_currency: null, exchange_rate: null, tax_code: null, dimensions: null, cost_center: null, project: null },
],
}
const walkerRows = [
{ data: { id: 'c2', correction_of_id: 'c1', reverses_id: null, voucher_series: 'A', voucher_number: 12 }, error: null },
{ data: { id: 'c1', correction_of_id: 'orig-0', reverses_id: null, voucher_series: 'A', voucher_number: 9 }, error: null },
{ data: { id: 'orig-0', correction_of_id: null, reverses_id: null, voucher_series: 'A', voucher_number: 7 }, error: null },
]
const replacementLines = [
{ account_number: '5410', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
]
it('refuses to stage a correction on a chain 3 levels deep with an agent-actionable error', async () => {
const { supabase, enqueue, enqueueMany } = createQueuedMockSupabase()
// Lines carry no dimensions, so no dimension-registry query precedes the
// entry fetch.
enqueue({ data: deepTarget, error: null })
enqueueMany(walkerRows)
await expect(
correctEntry.execute(
{ entry_id: deepTarget.id, lines: replacementLines },
'company-1',
'user-1',
supabase as never,
),
).rejects.toMatchObject({
code: 'CORRECTION_CHAIN_TOO_DEEP',
depth: 3,
chainRootVoucher: 'A7',
})
})
it('stages when allow_deep_chain=true and forwards the flag to the executor params', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: deepTarget, error: null })
// No walker queries: the guard is skipped entirely on explicit override.
enqueue({ data: { bookkeeping_locked_through: null }, error: null })
enqueue({ data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null })
enqueue({ data: { id: 'op-deep-1' }, error: null })
await correctEntry.execute(
{ entry_id: deepTarget.id, lines: replacementLines, allow_deep_chain: true },
'company-1',
'user-1',
supabase as never,
)
const insertArgs = findCall('pending_operations', 'insert')
expect(insertArgs).toBeDefined()
const payload = (insertArgs as unknown[])[0] as { params?: { allow_deep_chain?: boolean } }
expect(payload.params?.allow_deep_chain).toBe(true)
})
it('refuses to stage a storno on a chain 3 levels deep', async () => {
const { supabase, enqueue, enqueueMany } = createQueuedMockSupabase()
enqueue({ data: deepTarget, error: null })
enqueueMany(walkerRows)
await expect(
reverseEntry.execute(
{ entry_id: deepTarget.id },
'company-1',
'user-1',
supabase as never,
),
).rejects.toMatchObject({ code: 'CORRECTION_CHAIN_TOO_DEEP', depth: 3 })
})
})
describe('gnubok_reverse_journal_entry: staging gates', () => {
it('is registered with bookkeeping:write scope and is not read-only', async () => {
const { TOOL_SCOPE_MAP } = await import('@/lib/auth/api-keys')
+39 -3
View File
@@ -168,7 +168,8 @@ import {
} from '@/lib/core/bookkeeping/kontantmetod-cutoff'
import { generateSIEExport } from '@/lib/reports/sie-export'
import { generateFullArchive, estimateArchiveSize } from '@/lib/reports/full-archive-export'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { bookkeepingErrorResponse, CorrectionChainTooDeepError } from '@/lib/bookkeeping/errors'
import { correctionChainDepth, CORRECTION_CHAIN_GUARD_DEPTH } from '@/lib/core/bookkeeping/correction-chain'
import { getSuggestedCategories, buildMerchantHistory, merchantHistoryFor } from '@/lib/transactions/category-suggestions'
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
import { buildDuplicateBookingClaim } from '@/lib/transactions/categorize-core'
@@ -14705,6 +14706,10 @@ export const tools: McpTool[] = [
required: ['account_number'],
},
},
allow_deep_chain: {
type: 'boolean',
description: 'Override the chain-depth guard (refuses at 3+ rättelse levels: book ONE net-effect correction instead). True only when another layer is intended.',
},
},
required: ['entry_id', 'lines'],
},
@@ -14713,6 +14718,7 @@ export const tools: McpTool[] = [
async execute(args, companyId, userId, supabase, actor) {
const entryRef = args.entry_id as string
const rawLines = args.lines as Array<Record<string, unknown>> | undefined
const allowDeepChain = args.allow_deep_chain === true
if (!entryRef || !Array.isArray(rawLines) || rawLines.length < 2) {
throw new Error('entry_id and at least two lines are required')
@@ -14767,6 +14773,8 @@ export const tools: McpTool[] = [
voucher_number: number
voucher_series: string
fiscal_period_id: string
correction_of_id: string | null
reverses_id: string | null
fiscal_periods: { name?: string; is_closed?: boolean; locked_at?: string | null } | { name?: string; is_closed?: boolean; locked_at?: string | null }[] | null
lines: Array<{
account_number: string
@@ -14785,7 +14793,7 @@ export const tools: McpTool[] = [
const { data, error: origErr } = await supabase
.from('journal_entries')
.select(
'id, status, entry_date, description, voucher_number, voucher_series, fiscal_period_id, ' +
'id, status, entry_date, description, voucher_number, voucher_series, fiscal_period_id, correction_of_id, reverses_id, ' +
'fiscal_periods!journal_entries_fiscal_period_id_fkey!inner(name, is_closed, locked_at), ' +
'lines:journal_entry_lines(account_number, debit_amount, credit_amount, line_description, currency, amount_in_currency, exchange_rate, tax_code, dimensions, cost_center, project)'
)
@@ -14816,6 +14824,15 @@ export const tools: McpTool[] = [
)
}
// Chain-depth guard at staging time so the agent reconsiders NOW, not at
// approval. The executor (commitCorrectEntry → correctEntry) re-checks.
if (!allowDeepChain) {
const chain = await correctionChainDepth(supabase, companyId, original)
if (chain.depth >= CORRECTION_CHAIN_GUARD_DEPTH) {
throw new CorrectionChainTooDeepError(chain.depth, chain.rootVoucher)
}
}
const originalLines = original.lines || []
return stagePendingOperation(supabase, companyId, userId, 'correct_entry',
@@ -14823,6 +14840,7 @@ export const tools: McpTool[] = [
{
entry_id: entryId,
lines,
...(allowDeepChain ? { allow_deep_chain: true } : {}),
},
{
original: {
@@ -14887,6 +14905,10 @@ export const tools: McpTool[] = [
entry_id: { type: 'string', description: 'Journal entry UUID OR voucher ref like "A-113". Prefer voucher refs: UUIDs reused from earlier tool output are frequently hallucinated by LLM callers.' },
reversal_date: { type: 'string', pattern: '^[0-9]{4}-[0-9]{2}-[0-9]{2}$', description: 'Optional ISO yyyy-MM-dd date for the storno verifikation. Defaults to today (Swedish timezone). Period attribution always follows the original entry, regardless of this date.' },
reason: { type: 'string', maxLength: 500, description: 'Optional human-readable reason: shown in pending_operations review. Not stored on the storno itself. Max 500 chars.' },
allow_deep_chain: {
type: 'boolean',
description: 'Override the chain-depth guard (refuses at 3+ rättelse levels: book ONE net-effect correction instead). True only when another storno layer is intended.',
},
},
required: ['entry_id'],
},
@@ -14896,6 +14918,7 @@ export const tools: McpTool[] = [
const entryRef = args.entry_id as string
const reversalDate = typeof args.reversal_date === 'string' ? args.reversal_date : undefined
const reason = typeof args.reason === 'string' ? args.reason : undefined
const allowDeepChain = args.allow_deep_chain === true
if (!entryRef) {
throw new Error('entry_id is required')
@@ -14926,6 +14949,8 @@ export const tools: McpTool[] = [
voucher_number: number
voucher_series: string
fiscal_period_id: string
correction_of_id: string | null
reverses_id: string | null
fiscal_periods: { name?: string; is_closed?: boolean; locked_at?: string | null } | { name?: string; is_closed?: boolean; locked_at?: string | null }[] | null
lines: Array<{
account_number: string
@@ -14937,7 +14962,7 @@ export const tools: McpTool[] = [
const { data, error: origErr } = await supabase
.from('journal_entries')
.select(
'id, status, entry_date, description, voucher_number, voucher_series, fiscal_period_id, ' +
'id, status, entry_date, description, voucher_number, voucher_series, fiscal_period_id, correction_of_id, reverses_id, ' +
'fiscal_periods!journal_entries_fiscal_period_id_fkey!inner(name, is_closed, locked_at), lines:journal_entry_lines(account_number, debit_amount, credit_amount, line_description)'
)
.eq('id', entryId)
@@ -14967,6 +14992,16 @@ export const tools: McpTool[] = [
)
}
// Chain-depth guard at staging time (mirrors gnubok_correct_entry): a
// storno on an entry already deep in a rättelse chain is almost always
// an agent reflexively cancelling its own correction.
if (!allowDeepChain) {
const chain = await correctionChainDepth(supabase, companyId, original)
if (chain.depth >= CORRECTION_CHAIN_GUARD_DEPTH) {
throw new CorrectionChainTooDeepError(chain.depth, chain.rootVoucher)
}
}
const originalLines = original.lines || []
const reversedPreviewLines = originalLines.map((l) => ({
account_number: l.account_number,
@@ -14993,6 +15028,7 @@ export const tools: McpTool[] = [
{
entry_id: entryId,
reversal_date: reversalDate,
...(allowDeepChain ? { allow_deep_chain: true } : {}),
},
{
original: {
@@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { AgentIntent } from '@/lib/agent/intents/types'
import type { StreamEvent } from '../run-turn'
// Anthropic client mock: `finalMessage()` delegates to a queued mock so tests
// can make the first stream attempt fail and the retry succeed. Mirrors the
// adapter in run-turn-memory.test.ts.
const messagesCreate = vi.fn()
vi.mock('@/lib/agent/composer/client', () => ({
getAnthropic: () => ({
messages: {
create: messagesCreate,
stream: (args: unknown) => {
const stream = {
on: () => stream,
finalMessage: () => messagesCreate(args),
}
return stream
},
},
}),
SONNET_MODEL: 'claude-sonnet-5',
MAX_TOKENS_NO_THINKING: 5400,
MAX_TOKENS_STANDARD: 16000,
MAX_TOKENS_DEEP: 24000,
}))
vi.mock('../system-prompt', () => ({
buildSystemPrompt: vi.fn().mockResolvedValue({
blocks: [],
promptHash: 'sha256:test',
atomsLoaded: [],
}),
}))
const getMock = vi.fn()
const getManyMock = vi.fn()
vi.mock('@/lib/agent/tools/registry', () => ({
agentToolRegistry: {
get: (...args: unknown[]) => getMock(...args),
getMany: (...args: unknown[]) => getManyMock(...args),
},
}))
import { runChatTurn, isTransientStreamError } from '../run-turn'
function fakeSupabase() {
const passthrough: Record<string, unknown> = {}
const proxy: unknown = new Proxy(passthrough, {
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
}
return () => proxy
},
})
return proxy as unknown as Parameters<typeof runChatTurn>[0]['supabase']
}
function makeIntent(): AgentIntent {
return {
id: 'general.help',
buttonLabel: 'x',
sheetTitle: 'x',
atoms: { mode: 'progressive', horizontal: [], includeCompanyVertical: false, includeCompanyModifiers: false },
tools: [],
model: 'claude-sonnet-5',
capture: async () => ({}),
promptTemplate: () => '',
}
}
async function runTurn(events: StreamEvent[]): Promise<void> {
await runChatTurn({
supabase: fakeSupabase(),
userId: 'user-1',
companyId: 'company-1',
companyName: 'Acme AB',
firstName: 'Anna',
intent: makeIntent(),
conversationId: 'conv-1',
userMessage: 'hej',
persist: false,
emit: (e) => {
events.push(e)
return true
},
})
}
function transientError(message: string, status?: number): Error & { status?: number } {
const err = new Error(message) as Error & { status?: number }
if (status !== undefined) err.status = status
return err
}
beforeEach(() => {
vi.clearAllMocks()
getManyMock.mockResolvedValue([])
})
describe('isTransientStreamError', () => {
it('classifies the known stream-corruption signatures as transient', () => {
expect(isTransientStreamError(new Error('Unexpected event order, got message_stop'))).toBe(true)
expect(isTransientStreamError(new Error('request ended without sending any chunks'))).toBe(true)
})
it('classifies throttling, 5xx, and transport cuts as transient', () => {
expect(isTransientStreamError(transientError('Too many requests', 429))).toBe(true)
expect(isTransientStreamError(transientError('Internal failure', 503))).toBe(true)
expect(isTransientStreamError(new Error('read ECONNRESET'))).toBe(true)
expect(isTransientStreamError(new Error('Request timed out'))).toBe(true)
})
it('classifies auth and validation failures as permanent', () => {
expect(isTransientStreamError(transientError('Forbidden', 403))).toBe(false)
expect(isTransientStreamError(transientError('Invalid model', 400))).toBe(false)
// A 4xx stays permanent even when the message contains a transient token.
expect(isTransientStreamError(transientError('socket auth rejected', 403))).toBe(false)
expect(isTransientStreamError(new Error('validation failed: max_tokens'))).toBe(false)
})
})
describe('runChatTurn: transient stream retry', () => {
it('retries once on a transient failure and completes the turn', async () => {
messagesCreate
.mockRejectedValueOnce(new Error('Unexpected event order, got message_stop'))
.mockResolvedValueOnce({
content: [{ type: 'text', text: 'Hej igen.' }],
stop_reason: 'end_turn',
})
const events: StreamEvent[] = []
await runTurn(events)
const restarts = events.filter((e) => e.kind === 'stream_restart')
expect(restarts).toHaveLength(1)
expect(restarts[0]).toMatchObject({ kind: 'stream_restart', assistant_text: '' })
expect(events.find((e) => e.kind === 'error')).toBeUndefined()
expect(events.find((e) => e.kind === 'turn_complete')).toBeDefined()
expect(messagesCreate).toHaveBeenCalledTimes(2)
}, 15_000)
it('does not retry a non-transient failure (403): error emitted, turn thrown', async () => {
messagesCreate.mockRejectedValueOnce(transientError('Forbidden', 403))
const events: StreamEvent[] = []
await expect(runTurn(events)).rejects.toThrow('Forbidden')
expect(events.filter((e) => e.kind === 'stream_restart')).toHaveLength(0)
expect(events.find((e) => e.kind === 'error')).toBeDefined()
expect(messagesCreate).toHaveBeenCalledTimes(1)
})
it('retries at most once per turn: a second transient failure surfaces as an error', async () => {
messagesCreate
.mockRejectedValueOnce(transientError('Service unavailable', 503))
.mockRejectedValueOnce(transientError('Service unavailable', 503))
const events: StreamEvent[] = []
await expect(runTurn(events)).rejects.toThrow('Service unavailable')
expect(events.filter((e) => e.kind === 'stream_restart')).toHaveLength(1)
expect(events.find((e) => e.kind === 'error')).toBeDefined()
expect(messagesCreate).toHaveBeenCalledTimes(2)
}, 15_000)
})
+185 -94
View File
@@ -51,6 +51,52 @@ export function friendlyModelError(err: unknown): string {
return 'Något gick fel hos assistenten. Försök igen om en stund.'
}
/**
* True when a Bedrock stream failure is transient: the identical request can
* succeed on an immediate retry without any input change. Covers throttling
* (429), server errors (5xx), transport cuts (timeout/reset/socket), and the
* two known SDK stream-corruption signatures observed in prod on the pinned
* 0.29.x SDK ("Unexpected event order", "request ended without sending any
* chunks"). Auth/validation failures (4xx other than 429) are NOT transient:
* retrying them is wasted work and they must keep surfacing immediately.
*/
export function isTransientStreamError(err: unknown): boolean {
const status = (err as { status?: number } | null)?.status
if (status === 429) return true
if (typeof status === 'number' && status >= 500) return true
// A non-429 4xx is permanent regardless of message text.
if (typeof status === 'number' && status >= 400) return false
const name = (err as { name?: string } | null)?.name ?? ''
const raw = err instanceof Error ? err.message : ''
let cause = ''
try {
const c = (err as { cause?: unknown } | null)?.cause
cause = c instanceof Error ? `${c.name} ${c.message}` : c != null ? String(c) : ''
} catch {
cause = ''
}
const text = `${name} ${raw} ${cause}`.toLowerCase()
return (
text.includes('unexpected event order') ||
text.includes('request ended without sending any chunks') ||
text.includes('throttl') ||
text.includes('rate limit') ||
text.includes('rate exceeded') ||
text.includes('too many') ||
text.includes('timeout') ||
text.includes('timed out') ||
text.includes('etimedout') ||
text.includes('econnreset') ||
text.includes('network') ||
text.includes('socket')
)
}
// One automatic retry per turn on a transient stream failure, after a short
// backoff. Per-turn, not per-iteration: a turn that dies twice is not a blip.
const STREAM_RETRY_BACKOFF_MS = 750
// One turn of the chat loop:
//
// 1. Resolve context (company, profile, ranked memory).
@@ -97,6 +143,15 @@ export type StreamEvent =
memory_kind?: 'fact' | 'preference' | 'pattern' | 'correction'
content?: string
}
| {
// The Bedrock stream died on a transient error and the turn is being
// retried once. Text and eager tool chips from the dead stream were
// never persisted; the chat surface must reset the in-progress
// assistant bubble to `assistant_text` (what had accumulated BEFORE the
// failed attempt) and drop un-completed tool chips.
kind: 'stream_restart'
assistant_text: string
}
| { kind: 'turn_complete'; assistant_text: string }
| { kind: 'error'; message: string }
@@ -255,6 +310,12 @@ export async function runChatTurn(args: RunTurnArgs): Promise<void> {
let assistantText = ''
let iterations = 0
// One automatic retry per TURN when the Bedrock stream dies on a transient
// error (throttling, 5xx, transport cut, stream corruption). The failed
// attempt persisted nothing (persist happens after finalMessage() succeeds),
// so a clean retry is safe; the client is told to discard the partial
// bubble via stream_restart.
let streamRetryUsed = false
// Extended thinking ("tänka längre"): when the intent opts in, every model
// call in the loop gets a reasoning channel so the agent reasons BEFORE it
@@ -291,103 +352,133 @@ export async function runChatTurn(args: RunTurnArgs): Promise<void> {
// sees Anna's reply appear word-by-word instead of waiting 1-5 s for
// the full block to land. We still collect the final assembled message
// for tool detection, persistence and stop-reason control flow.
const stream = anthropic.messages.stream({
model,
max_tokens: maxTokens,
system: systemPrompt.blocks,
messages,
tools: tools.length > 0 ? tools.map(toAnthropicTool) : undefined,
...(thinking ? { thinking } : {}),
...(outputConfig ? { output_config: outputConfig } : {}),
})
stream.on('text', (delta) => {
assistantText += delta
emit({ kind: 'text_delta', delta })
})
// Track which tool_use ids have already been announced to the client so
// the dispatch loop below doesn't re-emit them. Eager-emitting on
// `content_block_start` shaves the perceived lag for tool chips: the
// chip appears the moment the LLM commits to a tool call, instead of
// after the entire response is buffered.
const eagerToolIds = new Set<string>()
stream.on('streamEvent', (ev) => {
// The raw stream event shape depends on the SDK; we care about
// content_block_start with a tool_use block, and content_block_delta
// carrying extended-thinking text.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const e = ev as any
if (
e?.type === 'content_block_delta' &&
e?.delta?.type === 'thinking_delta' &&
typeof e.delta.thinking === 'string'
) {
emit({ kind: 'reasoning_delta', delta: e.delta.thinking })
return
}
if (e?.type === 'content_block_start' && e?.content_block?.type === 'tool_use') {
const block = e.content_block
if (typeof block.id === 'string' && typeof block.name === 'string') {
eagerToolIds.add(block.id)
emit({
kind: 'tool_use',
tool_use_id: block.id,
name: block.name,
// Input is still being streamed at this point; the chip only
// displays the tool name so empty input is fine.
input: {},
})
}
}
})
//
// The attempt loop wraps stream creation + finalMessage() so a transient
// failure (throttling, 5xx, transport cut, stream corruption) gets ONE
// automatic retry per turn. Everything the dead stream emitted is
// reverted: assistantText rolls back to its pre-attempt snapshot and the
// client discards the partial bubble on stream_restart.
let response
try {
response = await stream.finalMessage()
} catch (err) {
// Surface as a chat error so the UI clears its streaming state. Re-throw
// to let the route's outer try/catch persist the failure if needed.
// Normalize Bedrock throttling/timeout/5xx into a friendly Swedish line.
//
// Extract status/code/cause/stack explicitly: the logger keeps only
// name/message/code from an Error and drops the stack in production, so
// the real failure was invisible (every prod log just said "request ended
// without sending any chunks"). These fields tell us whether the empty
// stream is auth (403), bad model/region (400), throttling (429), or a
// genuine transport cut. No secrets: AWS/SDK errors carry none, and the
// logger still redacts personnummer/UUIDs from any string.
const bedrockErr = err as {
status?: number
code?: string
cause?: unknown
stack?: string
}
let errCause: string | undefined
try {
errCause =
bedrockErr?.cause != null
? String(
bedrockErr.cause instanceof Error
? `${bedrockErr.cause.name}: ${bedrockErr.cause.message}`
: bedrockErr.cause,
).slice(0, 300)
: undefined
} catch {
errCause = '[uninspectable cause]'
}
log.error('Bedrock stream failed', err, {
conversationId,
companyId,
let eagerToolIds = new Set<string>()
for (;;) {
const assistantTextBefore = assistantText
const stream = anthropic.messages.stream({
model,
iterations,
errStatus: typeof bedrockErr?.status === 'number' ? bedrockErr.status : undefined,
errCode: typeof bedrockErr?.code === 'string' ? bedrockErr.code : undefined,
errCause,
errStack: typeof bedrockErr?.stack === 'string' ? bedrockErr.stack.slice(0, 1200) : undefined,
max_tokens: maxTokens,
system: systemPrompt.blocks,
messages,
tools: tools.length > 0 ? tools.map(toAnthropicTool) : undefined,
...(thinking ? { thinking } : {}),
...(outputConfig ? { output_config: outputConfig } : {}),
})
emit({ kind: 'error', message: friendlyModelError(err) })
throw err
stream.on('text', (delta) => {
assistantText += delta
emit({ kind: 'text_delta', delta })
})
// Track which tool_use ids have already been announced to the client so
// the dispatch loop below doesn't re-emit them. Eager-emitting on
// `content_block_start` shaves the perceived lag for tool chips: the
// chip appears the moment the LLM commits to a tool call, instead of
// after the entire response is buffered.
eagerToolIds = new Set<string>()
stream.on('streamEvent', (ev) => {
// The raw stream event shape depends on the SDK; we care about
// content_block_start with a tool_use block, and content_block_delta
// carrying extended-thinking text.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const e = ev as any
if (
e?.type === 'content_block_delta' &&
e?.delta?.type === 'thinking_delta' &&
typeof e.delta.thinking === 'string'
) {
emit({ kind: 'reasoning_delta', delta: e.delta.thinking })
return
}
if (e?.type === 'content_block_start' && e?.content_block?.type === 'tool_use') {
const block = e.content_block
if (typeof block.id === 'string' && typeof block.name === 'string') {
eagerToolIds.add(block.id)
emit({
kind: 'tool_use',
tool_use_id: block.id,
name: block.name,
// Input is still being streamed at this point; the chip only
// displays the tool name so empty input is fine.
input: {},
})
}
}
})
try {
response = await stream.finalMessage()
break
} catch (err) {
if (!streamRetryUsed && isTransientStreamError(err)) {
streamRetryUsed = true
log.warn('Bedrock stream failed transiently, retrying once', {
conversationId,
companyId,
model,
iterations,
errMessage: err instanceof Error ? err.message.slice(0, 300) : String(err).slice(0, 300),
errStatus: (err as { status?: number } | null)?.status,
})
// Roll back what the dead stream produced. Nothing was persisted
// (persist happens after finalMessage() succeeds), so state-wise
// this attempt never happened; the client resets its bubble.
assistantText = assistantTextBefore
emit({ kind: 'stream_restart', assistant_text: assistantText })
await new Promise((resolve) => setTimeout(resolve, STREAM_RETRY_BACKOFF_MS))
continue
}
// Surface as a chat error so the UI clears its streaming state. Re-throw
// to let the route's outer try/catch persist the failure if needed.
// Normalize Bedrock throttling/timeout/5xx into a friendly Swedish line.
//
// Extract status/code/cause/stack explicitly: the logger keeps only
// name/message/code from an Error and drops the stack in production, so
// the real failure was invisible (every prod log just said "request ended
// without sending any chunks"). These fields tell us whether the empty
// stream is auth (403), bad model/region (400), throttling (429), or a
// genuine transport cut. No secrets: AWS/SDK errors carry none, and the
// logger still redacts personnummer/UUIDs from any string.
const bedrockErr = err as {
status?: number
code?: string
cause?: unknown
stack?: string
}
let errCause: string | undefined
try {
errCause =
bedrockErr?.cause != null
? String(
bedrockErr.cause instanceof Error
? `${bedrockErr.cause.name}: ${bedrockErr.cause.message}`
: bedrockErr.cause,
).slice(0, 300)
: undefined
} catch {
errCause = '[uninspectable cause]'
}
log.error('Bedrock stream failed', err, {
conversationId,
companyId,
model,
iterations,
retried: streamRetryUsed,
errStatus: typeof bedrockErr?.status === 'number' ? bedrockErr.status : undefined,
errCode: typeof bedrockErr?.code === 'string' ? bedrockErr.code : undefined,
errCause,
errStack: typeof bedrockErr?.stack === 'string' ? bedrockErr.stack.slice(0, 1200) : undefined,
})
emit({ kind: 'error', message: friendlyModelError(err) })
throw err
}
}
const assistantContent: ContentBlock[] = response.content
+7
View File
@@ -1173,6 +1173,10 @@ export const CorrectJournalEntrySchema = z.object({
// the user replace a header that echoed the wrong account's label (#1031).
description: z.string().trim().min(1, 'Description cannot be empty').optional(),
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'),
// Explicit override of the correction-chain depth guard (the "Rätta ändå"
// confirm in the UI). Without it, correcting an entry 3+ links deep in a
// rättelse chain returns CORRECTION_CHAIN_TOO_DEEP.
allow_deep_chain: z.boolean().optional(),
})
// ============================================================
@@ -1356,6 +1360,9 @@ export const UpdateDimensionValueSchema = z
*/
export const RecordateJournalEntrySchema = z.object({
new_entry_date: isoDate,
// Explicit override of the correction-chain depth guard ("Flytta ändå"):
// a date move is another storno+rättelse layer, so it carries the guard too.
allow_deep_chain: z.boolean().optional(),
})
// ============================================================
+3
View File
@@ -686,6 +686,9 @@ describe('reverseEntry: storno guard', () => {
let jeCall = 0
const jeResults = [
{ data: original, error: null },
// Chain-depth walker: follows correction_of_id to the chain root
// (depth 1, well under the guard threshold).
{ data: { id: 'entry-0', correction_of_id: null, reverses_id: null, voucher_series: 'A', voucher_number: 1 }, error: null },
{ data: reversal, error: null },
{ data: null, error: null },
{ data: [{ id: 'entry-1' }], error: null },
+24 -1
View File
@@ -7,12 +7,17 @@ import {
CannotEditNonDraftError,
CannotReverseNonPostedError,
CannotReverseStornoError,
CorrectionChainTooDeepError,
EntryAlreadyReversedError,
EntryDateOutsideFiscalPeriodError,
FiscalPeriodNotFoundError,
JournalEntryNotBalancedError,
JournalEntryNotFoundError,
} from '@/lib/bookkeeping/errors'
import {
correctionChainDepth,
CORRECTION_CHAIN_GUARD_DEPTH,
} from '@/lib/core/bookkeeping/correction-chain'
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
import {
normalizeLineDimensions,
@@ -1039,7 +1044,15 @@ export async function reverseEntry(
companyId: string,
userId: string,
entryId: string,
reversalDate?: string
reversalDate?: string,
options?: {
/**
* Bypass the correction-chain depth guard: reversing an entry that is
* already CORRECTION_CHAIN_GUARD_DEPTH+ links deep throws
* CorrectionChainTooDeepError unless set (see storno-service correctEntry).
*/
allowDeepChain?: boolean
}
): Promise<JournalEntry> {
// Fetch original entry with lines
@@ -1071,6 +1084,16 @@ export async function reverseEntry(
throw new CannotReverseStornoError(original.source_type)
}
// Chain-depth guard: a storno on an entry already deep in a rättelse chain
// is almost always an agent reflexively cancelling its own correction
// (Christoffer case 2026-08-11). Same guard and bypass as correctEntry.
if (!options?.allowDeepChain) {
const chain = await correctionChainDepth(supabase, companyId, original)
if (chain.depth >= CORRECTION_CHAIN_GUARD_DEPTH) {
throw new CorrectionChainTooDeepError(chain.depth, chain.rootVoucher)
}
}
const lines = (original.lines as JournalEntryLine[]) || []
// Create reversed lines (swap debit and credit, preserve dimensions)
+41
View File
@@ -43,6 +43,7 @@ export const CURRENCY_REVALUATION_ALREADY_EXISTS = 'CURRENCY_REVALUATION_ALREADY
export const INVALID_MAPPING_RESULT = 'INVALID_MAPPING_RESULT' as const
export const BOOKKEEPING_DATABASE_ERROR = 'BOOKKEEPING_DATABASE_ERROR' as const
export const MEANINGLESS_CORRECTION = 'MEANINGLESS_CORRECTION' as const
export const CORRECTION_CHAIN_TOO_DEEP = 'CORRECTION_CHAIN_TOO_DEEP' as const
export const NO_OPEN_PERIOD_FOR_DATE = 'NO_OPEN_PERIOD_FOR_DATE' as const
export const TARGET_PERIOD_CLOSED = 'TARGET_PERIOD_CLOSED' as const
export const TARGET_PERIOD_LOCKED = 'TARGET_PERIOD_LOCKED' as const
@@ -220,6 +221,32 @@ export class MeaninglessCorrectionError extends Error {
}
}
/**
* Raised when a correction/reversal targets an entry that already sits
* CORRECTION_CHAIN_GUARD_DEPTH or more links deep in a rättelse chain
* (correction_of_id/reverses_id walked backwards). Stacking yet another
* storno+rättelse on top buries the journal in noise vouchers; the sanctioned
* fix is ONE correction expressing the net effect of the whole chain. The
* guard is advisory: callers bypass it with an explicit allowDeepChain flag
* (allow_deep_chain on the API/MCP surfaces), so it never dead-ends a
* legitimate deep fix.
*/
export class CorrectionChainTooDeepError extends Error {
readonly code = CORRECTION_CHAIN_TOO_DEEP
constructor(
public readonly depth: number,
public readonly chainRootVoucher: string | null
) {
super(
`Correction chain is already ${depth} levels deep` +
(chainRootVoucher ? ` (chain root: ${chainRootVoucher})` : '') +
'. Compute the net effect of the whole chain and book ONE correction instead, ' +
'or pass allow_deep_chain=true to override.'
)
this.name = 'CorrectionChainTooDeepError'
}
}
/**
* Raised when a verifikation is moved (recordate) to a date that no fiscal
* period covers. We do not auto-create periods on a correction.
@@ -339,6 +366,7 @@ export function isBookkeepingError(err: unknown): boolean {
err instanceof InvalidMappingResultError ||
err instanceof BookkeepingDatabaseError ||
err instanceof MeaninglessCorrectionError ||
err instanceof CorrectionChainTooDeepError ||
err instanceof NoOpenPeriodForDateError ||
err instanceof TargetPeriodClosedError ||
err instanceof TargetPeriodLockedError ||
@@ -532,6 +560,19 @@ export function bookkeepingErrorResponse(err: unknown): NextResponse | null {
)
}
if (err instanceof CorrectionChainTooDeepError) {
return NextResponse.json(
{
error: {
code: err.code,
message: err.message,
details: { depth: err.depth, chainRootVoucher: err.chainRootVoucher },
},
},
{ status: 409 }
)
}
if (err instanceof NoOpenPeriodForDateError) {
return NextResponse.json(
{
@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
correctionChainDepth,
CORRECTION_CHAIN_GUARD_DEPTH,
MAX_CHAIN_WALK,
} from '../correction-chain'
// Mock supabase: each .single() call resolves the next queued result and
// records the id filtered on, so tests can assert exactly which parents were
// fetched.
let results: Array<{ data?: unknown; error?: unknown }>
let fetchedIds: string[]
function makeClient() {
return {
from: vi.fn().mockImplementation(() => {
const b: Record<string, unknown> = {}
b.select = vi.fn().mockReturnValue(b)
b.eq = vi.fn().mockImplementation((col: string, value: string) => {
if (col === 'id') fetchedIds.push(value)
return b
})
b.single = vi.fn().mockImplementation(async () => results.shift() ?? { data: null, error: null })
return b
}),
}
}
function row(
id: string,
parent: { correction_of_id?: string | null; reverses_id?: string | null } = {},
voucher: { series?: string; number?: number } = {}
) {
return {
data: {
id,
correction_of_id: parent.correction_of_id ?? null,
reverses_id: parent.reverses_id ?? null,
voucher_series: voucher.series ?? 'A',
voucher_number: voucher.number ?? 1,
},
error: null,
}
}
beforeEach(() => {
vi.clearAllMocks()
results = []
fetchedIds = []
})
describe('correctionChainDepth', () => {
it('returns depth 0 with zero queries for an unchained entry', async () => {
const supabase = makeClient()
const info = await correctionChainDepth(supabase as never, 'company-1', {
id: 'orig-1',
correction_of_id: null,
reverses_id: null,
})
expect(info).toEqual({ depth: 0, rootVoucher: null })
expect(supabase.from).not.toHaveBeenCalled()
})
it('walks correction_of_id links to the root and reports its voucher', async () => {
// target(c3) → c2 → c1 → orig (root, voucher A7)
results = [
row('c2', { correction_of_id: 'c1' }, { number: 4 }),
row('c1', { correction_of_id: 'orig' }, { number: 3 }),
row('orig', {}, { series: 'A', number: 7 }),
]
const supabase = makeClient()
const info = await correctionChainDepth(supabase as never, 'company-1', {
id: 'c3',
correction_of_id: 'c2',
reverses_id: null,
})
expect(info).toEqual({ depth: 3, rootVoucher: 'A7' })
expect(fetchedIds).toEqual(['c2', 'c1', 'orig'])
})
it('follows reverses_id when correction_of_id is absent (storno links)', async () => {
results = [row('orig', {}, { series: 'B', number: 12 })]
const supabase = makeClient()
const info = await correctionChainDepth(supabase as never, 'company-1', {
id: 'storno-1',
correction_of_id: null,
reverses_id: 'orig',
})
expect(info).toEqual({ depth: 1, rootVoucher: 'B12' })
})
it('stops on a broken link (parent not found)', async () => {
results = [
row('c1', { correction_of_id: 'gone' }),
{ data: null, error: { message: 'not found' } },
]
const supabase = makeClient()
const info = await correctionChainDepth(supabase as never, 'company-1', {
id: 'c2',
correction_of_id: 'c1',
reverses_id: null,
})
expect(info.depth).toBe(1)
// The walk never reached a parentless node, so no voucher may be
// presented as the chain root.
expect(info.rootVoucher).toBeNull()
})
it('terminates on a cyclic chain instead of looping', async () => {
// a → b → a (cycle). The walk must stop when it re-encounters a.
results = [
row('b', { correction_of_id: 'a' }),
row('a', { correction_of_id: 'b' }),
]
const supabase = makeClient()
const info = await correctionChainDepth(supabase as never, 'company-1', {
id: 'a',
correction_of_id: 'b',
reverses_id: null,
})
expect(info.depth).toBeLessThanOrEqual(2)
})
it('caps the walk at MAX_CHAIN_WALK hops', async () => {
for (let i = 0; i < MAX_CHAIN_WALK + 5; i++) {
results.push(row(`e${i}`, { correction_of_id: `e${i + 1}` }))
}
const supabase = makeClient()
const info = await correctionChainDepth(supabase as never, 'company-1', {
id: 'start',
correction_of_id: 'e0',
reverses_id: null,
})
expect(info.depth).toBe(MAX_CHAIN_WALK)
expect(fetchedIds).toHaveLength(MAX_CHAIN_WALK)
// Capped before reaching the root: the last node still has a parent.
expect(info.rootVoucher).toBeNull()
})
it('guard threshold is 3: original → rättelse → rättelse-av-rättelse stays allowed', () => {
// Locked by the plan: depth 2 targets (fixing the fix) pass the guard,
// depth 3+ requires the explicit override.
expect(CORRECTION_CHAIN_GUARD_DEPTH).toBe(3)
})
})
@@ -1,7 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { makeJournalEntry, makeJournalEntryLine } from '@/tests/helpers'
import { BookkeepingDatabaseError, MeaninglessCorrectionError } from '@/lib/bookkeeping/errors'
import {
BookkeepingDatabaseError,
CorrectionChainTooDeepError,
MeaninglessCorrectionError,
} from '@/lib/bookkeeping/errors'
// ============================================================
// Mock: separate client (no .then) from query builder (thenable)
@@ -350,18 +354,20 @@ describe('correctEntry', () => {
results = [
{ data: correctionAsOriginal, error: null }, // 0: fetch original (the prior correction)
{ data: [{ id: 'acc-5430', account_number: '5430' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts (Step 0)
{ data: secondReversal, error: null }, // 2: insert reversal
{ data: null, error: null }, // 3: insert reversal lines
{ data: null, error: null }, // 4: post reversal
{ data: secondCorrection, error: null }, // 5: insert corrected
{ data: null, error: null }, // 6: insert corrected lines
{ data: null, error: null }, // 7: post corrected
{ data: [{ id: 'correction-1' }], error: null }, // 8: CAS update
{ data: null, error: null }, // 9: relink transactions
{ data: null, error: null }, // 10: relink documents
{ data: { ...secondReversal, lines: [] }, error: null }, // 11: fetch final reversal
{ data: { ...secondCorrection, lines: [] }, error: null }, // 12: fetch final corrected
// 1: chain-depth walker follows correction_of_id to the root (depth 1)
{ data: { id: 'orig-A', correction_of_id: null, reverses_id: null, voucher_series: 'A', voucher_number: 1 }, error: null },
{ data: [{ id: 'acc-5430', account_number: '5430' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 2: accounts (Step 0)
{ data: secondReversal, error: null }, // 3: insert reversal
{ data: null, error: null }, // 4: insert reversal lines
{ data: null, error: null }, // 5: post reversal
{ data: secondCorrection, error: null }, // 6: insert corrected
{ data: null, error: null }, // 7: insert corrected lines
{ data: null, error: null }, // 8: post corrected
{ data: [{ id: 'correction-1' }], error: null }, // 9: CAS update
{ data: null, error: null }, // 10: relink transactions
{ data: null, error: null }, // 11: relink documents
{ data: { ...secondReversal, lines: [] }, error: null }, // 12: fetch final reversal
{ data: { ...secondCorrection, lines: [] }, error: null }, // 13: fetch final corrected
]
const supabase = makeClient()
@@ -375,6 +381,83 @@ describe('correctEntry', () => {
expect(result.corrected.source_type).toBe('correction')
})
it('rejects a correction whose target already sits 3 links deep in the chain', async () => {
// Chain: orig-0 ← c1 ← c2 ← c3 (target). Depth of c3 is 3 → guard fires.
// Christoffer case 2026-08-11: agents stacked rättelser 10 deep.
const target = makeJournalEntry({
id: 'c3',
status: 'posted',
source_type: 'correction',
correction_of_id: 'c2',
fiscal_period_id: 'fp-1',
voucher_series: 'A',
lines: [
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
],
})
results = [
{ data: target, error: null }, // 0: fetch original
{ data: { id: 'c2', correction_of_id: 'c1', reverses_id: null, voucher_series: 'A', voucher_number: 3 }, error: null }, // 1: walker hop 1
{ data: { id: 'c1', correction_of_id: 'orig-0', reverses_id: null, voucher_series: 'A', voucher_number: 2 }, error: null }, // 2: walker hop 2
{ data: { id: 'orig-0', correction_of_id: null, reverses_id: null, voucher_series: 'A', voucher_number: 1 }, error: null }, // 3: walker hop 3 (root)
]
const supabase = makeClient()
const err = await correctEntry(
supabase as never, 'company-1', 'user-1', 'c3', correctedLines
).catch((e) => e)
expect(err).toBeInstanceOf(CorrectionChainTooDeepError)
expect(err).toMatchObject({
code: 'CORRECTION_CHAIN_TOO_DEEP',
depth: 3,
chainRootVoucher: 'A1',
})
// Guard fires before any write or voucher-number draw.
expect(inserts).toHaveLength(0)
expect(getNextVoucherNumber).not.toHaveBeenCalled()
})
it('allowDeepChain: true bypasses the chain-depth guard without walking the chain', async () => {
const target = makeJournalEntry({
id: 'c3',
status: 'posted',
source_type: 'correction',
correction_of_id: 'c2',
description: 'Test purchase',
fiscal_period_id: 'fp-1',
voucher_series: 'A',
lines: [
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
],
})
const reversalEntry = makeJournalEntry({ id: 'reversal-1', reverses_id: 'c3' })
const correctedEntry = makeJournalEntry({ id: 'c4', correction_of_id: 'c3' })
results = [
{ data: target, error: null }, // 0: fetch original (no walker queries)
{ data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null }, // 1: accounts
{ data: reversalEntry, error: null }, // 2: insert reversal
{ data: null, error: null }, // 3: reversal lines
{ data: null, error: null }, // 4: post reversal
{ data: correctedEntry, error: null }, // 5: insert corrected
{ data: null, error: null }, // 6: corrected lines
{ data: null, error: null }, // 7: post corrected
{ data: [{ id: 'c3' }], error: null }, // 8: CAS
{ data: null, error: null }, // 9: relink transactions
{ data: null, error: null }, // 10: relink documents
{ data: { ...reversalEntry, lines: [] }, error: null }, // 11: final reversal
{ data: { ...correctedEntry, lines: [] }, error: null }, // 12: final corrected
]
const supabase = makeClient()
const result = await correctEntry(
supabase as never, 'company-1', 'user-1', 'c3', correctedLines, { allowDeepChain: true }
)
expect(result.corrected.id).toBe('c4')
})
it('fails fast on unknown accounts: BEFORE the storno exists or a voucher number is consumed', async () => {
// Regression: the old flow created+posted the storno first, then hit
// AccountsNotInChartError on the corrected lines and had to cancel the
+90
View File
@@ -0,0 +1,90 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Correction-chain depth walker.
*
* A rättelse chain is linked in the DB: a correction carries
* `correction_of_id` and a storno carries `reverses_id`, both pointing at the
* entry they replace/cancel. Walking those links backwards from an entry
* gives the number of correction generations between it and the chain root
* (the original verifikat).
*
* Depth is derived from the links, never from description parsing:
* "Rättelse: Rättelse:" matching breaks the moment a caller supplies a custom
* verifikationstext (allowed since issue #1031).
*
* Used by the chain-depth guard (Christoffer case 2026-08-11: agents looped
* corrections of corrections 10 deep, drowning the journal in noise vouchers).
*/
/**
* Depth at which correctEntry/reverseEntry refuse without an explicit
* override. Original → rättelse → rättelse-av-rättelse is a legitimate flow
* (someone fixes the fix); a target already 3+ links deep is thrash in
* practice.
*/
export const CORRECTION_CHAIN_GUARD_DEPTH = 3
/** Hard cap on backward hops so a pathological or cyclic chain cannot loop. */
export const MAX_CHAIN_WALK = 10
interface ChainEntryRow {
id: string
correction_of_id?: string | null
reverses_id?: string | null
voucher_series?: string | null
voucher_number?: number | null
}
export interface CorrectionChainInfo {
/** Backward hops from the entry to the chain root (0 = not part of a chain). */
depth: number
/** Voucher ref of the chain root (e.g. "A113"), when it was reached. */
rootVoucher: string | null
}
/**
* Walk `correction_of_id`/`reverses_id` backwards from `entry` and return the
* chain depth plus the root voucher ref. One query per hop, capped at
* MAX_CHAIN_WALK; a broken link (parent not found) or a cycle ends the walk.
* An entry with no links costs zero queries.
*/
export async function correctionChainDepth(
supabase: SupabaseClient,
companyId: string,
entry: ChainEntryRow
): Promise<CorrectionChainInfo> {
const visited = new Set<string>([entry.id])
let depth = 0
let root: ChainEntryRow = entry
let parentId = entry.correction_of_id ?? entry.reverses_id ?? null
while (parentId && depth < MAX_CHAIN_WALK) {
if (visited.has(parentId)) break
visited.add(parentId)
const { data: parent, error } = await supabase
.from('journal_entries')
.select('id, correction_of_id, reverses_id, voucher_series, voucher_number')
.eq('id', parentId)
.eq('company_id', companyId)
.single()
if (error || !parent) break
depth++
root = parent as ChainEntryRow
parentId = root.correction_of_id ?? root.reverses_id ?? null
}
// Only a node with no backward link is the genuine chain root. When the
// walk stopped early (broken link, cycle, MAX_CHAIN_WALK), `root` is just
// the last node reached: presenting its voucher as the root would mislead.
const reachedRoot = (root.correction_of_id ?? root.reverses_id) == null
const rootVoucher =
depth > 0 && reachedRoot && root.voucher_series && root.voucher_number != null
? `${root.voucher_series}${root.voucher_number}`
: null
return { depth, rootVoucher }
}
+34 -1
View File
@@ -9,10 +9,15 @@ import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
import { normalizeLineDimensions } from '@/lib/bookkeeping/dimension-resolver'
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service'
import {
correctionChainDepth,
CORRECTION_CHAIN_GUARD_DEPTH,
} from '@/lib/core/bookkeeping/correction-chain'
import {
AccountsNotInChartError,
BookkeepingDatabaseError,
CannotCorrectNonPostedError,
CorrectionChainTooDeepError,
EntryAlreadyReversedError,
EntryDateOutsideFiscalPeriodError,
FiscalPeriodNotFoundError,
@@ -137,6 +142,14 @@ export async function correctEntry(
* stale label echoing in the correction header (issue #1031).
*/
description?: string
/**
* Bypass the correction-chain depth guard. Correcting an entry that is
* already CORRECTION_CHAIN_GUARD_DEPTH+ links deep in a rättelse chain
* throws CorrectionChainTooDeepError unless this is set: the sanctioned
* fix is one correction expressing the chain's net effect, not another
* layer of storno+rättelse.
*/
allowDeepChain?: boolean
}
): Promise<{ reversal: JournalEntry; corrected: JournalEntry; documentRelinkError?: string }> {
// Validate the corrected lines are balanced
@@ -172,6 +185,17 @@ export async function correctEntry(
throw new CannotCorrectNonPostedError(original.status)
}
// Chain-depth guard: stop corrections stacking on corrections. Agents have
// looped storno+rättelse on their own rättelser 10 deep (Christoffer case
// 2026-08-11), turning a third of the journal into noise vouchers. Depth is
// walked over the DB links, so a custom verifikationstext cannot dodge it.
if (!options?.allowDeepChain) {
const chain = await correctionChainDepth(supabase, companyId, original)
if (chain.depth >= CORRECTION_CHAIN_GUARD_DEPTH) {
throw new CorrectionChainTooDeepError(chain.depth, chain.rootVoucher)
}
}
const originalLines = (original.lines as JournalEntryLine[]) || []
// Resolve where the corrected entry lands. Defaults to the original's own
@@ -497,7 +521,15 @@ export async function recordateEntry(
companyId: string,
userId: string,
originalEntryId: string,
newDate: string
newDate: string,
options?: {
/**
* Bypass the correction-chain depth guard (see correctEntry): a date
* move IS another storno+rättelse layer, so moving an entry already
* 3+ links deep needs the same explicit confirmation.
*/
allowDeepChain?: boolean
}
): Promise<{ reversal: JournalEntry; corrected: JournalEntry }> {
// Fetch original with lines
const { data: original, error: fetchError } = await supabase
@@ -565,6 +597,7 @@ export async function recordateEntry(
// Hand the entry we already fetched (with lines) to correctEntry so it
// doesn't re-read the same row.
preloadedOriginal: original as OriginalWithLines,
allowDeepChain: options?.allowDeepChain,
}
)
+14
View File
@@ -465,6 +465,20 @@ export function getErrorMessage(
return 'Rättelsen saknar ekonomisk innebörd: varje konto netto till noll. En rättelse måste beskriva en faktisk affärshändelse (BFL 5 kap. 5 §).'
}
if (structured.code === 'CORRECTION_CHAIN_TOO_DEEP') {
const details = structured.details as
| { depth?: number; chainRootVoucher?: string | null }
| undefined
const depthPart =
typeof details?.depth === 'number'
? `Kedjan är redan ${details.depth} nivåer djup`
: 'Rättelsekedjan är redan flera nivåer djup'
const rootPart = details?.chainRootVoucher
? ` (ursprungsverifikat ${details.chainRootVoucher})`
: ''
return `${depthPart}${rootPart}. Räkna ut nettoeffekten av hela kedjan och gör EN rättelse istället, eller skicka allow_deep_chain=true för att rätta ändå.`
}
if (structured.code === 'BOOKKEEPING_DATABASE_ERROR') {
// A DB-layer error may carry a user-relevant cause (e.g. period lock
// trigger). Try the known-pattern map before falling back to the
+7
View File
@@ -28,6 +28,7 @@ import {
CannotCorrectNonPostedError,
CannotReverseNonPostedError,
CannotReverseStornoError,
CorrectionChainTooDeepError,
DimensionValidationError,
EntryAlreadyReversedError,
EntryDateOutsideFiscalPeriodError,
@@ -442,6 +443,12 @@ function extractBookkeepingDetails(err: unknown): { code: string; details?: unkn
if (err instanceof MeaninglessCorrectionError) {
return { code: err.code, details: { reason: err.reason } }
}
if (err instanceof CorrectionChainTooDeepError) {
return {
code: err.code,
details: { depth: err.depth, chainRootVoucher: err.chainRootVoucher },
}
}
if (err instanceof DimensionValidationError) {
return { code: err.code, details: { issues: err.issues } }
}
+12
View File
@@ -283,6 +283,18 @@ const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
message_sv: 'Rättelsen motsvarar ingen ekonomisk händelse: det finns inget att rätta.',
message_en: 'The correction represents no economic event: nothing to correct.',
},
CORRECTION_CHAIN_TOO_DEEP: {
httpStatus: 409,
message_sv:
'Rättelsekedjan är redan flera nivåer djup. Räkna ut nettoeffekten av hela kedjan och gör EN rättelse istället, eller skicka allow_deep_chain=true för att rätta ändå.',
message_en:
'The correction chain is already several levels deep. Compute the net effect of the whole chain and book ONE correction instead, or pass allow_deep_chain=true to override.',
remediation: {
description:
'Read the full chain with gnubok_query_journal (follow correction_of_id/reverses_id to the chain root), compute the net effect across all entries, and stage ONE correction on the live entry that expresses it. Only pass allow_deep_chain=true if stacking another correction is genuinely intended.',
tool: 'gnubok_query_journal',
},
},
NO_OPEN_PERIOD_FOR_DATE: {
httpStatus: 400,
message_sv:
@@ -774,7 +774,8 @@ describe('commitPendingOperation: correct_entry', () => {
expect.arrayContaining([
expect.objectContaining({ account_number: '2645' }),
expect.objectContaining({ account_number: '2614' }),
])
]),
{ allowDeepChain: false }
)
})
@@ -930,7 +931,8 @@ describe('commitPendingOperation: reverse_entry', () => {
'company-1',
'user-1',
'je-original',
undefined
undefined,
{ allowDeepChain: false }
)
})
@@ -964,7 +966,8 @@ describe('commitPendingOperation: reverse_entry', () => {
'company-1',
'user-1',
'je-original',
'2026-05-20'
'2026-05-20',
{ allowDeepChain: false }
)
})
+9 -2
View File
@@ -4687,7 +4687,12 @@ async function commitCorrectEntry(
// made in May 2026 for a December 2025 voucher correctly lands in 2025,
// keeping that period's balances consistent. The is_closed pre-flight
// above is what blocks corrections to already-locked periods.
const result = await correctEntry(supabase, companyId, userId, entryId, lines)
const result = await correctEntry(supabase, companyId, userId, entryId, lines, {
// Staged by the MCP tool when the agent explicitly overrode the
// chain-depth guard; the guard re-checks here so a stale approval of a
// chain that grew deeper in the meantime still stops.
allowDeepChain: params.allow_deep_chain === true,
})
return {
data: {
original_entry_id: entryId,
@@ -4760,7 +4765,9 @@ async function commitReverseEntry(
}
try {
const reversal = await reverseEntry(supabase, companyId, userId, entryId, reversalDate)
const reversal = await reverseEntry(supabase, companyId, userId, entryId, reversalDate, {
allowDeepChain: params.allow_deep_chain === true,
})
// Invariant per BFL 5 kap 5§: the storno must land in the same fiscal period
// as the original entry. reverseEntry() at lib/bookkeeping/engine.ts:492 uses
// original.fiscal_period_id, but assert it here so a future engine change that
+8
View File
@@ -4704,6 +4704,14 @@
"toast_reverse_done_title": "Storno created",
"toast_reverse_done_description": "Storno entry {voucher} has been posted.",
"toast_reverse_failed": "Could not reverse journal entry",
"deep_chain_title": "The correction chain is already deep",
"deep_chain_body": "This entry is already part of a correction chain {depth} levels deep. Consider booking ONE correction that expresses the net effect of the whole chain instead, so the journal is not filled with more storno and correction entries.",
"deep_chain_cancel": "Cancel",
"deep_chain_correct_anyway": "Correct anyway",
"deep_chain_reverse_anyway": "Reverse anyway",
"deep_chain_move_anyway": "Move anyway",
"deep_chain_reverse_heading": "Reverse {voucher} despite the deep chain",
"deep_chain_reverse_body": "The storno is posted as usual and the chain stays traceable, but every extra level makes the history harder to read.",
"details_title": "Journal entry details",
"field_date": "Date",
"field_posted_at": "Posted",
+8
View File
@@ -4704,6 +4704,14 @@
"toast_reverse_done_title": "Storno skapad",
"toast_reverse_done_description": "Stornoverifikation {voucher} har bokförts.",
"toast_reverse_failed": "Kunde inte återföra verifikat",
"deep_chain_title": "Rättelsekedjan är redan djup",
"deep_chain_body": "Verifikatet ingår redan i en kedja av rättelser som är {depth} nivåer djup. Överväg att istället göra EN rättelse som motsvarar hela kedjans nettoeffekt, så att journalen inte fylls med fler storno- och rättelseverifikat.",
"deep_chain_cancel": "Avbryt",
"deep_chain_correct_anyway": "Rätta ändå",
"deep_chain_reverse_anyway": "Återför ändå",
"deep_chain_move_anyway": "Flytta ändå",
"deep_chain_reverse_heading": "Återför {voucher} trots djup kedja",
"deep_chain_reverse_body": "Stornot bokförs som vanligt och kedjan förblir spårbar, men ytterligare nivåer gör historiken svårare att läsa.",
"details_title": "Verifikationsdetaljer",
"field_date": "Datum",
"field_posted_at": "Bokförd",
@@ -213,6 +213,7 @@ Per Bokföringslagen 5 kap 5 §, posted entries cannot be modified. This endpoin
- The new lines must balance. JOURNAL_ENTRY_NOT_BALANCED if not.
- The original's entry_date and fiscal_period_id are inherited. If the original's period has been locked since posting, the call returns PERIOD_LOCKED.
- Three voucher numbers are advanced in this call: the original (already burned), the reversal, and the corrected. The series stays unbroken.
- A chain 3+ corrections deep returns CORRECTION_CHAIN_TOO_DEEP (409). Compute the net effect of the whole chain and book ONE correction, or pass allow_deep_chain=true to override.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
@@ -223,7 +224,8 @@ Request body:
```ts
{
description?: string,
lines: { account_number: string, debit_amount?: number, credit_amount?: number, line_description?: string, currency?: string, amount_in_currency?: number, exchange_rate?: number, tax_code?: string, dimensions?: Record<string, string>, cost_center?: string, project?: string }[]
lines: { account_number: string, debit_amount?: number, credit_amount?: number, line_description?: string, currency?: string, amount_in_currency?: number, exchange_rate?: number, tax_code?: string, dimensions?: Record<string, string>, cost_center?: string, project?: string }[],
allow_deep_chain?: boolean
}
```
@@ -264,6 +266,7 @@ Creates a reversing journal entry that nullifies the original. The original rema
- Idempotency-Key is mandatory.
- reversal_date defaults to today; the reversal is posted in the fiscal period covering that date. If today's period is locked the call returns PERIOD_LOCKED.
- You cannot reverse a draft (status must be posted). Use /correct after commit if the original needs replacing.
- Reversing an entry 3+ links deep in a correction chain returns CORRECTION_CHAIN_TOO_DEEP (409). Book ONE net-effect correction instead, or pass allow_deep_chain=true to override.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
@@ -272,7 +275,7 @@ Creates a reversing journal entry that nullifies the original. The original rema
Request body:
```ts
{ reversal_date?: string }
{ reversal_date?: string, allow_deep_chain?: boolean }
```
Response `200`: