* 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>
302 lines
12 KiB
TypeScript
302 lines
12 KiB
TypeScript
/**
|
|
* Core Receipt Matcher: pure matching utility functions extracted from the
|
|
* receipt-ocr extension so they can be reused by the document matching engine.
|
|
*
|
|
* These are pure functions with no Supabase or extension dependencies.
|
|
*/
|
|
|
|
// Matching configuration (re-exported for consumers)
|
|
export const DATE_TOLERANCE_DAYS = 3
|
|
export const AMOUNT_TOLERANCE_PERCENT = 0.05
|
|
export const MIN_MATCH_CONFIDENCE = 0.4
|
|
|
|
/**
|
|
* Normalize a merchant name for comparison.
|
|
* Removes special characters, Swedish company suffixes, and extra whitespace.
|
|
*
|
|
* FROZEN. This is not merely a helper: `normalizeCounterpartyName`
|
|
* (lib/bookkeeping/counterparty-templates.ts) ends in this function, and its
|
|
* output is PERSISTED as `categorization_templates.counterparty_name` under
|
|
* UNIQUE (company_id, counterparty_name), with a hand-written SQL mirror
|
|
* `public.normalize_counterparty_key()` that the ledger-context RPC recomputes
|
|
* at query time. Change this and stored keys stop equalling computed ones: the
|
|
* konteringskarta join misses, learned vat_treatment degrades to history, and
|
|
* `insertOrUpdateTemplate` silently inserts a SECOND row per merchant, orphaning
|
|
* the occurrence counts instead of migrating them.
|
|
*
|
|
* To improve MATCHING, edit `normalizeForMatch` below, which nothing persists.
|
|
* To improve the canonical KEY, it is a three-part atomic change: this function
|
|
* + CREATE OR REPLACE of the SQL mirror + a backfill/merge migration over
|
|
* categorization_templates, with tests/pg/ledger-usage-stats-rpc.pg.test.ts
|
|
* extended in the same commit.
|
|
*/
|
|
export function normalizeMerchantName(name: string): string {
|
|
return name
|
|
.toLowerCase()
|
|
.replace(/[^\w\såäöé]/g, '') // Remove special chars except Swedish letters
|
|
.replace(/\b(ab|hb|kb|ek|för|stiftelse)\b/g, '') // Remove company suffixes
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
}
|
|
|
|
/**
|
|
* Legal-form tokens that carry no identity. Deliberately wider than the frozen
|
|
* key normalizer's list: a receipt says "Adobe Systems Software Ireland Ltd"
|
|
* where the bank says "Adobe", and only the matcher needs to see through that.
|
|
*/
|
|
const LEGAL_FORM_TOKENS =
|
|
/\b(ab|hb|kb|ek|för|stiftelse|inc|llc|ltd|limited|gmbh|oy|oyj|ap|aps|pbc|plc|sarl|bv|nv|corp|corporation|company|filial|int|international)\b/g
|
|
|
|
/**
|
|
* Noise the Swedish card rails staple onto a merchant, observed in production:
|
|
* `Ryde Sweden AB K8066 Kortköp/uttag`, `Kortköp 260612 Prime Video-*NL5EK5W`,
|
|
* `ELGIGANTEN S/25-07-14`, `Qstar Lilla Edet 6531 K8781 Kortköp/uttag`.
|
|
*/
|
|
const CARD_TOKEN = /\bk\d{4}\b/g
|
|
const CARD_VERB = /\bkort(kop|kop\/uttag)?\b|\buttag\b/g
|
|
/**
|
|
* Swedish words a bank statement puts around a merchant name that are not part
|
|
* of it. "Utlägg Norwegian" is an expense reimbursement for a Norwegian
|
|
* ticket, not a company called Utlägg: leaving the word in cost the pair its
|
|
* token-subset match and dropped merchant similarity to 0.18, which was enough
|
|
* to keep an exact 1 998 kr match from ever being proposed.
|
|
*/
|
|
const PAYMENT_NOISE = /\butl[aä]gg\b|\b[oö]verf[oö]ring\b|\bvia internet\b|\bbg-?bet\b|\bautogiro\b/g
|
|
const CARD_DATE_PREFIX = /^\s*kortkop\s+\d{6}\s*/
|
|
const TRAILING_DATE = /\s*\/?\s*\d{2}-\d{2}-\d{2}\s*$/
|
|
/**
|
|
* Reference numbers, which banks glue straight onto the name
|
|
* ("GOOGLE ADS8047863617"), so this deliberately has no word boundary. Runs of
|
|
* three digits or fewer stay: they are often part of the identity
|
|
* ("Rusta Lindingö 135", "7-Eleven").
|
|
*/
|
|
const LONG_DIGIT_RUN = /\d{4,}/g
|
|
const DOMAIN_TAIL = /\.(com|se|io|ai|co|net|org|nu|dk|no|fi|de|uk)\b/g
|
|
|
|
/**
|
|
* Fold a merchant string down to the part that actually identifies it, for
|
|
* SIMILARITY ONLY. Nothing persists this, so it can be aggressive where the
|
|
* frozen key normalizer must not be.
|
|
*
|
|
* Aggressive folding is safe here precisely because it is applied to BOTH sides
|
|
* of every comparison: an over-eager fold that turns "Boeing" into "boing" does
|
|
* so for the receipt and the bank row alike, so the pair still matches. The only
|
|
* real risk is two genuinely different merchants colliding, which the amount and
|
|
* date signals then have to disagree with.
|
|
*/
|
|
export function normalizeForMatch(name: string): string {
|
|
let s = name.toLowerCase()
|
|
|
|
// Banks disagree about diacritics: one writes "kött", another transliterates
|
|
// to "koett", a third mangles the encoding into "LINDING??". Fold all three
|
|
// to the same base letters so they stop being three different merchants.
|
|
s = s.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
s = s.replace(/\?{2,}|�/g, ' ')
|
|
s = s.replace(/oe/g, 'o').replace(/ae/g, 'a').replace(/aa/g, 'a')
|
|
|
|
// A processor marker hides the merchant on one side or the other:
|
|
// GOOGLE*PLAY puts it first, K*IKEA GALLE puts it second. Keep both.
|
|
s = s.replace(/[*_]+/g, ' ')
|
|
|
|
s = s.replace(CARD_DATE_PREFIX, ' ')
|
|
s = s.replace(TRAILING_DATE, ' ')
|
|
s = s.replace(CARD_TOKEN, ' ')
|
|
s = s.replace(CARD_VERB, ' ')
|
|
s = s.replace(PAYMENT_NOISE, ' ')
|
|
s = s.replace(DOMAIN_TAIL, ' ')
|
|
s = s.replace(/\bwww\b/g, ' ')
|
|
|
|
// Punctuation to space rather than nothing, so "Word,and" stays two tokens.
|
|
s = s.replace(/[^\w\s]/g, ' ')
|
|
s = s.replace(LONG_DIGIT_RUN, ' ')
|
|
s = s.replace(LEGAL_FORM_TOKENS, ' ')
|
|
|
|
return s.replace(/\s+/g, ' ').trim()
|
|
}
|
|
|
|
/**
|
|
* Calculate Levenshtein (edit) distance between two strings.
|
|
*/
|
|
export function levenshteinDistance(str1: string, str2: string): number {
|
|
const m = str1.length
|
|
const n = str2.length
|
|
|
|
const dp: number[][] = Array(m + 1)
|
|
.fill(null)
|
|
.map(() => Array(n + 1).fill(0))
|
|
|
|
for (let i = 0; i <= m; i++) dp[i][0] = i
|
|
for (let j = 0; j <= n; j++) dp[0][j] = j
|
|
|
|
for (let i = 1; i <= m; i++) {
|
|
for (let j = 1; j <= n; j++) {
|
|
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1
|
|
dp[i][j] = Math.min(
|
|
dp[i - 1][j] + 1, // deletion
|
|
dp[i][j - 1] + 1, // insertion
|
|
dp[i - 1][j - 1] + cost // substitution
|
|
)
|
|
}
|
|
}
|
|
|
|
return dp[m][n]
|
|
}
|
|
|
|
/**
|
|
* Calculate merchant name similarity using Levenshtein distance and word overlap.
|
|
* Returns a value between 0 (no match) and 1 (exact match).
|
|
*/
|
|
export function calculateMerchantSimilarity(name1: string, name2: string): number {
|
|
if (!name1 || !name2) return 0
|
|
|
|
// Matching-only folding: sees through card tokens, processor stars,
|
|
// transliterated diacritics and legal forms, none of which change identity.
|
|
const n1 = normalizeForMatch(name1)
|
|
const n2 = normalizeForMatch(name2)
|
|
if (!n1 || !n2) return 0
|
|
|
|
// Exact match
|
|
if (n1 === n2) return 1
|
|
|
|
// One contains the other. Also covers the bank's fixed-width truncation
|
|
// ("apple com bi" inside "apple com bill"), which is a prefix by nature.
|
|
if (n1.includes(n2) || n2.includes(n1)) return 0.9
|
|
|
|
const words1 = n1.split(/\s+/).filter(Boolean)
|
|
const words2 = n2.split(/\s+/).filter(Boolean)
|
|
const set1 = new Set(words1)
|
|
const set2 = new Set(words2)
|
|
const commonWords = words1.filter((w) => set2.has(w))
|
|
|
|
// Every token of the shorter name appears in the longer one: "Adobe" against
|
|
// "Adobe Systems Software Ireland", or a receipt's legal name against the
|
|
// bank's trading name. Scored level with substring containment because it is
|
|
// the same claim, made token-wise instead of character-wise.
|
|
const smaller = set1.size <= set2.size ? set1 : set2
|
|
const larger = smaller === set1 ? set2 : set1
|
|
if (smaller.size > 0 && [...smaller].every((w) => larger.has(w))) return 0.9
|
|
|
|
// Word overlap
|
|
if (commonWords.length > 0) {
|
|
const overlapScore = commonWords.length / Math.max(words1.length, words2.length)
|
|
if (overlapScore >= 0.5) return 0.7 + overlapScore * 0.2
|
|
}
|
|
|
|
// Levenshtein similarity
|
|
const distance = levenshteinDistance(n1, n2)
|
|
const maxLength = Math.max(n1.length, n2.length)
|
|
return 1 - distance / maxLength
|
|
}
|
|
|
|
/**
|
|
* Compute the relative amount variance between a bank transaction and an
|
|
* underlag (receipt/invoice) total, currency-aware. Feeds the `amountVariance`
|
|
* argument of calculateMatchConfidence.
|
|
*
|
|
* Returns `null` when the amounts cannot be compared: either there is no
|
|
* underlag total, or the two are in different currencies and the underlag has
|
|
* no SEK value (no FX rate). A null result is the signal for
|
|
* calculateMatchConfidence to drop the amount weight entirely instead of
|
|
* comparing raw magnitudes across currencies: that cross-currency raw compare
|
|
* is exactly what made a 750 EUR receipt falsely match a 750 SEK transaction.
|
|
*
|
|
* Magnitudes are compared (Math.abs) because a bank expense row is negative
|
|
* while an underlag total is positive.
|
|
*
|
|
* @param receiptTotal underlag total in its own currency (sign-agnostic)
|
|
* @param receiptCurrency underlag currency, e.g. 'EUR'
|
|
* @param receiptSek underlag total converted to SEK, or null if unknown
|
|
* @param txAmount transaction amount in its own currency (sign-agnostic)
|
|
* @param txCurrency transaction currency, e.g. 'SEK'
|
|
* @param txSek transaction amount in SEK (equals txAmount for SEK rows)
|
|
*/
|
|
export function amountVarianceForMatch(
|
|
receiptTotal: number | null,
|
|
receiptCurrency: string,
|
|
receiptSek: number | null,
|
|
txAmount: number,
|
|
txCurrency: string,
|
|
txSek: number,
|
|
): number | null {
|
|
if (receiptTotal == null) return null
|
|
const absTotal = Math.abs(receiptTotal)
|
|
if (absTotal === 0) return null
|
|
|
|
// Same currency → compare raw magnitudes (most reliable, needs no rate).
|
|
if (txCurrency.toUpperCase() === receiptCurrency.toUpperCase()) {
|
|
return Math.abs(Math.abs(txAmount) - absTotal) / absTotal
|
|
}
|
|
|
|
// Different currencies → compare in SEK, but only with an SEK value for both.
|
|
if (receiptSek != null && Math.abs(receiptSek) > 0) {
|
|
return Math.abs(Math.abs(txSek) - Math.abs(receiptSek)) / Math.abs(receiptSek)
|
|
}
|
|
|
|
// Cross-currency with no rate → not comparable.
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Calculate a weighted match confidence score from date, amount, and merchant signals.
|
|
* Weights: amount 40%, merchant 35%, date 25%.
|
|
*
|
|
* When merchant similarity is 0, the merchant weight is excluded from the
|
|
* total weight so the confidence is normalized across the active signals only.
|
|
*
|
|
* `amountVariance` may be `null` when the candidate and the underlag are in
|
|
* different currencies and no FX rate was available to normalise them. In that
|
|
* case the amount signal is dropped entirely (same treatment as a missing
|
|
* merchant) rather than comparing raw magnitudes across currencies: that is
|
|
* what made a 750 EUR receipt falsely match a 750 SEK transaction.
|
|
*/
|
|
export function calculateMatchConfidence(
|
|
dateVariance: number,
|
|
amountVariance: number | null,
|
|
merchantSimilarity: number,
|
|
dateTolerance: number = DATE_TOLERANCE_DAYS,
|
|
amountTolerance: number = AMOUNT_TOLERANCE_PERCENT
|
|
): { confidence: number; matchReasons: string[] } {
|
|
const matchReasons: string[] = []
|
|
let totalWeight = 0
|
|
let weightedScore = 0
|
|
|
|
// Date score (weight: 25%)
|
|
const dateScore = Math.max(0, 1 - dateVariance / dateTolerance)
|
|
if (dateScore >= 0.8) {
|
|
matchReasons.push(dateVariance === 0 ? 'Exakt datum' : `Datum ±${Math.round(dateVariance)} dagar`)
|
|
}
|
|
weightedScore += dateScore * 0.25
|
|
totalWeight += 0.25
|
|
|
|
// Amount score (weight: 40%): only counted when the amounts are comparable
|
|
// (same currency, or both normalisable to SEK).
|
|
if (amountVariance != null) {
|
|
const amountScore = Math.max(0, 1 - amountVariance / amountTolerance)
|
|
if (amountVariance < 0.01) {
|
|
matchReasons.push('Exakt belopp')
|
|
} else if (amountVariance < amountTolerance) {
|
|
matchReasons.push(`Belopp ±${Math.round(amountVariance * 100)}%`)
|
|
}
|
|
weightedScore += amountScore * 0.4
|
|
totalWeight += 0.4
|
|
}
|
|
|
|
// Merchant score (weight: 35%): only counted when there's data
|
|
if (merchantSimilarity > 0) {
|
|
if (merchantSimilarity >= 0.9) {
|
|
matchReasons.push('Handlare matchar')
|
|
} else if (merchantSimilarity >= 0.6) {
|
|
matchReasons.push('Trolig handlarmatch')
|
|
}
|
|
weightedScore += merchantSimilarity * 0.35
|
|
totalWeight += 0.35
|
|
}
|
|
|
|
const confidence = totalWeight > 0 ? weightedScore / totalWeight : 0
|
|
|
|
return {
|
|
confidence: Math.round(confidence * 100) / 100,
|
|
matchReasons,
|
|
}
|
|
}
|