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
@@ -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 && (