fix(transactions): anchor the pinned document to the verifikat on booking (#1560)
A document pinned to a transaction (transactions.document_id) with no unconsumed inbox item was never anchored onto the verifikat when the transaction was booked: document_attachments.journal_entry_id stayed null and every underlag surface reported "Underlag saknas" for a booking that HAS its underlag (attach-before-book via the manual booking dialog, the 2026-08-13 user report). PR #1547 already routed /book, bulk-book and categorize through the shared propagateUnderlagForBookedTransaction helper, but that helper only walked matched inbox items. This adds a pinned-document leg to the helper, so all booking paths anchor the pin in one place: - the pin is read fresh inside the helper (not from the caller's pre-booking snapshot) so a concurrent attach is still anchored - same guard semantics as inbox docs, via the extracted anchorDocumentToJournalEntry: no-op when already anchored to this verifikat, never steal another verifikat's underlag, log-and-continue on failure (the booking is already posted; a re-run repairs the link) - the bulk-book RPC already anchors pins atomically, so the leg no-ops there Route tests cover the three plan cases: pinned doc anchored, matched inbox item stamped, and propagation failure never failing the booking. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
314efe8b22
commit
d9dddba682
@@ -1024,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: { document_id: null }, error: null }) // completion: tx pin lookup
|
||||
enqueue({ data: [], error: null }) // completion: matched inbox items (none)
|
||||
enqueue({ data: null, error: null }) // dispatcher commit update
|
||||
|
||||
@@ -1048,6 +1049,7 @@ describe('commitPendingOperation: attach_document_to_transaction', () => {
|
||||
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: { document_id: null }, error: null }) // propagation: tx pin lookup
|
||||
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
|
||||
|
||||
@@ -519,14 +519,15 @@ describe('bulkBookMatchedInboxItems: WhatsApp channel-context notes threading',
|
||||
})
|
||||
|
||||
describe('bulkBookMatchedInboxItems: intra-batch duplicate handling', () => {
|
||||
/** Six queued from() results for one successfully-booked item. */
|
||||
/** Seven queued from() results for one successfully-booked item. */
|
||||
const bookableItem = (itemId: string, txId: string, amount: number) => [
|
||||
{ data: { id: itemId, matched_transaction_id: txId, created_journal_entry_id: null, created_supplier_invoice_id: null } },
|
||||
{ data: { id: txId, date: '2026-06-01', amount, currency: 'SEK', cash_account_id: null, journal_entry_id: null } },
|
||||
{ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } },
|
||||
{ data: [{ id: 'fp-1' }] },
|
||||
{ error: null },
|
||||
{ data: [] },
|
||||
{ data: { document_id: null } }, // propagation: tx pin lookup
|
||||
{ data: [] }, // propagation: matched inbox items
|
||||
]
|
||||
|
||||
it('books BOTH distinct transactions that share (date, amount) in one bulk run', async () => {
|
||||
|
||||
@@ -65,6 +65,7 @@ describe('propagateUnderlagForBookedTransaction', () => {
|
||||
|
||||
it('links the document and stamps the matched item', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { document_id: null } }) // tx pin lookup: nothing pinned
|
||||
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
|
||||
@@ -83,6 +84,7 @@ describe('propagateUnderlagForBookedTransaction', () => {
|
||||
|
||||
it('skips the document write when it already points at the verifikat', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { document_id: null } }) // tx pin lookup: nothing pinned
|
||||
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] })
|
||||
enqueue({ data: { journal_entry_id: JE1 } }) // already anchored to JE1
|
||||
enqueue({ data: null }) // stamp update
|
||||
@@ -106,6 +108,7 @@ describe('propagateUnderlagForBookedTransaction', () => {
|
||||
// mismatch from every future run and from manual reconciliation
|
||||
// (BFL 5 kap 6-7 §).
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { document_id: null } }) // tx pin lookup: nothing pinned
|
||||
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] })
|
||||
enqueue({ data: { journal_entry_id: 'je-other' } })
|
||||
|
||||
@@ -122,6 +125,7 @@ describe('propagateUnderlagForBookedTransaction', () => {
|
||||
|
||||
it('stamps an item without a document (no document read)', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { document_id: null } }) // tx pin lookup: nothing pinned
|
||||
enqueue({ data: [{ id: 'i1', document_id: null }] })
|
||||
enqueue({ data: null }) // stamp update
|
||||
|
||||
@@ -144,6 +148,7 @@ describe('propagateUnderlagForBookedTransaction', () => {
|
||||
// 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: { document_id: null } }) // tx pin lookup: nothing pinned
|
||||
enqueue({ data: [{ id: 'i1', document_id: null }] })
|
||||
enqueue({ data: null, error: { code: '23505', message: 'duplicate key value' } })
|
||||
|
||||
@@ -164,6 +169,7 @@ describe('propagateUnderlagForBookedTransaction', () => {
|
||||
// and nothing left to surface or repair it.
|
||||
vi.mocked(linkToJournalEntry).mockRejectedValueOnce(new Error('period locked'))
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { document_id: null } }) // tx pin lookup: nothing pinned
|
||||
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] })
|
||||
enqueue({ data: { journal_entry_id: null } })
|
||||
|
||||
@@ -177,6 +183,102 @@ describe('propagateUnderlagForBookedTransaction', () => {
|
||||
).resolves.toBeUndefined()
|
||||
expect(findCalls('invoice_inbox_items', 'update')).toEqual([])
|
||||
})
|
||||
|
||||
// ── The transaction's own pinned document (transactions.document_id) ──
|
||||
// A doc attached directly to the transaction has no inbox item to carry
|
||||
// it, so the propagation must anchor it itself: this was the 2026-08-13
|
||||
// "Underlag saknas" gap (attach-before-book via the manual booking dialog).
|
||||
|
||||
it('anchors the pinned document even when no inbox item exists', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { document_id: 'doc-pin' } }) // tx pin lookup
|
||||
enqueue({ data: { journal_entry_id: null } }) // pinned doc not yet anchored
|
||||
enqueue({ data: [] }) // no matched inbox items
|
||||
|
||||
await propagateUnderlagForBookedTransaction(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
TX1,
|
||||
JE1,
|
||||
)
|
||||
|
||||
expect(linkToJournalEntry).toHaveBeenCalledWith(expect.anything(), COMPANY, 'doc-pin', JE1)
|
||||
expect(findCalls('invoice_inbox_items', 'update')).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a pinned document that already points at this verifikat alone', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { document_id: 'doc-pin' } })
|
||||
enqueue({ data: { journal_entry_id: JE1 } }) // already anchored here
|
||||
enqueue({ data: [] })
|
||||
|
||||
await propagateUnderlagForBookedTransaction(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
TX1,
|
||||
JE1,
|
||||
)
|
||||
|
||||
expect(linkToJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never steals a pinned document anchored to another verifikat', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { document_id: 'doc-pin' } })
|
||||
enqueue({ data: { journal_entry_id: 'je-other' } })
|
||||
enqueue({ data: [] })
|
||||
|
||||
await propagateUnderlagForBookedTransaction(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
TX1,
|
||||
JE1,
|
||||
)
|
||||
|
||||
expect(linkToJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still processes inbox items when the pinned-document link fails', async () => {
|
||||
vi.mocked(linkToJournalEntry).mockRejectedValueOnce(new Error('period locked'))
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { document_id: 'doc-pin' } })
|
||||
enqueue({ data: { journal_entry_id: null } }) // pinned doc; link will throw
|
||||
enqueue({ data: [{ id: 'i1', document_id: null }] }) // docless matched item
|
||||
enqueue({ data: null }) // stamp update
|
||||
|
||||
await expect(
|
||||
propagateUnderlagForBookedTransaction(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
TX1,
|
||||
JE1,
|
||||
),
|
||||
).resolves.toBeUndefined()
|
||||
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
|
||||
{ created_journal_entry_id: JE1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('links a document shared by the pin and a matched item only once', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { document_id: 'doc-1' } }) // pin points at the same doc
|
||||
enqueue({ data: { journal_entry_id: null } }) // pin leg links it
|
||||
enqueue({ data: [{ id: 'i1', document_id: 'doc-1' }] }) // matched item, same doc
|
||||
enqueue({ data: { journal_entry_id: JE1 } }) // item leg finds it anchored
|
||||
enqueue({ data: null }) // stamp update
|
||||
|
||||
await propagateUnderlagForBookedTransaction(
|
||||
supabase as unknown as SupabaseClient,
|
||||
COMPANY,
|
||||
TX1,
|
||||
JE1,
|
||||
)
|
||||
|
||||
expect(linkToJournalEntry).toHaveBeenCalledTimes(1)
|
||||
expect(findCalls('invoice_inbox_items', 'update')).toContainEqual([
|
||||
{ created_journal_entry_id: JE1 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('completeInboxItemsForBookedTransaction', () => {
|
||||
@@ -196,8 +298,9 @@ describe('completeInboxItemsForBookedTransaction', () => {
|
||||
expect(findCalls('invoice_inbox_items', 'select').length).toBe(0)
|
||||
})
|
||||
|
||||
it('skips the transaction fetch when the caller passes the direct id', async () => {
|
||||
it('skips the resolution fetch when the caller passes the direct id', async () => {
|
||||
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
||||
enqueue({ data: { document_id: null } }) // tx pin lookup inside propagate
|
||||
enqueue({ data: [] }) // matched items (none)
|
||||
|
||||
const result = await completeInboxItemsForBookedTransaction(
|
||||
@@ -207,12 +310,15 @@ describe('completeInboxItemsForBookedTransaction', () => {
|
||||
{ directJournalEntryId: JE1 },
|
||||
)
|
||||
expect(result).toBe(JE1)
|
||||
expect(findCalls('transactions', 'select').length).toBe(0)
|
||||
// The only transactions read is the pin lookup inside the propagation:
|
||||
// the id/journal_entry_id resolution query never runs.
|
||||
expect(findCalls('transactions', 'select')).toEqual([['document_id']])
|
||||
})
|
||||
|
||||
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: { document_id: null } }) // tx pin lookup inside propagate
|
||||
enqueue({ data: [{ id: 'i1', document_id: null }] }) // matched items
|
||||
enqueue({ data: null }) // stamp update
|
||||
|
||||
|
||||
@@ -16,9 +16,14 @@
|
||||
* 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
|
||||
* - propagateUnderlagForBookedTransaction: anchor the transaction's pinned
|
||||
* document (transactions.document_id) and link matched items' documents to
|
||||
* the verifikat (BFL 5 kap 6 §: the verifikation must reference its
|
||||
* underlag) and stamp created_journal_entry_id.
|
||||
* underlag), stamping created_journal_entry_id on the items. The pinned-doc
|
||||
* leg matters because a document attached directly to a transaction has no
|
||||
* inbox item to carry it: without it, booking through the manual dialog
|
||||
* left document_attachments.journal_entry_id null and every underlag
|
||||
* surface read "Underlag saknas" (the 2026-08-13 user report).
|
||||
* - completeInboxItemsForBookedTransaction: the attach-time entry point that
|
||||
* resolves first and propagates only when the transaction is booked.
|
||||
*
|
||||
@@ -116,18 +121,69 @@ export async function resolveVoucherLinkedEntryIds(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Anchor one document to the verifikat, with the guard semantics every
|
||||
* booking path shares: a document already pointing at THIS verifikat is a
|
||||
* no-op (a same-value rewrite would trip the period-lock trigger), a document
|
||||
* anchored to ANOTHER verifikat is never stolen, and a failed link is
|
||||
* reported so the caller can withhold any consumed-stamp. Returns true when
|
||||
* the document ends up referencing the verifikat.
|
||||
*/
|
||||
async function anchorDocumentToJournalEntry(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
documentId: string,
|
||||
journalEntryId: string,
|
||||
logContext: Record<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
const { data: doc } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('journal_entry_id')
|
||||
.eq('id', documentId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
const currentDocEntryId = (doc?.journal_entry_id as string | null) ?? null
|
||||
if (currentDocEntryId === journalEntryId) return true
|
||||
if (currentDocEntryId !== null) {
|
||||
// Anchored to another verifikat: preserved, never stolen (BFL 5 kap 6-7 §).
|
||||
log.warn('Document already anchored to another verifikat; leaving it', {
|
||||
...logContext,
|
||||
document_id: documentId,
|
||||
document_journal_entry_id: currentDocEntryId,
|
||||
journal_entry_id: journalEntryId,
|
||||
})
|
||||
return false
|
||||
}
|
||||
try {
|
||||
await linkToJournalEntry(supabase, companyId, documentId, journalEntryId)
|
||||
return true
|
||||
} catch (err) {
|
||||
log.error('Failed to link document to journal entry', {
|
||||
...logContext,
|
||||
document_id: documentId,
|
||||
journal_entry_id: journalEntryId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagate the underlag onto the verifikat that booked a transaction.
|
||||
* Without this, BFL 7 kap is violated: a verifikation exists with no underlag
|
||||
* attached even though the user explicitly linked a document (or an inbox
|
||||
* item with a document) to this transaction. We:
|
||||
* 1. anchor the transaction's own pinned document (transactions.document_id)
|
||||
* when it does not reference a verifikat yet: a document attached
|
||||
* directly to the transaction has no inbox item, so nothing else carries
|
||||
* it onto the verifikat
|
||||
* 2. 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
|
||||
* 3. 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
|
||||
* 4. 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.
|
||||
@@ -139,6 +195,24 @@ export async function propagateUnderlagForBookedTransaction(
|
||||
journalEntryId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// The pin is read fresh here (not passed in from the caller's pre-booking
|
||||
// snapshot) so an attach that lands concurrently with the booking is
|
||||
// still anchored. The bulk-book RPC already anchors pins atomically;
|
||||
// there this read finds the doc pointing at the same verifikat and no-ops.
|
||||
const { data: tx } = await supabase
|
||||
.from('transactions')
|
||||
.select('document_id')
|
||||
.eq('id', txId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
const pinnedDocumentId = (tx?.document_id as string | null) ?? null
|
||||
if (pinnedDocumentId) {
|
||||
await anchorDocumentToJournalEntry(supabase, companyId, pinnedDocumentId, journalEntryId, {
|
||||
transaction_id: txId,
|
||||
source: 'transaction_pin',
|
||||
})
|
||||
}
|
||||
|
||||
const { data: matchedInboxItems } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id, document_id')
|
||||
@@ -156,42 +230,18 @@ export async function propagateUnderlagForBookedTransaction(
|
||||
// (.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.
|
||||
// nothing surfaces anymore. Similarly, an item whose document is
|
||||
// anchored to a DIFFERENT verifikat is not stamped: that would hide
|
||||
// the very signal that the mismatch needs a human.
|
||||
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,
|
||||
})
|
||||
}
|
||||
underlagSettled = await anchorDocumentToJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
inbox.document_id,
|
||||
journalEntryId,
|
||||
{ inbox_item_id: inbox.id, source: 'inbox_match' },
|
||||
)
|
||||
}
|
||||
if (!underlagSettled) continue
|
||||
// CAS on the null predicate so a concurrent stamp stays a no-op, and
|
||||
|
||||
Reference in New Issue
Block a user