Files
accounted/lib/transactions/__tests__/inbox-underlag.test.ts
T
MattssonandClaude Fable 5 8d56219c31 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>
2026-08-13 00:49:20 +02:00

231 lines
8.2 KiB
TypeScript

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 },
])
})
})