* 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>
118 lines
4.1 KiB
TypeScript
118 lines
4.1 KiB
TypeScript
import type { Extension } from '@/lib/extensions/types'
|
|
import type { EventPayload } from '@/lib/events/types'
|
|
import type { InvoiceInboxItem } from '@/types'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createClient } from '@supabase/supabase-js'
|
|
import { processInboxItemMatch } from './lib/process-match'
|
|
|
|
const EXTENSION_ID = 'inbox-smart-match'
|
|
|
|
// The handler always uses a service-role client:
|
|
// - processing_history has no INSERT RLS policy (audit integrity) — only service-role can append
|
|
// - every query is scoped by company_id from the event payload
|
|
// - we write pseudonymous IDs only (PII validator enforces this at the append layer)
|
|
// Using ctx.supabase is unsafe because the registry wrapper may build an anon-key
|
|
// client when no user session exists (e.g. webhook path).
|
|
function getServiceSupabase(): SupabaseClient {
|
|
return createClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
|
)
|
|
}
|
|
|
|
export const inboxSmartMatchExtension: Extension = {
|
|
id: EXTENSION_ID,
|
|
name: 'Smart matchning',
|
|
version: '0.1.0',
|
|
|
|
eventHandlers: [
|
|
// When an inbox item is freshly classified, try to match it to a transaction
|
|
{
|
|
eventType: 'inbox_item.classified',
|
|
handler: async (payload: EventPayload<'inbox_item.classified'>) => {
|
|
// Match both receipts and supplier invoices — other document types
|
|
// (government letters, unknown) have nothing to match against.
|
|
if (payload.documentType !== 'receipt' && payload.documentType !== 'supplier_invoice') {
|
|
return
|
|
}
|
|
|
|
const supabase = getServiceSupabase()
|
|
|
|
try {
|
|
await processInboxItemMatch(
|
|
{
|
|
supabase,
|
|
companyId: payload.companyId,
|
|
userId: payload.userId,
|
|
extensionId: EXTENSION_ID,
|
|
triggerReason: 'classified',
|
|
},
|
|
payload.inboxItem
|
|
)
|
|
} catch (err) {
|
|
console.error(
|
|
`[${EXTENSION_ID}] Failed to process classified inbox item ${payload.inboxItem.id}:`,
|
|
err
|
|
)
|
|
}
|
|
},
|
|
},
|
|
|
|
// When new transactions land, retry matching on any receipts still waiting
|
|
{
|
|
eventType: 'transaction.synced',
|
|
handler: async (payload: EventPayload<'transaction.synced'>) => {
|
|
const newTransactionIds = payload.transactions.map((t) => t.id).filter(Boolean)
|
|
if (newTransactionIds.length === 0) return
|
|
|
|
const supabase = getServiceSupabase()
|
|
|
|
// Find pending receipts/invoices for this company. Cap at 10 per sync
|
|
// so one big bank import doesn't time out the handler; leftover pending
|
|
// items pick up on the next sync.
|
|
const { data: pendingItems, error } = await supabase
|
|
.from('invoice_inbox_items')
|
|
.select('*')
|
|
.eq('company_id', payload.companyId)
|
|
.in('document_type', ['receipt', 'supplier_invoice'])
|
|
.eq('status', 'ready')
|
|
.eq('match_method', 'pending_transaction')
|
|
.order('created_at', { ascending: false })
|
|
.limit(10)
|
|
|
|
if (error) {
|
|
console.error(`[${EXTENSION_ID}] Failed to fetch pending receipts:`, error)
|
|
return
|
|
}
|
|
if (!pendingItems || pendingItems.length === 0) return
|
|
|
|
// Run LLM calls in parallel; one failing receipt shouldn't stop the others.
|
|
const results = await Promise.allSettled(
|
|
(pendingItems as InvoiceInboxItem[]).map((item) =>
|
|
processInboxItemMatch(
|
|
{
|
|
supabase,
|
|
companyId: payload.companyId,
|
|
userId: payload.userId,
|
|
extensionId: EXTENSION_ID,
|
|
triggerReason: 'transaction_synced',
|
|
},
|
|
item
|
|
)
|
|
)
|
|
)
|
|
|
|
results.forEach((r, i) => {
|
|
if (r.status === 'rejected') {
|
|
const itemId = (pendingItems[i] as InvoiceInboxItem).id
|
|
console.error(
|
|
`[${EXTENSION_ID}] Retroactive match failed for item ${itemId}:`,
|
|
r.reason
|
|
)
|
|
}
|
|
})
|
|
},
|
|
},
|
|
],
|
|
}
|