diff --git a/DECISIONS.md b/DECISIONS.md index bdc092f2..4b3c9b14 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -901,6 +901,10 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index 119cb918..86d15fce 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -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(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 handleReverse()} isSubmitting={isReversing} title={t('reverse_confirm_title')} warningText={t('reverse_warning')} @@ -1257,6 +1274,26 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i + + {/* Chain-depth guard confirm: the storno was refused because this entry + already sits deep in a rättelse chain. Advisory, never a dead end. */} + { 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')} + > +
+ +
+

{t('deep_chain_reverse_heading', { voucher: formatVoucher(entry) })}

+

{t('deep_chain_reverse_body')}

+
+
+
) } diff --git a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts index b7280e27..c074b673 100644 --- a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts @@ -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 }) }, diff --git a/app/api/bookkeeping/journal-entries/[id]/recordate/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/recordate/__tests__/route.test.ts index 9313cd97..a726666a 100644 --- a/app/api/bookkeeping/journal-entries/[id]/recordate/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/[id]/recordate/__tests__/route.test.ts @@ -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 } ) }) diff --git a/app/api/bookkeeping/journal-entries/[id]/recordate/route.ts b/app/api/bookkeeping/journal-entries/[id]/recordate/route.ts index d48f60c1..a18f48d1 100644 --- a/app/api/bookkeeping/journal-entries/[id]/recordate/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/recordate/route.ts @@ -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 }, diff --git a/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts index a3fddd27..60c8680d 100644 --- a/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts @@ -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 () => { diff --git a/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts b/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts index ce3a5dca..05cc5d48 100644 --- a/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts @@ -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 }, diff --git a/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts b/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts index 5f38fd0d..90ebd767 100644 --- a/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts +++ b/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts @@ -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: "; - * 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: "; 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( { diff --git a/app/api/v1/companies/[companyId]/journal-entries/[id]/reverse/route.ts b/app/api/v1/companies/[companyId]/journal-entries/[id]/reverse/route.ts index 4222b252..ccfec185 100644 --- a/app/api/v1/companies/[companyId]/journal-entries/[id]/reverse/route.ts +++ b/app/api/v1/companies/[companyId]/journal-entries/[id]/reverse/route.ts @@ -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, diff --git a/components/agent/AgentChat.tsx b/components/agent/AgentChat.tsx index 9434dbee..48c6d129 100644 --- a/components/agent/AgentChat.tsx +++ b/components/agent/AgentChat.tsx @@ -256,6 +256,10 @@ export default function AgentChat({ } }, [streaming, onStatus]) const [errorMessage, setErrorMessage] = useState(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(null) const textareaRef = useRef(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({ ))} + {retryNotice && !errorMessage && ( +
+ Anslutningen bröts, försöker igen… +
+ )} + {errorMessage && (
{errorMessage} diff --git a/components/bookkeeping/CorrectionEntryDialog.tsx b/components/bookkeeping/CorrectionEntryDialog.tsx index 115d2053..2f204b94 100644 --- a/components/bookkeeping/CorrectionEntryDialog.tsx +++ b/components/bookkeeping/CorrectionEntryDialog.tsx @@ -58,6 +58,9 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor const [lines, setLines] = useState([]) 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(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(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: ") 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 + + {/* 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. */} + { if (!next) setDeepChainDepth(null) }}> + + + {t('deep_chain_title')} + +

+ {t('deep_chain_body', { depth: deepChainDepth ?? 3 })} +

+ + + + +
+
{/* Nested on purpose: closing this one (Esc, click-outside, Avbryt) must diff --git a/components/bookkeeping/RecordateEntryDialog.tsx b/components/bookkeeping/RecordateEntryDialog.tsx index 62925223..7f6baf77 100644 --- a/components/bookkeeping/RecordateEntryDialog.tsx +++ b/components/bookkeeping/RecordateEntryDialog.tsx @@ -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(null) const [previewLoading, setPreviewLoading] = useState(false) const [previewError, setPreviewError] = useState(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(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 - + + {/* 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. */} + { if (!next) setDeepChainDepth(null) }}> + + + {t('deep_chain_title')} + +

+ {t('deep_chain_body', { depth: deepChainDepth ?? 3 })} +

+ + + + +
+
) diff --git a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts index 1a19464c..adee27e3 100644 --- a/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts +++ b/extensions/general/mcp-server/__tests__/payload-size.bench.test.ts @@ -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) }) }) diff --git a/extensions/general/mcp-server/__tests__/voucher-tools.test.ts b/extensions/general/mcp-server/__tests__/voucher-tools.test.ts index 1cfa2d8b..0fcda8b2 100644 --- a/extensions/general/mcp-server/__tests__/voucher-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/voucher-tools.test.ts @@ -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') diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 08682e62..6b0d5dcf 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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> | 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: { diff --git a/lib/agent/chat/__tests__/run-turn-stream-retry.test.ts b/lib/agent/chat/__tests__/run-turn-stream-retry.test.ts new file mode 100644 index 00000000..eb97b4ef --- /dev/null +++ b/lib/agent/chat/__tests__/run-turn-stream-retry.test.ts @@ -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 = {} + 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[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 { + 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) +}) diff --git a/lib/agent/chat/run-turn.ts b/lib/agent/chat/run-turn.ts index b790c40e..16b9684e 100644 --- a/lib/agent/chat/run-turn.ts +++ b/lib/agent/chat/run-turn.ts @@ -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 { 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 { // 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() - 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() + 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() + 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 diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 63b7b68a..b590c81b 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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(), }) // ============================================================ diff --git a/lib/bookkeeping/__tests__/engine.test.ts b/lib/bookkeeping/__tests__/engine.test.ts index 2ee09df8..2215976e 100644 --- a/lib/bookkeeping/__tests__/engine.test.ts +++ b/lib/bookkeeping/__tests__/engine.test.ts @@ -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 }, diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index 8c7b3d5f..725f0b14 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -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 { // 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) diff --git a/lib/bookkeeping/errors.ts b/lib/bookkeeping/errors.ts index 48707a3f..28026b47 100644 --- a/lib/bookkeeping/errors.ts +++ b/lib/bookkeeping/errors.ts @@ -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( { diff --git a/lib/core/bookkeeping/__tests__/correction-chain.test.ts b/lib/core/bookkeeping/__tests__/correction-chain.test.ts new file mode 100644 index 00000000..3a425bad --- /dev/null +++ b/lib/core/bookkeeping/__tests__/correction-chain.test.ts @@ -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 = {} + 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) + }) +}) diff --git a/lib/core/bookkeeping/__tests__/storno-service.test.ts b/lib/core/bookkeeping/__tests__/storno-service.test.ts index 59b2c6e6..fb2032ad 100644 --- a/lib/core/bookkeeping/__tests__/storno-service.test.ts +++ b/lib/core/bookkeeping/__tests__/storno-service.test.ts @@ -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 diff --git a/lib/core/bookkeeping/correction-chain.ts b/lib/core/bookkeeping/correction-chain.ts new file mode 100644 index 00000000..debcdec9 --- /dev/null +++ b/lib/core/bookkeeping/correction-chain.ts @@ -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 { + const visited = new Set([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 } +} diff --git a/lib/core/bookkeeping/storno-service.ts b/lib/core/bookkeeping/storno-service.ts index 2ed56dd6..aee7a1ff 100644 --- a/lib/core/bookkeeping/storno-service.ts +++ b/lib/core/bookkeeping/storno-service.ts @@ -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, } ) diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts index e123fa59..e2cee47f 100644 --- a/lib/errors/get-error-message.ts +++ b/lib/errors/get-error-message.ts @@ -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 diff --git a/lib/errors/get-structured-error.ts b/lib/errors/get-structured-error.ts index 56aeaf5d..c64dd657 100644 --- a/lib/errors/get-structured-error.ts +++ b/lib/errors/get-structured-error.ts @@ -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 } } } diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 15ddfe9c..71640cf6 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -283,6 +283,18 @@ const BOOKKEEPING: Record = { 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: diff --git a/lib/pending-operations/__tests__/voucher-executors.test.ts b/lib/pending-operations/__tests__/voucher-executors.test.ts index 6f5ce011..a012027a 100644 --- a/lib/pending-operations/__tests__/voucher-executors.test.ts +++ b/lib/pending-operations/__tests__/voucher-executors.test.ts @@ -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 } ) }) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 771bf513..397bebad 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -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 diff --git a/messages/en.json b/messages/en.json index 6ba16463..73487616 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 9882bb8f..570abf6d 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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", diff --git a/skills/accounted-api/references/journal-entries.md b/skills/accounted-api/references/journal-entries.md index 34ab43cd..05d48ece 100644 --- a/skills/accounted-api/references/journal-entries.md +++ b/skills/accounted-api/references/journal-entries.md @@ -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, 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, 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`: