287 lines
7.7 KiB
TypeScript
287 lines
7.7 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
findTransactionMatches,
|
|
autoMatchReceipts,
|
|
filterUnmatchedTransactions,
|
|
filterUnmatchedReceipts,
|
|
} from '../receipt-matcher'
|
|
import { makeReceipt, makeTransaction } from '@/tests/helpers'
|
|
|
|
describe('findTransactionMatches', () => {
|
|
it('exact date + exact amount → high confidence', () => {
|
|
const receipt = makeReceipt({
|
|
receipt_date: '2024-06-15',
|
|
total_amount: 299,
|
|
merchant_name: 'ICA Maxi',
|
|
})
|
|
const transactions = [
|
|
makeTransaction({
|
|
date: '2024-06-15',
|
|
amount: -299,
|
|
merchant_name: 'ICA Maxi',
|
|
receipt_id: null,
|
|
}),
|
|
]
|
|
|
|
const matches = findTransactionMatches(receipt, transactions)
|
|
expect(matches).toHaveLength(1)
|
|
expect(matches[0].confidence).toBeGreaterThanOrEqual(0.8)
|
|
expect(matches[0].dateVariance).toBe(0)
|
|
})
|
|
|
|
it('date within ±3 days → matches with lower confidence', () => {
|
|
const receipt = makeReceipt({
|
|
receipt_date: '2024-06-15',
|
|
total_amount: 500,
|
|
merchant_name: '',
|
|
})
|
|
const transactions = [
|
|
makeTransaction({
|
|
date: '2024-06-17',
|
|
amount: -500,
|
|
merchant_name: '',
|
|
receipt_id: null,
|
|
}),
|
|
]
|
|
|
|
const matches = findTransactionMatches(receipt, transactions)
|
|
expect(matches.length).toBeGreaterThanOrEqual(1)
|
|
expect(matches[0].dateVariance).toBeCloseTo(2, 0)
|
|
})
|
|
|
|
it('date outside ±3 days → no match', () => {
|
|
const receipt = makeReceipt({
|
|
receipt_date: '2024-06-15',
|
|
total_amount: 500,
|
|
merchant_name: '',
|
|
})
|
|
const transactions = [
|
|
makeTransaction({
|
|
date: '2024-06-25',
|
|
amount: -500,
|
|
merchant_name: '',
|
|
receipt_id: null,
|
|
}),
|
|
]
|
|
|
|
const matches = findTransactionMatches(receipt, transactions)
|
|
expect(matches).toHaveLength(0)
|
|
})
|
|
|
|
it('amount within 5% tolerance → matches', () => {
|
|
const receipt = makeReceipt({
|
|
receipt_date: '2024-06-15',
|
|
total_amount: 1000,
|
|
merchant_name: '',
|
|
})
|
|
// 4% off = 960
|
|
const transactions = [
|
|
makeTransaction({
|
|
date: '2024-06-15',
|
|
amount: -960,
|
|
merchant_name: '',
|
|
receipt_id: null,
|
|
}),
|
|
]
|
|
|
|
const matches = findTransactionMatches(receipt, transactions)
|
|
expect(matches.length).toBeGreaterThanOrEqual(1)
|
|
})
|
|
|
|
it('amount outside tolerance → no match', () => {
|
|
const receipt = makeReceipt({
|
|
receipt_date: '2024-06-15',
|
|
total_amount: 1000,
|
|
merchant_name: '',
|
|
is_foreign_merchant: false,
|
|
})
|
|
// 10% off = 900 (way above 5%)
|
|
const transactions = [
|
|
makeTransaction({
|
|
date: '2024-06-15',
|
|
amount: -900,
|
|
merchant_name: '',
|
|
receipt_id: null,
|
|
}),
|
|
]
|
|
|
|
const matches = findTransactionMatches(receipt, transactions)
|
|
expect(matches).toHaveLength(0)
|
|
})
|
|
|
|
it('merchant name similarity boosts confidence', () => {
|
|
const receipt = makeReceipt({
|
|
receipt_date: '2024-06-15',
|
|
total_amount: 500,
|
|
merchant_name: 'Coop Konsum',
|
|
})
|
|
|
|
const txWithMerchant = makeTransaction({
|
|
id: 'tx-with',
|
|
date: '2024-06-15',
|
|
amount: -500,
|
|
merchant_name: 'Coop Konsum Stockholm',
|
|
receipt_id: null,
|
|
})
|
|
const txWithout = makeTransaction({
|
|
id: 'tx-without',
|
|
date: '2024-06-15',
|
|
amount: -500,
|
|
merchant_name: '',
|
|
receipt_id: null,
|
|
})
|
|
|
|
const matchesWithMerchant = findTransactionMatches(receipt, [txWithMerchant])
|
|
const matchesWithout = findTransactionMatches(receipt, [txWithout])
|
|
|
|
// Both should match since date+amount are exact
|
|
expect(matchesWithMerchant.length).toBeGreaterThanOrEqual(1)
|
|
expect(matchesWithout.length).toBeGreaterThanOrEqual(1)
|
|
|
|
// Merchant match should have higher confidence
|
|
expect(matchesWithMerchant[0].confidence).toBeGreaterThan(
|
|
matchesWithout[0].confidence
|
|
)
|
|
})
|
|
|
|
it('skips already-matched transactions (receipt_id set)', () => {
|
|
const receipt = makeReceipt({
|
|
receipt_date: '2024-06-15',
|
|
total_amount: 299,
|
|
})
|
|
const transactions = [
|
|
makeTransaction({
|
|
date: '2024-06-15',
|
|
amount: -299,
|
|
receipt_id: 'already-matched',
|
|
}),
|
|
]
|
|
|
|
const matches = findTransactionMatches(receipt, transactions)
|
|
expect(matches).toHaveLength(0)
|
|
})
|
|
|
|
it('skips income transactions (amount >= 0)', () => {
|
|
const receipt = makeReceipt({
|
|
receipt_date: '2024-06-15',
|
|
total_amount: 299,
|
|
})
|
|
const transactions = [
|
|
makeTransaction({
|
|
date: '2024-06-15',
|
|
amount: 299, // income, positive
|
|
receipt_id: null,
|
|
}),
|
|
]
|
|
|
|
const matches = findTransactionMatches(receipt, transactions)
|
|
expect(matches).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('autoMatchReceipts', () => {
|
|
it('returns matches above threshold', () => {
|
|
const receipts = [
|
|
makeReceipt({
|
|
id: 'r1',
|
|
receipt_date: '2024-06-15',
|
|
total_amount: 500,
|
|
merchant_name: 'Coop',
|
|
matched_transaction_id: null,
|
|
}),
|
|
]
|
|
const transactions = [
|
|
makeTransaction({
|
|
id: 'tx1',
|
|
date: '2024-06-15',
|
|
amount: -500,
|
|
merchant_name: 'Coop',
|
|
receipt_id: null,
|
|
}),
|
|
]
|
|
|
|
const results = autoMatchReceipts(receipts, transactions, 0.5)
|
|
expect(results).toHaveLength(1)
|
|
expect(results[0].receipt.id).toBe('r1')
|
|
expect(results[0].match.confidence).toBeGreaterThanOrEqual(0.5)
|
|
})
|
|
|
|
it('respects custom threshold', () => {
|
|
const receipts = [
|
|
makeReceipt({
|
|
id: 'r1',
|
|
receipt_date: '2024-06-15',
|
|
total_amount: 500,
|
|
merchant_name: '',
|
|
matched_transaction_id: null,
|
|
}),
|
|
]
|
|
const transactions = [
|
|
makeTransaction({
|
|
id: 'tx1',
|
|
date: '2024-06-17', // 2 days off, no merchant → moderate confidence
|
|
amount: -500,
|
|
merchant_name: '',
|
|
receipt_id: null,
|
|
}),
|
|
]
|
|
|
|
// With a very high threshold, it should not match
|
|
const highThreshold = autoMatchReceipts(receipts, transactions, 0.99)
|
|
expect(highThreshold).toHaveLength(0)
|
|
|
|
// With a lower threshold, it should match
|
|
const lowThreshold = autoMatchReceipts(receipts, transactions, 0.4)
|
|
expect(lowThreshold).toHaveLength(1)
|
|
})
|
|
|
|
it('skips already-matched receipts', () => {
|
|
const receipts = [
|
|
makeReceipt({
|
|
id: 'r1',
|
|
receipt_date: '2024-06-15',
|
|
total_amount: 500,
|
|
matched_transaction_id: 'existing-tx',
|
|
}),
|
|
]
|
|
const transactions = [
|
|
makeTransaction({
|
|
date: '2024-06-15',
|
|
amount: -500,
|
|
receipt_id: null,
|
|
}),
|
|
]
|
|
|
|
const results = autoMatchReceipts(receipts, transactions)
|
|
expect(results).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('filterUnmatchedTransactions', () => {
|
|
it('returns only unmatched expenses', () => {
|
|
const transactions = [
|
|
makeTransaction({ id: 't1', receipt_id: null, amount: -100 }),
|
|
makeTransaction({ id: 't2', receipt_id: 'r1', amount: -200 }), // matched
|
|
makeTransaction({ id: 't3', receipt_id: null, amount: 300 }), // income
|
|
]
|
|
|
|
const result = filterUnmatchedTransactions(transactions)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].id).toBe('t1')
|
|
})
|
|
})
|
|
|
|
describe('filterUnmatchedReceipts', () => {
|
|
it('returns only confirmed unmatched receipts', () => {
|
|
const receipts = [
|
|
makeReceipt({ id: 'r1', status: 'confirmed', matched_transaction_id: null }),
|
|
makeReceipt({ id: 'r2', status: 'confirmed', matched_transaction_id: 'tx1' }),
|
|
makeReceipt({ id: 'r3', status: 'extracted', matched_transaction_id: null }),
|
|
]
|
|
|
|
const result = filterUnmatchedReceipts(receipts)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].id).toBe('r1')
|
|
})
|
|
})
|