feat(agent): offer unmatched inbox receipts as confirmable underlag (#1436)

The originally reported scenario is still broken after #1425 and its
backfill-by-document_id: a user photographs a receipt into WhatsApp, answers
the bot's questions, then opens the app, clicks the bank transaction and asks
the assistant to book it, and is told "UNDERLAG: saknas" about a receipt we
are holding, then asked everything again.

WhatsApp intake writes neither invoice_inbox_items.matched_transaction_id
(process-inbound.ts passes uploadAndExtract's matchedTransactionId as
undefined) nor transactions.document_id (that mirror is written by the manual
match route). Only TransactionMatchPicker fills either column. So the underlag
list comes back empty, and a backfill that keys on document_id has nothing to
key on.

Unmatched, unconsumed inbox items are now scored against the transaction with
the same pure scorer the picker uses and the strongest few are surfaced as
TROLIGT UNDERLAG, carrying their captured chat answers.

Proposals only: nothing writes matched_transaction_id, and the prompt tells
the agent to get the match confirmed and to book only against a confirmed one.
Setting the link at intake above a confidence bar is the obvious alternative
and is deliberately left open.

An uncomparable amount (cross-currency with no rate) disqualifies a candidate,
because calculateMatchConfidence drops the amount signal in that case and date
+ merchant alone then score a confident match nobody checked the sums for.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-06 14:07:30 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Opus 5
parent 0f7147a078
commit 43386b4852
6 changed files with 630 additions and 2 deletions
@@ -0,0 +1,188 @@
import { describe, it, expect } from 'vitest'
import { transactionCategorization } from '../transaction-categorization'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* The reported bug, end to end.
*
* A user photographs a restaurant receipt into WhatsApp. The bot asks who was
* there and why, the user answers, and the answer is stored on the inbox item's
* channel_context. The user then opens the app, clicks the bank transaction and
* asks the assistant to book it, and is asked the same question again.
*
* Two independent causes, both covered here:
* 1. The capture never selected channel_context, so the answers were invisible.
* 2. The capture finds underlag only via matched_transaction_id, and WhatsApp
* intake never sets that column, so for a chat-captured receipt there was
* nothing to attach the answers to in the first place.
*/
const TX_ID = '11111111-1111-1111-1111-111111111111'
const COMPANY_ID = '22222222-2222-2222-2222-222222222222'
const TX_ROW = {
id: TX_ID,
date: '2026-05-12',
description: 'ESPRESSO HOUSE 1234 STOCKHOLM',
merchant_name: 'Espresso House',
amount: -184,
currency: 'SEK',
amount_sek: null,
exchange_rate: null,
document_id: null,
journal_entry_id: null,
}
const ANSWERED_REPRESENTATION = {
channel: 'whatsapp',
representation: {
participants: [{ name: 'Anna Berg', company: 'Volvo' }],
purpose: 'kundmöte om Q3-leveransen',
event_date: null,
raw_answer: 'jag och Anna Berg från Volvo, kundmöte',
answered_at: '2026-05-12T13:00:00Z',
},
}
const WHATSAPP_ITEM = {
id: 'item-1',
document_id: 'doc-1',
extracted_data: {
supplier: { name: 'Espresso House' },
invoice: { invoiceDate: '2026-05-12', currency: 'SEK' },
totals: { total: 184, vatAmount: 22 },
},
channel_context: ANSWERED_REPRESENTATION,
}
/**
* Drive the real capture. Queue order matches the query order in capture():
* transaction, receipts, matched inbox items, then the candidate scan (only
* reached when nothing was linked).
*/
async function captureWith(opts: {
matchedItems?: unknown[]
unmatchedItems?: unknown[]
}) {
const { supabase, enqueueMany } = createQueuedMockSupabase()
const matched = opts.matchedItems ?? []
enqueueMany([
{ data: TX_ROW },
{ data: [] },
{ data: matched },
// #1425's backfill-by-document_id issues a query only when an underlag was
// found and lacks chat_answers; the candidate scan runs only when nothing
// was found at all, so exactly one of the two consumes this slot.
...(matched.length > 0 ? [{ data: [] }] : [{ data: opts.unmatchedItems ?? [] }]),
])
return transactionCategorization.capture(
{ transaction_id: TX_ID },
{
supabase: supabase as unknown as SupabaseClient,
userId: 'user-1',
companyId: COMPANY_ID,
},
)
}
function render(captured: Awaited<ReturnType<typeof captureWith>>) {
return transactionCategorization.promptTemplate({
captured,
profileSummary: null,
activeMemory: [],
})
}
describe('ask-once: WhatsApp answers reach the in-app assistant', () => {
it('carries answers from a matched WhatsApp underlag into the prompt', async () => {
const captured = await captureWith({ matchedItems: [WHATSAPP_ITEM] })
const out = render(captured)
expect(out).toContain('deltagare (uppgivna av användaren)')
expect(out).toContain('Anna Berg (Volvo)')
expect(out).toContain('kundmöte om Q3-leveransen')
// and the instruction that makes the agent act on them
expect(out).toContain('Fråga ALDRIG om något som redan står där')
})
it('finds an UNMATCHED WhatsApp receipt, which is the reported scenario', async () => {
// matched_transaction_id is NULL on every WhatsApp item, so the matched
// query returns nothing and the candidate scan is what saves the user.
const captured = await captureWith({ matchedItems: [], unmatchedItems: [WHATSAPP_ITEM] })
expect(captured.underlag).toHaveLength(1)
expect(captured.underlag[0].match).toBe('candidate')
const out = render(captured)
expect(out).not.toContain('UNDERLAG: saknas')
expect(out).toContain('TROLIGT UNDERLAG')
expect(out).toContain('Anna Berg (Volvo)')
// A candidate is a proposal, not a link: the agent must get it confirmed.
expect(out).toContain('en människa måste bekräfta kopplingen')
})
it('does not re-ask about representation after the user answered "nej"', async () => {
const denied = {
...WHATSAPP_ITEM,
channel_context: {
channel: 'whatsapp',
representation: {
participants: [],
purpose: null,
event_date: null,
raw_answer: 'nej',
answered_at: '2026-05-12T13:00:00Z',
denied: true,
},
},
}
const captured = await captureWith({ matchedItems: [denied] })
const out = render(captured)
// The verifikat renderer produces nothing at all for a denial, which is
// why the prompt is built from the structured record instead.
expect(out).toContain('INTE representation')
})
it('still asks when nothing has been captured', async () => {
const captured = await captureWith({
matchedItems: [
{ id: 'item-2', document_id: 'doc-2', extracted_data: WHATSAPP_ITEM.extracted_data, channel_context: null },
],
})
const out = render(captured)
// No captured answer, so no data line. ("REDAN BESVARAT" still appears in
// the standing instruction, hence matching on the rendered marker.)
expect(out).not.toContain('uppgivna av användaren')
// The original instruction survives for the genuinely unanswered case.
expect(out).toContain('Hur många var ni, och vilka?')
})
it('surfaces an unanswered question the chat gave up on, and only that one', async () => {
const moved = {
...WHATSAPP_ITEM,
channel_context: {
channel: 'whatsapp',
pending_question: {
type: 'representation',
asked_at: '2026-05-12T13:00:00Z',
status: 'moved_to_app',
},
},
}
const captured = await captureWith({ matchedItems: [moved] })
const out = render(captured)
expect(out).toContain('OBESVARAD FRÅGA')
expect(out).toContain('ägs nu av appen')
expect(out).toContain('ställ exakt den frågan och ingen annan')
})
it('falls back to the ask-for-underlag branch when nothing matches at all', async () => {
const captured = await captureWith({ matchedItems: [], unmatchedItems: [] })
expect(captured.underlag).toHaveLength(0)
expect(render(captured)).toContain('UNDERLAG: saknas')
})
})
@@ -49,6 +49,7 @@ function renderPrompt(opts: {
is_systembolaget: null,
raw_extraction: null,
chat_answers: opts.chatAnswers ?? null,
match: 'linked' as const,
},
]
: [],
@@ -4,6 +4,7 @@ import {
renderClarificationLines,
summariseClarifications,
} from '@/lib/agent-context/chat-clarifications'
import { findUnderlagCandidates } from '@/lib/agent-context/underlag-candidates'
import type { InboxChannelContext } from '@/types'
// transaction.categorization: "Fråga om denna transaktion" on a transaction
@@ -59,6 +60,22 @@ interface CapturedTransaction {
* system that already holds the answer can do.
*/
chat_answers: InboxChannelContext | null
/**
* `linked` = tied to this transaction by a human or an explicit flow.
* `candidate` = scored as probably the same economic event but NOT linked.
*
* Candidates exist because WhatsApp intake writes neither
* matched_transaction_id nor transactions.document_id, so a chat-captured
* receipt reaches neither the matched query nor the document backfill and
* the assistant reports "UNDERLAG: saknas" about a receipt we hold.
*/
match: 'linked' | 'candidate'
/** Only set for candidates: 0-1 from the shared receipt matcher. */
confidence?: number
/** Only set for candidates: Swedish reasons the match scored. */
match_reasons?: string[]
/** Set for inbox-sourced underlag so the agent can propose the match. */
inbox_item_id?: string | null
}[]
}
@@ -98,7 +115,11 @@ export const transactionCategorization = defineAgentIntent<
capture: async ({ transaction_id }, { supabase, companyId }) => {
const { data: tx } = await supabase
.from('transactions')
.select('id, date, description, amount, currency, document_id, journal_entry_id')
.select(
// merchant_name / amount_sek / exchange_rate feed the candidate scorer
// (currency-aware amount comparison + merchant similarity).
'id, date, description, merchant_name, amount, currency, amount_sek, exchange_rate, document_id, journal_entry_id',
)
.eq('id', transaction_id)
.eq('company_id', companyId)
.single()
@@ -173,6 +194,7 @@ export const transactionCategorization = defineAgentIntent<
is_systembolaget: r.is_systembolaget,
raw_extraction: r.raw_extraction,
chat_answers: null,
match: 'linked',
})
}
for (const it of (inboxItems ?? []) as {
@@ -196,6 +218,7 @@ export const transactionCategorization = defineAgentIntent<
is_systembolaget: null,
raw_extraction: ex,
chat_answers: it.channel_context ?? null,
match: 'linked',
})
}
@@ -240,6 +263,7 @@ export const transactionCategorization = defineAgentIntent<
is_systembolaget: null,
raw_extraction: null,
chat_answers: null,
match: 'linked',
})
continue
}
@@ -258,6 +282,7 @@ export const transactionCategorization = defineAgentIntent<
is_systembolaget: null,
raw_extraction: ex,
chat_answers: null,
match: 'linked',
})
}
@@ -288,6 +313,45 @@ export const transactionCategorization = defineAgentIntent<
}
}
// Still nothing. That is the ordinary state for a receipt captured in
// WhatsApp: intake writes neither invoice_inbox_items.matched_transaction_id
// nor transactions.document_id (both are set only when a human matches in
// the picker), so the item reaches neither the matched query nor the
// backfill above, and the user is told "UNDERLAG: saknas" about a receipt
// we are holding. Score the unmatched items and offer the strongest for a
// human to confirm.
if (underlag.length === 0 && tx) {
const candidates = await findUnderlagCandidates(supabase, companyId, {
id: tx.id as string,
date: (tx.date as string | null) ?? null,
description: (tx.description as string | null) ?? null,
merchant_name: (tx.merchant_name as string | null) ?? null,
amount: tx.amount as number | null,
currency: (tx.currency as string | null) ?? null,
amount_sek: (tx.amount_sek as number | null) ?? null,
exchange_rate: (tx.exchange_rate as number | null) ?? null,
})
for (const c of candidates) {
underlag.push({
kind: 'invoice_inbox',
match: 'candidate',
confidence: c.confidence,
match_reasons: c.matchReasons,
inbox_item_id: c.inbox_item_id,
chat_answers: c.channelContext,
document_id: c.document_id,
merchant_name: c.merchant_name,
receipt_date: c.receipt_date,
total_amount: c.total_amount,
vat_amount: c.vat_amount,
currency: c.currency,
is_restaurant: null,
is_systembolaget: null,
raw_extraction: null,
})
}
}
return {
transaction: tx
? {
@@ -343,12 +407,23 @@ export const transactionCategorization = defineAgentIntent<
} else {
// Underlag IS attached: read the extracted metadata and use it
// directly. Don't ask the user for things the extraction already nailed.
lines.push(`UNDERLAG: ${captured.underlag.length} st bifogat. Extraherade fält:`)
const linkedCount = captured.underlag.filter((u) => u.match === 'linked').length
const candidateCount = captured.underlag.length - linkedCount
lines.push(
linkedCount > 0
? `UNDERLAG: ${linkedCount} st bifogat. Extraherade fält:`
: `UNDERLAG: inget är kopplat till transaktionen, men ${candidateCount} st i Dokumentinkorgen liknar den starkt (TROLIGT UNDERLAG, ej bekräftat). Extraherade fält:`,
)
// Set when at least one underlag actually contributed a clarification
// line, which is what the guidance paragraph below refers to.
let renderedClarifications = false
for (const u of captured.underlag) {
const parts: string[] = []
if (u.match === 'candidate') {
parts.push(`TROLIGT UNDERLAG (${Math.round((u.confidence ?? 0) * 100)}% säkerhet)`)
if (u.match_reasons?.length) parts.push(u.match_reasons.join(' + '))
if (u.inbox_item_id) parts.push(`inbox_item_id=${u.inbox_item_id}`)
}
if (u.document_id) parts.push(`document_id=${u.document_id}`)
if (u.merchant_name) parts.push(`leverantör=${u.merchant_name}`)
if (u.receipt_date) parts.push(`datum=${u.receipt_date}`)
@@ -390,6 +465,9 @@ export const transactionCategorization = defineAgentIntent<
if (renderedClarifications) {
lines.push('Rader märkta "uppgivna av användaren" kommer från en tidigare konversation om samma underlag (t.ex. WhatsApp när kvittot skickades in). Det är MÄNSKLIGT bekräftade uppgifter och väger tyngre än vad du själv läser ut ur bilden. Fråga ALDRIG om något som redan står där; behöver du komplettera, fråga bara om den del som faktiskt saknas. Står det "OBESVARAD FRÅGA": ställ exakt den frågan och ingen annan. När du stagear: ta med deltagare och syfte i notes så de följer med till verifikationen.')
}
if (candidateCount > 0) {
lines.push('TROLIGT UNDERLAG är INTE kopplat ännu: en människa måste bekräfta kopplingen. Fråga kort om det är rätt underlag (nämn leverantör, datum, belopp) och be användaren koppla det i Dokumentinkorgen via "Matcha mot transaktion". Bokför inte mot ett troligt underlag som användaren inte bekräftat, men använd gärna dess uppgifter för att föreslå kategori under tiden.')
}
}
lines.push('')
lines.push('Arbetssätt: hämta information via verktygsanrop FÖRST (tyst: statusraderna visar att du söker, och ditt resonemang sker i tankekanalen), föreslå sedan. Skriv din förklaring EN gång efteråt, inte i flera block runt anropen.')