feat(inbox): per-item underlag anchoring status and a daily reconcile cron for stranded underlag (#1548) (#2012)

* feat(invoice-inbox): per-item underlag status and daily reconcile of stranded booked items (#1548)

The inbox derives "booked" from the matched transaction's verifikat, but
that says nothing about whether THIS item's document reached it: a link
that failed at propagation time, or a document anchored to another
verifikat, read as booked while the verifikat sat without its underlag
(BFL 5 kap 6-7 §). GET /items and /items/:id now also emit
underlag_status (anchored | unlinked | anchored_elsewhere) from one
batched document_attachments read; the workspace keeps divergent items
in "Att göra", drops the booking bridge for them (the book routes 409 on
a booked transaction) and shows one explanatory line with a link to the
verifikat.

The backfill script's loop moves into lib/transactions/
inbox-underlag-reconcile.ts and runs daily from a new extension-owned
cron (vercel.json plus the generated Docker crontabs): transient link
failures heal without an ad-hoc script run, permanent conflicts are
counted in one summary, and each repaired transaction leaves an
InboxUnderlagReconciled row in behandlingshistorik. That event type is
registered by migration 20260828154800: processing_history.event_type has
an FK to processing_event_types, and the script's previous
InboxUnderlagBackfilled type was never registered, so its appends had
always failed silently.

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

* fix(invoice-inbox): address review findings on the underlag reconcile (#1548)

Findings 1, 3, 6 (scan cap starves the tail): the reconcile no longer caps
the read. The matched-unconsumed candidate set holds permanent residents
(samlingsverifikat siblings, anchored-elsewhere items) that never leave
it, so a uuid-ordered read cap would revisit the same 1000 rows every
night and never reach a stranded item sorting past the cut. The scan now
pages through every candidate (four columns per row) and maxItems bounds
the WORK: at most that many unlinked (or unreadable) items are propagated
per run; already-anchored, anchored-elsewhere and locked items are counted
from the pre-state without a propagation or budget. Items past the budget
are counted as deferred and truncated is logged at warn level.

Findings 2, 5 (false "linked automatically" promise for locked periods):
resolveUnderlagAnchoring reads the fiscal period lock state of the
verifikat for every unlinked item and reports unlinked_locked when
is_closed or locked_at is set, the same pair enforce_period_lock_documents
checks. The reconciler counts it separately (unlinkedLocked), never
propagates it and never warns "still unlinked after re-run"; the rail
shows a message that says the period must be unlocked first.

Findings 4, 7 (absent anchoring read as booked): the list and detail
enrichment emit underlag_status 'unknown' when the helper could not read
the document row, and the workspace treats any status but 'anchored' as
divergent (stays in Att göra, no booking bridge, own message). classify()
counts a repair only when the pre-state was explicitly unlinked, so an
unreadable before-read never earns an InboxUnderlagReconciled event.

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

* fix(invoice-inbox): address round-2 review findings (#1548)

1. [minor] Round-1 fix dropped propagation for transactions whose inbox
   items already read anchored, so the pinned-document leg
   (transactions.document_id) was never repaired and settled items never
   received their created_journal_entry_id stamp, staying in the scan and
   inflating alreadyAnchored every night. reconcileCompany now propagates
   every stranded transaction that has an unlinked (budgeted) item or an
   anchored / document-less item, outside the maxItems budget: the helper
   is idempotent and the stamp shrinks its own population. Locked-only and
   anchored-elsewhere-only transactions stay skipped. Counting and the
   behandlingshistorik trail are unchanged (anchored items keep their
   pre-state verdict, no event). Tests updated and a new case pins the
   anchored-item plus document-less-item transaction: propagated, no
   after-read, no history. DECISIONS line amended.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-28 17:45:10 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent ad8566f1ae
commit a4ceaafa4f
18 changed files with 1727 additions and 116 deletions
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
vi.mock('@/lib/extensions/loader', () => ({
loadExtensions: vi.fn(),
}))
vi.mock('@/lib/extensions/registry', () => ({
extensionRegistry: {
get: vi.fn(),
},
}))
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: vi.fn().mockReturnValue({}),
}))
vi.mock('@/lib/transactions/inbox-underlag-reconcile', () => ({
reconcileStrandedInboxUnderlag: vi.fn(),
}))
vi.mock('@/lib/auth/cron', () => ({
verifyCronSecret: vi.fn().mockReturnValue(null),
}))
import { GET } from '../route'
import { extensionRegistry } from '@/lib/extensions/registry'
import { loadExtensions } from '@/lib/extensions/loader'
import { reconcileStrandedInboxUnderlag } from '@/lib/transactions/inbox-underlag-reconcile'
import { verifyCronSecret } from '@/lib/auth/cron'
const mockRegistryGet = vi.mocked(extensionRegistry.get)
const mockVerifyCronSecret = vi.mocked(verifyCronSecret)
const mockReconcile = vi.mocked(reconcileStrandedInboxUnderlag)
function makeRequest() {
return new Request('http://localhost/api/extensions/invoice-inbox/underlag-reconcile/cron', {
headers: { authorization: 'Bearer synthetic-cron-secret' },
})
}
const SUMMARY = {
execute: true,
scanned: 4,
truncated: false,
strandedOnBooked: 3,
repaired: 2,
alreadyAnchored: 0,
stillUnlinked: 0,
unlinkedLocked: 0,
deferred: 0,
anchoredElsewhere: 1,
companiesTouched: 1,
historyAppended: 2,
failures: 0,
}
beforeEach(() => {
vi.clearAllMocks()
mockVerifyCronSecret.mockReturnValue(null)
})
describe('GET /api/extensions/invoice-inbox/underlag-reconcile/cron', () => {
it('returns 401 when the cron secret is rejected', async () => {
mockVerifyCronSecret.mockReturnValue(
NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
)
const response = await GET(makeRequest())
expect(response.status).toBe(401)
expect(mockReconcile).not.toHaveBeenCalled()
})
it('returns 503 EXTENSION_DISABLED when the extension is not in the registry', async () => {
// Physical extension routes deploy in every build; the registry, generated
// from extensions.config.json, is what turns them on. Disabled must mean
// no reconciling AND a visible failure if the cron is scheduled anyway.
mockRegistryGet.mockReturnValue(undefined)
const response = await GET(makeRequest())
const body = await response.json()
expect(response.status).toBe(503)
expect(body.code).toBe('EXTENSION_DISABLED')
expect(mockReconcile).not.toHaveBeenCalled()
})
it('runs the reconciliation in execute mode and returns its summary when enabled', async () => {
mockRegistryGet.mockReturnValue({ id: 'invoice-inbox' } as never)
mockReconcile.mockResolvedValue(SUMMARY)
const response = await GET(makeRequest())
const body = await response.json()
expect(loadExtensions).toHaveBeenCalled()
expect(mockRegistryGet).toHaveBeenCalledWith('invoice-inbox')
expect(mockReconcile).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ execute: true, actorId: 'cron.invoice_inbox_underlag_reconcile' }),
)
expect(response.status).toBe(200)
expect(body.data).toEqual(SUMMARY)
})
})
@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server'
import { loadExtensions } from '@/lib/extensions/loader'
import { extensionRegistry } from '@/lib/extensions/registry'
import { withCronContext } from '@/lib/api/with-cron-context'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { reconcileStrandedInboxUnderlag } from '@/lib/transactions/inbox-underlag-reconcile'
/**
* GET /api/extensions/invoice-inbox/underlag-reconcile/cron: daily
* reconciliation of inbox items stranded on already-booked transactions
* (#1548). Re-runs the underlag propagation for matched items whose
* transaction is booked but whose stamp never landed, so a transient link
* failure heals without an ad-hoc script run, and counts the permanent
* conflicts (document anchored to another verifikat) so they are visible
* in one summary instead of scattered warn lines. Scheduled daily in
* vercel.json (and the generated Docker crontabs).
*
* Idempotent and safe to overlap with a slow previous run: the propagation
* skips documents that already reference the verifikat and CASes the stamp
* on its null predicate.
*/
// One bounded scan (1000 items) plus a handful of batched lookups per
// company, and a document link per stranded item. Same budget as the
// WhatsApp sweep so a large backlog on first run cannot time out midway.
export const maxDuration = 300
export const GET = withCronContext('cron.invoice_inbox_underlag_reconcile', async (_request, ctx) => {
// Load the registry so it reflects extensions.config.json.
loadExtensions()
// Physical routes under app/api/extensions/<id>/ compile into EVERY build,
// including the core-with-zero-extensions one: the registry (generated from
// extensions.config.json) is what actually switches an extension on. A
// scheduled-but-disabled cron must fail visibly (503) instead of quietly
// doing the work anyway.
if (!extensionRegistry.get('invoice-inbox')) {
ctx.log.warn('invoice-inbox extension is not enabled; cron refused')
return NextResponse.json(
{ error: 'Invoice inbox extension is not enabled', code: 'EXTENSION_DISABLED' },
{ status: 503 },
)
}
const supabase = createServiceClientNoCookies()
const summary = await reconcileStrandedInboxUnderlag(supabase, {
execute: true,
log: ctx.log,
actorId: 'cron.invoice_inbox_underlag_reconcile',
})
ctx.log.info('invoice inbox underlag reconcile complete', { ...summary })
return NextResponse.json({ data: summary })
})