* 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>
150 lines
5.8 KiB
TypeScript
150 lines
5.8 KiB
TypeScript
#!/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)
|
|
})
|