New Base func

This commit is contained in:
Jakob Wennberg
2026-02-19 09:48:02 +01:00
parent afc9f69def
commit cdf1dcc4c8
73 changed files with 11036 additions and 51 deletions
@@ -0,0 +1,154 @@
import { describe, it, expect } from 'vitest'
import {
mapSuggestedCategory,
getBASAccount,
categorizeLineItem,
processLineItems,
calculateReceiptSplit,
getDefaultClassification,
} from '../receipt-categorizer'
import type { ExtractedLineItem } from '@/types'
describe('mapSuggestedCategory', () => {
it('maps AI categories to TransactionCategory', () => {
expect(mapSuggestedCategory('equipment')).toBe('expense_equipment')
expect(mapSuggestedCategory('software')).toBe('expense_software')
expect(mapSuggestedCategory('travel')).toBe('expense_travel')
expect(mapSuggestedCategory('office')).toBe('expense_office')
expect(mapSuggestedCategory('marketing')).toBe('expense_marketing')
expect(mapSuggestedCategory('professional_services')).toBe('expense_professional_services')
expect(mapSuggestedCategory('education')).toBe('expense_education')
expect(mapSuggestedCategory('other')).toBe('expense_other')
})
it('returns null for unknown categories', () => {
expect(mapSuggestedCategory('nonexistent')).toBeNull()
expect(mapSuggestedCategory(null)).toBeNull()
})
})
describe('getBASAccount', () => {
it('returns correct BAS account per category', () => {
expect(getBASAccount('expense_equipment')).toBe('5410')
expect(getBASAccount('expense_software')).toBe('5420')
expect(getBASAccount('expense_travel')).toBe('5800')
expect(getBASAccount('expense_office')).toBe('5010')
expect(getBASAccount('expense_marketing')).toBe('5910')
expect(getBASAccount('expense_professional_services')).toBe('6530')
expect(getBASAccount('expense_education')).toBe('6991')
expect(getBASAccount('expense_bank_fees')).toBe('6570')
expect(getBASAccount('income_services')).toBe('3001')
})
})
describe('categorizeLineItem', () => {
it('keyword patterns match Swedish terms — dator → expense_equipment', () => {
const result = categorizeLineItem('MacBook Pro dator')
expect(result.category).toBe('expense_equipment')
expect(result.confidence).toBe(0.7)
})
it('matches software patterns', () => {
const result = categorizeLineItem('Adobe Creative Cloud prenumeration')
expect(result.category).toBe('expense_software')
})
it('matches travel patterns', () => {
const result = categorizeLineItem('SJ tåg Stockholm-Malmö')
expect(result.category).toBe('expense_travel')
})
it('returns null category for unrecognized descriptions', () => {
const result = categorizeLineItem('xyzzy foobarbaz')
expect(result.category).toBeNull()
expect(result.confidence).toBe(0)
})
})
describe('processLineItems', () => {
it('prefers AI suggestion over pattern match', () => {
const items: ExtractedLineItem[] = [
{
description: 'MacBook Pro dator', // pattern → equipment
quantity: 1,
unitPrice: 15000,
lineTotal: 15000,
vatRate: 25,
suggestedCategory: 'software', // AI says software
confidence: 0.9,
},
]
const result = processLineItems(items)
expect(result[0].category).toBe('expense_software') // AI wins
expect(result[0].basAccount).toBe('5420')
})
it('falls back to pattern match when no AI suggestion', () => {
const items: ExtractedLineItem[] = [
{
description: 'MacBook Pro dator',
quantity: 1,
unitPrice: 15000,
lineTotal: 15000,
vatRate: 25,
suggestedCategory: null,
},
]
const result = processLineItems(items)
expect(result[0].category).toBe('expense_equipment') // pattern match
expect(result[0].basAccount).toBe('5410')
})
})
describe('calculateReceiptSplit', () => {
it('correct business/private/unclassified totals', () => {
const items = [
{ lineTotal: 100, is_business: true as boolean | null },
{ lineTotal: 50, is_business: false as boolean | null },
{ lineTotal: 25, is_business: null as boolean | null },
]
const result = calculateReceiptSplit(items)
expect(result.businessTotal).toBe(100)
expect(result.privateTotal).toBe(50)
expect(result.unclassifiedTotal).toBe(25)
// 100 / 175 * 100 = 57.142... → 57.1
expect(result.businessPercentage).toBeCloseTo(57.1, 1)
})
it('handles rounding correctly', () => {
const items = [
{ lineTotal: 33.333, is_business: true as boolean | null },
{ lineTotal: 66.667, is_business: false as boolean | null },
]
const result = calculateReceiptSplit(items)
expect(result.businessTotal).toBe(33.33)
expect(result.privateTotal).toBe(66.67)
})
})
describe('getDefaultClassification', () => {
it('Systembolaget defaults to private', () => {
const result = getDefaultClassification(false, true)
expect(result.defaultIsBusiness).toBe(false)
expect(result.requiresReview).toBe(true)
expect(result.warningMessage).toContain('Alkohol')
})
it('restaurant requires review', () => {
const result = getDefaultClassification(true, false)
expect(result.defaultIsBusiness).toBeNull()
expect(result.requiresReview).toBe(true)
expect(result.warningMessage).toContain('Restaurangbesök')
})
it('non-restaurant, non-systembolaget has no warning', () => {
const result = getDefaultClassification(false, false)
expect(result.defaultIsBusiness).toBeNull()
expect(result.requiresReview).toBe(false)
expect(result.warningMessage).toBeNull()
})
})
@@ -0,0 +1,286 @@
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')
})
})