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:
@@ -1323,6 +1323,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-28] AR-PDF minus fix uses ASCII hyphen formatting, not font embedding: registering a Unicode TTF for react-pdf would change the whole document's typography and bundle size to fix one glyph; formatPdfKronor keeps built-in Helvetica and sidesteps WinAnsi's missing U+2212.
|
||||
[2026-08-28] Same-bank warning limited to observed one-session banks (SEB only): prod shows Handelsbanken tolerates 4 concurrent sessions, and the generic warning made a user abandon a legitimate renewal. Planned sync-death visibility work was dropped: already shipped via #1271 (health probe), #1727 (stale state), #1969 (cron unstarve).
|
||||
[2026-08-28] Same-bank warning revised to three tiers after skeptic refutation: hard warn SEB, silent/calm only for verified multi-session banks (Handelsbanken, 4 distinct session_ids observed), legacy hedged warning for unknown banks (fail closed), shared-session siblings exempt (fan-out carries them).
|
||||
[2026-08-28] Inbox underlag divergence (#1548) is a separate underlag_status field, not a nulled matched_transaction_journal_entry_id: the book-direct and bulk-book routes 409 on an already-booked transaction, so hiding the verifikat would make the rail re-offer a booking that always fails; the UI keeps divergent items in Att göra with an explanation and a link instead. Anchored-elsewhere conflicts are counted and logged by the daily reconcile cron, never auto-resolved (moving a document between verifikat is a human decision; never-steal is the 2026-08-13 invariant), and not escalated to processing_history from the inline booking path (no dedupe key; it would fire on every booking). The reconcile's 'InboxUnderlagReconciled' event needed a processing_event_types row (FK): the script's old 'InboxUnderlagBackfilled' type was never registered, so its appends had always failed silently.
|
||||
[2026-08-28] Inbox underlag reconcile (#1548) bounds link work, not the read: the candidate set (matched, unconsumed) holds permanent residents (samlingsverifikat siblings, anchored-elsewhere items) that never leave it, so a read cap ordered by uuid would revisit the same window nightly and starve the tail. The full scan is four columns per row; maxItems now caps unlinked items linked per run, the rest are counted as deferred; transactions whose items already read anchored are still propagated outside that budget, because only the propagation anchors the transaction's pinned document and stamps settled items out of the scan (idempotent, self-shrinking). A verifikat in a locked/closed period is its own status (unlinked_locked): the period-lock trigger rejects the link every time, so it is neither retried nor promised to the user as automatic. An unreadable document row is reported as 'unknown' on the wire and kept out of the booked bucket, matching the helper's absence-is-never-anchored contract.
|
||||
[2026-08-28] data_analysis_opt_in (#1346) defaults false with no grandfathering and gates only bookkeeping-content analysis (categorize_calibration_samples via POST /api/agent/categorize/outcome, the calibration-fit script, and the backtest script, whose wider inputs (transaction descriptions, merchant names, matched underlag re-run through the model) are named in the consent copy as "evaluation runs" after review found the outcomes-only wording false for that path; the toggle is admin/owner only to match the company_settings RLS update policy), not PostHog product analytics or MCP reliability telemetry: those carry no bookkeeping content and stay under Art. 6.1f per ropa.yaml, so widening the flag is a founder call; consequence: the calibration corpus collects nothing until a company opts in, and the flag is UI-only (absent from v1 REST / MCP settings pick lists) because consent is a human action.
|
||||
[2026-08-28] Bulk revenue rate-0 slot (#1912): item 1 (require a positive momsfri/export/EU classification) stays deferred because most real momsfri/export accounts are unconfigured today and refusing them needs a configure path first (the issue's own trade-off); resolveRevenueVatBox makes it a one-line switch later. The order-context check refuses only the export/EU-account direction (ruta 36 vs SE/EU country, ruta 40 vs SE, ruta 35/38/39 vs SE/non-EU), because the domestic-account + foreign-country direction is already advisory in the dialog (zero_rate_foreign) and hard-blocking it would regress every untemplated sweep; ruta 40 is 'omsatta utom landet', so an EU country does not contradict it. EXPORT_BOXES from moms-box-mapping was not reused because it merges EU and export boxes.
|
||||
[2026-08-28] Verifikat page promotes "Stryk rader i verifikatet" to a visible outline button only when the period-status endpoint answers open (anything else or unknown keeps it in the ⋯ menu only), and the ⋯ item stays: inline rättelse is the normal path in an open period (#1554), the promoted button must never invite an action the RPC will refuse, and the menu remains the complete action list per the 2026-08-20 detail-page grammar. Struck-line actor labels are resolved server-side via resolveUserLabelsFromProfiles (profiles RLS is self-only) rather than a new column on the log.
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -142,6 +142,11 @@ function reportUploadFailure(report: {
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────
|
||||
|
||||
// Mirrors the extension's UnderlagStatus: the anchoring verdict, or 'unknown'
|
||||
// when the server could not read the document row. Anything but 'anchored'
|
||||
// keeps the item out of the booked bucket.
|
||||
type UnderlagStatus = 'anchored' | 'unlinked' | 'unlinked_locked' | 'anchored_elsewhere' | 'unknown'
|
||||
|
||||
interface InboxItem {
|
||||
id: string
|
||||
// 'processing' is the staged-upload in-flight state: the row exists (the
|
||||
@@ -170,6 +175,16 @@ interface InboxItem {
|
||||
// samlingsverifikat only one of N items can carry the stamp; this field is
|
||||
// what lets the rest read as booked. Absent on client-side placeholders.
|
||||
matched_transaction_journal_entry_id?: string | null
|
||||
// Whether THIS item's underlag reached that verifikat (#1548). The
|
||||
// transaction being booked is a fact about the transaction, not about the
|
||||
// item's document: one whose link failed ('unlinked', transient: the daily
|
||||
// reconcile retries it), whose verifikat sits in a locked period
|
||||
// ('unlinked_locked', unlock first), that sits on another verifikat
|
||||
// ('anchored_elsewhere', a human decision), or that could not be read
|
||||
// ('unknown') keeps the item in "Att göra". null when nothing was derived
|
||||
// (no booked matched transaction, or the item is stamped). Absent on
|
||||
// client-side placeholders.
|
||||
underlag_status?: UnderlagStatus | null
|
||||
error_message: string | null
|
||||
// True when AI extraction was skipped: either because the upload caller
|
||||
// passed skip_extraction=true (MCP/agent path) or because the server's
|
||||
@@ -298,9 +313,31 @@ function countExtractedFields(data: InvoiceExtractionResult | null): number {
|
||||
// else needs a first action.
|
||||
type InboxStatus = 'needs_action' | 'processing' | 'linked' | 'booked' | 'error'
|
||||
|
||||
// A matched transaction that is booked while this item's own underlag is not
|
||||
// on its verifikat: not "booked" for the inbox, and not bookable either (the
|
||||
// book routes 409 on an already-booked transaction). The rail explains it
|
||||
// instead of offering a bridge that can only fail.
|
||||
function isUnderlagDivergent(item: InboxItem): boolean {
|
||||
return (
|
||||
!!item.matched_transaction_journal_entry_id &&
|
||||
!!item.underlag_status &&
|
||||
item.underlag_status !== 'anchored'
|
||||
)
|
||||
}
|
||||
|
||||
// One explanatory line per non-anchored status (#1548). 'unlinked' is the
|
||||
// only one the daily reconcile can heal on its own; the others say what
|
||||
// stands in the way instead of promising an automatic link.
|
||||
const UNDERLAG_STATUS_MESSAGE_KEY: Record<Exclude<UnderlagStatus, 'anchored'>, string> = {
|
||||
unlinked: 'underlag_unlinked',
|
||||
unlinked_locked: 'underlag_unlinked_locked',
|
||||
anchored_elsewhere: 'underlag_anchored_elsewhere',
|
||||
unknown: 'underlag_unknown',
|
||||
}
|
||||
|
||||
function deriveInboxStatus(item: InboxItem): InboxStatus {
|
||||
if (item.created_supplier_invoice_id || item.created_journal_entry_id) return 'booked'
|
||||
if (item.matched_transaction_journal_entry_id) return 'booked'
|
||||
if (item.matched_transaction_journal_entry_id && !isUnderlagDivergent(item)) return 'booked'
|
||||
// Staged upload mid-extraction. Outranks 'linked': a transaction-anchored
|
||||
// upload is matched from birth, but offering the booking bridge before the
|
||||
// fields exist would book from empty data. Transient (seconds): stays in
|
||||
@@ -2701,14 +2738,22 @@ function FieldsRail({
|
||||
}, [item.id])
|
||||
|
||||
const isProcessed = !!item.created_supplier_invoice_id
|
||||
const underlagDivergent = isUnderlagDivergent(item)
|
||||
// The verifikat this item resolved into: its own stamp, or the entry that
|
||||
// anchors its matched (and already booked) transaction. See InboxItem.
|
||||
// anchors its matched (and already booked) transaction, unless this item's
|
||||
// own underlag never reached it. See InboxItem.
|
||||
const bookedEntryId =
|
||||
item.created_journal_entry_id ?? item.matched_transaction_journal_entry_id ?? null
|
||||
item.created_journal_entry_id ??
|
||||
(underlagDivergent ? null : item.matched_transaction_journal_entry_id) ??
|
||||
null
|
||||
const isBookedDirectly = !isProcessed && !!bookedEntryId
|
||||
// "Resolved" now means a journal entry exists: matched_transaction_id alone
|
||||
// is not resolved, it's the prerequisite for booking against that tx.
|
||||
const isLinkedToTransaction = !isProcessed && !isBookedDirectly && !!item.matched_transaction_id
|
||||
// The booking bridge (proposal, book/ask actions) only while the
|
||||
// transaction is unbooked: a divergent item's transaction already has a
|
||||
// verifikat, so booking would 409. It gets an explanation and a link.
|
||||
const showBookingBridge = isLinkedToTransaction && !underlagDivergent
|
||||
const isResolved = isProcessed || isBookedDirectly
|
||||
// Staged upload mid-extraction: a real row whose deferred AI extraction has
|
||||
// not landed yet. Same disabled treatment as the optimistic placeholder
|
||||
@@ -2991,7 +3036,7 @@ function FieldsRail({
|
||||
scrolling past nine values to reach the one thing to approve.
|
||||
Suppressed while extraction is in flight: a proposal computed from
|
||||
empty fields would be an invitation to book nothing. */}
|
||||
{isLinkedToTransaction && !inFlight && (
|
||||
{showBookingBridge && !inFlight && (
|
||||
<ProposedBooking itemId={item.id} onLoaded={setProposal} />
|
||||
)}
|
||||
|
||||
@@ -3089,10 +3134,29 @@ function FieldsRail({
|
||||
</Link>
|
||||
) : isLinkedToTransaction && item.matched_transaction_id ? (
|
||||
<>
|
||||
{/* The transaction is booked but this item's underlag is not on
|
||||
its verifikat (#1548): say so, link the verifikat, and keep
|
||||
"Avbryt matchning" as the way out. No booking bridge: the
|
||||
book routes 409 on an already-booked transaction. */}
|
||||
{underlagDivergent && item.matched_transaction_journal_entry_id && (
|
||||
<AttnLine
|
||||
className="pb-1"
|
||||
action={{
|
||||
label: t('underlag_open_verification'),
|
||||
href: `/bookkeeping/${item.matched_transaction_journal_entry_id}`,
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
UNDERLAG_STATUS_MESSAGE_KEY[
|
||||
(item.underlag_status ?? 'unknown') as Exclude<UnderlagStatus, 'anchored'>
|
||||
],
|
||||
)}
|
||||
</AttnLine>
|
||||
)}
|
||||
{/* Matched-to-tx state: show the bridge to booking. The user
|
||||
picks one of two actions: book themselves with the
|
||||
deterministic dialog, or hand off to the assistant. */}
|
||||
{onAskAssistant && (
|
||||
{showBookingBridge && onAskAssistant && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
@@ -3107,14 +3171,16 @@ function FieldsRail({
|
||||
there is not, so there is no separate "book manually" path to
|
||||
choose between. Nothing posts from here without the form's own
|
||||
review step (convention 14). */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setEditOpen(true)}
|
||||
>
|
||||
{proposal?.lines.length ? t('review_and_book') : t('book_manually')}
|
||||
</Button>
|
||||
{showBookingBridge && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setEditOpen(true)}
|
||||
>
|
||||
{proposal?.lines.length ? t('review_and_book') : t('book_manually')}
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
* * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/whatsapp-inbox/sweep/cron
|
||||
15 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/whatsapp-inbox/retention/cron
|
||||
*/2 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/invoice-inbox/sweep/cron
|
||||
45 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/invoice-inbox/underlag-reconcile/cron
|
||||
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
|
||||
30 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/receipt-hunt/cron
|
||||
*/10 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/inbound/cron
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
* * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/whatsapp-inbox/sweep/cron
|
||||
15 4 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/whatsapp-inbox/retention/cron
|
||||
*/2 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/invoice-inbox/sweep/cron
|
||||
45 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/invoice-inbox/underlag-reconcile/cron
|
||||
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
|
||||
30 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/receipt-hunt/cron
|
||||
*/10 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/inbound/cron
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
// The reconciler orchestrates the shared underlag helpers; those are covered
|
||||
// in inbox-underlag.test.ts, so here they are mocked and the run's own
|
||||
// logic is what is under test: grouping, dry-run vs execute, classification,
|
||||
// the history trail, the scan cap, and never throwing.
|
||||
const resolveBooked = vi.fn()
|
||||
const resolveAnchoring = vi.fn()
|
||||
const propagate = vi.fn()
|
||||
vi.mock('@/lib/transactions/inbox-underlag', () => ({
|
||||
resolveBookedJournalEntryIds: (...a: unknown[]) => resolveBooked(...a),
|
||||
resolveUnderlagAnchoring: (...a: unknown[]) => resolveAnchoring(...a),
|
||||
propagateUnderlagForBookedTransaction: (...a: unknown[]) => propagate(...a),
|
||||
}))
|
||||
|
||||
const appendHistory = vi.fn()
|
||||
vi.mock('@/lib/processing-history/append', () => ({
|
||||
appendProcessingHistoryWithClient: (...a: unknown[]) => appendHistory(...a),
|
||||
}))
|
||||
|
||||
import {
|
||||
reconcileStrandedInboxUnderlag,
|
||||
INBOX_UNDERLAG_RECONCILED_EVENT,
|
||||
} from '../inbox-underlag-reconcile'
|
||||
|
||||
const C1 = 'company-1'
|
||||
const C2 = 'company-2'
|
||||
const TX1 = 'tx-1'
|
||||
const TX2 = 'tx-2'
|
||||
const TX3 = 'tx-3'
|
||||
const JE1 = 'je-1'
|
||||
const JE2 = 'je-2'
|
||||
|
||||
function item(id: string, company: string, tx: string, doc: string | null = `doc-${id}`) {
|
||||
return { id, company_id: company, matched_transaction_id: tx, document_id: doc }
|
||||
}
|
||||
|
||||
function anchoring(
|
||||
entries: Record<string, 'anchored' | 'unlinked' | 'unlinked_locked' | 'anchored_elsewhere'>,
|
||||
) {
|
||||
return new Map(
|
||||
Object.entries(entries).map(([id, status]) => [
|
||||
id,
|
||||
{ status, document_journal_entry_id: status === 'anchored_elsewhere' ? 'je-other' : null },
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: vi.fn() }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
propagate.mockResolvedValue(undefined)
|
||||
appendHistory.mockResolvedValue('event-1')
|
||||
})
|
||||
|
||||
describe('reconcileStrandedInboxUnderlag', () => {
|
||||
it('dry-run classifies without writing and counts stranded items', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1), item('i2', C1, TX2), item('i3', C1, TX3)] })
|
||||
// TX3 is not booked: its item is scanned but not stranded.
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1], [TX2, JE2]]))
|
||||
resolveAnchoring.mockResolvedValue(anchoring({ i1: 'unlinked', i2: 'anchored_elsewhere' }))
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: false,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
execute: false,
|
||||
scanned: 3,
|
||||
truncated: false,
|
||||
strandedOnBooked: 2,
|
||||
repaired: 0,
|
||||
stillUnlinked: 1,
|
||||
anchoredElsewhere: 1,
|
||||
companiesTouched: 1,
|
||||
historyAppended: 0,
|
||||
failures: 0,
|
||||
})
|
||||
expect(propagate).not.toHaveBeenCalled()
|
||||
expect(appendHistory).not.toHaveBeenCalled()
|
||||
expect(resolveAnchoring).toHaveBeenCalledTimes(1)
|
||||
expect(resolveAnchoring).toHaveBeenCalledWith(expect.anything(), C1, [
|
||||
{ id: 'i1', document_id: 'doc-i1', journalEntryId: JE1 },
|
||||
{ id: 'i2', document_id: 'doc-i2', journalEntryId: JE2 },
|
||||
])
|
||||
expect(findCalls('invoice_inbox_items', 'update')).toEqual([])
|
||||
})
|
||||
|
||||
it('execute propagates unlinked and anchored transactions but logs history only for repaired ones', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// Two items on TX1 (a samlingsverifikat) and one on TX2.
|
||||
enqueue({ data: [item('i1', C1, TX1), item('i2', C1, TX1), item('i3', C1, TX2)] })
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1], [TX2, JE2]]))
|
||||
resolveAnchoring
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'unlinked', i2: 'anchored', i3: 'anchored' })) // before
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'anchored' })) // after, only the linked item is re-read
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
actorId: 'test-actor',
|
||||
})
|
||||
|
||||
// TX2's item was already anchored, so its link is settled, but the
|
||||
// propagation still runs for the legs only it covers (the transaction's
|
||||
// pinned document, the created_journal_entry_id stamp); only TX1's
|
||||
// unlinked item is re-read afterwards.
|
||||
expect(propagate).toHaveBeenCalledTimes(2)
|
||||
expect(propagate).toHaveBeenCalledWith(expect.anything(), C1, TX1, JE1)
|
||||
expect(propagate).toHaveBeenCalledWith(expect.anything(), C1, TX2, JE2)
|
||||
expect(resolveAnchoring).toHaveBeenCalledTimes(2)
|
||||
expect(resolveAnchoring).toHaveBeenNthCalledWith(2, expect.anything(), C1, [
|
||||
{ id: 'i1', document_id: 'doc-i1', journalEntryId: JE1 },
|
||||
])
|
||||
expect(summary).toMatchObject({
|
||||
strandedOnBooked: 3,
|
||||
repaired: 1,
|
||||
alreadyAnchored: 2,
|
||||
stillUnlinked: 0,
|
||||
anchoredElsewhere: 0,
|
||||
historyAppended: 1,
|
||||
failures: 0,
|
||||
})
|
||||
// Only TX1 changed linkage (i1); TX2's item was already anchored, so no
|
||||
// changelog row pretends a repair happened there.
|
||||
expect(appendHistory).toHaveBeenCalledTimes(1)
|
||||
expect(appendHistory).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: C1,
|
||||
aggregateType: 'BankTransaction',
|
||||
aggregateId: TX1,
|
||||
correlationId: TX1,
|
||||
eventType: INBOX_UNDERLAG_RECONCILED_EVENT,
|
||||
payload: {
|
||||
transaction_id: TX1,
|
||||
journal_entry_id: JE1,
|
||||
inbox_item_ids: ['i1'],
|
||||
source: 'test-actor',
|
||||
},
|
||||
actor: { type: 'system', id: 'test-actor' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('counts a link that failed again as still unlinked and appends nothing', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1)] })
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1]]))
|
||||
resolveAnchoring
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'unlinked' }))
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'unlinked' }))
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({ repaired: 0, stillUnlinked: 1, historyAppended: 0 })
|
||||
expect(appendHistory).not.toHaveBeenCalled()
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('still unlinked'),
|
||||
expect.objectContaining({ inbox_item_id: 'i1', transaction_id: TX1 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('counts a document anchored to another verifikat as a conflict, greppable by its verifikat', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1)] })
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1]]))
|
||||
// Settled from the pre-state alone: no propagation, no after-read.
|
||||
resolveAnchoring.mockResolvedValue(anchoring({ i1: 'anchored_elsewhere' }))
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({ repaired: 0, anchoredElsewhere: 1, historyAppended: 0 })
|
||||
expect(propagate).not.toHaveBeenCalled()
|
||||
expect(resolveAnchoring).toHaveBeenCalledTimes(1)
|
||||
expect(appendHistory).not.toHaveBeenCalled()
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('another verifikat'),
|
||||
expect.objectContaining({ inbox_item_id: 'i1', document_journal_entry_id: 'je-other' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('treats an item the anchoring read could not classify as unlinked, never repaired', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1)] })
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1]]))
|
||||
resolveAnchoring.mockResolvedValue(new Map())
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({ repaired: 0, stillUnlinked: 1, historyAppended: 0 })
|
||||
})
|
||||
|
||||
it('groups per company and skips companies with no booked matches', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1), item('i2', C2, TX2)] })
|
||||
resolveBooked.mockImplementation(async (_s: unknown, companyId: string) =>
|
||||
companyId === C1 ? new Map([[TX1, JE1]]) : new Map(),
|
||||
)
|
||||
resolveAnchoring
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'unlinked' }))
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'anchored' }))
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(resolveBooked).toHaveBeenCalledWith(expect.anything(), C1, [TX1])
|
||||
expect(resolveBooked).toHaveBeenCalledWith(expect.anything(), C2, [TX2])
|
||||
expect(summary).toMatchObject({ scanned: 2, strandedOnBooked: 1, companiesTouched: 1, repaired: 1 })
|
||||
expect(propagate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reads every candidate and spends maxItems on unlinked items only, deferring the rest', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
// Six candidates: two already anchored, one anchored elsewhere, three
|
||||
// unlinked. A read cap would have stopped at the first rows; the link
|
||||
// budget (2) must reach past the settled ones and defer only the third
|
||||
// unlinked item.
|
||||
enqueue({
|
||||
data: [
|
||||
item('a1', C1, TX1),
|
||||
item('a2', C1, TX1),
|
||||
item('e1', C1, TX2),
|
||||
item('u1', C1, TX3),
|
||||
item('u2', C2, 'tx-4'),
|
||||
item('u3', C2, 'tx-5'),
|
||||
],
|
||||
})
|
||||
resolveBooked.mockImplementation(async (_s: unknown, companyId: string) =>
|
||||
companyId === C1
|
||||
? new Map([[TX1, JE1], [TX2, JE2], [TX3, 'je-3']])
|
||||
: new Map([['tx-4', 'je-4'], ['tx-5', 'je-5']]),
|
||||
)
|
||||
resolveAnchoring.mockImplementation(async (_s: unknown, companyId: string) =>
|
||||
companyId === C1
|
||||
? anchoring({ a1: 'anchored', a2: 'anchored', e1: 'anchored_elsewhere', u1: 'unlinked' })
|
||||
: anchoring({ u2: 'unlinked', u3: 'unlinked' }),
|
||||
)
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
maxItems: 2,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(findCalls('invoice_inbox_items', 'range')).toEqual([[0, 999]])
|
||||
expect(summary).toMatchObject({
|
||||
scanned: 6,
|
||||
strandedOnBooked: 6,
|
||||
alreadyAnchored: 2,
|
||||
anchoredElsewhere: 1,
|
||||
deferred: 1,
|
||||
truncated: true,
|
||||
})
|
||||
// u1 and u2 got the budget; u3 waits for the next run, without a
|
||||
// misleading "still unlinked after re-run" line. TX1 (anchored items)
|
||||
// is propagated outside the budget for its pin and stamp legs; the
|
||||
// anchored-elsewhere conflict on TX2 is not.
|
||||
expect(propagate).toHaveBeenCalledTimes(3)
|
||||
expect(propagate).toHaveBeenCalledWith(expect.anything(), C1, TX1, JE1)
|
||||
expect(propagate).toHaveBeenCalledWith(expect.anything(), C1, TX3, 'je-3')
|
||||
expect(propagate).toHaveBeenCalledWith(expect.anything(), C2, 'tx-4', 'je-4')
|
||||
expect(propagate).not.toHaveBeenCalledWith(expect.anything(), C1, TX2, JE2)
|
||||
expect(propagate).not.toHaveBeenCalledWith(expect.anything(), C2, 'tx-5', 'je-5')
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('link budget spent'),
|
||||
expect.objectContaining({ max_items: 2, deferred: 1 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('pages through more than one page of candidates', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
const firstPage = Array.from({ length: 1000 }, (_, i) => item(`p${i}`, C1, TX1))
|
||||
enqueue({ data: firstPage })
|
||||
enqueue({ data: [item('tail', C1, TX1)] })
|
||||
resolveBooked.mockResolvedValue(new Map())
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: false,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary.scanned).toBe(1001)
|
||||
expect(summary.truncated).toBe(false)
|
||||
expect(findCalls('invoice_inbox_items', 'range')).toEqual([[0, 999], [1000, 1999]])
|
||||
})
|
||||
|
||||
it('still propagates a transaction whose only item is anchored so an unlinked pin is repaired', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// i1's own document is anchored; i2 carries no document at all. Both
|
||||
// read 'anchored', but the transaction may still have a pinned document
|
||||
// (transactions.document_id) whose link failed earlier, and the items
|
||||
// still lack their stamp: only the propagation repairs those.
|
||||
enqueue({ data: [item('i1', C1, TX1), item('i2', C1, TX2, null)] })
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1], [TX2, JE2]]))
|
||||
resolveAnchoring.mockResolvedValue(anchoring({ i1: 'anchored', i2: 'anchored' }))
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
maxItems: 0,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
// Outside the link budget (maxItems 0 still lets this run), no after-read
|
||||
// (the pre-state is the verdict) and no history: nothing this run can
|
||||
// vouch for changed the item's linkage.
|
||||
expect(propagate).toHaveBeenCalledTimes(2)
|
||||
expect(propagate).toHaveBeenCalledWith(expect.anything(), C1, TX1, JE1)
|
||||
expect(propagate).toHaveBeenCalledWith(expect.anything(), C1, TX2, JE2)
|
||||
expect(resolveAnchoring).toHaveBeenCalledTimes(1)
|
||||
expect(summary).toMatchObject({
|
||||
strandedOnBooked: 2,
|
||||
repaired: 0,
|
||||
alreadyAnchored: 2,
|
||||
deferred: 0,
|
||||
truncated: false,
|
||||
historyAppended: 0,
|
||||
})
|
||||
expect(appendHistory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('counts a locked-period item separately, never propagates it, and appends nothing', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1)] })
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1]]))
|
||||
resolveAnchoring.mockResolvedValue(anchoring({ i1: 'unlinked_locked' }))
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
strandedOnBooked: 1,
|
||||
repaired: 0,
|
||||
stillUnlinked: 0,
|
||||
unlinkedLocked: 1,
|
||||
deferred: 0,
|
||||
truncated: false,
|
||||
historyAppended: 0,
|
||||
})
|
||||
// The link is known to fail (enforce_period_lock_documents): no
|
||||
// propagation, no budget spent, no daily "still unlinked" warning.
|
||||
expect(propagate).not.toHaveBeenCalled()
|
||||
expect(appendHistory).not.toHaveBeenCalled()
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('locked period'),
|
||||
expect.objectContaining({ inbox_item_id: 'i1' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not count an unreadable pre-state that reads anchored afterwards as repaired', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1)] })
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1]]))
|
||||
resolveAnchoring
|
||||
.mockResolvedValueOnce(new Map()) // before: document row unreadable
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'anchored' })) // after
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
// Unknown before is a reason to look (propagate), not evidence that this
|
||||
// run changed the linkage: no InboxUnderlagReconciled event.
|
||||
expect(propagate).toHaveBeenCalledTimes(1)
|
||||
expect(summary).toMatchObject({ repaired: 0, alreadyAnchored: 1, historyAppended: 0 })
|
||||
expect(appendHistory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a zero summary with one failure when the scan errors, without throwing', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: { message: 'connection reset' } })
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({ scanned: 0, strandedOnBooked: 0, repaired: 0, failures: 1 })
|
||||
expect(propagate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('counts a failing company and continues with the next one', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1), item('i2', C2, TX2)] })
|
||||
resolveBooked.mockImplementation(async (_s: unknown, companyId: string) => {
|
||||
if (companyId === C1) throw new Error('resolver blew up')
|
||||
return new Map([[TX2, JE2]])
|
||||
})
|
||||
resolveAnchoring
|
||||
.mockResolvedValueOnce(anchoring({ i2: 'unlinked' }))
|
||||
.mockResolvedValueOnce(anchoring({ i2: 'anchored' }))
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({ failures: 1, companiesTouched: 1, repaired: 1 })
|
||||
expect(propagate).toHaveBeenCalledWith(expect.anything(), C2, TX2, JE2)
|
||||
})
|
||||
|
||||
it('keeps the repair when the history append fails', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [item('i1', C1, TX1)] })
|
||||
resolveBooked.mockResolvedValue(new Map([[TX1, JE1]]))
|
||||
resolveAnchoring
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'unlinked' }))
|
||||
.mockResolvedValueOnce(anchoring({ i1: 'anchored' }))
|
||||
appendHistory.mockRejectedValue(new Error('fk violation'))
|
||||
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase as unknown as SupabaseClient, {
|
||||
execute: true,
|
||||
log: log as never,
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({ repaired: 1, historyAppended: 0, failures: 0 })
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('processing_history append failed'),
|
||||
expect.objectContaining({ transaction_id: TX1 }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
completeInboxItemsForBookedTransaction,
|
||||
propagateUnderlagForBookedTransaction,
|
||||
resolveBookedJournalEntryIds,
|
||||
resolveUnderlagAnchoring,
|
||||
} from '../inbox-underlag'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
|
||||
@@ -356,3 +357,106 @@ describe('completeInboxItemsForBookedTransaction', () => {
|
||||
expect(findCalls('invoice_inbox_items', 'update')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveUnderlagAnchoring', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('reads an item without a document as anchored, without querying', async () => {
|
||||
// No underlag to carry onto the verifikat: only the stamp is missing.
|
||||
const { supabase, calls } = createQueuedMockSupabase()
|
||||
const map = await resolveUnderlagAnchoring(supabase as unknown as SupabaseClient, COMPANY, [
|
||||
{ id: 'i1', document_id: null, journalEntryId: JE1 },
|
||||
])
|
||||
expect(map.get('i1')).toEqual({ status: 'anchored', document_journal_entry_id: null })
|
||||
expect(calls.length).toBe(0)
|
||||
})
|
||||
|
||||
it('classifies anchored, unlinked and anchored_elsewhere from one batched select', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'doc-a', journal_entry_id: JE1 },
|
||||
{ id: 'doc-b', journal_entry_id: null },
|
||||
{ id: 'doc-c', journal_entry_id: JE2 },
|
||||
],
|
||||
})
|
||||
|
||||
const map = await resolveUnderlagAnchoring(supabase as unknown as SupabaseClient, COMPANY, [
|
||||
{ id: 'i-a', document_id: 'doc-a', journalEntryId: JE1 },
|
||||
{ id: 'i-b', document_id: 'doc-b', journalEntryId: JE1 },
|
||||
{ id: 'i-c', document_id: 'doc-c', journalEntryId: JE1 },
|
||||
{ id: 'i-none', document_id: null, journalEntryId: JE1 },
|
||||
])
|
||||
|
||||
expect(map.get('i-a')?.status).toBe('anchored')
|
||||
expect(map.get('i-b')?.status).toBe('unlinked')
|
||||
expect(map.get('i-c')).toEqual({ status: 'anchored_elsewhere', document_journal_entry_id: JE2 })
|
||||
expect(map.get('i-none')?.status).toBe('anchored')
|
||||
const selects = findCalls('document_attachments', 'select')
|
||||
expect(selects.length).toBe(1)
|
||||
expect(findCalls('document_attachments', 'in')).toEqual([['id', ['doc-a', 'doc-b', 'doc-c']]])
|
||||
})
|
||||
|
||||
it('reads an unlinked item whose verifikat sits in a locked or closed period as unlinked_locked', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'doc-a', journal_entry_id: null },
|
||||
{ id: 'doc-b', journal_entry_id: null },
|
||||
{ id: 'doc-c', journal_entry_id: null },
|
||||
{ id: 'doc-d', journal_entry_id: JE1 },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: JE1, fiscal_period: { is_closed: false, locked_at: '2026-08-01T00:00:00Z' } },
|
||||
{ id: JE2, fiscal_period: { is_closed: true, locked_at: null } },
|
||||
{ id: 'je-open', fiscal_period: { is_closed: false, locked_at: null } },
|
||||
],
|
||||
})
|
||||
|
||||
const map = await resolveUnderlagAnchoring(supabase as unknown as SupabaseClient, COMPANY, [
|
||||
{ id: 'i-a', document_id: 'doc-a', journalEntryId: JE1 },
|
||||
{ id: 'i-b', document_id: 'doc-b', journalEntryId: JE2 },
|
||||
{ id: 'i-c', document_id: 'doc-c', journalEntryId: 'je-open' },
|
||||
{ id: 'i-d', document_id: 'doc-d', journalEntryId: JE1 },
|
||||
])
|
||||
|
||||
expect(map.get('i-a')).toEqual({ status: 'unlinked_locked', document_journal_entry_id: null })
|
||||
expect(map.get('i-b')).toEqual({ status: 'unlinked_locked', document_journal_entry_id: null })
|
||||
expect(map.get('i-c')?.status).toBe('unlinked')
|
||||
// Already anchored: the lock does not matter and it is not looked up.
|
||||
expect(map.get('i-d')?.status).toBe('anchored')
|
||||
expect(findCalls('journal_entries', 'in')).toEqual([['id', [JE1, JE2, 'je-open']]])
|
||||
})
|
||||
|
||||
it('keeps an unlinked item unlinked (retryable) when the lock-state read fails', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'doc-a', journal_entry_id: null }] })
|
||||
enqueue({ data: null, error: { message: 'boom' } })
|
||||
const map = await resolveUnderlagAnchoring(supabase as unknown as SupabaseClient, COMPANY, [
|
||||
{ id: 'i-a', document_id: 'doc-a', journalEntryId: JE1 },
|
||||
])
|
||||
expect(map.get('i-a')?.status).toBe('unlinked')
|
||||
})
|
||||
|
||||
it('leaves items absent (unknown, not anchored) when the document read fails', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null, error: { message: 'boom' } })
|
||||
const map = await resolveUnderlagAnchoring(supabase as unknown as SupabaseClient, COMPANY, [
|
||||
{ id: 'i-a', document_id: 'doc-a', journalEntryId: JE1 },
|
||||
{ id: 'i-none', document_id: null, journalEntryId: JE1 },
|
||||
])
|
||||
expect(map.has('i-a')).toBe(false)
|
||||
expect(map.get('i-none')?.status).toBe('anchored')
|
||||
})
|
||||
|
||||
it('leaves an item absent when its document row is not returned', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [] })
|
||||
const map = await resolveUnderlagAnchoring(supabase as unknown as SupabaseClient, COMPANY, [
|
||||
{ id: 'i-a', document_id: 'doc-a', journalEntryId: JE1 },
|
||||
])
|
||||
expect(map.has('i-a')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Periodic reconciliation of inbox items stranded on booked transactions
|
||||
* (#1548, the follow-up to the 2026-08-12 "booked items stuck in Att göra"
|
||||
* fix).
|
||||
*
|
||||
* An item whose matched transaction is booked should have its document
|
||||
* anchored to that verifikat (BFL 5 kap 6-7 §) and, where the UNIQUE
|
||||
* created_journal_entry_id allows, carry the stamp. Two things leave an
|
||||
* item behind that only a re-run repairs or a human resolves:
|
||||
*
|
||||
* - transient: linkToJournalEntry failed at propagation time (a DB blip).
|
||||
* Re-running the same propagation links it.
|
||||
* - locked: the verifikat sits in a closed or locked period, so the link is
|
||||
* rejected by enforce_period_lock_documents every time. Counted, not
|
||||
* retried: only unlocking the period (or a human) resolves it.
|
||||
* - permanent: the item's document is already anchored to a DIFFERENT
|
||||
* verifikat. Never stolen; the run counts and logs it so it is visible
|
||||
* outside ad-hoc log greps, and the inbox keeps the item in "Att göra"
|
||||
* (underlag_status enrichment) until someone decides.
|
||||
*
|
||||
* The scan reads every matched, unconsumed item (cheap: four columns per
|
||||
* row) and bounds the WORK instead: at most `maxItems` unlinked items are
|
||||
* linked per run. Capping the read would starve the tail: healthy matched
|
||||
* items and samlingsverifikat siblings never leave the candidate set, so a
|
||||
* read cap keyed on id would revisit the same window every night.
|
||||
*
|
||||
* Transactions whose items already read anchored are still propagated,
|
||||
* outside the budget: the propagation also anchors the transaction's own
|
||||
* pinned document (transactions.document_id) and stamps
|
||||
* created_journal_entry_id on settled items so they leave the scan. That
|
||||
* leg is idempotent and shrinks its own population, so it needs no cap.
|
||||
*
|
||||
* This is the one implementation the daily cron
|
||||
* (app/api/extensions/invoice-inbox/underlag-reconcile/cron) and the manual
|
||||
* script (scripts/backfill-inbox-booked-underlag.ts) share. It never throws:
|
||||
* a failing company is counted and the rest of the run continues.
|
||||
*
|
||||
* Behandlingshistorik (BFNAR 2013:2 kap 8): a run that changed the
|
||||
* underlag-to-verifikat linkage appends one 'InboxUnderlagReconciled' event
|
||||
* per repaired transaction, distinguishing the repair from the original
|
||||
* booking. Only genuinely repaired transactions get an event: stamping an
|
||||
* already-anchored item is a display fast path, not a linkage change.
|
||||
*/
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { appendProcessingHistoryWithClient } from '@/lib/processing-history/append'
|
||||
import { createLogger, type Logger } from '@/lib/logger'
|
||||
import {
|
||||
propagateUnderlagForBookedTransaction,
|
||||
resolveBookedJournalEntryIds,
|
||||
resolveUnderlagAnchoring,
|
||||
type UnderlagAnchoringResult,
|
||||
} from '@/lib/transactions/inbox-underlag'
|
||||
|
||||
/** Registered in processing_event_types by migration 20260828154800. */
|
||||
export const INBOX_UNDERLAG_RECONCILED_EVENT = 'InboxUnderlagReconciled'
|
||||
|
||||
/** Default actor id in behandlingshistorik when the caller names none. */
|
||||
export const DEFAULT_RECONCILE_ACTOR_ID = 'inbox-underlag-reconcile'
|
||||
|
||||
/** Link budget per run (unlinked items linked) so a scheduled pass stays bounded. */
|
||||
export const DEFAULT_RECONCILE_MAX_ITEMS = 1000
|
||||
|
||||
const PAGE_SIZE = 1000
|
||||
|
||||
export interface ReconcileStrandedInboxUnderlagOptions {
|
||||
/** false: classify only, no writes (the script's dry-run). */
|
||||
execute: boolean
|
||||
/** Link at most this many unlinked items per run (default 1000); the rest wait for the next run. */
|
||||
maxItems?: number
|
||||
log?: Logger
|
||||
/** Who the behandlingshistorik event is attributed to. */
|
||||
actorId?: string
|
||||
}
|
||||
|
||||
export interface ReconcileStrandedInboxUnderlagSummary {
|
||||
execute: boolean
|
||||
/** Matched, unconsumed items read (across all companies, uncapped). */
|
||||
scanned: number
|
||||
/** True when more unlinked items existed than maxItems allowed this run to link. */
|
||||
truncated: boolean
|
||||
/** Scanned items whose matched transaction resolves as booked. */
|
||||
strandedOnBooked: number
|
||||
/** execute only: items whose underlag was unlinked before and anchored after this run. */
|
||||
repaired: number
|
||||
/** Items whose underlag already referenced the verifikat (only the stamp was missing). */
|
||||
alreadyAnchored: number
|
||||
/**
|
||||
* Items whose underlag references no verifikat. execute: the link failed
|
||||
* again this run (transient, retried next run). dry-run: what a run would
|
||||
* link.
|
||||
*/
|
||||
stillUnlinked: number
|
||||
/** Unlinked items whose verifikat sits in a locked/closed period: the link cannot land until it is unlocked. */
|
||||
unlinkedLocked: number
|
||||
/** Unlinked items left for the next run because this run's link budget (maxItems) was spent. */
|
||||
deferred: number
|
||||
/** Items whose document is anchored to another verifikat: a human decision. */
|
||||
anchoredElsewhere: number
|
||||
companiesTouched: number
|
||||
/** 'InboxUnderlagReconciled' events written (one per repaired transaction). */
|
||||
historyAppended: number
|
||||
/** Companies (or the initial scan) that threw; the run continued past them. */
|
||||
failures: number
|
||||
}
|
||||
|
||||
interface StrandedItem {
|
||||
id: string
|
||||
company_id: string
|
||||
matched_transaction_id: string
|
||||
document_id: string | null
|
||||
}
|
||||
|
||||
function emptySummary(execute: boolean): ReconcileStrandedInboxUnderlagSummary {
|
||||
return {
|
||||
execute,
|
||||
scanned: 0,
|
||||
truncated: false,
|
||||
strandedOnBooked: 0,
|
||||
repaired: 0,
|
||||
alreadyAnchored: 0,
|
||||
stillUnlinked: 0,
|
||||
unlinkedLocked: 0,
|
||||
deferred: 0,
|
||||
anchoredElsewhere: 0,
|
||||
companiesTouched: 0,
|
||||
historyAppended: 0,
|
||||
failures: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The backfill script's query: matched to a transaction, consumed by neither
|
||||
* a journal entry nor a supplier invoice. Ordered by id for stable paging
|
||||
* and read in full: the per-run bound is on links, not on rows read.
|
||||
*/
|
||||
async function fetchStrandedCandidates(supabase: SupabaseClient): Promise<StrandedItem[]> {
|
||||
const rows: StrandedItem[] = []
|
||||
let from = 0
|
||||
for (;;) {
|
||||
const to = from + PAGE_SIZE - 1
|
||||
const { data, error } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, company_id, matched_transaction_id, document_id')
|
||||
.not('matched_transaction_id', 'is', null)
|
||||
.is('created_journal_entry_id', null)
|
||||
.is('created_supplier_invoice_id', null)
|
||||
.order('id', { ascending: true })
|
||||
.range(from, to)
|
||||
if (error) throw new Error(error.message)
|
||||
const page = (data ?? []) as StrandedItem[]
|
||||
rows.push(...page)
|
||||
if (page.length < PAGE_SIZE) break
|
||||
from = to + 1
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export async function reconcileStrandedInboxUnderlag(
|
||||
supabase: SupabaseClient,
|
||||
opts: ReconcileStrandedInboxUnderlagOptions,
|
||||
): Promise<ReconcileStrandedInboxUnderlagSummary> {
|
||||
const log = opts.log ?? createLogger('transactions/inbox-underlag-reconcile')
|
||||
const maxItems = opts.maxItems ?? DEFAULT_RECONCILE_MAX_ITEMS
|
||||
const actorId = opts.actorId ?? DEFAULT_RECONCILE_ACTOR_ID
|
||||
const summary = emptySummary(opts.execute)
|
||||
|
||||
let candidates: StrandedItem[]
|
||||
try {
|
||||
candidates = await fetchStrandedCandidates(supabase)
|
||||
} catch (err) {
|
||||
log.error('inbox underlag reconcile: failed to read matched inbox items', {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
summary.failures++
|
||||
return summary
|
||||
}
|
||||
summary.scanned = candidates.length
|
||||
|
||||
// Group per company so the resolvers run one batched lookup per tenant.
|
||||
const byCompany = new Map<string, StrandedItem[]>()
|
||||
for (const item of candidates) {
|
||||
const list = byCompany.get(item.company_id) ?? []
|
||||
list.push(item)
|
||||
byCompany.set(item.company_id, list)
|
||||
}
|
||||
|
||||
const run = { execute: opts.execute, actorId, log, summary, budget: maxItems }
|
||||
for (const [companyId, companyItems] of byCompany) {
|
||||
try {
|
||||
await reconcileCompany(supabase, companyId, companyItems, run)
|
||||
} catch (err) {
|
||||
summary.failures++
|
||||
log.error('inbox underlag reconcile: company failed', {
|
||||
company_id: companyId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.truncated) {
|
||||
log.warn('inbox underlag reconcile: link budget spent; unlinked items deferred to the next run', {
|
||||
max_items: maxItems,
|
||||
deferred: summary.deferred,
|
||||
})
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
/** Per-run state shared by every company: the counters and the remaining link budget. */
|
||||
interface RunState {
|
||||
execute: boolean
|
||||
actorId: string
|
||||
log: Logger
|
||||
summary: ReconcileStrandedInboxUnderlagSummary
|
||||
/** Unlinked items this run may still propagate; decremented as work is claimed. */
|
||||
budget: number
|
||||
}
|
||||
|
||||
/** Whether an item needs (and may benefit from) a propagation: unlinked, or unreadable. */
|
||||
function needsLink(before: UnderlagAnchoringResult | undefined): boolean {
|
||||
return before === undefined || before.status === 'unlinked'
|
||||
}
|
||||
|
||||
async function reconcileCompany(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
companyItems: StrandedItem[],
|
||||
run: RunState,
|
||||
): Promise<void> {
|
||||
const { summary, log } = run
|
||||
const txIds = Array.from(new Set(companyItems.map((i) => i.matched_transaction_id)))
|
||||
const bookedByTx = await resolveBookedJournalEntryIds(supabase, companyId, txIds)
|
||||
const stranded = companyItems.filter((i) => bookedByTx.has(i.matched_transaction_id))
|
||||
if (stranded.length === 0) return
|
||||
|
||||
summary.companiesTouched++
|
||||
summary.strandedOnBooked += stranded.length
|
||||
|
||||
const anchoringInput = stranded.map((i) => ({
|
||||
id: i.id,
|
||||
document_id: i.document_id,
|
||||
journalEntryId: bookedByTx.get(i.matched_transaction_id) as string,
|
||||
}))
|
||||
const before = await resolveUnderlagAnchoring(supabase, companyId, anchoringInput)
|
||||
|
||||
// Only unlinked (or unreadable) items claim budget. Already-anchored
|
||||
// siblings, anchored-elsewhere conflicts and locked periods are counted
|
||||
// straight from the pre-state: re-linking them would either no-op or
|
||||
// fail identically, and letting them consume the budget is what would
|
||||
// starve the real work.
|
||||
const toLink = new Set<string>()
|
||||
for (const item of stranded) {
|
||||
if (!needsLink(before.get(item.id))) continue
|
||||
if (run.budget <= 0) {
|
||||
summary.deferred++
|
||||
summary.truncated = true
|
||||
continue
|
||||
}
|
||||
run.budget--
|
||||
toLink.add(item.id)
|
||||
}
|
||||
|
||||
if (!run.execute) {
|
||||
for (const item of stranded) {
|
||||
if (summaryDeferred(item, before, toLink)) continue
|
||||
classify(item, before.get(item.id), null, run, companyId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Propagate every transaction with link work, plus every transaction
|
||||
// whose items already read anchored (or carry no document). The helper is
|
||||
// idempotent and does two more things than the item link that only it
|
||||
// can do: anchor the transaction's own pinned document
|
||||
// (transactions.document_id, which no inbox item carries) and stamp
|
||||
// created_journal_entry_id on settled items so they leave this scan.
|
||||
// Neither claims budget: the stamp is what shrinks the candidate set, so
|
||||
// this leg is self-limiting. Transactions whose items are all locked or
|
||||
// anchored elsewhere are still skipped: the link would fail identically,
|
||||
// and the conflict is a human decision.
|
||||
const txIdsToComplete = new Set<string>()
|
||||
for (const item of stranded) {
|
||||
if (toLink.has(item.id) || before.get(item.id)?.status === 'anchored') {
|
||||
txIdsToComplete.add(item.matched_transaction_id)
|
||||
}
|
||||
}
|
||||
for (const txId of txIdsToComplete) {
|
||||
const journalEntryId = bookedByTx.get(txId)
|
||||
if (!journalEntryId) continue
|
||||
await propagateUnderlagForBookedTransaction(supabase, companyId, txId, journalEntryId)
|
||||
}
|
||||
|
||||
const after =
|
||||
toLink.size > 0
|
||||
? await resolveUnderlagAnchoring(
|
||||
supabase,
|
||||
companyId,
|
||||
anchoringInput.filter((i) => toLink.has(i.id)),
|
||||
)
|
||||
: new Map<string, UnderlagAnchoringResult>()
|
||||
const repairedByTx = new Map<string, string[]>()
|
||||
for (const item of stranded) {
|
||||
if (summaryDeferred(item, before, toLink)) continue
|
||||
// Items outside this run's link work keep their pre-state verdict.
|
||||
const verdict = toLink.has(item.id) ? after.get(item.id) : before.get(item.id)
|
||||
const repaired = classify(item, before.get(item.id), verdict, run, companyId)
|
||||
if (repaired) {
|
||||
const list = repairedByTx.get(item.matched_transaction_id) ?? []
|
||||
list.push(item.id)
|
||||
repairedByTx.set(item.matched_transaction_id, list)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [txId, itemIds] of repairedByTx) {
|
||||
const journalEntryId = bookedByTx.get(txId) as string
|
||||
try {
|
||||
await appendProcessingHistoryWithClient(supabase, {
|
||||
companyId,
|
||||
correlationId: txId,
|
||||
aggregateType: 'BankTransaction',
|
||||
aggregateId: txId,
|
||||
eventType: INBOX_UNDERLAG_RECONCILED_EVENT,
|
||||
payload: {
|
||||
transaction_id: txId,
|
||||
journal_entry_id: journalEntryId,
|
||||
inbox_item_ids: itemIds,
|
||||
source: run.actorId,
|
||||
},
|
||||
actor: { type: 'system', id: run.actorId },
|
||||
occurredAt: new Date(),
|
||||
})
|
||||
summary.historyAppended++
|
||||
} catch (err) {
|
||||
// The repair itself is done; a missing changelog row is a logged gap,
|
||||
// not a reason to fail the run.
|
||||
log.error('inbox underlag reconcile: processing_history append failed', {
|
||||
company_id: companyId,
|
||||
transaction_id: txId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True for an item that needed a link but fell outside this run's budget (already counted as deferred). */
|
||||
function summaryDeferred(
|
||||
item: StrandedItem,
|
||||
before: Map<string, UnderlagAnchoringResult>,
|
||||
toLink: Set<string>,
|
||||
): boolean {
|
||||
return needsLink(before.get(item.id)) && !toLink.has(item.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Count one item into the summary. `after` is null on a dry-run (the
|
||||
* pre-state is the verdict). Returns true when this run linked the
|
||||
* underlag: explicitly unlinked before, anchored after. An unreadable
|
||||
* pre-state that reads anchored afterwards is not a repair this run can
|
||||
* vouch for, so it earns no behandlingshistorik event.
|
||||
*/
|
||||
function classify(
|
||||
item: StrandedItem,
|
||||
before: UnderlagAnchoringResult | undefined,
|
||||
after: UnderlagAnchoringResult | null | undefined,
|
||||
run: { execute: boolean; log: Logger; summary: ReconcileStrandedInboxUnderlagSummary },
|
||||
companyId: string,
|
||||
): boolean {
|
||||
const { summary, log } = run
|
||||
// An unreadable document row is unknown, never settled: it stays counted
|
||||
// as unlinked so the next run looks again.
|
||||
const verdict = (run.execute ? after : before) ?? {
|
||||
status: 'unlinked' as const,
|
||||
document_journal_entry_id: null,
|
||||
}
|
||||
const context = {
|
||||
company_id: companyId,
|
||||
inbox_item_id: item.id,
|
||||
transaction_id: item.matched_transaction_id,
|
||||
document_id: item.document_id,
|
||||
}
|
||||
switch (verdict.status) {
|
||||
case 'anchored': {
|
||||
if (
|
||||
run.execute &&
|
||||
(before?.status === 'unlinked' || before?.status === 'unlinked_locked')
|
||||
) {
|
||||
summary.repaired++
|
||||
return true
|
||||
}
|
||||
summary.alreadyAnchored++
|
||||
return false
|
||||
}
|
||||
case 'unlinked': {
|
||||
summary.stillUnlinked++
|
||||
log.warn(
|
||||
run.execute
|
||||
? 'inbox underlag reconcile: document still unlinked after re-run'
|
||||
: 'inbox underlag reconcile: document unlinked (would link)',
|
||||
context,
|
||||
)
|
||||
return false
|
||||
}
|
||||
case 'unlinked_locked': {
|
||||
summary.unlinkedLocked++
|
||||
log.warn('inbox underlag reconcile: document unlinked and its verifikat sits in a locked period; unlock to link', context)
|
||||
return false
|
||||
}
|
||||
case 'anchored_elsewhere': {
|
||||
summary.anchoredElsewhere++
|
||||
log.warn('inbox underlag reconcile: document anchored to another verifikat; needs a human', {
|
||||
...context,
|
||||
document_journal_entry_id: verdict.document_journal_entry_id,
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,136 @@ export async function resolveVoucherLinkedEntryIds(
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an inbox item's underlag actually references the verifikat that
|
||||
* booked its transaction. Both readers of "this item is booked" need it:
|
||||
*
|
||||
* - the inbox list enrichment, so an item whose document never reached the
|
||||
* verifikat (a failed link, or a document anchored to a DIFFERENT
|
||||
* verifikat) keeps showing in "Att göra" instead of reading as booked on
|
||||
* the transaction's word alone (#1548)
|
||||
* - the reconciliation pass, to classify what a re-run repaired and what
|
||||
* still needs a human
|
||||
*
|
||||
* 'anchored' : document_attachments.journal_entry_id equals the
|
||||
* verifikat, or the item carries no document (there
|
||||
* is no underlag to link; the stamp is all that is
|
||||
* missing)
|
||||
* 'unlinked' : the document references no verifikat yet
|
||||
* (transient: a re-run of the propagation links it)
|
||||
* 'unlinked_locked' : unlinked, and the verifikat sits in a closed or
|
||||
* locked period, so enforce_period_lock_documents
|
||||
* rejects the link until someone unlocks the period
|
||||
* (not transient: a re-run fails the same way)
|
||||
* 'anchored_elsewhere' : the document references another verifikat
|
||||
* (permanent: never stolen, a human decides)
|
||||
*
|
||||
* One batched select for N items, plus one lock-state read for the verifikat
|
||||
* of every unlinked item. Items whose document row cannot be read (select
|
||||
* error) are absent from the map: callers treat absence as unknown, never as
|
||||
* anchored. A failed lock-state read leaves the item 'unlinked' (the
|
||||
* propagation is what fails safely, so erring towards "retry" is harmless).
|
||||
*/
|
||||
export type UnderlagAnchoring = 'anchored' | 'unlinked' | 'unlinked_locked' | 'anchored_elsewhere'
|
||||
|
||||
export interface UnderlagAnchoringResult {
|
||||
status: UnderlagAnchoring
|
||||
/** The verifikat the document currently references, if any. */
|
||||
document_journal_entry_id: string | null
|
||||
}
|
||||
|
||||
export async function resolveUnderlagAnchoring(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
items: Array<{ id: string; document_id: string | null; journalEntryId: string }>,
|
||||
): Promise<Map<string, UnderlagAnchoringResult>> {
|
||||
const map = new Map<string, UnderlagAnchoringResult>()
|
||||
const withDocument: typeof items = []
|
||||
for (const item of items) {
|
||||
if (item.document_id) withDocument.push(item)
|
||||
else map.set(item.id, { status: 'anchored', document_journal_entry_id: null })
|
||||
}
|
||||
if (withDocument.length === 0) return map
|
||||
|
||||
const docIds = Array.from(new Set(withDocument.map((i) => i.document_id as string)))
|
||||
const { data: docs, error } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id, journal_entry_id')
|
||||
.in('id', docIds)
|
||||
.eq('company_id', companyId)
|
||||
if (error) {
|
||||
log.error('Failed to resolve document anchoring for inbox items', {
|
||||
company_id: companyId,
|
||||
error: error.message,
|
||||
})
|
||||
return map
|
||||
}
|
||||
const entryByDoc = new Map<string, string | null>()
|
||||
for (const doc of (docs ?? []) as Array<{ id: string; journal_entry_id: string | null }>) {
|
||||
entryByDoc.set(doc.id, doc.journal_entry_id)
|
||||
}
|
||||
const unlinked: typeof items = []
|
||||
for (const item of withDocument) {
|
||||
const docId = item.document_id as string
|
||||
if (!entryByDoc.has(docId)) continue // unreadable row: unknown, not anchored
|
||||
const current = entryByDoc.get(docId) ?? null
|
||||
const status: UnderlagAnchoring =
|
||||
current === null
|
||||
? 'unlinked'
|
||||
: current === item.journalEntryId
|
||||
? 'anchored'
|
||||
: 'anchored_elsewhere'
|
||||
if (status === 'unlinked') unlinked.push(item)
|
||||
map.set(item.id, { status, document_journal_entry_id: current })
|
||||
}
|
||||
if (unlinked.length === 0) return map
|
||||
|
||||
const lockedEntryIds = await resolveLockedJournalEntryIds(
|
||||
supabase,
|
||||
companyId,
|
||||
Array.from(new Set(unlinked.map((i) => i.journalEntryId))),
|
||||
)
|
||||
for (const item of unlinked) {
|
||||
if (lockedEntryIds.has(item.journalEntryId)) {
|
||||
map.set(item.id, { status: 'unlinked_locked', document_journal_entry_id: null })
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the given verifikat sit in a closed or locked fiscal period: the
|
||||
* same (is_closed, locked_at) pair enforce_period_lock_documents checks, so
|
||||
* a document link to them is known to fail before it is attempted. A read
|
||||
* error yields an empty set (nothing is reported as locked on a guess).
|
||||
*/
|
||||
async function resolveLockedJournalEntryIds(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
entryIds: string[],
|
||||
): Promise<Set<string>> {
|
||||
const locked = new Set<string>()
|
||||
if (entryIds.length === 0) return locked
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, fiscal_period:fiscal_periods(is_closed, locked_at)')
|
||||
.in('id', entryIds)
|
||||
.eq('company_id', companyId)
|
||||
if (error) {
|
||||
log.error('Failed to resolve period lock state for inbox underlag anchoring', {
|
||||
company_id: companyId,
|
||||
error: error.message,
|
||||
})
|
||||
return locked
|
||||
}
|
||||
type PeriodLock = { is_closed?: boolean | null; locked_at?: string | null }
|
||||
for (const row of (data ?? []) as Array<{ id: string; fiscal_period: PeriodLock | PeriodLock[] | null }>) {
|
||||
const period = Array.isArray(row.fiscal_period) ? row.fiscal_period[0] : row.fiscal_period
|
||||
if (period?.is_closed || period?.locked_at) locked.add(row.id)
|
||||
}
|
||||
return locked
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor one document to the verifikat, with the guard semantics every
|
||||
* booking path shares: a document already pointing at THIS verifikat is a
|
||||
|
||||
@@ -3481,6 +3481,11 @@
|
||||
"fields_filled": "{count} filled",
|
||||
"review_and_book": "Review and book",
|
||||
"book_manually": "Book manually",
|
||||
"underlag_unlinked": "The transaction is booked but the document is not linked to the voucher yet. It is linked automatically on the next run.",
|
||||
"underlag_unlinked_locked": "The transaction is booked but the document cannot be linked: the voucher sits in a closed or locked period. Unlock the period and it is linked on the next run.",
|
||||
"underlag_unknown": "The transaction is booked but whether the document is linked to the voucher could not be read. Reload the page.",
|
||||
"underlag_anchored_elsewhere": "The document is linked to another voucher. Check which voucher it belongs to.",
|
||||
"underlag_open_verification": "Open the voucher",
|
||||
"retry": "Try again",
|
||||
"copy_address": "Copy address",
|
||||
"copy_address_failed": "The address was not copied. Select it above and copy it manually.",
|
||||
|
||||
@@ -3481,6 +3481,11 @@
|
||||
"fields_filled": "{count} ifyllda",
|
||||
"review_and_book": "Granska och bokför",
|
||||
"book_manually": "Bokför manuellt",
|
||||
"underlag_unlinked": "Transaktionen är bokförd men underlaget är inte kopplat till verifikatet ännu. Kopplas automatiskt vid nästa körning.",
|
||||
"underlag_unlinked_locked": "Transaktionen är bokförd men underlaget kan inte kopplas: verifikatet ligger i en stängd eller låst period. Lås upp perioden så kopplas det vid nästa körning.",
|
||||
"underlag_unknown": "Transaktionen är bokförd men det gick inte att läsa om underlaget är kopplat till verifikatet. Ladda om sidan.",
|
||||
"underlag_anchored_elsewhere": "Underlaget är kopplat till ett annat verifikat. Kontrollera vilket verifikat det hör till.",
|
||||
"underlag_open_verification": "Öppna verifikatet",
|
||||
"retry": "Försök igen",
|
||||
"copy_address": "Kopiera adress",
|
||||
"copy_address_failed": "Adressen kopierades inte. Markera den ovan och kopiera manuellt.",
|
||||
|
||||
@@ -14,8 +14,13 @@
|
||||
* constraint allows (on a samlingsverifikat only one of N items can
|
||||
* carry it; the rest stay derived).
|
||||
*
|
||||
* Idempotent: items already stamped are excluded by the query, and the
|
||||
* propagation helper skips documents that are already anchored.
|
||||
* Since #1548 the same pass runs daily from
|
||||
* app/api/extensions/invoice-inbox/underlag-reconcile/cron; this script is
|
||||
* the manual, uncapped entry point to the shared implementation in
|
||||
* lib/transactions/inbox-underlag-reconcile.ts (dry-run, or a full sweep
|
||||
* after an incident). Idempotent: items already stamped are excluded by the
|
||||
* query, and the propagation helper skips documents that are already
|
||||
* anchored.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/backfill-inbox-booked-underlag.ts # dry-run (default)
|
||||
@@ -35,12 +40,7 @@
|
||||
import { config } from 'dotenv'
|
||||
config({ path: '.env.local' })
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { appendProcessingHistoryWithClient } from '@/lib/processing-history/append'
|
||||
import {
|
||||
propagateUnderlagForBookedTransaction,
|
||||
resolveBookedJournalEntryIds,
|
||||
} from '@/lib/transactions/inbox-underlag'
|
||||
import { reconcileStrandedInboxUnderlag } from '@/lib/transactions/inbox-underlag-reconcile'
|
||||
|
||||
const EXECUTE = process.argv.includes('--execute')
|
||||
|
||||
@@ -54,93 +54,30 @@ if (!supabaseUrl || !serviceRoleKey) {
|
||||
|
||||
const supabase = createClient(supabaseUrl, serviceRoleKey)
|
||||
|
||||
interface StrandedItem {
|
||||
id: string
|
||||
company_id: string
|
||||
matched_transaction_id: string
|
||||
document_id: string | null
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Target: ${supabaseUrl}`)
|
||||
console.log(EXECUTE ? 'MODE: EXECUTE (writing)' : 'MODE: dry-run (no writes)')
|
||||
|
||||
const items = await fetchAllRows<StrandedItem>((range) =>
|
||||
supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, company_id, matched_transaction_id, document_id')
|
||||
.not('matched_transaction_id', 'is', null)
|
||||
.is('created_journal_entry_id', null)
|
||||
.is('created_supplier_invoice_id', null)
|
||||
.order('id', { ascending: true })
|
||||
.range(range.from, range.to),
|
||||
)
|
||||
console.log(`Matched, unconsumed inbox items: ${items.length}`)
|
||||
|
||||
// Group per company so the resolver runs one batched lookup per tenant.
|
||||
const byCompany = new Map<string, StrandedItem[]>()
|
||||
for (const item of items) {
|
||||
const list = byCompany.get(item.company_id) ?? []
|
||||
list.push(item)
|
||||
byCompany.set(item.company_id, list)
|
||||
}
|
||||
|
||||
let strandedOnBooked = 0
|
||||
let companiesTouched = 0
|
||||
for (const [companyId, companyItems] of byCompany) {
|
||||
const txIds = Array.from(new Set(companyItems.map((i) => i.matched_transaction_id)))
|
||||
const bookedByTx = await resolveBookedJournalEntryIds(supabase, companyId, txIds)
|
||||
const stranded = companyItems.filter((i) => bookedByTx.has(i.matched_transaction_id))
|
||||
if (stranded.length === 0) continue
|
||||
companiesTouched++
|
||||
strandedOnBooked += stranded.length
|
||||
console.log(`company ${companyId}: ${stranded.length} item(s) on booked transactions`)
|
||||
|
||||
if (!EXECUTE) continue
|
||||
const txIdsToComplete = Array.from(new Set(stranded.map((i) => i.matched_transaction_id)))
|
||||
for (const txId of txIdsToComplete) {
|
||||
const journalEntryId = bookedByTx.get(txId)
|
||||
if (!journalEntryId) continue
|
||||
await propagateUnderlagForBookedTransaction(supabase, companyId, txId, journalEntryId)
|
||||
|
||||
// Behandlingshistorik (BFNAR 2013:2 kap 8): a mass repair touching
|
||||
// underlag-to-verifikat linkage must leave a changelog trail
|
||||
// distinguishing it from the original booking action. Written through
|
||||
// the shared appender (row shape + PII validation) on this script's
|
||||
// own service-role client; payload is pseudonymous IDs only.
|
||||
const itemIds = stranded
|
||||
.filter((i) => i.matched_transaction_id === txId)
|
||||
.map((i) => i.id)
|
||||
try {
|
||||
await appendProcessingHistoryWithClient(supabase, {
|
||||
companyId,
|
||||
correlationId: txId,
|
||||
aggregateType: 'BankTransaction',
|
||||
aggregateId: txId,
|
||||
eventType: 'InboxUnderlagBackfilled',
|
||||
payload: {
|
||||
transaction_id: txId,
|
||||
journal_entry_id: journalEntryId,
|
||||
inbox_item_ids: itemIds,
|
||||
script: 'backfill-inbox-booked-underlag',
|
||||
},
|
||||
actor: { type: 'system', id: 'backfill-inbox-booked-underlag' },
|
||||
occurredAt: new Date(),
|
||||
})
|
||||
} catch (historyError) {
|
||||
console.error(
|
||||
`processing_history append failed for tx ${txId}: ${
|
||||
historyError instanceof Error ? historyError.message : String(historyError)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Uncapped: the cron is bounded per run, a manual sweep is not.
|
||||
const summary = await reconcileStrandedInboxUnderlag(supabase, {
|
||||
execute: EXECUTE,
|
||||
maxItems: Number.POSITIVE_INFINITY,
|
||||
actorId: 'backfill-inbox-booked-underlag',
|
||||
})
|
||||
|
||||
console.log(`Matched, unconsumed inbox items: ${summary.scanned}`)
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
console.log(
|
||||
`${EXECUTE ? 'Repaired' : 'Would repair'} ${strandedOnBooked} item(s) across ${companiesTouched} company/companies.`,
|
||||
EXECUTE
|
||||
? `Repaired ${summary.repaired} item(s) across ${summary.companiesTouched} company/companies; ` +
|
||||
`${summary.stillUnlinked} still unlinked, ${summary.unlinkedLocked} in locked periods, ` +
|
||||
`${summary.anchoredElsewhere} anchored elsewhere.`
|
||||
: `Would link ${summary.stillUnlinked} item(s) across ${summary.companiesTouched} company/companies; ` +
|
||||
`${summary.unlinkedLocked} in locked periods need an unlock, ` +
|
||||
`${summary.anchoredElsewhere} anchored elsewhere need a human.`,
|
||||
)
|
||||
if (!EXECUTE) console.log('Re-run with --execute to apply.')
|
||||
if (summary.failures > 0) process.exitCode = 1
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Register the behandlingshistorik event written by the inbox-underlag
|
||||
-- reconciliation pass (lib/transactions/inbox-underlag-reconcile.ts, run
|
||||
-- daily from app/api/extensions/invoice-inbox/underlag-reconcile/cron and
|
||||
-- manually from scripts/backfill-inbox-booked-underlag.ts): one event per
|
||||
-- transaction whose matched inbox item's document this run linked to the
|
||||
-- verifikat that booked it (#1548).
|
||||
--
|
||||
-- processing_history.event_type has an FK to processing_event_types, so an
|
||||
-- unregistered type fails the insert. The append is best-effort by design
|
||||
-- (the repair itself is done either way), which is exactly how the script's
|
||||
-- previous 'InboxUnderlagBackfilled' type went unnoticed: it was never
|
||||
-- registered, so every append it attempted failed. Registering here makes
|
||||
-- the repair trail durable (BFNAR 2013:2 kap 8).
|
||||
--
|
||||
-- Catalog row only: aggregate_type 'BankTransaction' is already permitted by
|
||||
-- the aggregate_type CHECK, so no constraint change is needed.
|
||||
|
||||
INSERT INTO public.processing_event_types (event_type) VALUES
|
||||
('InboxUnderlagReconciled')
|
||||
ON CONFLICT (event_type) DO NOTHING;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { getPool } from './setup'
|
||||
import { seedCompany } from './fixtures'
|
||||
|
||||
describe('migration 20260828154800: InboxUnderlagReconciled event type', () => {
|
||||
it('registers InboxUnderlagReconciled in the processing history catalog', async () => {
|
||||
// reconcileStrandedInboxUnderlag() swallows an append failure (the
|
||||
// repair is already done), so an unregistered type would be invisible
|
||||
// outside the logs: this is what happened to the script's previous
|
||||
// 'InboxUnderlagBackfilled' type.
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT event_type
|
||||
FROM public.processing_event_types
|
||||
WHERE event_type = 'InboxUnderlagReconciled'`,
|
||||
)
|
||||
|
||||
expect(rows).toEqual([{ event_type: 'InboxUnderlagReconciled' }])
|
||||
})
|
||||
|
||||
it('accepts an InboxUnderlagReconciled row through the event_type FK', async () => {
|
||||
const { companyId } = await seedCompany()
|
||||
const txId = randomUUID()
|
||||
const { rows } = await getPool().query<{ event_type: string }>(
|
||||
`INSERT INTO public.processing_history
|
||||
(company_id, correlation_id, aggregate_type, aggregate_id, event_type, payload, actor, occurred_at)
|
||||
VALUES ($1, $2, 'BankTransaction', $2, 'InboxUnderlagReconciled',
|
||||
$3::jsonb, '{"type":"system","id":"inbox-underlag-reconcile"}', now())
|
||||
RETURNING event_type`,
|
||||
[
|
||||
companyId,
|
||||
txId,
|
||||
JSON.stringify({ transaction_id: txId, inbox_item_ids: [], source: 'inbox-underlag-reconcile' }),
|
||||
],
|
||||
)
|
||||
|
||||
expect(rows).toEqual([{ event_type: 'InboxUnderlagReconciled' }])
|
||||
})
|
||||
})
|
||||
@@ -86,6 +86,10 @@
|
||||
"path": "/api/extensions/invoice-inbox/sweep/cron",
|
||||
"schedule": "*/2 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/extensions/invoice-inbox/underlag-reconcile/cron",
|
||||
"schedule": "45 5 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/bookkeeping/accruals/post-due/cron",
|
||||
"schedule": "15 5 * * *"
|
||||
|
||||
Reference in New Issue
Block a user