Files
accounted/lib/receipt-hunt/select.ts
T
1d635b0d25 feat(receipt-hunt): look for receipts on request, from the mailbox settings page (#1496)
* feat(receipt-hunt): a button that looks for receipts on request

The nightly cron exists but still does not search mailboxes, and for a
good reason: a sweep of one real 172-message mailbox took over 600s,
against a scheduled function's 300. Pressing a button is the honest shape
for work that big. A bounded pass reports what it found and how much is
left, and the person decides whether to press again; a nightly run could
only truncate silently.

POST /api/receipt-hunt/run searches the mailboxes for eight purchases and
fetches at most ten receipts per press. Gated on the AI tier, because
reading the amount out of a PDF is what makes a fetched attachment
matchable at all: without it the hunt would file documents that can never
pair, which is worse than not running. Writes no journal entries; every
pairing is still a proposal waiting for approval.

The button lives on the mailbox settings page, which already ships, and
says what happened in words rather than a spinner that stops: "3 underlag
hämtade. 12 köp kvar att söka igenom."

huntCompany gains maxReceipts so a manual pass can carry a different
budget from a nightly one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(receipt-hunt): stop a manual press spending its budget on the wrong purchases

The first real press searched eight purchases, read forty mails and found
nothing, which looked like a broken model. It was the ordering.

Purchases are searched largest first, which is right for a nightly sweep
that eventually covers everything and wrong for a button pressed a few
times. On a real ledger the largest rows are the least likely to have a
findable receipt: rent already invoiced, bare payment references, direct
debits. Those filled the forty-mail cap, so the productive purchases
further down the list, the ones whose receipts are actually sitting in the
mailbox, were never read at all.

The cap was the binding constraint, not the time: eight purchases and
forty mails took 43s of the 300 available. A press now searches 25
purchases and reads 100 mails, measured at 85s and finding 7 underlag on
the same ledger that returned 0 before.

huntCompany gains maxMails alongside maxReceipts, so a manual pass can
carry a different budget from a nightly one rather than sharing an
environment default with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(receipt-hunt): one underlag per purchase, and a press that fits its budget

Your second press exposed three things, none of which a dry run reaches.

It took 5.8 minutes. The 85s I measured was a dry run, which never
fetches, uploads or extracts; each fetched receipt costs about another
37s because it is downloaded, stored, and then read by a model that opens
the PDF. Seven of them ran past the 300s a serverless function gets, so
in production that press would have been killed. Four receipts per press
keeps a full pass inside the budget.

It fetched seven receipts and proposed nothing. A single mail carries the
invoice AND the receipt for one purchase under different names
("Invoice-E19DBF63-0021.pdf" beside "Receipt-2066-0204-8388.pdf"), and
the same receipt reaches a second mailbox on a different message. Each
was fetched separately, so the pool filled with identical candidates and
the matcher refused to propose any of them rather than flip a coin. The
per-run key is now the vendor and the total, which is what identifies a
purchase; the filename only decides when no amount was read. Nine
duplicates already in the pool were removed.

And with the duplicates gone it still proposed nothing, for a separate
reason: "Utlägg Norwegian" scored 0.18 against "Norwegian Air Shuttle
AOC AS". Utlägg is Swedish for an expense reimbursement, bank vocabulary
rather than a company, and leaving it in broke the token-subset match, so
an exact 1 998 kr pair leaned entirely on a date eight days out and fell
under the floor. Stripped, along with överföring, via internet, bg-bet
and autogiro, in the comparison path only.

normalizeMerchantName is untouched: it is the persisted konteringskarta
key with a SQL mirror, and its 22 string pins and the 27-pair golden set
still pass.

Measured after: the Norwegian pair proposes at 0.72.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(mail): read each message once per press, not once per query

The third press proposed a pairing, which the second had not, but still
ran 5.2 minutes against a function's 300s. Cutting receipts from seven to
four had only saved 36s, which said the receipts were never the cost.

Every search fetched a full message for every hit, and a press searches
many purchases across every connected mailbox. One receipt mail answers
several of those queries, so 25 purchases against 2 mailboxes could ask
Gmail for well over a thousand messages to end up with a hundred distinct
ones. Deduplication happened in the caller, too late to save the work.

A mail's content never changes, so it is now read once per mailbox and
kept, bounded at a thousand entries and evicting oldest first. Measured
on the same ledger: 55 purchases and 100 mails now take 102s, where 25
purchases alone previously cost around 264s before a single receipt was
fetched.

clearMessageCache exists because tests reuse message ids and production
does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(receipt-hunt): do not refetch a receipt the company already holds

The fourth press fetched four documents the company already had:
Bolagsverket, Supabase twice, Uber. They came back because I had deleted
them as duplicates, and the cross-run check is the message and attachment
id, which lives in the rows I removed.

That was my mistake, but it exposed a real gap. The vendor-and-total key
only deduplicates inside a single pass. Across passes the same purchase
still arrives as an invoice in one mail and a receipt in another, with
different file keys, and both were fetched: the pool fills with identical
candidates and the matcher then refuses to choose between them, which is
how a press can fetch four documents and propose nothing.

The pass now starts from what the company already holds, so its budget
goes on documents that are actually missing.

Receipts per press drops to three. Measured on this ledger, a fetched
receipt costs about 50s from download to a stored amount, and that is the
model reading the PDF rather than the network: seven took 5.8 minutes and
four took 5.1, both past the 300s a function gets. Three fits, but it is a
stopgap. Doing the fetch inside the request is the wrong shape for work
this slow, and the fix is to move it off the request rather than keep
shaving this number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(mail): a mail body must not outlive the run that read it

Two findings from the review, both correct.

The message cache held whole MailCandidate values, and one of those fields
is the mail body. The contract says a body is read once to extract fields
and discarded, and a process-global cache quietly broke that: bodies of
one company's mail sat in memory across requests until eviction or a
restart. The MailSearchService contract now has releaseCache, the Gmail
adapter clears its messages, and the hunt calls it in a finally so a
failed run releases them too.

The duplicate key accepted an empty vendor, so two unrelated documents
that happened to cost the same collapsed into one candidate. Those now
fall back to the file they came from: without a vendor there is nothing
to anchor an amount to.

The same finding caught something worse that I had introduced one commit
earlier. The persistent check derived its key from the stored extraction
while the fetch derived one from the reading model, so a document filed
as "Norwegian Air Shuttle AOC AS" did not recognise an incoming
"Norwegian" and was fetched again. Rather than guess at aliases, which
would fold "Google Cloud" into "Google Workspace", the identity is now
written onto the row when the receipt is filed and read back verbatim.
Rows filed before that fall back to the extraction.

receiptIdentity is one exported helper with its own tests, used by both
sides, instead of the same expression written twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(receipt-hunt): a monthly subscription is not a duplicate of last month

The review caught that my duplicate key was worse than the problem it
solved. Anthropic bills the same amount every month, and keying on vendor
and total alone made July look like a duplicate of June: every later
receipt from any recurring supplier would have been suppressed forever,
silently. Duplicates block one proposal; that would have lost a receipt
per month per subscription.

The identity now carries the document date. Two documents for one
purchase share a date; June and July do not.

Two smaller faults in the same key. The amount was serialised as a raw
float, so 0.1 + 0.2 read as a different total from 0.3; it is rounded to
öre like every other money comparison in this codebase. And a document
with no vendor was identified by its filename alone, which collapses two
unrelated papers whenever a billing system attaches "invoice.pdf": those
now carry the message they came from.

The key is versioned so a future change to its shape cannot be mistaken
for a match against rows written under the old one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 16:08:06 +02:00

332 lines
12 KiB
TypeScript

/**
* Which receipt gets proposed for which unbooked card purchase.
*
* The hunt attaches an underlag to a transaction *before* it is booked, so the
* gap never forms: `commitAttachDocumentToTransaction` sets
* `invoice_inbox_items.matched_transaction_id`, and when the user later books
* the transaction `categorize-core.ts` propagates that document onto the new
* verifikat. Chasing already-posted verifikat is deliberately NOT this job:
* that backlog is 96% imported history whose originals live in the previous
* system, and it stays a pull (the `verifikat_missing_document` worklist).
*
* Everything here is pure so the ranking and every guard is unit-testable
* without a database; the caller owns the reads and the staging write.
*/
import { scoreUnderlagCandidates, type CandidateTransaction } from '@/lib/agent-context/underlag-candidates'
import {
calculateMerchantSimilarity,
normalizeForMatch,
} from '@/lib/documents/core-receipt-matcher'
/**
* Confidence a candidate must reach to be proposed unattended.
*
* Above `CANDIDATE_MIN_CONFIDENCE` (0.6), which governs candidates an agent
* reads and reasons about with a human in the loop. A proposal staged by the
* nightly hunt is read as "these two belong together", so it trades recall for
* precision: a wrong receipt on the wrong purchase is a mis-booking, and the
* weaker pairs still reach the user through the manual picker.
*/
export const HUNT_MIN_CONFIDENCE = 0.7
/**
* How far clear the winner must be before we propose it.
*
* Two receipts scoring the same against one purchase is a signal, not a tie to
* break: a duplicate, a split payment, or a recurring charge whose sibling we
* picked at random. Proposing either would be a coin flip presented as a
* finding, so both are left to the picker.
*/
export const AMBIGUITY_MARGIN = 0.05
/**
* Proposals per company per run.
*
* The queue is drained largest-amount-first over several nights instead of
* arriving at once: prod holds companies with hundreds of receiptless
* purchases, and a first run that staged all of them would bury the
* granskningskö the feature is supposed to relieve.
*/
export const MAX_PROPOSALS_PER_RUN = 20
/** A transaction the hunt may propose an underlag for. */
export interface HuntTransaction extends CandidateTransaction {
company_id: string
}
/** An unconsumed inbox item, already filtered to ones whose document is attachable. */
export interface HuntPoolItem {
id: string
document_id: string | null
extracted_data: unknown
channel_context: unknown
}
export interface HuntProposal {
transaction_id: string
document_id: string
inbox_item_id: string
confidence: number
matchReasons: string[]
/** Receipt-side facts, for the approval preview. */
merchant_name: string | null
receipt_date: string | null
total_amount: number | null
currency: string | null
/**
* Where the document came from, carried through from the inbox item so the
* proposal can say "found in your mailbox" rather than implying a human
* uploaded it.
*/
mailProvenance: {
mailbox?: string
subject?: string
from?: string
} | null
}
export interface SuppressionSets {
/**
* Transactions that already have an open or settled proposal. One live
* question per purchase: a second one is noise even when it names a
* different receipt.
*/
claimedTransactionIds: ReadonlySet<string>
/**
* Receipts already offered to some purchase and not yet rejected. One
* underlag belongs to one verifikat, and a proposal is a claim on it until a
* human says otherwise.
*/
claimedDocumentIds: ReadonlySet<string>
/**
* Pairs a human already said no to, as `${transaction_id}:${document_id}`.
* Scoped to the pair rather than the transaction so a rejection retires one
* wrong guess without retiring the purchase.
*/
rejectedPairs: ReadonlySet<string>
}
export function pairKey(transactionId: string, documentId: string): string {
return `${transactionId}:${documentId}`
}
/**
* Rank the pool against each transaction and return the proposals worth
* staging, strongest purchases first.
*
* Ordering is by absolute amount, not by confidence: when the cap truncates the
* run, the money that matters most for the books should be the part that gets
* asked about tonight.
*/
export function selectProposals(
transactions: readonly HuntTransaction[],
pool: readonly HuntPoolItem[],
suppression: SuppressionSets,
limit: number = MAX_PROPOSALS_PER_RUN,
): HuntProposal[] {
if (pool.length === 0) return []
const byLargestAmount = [...transactions].sort(
(a, b) => Math.abs(b.amount ?? 0) - Math.abs(a.amount ?? 0),
)
const poolById = new Map(pool.map((item) => [item.id, item]))
const proposals: HuntProposal[] = []
// One receipt can only settle one purchase, and the pool is scored per
// transaction, so the same document can win twice in a single run. Whoever
// is scored first (the larger amount) keeps it.
const spentDocumentIds = new Set<string>()
for (const tx of byLargestAmount) {
if (proposals.length >= limit) break
if (suppression.claimedTransactionIds.has(tx.id)) continue
const scored = scoreUnderlagCandidates(tx, pool as never[]).filter(
(candidate) =>
candidate.document_id != null &&
!spentDocumentIds.has(candidate.document_id) &&
!suppression.claimedDocumentIds.has(candidate.document_id) &&
!suppression.rejectedPairs.has(pairKey(tx.id, candidate.document_id)),
)
if (scored.length === 0) continue
const [winner, runnerUp] = scored
if (winner.confidence < HUNT_MIN_CONFIDENCE) continue
if (runnerUp && winner.confidence - runnerUp.confidence < AMBIGUITY_MARGIN) continue
const documentId = winner.document_id as string
spentDocumentIds.add(documentId)
const context = poolById.get(winner.inbox_item_id)?.channel_context as
| { channel?: string; mail_mailbox?: string; mail_subject?: string; mail_from?: string }
| null
| undefined
proposals.push({
mailProvenance:
context?.channel === 'mail_hunt'
? {
mailbox: context.mail_mailbox,
subject: context.mail_subject,
from: context.mail_from,
}
: null,
transaction_id: tx.id,
document_id: documentId,
inbox_item_id: winner.inbox_item_id,
confidence: winner.confidence,
matchReasons: winner.matchReasons,
merchant_name: winner.merchant_name,
receipt_date: winner.receipt_date,
total_amount: winner.total_amount,
currency: winner.currency,
})
}
return proposals
}
/**
* Payments that cannot have an emailed receipt, however hard we look.
*
* Salary, tax, employer contributions, VAT settlements, dividends, loan
* amortisation and interest are all money moving on the strength of a
* declaration or an agreement, not a purchase a merchant confirms by mail.
*
* Deliberately narrow. Supplier payments over bankgiro DO arrive with an
* emailed invoice (a provkörning matched a Sting office invoice that way), and
* an "Utlägg" reimbursement has a real receipt behind it, so neither the rail
* nor the word "överföring" is grounds for skipping.
*
* Swedish bank statements truncate hard, which is why "skat" has to match as
* well as "skatt": a real ledger row reads "Inbetalning skat BG 000005...".
*/
const NO_EMAIL_RECEIPT_EXISTS =
/\bl[oö]n\b|\bl[oö]ner\b|\bskatt?\b|skatteverket|arbetsgivaravg|\bmoms\b|utdelning|amortering|\br[aä]nta\b|egen ins[aä]ttning/i
/**
* Whether it is worth spending a mailbox search on this purchase.
*
* Only gates the mail leg: the Underlag pairing is scored on amount and
* merchant and is already safe on these rows.
*/
export function canHaveEmailReceipt(description: string | null | undefined): boolean {
if (!description) return true
return !NO_EMAIL_RECEIPT_EXISTS.test(description)
}
/** Vendor names this alike are treated as the same merchant. */
export const FETCH_VENDOR_SIMILARITY = 0.6
/** How far a receipt's own date may sit from the charge and still be plausible. */
export const FETCH_DATE_WINDOW_DAYS = 10
/** Amounts within this fraction of each other are the same amount. */
const AMOUNT_TOLERANCE = 0.01
function daysBetween(a: string, b: string): number {
return Math.abs(new Date(a).getTime() - new Date(b).getTime()) / 86_400_000
}
/**
* Is this mailbox document worth downloading?
*
* The gate before spending a fetch, a megabyte of storage and a page of the
* granskningskö on a document. It is deliberately not the match: a mail body
* states the total only about a quarter of the time, and the real amount comes
* out of the PDF afterwards, at which point the ordinary matcher decides.
*
* Ordered by how much each signal is worth. An amount that agrees is close to
* proof on its own, which is why it does not also need the vendor or the date.
* A vendor that agrees is suggestive rather than conclusive, so it is asked to
* bring a plausible date along. Everything else waits.
*/
export function worthFetching(
doc: {
vendor: string | null
date: string | null
amount: number | null
currency: string | null
},
transactions: readonly HuntTransaction[],
/**
* The purchases whose own search turned this mail up.
*
* Already evidence, and evidence of a kind the document itself cannot carry:
* the query was this purchase's amount or its merchant tokens, so a hit means
* one of those appeared in the mail or inside its attachment. It rescues the
* case where the two names genuinely differ, which is common and not an
* error: the bank writes "Kontorsplatser j BG" and the landlord's invoice
* says "Stockholm Innovation & Growth AB".
*/
retrievedFor: readonly HuntTransaction[] = [],
): boolean {
for (const tx of transactions) {
if (tx.amount == null) continue
const charged = Math.abs(tx.amount)
// Same currency only. A EUR receipt against a SEK charge is not a
// disagreement about the number, it is a different number: converting one
// to the other is a guess and this code does not guess.
if (
doc.amount != null &&
(doc.currency ?? 'SEK') === (tx.currency ?? 'SEK') &&
Math.abs(doc.amount - charged) <= charged * AMOUNT_TOLERANCE
) {
return true
}
if (!doc.vendor) continue
const similarity = calculateMerchantSimilarity(
doc.vendor,
tx.merchant_name || tx.description || '',
)
if (similarity < FETCH_VENDOR_SIMILARITY) continue
// No date on the receipt is missing evidence, not contrary evidence: the
// vendor agreeing is enough to look inside the file.
if (!doc.date || !tx.date) return true
if (daysBetween(doc.date, tx.date) <= FETCH_DATE_WINDOW_DAYS) return true
}
for (const tx of retrievedFor) {
if (!doc.date || !tx.date) continue
if (daysBetween(doc.date, tx.date) <= FETCH_DATE_WINDOW_DAYS) return true
}
return false
}
/**
* What identifies one purchase's paperwork, for deciding whether to fetch it.
*
* A mail carries the invoice and the receipt for the same purchase under
* different names, and the same receipt reaches a second mailbox on another
* message, so neither the filename nor the message id identifies anything.
* The vendor, the total and the date do.
*
* The date is what keeps a subscription working. Anthropic bills the same
* amount every month, and without a date every month after the first would be
* treated as a duplicate of it and suppressed forever: a worse failure than
* the duplicates this key exists to prevent, because it is permanent and
* silent. Two documents for one purchase share a date; June and July do not.
*
* Without a vendor there is nothing to anchor an amount to, so those fall back
* to the message and the file. The filename alone is not enough: half the
* world's billing systems attach "invoice.pdf".
*/
export function receiptIdentity(doc: {
vendor: string | null
amount: number | null
currency: string | null
date?: string | null
attachmentName?: string | null
messageId?: string | null
}): string {
const vendor = normalizeForMatch(doc.vendor ?? '').trim()
if (vendor && doc.amount != null) {
// Öre, not floating point: 0.1 + 0.2 must not read as a different total.
const amount = Math.round(doc.amount * 100) / 100
return `v1::${vendor}::${amount}::${(doc.currency ?? 'SEK').toLowerCase()}::${doc.date ?? ''}`
}
return `v1::file::${(doc.messageId ?? '').toLowerCase()}::${(doc.attachmentName ?? '').toLowerCase()}`
}