Mcp/template data feedback (#617)

* fix(booking-templates): scope template list to the active company

GET /api/settings/booking-templates relied solely on the btl_select RLS
policy, which is membership-wide (user_company_ids) and returns templates
from every company the user belongs to. A user who owns multiple companies
saw all their templates merged regardless of which company was active.

Narrow the list in the API layer (mirroring counterparty-templates) to
system + the active company + the active company's team. RLS stays the
security backstop; this fixes the cross-company merge within a single
user's own view (it was never a cross-tenant data leak).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(import): show proper message for duplicate bank file upload

The bank file import page mis-parsed the structured error envelope
({ error: { code, message, details } }), so a BANK_FILE_DUPLICATE
(409) fell through to the generic "Kunde inte läsa filen" fallback.
The upload step also hardcoded that same string as the error heading,
so duplicates were doubly misreported as parse failures.

- Parse the structured envelope by error.code; surface error.message
  for all codes instead of rendering the error object.
- Add a dedicated BANK_FILE_DUPLICATE message using the importedAt /
  importedCount details the route already returns.
- Add an optional errorTitle prop to BankFileUploadStep (defaults to
  the previous text) and pass "Filen är redan importerad" for dupes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tests): add comprehensive tests for recordateEntry, inbox-linking, and external-id handling

- Implemented unit tests for recordateEntry in the bookkeeping module to validate various scenarios including date changes, non-posted entries, and fiscal period restrictions.
- Created tests for inbox-linking status in pending operations to ensure correct handling of invoice inbox items and supplier invoices, addressing historical bugs related to status updates.
- Added tests for external-id utilities to ensure consistent handling of monetary amounts and deduplication keys across different transaction sources.
- Introduced new functions in external-id.ts for stable external ID generation and normalization of imported descriptions, enhancing transaction deduplication reliability.

feat(migrations): add new database migrations for transaction handling

- Created migration to exclude storno and correction vouchers from unmatched GL lines, ensuring accurate reconciliation.
- Added a migration to preserve original bank transaction descriptions in a new immutable column, allowing for user edits while maintaining audit trails and deduplication integrity.

* feat(migrations): add function to exclude storno/correction vouchers from unmatched GL lines

