Files
accounted/components/inbox/TransactionMatchPicker.tsx
T
Mattsson 1a3686dd45 feat(inbox): promote a single prominent amount into the editable total (#2073)
* feat(inbox): promote a single prominent amount into the editable total

Follow-up to #2048 after founder review: the Belopp row was load-bearing
for matching but read-only, so a misread amount could not be corrected,
and an empty TOTALT still read as "extraction failed".

- promoteSingleProminentAmount (extraction post-step, all intake paths):
  documentKind other/government_letter with no total and exactly one
  distinct nonzero prominent amount gets it copied into totals.total,
  stamped totalSource: 'prominent'. Multi-amount documents are left
  alone: picking one silently would invent a total.
- provenance keeps the safety rails: matching demotes a promoted total
  back through the prominent-amounts fallback (0.85 discount, date guard,
  amountSource tag), so the nightly receipt-hunt still excludes these
  documents and confidence never presents as certainty.
- the fields-PATCH route clears totalSource when a human edits TOTALT:
  a user-set amount is a verified total at full weight.
- the read-only Belopp row now renders only for multi-amount documents,
  and filters zero amounts ("Totalt manadspris: 0 kr" noise).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb

* fix(inbox): review pass: concurrency-safe fields PATCH, zero-amount predicate

CodeRabbit findings on #2073:
- the fields-PATCH read-merge-write could let a racing autosave restore a
  stale extracted_data blob (including a totalSource stamp a concurrent
  TOTALT edit had just cleared). The update is now conditional on the
  trigger-maintained updated_at; zero rows matched returns 409 and the
  client's next debounced save re-reads.
- hasAnyExtractedField now uses the same meaningful-amount predicate as
  the Belopp render filter, so a zero-only prominentAmounts list no longer
  suppresses the retry / upgrade affordances.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hqm9QgdyNAFaWiz6Ww7pgb

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:40:30 +02:00

509 lines
20 KiB
TypeScript

'use client'
import { useState, useEffect, useMemo } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { Loader2, Search } from 'lucide-react'
import { Input } from '@/components/ui/input'
import {
FALLBACK_CONFIDENCE_FACTOR,
amountVarianceForMatch,
bestProminentAmountVariance,
calculateMatchConfidence,
calculateMerchantSimilarity,
} from '@/lib/documents/core-receipt-matcher'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
import { roundOre } from '@/lib/money'
import type { InvoiceExtractionResult } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// TransactionMatchPicker
//
// Opens from the InvoiceInboxWorkspace FieldsRail when the user clicks
// "Matcha mot transaktion" on an inbox item whose matched_transaction_id is
// null. Lists *uncategorised* transactions only: matching points an underlag
// at the bank payment you're about to book, so already-booked rows are out of
// scope (and surfacing them would invite a double-booking via "Bokför
// manuellt"). Two modes:
// • Suggested (no search): every uncategorised company transaction, scored
// via lib/documents/core-receipt-matcher and sorted best-first. No date
// window: over-fetching is cheap for a manual picker and a hard window
// silently dropped late payments out of the candidate set.
// • Search (≥2 chars): the same uncategorised set across ALL dates, narrowed
// server-side by description/merchant. This is the fix for "not all
// transactions come up to search for": the old client-only filter could
// only see the windowed rows already fetched.
// Amounts are compared currency-aware (see scoring memo): same-currency raw,
// otherwise normalised to SEK via the underlag's FX rate. User picks one →
// POST /items/:id/match-transaction → onMatched callback.
interface RawTransaction {
id: string
date: string
description: string | null
merchant_name: string | null
amount: number
currency: string | null
amount_sek: number | null
exchange_rate: number | null
}
interface CandidateTransaction {
id: string
date: string
description: string | null
merchant_name: string | null
amount: number
currency: string
/** SEK-equivalent for non-SEK transactions, for the "≈ … kr" hint. */
amountSek: number | null
confidence: number
reasons: string[]
}
interface Props {
open: boolean
onClose: () => void
inboxItemId: string
extractedData: InvoiceExtractionResult | null
onMatched: (transactionId: string) => void
}
// Currencies the /api/currency/rate endpoint (Riksbanken) can resolve. Used
// to normalise a foreign-currency underlag total into SEK so it can be
// compared against SEK bank charges.
const SUPPORTED_FX = ['EUR', 'USD', 'GBP', 'NOK', 'DKK']
// Date tolerance used only for *ranking* candidates (not for filtering, there
// is no longer a date window). Far wider than the receipt matcher's tight ±3d
// default so a payment landing weeks or months after the invoice still earns
// partial date credit and the true match floats to the top, instead of every
// candidate collapsing to "Svag match".
const MATCH_DATE_TOLERANCE_DAYS = 120
// Minimum characters before a keystroke flips the picker into server-side
// search mode. Below this we stay on the scored suggestions and just narrow
// them client-side for instant feedback.
const SEARCH_MIN_CHARS = 2
export default function TransactionMatchPicker({
open,
onClose,
inboxItemId,
extractedData,
onMatched,
}: Props) {
const supabase = useMemo(() => createClient(), [])
const { company } = useCompany()
const { toast } = useToast()
// Starts true so the first paint after opening shows skeletons, not a flash
// of the empty state before the fetch effect runs.
const [loading, setLoading] = useState(true)
const [rawRows, setRawRows] = useState<RawTransaction[]>([])
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [matchingId, setMatchingId] = useState<string | null>(null)
// SEK per unit of the underlag currency (e.g. ~11.5 for EUR). null = SEK
// underlag, fetch pending, or unsupported/failed: amount matching then
// falls back to same-currency-only.
const [fxRate, setFxRate] = useState<number | null>(null)
// ── Underlag (receipt) facts pulled from extracted_data ──────
const rawInvoiceDate = extractedData?.invoice?.invoiceDate ?? null
const hasInvoiceDate = useMemo(() => {
if (!rawInvoiceDate) return false
return !Number.isNaN(new Date(rawInvoiceDate).getTime())
}, [rawInvoiceDate])
// Default to today if no date was extracted so scoring still has an anchor.
const invoiceDate = useMemo(() => {
if (!rawInvoiceDate) return new Date()
const parsed = new Date(rawInvoiceDate)
return Number.isNaN(parsed.getTime()) ? new Date() : parsed
}, [rawInvoiceDate])
// A total promoted from the document's single prominent amount (totalSource
// 'prominent') is fallback-grade evidence, not an invoice total: demote it
// here so it is scored through the discounted fallback path below. A
// user-edited total has the stamp cleared and counts at full weight.
const total =
extractedData?.totalSource === 'prominent' ? null : (extractedData?.totals?.total ?? null)
const receiptCurrency = (extractedData?.invoice?.currency ?? 'SEK').toUpperCase()
const supplier = extractedData?.supplier?.name ?? null
// Non-invoice documents (bankintyg, avtal) have no total but often show the
// money amount anyway; those feed a discounted amount fallback below.
const prominentAmounts = useMemo(
() =>
total == null
? (extractedData?.prominentAmounts ?? []).filter(
(a) => Number.isFinite(a.amount) && a.amount !== 0,
)
: [],
[extractedData, total],
)
// SEK value of the underlag total. For a SEK underlag that's the total
// itself; for a foreign one it needs the fetched FX rate.
const receiptSek = useMemo(() => {
if (total == null) return null
if (receiptCurrency === 'SEK') return total
if (fxRate != null) return Math.round(total * fxRate * 100) / 100
return null
}, [total, receiptCurrency, fxRate])
// True when the underlag is in a foreign currency we can't price right now:
// amount matching is unavailable, so we say so instead of mis-ranking.
const amountMatchUnavailable =
total != null && receiptCurrency !== 'SEK' && receiptSek == null
// ── Reset transient state each time the dialog opens ─────────
useEffect(() => {
if (!open) return
setSearch('')
setDebouncedSearch('')
setRawRows([])
setLoading(true)
}, [open])
// ── Debounce the search term feeding the server query ────────
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 300)
return () => clearTimeout(t)
}, [search])
// ── Fetch the underlag's FX rate (foreign currency only) ─────
useEffect(() => {
if (!open) return
setFxRate(null)
if (receiptCurrency === 'SEK' || !SUPPORTED_FX.includes(receiptCurrency)) return
let cancelled = false
const dateParam = hasInvoiceDate && rawInvoiceDate ? `&date=${rawInvoiceDate}` : ''
fetch(`/api/currency/rate?currency=${receiptCurrency}${dateParam}`)
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
if (cancelled) return
const rate = body?.data?.rate
if (typeof rate === 'number' && rate > 0) setFxRate(rate)
})
.catch(() => {
/* leave null: cross-currency amount signal simply drops out */
})
return () => {
cancelled = true
}
}, [open, receiptCurrency, hasInvoiceDate, rawInvoiceDate])
// ── Fetch candidate rows (suggested vs search mode) ──────────
useEffect(() => {
if (!open) return
if (!company) return // provider still hydrating
let cancelled = false
setLoading(true)
;(async () => {
// Strip PostgREST filter-DSL structural chars before interpolating into
// `.or()`. Commas separate OR-conditions and parentheses group nested
// filters, so leaving them in would let a search term inject a synthetic
// clause (e.g. "Acme,company_id.neq.…"). `%` and `\` are LIKE wildcards/
// escapes we also drop. `.` is safe: it stays inside the ilike value.
const safe = debouncedSearch.trim().replace(/[%,()\\]/g, ' ').trim()
const searchMode = safe.length >= SEARCH_MIN_CHARS
// Defense-in-depth: RLS only narrows to "any company the user belongs
// to". Multi-tenant users (consultants) would otherwise see other
// companies' transactions. Filter to the active company explicitly.
// Uncategorised only (journal_entry_id IS NULL) in both modes: see the
// component header. The search just adds a server-side name filter so
// matches outside the suggested set still surface.
let query = supabase
.from('transactions')
.select(
'id, date, description, merchant_name, amount, currency, amount_sek, exchange_rate',
)
.eq('company_id', company.id)
.is('journal_entry_id', null)
if (searchMode) {
query = query
.or(`description.ilike.%${safe}%,merchant_name.ilike.%${safe}%`)
.order('date', { ascending: false })
.limit(100)
} else {
// Recent first; scoring floats the best match up regardless of date.
query = query.order('date', { ascending: false }).limit(300)
}
const { data, error } = await query
if (cancelled) return
if (error) {
toast({
title: 'Kunde inte hämta transaktioner',
description: getUserErrorMessage(error),
variant: 'destructive',
})
setRawRows([])
setLoading(false)
return
}
setRawRows((data ?? []) as RawTransaction[])
setLoading(false)
})()
return () => {
cancelled = true
}
}, [open, supabase, company, debouncedSearch, toast])
// ── Score + sort (currency-aware) ────────────────────────────
const candidates = useMemo<CandidateTransaction[]>(() => {
const scored = rawRows.map((tx) => {
const txCurrency = (tx.currency ?? 'SEK').toUpperCase()
const txSek =
txCurrency === 'SEK'
? tx.amount
: resolveSekAmount(tx.amount, tx.amount_sek, tx.currency, tx.exchange_rate)
// Currency-aware variance: null when uncomparable, which makes the
// matcher drop the amount signal instead of matching 750 EUR to 750 SEK.
let amountVariance = amountVarianceForMatch(
total,
receiptCurrency,
receiptSek,
tx.amount,
txCurrency,
txSek,
)
// No total (bankintyg, avtal): fall back to the closest prominent
// amount, discounted below so a printed figure never presents as the
// certainty a real total gives.
const fallbackMatch =
total == null && prominentAmounts.length > 0
? bestProminentAmountVariance(
prominentAmounts,
receiptCurrency,
tx.amount,
txCurrency,
txSek,
)
: null
if (fallbackMatch) amountVariance = fallbackMatch.variance
const dateVariance = Math.abs(
(new Date(tx.date).getTime() - invoiceDate.getTime()) / (1000 * 60 * 60 * 24),
)
const merchant = tx.merchant_name || tx.description || ''
const similarity = supplier ? calculateMerchantSimilarity(supplier, merchant) : 0
const scoredMatch = calculateMatchConfidence(
dateVariance,
amountVariance,
similarity,
MATCH_DATE_TOLERANCE_DAYS,
)
const matchReasons = scoredMatch.matchReasons
const confidence = fallbackMatch
? roundOre(scoredMatch.confidence * FALLBACK_CONFIDENCE_FACTOR)
: scoredMatch.confidence
return {
id: tx.id,
date: tx.date,
description: tx.description ?? null,
merchant_name: tx.merchant_name ?? null,
amount: tx.amount,
currency: txCurrency,
amountSek: txCurrency === 'SEK' ? null : Math.round(txSek * 100) / 100,
confidence,
reasons: matchReasons,
}
})
scored.sort((a, b) => b.confidence - a.confidence)
return scored
}, [rawRows, invoiceDate, total, prominentAmounts, receiptCurrency, receiptSek, supplier])
// Instant client-side narrowing while the debounced server query catches up.
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return candidates
return candidates.filter((c) => {
const hay = `${c.description ?? ''} ${c.merchant_name ?? ''}`.toLowerCase()
return hay.includes(q)
})
}, [candidates, search])
// Any typed text counts as "searching" for labelling/empty-state purposes
// (1 char narrows the suggested set client-side; ≥2 also hits the server).
const hasSearchText = search.trim().length > 0
async function handlePick(transactionId: string) {
setMatchingId(transactionId)
try {
const res = await fetch(
`/api/extensions/ext/invoice-inbox/items/${inboxItemId}/match-transaction`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_id: transactionId }),
},
)
const json = (await res.json().catch(() => ({}))) as { error?: string }
if (!res.ok) {
toast({
title: 'Kunde inte matcha',
description: json.error ?? `HTTP ${res.status}`,
variant: 'destructive',
})
return
}
onMatched(transactionId)
onClose()
} finally {
setMatchingId(null)
}
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Matcha mot transaktion</DialogTitle>
<DialogDescription>
Välj banktransaktionen som hör till underlaget.
</DialogDescription>
</DialogHeader>
{/* Underlag reference: what we're matching against, so a currency or
amount mismatch with a candidate is obvious at a glance. */}
{(total != null || prominentAmounts.length > 0 || supplier) && (
<div className="rounded-lg border bg-muted/30 px-3 py-2 text-xs flex items-center gap-x-3 gap-y-1 flex-wrap">
<span className="text-muted-foreground shrink-0">Underlag</span>
{supplier && <span className="font-medium truncate">{supplier}</span>}
{total != null && (
<span className="tabular-nums font-medium shrink-0">
{formatCurrency(total, receiptCurrency)}
{receiptSek != null && receiptCurrency !== 'SEK' && (
<span className="text-muted-foreground font-normal">
{' '}
{formatCurrency(receiptSek, 'SEK')}
</span>
)}
</span>
)}
{total == null && prominentAmounts.length > 0 && (
<span className="tabular-nums font-medium shrink-0">
{prominentAmounts.map((a) => formatCurrency(a.amount, receiptCurrency)).join(' · ')}
</span>
)}
{hasInvoiceDate && rawInvoiceDate && (
<span className="text-muted-foreground tabular-nums shrink-0">
{formatDate(rawInvoiceDate)}
</span>
)}
</div>
)}
{amountMatchUnavailable && (
<p className="text-[11px] text-muted-foreground -mt-1">
Växelkurs saknas för {receiptCurrency}: kandidaterna rankas datum
och leverantör, inte belopp.
</p>
)}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Sök i alla transaktioner…"
className="pl-9"
/>
</div>
<div className="flex items-center justify-between px-1 text-[11px] text-muted-foreground">
<span>{hasSearchText ? 'Sökresultat' : 'Föreslagna matchningar'}</span>
{!loading && <span className="tabular-nums">{filtered.length} st</span>}
</div>
<div className="max-h-[55vh] overflow-y-auto -mx-6 px-6 divide-y">
{loading ? (
<div className="space-y-3 py-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : filtered.length === 0 ? (
<p className="py-6 text-sm text-muted-foreground text-center">
{hasSearchText
? `Inga okategoriserade transaktioner matchar "${search.trim()}".`
: 'Inga okategoriserade transaktioner att matcha mot.'}
</p>
) : (
filtered.map((c) => {
const tier =
c.confidence >= 0.8 ? 'success' : c.confidence >= 0.5 ? 'warning' : 'outline'
const tierLabel =
c.confidence >= 0.8
? 'Stark match'
: c.confidence >= 0.5
? 'Möjlig match'
: 'Svag match'
const isMatching = matchingId === c.id
return (
<button
key={c.id}
type="button"
onClick={() => void handlePick(c.id)}
disabled={!!matchingId}
className={cn(
'w-full text-left flex items-center gap-3 py-3 hover:bg-muted/50 transition-colors px-2 -mx-2 rounded-sm',
matchingId && !isMatching && 'opacity-50',
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium truncate">
{c.merchant_name ?? c.description ?? 'Okänd transaktion'}
</span>
<Badge variant={tier} className="shrink-0 text-[10px]">
{tierLabel}
</Badge>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
<span>{formatDate(c.date)}</span>
{c.reasons.length > 0 && (
<>
<span>·</span>
<span className="truncate">{c.reasons.join(' · ')}</span>
</>
)}
</div>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-medium tabular-nums">
{formatCurrency(c.amount, c.currency)}
</p>
{c.amountSek != null && (
<p className="text-[11px] text-muted-foreground tabular-nums">
{formatCurrency(c.amountSek, 'SEK')}
</p>
)}
</div>
{isMatching && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
</button>
)
})
)}
</div>
</DialogContent>
</Dialog>
)
}