Files
accounted/lib/receipt-hunt/__tests__/fx.test.ts
T
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

114 lines
4.0 KiB
TypeScript

/**
* Putting a foreign receipt into kronor. What matters is that it only ever
* adds a number nobody had, never removes a pair the hunt could already make,
* and never invents a rate it could not fetch.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { roundOre } from '@/lib/money'
import type { HuntPoolItem } from '../select'
const mockFetchRate = vi.fn()
vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: (...args: unknown[]) => mockFetchRate(...args),
convertToSEK: (amount: number, rate: number) => amount * rate,
}))
import { attachSekTotals } from '../fx'
function item(
currency: string,
total: number | null,
date: string | null = '2026-06-15',
id = 'i1',
): HuntPoolItem {
return {
id,
document_id: `d-${id}`,
channel_context: null,
extracted_data: {
supplier: { name: 'Anthropic, PBC' },
invoice: { currency, invoiceDate: date },
totals: { total },
},
}
}
beforeEach(() => {
vi.clearAllMocks()
mockFetchRate.mockResolvedValue({ currency: 'EUR', rate: 11.19, date: '2026-06-15' })
})
describe('attachSekTotals', () => {
it('resolves a foreign total into kronor', async () => {
// Anthropic bills 180 EUR; the statement reads -2 014,32 kr. Neither
// number appears in the other document.
const [out] = await attachSekTotals({} as never, [item('EUR', 180)])
expect(out.sek_total).toBe(2014.2)
})
it('rounds to öre rather than carrying a float into a comparison', async () => {
mockFetchRate.mockResolvedValue({ currency: 'USD', rate: 9.747, date: '2026-06-19' })
const [out] = await attachSekTotals({} as never, [item('USD', 104.23)])
expect(out.sek_total).toBe(roundOre(104.23 * 9.747))
})
it('leaves a Swedish receipt alone', async () => {
const [out] = await attachSekTotals({} as never, [item('SEK', 425)])
expect(out.sek_total).toBeUndefined()
expect(mockFetchRate).not.toHaveBeenCalled()
})
it('asks for a rate once per currency and day, not once per receipt', async () => {
// Riksbanken answers 429 to a caller that asks per document, and a run
// holds a dozen receipts from the same vendor in the same month.
await attachSekTotals({} as never, [
item('EUR', 180, '2026-06-15', 'a'),
item('EUR', 225, '2026-06-15', 'b'),
item('EUR', 22.5, '2026-06-15', 'c'),
])
expect(mockFetchRate).toHaveBeenCalledTimes(1)
})
it('still asks again for another day', async () => {
await attachSekTotals({} as never, [
item('EUR', 180, '2026-06-15', 'a'),
item('EUR', 180, '2026-07-15', 'b'),
])
expect(mockFetchRate).toHaveBeenCalledTimes(2)
})
it('leaves the receipt untouched when no rate can be had', async () => {
// Exactly as incomparable as before, which is the point: the hunt loses
// nothing it previously had.
mockFetchRate.mockResolvedValue(null)
const [out] = await attachSekTotals({} as never, [item('EUR', 180)])
expect(out.sek_total).toBeUndefined()
})
it('survives the rate service failing outright', async () => {
mockFetchRate.mockRejectedValue(new Error('riksbanken 429'))
const [out] = await attachSekTotals({} as never, [item('EUR', 180)])
expect(out.sek_total).toBeUndefined()
})
it('does not guess at a currency Riksbanken has no series for', async () => {
const [out] = await attachSekTotals({} as never, [item('ZWL', 500)])
expect(out.sek_total).toBeUndefined()
expect(mockFetchRate).not.toHaveBeenCalled()
})
it('refuses to date an undated receipt with today', async () => {
// Today's rate on a receipt of unknown age would make something
// incomparable look comparable, which is how a wrong pairing gets
// confidence it has not earned.
const [out] = await attachSekTotals({} as never, [item('EUR', 180, null)])
expect(out.sek_total).toBeUndefined()
})
it('ignores a receipt with no total to convert', async () => {
const [out] = await attachSekTotals({} as never, [item('EUR', null)])
expect(out.sek_total).toBeUndefined()
expect(mockFetchRate).not.toHaveBeenCalled()
})
})