* feat(transactions): enhance transaction handling with improved description normalization and preloaded original entries

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-01 14:45:49 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2c59c3633f
commit c6c86cded4
54 changed files with 3673 additions and 158 deletions
@@ -0,0 +1,248 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { AlertTriangle, Lock, ArrowRight } from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import type { JournalEntry } from '@/types'
interface Props {
entry: JournalEntry
open: boolean
onOpenChange: (open: boolean) => void
onMoved: () => void
}
type PeriodStatus = {
status: 'open' | 'locked' | 'closed'
period_id: string | null
lock_date: string | null
period_name: string | null
}
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
export default function RecordateEntryDialog({ entry, open, onOpenChange, onMoved }: Props) {
const { toast } = useToast()
const router = useRouter()
const [newDate, setNewDate] = useState(entry.entry_date)
const [preview, setPreview] = useState<PeriodStatus | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewError, setPreviewError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
// Reset to the original date each time the dialog opens.
useEffect(() => {
if (open) {
setNewDate(entry.entry_date)
setPreview(null)
setPreviewError(null)
}
}, [open, entry.entry_date])
// Resolve the target period status whenever a valid, changed date is entered.
useEffect(() => {
if (!open) return
if (!ISO_DATE.test(newDate) || newDate === entry.entry_date) {
setPreview(null)
setPreviewError(null)
return
}
let cancelled = false
setPreviewLoading(true)
setPreviewError(null)
const handle = setTimeout(async () => {
try {
const res = await fetch(
`/api/bookkeeping/fiscal-periods/period-status?date=${encodeURIComponent(newDate)}`
)
if (!res.ok) throw new Error('period_status_failed')
const { data } = await res.json()
if (!cancelled) {
setPreview((data as PeriodStatus) ?? null)
setPreviewError(null)
}
} catch {
if (!cancelled) {
setPreview(null)
setPreviewError('Kunde inte kontrollera perioden. Försök igen.')
}
} finally {
if (!cancelled) setPreviewLoading(false)
}
}, 250)
return () => {
cancelled = true
clearTimeout(handle)
}
}, [newDate, open, entry.entry_date])
const dateChanged = ISO_DATE.test(newDate) && newDate !== entry.entry_date
const targetOpen = preview?.status === 'open' && !!preview?.period_id
const noCoveringPeriod = preview?.status === 'open' && !preview?.period_id
// Soft, non-blocking advisory when moving into a past date — the moms for
// that period may already have been filed.
const today = new Date().toISOString().slice(0, 10)
const movingIntoPast = dateChanged && newDate < today
const canSubmit = dateChanged && targetOpen && !isSubmitting
async function handleSubmit() {
if (!canSubmit) return
setIsSubmitting(true)
try {
const res = await fetch(`/api/bookkeeping/journal-entries/${entry.id}/recordate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_entry_date: newDate }),
})
const result = await res.json()
if (!res.ok) {
const error = new Error('Failed to move entry') as Error & { body?: unknown; status?: number }
error.body = result
error.status = res.status
throw error
}
const correctedId = result.data?.corrected?.id
toast({
title: 'Verifikationen flyttad',
description: 'En storno och en rättelse med rätt datum har bokförts.',
action: correctedId ? (
<Button variant="outline" size="sm" onClick={() => router.push(`/bookkeeping/${correctedId}`)}>
Visa rättelsen
</Button>
) : undefined,
})
onOpenChange(false)
onMoved()
} catch (err) {
const anyErr = err as { body?: unknown; status?: number }
toast({
title: 'Kunde inte flytta verifikationen',
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Rätta datum</DialogTitle>
</DialogHeader>
{/* Explanation */}
<div className="rounded-lg bg-muted/50 border p-3 text-sm text-muted-foreground">
<p className="font-medium text-foreground mb-1">Flytta verifikationen till rätt datum</p>
<p>
En bokförd verifikation kan inte ändras direkt. Raderna behålls oförändrade — istället
skapas automatiskt:
</p>
<ol className="list-decimal list-inside mt-1 space-y-0.5">
<li>En <strong>stornoverifikation</strong> som nollställer originalet i sin period</li>
<li>En ny verifikation med samma rader, bokförd på det nya datumet</li>
</ol>
</div>
{/* Original */}
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
<span className="font-mono">{formatVoucher(entry)}</span>
<span className="tabular-nums">{formatDate(entry.entry_date)}</span>
<Badge variant="outline" className="text-xs">Original</Badge>
</div>
<p className="text-sm">{entry.description}</p>
</div>
{/* New date */}
<div className="space-y-2">
<label htmlFor="recordate-date" className="text-sm font-medium">
Nytt datum
</label>
<Input
id="recordate-date"
type="date"
value={newDate}
onChange={(e) => setNewDate(e.target.value)}
className="tabular-nums"
/>
{/* Target period feedback */}
{dateChanged && (
<div className="text-sm" aria-live="polite">
{previewLoading && <span className="text-muted-foreground">Kontrollerar period…</span>}
{!previewLoading && previewError && (
<span className="inline-flex items-center gap-1.5 text-destructive">
<AlertTriangle className="h-4 w-4" />
{previewError}
</span>
)}
{!previewLoading && !previewError && targetOpen && (
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<ArrowRight className="h-4 w-4" />
Flyttas till {preview?.period_name ?? 'rätt räkenskapsår'}
</span>
)}
{!previewLoading && noCoveringPeriod && (
<span className="text-destructive">
Det finns ingen räkenskapsperiod som täcker datumet. Skapa eller öppna räkenskapsåret först.
</span>
)}
{!previewLoading && preview?.status === 'closed' && (
<span className="text-destructive">
Räkenskapsåret är stängt (bokslut) och kan inte återöppnas. Bokför rättelsen i innevarande period istället.
</span>
)}
{!previewLoading && preview?.status === 'locked' && (
<span className="inline-flex items-center gap-1.5 text-destructive">
<Lock className="h-4 w-4" />
Perioden är låst{preview?.lock_date ? ` t.o.m. ${formatDate(preview.lock_date)}` : ''}. Lås upp perioden för att flytta verifikationen dit.
</span>
)}
</div>
)}
{/* Soft advisory: moving into a past period */}
{targetOpen && movingIntoPast && (
<p className="inline-flex items-start gap-1.5 text-sm text-muted-foreground">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<span>
Om momsen för perioden redan är inlämnad kan du behöva lämna en rättad momsdeklaration.
</span>
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Avbryt
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{isSubmitting ? 'Flyttar…' : 'Flytta verifikationen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+3 -1
View File
@@ -40,6 +40,7 @@ interface BankFileUploadStepProps {
onFileSelect: (file: File, formatOverride?: BankFileFormatId) => void
isLoading: boolean
error: string | null
errorTitle?: string | null
detectedFormat?: string | null
detectedFormatName?: string | null
}
@@ -48,6 +49,7 @@ export default function BankFileUploadStep({
onFileSelect,
isLoading,
error,
errorTitle,
detectedFormat,
detectedFormatName,
}: BankFileUploadStepProps) {
@@ -200,7 +202,7 @@ export default function BankFileUploadStep({
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg flex gap-3">
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-destructive">Kunde inte läsa filen</p>
<p className="font-medium text-destructive">{errorTitle || 'Kunde inte läsa filen'}</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
@@ -50,6 +50,7 @@ interface ReconciliationStatus {
gl_1930_balance: number
gl_1930_period_movement: number
gl_1930_opening_balance: number
gl_1930_correction_adjustment: number
difference: number
is_reconciled: boolean
matched_count: number
@@ -644,6 +645,13 @@ export function BankReconciliationView() {
{' '}— räknas inte i avstämningen.
</p>
)}
{status.gl_1930_correction_adjustment !== 0 && (
<p className="pt-2 text-xs text-muted-foreground">
Rättelser och stornon på <AccountNumber number={accountNumber} /> i perioden:{' '}
<span className="font-mono">{formatCurrency(status.gl_1930_correction_adjustment)}</span>
{' '}— bokföringsmässiga rättelser utan motsvarande bankhändelse, räknas inte i avstämningen.
</p>
)}
<div className="flex gap-4 pt-2 text-xs text-muted-foreground">
<span>Matchade: {status.matched_count}</span>
<span>Omatchade transaktioner: {status.unmatched_transaction_count}</span>
@@ -0,0 +1,131 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Loader2 } from 'lucide-react'
interface EditTransactionTitleDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
/** Current (possibly edited) title shown in the input. */
currentTitle: string
/** Bank's original title; when it differs from the current title a restore
* affordance is offered. */
originalTitle: string | null
/** Persist a new title. Resolves true on success (dialog closes), false to
* keep the dialog open (e.g. the request failed). */
onSave: (description: string) => Promise<boolean>
}
/**
* Edit a bank transaction's working title. Carries the product-required warning
* ("Är du säker…") in the dialog body and offers a one-click restore back to
* the bank's original name. Gating (only unbooked/unmatched rows) is enforced
* server-side; callers only open this for editable rows.
*/
export default function EditTransactionTitleDialog({
open,
onOpenChange,
currentTitle,
originalTitle,
onSave,
}: EditTransactionTitleDialogProps) {
const t = useTranslations('tx_inbox_card')
const [value, setValue] = useState(currentTitle)
const [isSaving, setIsSaving] = useState(false)
// Re-seed the field each time the dialog opens for a (possibly different) row.
useEffect(() => {
if (open) setValue(currentTitle)
}, [open, currentTitle])
const trimmed = value.trim()
const canRestore = originalTitle != null && originalTitle !== currentTitle
const isUnchanged = trimmed === currentTitle.trim()
async function persist(next: string) {
setIsSaving(true)
try {
const ok = await onSave(next)
if (ok) onOpenChange(false)
} finally {
setIsSaving(false)
}
}
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (isSaving) return
onOpenChange(v)
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('edit_title_dialog_title')}</DialogTitle>
<DialogDescription>{t('edit_title_warning')}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="tx-title-input">{t('edit_title_label')}</Label>
<Input
id="tx-title-input"
value={value}
onChange={(e) => setValue(e.target.value)}
maxLength={500}
autoFocus
disabled={isSaving}
onKeyDown={(e) => {
if (e.key === 'Enter' && trimmed && !isUnchanged && !isSaving) {
e.preventDefault()
void persist(trimmed)
}
}}
/>
{canRestore && (
<p className="text-xs text-muted-foreground">
{t('edit_title_original_hint', { name: originalTitle as string })}{' '}
<button
type="button"
onClick={() => void persist(originalTitle as string)}
disabled={isSaving}
className="underline underline-offset-2 hover:text-foreground transition-colors disabled:opacity-50"
>
{t('edit_title_restore')}
</button>
</p>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
className="min-h-11 w-full sm:w-auto"
>
{t('edit_title_cancel')}
</Button>
<Button
onClick={() => void persist(trimmed)}
disabled={isSaving || !trimmed || isUnchanged}
className="min-h-11 w-full sm:w-auto"
>
{isSaving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t('edit_title_save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -22,6 +22,7 @@ import {
FileText,
Link2,
Loader2,
Pencil,
Split,
Trash2,
} from 'lucide-react'
@@ -54,6 +55,8 @@ interface TransactionInboxCardProps {
onOpenSplitMatch?: (transaction: TransactionWithInvoice) => void
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
onDelete?: (id: string) => void
/** Open the edit-title dialog. Only wired for editable (unbooked/unmatched) rows. */
onEditTitle?: (transaction: TransactionWithInvoice) => void
onToggleSelect: (id: string) => void
onAnimationComplete?: (id: string) => void
}
@@ -69,6 +72,7 @@ export default function TransactionInboxCard({
onOpenSplitMatch,
onOpenCategoryDialog,
onDelete,
onEditTitle,
onToggleSelect,
onAnimationComplete,
}: TransactionInboxCardProps) {
@@ -109,6 +113,11 @@ export default function TransactionInboxCard({
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
const showCheckbox = isBatchMode && isUncategorized
const isDeletable = !transaction.journal_entry_id
// Title is editable only on a mutable staging row — not booked and not
// confirmed-matched. Mirrors the server-side gate in PATCH /api/transactions/[id].
const isTitleEditable =
!transaction.journal_entry_id && !transaction.invoice_id && !transaction.supplier_invoice_id
const originalName = transaction.original_description
// Primary action: invoice/supplier-invoice match keeps the 1-click shortcut;
// otherwise the user opens the template picker.
@@ -290,6 +299,22 @@ export default function TransactionInboxCard({
Per-transaction agent help has moved to Dokumentinkorgen:
match the underlag to the transaction and ask from there,
where the receipt/invoice is in view. */}
{isTitleEditable && onEditTitle && (
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
onEditTitle(transaction)
}}
aria-label={t('edit_title_aria')}
title={t('edit_title_aria')}
disabled={isProcessing || isDisabled}
>
<Pencil className="h-4 w-4" />
</Button>
)}
{isDeletable && onDelete && (
<Button
variant="ghost"
@@ -316,6 +341,18 @@ export default function TransactionInboxCard({
</div>
<DataListMeta className="mt-1">
<span className="tabular-nums">{formatDate(transaction.date)}</span>
{transaction.title_edited_at && (
<>
<DataListMetaSeparator />
<Badge
variant="secondary"
className="h-4 px-1.5 py-0 text-[10px]"
title={originalName ? t('original_name_tooltip', { name: originalName }) : undefined}
>
{t('edited_badge')}
</Badge>
</>
)}
{skvCounterpartDate && (
<>
<DataListMetaSeparator />