Files
accounted/lib/receipt-hunt/fx.ts
T
Jakob Wennberg 4c479f84f4 feat(receipt-hunt): convert a foreign receipt instead of refusing to compare it (#1497)
* feat(receipt-hunt): compare a foreign receipt by converting it, not by refusing

A Swedish bank posts a converted figure for a purchase abroad while the
receipt states the original: Anthropic bills 180,00 EUR and the statement
reads -2 014,32 kr. Neither number appears in the other document, so the
matcher refused the pair rather than guess. On a SaaS-heavy ledger that is
not an edge case: of 25 receipts fetched from a real company's mailboxes,
14 were in USD or EUR and none could ever pair.

The receipt's total is now resolved into kronor with Riksbanken's rate for
its own date, and handed to the same matcher, which still wants the
merchant and the date to agree. The seam already existed: the scorer
passed null where a SEK value would go, with a comment explaining that
cross-currency pairs were deliberately incomparable. Surfaces that do not
resolve a rate still pass nothing and behave exactly as before.

Rates are fetched once per currency and day. Riksbanken answers 429 to a
caller that asks per document, and a run holds a dozen receipts from one
vendor in one month. A rate that cannot be resolved leaves the receipt
exactly as incomparable as it was.

Two calibration faults surfaced once the amounts became comparable:

A converted total is judged at 9% rather than 5%. Riksbanken publishes a
mid rate and a card issuer charges its own, so the two carry a known
spread on top of any disagreement about the sum: measured against real
statements, 1.2% to 3%. Holding both to one bar treats a rate spread as
if it were a discrepancy.

The date tolerance moves from 3 days to 10. A card settles days after the
purchase, an international one routinely a week later, and a forwarded
receipt carries the purchase date while the statement carries the posting.
At three days the signal scored zero for ordinary correct pairs and took a
quarter of the weight with it: a receipt agreeing to within 1%, from a
merchant the matcher recognised, still capped at 0.62. The 27 human-
confirmed pairs and the 7 near-misses that must not match all still hold.

Measured on that ledger: purchases with any candidate at all go 9 -> 13,
and the Anthropic pair proposes at 0.82 where it was previously
unscoreable.

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

* fix(receipt-hunt): an undated receipt is not converted at today's rate

Raised in review. A foreign receipt with no invoiceDate fell back to the
current date, which put a receipt of unknown age into amount matching on
the strength of a guess: a rate two years out is how something
incomparable acquires confidence it has not earned. No date, no
conversion, and the receipt stays exactly as it was.

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 17:54:22 +02:00

124 lines
4.3 KiB
TypeScript

/**
* Putting a foreign receipt into kronor so it can be compared at all.
*
* Swedish banks post a converted SEK figure for a card purchase abroad, and
* the receipt states the original: Anthropic bills 180,00 EUR and the
* statement reads -2 014,32 kr. Neither number appears in the other document,
* so the matcher has always refused the pair rather than guess. On a
* SaaS-heavy ledger that is not an edge case: measured on a real company, 14
* of 25 fetched receipts were in USD or EUR and none of them could ever pair.
*
* The conversion is deliberately not a match on its own. It fills in the one
* missing number and hands the pair back to the same matcher, which still
* wants the merchant and the date to agree, and still applies its own
* tolerance. That tolerance is what absorbs the difference between
* Riksbanken's mid rate and what a card issuer actually charged: measured
* against real statements, Supabase came out 1.22% off and Vercel 1.23%,
* comfortably inside the 5% the matcher already allows.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { convertToSEK, fetchExchangeRate } from '@/lib/currency/riksbanken'
import { roundOre } from '@/lib/money'
import { createLogger } from '@/lib/logger'
import type { HuntPoolItem } from './select'
const log = createLogger('receipt-hunt-fx')
/** Currencies Riksbanken publishes a series for. */
const SUPPORTED = new Set(['EUR', 'USD', 'GBP', 'NOK', 'DKK', 'CHF', 'JPY', 'PLN'])
interface Signals {
currency: string
total: number | null
date: string | null
}
function signalsOf(item: HuntPoolItem): Signals {
const data = item.extracted_data as
| {
invoice?: { currency?: string | null; invoiceDate?: string | null }
totals?: { total?: number | null }
}
| null
| undefined
return {
currency: (data?.invoice?.currency || 'SEK').toUpperCase(),
total: data?.totals?.total ?? null,
date: data?.invoice?.invoiceDate ?? null,
}
}
/**
* Resolve a SEK total for every pool item stated in another currency.
*
* Rates are fetched once per currency and day and reused, because Riksbanken
* answers 429 to a caller that asks per document, and a run can hold a dozen
* receipts from the same vendor in the same month.
*
* An item whose rate cannot be resolved is returned untouched, which leaves it
* exactly as incomparable as it was before: the hunt loses nothing it had.
*/
export async function attachSekTotals(
supabase: SupabaseClient,
pool: readonly HuntPoolItem[],
): Promise<HuntPoolItem[]> {
const rates = new Map<string, number | null>()
const rateFor = async (currency: string, date: string | null): Promise<number | null> => {
// No date, no conversion. Reaching for today's rate would put a receipt
// whose age nobody knows into amount matching on the strength of a guess,
// and a rate two years out is how an incomparable receipt becomes a
// confident wrong pairing.
if (!date) return null
// Riksbanken publishes per day, so the day is the whole cache key.
const day = date
const key = `${currency}::${day}`
if (rates.has(key)) return rates.get(key) ?? null
try {
const rate = await fetchExchangeRate(currency as never, new Date(day), supabase as never)
rates.set(key, rate?.rate ?? null)
return rate?.rate ?? null
} catch (error) {
log.warn('could not resolve an exchange rate', {
currency,
day,
error: error instanceof Error ? error.message : String(error),
})
rates.set(key, null)
return null
}
}
const out: HuntPoolItem[] = []
let converted = 0
for (const item of pool) {
const sig = signalsOf(item)
if (sig.currency === 'SEK' || sig.total == null || !SUPPORTED.has(sig.currency)) {
out.push(item)
continue
}
const rate = await rateFor(sig.currency, sig.date)
if (rate == null) {
out.push(item)
continue
}
converted++
out.push({
...item,
// Öre, like every other money value here: a raw float would put
// 1015.9700000000001 into a comparison against a bank amount.
sek_total: roundOre(convertToSEK(sig.total, rate)),
})
}
if (converted > 0) {
log.info('resolved foreign receipt totals', { converted, rates: rates.size })
}
return out
}