fix(invoices): ROT/RUT credit notes; verifikat amount sort, HTML underlag, source chip (#1523)

* feat(invoice-inbox): store HTML mails as underlag, expandable field editor

Body-only mails and .html attachments (including forwarded .eml bodies) no
longer dead-end as "Fel vid bearbetning": the mail body is wrapped into a
self-contained text/html document, stored through the normal upload/extract
pipeline, and extracted via a new HTML-to-text Bedrock path, so the mail
itself can serve as bookable underlag. Empty mails keep the error row,
unsupported types are still rejected, and webhook retries dedupe on
resend_email_id.

Mail HTML is attacker-controlled, so rendering is fully sandboxed: iframe
sandbox in the workspace preview and a CSP sandbox header on
/api/documents/:id/inline for text/html. The type is accepted only from the
email pipeline (EMAIL_ALLOWED_MIME_TYPES), never from manual upload.

The "Extraherade falt" rail gains an expand button opening a centered
dialog with the same autosaving field editor at a readable size (two
columns), which also gives every failed or skipped extraction a manual
fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): sortable verifikat list headers with amount sort

- clickable sort toggles on the verifikat list headers (asc -> desc -> default)
- total_amount computed column + sort_by total/description on the list route
- failed list loads render an error card with retry, never the empty-ledger state

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(import): decode bank CSV as Windows-1252 fallback in column mapping

The client read the uploaded file with file.text(), which is UTF-8-only,
so Windows-1252 exports (e.g. Handelsbanken) rendered and re-parsed with
U+FFFD in place of Swedish characters. Decode from bytes with the shared
decodeFileContent() helper, matching what the server parse route does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bookkeeping): stackable sort keys on verifikat list headers

- shift-click adds a column as secondary/tertiary sort key (max 3), plain
  click keeps the single-key tri-state cycle
- sort_by accepts a comma-separated priority list; single tokens stay valid
- voucher tiebreak follows the last key's direction (#972 parity)
- priority numbers on stacked headers; hint text in the filter dialog

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): keep ROT/RUT deduction fields positive on credit notes

Crediting an invoice with a ROT/RUT deduction failed 100% of the time:
the credit-note path negated deduction_total (and per-item
deduction_amount) like the other amounts, but both columns carry
CHECK (>= 0), so Postgres rejected the insert and the user only saw
'Kunde inte skapa kreditfaktura'.

Store the deduction fields as positive magnitudes, matching the
convention everywhere else. The stored sign is inert on credit notes:
the reversing verifikat recomputes the ROT/RUT split from the items,
and the PDF and amount-to-pay logic skip deductions on credit notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(transactions): share the source chip across inbox and history modes

Move SourceFilter to transaction-types.ts (widened with 'bank:other' and
'acct:<id>'), render the one toolbar ContextPicker in both view modes,
and drop the narrower duplicate chip inside TransactionHistoryList. The
history list now applies the acct:/bank:other narrowing itself and hides
skattekonto rows under any bank-side selection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(deps): bump js-yaml to 4.3.1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(schema): recognize PostgREST computed columns in the migration parser

The verifikat amount sort orders by total_amount, a PostgREST computed
column (a function on the journal_entries row type, migration
20260811100000). The schema guard only modeled real columns, so
no-phantom-columns flagged the order as a phantom.

Teach the parser that a function whose only argument is a table's row
type joins that table's column set, with DROP FUNCTION retraction when
the signature names the row type.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: resolve PR #1523 review findings

- journal-entries route: append the globally unique id tiebreak to every
  direct-query sort; voucher series+number repeat across fiscal years, so
  the all-years scope could duplicate or drop rows at page boundaries.
  Existing order assertions updated, new all-years tiebreak test.
- documents inline route: CSP source policy on HTML previews; sandbox
  alone still loads remote resources, letting a tracking pixel notify the
  sender on open. New route test asserts the full header.
- JournalEntryList: catch rejected list requests so loading cannot stick
  forever, and gate every post-await state write behind a request
  generation so a slow earlier request cannot overwrite the current sort.
