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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
c420fd2aa1
commit
1eebb75269
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user