Files
accounted/extensions/general/inbox-smart-match/__tests__/fetch-candidates.test.ts
T
Jakob WennbergandClaude Opus 4.7 b5df2fb292 feat: invoice-inbox polish + SIE source voucher traceability (#299)
* fix: consolidate commit_journal_entry to single 4-arg signature

Replaces the phantom-overload drop migration with an idempotent consolidation
that leaves only the 4-arg-with-defaults signature, callable with either 2 or
4 named args. Fixes the "Could not choose the best candidate function"
ambiguity caused when the commit-metadata migration CREATE OR REPLACE'd a
4-arg version alongside the existing 2-arg one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: preserve SIE source voucher identity on journal entries

Adds source_voucher_series / source_voucher_number columns to journal_entries
so per-verifikat traceability survives the importer's skip-empty-voucher
logic. The SIE importer populates the original series/number even when
skipped vouchers cause gnubok's target numbering to drift from the source
file's sequence. Required for BFNAR 2013:2 kap 8 behandlingshistorik.

- Migration adds columns + partial index + extends immutability trigger
- importVouchers() records rawSeries/rawNumber per voucher
- JournalEntry type + test fixtures gain the new fields
- Bookkeeping detail page surfaces "Ursprungligt verifikat" when present

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: polish invoice-inbox workspace for production use

- Bedrock image fit: shrink images > 5 MB via sharp before Bedrock upload
  so HEIC/high-res phone photos don't fail with the 5 MB cap
- Swedish error mapping: toSwedishInboxError translates Bedrock /
  infrastructure errors to Swedish sentences stored in error_message
- History timeline endpoint (GET /items/:id/history) returns the
  processing_history events correlated to the inbox item
- Workspace UI: inline diagnostic timeline inside the convert dialog,
  same-email row grouping ("+N dokument" chip), inferred-VAT affordance
  with "needs review" signalling, Riksbanken exchange-rate prefill for
  foreign-currency invoices so the supplier-invoice create path populates
  *_sek audit columns

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: extend inbox-smart-match to supplier invoices

Both receipts and supplier invoices expose structurally identical match
anchors (date, amount, currency, counterparty name) so the matcher can
reuse the same narrowing + LLM prompt. Adds getMatchAnchors() as a shared
extractor across ReceiptExtractionResult / InvoiceExtractionResult, and
updates the event handlers to process supplier_invoice items alongside
receipts. LLM prompt re-phrased as "dokument" rather than "kvitto" and
loosened the date-window heuristic since invoice payments can lag behind
the invoice date by weeks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: drop unused category selector from TransactionForm

The manual "Lägg till transaktion" dialog predates the current categorization
flow (SwipeCategorizationView, BatchCategorySelector, AI suggestions). The
category dropdown here never drove journal-entry creation — onSubmit fanned
it out to CreateTransactionInput.category, which is optional. Removes the
dropdown, the unused watch() hook, and the categories lookup table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(migrations): restore drop-phantom file and rebump timestamps

Supabase branch DB failed with PK violation on schema_migrations because
my two migrations collided with timestamps already on main:
  20260421120000 → journal_entries_with_related_rpc (PR #298)
  20260421130000 → drop_legacy_supplier_invoice_user_id_uniqueness (PR #296)

Rebumped to 20260421140000 and 20260421150000 so each migration has a
unique version (Supabase uses only the 14-digit prefix as the PK).

Also restored the 20260420130000_drop_phantom_commit_journal_entry_overload
migration I had deleted — CLAUDE.md rule #5 forbids modifying existing
migrations. My consolidate migration is still compatible: drop_phantom
drops the 4-arg overload (no-op where absent), then consolidate recreates
it with defaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(inbox-smart-match): anchor invoices on dueDate with wider window

The original ±7d window around invoiceDate filtered out all real payments
for invoices with standard 30–60 day terms — the matcher would see zero
candidates before the LLM was called, making the supplier-invoice matcher
effectively dead.

New anchor selection:
- Receipts: receipt date ±7 days (unchanged; paid on the spot)
- Invoices with dueDate: dueDate ±14 days (covers early/late payments)
- Invoices without dueDate: invoiceDate -7/+45 days (covers 30-day terms)

MatchAnchors now carries windowDaysBefore/After so the window can vary per
document shape. Added three getMatchAnchors tests asserting window sizes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:33:38 +02:00

140 lines
6.1 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
fetchCandidateTransactions,
getMatchAnchors,
} from '@/extensions/general/inbox-smart-match/lib/fetch-candidates'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
function makeReceipt(overrides?: Partial<ReceiptExtractionResult>): ReceiptExtractionResult {
return {
merchant: { name: 'Willys Hemma', orgNumber: null, vatNumber: null, isForeign: false },
receipt: { date: '2026-04-15', time: null, currency: 'SEK' },
lineItems: [],
totals: { subtotal: 239, vatAmount: 60, total: 299 },
flags: { isRestaurant: false, isSystembolaget: false, isForeignMerchant: false },
confidence: 0.9,
...overrides,
} as ReceiptExtractionResult
}
describe('fetchCandidateTransactions', () => {
it('returns empty list when extracted data is missing', async () => {
const { supabase } = createQueuedMockSupabase()
const result = await fetchCandidateTransactions(supabase as never, 'company-1', null)
expect(result).toEqual([])
})
it('returns empty list when no anchors could be derived', async () => {
const { supabase } = createQueuedMockSupabase()
const result = await fetchCandidateTransactions(
supabase as never,
'company-1',
makeReceipt({ totals: { subtotal: 0, vatAmount: 0, total: 0 } } as never)
)
expect(result).toEqual([])
})
it('ranks candidates by amount proximity and returns top 5', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// 1. already-matched lookup (none)
enqueue({ data: [] })
// 2. candidate transactions
enqueue({
data: [
{ id: 't1', date: '2026-04-15', description: 'A', amount: -400, amount_sek: null, currency: 'SEK', merchant_name: null },
{ id: 't2', date: '2026-04-14', description: 'B', amount: -299, amount_sek: null, currency: 'SEK', merchant_name: null }, // perfect match
{ id: 't3', date: '2026-04-16', description: 'C', amount: -305, amount_sek: null, currency: 'SEK', merchant_name: null }, // close
{ id: 't4', date: '2026-04-15', description: 'D', amount: -150, amount_sek: null, currency: 'SEK', merchant_name: null },
{ id: 't5', date: '2026-04-13', description: 'E', amount: -298, amount_sek: null, currency: 'SEK', merchant_name: null },
{ id: 't6', date: '2026-04-15', description: 'F', amount: -600, amount_sek: null, currency: 'SEK', merchant_name: null },
],
})
const result = await fetchCandidateTransactions(supabase as never, 'company-1', makeReceipt())
expect(result).toHaveLength(5)
expect(result[0].id).toBe('t2') // exact match sorted first
expect(result[1].id).toBe('t5') // ±1 next
})
it('excludes transactions already claimed by other inbox items', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// 1. already-matched lookup — t2 is already taken
enqueue({ data: [{ matched_transaction_id: 't2' }] })
// 2. candidate transactions
enqueue({
data: [
{ id: 't1', date: '2026-04-15', description: 'A', amount: -400, amount_sek: null, currency: 'SEK', merchant_name: null },
{ id: 't2', date: '2026-04-14', description: 'B', amount: -299, amount_sek: null, currency: 'SEK', merchant_name: null },
{ id: 't3', date: '2026-04-16', description: 'C', amount: -305, amount_sek: null, currency: 'SEK', merchant_name: null },
],
})
const result = await fetchCandidateTransactions(supabase as never, 'company-1', makeReceipt())
const ids = result.map((c) => c.id)
expect(ids).not.toContain('t2')
expect(ids).toContain('t3')
})
it('anchors invoices on dueDate with a ±14d window', () => {
const invoice: InvoiceExtractionResult = {
supplier: { name: 'Acme AB', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
invoice: { invoiceNumber: 'INV-1', invoiceDate: '2026-03-01', dueDate: '2026-03-31', paymentReference: null, currency: 'SEK' },
lineItems: [],
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
vatBreakdown: [],
confidence: 0.9,
}
const anchors = getMatchAnchors(invoice)
expect(anchors).not.toBeNull()
expect(anchors!.date).toBe('2026-03-31')
expect(anchors!.windowDaysBefore).toBe(14)
expect(anchors!.windowDaysAfter).toBe(14)
})
it('anchors invoices without dueDate on invoiceDate with a -7/+45 day window', () => {
const invoice: InvoiceExtractionResult = {
supplier: { name: 'Acme AB', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
invoice: { invoiceNumber: 'INV-1', invoiceDate: '2026-03-01', dueDate: null, paymentReference: null, currency: 'SEK' },
lineItems: [],
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
vatBreakdown: [],
confidence: 0.9,
}
const anchors = getMatchAnchors(invoice)
expect(anchors).not.toBeNull()
expect(anchors!.date).toBe('2026-03-01')
expect(anchors!.windowDaysBefore).toBe(7)
expect(anchors!.windowDaysAfter).toBe(45)
})
it('anchors receipts on receipt date with a ±7d window', () => {
const anchors = getMatchAnchors(makeReceipt())
expect(anchors).not.toBeNull()
expect(anchors!.date).toBe('2026-04-15')
expect(anchors!.windowDaysBefore).toBe(7)
expect(anchors!.windowDaysAfter).toBe(7)
})
it('uses amount_sek when receipt is foreign currency', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [] }) // no already-matched
enqueue({
data: [
{ id: 't1', date: '2026-04-15', description: 'USD-denom', amount: -895, amount_sek: -895, currency: 'SEK', merchant_name: null },
],
})
const usdReceipt = makeReceipt({
receipt: { date: '2026-04-15', time: null, currency: 'USD' },
totals: { subtotal: 80, vatAmount: 0, total: 85 },
} as never)
const result = await fetchCandidateTransactions(supabase as never, 'company-1', usdReceipt)
// Amount doesn't perfectly match but the function should return it anyway
expect(result).toHaveLength(1)
expect(result[0].id).toBe('t1')
})
})