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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
ad8566f1ae
commit
a4ceaafa4f
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user