fix(inbox): booked items no longer strand in Att gora as matched-forever (#1547)
* fix(inbox): booked items no longer strand in Att gora as matched-forever A matched inbox item only left the active inbox when created_journal_entry_id was stamped, and only categorizeTransactionCore stamped it. Booking the matched transaction through any other path (the /book dialog route, bulk-book, link-to-existing-voucher) or matching a receipt to an already-booked transaction (receipt hunt approvals, attach-document, match-transaction) left the item "linked" forever, pointing at a transaction that had already left the transactions work list. Todays hunt fix (#1524) turned this July-old gap into a visible flood of stuck items. Two-part fix, because stamps alone cannot cover the reported case: created_journal_entry_id is UNIQUE (20260515090000), so on a bulk-book samlingsverifikat only one of N matched items can ever carry it. Write side: lib/transactions/inbox-underlag.ts is the shared implementation all paths now call. It links matched items' documents to the anchoring verifikat (BFL 5 kap 6-7 kap: underlag on the verifikation) and stamps created_journal_entry_id best-effort (CAS on null, unique_violation tolerated). Wired into categorize-core (replacing its inline block), /book, bulk-book, linkTransactionToJournalEntry, both attach paths (REST + pending-operation), and the inbox match-transaction handler. The attach paths and the doc-conflict guard also resolve bulk-booked transactions through transaction_voucher_links, which they previously treated as unbooked. Read side: GET /items (and /items/:id) enrich matched-but-unstamped items with matched_transaction_journal_entry_id, and the workspace derives "booked" from it. This is what clears the stuck rows already in prod without a status backfill, and what covers the N-1 samlingsverifikat items the UNIQUE constraint refuses to stamp. Bulk-book selection filters exclude such items so "Bokfor valda" no longer offers 409 fodder. scripts/backfill-inbox-booked-underlag.ts (dry-run by default) repairs the historical document->verifikat links the old paths never made. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(inbox): stamp only settled underlag, and give the backfill behandlingshistorik Both from the Swedish accounting compliance review. The consumed-stamp is now conditional on the underlag actually referencing a verifikat: stamping over a failed document link hid the item from the .is('created_journal_entry_id', null) query forever, leaving a posted verifikation without its underlag reference (BFL 5 kap 6-7 kap) and nothing left to surface or repair it. A failed link now leaves the item unstamped so re-runs and the backfill can finish the job; a document preserved on another verifikat still counts as settled. The backfill script now appends an InboxUnderlagBackfilled event per repaired transaction to processing_history (BFNAR 2013:2 kap 8): a mass repair touching underlag-to-verifikat linkage leaves a changelog trail distinguishing it from the original booking action. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(inbox): backfill writes behandlingshistorik through the shared appender From the Swedish accounting compliance review round 2: a hand-rolled processing_history insert in the backfill script could drift from the shared row shape and skip the PII validation. appendProcessingHistory now delegates to appendProcessingHistoryWithClient, which takes a caller-supplied service-role client, so standalone scripts write behandlingshistorik through the exact same code path as the app (BFNAR 2013:2 kap 8: one reconcilable change log across writers). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(inbox): leave the item unstamped when its document belongs to another verifikat Swedish accounting review round 3: refusing to steal the document was right, but stamping the item consumed anyway hid the fact that the transaction's own verifikat ended up with no underlag reference from it (BFL 5 kap 6-7 kap). The anchored-elsewhere case now leaves created_journal_entry_id null so the mismatch keeps surfacing for reconciliation, same posture as a failed link. Also documents in the backfill script header why its writes cannot land in locked periods: linkToJournalEntry's UPDATE is guarded by the enforce_period_lock DB trigger, which fires for service-role writes too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
402bf02b1b
commit
8d56219c31
@@ -889,3 +889,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-12] Detail pager uses sessionStorage list-context + router.replace, not server neighbor queries: the kundfaktura list order is client-computed and not expressible in PostgREST.
|
||||
[2026-08-12] Automatic logout is opt-in per user (user_preferences.auto_logout, default OFF), reversing the 2026-07 always-on session timeouts: founder decision after a user complaint about multiple daily re-logins. The objection that an opt-in security control is effectively a removed one was raised and overruled; the compromise levers kept are NEXT_PUBLIC_SESSION_TIMEOUT_FORCE_ALL=true (re-enables enforcement for everyone without a code change) and the opt-in snapshot living inside the signed timeout cookie (no per-request DB read; the preference is read only at mint, and the preferences API clears the cookie on change so a toggle takes effect on the next request). No backstop absolute cap was added: "off" means the Supabase refresh-token lifetime governs, exactly the pre-hardening behavior. Pre-toggle cookies (no autoLogout field) are authentic-but-stale and re-minted preserving their timers, never routed down the tamper path, so the rollout logs nobody out.
|
||||
[2026-08-12] Content dedupe on document ingest is an opt-in uploadDocument flag wired into the intake funnel (uploadAndExtract + mail-hunt ingest), NOT a unique index on (company_id, sha256_hash): archival callers (sent invoices, filings, bank exports) legitimately store repeating bytes and a blanket constraint would break them; the SELECT-then-insert race is accepted exactly as in the WhatsApp precedent. On a hit the funnel adopts the existing inbox item (callers always get a real inbox_item_id) or files an item against the existing document; the mail hunt skips outright since a second item would only duplicate work in Underlag.
|
||||
[2026-08-12] Inbox "booked" state is derived server-side from the matched transaction (GET /items enrichment) in ADDITION to write-side created_journal_entry_id stamps, not stamps alone: created_journal_entry_id is UNIQUE (migration 20260515090000), so on a bulk-book samlingsverifikat only one of N matched items can ever carry the stamp; a stamp-only fix could not clear the reported flood of matched items on bulk-booked transactions. The constraint stays (it guards the book-direct double-fire race); the stamp becomes a fast path and the derivation the source of truth.
|
||||
|
||||
@@ -107,6 +107,7 @@ describe('POST /api/transactions/[id]/attach-document', () => {
|
||||
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
|
||||
enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING)
|
||||
enqueue({ data: null, error: null }) // inbox-link best-effort update
|
||||
enqueue({ data: [], error: null }) // completion: voucher-link resolution (not bulk-booked)
|
||||
const res = await POST(
|
||||
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
||||
createMockRouteParams({ id: 'tx-1' }),
|
||||
@@ -128,6 +129,7 @@ describe('POST /api/transactions/[id]/attach-document', () => {
|
||||
enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // transactions update (RETURNING)
|
||||
enqueue({ data: null, error: null }) // inbox-link best-effort update
|
||||
enqueue({ data: null, error: null }) // document_attachments propagation
|
||||
enqueue({ data: [], error: null }) // completion: matched inbox items (none)
|
||||
const res = await POST(
|
||||
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
||||
createMockRouteParams({ id: 'tx-1' }),
|
||||
@@ -145,6 +147,7 @@ describe('POST /api/transactions/[id]/attach-document', () => {
|
||||
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1' }, error: null }) // doc fetch
|
||||
enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // transactions update (RETURNING)
|
||||
enqueue({ data: null, error: null }) // inbox-link best-effort update
|
||||
enqueue({ data: [], error: null }) // completion: matched inbox items (none)
|
||||
const res = await POST(
|
||||
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
||||
createMockRouteParams({ id: 'tx-1' }),
|
||||
@@ -159,6 +162,7 @@ describe('POST /api/transactions/[id]/attach-document', () => {
|
||||
it('returns 409 when the document already belongs to a different verifikation', async () => {
|
||||
enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null }) // tx fetch
|
||||
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch
|
||||
enqueue({ data: [], error: null }) // voucher-link check: je-OTHER anchors nothing here
|
||||
const res = await POST(
|
||||
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
||||
createMockRouteParams({ id: 'tx-1' }),
|
||||
@@ -168,6 +172,30 @@ describe('POST /api/transactions/[id]/attach-document', () => {
|
||||
expect(body.error).toContain('annan verifikation')
|
||||
})
|
||||
|
||||
it('completes the matched inbox item when the tx is anchored via a bulk-book samlingsverifikat', async () => {
|
||||
// A bulk-booked tx keeps transactions.journal_entry_id null: the
|
||||
// verifikat hangs off transaction_voucher_links. Attaching a receipt to
|
||||
// it must link the underlag to that verifikat and stamp the matched
|
||||
// inbox item, or the item strands as "linked" (the 2026-08-12 report).
|
||||
const DOC = '11111111-1111-4111-8111-111111111111'
|
||||
enqueue({ data: { id: 'tx-1', journal_entry_id: null }, error: null }) // tx fetch
|
||||
enqueue({ data: { id: DOC, journal_entry_id: null }, error: null }) // doc fetch
|
||||
enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING)
|
||||
enqueue({ data: null, error: null }) // inbox-link best-effort update
|
||||
enqueue({ data: [{ transaction_id: 'tx-1', journal_entry_id: 'je-9' }], error: null }) // voucher links
|
||||
enqueue({ data: [{ id: 'inbox-1', document_id: DOC }], error: null }) // matched inbox items
|
||||
enqueue({ data: { journal_entry_id: null }, error: null }) // doc anchor check: free
|
||||
enqueue({ data: { id: 'je-9' }, error: null }) // linkToJournalEntry: JE ownership check
|
||||
enqueue({ data: { id: DOC, journal_entry_id: 'je-9' }, error: null }) // linkToJournalEntry: doc update
|
||||
enqueue({ data: null, error: null }) // created_journal_entry_id stamp
|
||||
|
||||
const res = await POST(makeReq({ document_id: DOC }), createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ data: { journal_entry_id: string } }>(res)
|
||||
expect(status).toBe(200)
|
||||
// The response reports the samlingsverifikat the attach completed against.
|
||||
expect(body.data.journal_entry_id).toBe('je-9')
|
||||
})
|
||||
|
||||
it('returns 409 when the verifikation period is locked during propagation', async () => {
|
||||
enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null }) // tx fetch
|
||||
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
|
||||
@@ -219,6 +247,7 @@ describe('POST /api/transactions/[id]/attach-document', () => {
|
||||
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
|
||||
enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING)
|
||||
enqueue({ data: null, error: null }) // inbox-link update
|
||||
enqueue({ data: [], error: null }) // completion: voucher-link resolution (not bulk-booked)
|
||||
|
||||
await POST(
|
||||
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
||||
@@ -234,6 +263,7 @@ describe('POST /api/transactions/[id]/attach-document', () => {
|
||||
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
|
||||
enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING)
|
||||
enqueue({ data: null, error: { message: 'rls denied' } }) // inbox-link fails
|
||||
enqueue({ data: [], error: null }) // completion: voucher-link resolution (not bulk-booked)
|
||||
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const res = await POST(
|
||||
|
||||
@@ -4,6 +4,10 @@ import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { AttachDocumentSchema } from '@/lib/api/schemas'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
import {
|
||||
completeInboxItemsForBookedTransaction,
|
||||
resolveVoucherLinkedEntryIds,
|
||||
} from '@/lib/transactions/inbox-underlag'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -55,13 +59,20 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
// A document that already serves as underlag for a DIFFERENT verifikation
|
||||
// cannot be pinned here: propagating would either corrupt that link or be
|
||||
// blocked by the document-metadata immutability trigger. Same verifikation
|
||||
// is fine (idempotent re-attach; propagation below becomes a no-op).
|
||||
// is fine (idempotent re-attach; propagation below becomes a no-op). A
|
||||
// bulk-booked tx keeps journal_entry_id null and is anchored through
|
||||
// transaction_voucher_links, so that anchoring counts as "same" too.
|
||||
const docJournalEntryId = (document.journal_entry_id as string | null) ?? null
|
||||
if (docJournalEntryId && docJournalEntryId !== transaction.journal_entry_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Underlaget är redan kopplat till en annan verifikation.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
const voucherLinked = await resolveVoucherLinkedEntryIds(supabase, companyId, [
|
||||
transactionId,
|
||||
])
|
||||
if (docJournalEntryId !== voucherLinked.get(transactionId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Underlaget är redan kopplat till en annan verifikation.' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Race-free read of journal_entry_id: UPDATE ... RETURNING so the value we
|
||||
@@ -152,6 +163,21 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
}
|
||||
}
|
||||
|
||||
// The transaction may already be booked, directly or via a bulk-book
|
||||
// samlingsverifikat (journal_entry_id null, anchored through
|
||||
// transaction_voucher_links): complete the matched inbox items against
|
||||
// the anchoring verifikat so an after-the-fact attach resolves them
|
||||
// instead of stranding them as "linked" forever. Best-effort, logged
|
||||
// inside. Returns the anchoring verifikat (the direct id when there is
|
||||
// one), so the response and audit trail can report the voucher-linked
|
||||
// case too.
|
||||
const effectiveJournalEntryId = await completeInboxItemsForBookedTransaction(
|
||||
supabase,
|
||||
companyId,
|
||||
transactionId,
|
||||
{ directJournalEntryId: journalEntryId },
|
||||
)
|
||||
|
||||
// Rättelse audit trail (BFL 5 kap 5 §): record swaps where a non-null doc
|
||||
// was replaced. Best-effort: a logging failure must not roll back the
|
||||
// (compliant) attach.
|
||||
@@ -167,7 +193,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
transaction_id: transactionId,
|
||||
previous_document_id: previousDocumentId,
|
||||
new_document_id: document_id,
|
||||
journal_entry_id: journalEntryId,
|
||||
journal_entry_id: effectiveJournalEntryId,
|
||||
},
|
||||
actor: { type: 'user', id: user.id },
|
||||
occurredAt: new Date(),
|
||||
@@ -182,7 +208,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
transaction_id: transactionId,
|
||||
document_id,
|
||||
previous_document_id: previousDocumentId,
|
||||
journal_entry_id: journalEntryId,
|
||||
journal_entry_id: effectiveJournalEntryId,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { BookTransactionSchema } from '@/lib/api/schemas'
|
||||
import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection'
|
||||
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
@@ -181,6 +182,11 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
)
|
||||
}
|
||||
|
||||
// A hunt- or hand-matched inbox item is consumed by this booking even
|
||||
// though the dialog never saw it: link its underlag to the verifikat and
|
||||
// stamp it so it leaves the active inbox (best-effort, logged inside).
|
||||
await propagateUnderlagForBookedTransaction(supabase, companyId, id, journalEntry.id)
|
||||
|
||||
// Emit event (non-blocking)
|
||||
try {
|
||||
await eventBus.emit({
|
||||
|
||||
@@ -33,8 +33,16 @@ vi.mock('@/lib/bookkeeping/template-library', () => ({
|
||||
applyTemplate: vi.fn(),
|
||||
}))
|
||||
|
||||
// Stubbed so the queued supabase mock stays aligned with the route's own
|
||||
// queries; the helper's behaviour is covered in
|
||||
// lib/transactions/__tests__/inbox-underlag.test.ts.
|
||||
vi.mock('@/lib/transactions/inbox-underlag', () => ({
|
||||
propagateUnderlagForBookedTransaction: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { applyTemplate } from '@/lib/bookkeeping/template-library'
|
||||
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
|
||||
|
||||
const TX1 = '11111111-1111-4111-8111-111111111111'
|
||||
const TX2 = '22222222-2222-4222-8222-222222222222'
|
||||
@@ -114,6 +122,20 @@ describe('POST /api/transactions/bulk-book', () => {
|
||||
expect(body.data.mode).toBe('link_existing')
|
||||
expect(body.data.journal_entry_id).toBe(JE)
|
||||
expect(body.data.linked_tx_count).toBe(2)
|
||||
// Every booked tx gets its matched inbox items completed against the
|
||||
// samlingsverifikat (underlag link + consumed stamp).
|
||||
expect(propagateUnderlagForBookedTransaction).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
TX1,
|
||||
JE,
|
||||
)
|
||||
expect(propagateUnderlagForBookedTransaction).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
TX2,
|
||||
JE,
|
||||
)
|
||||
})
|
||||
|
||||
it('create-new path fetches template, expands per mode, and calls RPC', async () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
fetchActiveDimensionRules,
|
||||
} from '@/lib/bookkeeping/dimension-rules'
|
||||
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
|
||||
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { BookingTemplateLibraryLine, Transaction } from '@/types'
|
||||
@@ -324,6 +325,20 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode(code, opLog, { requestId, details })
|
||||
}
|
||||
|
||||
// Complete any matched inbox items against the samlingsverifikat: link
|
||||
// their underlag and stamp them consumed so they leave the active inbox.
|
||||
// Best-effort, logged inside; created_journal_entry_id is UNIQUE so at
|
||||
// most one item can carry the stamp; the inbox list derives "booked"
|
||||
// from the voucher links for the rest.
|
||||
for (const txId of body.tx_ids) {
|
||||
await propagateUnderlagForBookedTransaction(
|
||||
supabase,
|
||||
companyId!,
|
||||
txId,
|
||||
result.journal_entry_id,
|
||||
)
|
||||
}
|
||||
|
||||
// Emit one transaction.reconciled event per tx so existing subscribers
|
||||
// (reminder cancellation, automation, processing-history) keep working.
|
||||
// Best-effort; a failure here does not roll back the booking.
|
||||
|
||||
@@ -27,6 +27,9 @@ interface BulkBookInboxItem {
|
||||
matched_transaction_id: string | null
|
||||
created_journal_entry_id: string | null
|
||||
created_supplier_invoice_id: string | null
|
||||
// Server-derived: the verifikat anchoring an already-booked matched
|
||||
// transaction (see InvoiceInboxWorkspace's InboxItem).
|
||||
matched_transaction_journal_entry_id?: string | null
|
||||
extracted_data: InvoiceExtractionResult | null
|
||||
}
|
||||
|
||||
@@ -85,7 +88,14 @@ const VAT_OPTIONS: { value: VatTreatment | 'auto'; label: string }[] = [
|
||||
]
|
||||
|
||||
function isBookable(it: BulkBookInboxItem): boolean {
|
||||
return Boolean(it.matched_transaction_id) && !it.created_journal_entry_id && !it.created_supplier_invoice_id
|
||||
return (
|
||||
Boolean(it.matched_transaction_id) &&
|
||||
!it.created_journal_entry_id &&
|
||||
!it.created_supplier_invoice_id &&
|
||||
// A matched transaction that is already booked has nothing left to book:
|
||||
// the server would only skip it as already_booked_or_duplicate.
|
||||
!it.matched_transaction_journal_entry_id
|
||||
)
|
||||
}
|
||||
|
||||
export default function BulkBookInboxDialog({ open, onOpenChange, items, onSuccess }: Props) {
|
||||
@@ -101,7 +111,13 @@ export default function BulkBookInboxDialog({ open, onOpenChange, items, onSucce
|
||||
[items],
|
||||
)
|
||||
const alreadyBooked = useMemo(
|
||||
() => items.filter((it) => it.created_journal_entry_id || it.created_supplier_invoice_id).length,
|
||||
() =>
|
||||
items.filter(
|
||||
(it) =>
|
||||
it.created_journal_entry_id ||
|
||||
it.created_supplier_invoice_id ||
|
||||
it.matched_transaction_journal_entry_id,
|
||||
).length,
|
||||
[items],
|
||||
)
|
||||
|
||||
|
||||
@@ -95,6 +95,12 @@ interface InboxItem {
|
||||
matched_transaction_id: string | null
|
||||
created_supplier_invoice_id: string | null
|
||||
created_journal_entry_id: string | null
|
||||
// The verifikat that anchors the matched transaction when it is already
|
||||
// booked (directly or via a bulk-book samlingsverifikat). Server-derived by
|
||||
// GET /items: created_journal_entry_id is UNIQUE per verifikat, so on a
|
||||
// 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
|
||||
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
|
||||
@@ -214,16 +220,18 @@ function countExtractedFields(data: InvoiceExtractionResult | null): number {
|
||||
// Lifecycle stage of an inbox item. Single source of truth shared by the list
|
||||
// filter, the count pills, and the row icons so they never drift apart.
|
||||
//
|
||||
// Precedence mirrors the FieldsRail: a booked item (supplier invoice OR a
|
||||
// direct journal entry) is done and drops out of the active inbox. A
|
||||
// matched-but-unbooked item is "linked": it STAYS in the inbox as its own
|
||||
// category because the bank payment still needs booking (a document attached
|
||||
// to a transaction is not the same as a booked one). An extraction failure is
|
||||
// "error"; everything else needs a first action.
|
||||
// Precedence mirrors the FieldsRail: a booked item (supplier invoice, a
|
||||
// direct journal entry, OR a matched transaction that is itself booked) is
|
||||
// done and drops out of the active inbox. A matched-but-unbooked item is
|
||||
// "linked": it STAYS in the inbox as its own category because the bank
|
||||
// payment still needs booking (a document attached to a transaction is not
|
||||
// the same as a booked one). An extraction failure is "error"; everything
|
||||
// else needs a first action.
|
||||
type InboxStatus = 'needs_action' | 'linked' | 'booked' | 'error'
|
||||
|
||||
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_id) return 'linked'
|
||||
if (item.status === 'error') return 'error'
|
||||
return 'needs_action'
|
||||
@@ -976,7 +984,13 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
const bookableSelectedCount = useMemo(
|
||||
() =>
|
||||
selectedItems.filter(
|
||||
(it) => it.matched_transaction_id && !it.created_journal_entry_id && !it.created_supplier_invoice_id,
|
||||
(it) =>
|
||||
it.matched_transaction_id &&
|
||||
!it.created_journal_entry_id &&
|
||||
!it.created_supplier_invoice_id &&
|
||||
// A matched transaction that is already booked has nothing left to
|
||||
// bulk-book: the server would only skip it with a 409.
|
||||
!it.matched_transaction_journal_entry_id,
|
||||
).length,
|
||||
[selectedItems],
|
||||
)
|
||||
@@ -2724,7 +2738,11 @@ function FieldsRail({
|
||||
}, [item.id])
|
||||
|
||||
const isProcessed = !!item.created_supplier_invoice_id
|
||||
const isBookedDirectly = !isProcessed && !!item.created_journal_entry_id
|
||||
// The verifikat this item resolved into: its own stamp, or the entry that
|
||||
// anchors its matched (and already booked) transaction. See InboxItem.
|
||||
const bookedEntryId =
|
||||
item.created_journal_entry_id ?? 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
|
||||
@@ -3059,8 +3077,8 @@ function FieldsRail({
|
||||
Öppna leverantörsfaktura
|
||||
</Button>
|
||||
</Link>
|
||||
) : isBookedDirectly && item.created_journal_entry_id ? (
|
||||
<Link href={`/bookkeeping/${item.created_journal_entry_id}`} className="block">
|
||||
) : isBookedDirectly && bookedEntryId ? (
|
||||
<Link href={`/bookkeeping/${bookedEntryId}`} className="block">
|
||||
<Button variant="default" size="sm" className="w-full">
|
||||
<ArrowRight className="h-3.5 w-3.5 mr-1.5" />
|
||||
Öppna verifikation
|
||||
|
||||
@@ -57,6 +57,10 @@ import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import { renderChannelContextNotes } from '@/lib/documents/channel-context-notes'
|
||||
import { CreateSupplierInvoiceSchema, BookInboxItemDirectlySchema, BulkBookInboxSchema } from '@/lib/api/schemas'
|
||||
import { bulkBookMatchedInboxItems } from '@/lib/transactions/categorize-core'
|
||||
import {
|
||||
completeInboxItemsForBookedTransaction,
|
||||
resolveBookedJournalEntryIds,
|
||||
} from '@/lib/transactions/inbox-underlag'
|
||||
import { hasCapability, capabilityBlockedResponse } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
|
||||
@@ -300,7 +304,44 @@ export const invoiceInboxExtension: Extension = {
|
||||
const { data, error } = await query
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
|
||||
return NextResponse.json({ data: { items: data, count: data?.length ?? 0 } })
|
||||
// Enrich matched-but-unstamped items with the verifikat that anchors
|
||||
// their transaction, when it is already booked. The stamp
|
||||
// (created_journal_entry_id) is UNIQUE per verifikat, so on a
|
||||
// samlingsverifikat only one of N items can carry it: deriving
|
||||
// "booked" from the transaction's own state is what lets the rest
|
||||
// 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 = {
|
||||
matched_transaction_id: string | null
|
||||
created_journal_entry_id: string | null
|
||||
created_supplier_invoice_id: string | null
|
||||
}
|
||||
const rows = (data ?? []) as ItemRow[]
|
||||
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),
|
||||
),
|
||||
)
|
||||
const bookedByTx = await resolveBookedJournalEntryIds(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
unresolvedTxIds,
|
||||
)
|
||||
const items = rows.map((r) => ({
|
||||
...r,
|
||||
matched_transaction_journal_entry_id: r.matched_transaction_id
|
||||
? bookedByTx.get(r.matched_transaction_id) ?? null
|
||||
: null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ data: { items, count: items.length } })
|
||||
},
|
||||
},
|
||||
|
||||
@@ -361,7 +402,28 @@ export const invoiceInboxExtension: Extension = {
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
|
||||
return NextResponse.json({ data })
|
||||
// Same enrichment as the list: the detail rail must not offer to
|
||||
// book a matched transaction that is already booked.
|
||||
const row = data as {
|
||||
matched_transaction_id: string | null
|
||||
created_journal_entry_id: string | null
|
||||
created_supplier_invoice_id: string | null
|
||||
}
|
||||
let matchedTransactionJournalEntryId: string | null = null
|
||||
if (
|
||||
row.matched_transaction_id &&
|
||||
!row.created_journal_entry_id &&
|
||||
!row.created_supplier_invoice_id
|
||||
) {
|
||||
const bookedByTx = await resolveBookedJournalEntryIds(ctx.supabase, ctx.companyId, [
|
||||
row.matched_transaction_id,
|
||||
])
|
||||
matchedTransactionJournalEntryId = bookedByTx.get(row.matched_transaction_id) ?? null
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: { ...row, matched_transaction_journal_entry_id: matchedTransactionJournalEntryId },
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
@@ -846,6 +908,17 @@ export const invoiceInboxExtension: Extension = {
|
||||
}
|
||||
}
|
||||
|
||||
// The matched transaction may already be booked (directly or via a
|
||||
// bulk-book samlingsverifikat): complete the item against the
|
||||
// anchoring verifikat (underlag link + consumed stamp) so matching
|
||||
// to a settled purchase resolves the item instead of stranding it
|
||||
// as "linked". Best-effort, logged inside.
|
||||
await completeInboxItemsForBookedTransaction(
|
||||
ctx.supabase,
|
||||
ctx.companyId,
|
||||
body.transaction_id,
|
||||
)
|
||||
|
||||
return NextResponse.json({ data: updated })
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1004,6 +1004,7 @@ describe('commitPendingOperation: attach_document_to_transaction', () => {
|
||||
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
|
||||
enqueue({ data: { journal_entry_id: null }, error: null }) // UPDATE returning
|
||||
enqueue({ data: null, error: null }) // invoice_inbox_items best-effort link
|
||||
enqueue({ data: [], error: null }) // voucher-link resolution: not bulk-booked either
|
||||
enqueue({ data: null, error: null }) // dispatcher commit update
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
@@ -1023,6 +1024,7 @@ describe('commitPendingOperation: attach_document_to_transaction', () => {
|
||||
enqueue({ data: { journal_entry_id: 'je-7' }, error: null }) // UPDATE returning post-state
|
||||
enqueue({ data: null, error: null }) // invoice_inbox_items best-effort link
|
||||
enqueue({ data: null, error: null }) // doc propagation update
|
||||
enqueue({ data: [], error: null }) // completion: matched inbox items (none)
|
||||
enqueue({ data: null, error: null }) // dispatcher commit update
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
@@ -1034,6 +1036,34 @@ describe('commitPendingOperation: attach_document_to_transaction', () => {
|
||||
expect(result.status).toBe('committed')
|
||||
})
|
||||
|
||||
it('completes matched inbox items when the tx is anchored via a bulk-book samlingsverifikat', async () => {
|
||||
// A bulk-booked tx keeps transactions.journal_entry_id null: the verifikat
|
||||
// hangs off transaction_voucher_links. Attaching a hunted receipt to it
|
||||
// must still resolve the matched inbox item, or it strands as "linked"
|
||||
// forever (the 2026-08-12 report).
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: { id: 'tx-1', document_id: null, journal_entry_id: null }, error: null })
|
||||
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
|
||||
enqueue({ data: { journal_entry_id: null }, error: null }) // UPDATE returning (still null)
|
||||
enqueue({ data: null, error: null }) // invoice_inbox_items best-effort link
|
||||
enqueue({ data: [{ transaction_id: 'tx-1', journal_entry_id: 'je-9' }], error: null }) // voucher links
|
||||
enqueue({ data: [{ id: 'inbox-9', document_id: null }], error: null }) // matched inbox items
|
||||
enqueue({ data: null, error: null }) // created_journal_entry_id stamp
|
||||
enqueue({ data: null, error: null }) // dispatcher commit update
|
||||
|
||||
const result = await commitPendingOperation(
|
||||
supabase as never,
|
||||
'user-1',
|
||||
'company-1',
|
||||
makePendingOp(baseOp),
|
||||
)
|
||||
expect(result.status).toBe('committed')
|
||||
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
|
||||
{ created_journal_entry_id: 'je-9' },
|
||||
])
|
||||
})
|
||||
|
||||
it('still commits when the inbox-link best-effort update errors', async () => {
|
||||
// Inbox sync is best-effort: a failure to mark the inbox row as matched
|
||||
// must not roll back the (compliant) doc→tx attach. Mirrors the REST
|
||||
@@ -1046,6 +1076,7 @@ describe('commitPendingOperation: attach_document_to_transaction', () => {
|
||||
enqueue({ data: { id: 'doc-1' }, error: null }) // doc fetch
|
||||
enqueue({ data: { journal_entry_id: null }, error: null }) // tx UPDATE returning
|
||||
enqueue({ data: null, error: { message: 'inbox row missing or RLS-blocked' } }) // inbox link: errors
|
||||
enqueue({ data: [], error: null }) // voucher-link resolution: not bulk-booked either
|
||||
enqueue({ data: null, error: null }) // dispatcher commit update
|
||||
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
@@ -69,6 +69,10 @@ import {
|
||||
type BatchAllocationResult,
|
||||
} from '@/lib/invoices/clear-settled-batch-allocations'
|
||||
import { linkTransactionToJournalEntry } from '@/lib/transactions/link-journal-entry'
|
||||
import {
|
||||
completeInboxItemsForBookedTransaction,
|
||||
resolveVoucherLinkedEntryIds,
|
||||
} from '@/lib/transactions/inbox-underlag'
|
||||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
import { parseSIEFile } from '@/lib/import/sie-parser'
|
||||
import { executeSIEImport, undoSIEImport } from '@/lib/import/sie-import'
|
||||
@@ -2942,13 +2946,18 @@ async function commitAttachDocumentToTransaction(
|
||||
// A document that already serves as underlag for a DIFFERENT verifikation
|
||||
// cannot be pinned here: propagating would either corrupt that link or be
|
||||
// blocked by the document-metadata immutability trigger. Same verifikation
|
||||
// is fine (idempotent re-attach; propagation below becomes a no-op).
|
||||
// is fine (idempotent re-attach; propagation below becomes a no-op). A
|
||||
// bulk-booked tx keeps journal_entry_id null and is anchored through
|
||||
// transaction_voucher_links, so that anchoring counts as "same" too.
|
||||
// Mirrors the REST route in app/api/transactions/[id]/attach-document.
|
||||
const docJournalEntryId = (doc.journal_entry_id as string | null) ?? null
|
||||
if (docJournalEntryId && docJournalEntryId !== tx.journal_entry_id) {
|
||||
return {
|
||||
error: 'Underlaget är redan kopplat till en annan verifikation.',
|
||||
status: 409,
|
||||
const voucherLinked = await resolveVoucherLinkedEntryIds(supabase, companyId, [txId])
|
||||
if (docJournalEntryId !== voucherLinked.get(txId)) {
|
||||
return {
|
||||
error: 'Underlaget är redan kopplat till en annan verifikation.',
|
||||
status: 409,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3042,6 +3051,20 @@ async function commitAttachDocumentToTransaction(
|
||||
}
|
||||
}
|
||||
|
||||
// The transaction may already be booked, directly or via a bulk-book
|
||||
// samlingsverifikat (journal_entry_id null, anchored through
|
||||
// transaction_voucher_links): complete the matched inbox items against the
|
||||
// anchoring verifikat so an after-the-fact attach resolves them instead of
|
||||
// stranding them as "linked" forever. Best-effort, logged inside. Returns
|
||||
// the anchoring verifikat (the direct id when there is one), so the result
|
||||
// and audit trail can report the voucher-linked case too.
|
||||
const effectiveJournalEntryId = await completeInboxItemsForBookedTransaction(
|
||||
supabase,
|
||||
companyId,
|
||||
txId,
|
||||
{ directJournalEntryId: journalEntryId },
|
||||
)
|
||||
|
||||
// Rättelse audit trail (BFL 5 kap 5 §): if we replaced a non-null doc, log
|
||||
// the swap to processing_history so the original is traceable. Best-effort:
|
||||
// a logging failure must not roll back the (compliant) attach.
|
||||
@@ -3057,7 +3080,7 @@ async function commitAttachDocumentToTransaction(
|
||||
transaction_id: txId,
|
||||
previous_document_id: previousDocumentId,
|
||||
new_document_id: documentId,
|
||||
journal_entry_id: journalEntryId,
|
||||
journal_entry_id: effectiveJournalEntryId,
|
||||
},
|
||||
actor: { type: 'user', id: userId },
|
||||
occurredAt: new Date(),
|
||||
@@ -3072,7 +3095,7 @@ async function commitAttachDocumentToTransaction(
|
||||
transaction_id: txId,
|
||||
document_id: documentId,
|
||||
previous_document_id: previousDocumentId,
|
||||
journal_entry_id: journalEntryId,
|
||||
journal_entry_id: effectiveJournalEntryId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,17 @@ import type {
|
||||
ProcessingHistoryAggregateType,
|
||||
ProcessingHistoryActor,
|
||||
} from '@/types'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Any service-role client with the query surface the append needs. Structural
|
||||
* so both the Next-bound createServiceClient() and a script's own
|
||||
* createClient(url, serviceRoleKey) satisfy it.
|
||||
*/
|
||||
type SupabaseClientLike = Pick<SupabaseClient, 'from'>
|
||||
|
||||
// ── PII validator ───────────────────────────────────────────────
|
||||
// Rejects payloads containing Swedish personal identity numbers.
|
||||
// Personnummer: YYMMDD-NNNN or YYMMDDNNNN (6+4 digits)
|
||||
@@ -101,13 +109,27 @@ export interface AppendEventInput {
|
||||
*/
|
||||
export async function appendProcessingHistory(
|
||||
input: AppendEventInput
|
||||
): Promise<string> {
|
||||
return appendProcessingHistoryWithClient(createServiceClient(), input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Same append, on a caller-supplied service-role client. For standalone
|
||||
* scripts (e.g. scripts/backfill-inbox-booked-underlag.ts) that cannot build
|
||||
* the Next-bound service client but must still write behandlingshistorik
|
||||
* through the one shared row shape and PII validation (BFNAR 2013:2 kap 8:
|
||||
* the change log has to reconcile across writers, so scripts never hand-roll
|
||||
* the insert).
|
||||
*/
|
||||
export async function appendProcessingHistoryWithClient(
|
||||
supabase: SupabaseClientLike,
|
||||
input: AppendEventInput
|
||||
): Promise<string> {
|
||||
// Validate payload + actor.label contain no PII
|
||||
piiSafePayload.parse(input.payload)
|
||||
assertActorPiiSafe(input.actor)
|
||||
|
||||
const eventId = crypto.randomUUID()
|
||||
const supabase = createServiceClient()
|
||||
|
||||
const { error } = await supabase
|
||||
.from('processing_history')
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
linkToJournalEntry: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
import {
|
||||
completeInboxItemsForBookedTransaction,
|
||||
propagateUnderlagForBookedTransaction,
|
||||
resolveBookedJournalEntryIds,
|
||||
} from '../inbox-underlag'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
|
||||
const COMPANY = 'company-1'
|
||||
const TX1 = 'tx-1'
|
||||
const TX2 = 'tx-2'
|
||||
const JE1 = 'je-1'
|
||||
const JE2 = 'je-2'
|
||||
|
||||
describe('resolveBookedJournalEntryIds', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns an empty map without querying for an empty id list', async () => {
|
||||
const { supabase, calls } = createQueuedMockSupabase()
|
||||
const map = await resolveBookedJournalEntryIds(supabase as unknown as SupabaseClient, COMPANY, [])
|
||||
expect(map.size).toBe(0)
|
||||
expect(calls.length).toBe(0)
|
||||
})
|
||||
|
||||
it('resolves direct journal_entry_id and falls back to voucher links', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: TX1, journal_entry_id: JE1 },
|
||||
{ id: TX2, journal_entry_id: null },
|
||||
],
|
||||
})
|
||||
// Voucher-link fallback runs only for the unbooked remainder (TX2).
|
||||
enqueue({ data: [{ transaction_id: TX2, journal_entry_id: JE2 }] })
|
||||
|
||||
const map = await resolveBookedJournalEntryIds(supabase as unknown as SupabaseClient, COMPANY, [
|
||||
TX1,
|
||||
TX2,
|
||||
])
|
||||
expect(map.get(TX1)).toBe(JE1)
|
||||
expect(map.get(TX2)).toBe(JE2)
|
||||
})
|
||||
|
||||
it('omits transactions booked by neither route', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: TX1, journal_entry_id: null }] })
|
||||
enqueue({ data: [] }) // no voucher links either
|
||||
|
||||
const map = await resolveBookedJournalEntryIds(supabase as unknown as SupabaseClient, COMPANY, [
|
||||
TX1,
|
||||
])
|
||||
expect(map.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('propagateUnderlagForBookedTransaction', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('links the document and stamps the matched item', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] }) // matched items
|
||||
enqueue({ data: { journal_entry_id: null } }) // doc not yet anchored
|
||||
enqueue({ data: null }) // stamp update
|
||||
|
||||
await propagateUnderlagForBookedTransaction(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
TX1,
|
||||
JE1,
|
||||
)
|
||||
|
||||
expect(linkToJournalEntry).toHaveBeenCalledWith(expect.anything(), COMPANY, 'doc-1', JE1)
|
||||
const stamps = findCalls('invoice_inbox_items', 'update')
|
||||
expect(stamps).toContainEqual([{ created_journal_entry_id: JE1 }])
|
||||
})
|
||||
|
||||
it('skips the document write when it already points at the verifikat', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] })
|
||||
enqueue({ data: { journal_entry_id: JE1 } }) // already anchored to JE1
|
||||
enqueue({ data: null }) // stamp update
|
||||
|
||||
await propagateUnderlagForBookedTransaction(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
TX1,
|
||||
JE1,
|
||||
)
|
||||
|
||||
expect(linkToJournalEntry).not.toHaveBeenCalled()
|
||||
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
|
||||
{ created_journal_entry_id: JE1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('never steals a document anchored to another verifikat, and does not stamp', async () => {
|
||||
// The doc stays on its verifikat, but THIS transaction's verifikat then
|
||||
// has no underlag from the item: stamping it consumed would hide the
|
||||
// mismatch from every future run and from manual reconciliation
|
||||
// (BFL 5 kap 6-7 §).
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] })
|
||||
enqueue({ data: { journal_entry_id: 'je-other' } })
|
||||
|
||||
await propagateUnderlagForBookedTransaction(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
TX1,
|
||||
JE1,
|
||||
)
|
||||
|
||||
expect(linkToJournalEntry).not.toHaveBeenCalled()
|
||||
expect(findCalls('invoice_inbox_items', 'update')).toEqual([])
|
||||
})
|
||||
|
||||
it('stamps an item without a document (no document read)', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'i1', document_id: null }] })
|
||||
enqueue({ data: null }) // stamp update
|
||||
|
||||
await propagateUnderlagForBookedTransaction(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
TX1,
|
||||
JE1,
|
||||
)
|
||||
|
||||
expect(linkToJournalEntry).not.toHaveBeenCalled()
|
||||
expect(findCalls('document_attachments', 'select').length).toBe(0)
|
||||
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
|
||||
{ created_journal_entry_id: JE1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('tolerates the UNIQUE violation when a sibling item already claimed the verifikat', async () => {
|
||||
// Samlingsverifikat: created_journal_entry_id is UNIQUE, so on N matched
|
||||
// items only the first stamp lands. The rest must resolve quietly: the
|
||||
// inbox list derives "booked" from the transaction's state for them.
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'i1', document_id: null }] })
|
||||
enqueue({ data: null, error: { code: '23505', message: 'duplicate key value' } })
|
||||
|
||||
await expect(
|
||||
propagateUnderlagForBookedTransaction(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
TX1,
|
||||
JE1,
|
||||
),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('does NOT stamp when the document link fails, so a re-run can still repair it', async () => {
|
||||
// Stamping over a failed link would hide the item from the
|
||||
// `.is('created_journal_entry_id', null)` query forever, leaving a
|
||||
// posted verifikation without its underlag reference (BFL 5 kap 6-7 §)
|
||||
// and nothing left to surface or repair it.
|
||||
vi.mocked(linkToJournalEntry).mockRejectedValueOnce(new Error('period locked'))
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] })
|
||||
enqueue({ data: { journal_entry_id: null } })
|
||||
|
||||
await expect(
|
||||
propagateUnderlagForBookedTransaction(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
TX1,
|
||||
JE1,
|
||||
),
|
||||
).resolves.toBeUndefined()
|
||||
expect(findCalls('invoice_inbox_items', 'update')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('completeInboxItemsForBookedTransaction', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('is a no-op for an unbooked transaction', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: TX1, journal_entry_id: null }] })
|
||||
enqueue({ data: [] }) // no voucher links
|
||||
|
||||
const result = await completeInboxItemsForBookedTransaction(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
TX1,
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
expect(findCalls('invoice_inbox_items', 'select').length).toBe(0)
|
||||
})
|
||||
|
||||
it('skips the transaction fetch when the caller passes the direct id', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: [] }) // matched items (none)
|
||||
|
||||
const result = await completeInboxItemsForBookedTransaction(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
TX1,
|
||||
{ directJournalEntryId: JE1 },
|
||||
)
|
||||
expect(result).toBe(JE1)
|
||||
expect(findCalls('transactions', 'select').length).toBe(0)
|
||||
})
|
||||
|
||||
it('resolves the samlingsverifikat through voucher links when the direct id is null', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ transaction_id: TX1, journal_entry_id: JE2 }] }) // voucher links
|
||||
enqueue({ data: [{ id: 'i1', document_id: null }] }) // matched items
|
||||
enqueue({ data: null }) // stamp update
|
||||
|
||||
const result = await completeInboxItemsForBookedTransaction(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
TX1,
|
||||
{ directJournalEntryId: null },
|
||||
)
|
||||
expect(result).toBe(JE2)
|
||||
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
|
||||
{ created_journal_entry_id: JE2 },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -29,7 +29,6 @@ import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import { renderChannelContextNotes } from '@/lib/documents/channel-context-notes'
|
||||
import {
|
||||
detectBookingDuplicate,
|
||||
@@ -37,6 +36,7 @@ import {
|
||||
type BookingDuplicateExclusions,
|
||||
} from '@/lib/transactions/booking-duplicate-detection'
|
||||
import { hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry'
|
||||
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
|
||||
import { appendProcessingHistory } from '@/lib/processing-history/append'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { InboxChannelContext, Transaction, TransactionCategory, EntityType, VatTreatment } from '@/types'
|
||||
@@ -375,57 +375,12 @@ export async function categorizeMatchedTransaction(
|
||||
return { error: 'Failed to update transaction', status: 500 }
|
||||
}
|
||||
|
||||
// Propagate the underlag from a matched invoice-inbox item onto the new
|
||||
// verifikation. Without this, BFL 7 kap is violated: a verifikation exists
|
||||
// with no underlag attached even though the user explicitly linked an inbox
|
||||
// item (with a document) to this transaction. We:
|
||||
// 1. find the inbox item(s) where matched_transaction_id = txId
|
||||
// 2. for each item with a document_id, set
|
||||
// document_attachments.journal_entry_id = journalEntryId (idempotent)
|
||||
// 3. stamp invoice_inbox_items.created_journal_entry_id so the inbox row
|
||||
// visibly moves to "Bearbetade" and shows "Öppna verifikation".
|
||||
// Errors are logged but don't fail the commit: the verifikation itself is
|
||||
// already posted, and the link can be repaired by re-running this step.
|
||||
// Propagate the underlag from matched invoice-inbox items onto the new
|
||||
// verifikation and stamp them consumed (BFL 7 kap): shared with the other
|
||||
// booking paths, see lib/transactions/inbox-underlag.ts. Best-effort: the
|
||||
// verifikation is already posted, so a failure is logged, never fatal.
|
||||
if (journalEntryId) {
|
||||
try {
|
||||
const { data: matchedInboxItems } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('matched_transaction_id', txId)
|
||||
.is('created_journal_entry_id', null)
|
||||
for (const inbox of (matchedInboxItems ?? []) as Array<{
|
||||
id: string
|
||||
document_id: string | null
|
||||
}>) {
|
||||
if (inbox.document_id) {
|
||||
try {
|
||||
await linkToJournalEntry(supabase, companyId, inbox.document_id, journalEntryId)
|
||||
} catch (err) {
|
||||
log.error('Failed to link inbox document to journal entry', {
|
||||
inbox_item_id: inbox.id,
|
||||
document_id: inbox.document_id,
|
||||
journal_entry_id: journalEntryId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
const { error: stampError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ created_journal_entry_id: journalEntryId })
|
||||
.eq('id', inbox.id)
|
||||
.eq('company_id', companyId)
|
||||
if (stampError) {
|
||||
log.error('Failed to stamp inbox item created_journal_entry_id', {
|
||||
inbox_item_id: inbox.id,
|
||||
journal_entry_id: journalEntryId,
|
||||
error: stampError.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Failed to propagate underlag from matched inbox items', err)
|
||||
}
|
||||
await propagateUnderlagForBookedTransaction(supabase, companyId, txId, journalEntryId)
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Inbox-underlag lifecycle for booked bank transactions.
|
||||
*
|
||||
* An invoice_inbox_items row leaves the active inbox ("Att göra") only when a
|
||||
* journal entry consumes it: created_journal_entry_id (or
|
||||
* created_supplier_invoice_id) is what deriveInboxStatus and the count pills
|
||||
* read. Historically only categorizeTransactionCore stamped that column, so a
|
||||
* matched item whose transaction was booked through any OTHER path (the /book
|
||||
* route, bulk-book, link-to-existing-voucher, or an attach that landed after
|
||||
* booking) stayed "linked" forever, pointing at a transaction that had left
|
||||
* the transactions work list (the 2026-08-12 user report).
|
||||
*
|
||||
* This module is the single implementation all booking and attach paths share:
|
||||
*
|
||||
* - resolveBookedJournalEntryIds: which verifikat anchors each transaction,
|
||||
* covering both direct journal_entry_id and the bulk-book
|
||||
* transaction_voucher_links shape (see lib/transactions/is-booked.ts for
|
||||
* why the column alone is not "booked").
|
||||
* - propagateUnderlagForBookedTransaction: link matched items' documents to
|
||||
* the verifikat (BFL 5 kap 6 §: the verifikation must reference its
|
||||
* underlag) and stamp created_journal_entry_id.
|
||||
* - completeInboxItemsForBookedTransaction: the attach-time entry point that
|
||||
* resolves first and propagates only when the transaction is booked.
|
||||
*
|
||||
* Everything here is best-effort by contract: the verifikat is already posted
|
||||
* when these run, so a failure is logged and repaired by re-running, never
|
||||
* allowed to roll back a compliant booking. Note that
|
||||
* invoice_inbox_items.created_journal_entry_id is UNIQUE (migration
|
||||
* 20260515090000): when several items share one samlingsverifikat only the
|
||||
* first stamp can land, so the stamp is a fast path, not the source of truth:
|
||||
* the inbox list ALSO derives "booked" from the matched transaction's state
|
||||
* via resolveBookedJournalEntryIds (GET /items enrichment).
|
||||
*/
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('transactions/inbox-underlag')
|
||||
|
||||
/** Postgres unique_violation: a sibling item already claimed this verifikat. */
|
||||
const UNIQUE_VIOLATION = '23505'
|
||||
|
||||
/**
|
||||
* Map each booked transaction id to the journal entry that anchors it:
|
||||
* transactions.journal_entry_id first, then transaction_voucher_links
|
||||
* (the N-tx-to-1-JE bulk-book shape). Unbooked transactions are absent
|
||||
* from the returned map.
|
||||
*
|
||||
* The multi-allocation payment shape (invoice_payments /
|
||||
* supplier_invoice_payments) is deliberately not resolved here: those flows
|
||||
* consume inbox items through their own supplier-invoice lifecycle
|
||||
* (created_supplier_invoice_id), not through this one.
|
||||
*/
|
||||
export async function resolveBookedJournalEntryIds(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
txIds: string[],
|
||||
): Promise<Map<string, string>> {
|
||||
const map = new Map<string, string>()
|
||||
if (txIds.length === 0) return map
|
||||
|
||||
const { data: txs, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, journal_entry_id')
|
||||
.in('id', txIds)
|
||||
.eq('company_id', companyId)
|
||||
if (txError) {
|
||||
log.error('Failed to resolve transactions for booked-entry lookup', {
|
||||
company_id: companyId,
|
||||
error: txError.message,
|
||||
})
|
||||
return map
|
||||
}
|
||||
const unbooked: string[] = []
|
||||
for (const tx of (txs ?? []) as Array<{ id: string; journal_entry_id: string | null }>) {
|
||||
if (tx.journal_entry_id) map.set(tx.id, tx.journal_entry_id)
|
||||
else unbooked.push(tx.id)
|
||||
}
|
||||
if (unbooked.length === 0) return map
|
||||
|
||||
const voucherLinked = await resolveVoucherLinkedEntryIds(supabase, companyId, unbooked)
|
||||
for (const [txId, journalEntryId] of voucherLinked) {
|
||||
if (!map.has(txId)) map.set(txId, journalEntryId)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* The transaction_voucher_links leg of the resolution alone: for callers that
|
||||
* already hold transactions.journal_entry_id and only need the bulk-book
|
||||
* fallback.
|
||||
*/
|
||||
export async function resolveVoucherLinkedEntryIds(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
txIds: string[],
|
||||
): Promise<Map<string, string>> {
|
||||
const map = new Map<string, string>()
|
||||
if (txIds.length === 0) return map
|
||||
const { data: links, error: linkError } = await supabase
|
||||
.from('transaction_voucher_links')
|
||||
.select('transaction_id, journal_entry_id')
|
||||
.in('transaction_id', txIds)
|
||||
.eq('company_id', companyId)
|
||||
if (linkError) {
|
||||
log.error('Failed to resolve voucher links for booked-entry lookup', {
|
||||
company_id: companyId,
|
||||
error: linkError.message,
|
||||
})
|
||||
return map
|
||||
}
|
||||
for (const link of (links ?? []) as Array<{ transaction_id: string; journal_entry_id: string }>) {
|
||||
if (!map.has(link.transaction_id)) map.set(link.transaction_id, link.journal_entry_id)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagate the underlag from matched invoice-inbox items onto the verifikat
|
||||
* that booked their transaction. Without this, BFL 7 kap is violated: a
|
||||
* verifikation exists with no underlag attached even though the user
|
||||
* explicitly linked an inbox item (with a document) to this transaction. We:
|
||||
* 1. find the inbox item(s) where matched_transaction_id = txId that no
|
||||
* journal entry or supplier invoice has consumed yet
|
||||
* 2. for each item with a document_id, set
|
||||
* document_attachments.journal_entry_id = journalEntryId, skipped when
|
||||
* the document already points at a verifikat: a same-value rewrite would
|
||||
* trip the period-lock trigger, and a different verifikat's underlag is
|
||||
* never stolen
|
||||
* 3. stamp invoice_inbox_items.created_journal_entry_id so the inbox row
|
||||
* visibly moves to "Bokförda" and shows "Öppna verifikation"
|
||||
* Errors are logged but never fail the caller: the verifikation itself is
|
||||
* already posted, and the link can be repaired by re-running this step.
|
||||
*/
|
||||
export async function propagateUnderlagForBookedTransaction(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
txId: string,
|
||||
journalEntryId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { data: matchedInboxItems } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('matched_transaction_id', txId)
|
||||
.is('created_journal_entry_id', null)
|
||||
.is('created_supplier_invoice_id', null)
|
||||
for (const inbox of (matchedInboxItems ?? []) as Array<{
|
||||
id: string
|
||||
document_id: string | null
|
||||
}>) {
|
||||
// Whether this item's underlag actually references a verifikat. The
|
||||
// stamp below is conditional on it: stamping after a FAILED document
|
||||
// link would hide the item from every future run of this same query
|
||||
// (.is('created_journal_entry_id', null)), making the promised
|
||||
// "repaired by re-running" impossible and leaving a posted
|
||||
// verifikation with no underlag reference (BFL 5 kap 6-7 §) that
|
||||
// nothing surfaces anymore.
|
||||
let underlagSettled = true
|
||||
if (inbox.document_id) {
|
||||
const { data: doc } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('journal_entry_id')
|
||||
.eq('id', inbox.document_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
const currentDocEntryId = (doc?.journal_entry_id as string | null) ?? null
|
||||
if (currentDocEntryId === null) {
|
||||
try {
|
||||
await linkToJournalEntry(supabase, companyId, inbox.document_id, journalEntryId)
|
||||
} catch (err) {
|
||||
underlagSettled = false
|
||||
log.error('Failed to link inbox document to journal entry', {
|
||||
inbox_item_id: inbox.id,
|
||||
document_id: inbox.document_id,
|
||||
journal_entry_id: journalEntryId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
} else if (currentDocEntryId !== journalEntryId) {
|
||||
// Anchored to another verifikat: preserved, never stolen. But the
|
||||
// verifikat that booked THIS transaction then has no underlag
|
||||
// reference from this item, so it must NOT be stamped consumed:
|
||||
// stamping would hide the very signal that the mismatch needs a
|
||||
// human (BFL 5 kap 6-7 §).
|
||||
underlagSettled = false
|
||||
log.warn('Inbox document already anchored to another verifikat; leaving it', {
|
||||
inbox_item_id: inbox.id,
|
||||
document_id: inbox.document_id,
|
||||
document_journal_entry_id: currentDocEntryId,
|
||||
journal_entry_id: journalEntryId,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!underlagSettled) continue
|
||||
// CAS on the null predicate so a concurrent stamp stays a no-op, and
|
||||
// unique_violation tolerated: on a samlingsverifikat only one item can
|
||||
// hold the UNIQUE created_journal_entry_id, and the inbox list derives
|
||||
// "booked" from the transaction's state for the rest.
|
||||
const { error: stampError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ created_journal_entry_id: journalEntryId })
|
||||
.eq('id', inbox.id)
|
||||
.eq('company_id', companyId)
|
||||
.is('created_journal_entry_id', null)
|
||||
if (stampError && stampError.code !== UNIQUE_VIOLATION) {
|
||||
log.error('Failed to stamp inbox item created_journal_entry_id', {
|
||||
inbox_item_id: inbox.id,
|
||||
journal_entry_id: journalEntryId,
|
||||
error: stampError.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Failed to propagate underlag from matched inbox items', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach-time entry point: when a document lands on (or an item is matched to)
|
||||
* a transaction that is ALREADY booked, resolve the anchoring verifikat and
|
||||
* complete the matched inbox items against it. No-op for unbooked
|
||||
* transactions: the booking paths call propagateUnderlagForBookedTransaction
|
||||
* themselves when the verifikat is created later.
|
||||
*
|
||||
* Callers that already read transactions.journal_entry_id pass it via
|
||||
* `directJournalEntryId` (null meaning "the column is null") to skip the
|
||||
* redundant transaction fetch; the voucher-link fallback still runs then.
|
||||
*
|
||||
* Returns the resolved journal entry id, or null when the transaction is not
|
||||
* booked.
|
||||
*/
|
||||
export async function completeInboxItemsForBookedTransaction(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
txId: string,
|
||||
opts?: { directJournalEntryId: string | null },
|
||||
): Promise<string | null> {
|
||||
let journalEntryId: string | null
|
||||
if (opts) {
|
||||
journalEntryId =
|
||||
opts.directJournalEntryId ??
|
||||
(await resolveVoucherLinkedEntryIds(supabase, companyId, [txId])).get(txId) ??
|
||||
null
|
||||
} else {
|
||||
journalEntryId =
|
||||
(await resolveBookedJournalEntryIds(supabase, companyId, [txId])).get(txId) ?? null
|
||||
}
|
||||
if (!journalEntryId) return null
|
||||
await propagateUnderlagForBookedTransaction(supabase, companyId, txId, journalEntryId)
|
||||
return journalEntryId
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { eventBus } from '@/lib/events/bus'
|
||||
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
|
||||
import { paidAtFromDate } from '@/lib/invoices/paid-at'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { Invoice, Transaction } from '@/types'
|
||||
|
||||
@@ -415,6 +416,11 @@ export async function linkTransactionToJournalEntry(
|
||||
}
|
||||
}
|
||||
|
||||
// The transaction is now anchored to an existing verifikat: complete any
|
||||
// matched inbox items against it (underlag link + consumed stamp) so they
|
||||
// leave the active inbox. Best-effort, logged inside.
|
||||
await propagateUnderlagForBookedTransaction(supabase, companyId, transactionId, journalEntryId)
|
||||
|
||||
logMatchEvent(supabase, userId, transactionId, 'linked_to_existing_voucher', {
|
||||
invoiceId,
|
||||
newState: {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Backfill for inbox items stranded as "linked" on an already-booked
|
||||
* transaction (2026-08-12 report: booked verifikat stuck in "Att göra").
|
||||
*
|
||||
* The inbox list now derives "booked" from the matched transaction's own
|
||||
* state, so the DISPLAY heals by itself. What this script repairs is the
|
||||
* data left behind by the old write paths:
|
||||
*
|
||||
* - document_attachments.journal_entry_id: the underlag of a matched item
|
||||
* was never linked to the verifikat that booked its transaction
|
||||
* (BFL 5 kap 6 §). Linked here unless the document is already anchored.
|
||||
* - invoice_inbox_items.created_journal_entry_id: stamped where the UNIQUE
|
||||
* 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.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/backfill-inbox-booked-underlag.ts # dry-run (default)
|
||||
* npx tsx scripts/backfill-inbox-booked-underlag.ts --execute # apply
|
||||
*
|
||||
* DRY-RUN IS THE DEFAULT. Point NEXT_PUBLIC_SUPABASE_URL /
|
||||
* SUPABASE_SERVICE_ROLE_KEY (.env.local) at staging first; prod only after
|
||||
* explicit confirmation.
|
||||
*
|
||||
* Period locks: the document link goes through linkToJournalEntry, whose
|
||||
* document_attachments.journal_entry_id UPDATE is guarded by the
|
||||
* enforce_period_lock DB trigger. Table triggers fire for service-role
|
||||
* writes too (service role bypasses RLS, never triggers), so a locked
|
||||
* period fails that item with a logged error instead of writing into it.
|
||||
*/
|
||||
|
||||
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'
|
||||
|
||||
const EXECUTE = process.argv.includes('--execute')
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !serviceRoleKey) {
|
||||
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
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)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${EXECUTE ? 'Repaired' : 'Would repair'} ${strandedOnBooked} item(s) across ${companiesTouched} company/companies.`,
|
||||
)
|
||||
if (!EXECUTE) console.log('Re-run with --execute to apply.')
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user