From cd9b540401294906b47afe46dd687dab4451ce9f Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 11 Aug 2026 12:14:49 +0200 Subject: [PATCH] feat(inbox): show what a matched underlag would be booked as (#1515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(inbox): show what a matched underlag would be booked as The rail told you what was extracted and never what would happen. A matched item offered "Bokför manuellt" and a dialog, so the only way to learn the answer was to open the dialog and read it there. The proposal now sits in the rail: the accounts, the amounts, the day it would be booked, and a folded reason. Read-only, because convention 14 says nothing AI-suggested posts without review; the dialog below is still the only way to commit it. When there is no proposal it says why in a sentence instead of showing an empty table. An unknown supplier gets "bokför manuellt en gång, så känns den igen nästa gång", which is true and actionable. A foreign-currency row matched by a konteringsregel says the moms would be wrong, because it would. Balance is stated only when it fails. The lines come from the same builder the commit path uses and balance by construction, so a mismatch means something upstream is wrong and the user should see it before booking rather than after. Co-Authored-By: Claude Opus 5 (1M context) * fix(inbox): name the money columns and abort superseded suggestions Two from review. Debet and Kredit were distinguishable only by column position and a muted tint, which the design rules rule out: colour is never the sole indicator. They have headers now. The suggestion fetch cancelled its setState but not the request. Arrowing down a list fires one per row, so the superseded ones still ran to completion server-side and a slow one could resolve after a faster later one. AbortController now cancels them. Left alone: lifting the response type into @/types. It is the contract between one route and one component, and a second declaration is a second thing to keep in step. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- .../general/InvoiceInboxWorkspace.tsx | 172 +++++++++++++++++- 1 file changed, 171 insertions(+), 1 deletion(-) diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 93fc9875..0d9d560f 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -38,7 +38,7 @@ import { MessageCircle, } from 'lucide-react' import Link from 'next/link' -import { cn, formatCurrency } from '@/lib/utils' +import { cn, formatCurrency, formatDate } from '@/lib/utils' import { createClient } from '@/lib/supabase/client' import { fetchWithTimeout } from '@/lib/http/fetch-with-timeout' import { copyInboxAddress, type AddressCopyState } from '@/components/extensions/general/inbox-address-copy' @@ -1764,6 +1764,172 @@ function EmptyPreview({ ) } +// ── Proposed kontering ─────────────────────────────────────── + +/** + * What this underlag would be booked as, for a matched transaction. + * + * Read-only on purpose. Per convention 14 nothing AI-suggested posts without + * review, so this shows the answer and the existing booking dialog remains the + * only way to commit it. The lines come from the same builder the commit path + * uses, so what is shown here is what would be posted. + * + * The route answers honestly when it cannot propose: a company with no rule and + * no history, a foreign-currency row the engine would mis-VAT, a transaction + * that already carries a verifikat. Each of those renders as a sentence rather + * than as an empty table, because "we have nothing for this" is information. + */ +type SuggestedBooking = { + source: + | 'mapping_rule' + | 'booking_template' + | 'counterparty_template' + | 'no_mapping' + | 'no_transaction' + | 'already_booked' + | 'currency_unsupported' + lines: { account_number: string; debit_amount: number; credit_amount: number; description: string }[] + confidence: number | null + requires_review?: boolean + direction_mismatch?: boolean + description?: string + rule_name?: string | null + entry_date?: string +} + +const SUGGESTION_SOURCE_LABEL: Record = { + counterparty_template: 'Så du brukar bokföra den här leverantören', + booking_template: 'Från en bokföringsmall', + mapping_rule: 'Från en konteringsregel', +} + +/** Why there is no proposal, said plainly rather than shown as an empty table. */ +const SUGGESTION_EMPTY_REASON: Record = { + no_mapping: + 'Vi har inget förslag: leverantören är obekant och ingen regel matchar. Bokför manuellt en gång, så känns den igen nästa gång.', + currency_unsupported: + 'Köpet är i utländsk valuta och matchades av en konteringsregel. Momsen skulle bli fel, så vi visar inget förslag.', +} + +function ProposedBooking({ itemId }: { itemId: string }) { + const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading') + const [data, setData] = useState(null) + + useEffect(() => { + // Arrowing down a list fires one of these per row. Without the abort the + // superseded requests still run to completion server-side, and a slow one + // can resolve after a faster later one. + const controller = new AbortController() + let cancelled = false + setState('loading') + setData(null) + fetch(`/api/extensions/ext/invoice-inbox/items/${itemId}/suggest-booking`, { + method: 'POST', + signal: controller.signal, + }) + .then(async (res) => { + if (!res.ok) throw new Error(String(res.status)) + return (await res.json()) as { data: SuggestedBooking } + }) + .then((json) => { + if (cancelled) return + setData(json.data) + setState('ready') + }) + .catch(() => { + // A suggestion that cannot be fetched is not something the user did: + // stay quiet rather than showing an error beside their document. + if (!cancelled) setState('failed') + }) + return () => { + cancelled = true + controller.abort() + } + }, [itemId]) + + if (state === 'loading') return + if (state === 'failed' || !data) return null + if (data.source === 'already_booked' || data.source === 'no_transaction') return null + + if (data.lines.length === 0) { + const reason = SUGGESTION_EMPTY_REASON[data.source] + return reason ?

{reason}

: null + } + + const debit = data.lines.reduce((t, l) => t + (l.debit_amount || 0), 0) + const credit = data.lines.reduce((t, l) => t + (l.credit_amount || 0), 0) + const balanced = Math.round((debit - credit) * 100) === 0 + + return ( +
+
+

Föreslagen kontering

+ {data.entry_date && ( + + Bokförs {formatDate(data.entry_date)} + + )} +
+ + + + + + + + + + + {data.lines.map((l, i) => ( + + + + + + + ))} + +
+ Konto + DebetKredit
{l.account_number} + {l.description} + + {l.debit_amount ? formatCurrency(l.debit_amount) : ''} + + {l.credit_amount ? formatCurrency(l.credit_amount) : ''} +
+ + {/* Balance is stated rather than assumed: the builder balances by + construction, so a mismatch here means something upstream is wrong + and the user should see it before booking. */} + {!balanced && ( +

+ Debet {formatCurrency(debit)} · Kredit {formatCurrency(credit)} +

+ )} + + {(SUGGESTION_SOURCE_LABEL[data.source] || data.requires_review || data.direction_mismatch) && ( +
+ Varför så här? +
+ {SUGGESTION_SOURCE_LABEL[data.source] &&

{SUGGESTION_SOURCE_LABEL[data.source]}

} + {data.rule_name &&

Regel: {data.rule_name}

} + {data.direction_mismatch && ( +

+ Beloppets riktning stämmer inte med hur leverantören brukar bokföras. Kontrollera innan du + bokför. +

+ )} + {data.requires_review && !data.direction_mismatch && ( +

Förslaget är osäkert och bör granskas innan du bokför.

+ )} +
+
+ )} +
+ ) +} + // ── Fields rail ────────────────────────────────────────────── function FieldsRail({ @@ -2111,6 +2277,10 @@ function FieldsRail({ Öppna transaktionen → + + {/* The answer the rail never gave: what this would be booked as. + Read-only; booking still goes through the dialog below. */} + {onAskAssistant && (