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,223 @@
/**
* GET /items and GET /items/:id: the booked-transaction enrichment.
*
* matched_transaction_journal_entry_id names the verifikat that booked the
* matched transaction; underlag_status (#1548) says whether THIS item's
* document reached it. The UI reads an item as booked only when both agree.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
makeInvoiceInboxItem,
} from '@/tests/helpers'
import type { ExtensionContext } from '@/lib/extensions/types'
const resolveBooked = vi.fn()
const resolveAnchoring = vi.fn()
vi.mock('@/lib/transactions/inbox-underlag', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/transactions/inbox-underlag')>()),
resolveBookedJournalEntryIds: (...a: unknown[]) => resolveBooked(...a),
resolveUnderlagAnchoring: (...a: unknown[]) => resolveAnchoring(...a),
}))
function findRoute(method: string, path: string) {
return invoiceInboxExtension.apiRoutes!.find((r) => r.method === method && r.path === path)!
}
function buildCtx(supabase: unknown): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'invoice-inbox',
supabase: supabase as ExtensionContext['supabase'],
emit: vi.fn(),
settings: { get: vi.fn(), set: vi.fn() },
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
services: {},
} as unknown as ExtensionContext
}
type EnrichedItem = {
id: string
matched_transaction_journal_entry_id: string | null
underlag_status: 'anchored' | 'unlinked' | 'unlinked_locked' | 'anchored_elsewhere' | 'unknown' | null
}
const TX1 = 'tx-1'
const TX2 = 'tx-2'
const JE1 = 'je-1'
const JE2 = 'je-2'
function anchoring(entries: Record<string, 'anchored' | 'unlinked' | 'anchored_elsewhere'>) {
return new Map(
Object.entries(entries).map(([id, status]) => [id, { status, document_journal_entry_id: null }]),
)
}
beforeEach(() => {
vi.clearAllMocks()
resolveBooked.mockResolvedValue(new Map())
resolveAnchoring.mockResolvedValue(new Map())
})
describe('GET /items', () => {
const route = findRoute('GET', '/items')
const req = () => createMockRequest('/items', { method: 'GET' })
it('returns 401 without a context', async () => {
expect((await route.handler(req())).status).toBe(401)
})
it('returns 500 when the list query fails', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'boom' } })
expect((await route.handler(req(), buildCtx(supabase))).status).toBe(500)
})
it('derives the verifikat and the per-item underlag status for matched, unstamped items', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
makeInvoiceInboxItem({ id: 'anchored', matched_transaction_id: TX1, document_id: 'doc-a' }),
makeInvoiceInboxItem({ id: 'unlinked', matched_transaction_id: TX1, document_id: 'doc-b' }),
makeInvoiceInboxItem({ id: 'elsewhere', matched_transaction_id: TX2, document_id: 'doc-c' }),
makeInvoiceInboxItem({ id: 'unbooked', matched_transaction_id: 'tx-open', document_id: 'doc-d' }),
makeInvoiceInboxItem({ id: 'stamped', matched_transaction_id: TX1, created_journal_entry_id: JE1 }),
makeInvoiceInboxItem({ id: 'unmatched', document_id: 'doc-e' }),
],
})
resolveBooked.mockResolvedValue(new Map([[TX1, JE1], [TX2, JE2]]))
resolveAnchoring.mockResolvedValue(
anchoring({ anchored: 'anchored', unlinked: 'unlinked', elsewhere: 'anchored_elsewhere' }),
)
const { status, body } = await parseJsonResponse<{ data: { items: EnrichedItem[] } }>(
await route.handler(req(), buildCtx(supabase)),
)
expect(status).toBe(200)
const byId = Object.fromEntries(body.data.items.map((i) => [i.id, i]))
expect(byId.anchored).toMatchObject({ matched_transaction_journal_entry_id: JE1, underlag_status: 'anchored' })
expect(byId.unlinked).toMatchObject({ matched_transaction_journal_entry_id: JE1, underlag_status: 'unlinked' })
expect(byId.elsewhere).toMatchObject({
matched_transaction_journal_entry_id: JE2,
underlag_status: 'anchored_elsewhere',
})
expect(byId.unbooked).toMatchObject({ matched_transaction_journal_entry_id: null, underlag_status: null })
// A stamped item is booked by its own column; the derived fields say
// nothing more about it (the verifikat id it carries here is the shared
// per-transaction resolution, not a per-item claim).
expect(byId.stamped.underlag_status).toBeNull()
expect(byId.unmatched).toMatchObject({ matched_transaction_journal_entry_id: null, underlag_status: null })
// Only the unstamped matched transactions are resolved, once, and only
// the items on booked ones go to the anchoring read.
expect(resolveBooked).toHaveBeenCalledTimes(1)
expect(resolveBooked).toHaveBeenCalledWith(expect.anything(), 'company-1', [TX1, TX2, 'tx-open'])
expect(resolveAnchoring).toHaveBeenCalledWith(expect.anything(), 'company-1', [
{ id: 'anchored', document_id: 'doc-a', journalEntryId: JE1 },
{ id: 'unlinked', document_id: 'doc-b', journalEntryId: JE1 },
{ id: 'elsewhere', document_id: 'doc-c', journalEntryId: JE2 },
])
})
it('reports unknown (never booked on a guess) when the anchoring read could not classify the item', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [makeInvoiceInboxItem({ id: 'i1', matched_transaction_id: TX1, document_id: 'doc-a' })] })
resolveBooked.mockResolvedValue(new Map([[TX1, JE1]]))
// The helper's contract: absence means the document row could not be
// read. The list must not degrade to the pre-#1548 "booked on the
// transaction's word" reading, so it is reported explicitly.
resolveAnchoring.mockResolvedValue(new Map())
const { body } = await parseJsonResponse<{ data: { items: EnrichedItem[] } }>(
await route.handler(req(), buildCtx(supabase)),
)
expect(body.data.items[0]).toMatchObject({
matched_transaction_journal_entry_id: JE1,
underlag_status: 'unknown',
})
})
})
describe('GET /items/:id', () => {
const route = findRoute('GET', '/items/:id')
const req = (id?: string) => createMockRequest(id ? `/items/${id}?_id=${id}` : '/items/x', { method: 'GET' })
it('returns 401 without a context', async () => {
expect((await route.handler(req('i1'))).status).toBe(401)
})
it('returns 400 without an id', async () => {
const { supabase } = createQueuedMockSupabase()
expect((await route.handler(req(), buildCtx(supabase))).status).toBe(400)
})
it('returns 404 when the item does not exist', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
expect((await route.handler(req('missing'), buildCtx(supabase))).status).toBe(404)
})
it('derives the verifikat and underlag status for a matched, unstamped item', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeInvoiceInboxItem({ id: 'i1', matched_transaction_id: TX1, document_id: 'doc-a' }) })
resolveBooked.mockResolvedValue(new Map([[TX1, JE1]]))
resolveAnchoring.mockResolvedValue(anchoring({ i1: 'anchored_elsewhere' }))
const { status, body } = await parseJsonResponse<{ data: EnrichedItem }>(
await route.handler(req('i1'), buildCtx(supabase)),
)
expect(status).toBe(200)
expect(body.data).toMatchObject({
id: 'i1',
matched_transaction_journal_entry_id: JE1,
underlag_status: 'anchored_elsewhere',
})
expect(resolveAnchoring).toHaveBeenCalledWith(expect.anything(), 'company-1', [
{ id: 'i1', document_id: 'doc-a', journalEntryId: JE1 },
])
})
it('reports unknown when the anchoring read could not classify the item', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeInvoiceInboxItem({ id: 'i1', matched_transaction_id: TX1, document_id: 'doc-a' }) })
resolveBooked.mockResolvedValue(new Map([[TX1, JE1]]))
resolveAnchoring.mockResolvedValue(new Map())
const { body } = await parseJsonResponse<{ data: EnrichedItem }>(
await route.handler(req('i1'), buildCtx(supabase)),
)
expect(body.data).toMatchObject({ matched_transaction_journal_entry_id: JE1, underlag_status: 'unknown' })
})
it('skips both lookups for a stamped item and reports null', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: makeInvoiceInboxItem({ id: 'i1', matched_transaction_id: TX1, created_journal_entry_id: JE1 }),
})
const { body } = await parseJsonResponse<{ data: EnrichedItem }>(
await route.handler(req('i1'), buildCtx(supabase)),
)
expect(body.data).toMatchObject({ matched_transaction_journal_entry_id: null, underlag_status: null })
expect(resolveBooked).not.toHaveBeenCalled()
expect(resolveAnchoring).not.toHaveBeenCalled()
})
it('reports null underlag status when the matched transaction is not booked', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeInvoiceInboxItem({ id: 'i1', matched_transaction_id: 'tx-open', document_id: 'doc-a' }) })
resolveBooked.mockResolvedValue(new Map())
const { body } = await parseJsonResponse<{ data: EnrichedItem }>(
await route.handler(req('i1'), buildCtx(supabase)),
)
expect(body.data).toMatchObject({ matched_transaction_journal_entry_id: null, underlag_status: null })
expect(resolveAnchoring).not.toHaveBeenCalled()
})
})
+69 -16
View File
@@ -63,7 +63,16 @@ import { bulkBookMatchedInboxItems } from '@/lib/transactions/categorize-core'
import {
completeInboxItemsForBookedTransaction,
resolveBookedJournalEntryIds,
resolveUnderlagAnchoring,
type UnderlagAnchoring,
} from '@/lib/transactions/inbox-underlag'
/**
* Per-item underlag status on the wire (#1548): the anchoring verdict, or
* 'unknown' when the document row could not be read. Absence is never
* reported as anchored; the UI keeps 'unknown' out of the booked bucket.
*/
type UnderlagStatus = UnderlagAnchoring | 'unknown'
import { hasCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
@@ -339,34 +348,61 @@ export const invoiceInboxExtension: Extension = {
// leave the active inbox (2026-08-12 report: booked items stuck in
// "Att göra" pointing at a transaction no longer in the work list).
type ItemRow = {
id: string
document_id: string | null
matched_transaction_id: string | null
created_journal_entry_id: string | null
created_supplier_invoice_id: string | null
}
const rows = (data ?? []) as ItemRow[]
const unresolved = rows.filter(
(r) =>
r.matched_transaction_id &&
!r.created_journal_entry_id &&
!r.created_supplier_invoice_id,
)
const unresolvedTxIds = Array.from(
new Set(
rows
.filter(
(r) =>
r.matched_transaction_id &&
!r.created_journal_entry_id &&
!r.created_supplier_invoice_id,
)
.map((r) => r.matched_transaction_id as string),
),
new Set(unresolved.map((r) => r.matched_transaction_id as string)),
)
const bookedByTx = await resolveBookedJournalEntryIds(
ctx.supabase,
ctx.companyId,
unresolvedTxIds,
)
const items = rows.map((r) => ({
...r,
matched_transaction_journal_entry_id: r.matched_transaction_id
// Per-item honesty (#1548): the transaction being booked says a
// verifikat exists, not that THIS item's underlag reached it. A
// document whose link failed, or that is anchored to another
// verifikat, must keep the item in "Att göra" instead of reading as
// booked on the transaction's word alone.
const anchoring = await resolveUnderlagAnchoring(
ctx.supabase,
ctx.companyId,
unresolved
.filter((r) => bookedByTx.has(r.matched_transaction_id as string))
.map((r) => ({
id: r.id,
document_id: r.document_id,
journalEntryId: bookedByTx.get(r.matched_transaction_id as string) as string,
})),
)
const items = rows.map((r) => {
const derivedEntryId = r.matched_transaction_id
? bookedByTx.get(r.matched_transaction_id) ?? null
: null,
}))
: null
// Absent from the anchoring map means the document row could not
// be read: 'unknown', which the UI treats like a divergent item
// (stays in Att göra, no booking bridge). Never booked on a guess.
// Only unstamped items were sent to the anchoring read; a stamped
// sibling is booked by its own column and gets no verdict here.
const unstamped = !r.created_journal_entry_id && !r.created_supplier_invoice_id
const underlagStatus: UnderlagStatus | null =
derivedEntryId && unstamped ? anchoring.get(r.id)?.status ?? 'unknown' : null
return {
...r,
matched_transaction_journal_entry_id: derivedEntryId,
underlag_status: underlagStatus,
}
})
return NextResponse.json({ data: { items, count: items.length } })
},
@@ -432,11 +468,14 @@ export const invoiceInboxExtension: Extension = {
// Same enrichment as the list: the detail rail must not offer to
// book a matched transaction that is already booked.
const row = data as {
id: string
document_id: string | null
matched_transaction_id: string | null
created_journal_entry_id: string | null
created_supplier_invoice_id: string | null
}
let matchedTransactionJournalEntryId: string | null = null
let underlagStatus: UnderlagStatus | null = null
if (
row.matched_transaction_id &&
!row.created_journal_entry_id &&
@@ -446,10 +485,24 @@ export const invoiceInboxExtension: Extension = {
row.matched_transaction_id,
])
matchedTransactionJournalEntryId = bookedByTx.get(row.matched_transaction_id) ?? null
if (matchedTransactionJournalEntryId) {
const anchoring = await resolveUnderlagAnchoring(ctx.supabase, ctx.companyId, [
{
id: row.id,
document_id: row.document_id,
journalEntryId: matchedTransactionJournalEntryId,
},
])
underlagStatus = anchoring.get(row.id)?.status ?? 'unknown'
}
}
return NextResponse.json({
data: { ...row, matched_transaction_journal_entry_id: matchedTransactionJournalEntryId },
data: {
...row,
matched_transaction_journal_entry_id: matchedTransactionJournalEntryId,
underlag_status: underlagStatus,
},
})
},
},