Files
accounted/extensions/general/inbox-smart-match/lib/fetch-candidates.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

171 lines
5.9 KiB
TypeScript

/**
* Candidate transaction fetcher — deterministic narrowing before the LLM call.
*
* Pulls unbooked expense transactions near the document's payment date,
* ordered by how close their amount is to the document total. Limits to top 5
* so the LLM has a focused candidate set and the token cost stays bounded.
*
* Anchor date selection:
* - Receipts: receipt date ±7 days (paid on the spot)
* - Invoices with dueDate: dueDate ±14 days (covers early and late payments)
* - Invoices without dueDate: invoiceDate, window shifted forward to cover
* standard 30-day terms (invoiceDate-7 .. invoiceDate+45)
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
const MAX_CANDIDATES = 5
export interface CandidateTransaction {
id: string
date: string
description: string
amount: number
amount_sek: number | null
currency: string
merchant_name: string | null
}
export type ExtractedDocument = ReceiptExtractionResult | InvoiceExtractionResult
export interface MatchAnchors {
date: string
amount: number
currency: string
counterpartyName: string | null
windowDaysBefore: number
windowDaysAfter: number
}
function isInvoiceExtraction(e: ExtractedDocument): e is InvoiceExtractionResult {
return 'invoice' in e && typeof (e as InvoiceExtractionResult).invoice === 'object'
}
/**
* Extract the reference date and absolute amount from a classified document's
* extracted data. Returns null if required fields are missing.
*/
export function getMatchAnchors(extracted: ExtractedDocument | null): MatchAnchors | null {
if (!extracted) return null
let date: string | null
let currency: string
let counterpartyName: string | null
let windowDaysBefore: number
let windowDaysAfter: number
if (isInvoiceExtraction(extracted)) {
const dueDate = extracted.invoice?.dueDate ?? null
const invoiceDate = extracted.invoice?.invoiceDate ?? null
if (dueDate) {
date = dueDate
windowDaysBefore = 14
windowDaysAfter = 14
} else {
date = invoiceDate
windowDaysBefore = 7
windowDaysAfter = 45
}
currency = extracted.invoice?.currency ?? 'SEK'
counterpartyName = extracted.supplier?.name ?? null
} else {
date = extracted.receipt?.date ?? null
currency = extracted.receipt?.currency ?? 'SEK'
counterpartyName = extracted.merchant?.name ?? null
windowDaysBefore = 7
windowDaysAfter = 7
}
const amount = extracted.totals?.total ?? null
if (!date || amount == null || amount <= 0) return null
return { date, amount, currency, counterpartyName, windowDaysBefore, windowDaysAfter }
}
/**
* Fetch up to MAX_CANDIDATES unbooked expense transactions near the document's
* date + amount. Ordering prefers exact amount matches first.
*/
export async function fetchCandidateTransactions(
supabase: SupabaseClient,
companyId: string,
extracted: ExtractedDocument | null
): Promise<CandidateTransaction[]> {
const anchors = getMatchAnchors(extracted)
if (!anchors) return []
const anchorDate = new Date(anchors.date)
if (isNaN(anchorDate.getTime())) return []
const windowStart = new Date(anchorDate)
windowStart.setUTCDate(windowStart.getUTCDate() - anchors.windowDaysBefore)
const windowEnd = new Date(anchorDate)
windowEnd.setUTCDate(windowEnd.getUTCDate() + anchors.windowDaysAfter)
// Exclude transactions already claimed by any other inbox item in this
// company. The partial unique index on (company_id, matched_transaction_id)
// is the final guard against concurrent double-matches, but filtering up
// front saves an LLM roundtrip on the obvious cases.
const { data: claimed, error: claimedError } = await supabase
.from('invoice_inbox_items')
.select('matched_transaction_id')
.eq('company_id', companyId)
.not('matched_transaction_id', 'is', null)
if (claimedError) {
throw new Error(`Failed to load matched transactions: ${claimedError.message}`)
}
const excludedIds = new Set(
(claimed ?? [])
.map((row) => (row as { matched_transaction_id: string | null }).matched_transaction_id)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
)
// Pull negative-amount (expense) transactions without a journal entry in the window
const { data, error } = await supabase
.from('transactions')
.select('id, date, description, amount, amount_sek, currency, merchant_name')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.lt('amount', 0)
.gte('date', windowStart.toISOString().slice(0, 10))
.lte('date', windowEnd.toISOString().slice(0, 10))
.order('date', { ascending: false })
.limit(50)
if (error) {
throw new Error(`Failed to fetch candidate transactions: ${error.message}`)
}
if (!data || data.length === 0) return []
const filtered = excludedIds.size > 0
? data.filter((tx) => !excludedIds.has(tx.id as string))
: data
if (filtered.length === 0) return []
// Rank candidates by amount proximity. For SEK documents we compare directly,
// for other currencies we prefer amount_sek if the document amount has been converted.
const anchorAbs = Math.abs(anchors.amount)
const scored = filtered.map((tx) => {
const txAmount = Math.abs(Number(tx.amount) || 0)
const txSek = tx.amount_sek == null ? null : Math.abs(Number(tx.amount_sek))
const primaryDiff = Math.abs(txAmount - anchorAbs)
const sekDiff = txSek == null ? Infinity : Math.abs(txSek - anchorAbs)
const bestDiff = Math.min(primaryDiff, sekDiff)
return { tx, diff: bestDiff }
})
scored.sort((a, b) => a.diff - b.diff)
return scored.slice(0, MAX_CANDIDATES).map(({ tx }) => ({
id: tx.id,
date: tx.date,
description: tx.description ?? '',
amount: Number(tx.amount),
amount_sek: tx.amount_sek == null ? null : Number(tx.amount_sek),
currency: tx.currency ?? 'SEK',
merchant_name: tx.merchant_name ?? null,
}))
}