- TransactionHistoryList: pagination follows the selected source scope
  (reachable with zero matches on the current page, hidden for the
  skattekonto scope it cannot affect).
- transactions page: bank:other picker availability derives from history
  rows too, not only the pending inbox dataset.
- DECISIONS.md: mark the superseded single-sort decision; record the
  credit-note deduction positive-magnitude invariant and its verified
  reader inventory (Swedish review flag).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: guard metadata refetches behind the list request generation

fetchAttachmentCounts and fetchRattelseFlags write state after their own
awaits; a stale list request's late completion could overwrite attachment
counts and rattelse flags for rows a newer request just rendered, showing
false missing-underlag warnings. Both helpers now take the caller's
generation guard and discard stale completions, including the
attachment-counts loaded flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-11 23:12:18 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f2d9e98af3
commit 9dbaebcc50
29 changed files with 1551 additions and 157 deletions
@@ -15,6 +15,12 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import {
Inbox,
@@ -38,6 +44,7 @@ import {
ChevronDown,
Sparkles,
MessageCircle,
Maximize2,
} from 'lucide-react'
import Link from 'next/link'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
@@ -600,13 +607,18 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const { data } = await res.json()
if (docRequestRef.current !== itemId) return
const url: string | null = data?.download_url ?? null
// HTML mail underlag renders via the same-origin inline proxy: it
// serves text/html with a CSP sandbox header and guaranteed inline
// disposition. Other types keep the signed storage URL.
const effectiveUrl =
data?.mime_type === 'text/html' ? `/api/documents/${documentId}/inline` : url
if (!url) {
// The document row exists but no signed URL came back: still a load
// failure, not an absent underlag.
setDocState('error')
return
}
setDocUrl(url)
setDocUrl(effectiveUrl)
setDocMime(data?.mime_type ?? null)
setDocState('ready')
} catch {
@@ -1669,6 +1681,19 @@ export function DocumentPreview({
className="block max-h-[calc(100vh-9rem)] max-w-full w-auto h-auto object-contain"
/>
</div>
) : docMime === 'text/html' ? (
// HTML mail underlag: arbitrary sender-controlled markup. sandbox
// with no tokens = opaque origin, no scripts, no forms, no popups.
// bg-white because mail HTML assumes a white canvas and would render
// transparent (unreadable in dark mode) otherwise.
<div className="h-full w-full max-w-3xl bg-background rounded-md border overflow-hidden">
<iframe
src={docUrl}
sandbox=""
className="w-full h-full border-0 bg-white"
title="Underlag"
/>
</div>
) : (
// PDF: iframe needs explicit height, frame fills the available pane.
<div className="h-full w-full max-w-3xl bg-background rounded-md border overflow-hidden">
@@ -2223,6 +2248,14 @@ function FieldsRail({
const isResolved = isProcessed || isBookedDirectly
const [isUnmatchingTx, setIsUnmatchingTx] = useState(false)
const [isRetrying, setIsRetrying] = useState(false)
// Larger edit surface for the extracted fields (the rail is deliberately
// compact and users found it hard to read/fill: dialog reuses the same
// autosaving list at a comfortable size). Closes on item switch so it never
// shows fields for a row other than the selected one.
const [fieldsExpanded, setFieldsExpanded] = useState(false)
useEffect(() => {
setFieldsExpanded(false)
}, [item.id])
const t = useTranslations('inbox_workspace')
// WhatsApp chat context: verified human answers captured by the intake bot
@@ -2455,9 +2488,23 @@ function FieldsRail({
{/* Extracted fields */}
<div className="flex-1 overflow-y-auto px-4 py-3">
<h3 className="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-3">
Extraherade fält
</h3>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs uppercase tracking-wide text-muted-foreground font-medium">
Extraherade fält
</h3>
{!item.isPlaceholder && (hasAnyExtractedField(data) || hasAi) && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 -my-2 text-muted-foreground hover:text-foreground"
onClick={() => setFieldsExpanded(true)}
aria-label={t('expand_fields')}
title={t('expand_fields')}
>
<Maximize2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
{item.isPlaceholder ? (
<div className="space-y-2">
<div className="text-xs text-muted-foreground italic flex items-center gap-2 mb-2">
@@ -2661,6 +2708,23 @@ function FieldsRail({
)}
</div>
)}
{/* Expanded fields editor: same autosaving list as the rail, at a
readable size. Convention 13: centered modal. */}
<Dialog open={fieldsExpanded} onOpenChange={setFieldsExpanded}>
<DialogContent className="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Extraherade fält</DialogTitle>
</DialogHeader>
<EditableFieldsList
itemId={item.id}
data={data ?? emptyExtraction()}
disabled={isResolved}
onUpdated={onFieldsUpdated}
variant="expanded"
/>
</DialogContent>
</Dialog>
</div>
)
}
@@ -2748,11 +2812,16 @@ export function EditableFieldsList({
data,
disabled,
onUpdated,
variant = 'rail',
}: {
itemId: string
data: InvoiceExtractionResult
disabled: boolean
onUpdated: (data: InvoiceExtractionResult) => void
/** 'rail' = the compact inline list in the side rail; 'expanded' = the
* larger two-column layout inside the fields dialog. Same behavior,
* different density. */
variant?: 'rail' | 'expanded'
}) {
const { toast } = useToast()
const [drafts, setDrafts] = useState<Record<FieldKey, string>>(() =>
@@ -2911,13 +2980,24 @@ export function EditableFieldsList({
)
return (
<div className="space-y-2">
<div
className={
variant === 'expanded'
? 'grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3'
: 'space-y-2'
}
>
{shownFields.map((f) => (
<div key={f.key} className="flex flex-col gap-0.5">
<div className="flex items-center justify-between gap-2">
<label
htmlFor={`field-${f.key}`}
className="text-[10px] uppercase tracking-wide text-muted-foreground/80"
// The rail and the expanded dialog can be mounted at the same
// time: the variant keeps the input ids unique between them.
htmlFor={`field-${variant}-${f.key}`}
className={cn(
'uppercase tracking-wide text-muted-foreground/80',
variant === 'expanded' ? 'text-xs' : 'text-[10px]'
)}
>
{f.label}
</label>
@@ -2927,7 +3007,7 @@ export function EditableFieldsList({
/>
</div>
<Input
id={`field-${f.key}`}
id={`field-${variant}-${f.key}`}
type={f.type}
inputMode={f.inputMode}
value={drafts[f.key]}
@@ -2936,7 +3016,9 @@ export function EditableFieldsList({
disabled={disabled}
placeholder="-"
className={cn(
'h-8 text-sm border-transparent bg-transparent px-2 -mx-2 hover:border-border focus-visible:border-ring',
variant === 'expanded'
? 'h-9 text-sm border-border bg-transparent px-3 focus-visible:border-ring'
: 'h-8 text-sm border-transparent bg-transparent px-2 -mx-2 hover:border-border focus-visible:border-ring',
drafts[f.key] === '' && 'text-muted-foreground/50 italic'
)}
/>
@@ -2946,13 +3028,16 @@ export function EditableFieldsList({
<button
type="button"
onClick={() => setShowAllFields(true)}
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline pt-1"
className={cn(
'text-[11px] text-muted-foreground hover:text-foreground hover:underline pt-1 text-left',
variant === 'expanded' && 'sm:col-span-2'
)}
>
Visa fakturafält ({hiddenCount})
</button>
)}
{vatRows.length > 0 && (
<div className="pt-2 border-t mt-3">
<div className={cn('pt-2 border-t mt-3', variant === 'expanded' && 'sm:col-span-2 mt-1')}>
<p className="text-[10px] uppercase tracking-wide text-muted-foreground/80 mb-1.5">
Momsfördelning
</p>
@@ -2970,7 +3055,12 @@ export function EditableFieldsList({
</div>
)}
{disabled && (
<p className="text-[10px] text-muted-foreground/70 pt-2">
<p
className={cn(
'text-[10px] text-muted-foreground/70 pt-2',
variant === 'expanded' && 'sm:col-span-2 text-xs'
)}
>
Posten är kopplad till en leverantörsfaktura: fälten kan inte ändras.
</p>
)}