Feat/verification attachments (#371)
* feat(attachments): enhance document preview functionality for images and PDFs * feat(reconciliation): enhance transaction matching logic and clarify reconciliation process
This commit is contained in:
@@ -73,9 +73,9 @@ describe('tryReconcileTransaction', () => {
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pass 2: Exact amount + reference match
|
||||
// Pass 2: Exact amount + OCR/reference match (within ±90 days)
|
||||
// ------------------------------------------------------------------
|
||||
it('matches on exact amount with reference match', () => {
|
||||
it('matches on exact amount with OCR reference match within 90 days', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: 3500,
|
||||
date: '2024-06-20',
|
||||
@@ -95,6 +95,47 @@ describe('tryReconcileTransaction', () => {
|
||||
expect(result!.confidence).toBe(0.90)
|
||||
})
|
||||
|
||||
// Regression: viktor@frnzn.com — recurring monthly bank fee from 2026 was
|
||||
// wrongly reconciled to a 2024 SIE-imported voucher because description +
|
||||
// amount collided. auto_reference must require a real OCR token AND a
|
||||
// bounded date window — description alone, no date check, is not enough.
|
||||
it('does NOT match recurring charge across years on description alone', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: -149,
|
||||
date: '2026-01-31',
|
||||
currency: 'SEK',
|
||||
description: 'Månadsavgift Baspaket',
|
||||
reference: null,
|
||||
})
|
||||
const line = makeGLLine({
|
||||
credit_amount: 149,
|
||||
entry_date: '2024-03-31',
|
||||
entry_description: 'Bankavgifter Månadsavgift Baspaket',
|
||||
})
|
||||
|
||||
const result = tryReconcileTransaction(tx, [line])
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('does NOT match on OCR reference when dates are >90 days apart', () => {
|
||||
const tx = makeTransaction({
|
||||
amount: 3500,
|
||||
date: '2026-06-20',
|
||||
currency: 'SEK',
|
||||
reference: '12345678',
|
||||
})
|
||||
const line = makeGLLine({
|
||||
debit_amount: 3500,
|
||||
entry_date: '2024-06-10',
|
||||
entry_description: 'Payment ref 12345678',
|
||||
})
|
||||
|
||||
const result = tryReconcileTransaction(tx, [line])
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pass 3: Exact amount + date within ±3 days
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -69,7 +69,6 @@ export function tryReconcileTransaction(
|
||||
|
||||
const txAmount = transaction.amount
|
||||
const txDate = transaction.date
|
||||
const txDescription = (transaction.description || '').toLowerCase()
|
||||
const txReference = (transaction.reference || '').toLowerCase()
|
||||
|
||||
let bestMatch: ReconciliationMatch | null = null
|
||||
@@ -82,7 +81,12 @@ export function tryReconcileTransaction(
|
||||
const fuzzyAmountMatches = Math.abs(Math.abs(txAmount) - Math.abs(lineAmount)) <= 0.01
|
||||
const exactDateMatch = txDate === line.entry_date
|
||||
const dateWithinRange = isDateWithinRange(txDate, line.entry_date, 3)
|
||||
const referenceMatch = hasReferenceMatch(txDescription, txReference, line)
|
||||
// Reference matches require BOTH a real OCR/reference token AND a bounded
|
||||
// date window. Never description-only — that collides on recurring monthly
|
||||
// charges (same description, same amount, different year). Never cross-year.
|
||||
const referenceMatch =
|
||||
hasOcrReferenceMatch(txReference, line) &&
|
||||
isDateWithinRange(txDate, line.entry_date, 90)
|
||||
|
||||
let method: ReconciliationMethod | null = null
|
||||
let confidence = 0
|
||||
@@ -92,7 +96,7 @@ export function tryReconcileTransaction(
|
||||
method = 'auto_exact'
|
||||
confidence = 0.95
|
||||
}
|
||||
// Pass 2: Exact amount + reference match
|
||||
// Pass 2: Exact amount + OCR/reference match within ±90 days
|
||||
else if (amountMatches && referenceMatch) {
|
||||
method = 'auto_reference'
|
||||
confidence = 0.90
|
||||
@@ -484,6 +488,19 @@ function isDirectionCompatible(txAmount: number, line: UnlinkedGLLine): boolean
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* OCR/reference-number match. Requires a non-trivial reference token (≥4 chars)
|
||||
* on the transaction that appears in the GL line/entry description. Description
|
||||
* substring matching is intentionally NOT done here — that collided on recurring
|
||||
* monthly charges across years (same description, same amount, different year).
|
||||
*/
|
||||
function hasOcrReferenceMatch(txReference: string, line: UnlinkedGLLine): boolean {
|
||||
if (!txReference || txReference.length < 4) return false
|
||||
const lineDesc = (line.line_description || '').toLowerCase()
|
||||
const entryDesc = (line.entry_description || '').toLowerCase()
|
||||
return lineDesc.includes(txReference) || entryDesc.includes(txReference)
|
||||
}
|
||||
|
||||
/** Check if two dates are within ±dayRange of each other */
|
||||
function isDateWithinRange(date1: string, date2: string, dayRange: number): boolean {
|
||||
const d1 = new Date(date1)
|
||||
@@ -493,32 +510,6 @@ function isDateWithinRange(date1: string, date2: string, dayRange: number): bool
|
||||
return diffDays <= dayRange
|
||||
}
|
||||
|
||||
/** Check if transaction description/reference matches the GL line description */
|
||||
function hasReferenceMatch(
|
||||
txDescription: string,
|
||||
txReference: string,
|
||||
line: UnlinkedGLLine
|
||||
): boolean {
|
||||
const lineDesc = (line.line_description || '').toLowerCase()
|
||||
const entryDesc = (line.entry_description || '').toLowerCase()
|
||||
|
||||
if (!txReference && !txDescription) return false
|
||||
|
||||
// Check OCR/reference number match
|
||||
if (txReference && txReference.length >= 4) {
|
||||
if (lineDesc.includes(txReference) || entryDesc.includes(txReference)) return true
|
||||
}
|
||||
|
||||
// Check description overlap (at least 8 chars matching substring)
|
||||
if (txDescription && txDescription.length >= 8) {
|
||||
if (lineDesc.includes(txDescription) || entryDesc.includes(txDescription)) return true
|
||||
if (txDescription.includes(lineDesc) && lineDesc.length >= 8) return true
|
||||
if (txDescription.includes(entryDesc) && entryDesc.length >= 8) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy matching: run 4-pass matching, each pass at a specific confidence level.
|
||||
* Track used GL lines and transactions to prevent double-matching.
|
||||
|
||||
Reference in New Issue
Block a user