feat(transactions): move an unbooked transaction to another cash account (#1570)

A bank transaction that ingested under the wrong cash account (or with no
account at all: legacy connections, own-account transfers the backfills
deliberately skipped) surfaces under the primary account's reconciliation
and can never be matched on the account it belongs to, because
cross-account matching is deliberately blocked. There was no first-party
way to fix the binding.

New PATCH /api/transactions/[id]/cash-account moves a movable staging row
(not booked, not invoice/supplier-invoice matched, not anchored via
transaction_voucher_links) to another of the company's cash accounts,
addressed by its BAS 19xx ledger account. Cross-currency moves are
hard-rejected (the row would vanish from every report's currency scope),
and the movable gate is re-asserted atomically in the UPDATE filter
against a concurrent book/auto-match, mirroring the title route. The tvl
check runs as a pre-check query since PostgREST cannot express NOT EXISTS
in an update filter; a tvl row appearing concurrently implies the booking
flow, which sets its own transaction state.

UI: 'Flytta till annat konto' in the transaction inbox row menu (opens a
radio-list dialog of the enabled cash accounts, current one preselected
and disabled) and direct 'Flytta till {name}' items in the bank
reconciliation unmatched-row menu that PATCH and refetch the view.

New structured error codes: TRANSACTION_MOVE_BOOKED,
TRANSACTION_MOVE_UNKNOWN_ACCOUNT, TRANSACTION_MOVE_CURRENCY_MISMATCH.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-13 15:20:14 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent c420fd2aa1
commit 1eebb75269
10 changed files with 698 additions and 3 deletions
+76 -1
View File
@@ -14,7 +14,7 @@ import { EmptyState } from '@/components/ui/empty-state'
import { AttnLine } from '@/components/ui/attn-line'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { AccountNumber } from '@/components/ui/account-number'
import { AlertCircle, ChevronDown, ChevronRight, Landmark, Link2, Unlink, Play, Eye, EyeOff, PiggyBank, MoreHorizontal } from 'lucide-react'
import { AlertCircle, ArrowRightLeft, ChevronDown, ChevronRight, Landmark, Link2, Unlink, Play, Eye, EyeOff, PiggyBank, MoreHorizontal } from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { CashAccountSelector } from '@/components/common/CashAccountSelector'
@@ -786,6 +786,47 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
}
}
/**
* Move a transaction to another of the company's cash accounts (PATCH
* /api/transactions/[id]/cash-account). The row then leaves THIS account's
* unmatched list and surfaces on the target account's reconciliation, which
* is the fix for rows stuck under the wrong (or the primary) account:
* cross-account matching is deliberately blocked, so the row must move to
* where its verifikat lives. Server-side gating rejects booked/matched rows.
*/
const handleMoveToAccount = async (tx: UnmatchedTransaction, target: CashAccount) => {
setActionLoading(tx.id)
try {
const res = await fetch(`/api/transactions/${tx.id}/cash-account`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_number: target.ledger_account }),
})
const result = await res.json()
if (!res.ok || result.error) {
toast({
variant: 'destructive',
title: 'Kunde inte flytta transaktionen',
description:
getUserErrorMessage(result.error) ||
(typeof result.error === 'string' ? result.error : undefined),
})
return
}
toast({
variant: 'success',
title: `Transaktionen flyttades till ${target.name || `Bankkonto ${target.currency}`} (${target.ledger_account})`,
})
// Both accounts' totals change (the row leaves this report and joins the
// target's), so refresh the whole view, status card included.
await fetchAll({ silent: true })
} catch {
toast({ variant: 'destructive', title: 'Kunde inte flytta transaktionen' })
} finally {
setActionLoading(null)
}
}
const handleIgnore = async (tx: UnmatchedTransaction) => {
// Even though Ignorera is fully reversible, it's still a state change the
// user could miss after a misclick: the row vanishes from the unmatched
@@ -1121,6 +1162,15 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
const quickBooks = QUICK_BOOK_TEMPLATES.filter((t) =>
isPositive ? t.direction === 'income' : t.direction === 'expense',
)
// Other enabled cash accounts this row could move to. Same
// currency only: the server hard-rejects a cross-currency move
// (the row would vanish from every report's currency scope).
const moveTargets = cashAccounts.filter(
(a) =>
a.enabled &&
a.ledger_account !== accountNumber &&
a.currency.toUpperCase() === tx.currency.toUpperCase(),
)
return (
<div
key={tx.id}
@@ -1195,6 +1245,31 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
<DropdownMenuSeparator />
</>
)}
{moveTargets.length > 0 && (
<>
<DropdownMenuLabel className="text-[11px] font-normal uppercase tracking-wider text-muted-foreground">
Flytta till annat konto
</DropdownMenuLabel>
{moveTargets.map((account) => (
<DropdownMenuItem
key={account.id}
onClick={() => handleMoveToAccount(tx, account)}
disabled={actionLoading === tx.id}
>
<ArrowRightLeft className="h-4 w-4" />
<div className="flex flex-col">
<span>
Flytta till {account.name || `Bankkonto ${account.currency}`}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{account.ledger_account}
</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => handleIgnore(tx)}
disabled={actionLoading === tx.id}
@@ -0,0 +1,150 @@
'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 { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { CashAccount } from '@/types'
interface MoveTransactionCashAccountDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
/** Enabled cash accounts to offer (the page's /api/cash-accounts?enabled_only=true list). */
cashAccounts: CashAccount[]
/** cash_accounts.id the transaction is currently bound to (null = unassigned). */
currentCashAccountId: string | null
/** Transaction currency: accounts in another currency cannot be picked
* (the server hard-rejects a cross-currency move). */
currency: string
/** Persist the move (PATCH). Resolves true on success (dialog closes),
* false to keep the dialog open (e.g. the request failed). */
onMove: (accountNumber: string) => Promise<boolean>
}
/**
* Move an unbooked bank transaction to another of the company's cash accounts.
* Radio list of the enabled accounts (name + ledger account); the current
* account is preselected and disabled so the user picks where the row should
* go. Gating (only unbooked/unmatched rows) is enforced server-side; callers
* only open this for movable rows.
*/
export default function MoveTransactionCashAccountDialog({
open,
onOpenChange,
cashAccounts,
currentCashAccountId,
currency,
onMove,
}: MoveTransactionCashAccountDialogProps) {
const t = useTranslations('tx_inbox_card')
const currentLedger =
cashAccounts.find((a) => a.id === currentCashAccountId)?.ledger_account ?? null
const [selected, setSelected] = useState<string | null>(currentLedger)
const [isSaving, setIsSaving] = useState(false)
// Re-seed the selection each time the dialog opens for a (possibly different) row.
useEffect(() => {
if (open) setSelected(currentLedger)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentCashAccountId])
const canSave = selected !== null && selected !== currentLedger && !isSaving
async function persist() {
if (!canSave || selected === null) return
setIsSaving(true)
try {
const ok = await onMove(selected)
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('move_account_dialog_title')}</DialogTitle>
<DialogDescription>{t('move_account_dialog_description')}</DialogDescription>
</DialogHeader>
<div role="radiogroup" aria-label={t('move_account_dialog_title')} className="space-y-2">
{cashAccounts.map((account) => {
const isCurrent = account.id === currentCashAccountId
const currencyMismatch = account.currency.toUpperCase() !== currency.toUpperCase()
const disabled = isCurrent || currencyMismatch || isSaving
return (
<label
key={account.id}
className={cn(
'flex min-h-11 items-center gap-3 rounded-lg border border-border px-3 py-2 text-sm transition-colors duration-150',
disabled ? 'opacity-60' : 'cursor-pointer hover:bg-secondary/35',
selected === account.ledger_account && !disabled && 'bg-secondary/40',
)}
>
<input
type="radio"
name="move-cash-account"
value={account.ledger_account}
checked={selected === account.ledger_account}
onChange={() => setSelected(account.ledger_account)}
disabled={disabled}
className="h-4 w-4 shrink-0 accent-foreground"
/>
<span className="min-w-0 flex-1 truncate">
{account.name || `Bankkonto ${account.currency}`}
</span>
{isCurrent && (
<span className="shrink-0 text-xs text-muted-foreground">
{t('move_account_current')}
</span>
)}
{!isCurrent && currencyMismatch && (
<span className="shrink-0 text-xs text-muted-foreground">
{t('move_account_currency_mismatch', { currency: account.currency })}
</span>
)}
<span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
{account.ledger_account}
</span>
</label>
)
})}
</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('move_account_cancel')}
</Button>
<Button
onClick={() => void persist()}
disabled={!canSave}
className="min-h-11 w-full sm:w-auto"
>
{isSaving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{t('move_account_save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -12,6 +12,7 @@ import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { isImportedTransaction } from '@/lib/transactions/origin'
import {
AlertCircle,
ArrowRightLeft,
ChevronRight,
EyeOff,
FileSearch,
@@ -39,6 +40,7 @@ const HAS_AI_EXTRACTION = ENABLED_EXTENSION_IDS.has('document-extraction')
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
import type { CashAccount } from '@/types'
interface TransactionInboxCardProps {
transaction: TransactionWithInvoice
@@ -73,6 +75,12 @@ interface TransactionInboxCardProps {
onIgnore?: (transaction: TransactionWithInvoice) => void
/** Open the edit-title dialog. Only wired for editable (unbooked/unmatched) rows. */
onEditTitle?: (transaction: TransactionWithInvoice) => void
/** Open the move-to-another-cash-account dialog. Only shown when the company
* has more than one enabled cash account (see `cashAccounts`). */
onMoveCashAccount?: (transaction: TransactionWithInvoice) => void
/** The company's enabled cash accounts (the page's ?enabled_only=true fetch):
* gates the move action, which is pointless with a single account. */
cashAccounts?: CashAccount[]
onToggleSelect: (id: string) => void
}
@@ -98,6 +106,8 @@ export default function TransactionInboxCard({
onDelete,
onIgnore,
onEditTitle,
onMoveCashAccount,
cashAccounts,
onToggleSelect,
}: TransactionInboxCardProps) {
const t = useTranslations('tx_inbox_card')
@@ -195,10 +205,15 @@ export default function TransactionInboxCard({
const showAttachDocumentItem = isUnbooked && canWrite && !!onOpenAttachDocument
const showSplitItem = showInvoiceMatchButton && !!onOpenSplitMatch
const showEditItem = isTitleEditable && !!onEditTitle
// Moving between cash accounts only makes sense with somewhere to move TO,
// and only for rows the server would accept: same movable gate as the title
// (not booked, not confirmed-matched: mirrors PATCH .../cash-account).
const showMoveAccountItem =
isTitleEditable && canWrite && (cashAccounts?.length ?? 0) > 1 && !!onMoveCashAccount
const showIgnoreItem = isUnbooked && isImportedTransaction(transaction) && !!onIgnore
const showDeleteItem = canDelete && !!onDelete
const showOverflowMenu =
showInvoiceMatchButton || showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem || showIgnoreItem || showDeleteItem
showInvoiceMatchButton || showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem || showMoveAccountItem || showIgnoreItem || showDeleteItem
// The foldout carries row detail only (actions live on the row: pill + ⋯).
// Rows with nothing to show don't expand at all; classified imported rows
@@ -384,7 +399,18 @@ export default function TransactionInboxCard({
{t('edit_title_aria')}
</DropdownMenuItem>
)}
{(showIgnoreItem || showDeleteItem) && (showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem) && (
{showMoveAccountItem && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onMoveCashAccount!(transaction)
}}
>
<ArrowRightLeft className="h-4 w-4" />
{t('move_account_btn')}
</DropdownMenuItem>
)}
{(showIgnoreItem || showDeleteItem) && (showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem || showMoveAccountItem) && (
<DropdownMenuSeparator />
)}
{showIgnoreItem && (