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
@@ -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