* 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>
140 lines
6.1 KiB
TypeScript
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')
|
|
})
|
|
})
|