* chore: remove Sentry, consolidate migrations, add test coverage Remove @sentry/nextjs and all Sentry integration code — error tracking now handled by Recapt. Consolidate 22 incremental migrations into a single schema sync migration. Add 6 new test suites (auth, invoice matching, VAT rules, opening balances) and extend report tests with edge cases. Update Docker image name to gnubok, sync crontabs and extension presets, fix CSP missing space, simplify journal entry missing-document dialog. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove viewer bank import migration never applied to production 20260413150000_viewer_bank_import_permissions.sql (PR #234) was merged to main but never applied to the production database. It references current_active_company_id() which does not exist in production either. This breaks fresh installs and Supabase preview branches because the migration runs before the consolidated schema sync. Remove it so the migration chain matches production. The viewer bank import RLS policies should be re-added in a future migration alongside the helper functions they depend on. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: correct delete policies for tables without company_id column Seven tables in the generic delete-policy loop don't have a direct company_id column, causing fresh installs to fail with "column company_id does not exist". Fix by moving them out of the loop: - invoice_items, journal_entry_lines, receipt_line_items, supplier_invoice_items → join through parent table - extension_toggles, notification_settings, push_subscriptions → user-scoped (auth.uid() = user_id) All policies match their existing production definitions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
356 lines
13 KiB
TypeScript
356 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import {
|
|
amountsMatchExact,
|
|
amountsMatchFuzzy,
|
|
customerNameMatches,
|
|
calculateMatchScore,
|
|
findMatchingInvoices,
|
|
getBestInvoiceMatch,
|
|
} from '../invoice-matching'
|
|
import type { Transaction, Invoice, Customer } from '@/types'
|
|
import { makeTransaction, makeInvoice, makeCustomer, createMockSupabase } from '@/tests/helpers'
|
|
|
|
// ============================================================
|
|
// amountsMatchExact
|
|
// ============================================================
|
|
|
|
describe('amountsMatchExact', () => {
|
|
it('matches identical amounts', () => {
|
|
expect(amountsMatchExact(1000, 1000)).toBe(true)
|
|
})
|
|
|
|
it('matches amounts differing only in floating-point noise', () => {
|
|
// 1000.004 rounds to 1000.00, same as 1000.00
|
|
expect(amountsMatchExact(1000.004, 1000)).toBe(true)
|
|
})
|
|
|
|
it('rejects amounts differing by 0.01', () => {
|
|
expect(amountsMatchExact(1000.01, 1000)).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// amountsMatchFuzzy
|
|
// ============================================================
|
|
|
|
describe('amountsMatchFuzzy', () => {
|
|
it('matches amounts within 1% tolerance', () => {
|
|
// 990 vs 1000 → diff=10, tolerance=min(10,500)=10 → 10 <= 10
|
|
expect(amountsMatchFuzzy(990, 1000)).toBe(true)
|
|
})
|
|
|
|
it('rejects amounts outside 1% tolerance', () => {
|
|
// 980 vs 1000 → diff=20, tolerance=min(10,500)=10 → 20 > 10
|
|
expect(amountsMatchFuzzy(980, 1000)).toBe(false)
|
|
})
|
|
|
|
it('returns false when invoiceTotal is 0', () => {
|
|
expect(amountsMatchFuzzy(100, 0)).toBe(false)
|
|
})
|
|
|
|
it('caps tolerance at 500 SEK for large invoices', () => {
|
|
// 100000 vs 100600 → diff=600, tolerance=min(100000*0.01=1000, 500)=500 → 600 > 500
|
|
expect(amountsMatchFuzzy(100600, 100000)).toBe(false)
|
|
// 100000 vs 100400 → diff=400, tolerance=500 → 400 <= 500
|
|
expect(amountsMatchFuzzy(100400, 100000)).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// customerNameMatches
|
|
// ============================================================
|
|
|
|
describe('customerNameMatches', () => {
|
|
it('matches when significant word from customer name appears in description', () => {
|
|
expect(customerNameMatches('Kontorsbolaget AB', 'Betalning Kontorsbolaget', null)).toBe(true)
|
|
})
|
|
|
|
it('ignores words shorter than 3 characters', () => {
|
|
// "AB" is 2 chars, filtered out
|
|
expect(customerNameMatches('AB', 'AB payment', null)).toBe(false)
|
|
})
|
|
|
|
it('matches against merchant_name', () => {
|
|
expect(customerNameMatches('Kontorsbolaget', 'Random description', 'Kontorsbolaget AB')).toBe(true)
|
|
})
|
|
|
|
it('returns false when customerName is undefined', () => {
|
|
expect(customerNameMatches(undefined as unknown as string, 'Description', null)).toBe(false)
|
|
})
|
|
|
|
it('is case-insensitive', () => {
|
|
expect(customerNameMatches('KONTORSBOLAGET', 'betalning kontorsbolaget', null)).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// calculateMatchScore
|
|
// ============================================================
|
|
|
|
describe('calculateMatchScore', () => {
|
|
function makeTx(overrides: Partial<Transaction> = {}): Transaction {
|
|
return makeTransaction({ amount: 12500, description: 'Betalning Kundnamn AB', merchant_name: null, ...overrides })
|
|
}
|
|
|
|
function makeInv(overrides: Partial<Invoice & { customer?: Customer }> = {}): Invoice & { customer?: Customer } {
|
|
return {
|
|
...makeInvoice({ total: 12500 }),
|
|
customer: makeCustomer({ name: 'Kundnamn AB' }),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
it('returns 0.95 for exact amount + customer name match', () => {
|
|
const { confidence } = calculateMatchScore(makeTx(), makeInv())
|
|
expect(confidence).toBe(0.95)
|
|
})
|
|
|
|
it('returns 0.80 for exact amount without customer match', () => {
|
|
const { confidence } = calculateMatchScore(
|
|
makeTx({ description: 'Random payment', merchant_name: null }),
|
|
makeInv({ customer: makeCustomer({ name: 'Completely Different Co' }) })
|
|
)
|
|
expect(confidence).toBe(0.80)
|
|
})
|
|
|
|
it('returns 0.70 for fuzzy amount + customer name match', () => {
|
|
// 12375 is within 1% of 12500 (diff=125, tolerance=min(125,500)=125)
|
|
const { confidence } = calculateMatchScore(
|
|
makeTx({ amount: 12375 }),
|
|
makeInv()
|
|
)
|
|
expect(confidence).toBe(0.70)
|
|
})
|
|
|
|
it('returns 0.50 for fuzzy amount without customer match', () => {
|
|
const { confidence } = calculateMatchScore(
|
|
makeTx({ amount: 12375, description: 'Random', merchant_name: null }),
|
|
makeInv({ customer: makeCustomer({ name: 'Completely Different Co' }) })
|
|
)
|
|
expect(confidence).toBe(0.50)
|
|
})
|
|
|
|
it('returns 0 confidence when no amount match', () => {
|
|
const { confidence } = calculateMatchScore(
|
|
makeTx({ amount: 99999 }),
|
|
makeInv()
|
|
)
|
|
expect(confidence).toBe(0)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// findMatchingInvoices (integration — mock Supabase)
|
|
// ============================================================
|
|
|
|
describe('findMatchingInvoices', () => {
|
|
const { supabase, mockResult } = createMockSupabase()
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('returns empty for expense transactions (amount <= 0)', async () => {
|
|
const tx = makeTransaction({ amount: -1000 })
|
|
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('returns empty when Supabase query errors', async () => {
|
|
mockResult({ data: null, error: { message: 'db error' } })
|
|
const tx = makeTransaction({ amount: 12500 })
|
|
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('matches by OCR reference with confidence 0.99', async () => {
|
|
const tx = makeTransaction({ amount: 12500, reference: 'F-2024001' })
|
|
mockResult({
|
|
data: [
|
|
{ ...makeInvoice({ invoice_number: 'F-2024001', total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }) },
|
|
],
|
|
error: null,
|
|
})
|
|
|
|
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].confidence).toBe(0.99)
|
|
expect(result[0].matchReason).toContain('OCR-referens')
|
|
})
|
|
|
|
it('returns immediately on OCR match without further scoring', async () => {
|
|
const tx = makeTransaction({ amount: 12500, reference: 'F-2024001' })
|
|
mockResult({
|
|
data: [
|
|
{ ...makeInvoice({ invoice_number: 'F-2024001', total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }) },
|
|
// Second invoice with exact amount — should not be scored
|
|
{
|
|
...makeInvoice({ id: 'inv-2', invoice_number: 'F-2024002', total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
|
|
customer: makeCustomer({ name: 'Test match description' }),
|
|
},
|
|
],
|
|
error: null,
|
|
})
|
|
|
|
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
|
|
// Only the OCR match should be returned
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].confidence).toBe(0.99)
|
|
})
|
|
|
|
it('scores exact amount + customer name at 0.95', async () => {
|
|
const tx = makeTransaction({ amount: 12500, description: 'Betalning Testbolaget', reference: null })
|
|
mockResult({
|
|
data: [{
|
|
...makeInvoice({ total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
|
|
customer: makeCustomer({ name: 'Testbolaget AB' }),
|
|
}],
|
|
error: null,
|
|
})
|
|
|
|
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].confidence).toBe(0.95)
|
|
})
|
|
|
|
it('scores exact amount only at 0.80', async () => {
|
|
const tx = makeTransaction({ amount: 12500, description: 'Unrelated text', merchant_name: null, reference: null })
|
|
mockResult({
|
|
data: [{
|
|
...makeInvoice({ total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
|
|
customer: makeCustomer({ name: 'Completely Different Name' }),
|
|
}],
|
|
error: null,
|
|
})
|
|
|
|
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].confidence).toBe(0.80)
|
|
})
|
|
|
|
it('sorts matches by confidence descending', async () => {
|
|
const tx = makeTransaction({ amount: 12500, description: 'Betalning Testbolaget', merchant_name: null, reference: null })
|
|
mockResult({
|
|
data: [
|
|
// Exact amount, no name match → 0.80
|
|
{
|
|
...makeInvoice({ id: 'inv-low', total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
|
|
customer: makeCustomer({ name: 'Nope Corp' }),
|
|
},
|
|
// Exact amount + name match → 0.95
|
|
{
|
|
...makeInvoice({ id: 'inv-high', total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
|
|
customer: makeCustomer({ name: 'Testbolaget AB' }),
|
|
},
|
|
],
|
|
error: null,
|
|
})
|
|
|
|
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
|
|
expect(result).toHaveLength(2)
|
|
expect(result[0].confidence).toBe(0.95)
|
|
expect(result[1].confidence).toBe(0.80)
|
|
})
|
|
|
|
it('filters out matches below 0.50 threshold', async () => {
|
|
const tx = makeTransaction({ amount: 99999, description: 'No match', merchant_name: null, reference: null })
|
|
mockResult({
|
|
data: [{
|
|
...makeInvoice({ total: 50000, status: 'sent', remaining_amount: 50000, currency: 'SEK' }),
|
|
customer: makeCustomer({ name: 'Irrelevant' }),
|
|
}],
|
|
error: null,
|
|
})
|
|
|
|
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('uses remaining_amount for partially_paid invoices', async () => {
|
|
const tx = makeTransaction({ amount: 5000, description: 'Unrelated', merchant_name: null, reference: null })
|
|
mockResult({
|
|
data: [{
|
|
...makeInvoice({ total: 12500, remaining_amount: 5000, status: 'partially_paid', currency: 'SEK' }),
|
|
customer: makeCustomer({ name: 'Different' }),
|
|
}],
|
|
error: null,
|
|
})
|
|
|
|
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].confidence).toBe(0.80) // exact amount match
|
|
})
|
|
|
|
it('skips invoices with non-matching currency', async () => {
|
|
const tx = makeTransaction({ amount: 12500, currency: 'SEK', description: 'Payment', merchant_name: null, reference: null })
|
|
mockResult({
|
|
data: [{
|
|
...makeInvoice({ total: 12500, remaining_amount: 12500, status: 'sent', currency: 'EUR', total_sek: null }),
|
|
customer: makeCustomer({ name: 'Different' }),
|
|
}],
|
|
error: null,
|
|
})
|
|
|
|
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
|
|
// EUR invoice with no total_sek → currency mismatch → skipped
|
|
expect(result).toEqual([])
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// getBestInvoiceMatch
|
|
// ============================================================
|
|
|
|
describe('getBestInvoiceMatch', () => {
|
|
const { supabase, mockResult } = createMockSupabase()
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('returns highest-confidence match when above minConfidence', async () => {
|
|
const tx = makeTransaction({ amount: 12500, description: 'Unrelated', merchant_name: null, reference: null })
|
|
mockResult({
|
|
data: [{
|
|
...makeInvoice({ total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
|
|
customer: makeCustomer({ name: 'Different' }),
|
|
}],
|
|
error: null,
|
|
})
|
|
|
|
const result = await getBestInvoiceMatch(supabase as never, 'company-1', tx)
|
|
expect(result).not.toBeNull()
|
|
expect(result!.confidence).toBe(0.80)
|
|
})
|
|
|
|
it('returns null when best match below minConfidence', async () => {
|
|
const tx = makeTransaction({ amount: 12500, description: 'Unrelated', merchant_name: null, reference: null })
|
|
mockResult({
|
|
data: [{
|
|
...makeInvoice({ total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
|
|
customer: makeCustomer({ name: 'Different' }),
|
|
}],
|
|
error: null,
|
|
})
|
|
|
|
// minConfidence 0.90 → 0.80 match rejected
|
|
const result = await getBestInvoiceMatch(supabase as never, 'company-1', tx, 0.90)
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('defaults minConfidence to 0.80', async () => {
|
|
const tx = makeTransaction({ amount: 12500, description: 'Unrelated', merchant_name: null, reference: null })
|
|
mockResult({
|
|
data: [{
|
|
...makeInvoice({ total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
|
|
customer: makeCustomer({ name: 'Different' }),
|
|
}],
|
|
error: null,
|
|
})
|
|
|
|
// Exact amount only → 0.80, meets default threshold
|
|
const result = await getBestInvoiceMatch(supabase as never, 'company-1', tx)
|
|
expect(result).not.toBeNull()
|
|
})
|
|
})
|