diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index ecc91c86..ce4f208d 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -99,6 +99,10 @@ const EditTransactionTitleDialog = dynamic( () => import('@/components/transactions/EditTransactionTitleDialog'), { loading: DialogLoadingSkeleton }, ) +const MoveTransactionCashAccountDialog = dynamic( + () => import('@/components/transactions/MoveTransactionCashAccountDialog'), + { loading: DialogLoadingSkeleton }, +) const SkattekontoMatchDialog = dynamic( () => import('@/components/skattekonto/SkattekontoMatchDialog').then((module) => module.SkattekontoMatchDialog), { loading: DialogLoadingSkeleton }, @@ -485,6 +489,8 @@ export default function TransactionsPage() { const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm() // Bank transaction whose title is being edited (null = dialog closed). const [editTitleTarget, setEditTitleTarget] = useState(null) + // Bank transaction being moved to another cash account (null = dialog closed). + const [moveAccountTarget, setMoveAccountTarget] = useState(null) const supabase = useRealtimeSupabase() const searchParams = useSearchParams() const highlightId = searchParams.get('highlight') @@ -2143,6 +2149,46 @@ export default function TransactionsPage() { setEditTitleTarget(transaction) } + function openMoveAccountDialog(transaction: TransactionWithInvoice) { + setMoveAccountTarget(transaction) + } + + // Persist a cash-account move via PATCH. Returns true on success so the + // dialog can close; refetches the list because the account chooser and the + // per-account scoping key off cash_account_id. + async function handleMoveCashAccount(accountNumber: string): Promise { + const target = moveAccountTarget + if (!target) return false + try { + const response = await fetch(`/api/transactions/${target.id}/cash-account`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ account_number: accountNumber }), + }) + const result = await response.json() + if (!response.ok) { + toast({ + title: t('move_account_failed'), + description: getErrorMessage(result, { context: 'transaction' }), + variant: 'destructive', + }) + return false + } + const moved = result.data as { cash_account_id: string } + setTransactions((prev) => + prev.map((tx) => + tx.id === target.id ? { ...tx, cash_account_id: moved.cash_account_id } : tx, + ), + ) + toast({ title: t('move_account_saved') }) + void refreshTransactions() + return true + } catch { + toast({ title: t('move_account_failed'), variant: 'destructive' }) + return false + } + } + // Persist a new title via PATCH. Returns true on success so the dialog can // close; updates the local list optimistically (description + edited tag). async function handleSaveTitle(description: string): Promise { @@ -3087,6 +3133,8 @@ export default function TransactionsPage() { onDelete={handleDeleteTransaction} onIgnore={handleIgnoreTransaction} onEditTitle={openEditTitleDialog} + onMoveCashAccount={openMoveAccountDialog} + cashAccounts={cashAccounts} onToggleSelect={toggleBatchSelect} /> ) : ( @@ -3392,6 +3440,19 @@ export default function TransactionsPage() { /> )} + {moveAccountTarget && ( + { + if (!v) setMoveAccountTarget(null) + }} + cashAccounts={cashAccounts} + currentCashAccountId={moveAccountTarget.cash_account_id} + currency={moveAccountTarget.currency} + onMove={handleMoveCashAccount} + /> + )} + {skvMatchTarget && ( ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +// PATCH goes through withRouteContext → requireAuth. +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: vi.fn(), +})) + +vi.mock('@/lib/sandbox/guard', () => ({ + guardSandbox: vi.fn(), +})) + +import { PATCH } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { guardSandbox } from '@/lib/sandbox/guard' +import { NextResponse } from 'next/server' + +describe('PATCH /api/transactions/[id]/cash-account (move cash account)', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + function patchReq(body: unknown) { + return new Request('http://localhost/api/transactions/tx-1/cash-account', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + /** A movable staging row: unbooked, unmatched. */ + function movableTx(overrides: Record = {}) { + return makeTransaction({ + id: 'tx-1', + journal_entry_id: null, + invoice_id: null, + supplier_invoice_id: null, + cash_account_id: 'ca-1', + currency: 'SEK', + ...overrides, + }) + } + + const targetAccount = { id: 'ca-2', ledger_account: '1931', currency: 'SEK' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: mockSupabase as never, + error: null, + }) + vi.mocked(guardSandbox).mockResolvedValue(null) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null as never, + supabase: mockSupabase as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const res = await PATCH(patchReq({ account_number: '1931' }), createMockRouteParams({ id: 'tx-1' })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(401) + }) + + it.each(['4000', '193', '19301', 'abcd', ''])( + 'returns 400 for a non-19xx account_number (%s)', + async (accountNumber) => { + const res = await PATCH( + patchReq({ account_number: accountNumber }), + createMockRouteParams({ id: 'tx-1' }), + ) + const { status } = await parseJsonResponse(res) + expect(status).toBe(400) + }, + ) + + it('returns 400 when account_number is missing', async () => { + const res = await PATCH(patchReq({}), createMockRouteParams({ id: 'tx-1' })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(400) + }) + + it('returns 404 when the transaction is not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) // tx fetch + + const res = await PATCH(patchReq({ account_number: '1931' }), createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + expect(status).toBe(404) + expect(body.error.code).toBe('TX_CATEGORIZE_TX_NOT_FOUND') + }) + + it('returns 409 when the transaction is booked (journal_entry_id set)', async () => { + enqueue({ data: movableTx({ journal_entry_id: 'je-1' }), error: null }) // tx fetch + + const res = await PATCH(patchReq({ account_number: '1931' }), createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + expect(status).toBe(409) + expect(body.error.code).toBe('TRANSACTION_MOVE_BOOKED') + }) + + it('returns 409 when matched to an invoice even if journal_entry_id is null', async () => { + enqueue({ data: movableTx({ invoice_id: 'inv-1' }), error: null }) // tx fetch + + const res = await PATCH(patchReq({ account_number: '1931' }), createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + expect(status).toBe(409) + expect(body.error.code).toBe('TRANSACTION_MOVE_BOOKED') + }) + + it('returns 409 when matched to a supplier invoice even if journal_entry_id is null', async () => { + enqueue({ data: movableTx({ supplier_invoice_id: 'si-1' }), error: null }) // tx fetch + + const res = await PATCH(patchReq({ account_number: '1931' }), createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + expect(status).toBe(409) + expect(body.error.code).toBe('TRANSACTION_MOVE_BOOKED') + }) + + it('returns 409 when anchored via transaction_voucher_links (bulk-book N>1)', async () => { + enqueue({ data: movableTx(), error: null }) // tx fetch passes the field gate + enqueue({ data: [{ transaction_id: 'tx-1' }], error: null }) // tvl pre-check hits + + const res = await PATCH(patchReq({ account_number: '1931' }), createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + expect(status).toBe(409) + expect(body.error.code).toBe('TRANSACTION_MOVE_BOOKED') + }) + + it('returns 404 when the account is not one of the company cash accounts', async () => { + enqueue({ data: movableTx(), error: null }) // tx fetch + enqueue({ data: [], error: null }) // tvl pre-check clean + enqueue({ data: null, error: null }) // cash account lookup misses + + const res = await PATCH(patchReq({ account_number: '1959' }), createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + expect(status).toBe(404) + expect(body.error.code).toBe('TRANSACTION_MOVE_UNKNOWN_ACCOUNT') + }) + + it('returns 400 when the transaction currency does not match the target account', async () => { + enqueue({ data: movableTx({ currency: 'EUR' }), error: null }) // tx fetch + enqueue({ data: [], error: null }) // tvl pre-check clean + enqueue({ data: targetAccount, error: null }) // SEK account + + const res = await PATCH(patchReq({ account_number: '1931' }), createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + expect(status).toBe(400) + expect(body.error.code).toBe('TRANSACTION_MOVE_CURRENCY_MISMATCH') + }) + + it('moves a movable transaction to the target account', async () => { + enqueue({ data: movableTx(), error: null }) // tx fetch + enqueue({ data: [], error: null }) // tvl pre-check clean + enqueue({ data: targetAccount, error: null }) // account lookup + enqueue({ data: { id: 'tx-1', cash_account_id: 'ca-2' }, error: null }) // update + + const res = await PATCH(patchReq({ account_number: '1931' }), createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ data: { id: string; cash_account_id: string } }>(res) + expect(status).toBe(200) + expect(body.data).toEqual({ id: 'tx-1', cash_account_id: 'ca-2' }) + }) + + it('returns 409 when the row is booked between read and write (optimistic-lock miss)', async () => { + enqueue({ data: movableTx(), error: null }) // tx fetch passes the read gate + enqueue({ data: [], error: null }) // tvl pre-check clean + enqueue({ data: targetAccount, error: null }) // account lookup + enqueue({ data: null, error: null }) // UPDATE affects 0 rows (gate re-assert failed) + + const res = await PATCH(patchReq({ account_number: '1931' }), createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res) + expect(status).toBe(409) + expect(body.error.code).toBe('TRANSACTION_MOVE_BOOKED') + }) +}) diff --git a/app/api/transactions/[id]/cash-account/route.ts b/app/api/transactions/[id]/cash-account/route.ts new file mode 100644 index 00000000..591c264b --- /dev/null +++ b/app/api/transactions/[id]/cash-account/route.ts @@ -0,0 +1,139 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { validateBody } from '@/lib/api/validate' +import { MoveTransactionCashAccountSchema } from '@/lib/api/schemas' +import { guardSandbox } from '@/lib/sandbox/guard' + +/** + * PATCH /api/transactions/[id]/cash-account + * + * Move an unbooked bank transaction to another of the company's cash accounts + * (cash_accounts row, addressed by its BAS 19xx ledger account). This is the + * escape hatch for rows that ingested under the wrong account or with no + * account at all (legacy connections, own-account transfers the backfills + * deliberately skipped): such a row surfaces under the primary account's + * reconciliation and can never be matched on the account it belongs to. + * + * Only a mutable staging row may move: NOT booked (journal_entry_id), NOT + * confirmed-matched (invoice_id / supplier_invoice_id), and NOT anchored via + * transaction_voucher_links (bulk-book N>1 links transactions to a verifikat + * WITHOUT setting journal_entry_id). Once anchored, the voucher's own 19xx + * line is ground truth for which account the money moved on (see the repair + * backfill 20260609120000), so the binding must not be editable. + */ +export const PATCH = withRouteContext( + 'transaction.moveCashAccount', + async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId, user } = ctx + + const blocked = await guardSandbox(supabase, companyId) + if (blocked) return blocked + + const validation = await validateBody(request, MoveTransactionCashAccountSchema, { + log, + operation: 'transaction.moveCashAccount', + }) + if (!validation.success) return validation.response + const { account_number: accountNumber } = validation.data + + const { data: transaction, error: fetchError } = await supabase + .from('transactions') + .select('id, currency, cash_account_id, journal_entry_id, invoice_id, supplier_invoice_id') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (fetchError || !transaction) { + return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId }) + } + + // Gate: movable only when neither booked nor confirmed-matched (a confirmed + // invoice match also sets journal_entry_id, but check all three for + // defense-in-depth, mirroring the title route). + if (transaction.journal_entry_id || transaction.invoice_id || transaction.supplier_invoice_id) { + return errorResponseFromCode('TRANSACTION_MOVE_BOOKED', log, { requestId }) + } + + // Bulk-book (N>1) anchors a transaction to a verifikat via + // transaction_voucher_links WITHOUT setting journal_entry_id, so the gate + // above misses it. PostgREST cannot express NOT EXISTS in an update filter, + // so this runs as a pre-check query instead of being re-asserted in the + // UPDATE below. That leaves no extra TOCTOU risk: a tvl row appearing + // concurrently implies the booking flow ran, and that flow sets its own + // transaction state as part of the same operation. + const { data: voucherLinks, error: tvlError } = await supabase + .from('transaction_voucher_links') + .select('transaction_id') + .eq('company_id', companyId) + .eq('transaction_id', id) + .limit(1) + + if (tvlError) { + return errorResponse(tvlError, log, { requestId }) + } + if ((voucherLinks ?? []).length > 0) { + return errorResponseFromCode('TRANSACTION_MOVE_BOOKED', log, { requestId }) + } + + const { data: targetAccount, error: accountError } = await supabase + .from('cash_accounts') + .select('id, ledger_account, currency') + .eq('company_id', companyId) + .eq('ledger_account', accountNumber) + .maybeSingle<{ id: string; ledger_account: string; currency: string }>() + + if (accountError) { + return errorResponse(accountError, log, { requestId }) + } + if (!targetAccount) { + return errorResponseFromCode('TRANSACTION_MOVE_UNKNOWN_ACCOUNT', log, { requestId }) + } + + // A cross-currency move would strand the row: every report scope pins + // .eq('currency', accountCurrency), so the row would vanish from BOTH the + // old and the new account's reconciliation. Hard-reject. + if (transaction.currency.toUpperCase() !== targetAccount.currency.toUpperCase()) { + return errorResponseFromCode('TRANSACTION_MOVE_CURRENCY_MISMATCH', log, { requestId }) + } + + const { data: updated, error: updateError } = await supabase + .from('transactions') + .update({ cash_account_id: targetAccount.id }) + .eq('id', id) + .eq('company_id', companyId) + // Re-assert the movable gate atomically against a concurrent book or + // auto-match (ingest's supplier auto-match can set supplier_invoice_id + // WITHOUT journal_entry_id), mirroring PATCH /api/transactions/[id]. + // The tvl part of the gate lives in the pre-check above; see the comment + // there for why that is safe. + .is('journal_entry_id', null) + .is('invoice_id', null) + .is('supplier_invoice_id', null) + .select('id, cash_account_id') + .maybeSingle<{ id: string; cash_account_id: string }>() + + if (updateError) { + return errorResponse(updateError, log, { requestId }) + } + if (!updated) { + // 0 rows updated: the row was booked/matched between read and write. + return errorResponseFromCode('TRANSACTION_MOVE_BOOKED', log, { requestId }) + } + + // Behandlingshistorik (BFNAR 2013:2 kap 8): light-touch for a pre-verifikat + // staging binding, same weight as the title route. updated_at (trigger) + // captures "when"; from/to ids record which way the row moved. + log.info('transaction moved to another cash account', { + transactionId: id, + actor: user.id, + fromCashAccountId: transaction.cash_account_id, + toCashAccountId: targetAccount.id, + toLedgerAccount: targetAccount.ledger_account, + }) + + return NextResponse.json({ data: updated }) + }, + { requireWrite: true }, +) diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx index 1b8424da..26951525 100644 --- a/components/reports/BankReconciliationView.tsx +++ b/components/reports/BankReconciliationView.tsx @@ -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 (
)} + {moveTargets.length > 0 && ( + <> + + Flytta till annat konto + + {moveTargets.map((account) => ( + handleMoveToAccount(tx, account)} + disabled={actionLoading === tx.id} + > + +
+ + Flytta till {account.name || `Bankkonto ${account.currency}`} + + + {account.ledger_account} + +
+
+ ))} + + + )} handleIgnore(tx)} disabled={actionLoading === tx.id} diff --git a/components/transactions/MoveTransactionCashAccountDialog.tsx b/components/transactions/MoveTransactionCashAccountDialog.tsx new file mode 100644 index 00000000..833c3dfe --- /dev/null +++ b/components/transactions/MoveTransactionCashAccountDialog.tsx @@ -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 +} + +/** + * 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(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 ( + { + if (isSaving) return + onOpenChange(v) + }} + > + + + {t('move_account_dialog_title')} + {t('move_account_dialog_description')} + +
+ {cashAccounts.map((account) => { + const isCurrent = account.id === currentCashAccountId + const currencyMismatch = account.currency.toUpperCase() !== currency.toUpperCase() + const disabled = isCurrent || currencyMismatch || isSaving + return ( + + ) + })} +
+ + + + +
+
+ ) +} diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index 440358e0..0cdd6afb 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -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')}
)} - {(showIgnoreItem || showDeleteItem) && (showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem) && ( + {showMoveAccountItem && ( + { + e.stopPropagation() + onMoveCashAccount!(transaction) + }} + > + + {t('move_account_btn')} + + )} + {(showIgnoreItem || showDeleteItem) && (showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem || showMoveAccountItem) && ( )} {showIgnoreItem && ( diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index fb5492eb..720695d4 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -1480,6 +1480,18 @@ export const UpdateTransactionTitleSchema = z.object({ description: z.string().trim().min(1, 'Title cannot be empty').max(500), }) +/** + * Move an unbooked bank transaction to another of the company's cash accounts, + * addressed by the target's BAS 19xx ledger account. Deliberately no null + * variant: unassigning a row would just re-strand it under the primary + * account's report (the exact symptom the move action exists to fix). + */ +export const MoveTransactionCashAccountSchema = z.object({ + account_number: z + .string() + .regex(/^19\d{2}$/, 'Expected a BAS 19xx bank account number'), +}) + export const BookInboxItemDirectlySchema = z.object({ fiscal_period_id: uuid, entry_date: isoDate, diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index d306cf51..15ddfe9c 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -392,6 +392,25 @@ const TRANSACTIONS: Record = { message_en: 'Cannot edit the title of a booked or matched transaction. Posted vouchers are corrected with storno.', }, + TRANSACTION_MOVE_BOOKED: { + httpStatus: 409, + message_sv: + 'Transaktionen är bokförd eller kopplad till en verifikation och kan inte flyttas till ett annat konto. Koppla bort den under Rapporter → Bankavstämning, eller storna verifikationen först.', + message_en: + 'The transaction is booked or linked to a voucher and cannot be moved to another account. Unlink it under Reports → Bank reconciliation, or reverse (storno) the voucher first.', + }, + TRANSACTION_MOVE_UNKNOWN_ACCOUNT: { + httpStatus: 404, + message_sv: 'Kontot finns inte bland företagets registrerade bankkonton.', + message_en: "The account is not one of the company's registered cash accounts.", + }, + TRANSACTION_MOVE_CURRENCY_MISMATCH: { + httpStatus: 400, + message_sv: + 'Transaktionens valuta stämmer inte med kontots valuta. En transaktion kan bara flyttas till ett konto i samma valuta.', + message_en: + 'The transaction currency does not match the target account currency. A transaction can only be moved to an account in the same currency.', + }, TX_CATEGORIZE_INVALID_ACCOUNT: { httpStatus: 400, message_sv: 'Det valda kontot finns inte i kontoplanen.', diff --git a/messages/en.json b/messages/en.json index 1cdc2e2f..8fe68027 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2710,6 +2710,13 @@ "edit_title_restore": "Restore", "edit_title_cancel": "Cancel", "edit_title_save": "Save", + "move_account_btn": "Move to another account", + "move_account_dialog_title": "Move to another bank account", + "move_account_dialog_description": "Choose which bank account the transaction belongs to. The move decides which bank reconciliation the transaction is counted in.", + "move_account_current": "Current account", + "move_account_currency_mismatch": "Different currency ({currency})", + "move_account_cancel": "Cancel", + "move_account_save": "Move", "method_line": "Payment method: {method}" }, "tx_method": { @@ -5323,6 +5330,8 @@ "delete_failed_description": "The transaction could not be deleted. Please try again.", "edit_title_saved": "Title updated", "edit_title_failed": "Could not update the title", + "move_account_saved": "Transaction moved", + "move_account_failed": "Could not move the transaction", "review_in_bookkeeping_description": "Review and post the journal entry in Bookkeeping.", "bank_sync_attention_one": "1 bank connection needs renewal", "bank_sync_attention_many": "{count} bank connections need renewal", diff --git a/messages/sv.json b/messages/sv.json index 586d52d7..a0d4a772 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -2710,6 +2710,13 @@ "edit_title_restore": "Återställ", "edit_title_cancel": "Avbryt", "edit_title_save": "Spara", + "move_account_btn": "Flytta till annat konto", + "move_account_dialog_title": "Flytta till annat bankkonto", + "move_account_dialog_description": "Välj vilket bankkonto transaktionen hör till. Flytten avgör vilken bankavstämning transaktionen räknas med i.", + "move_account_current": "Nuvarande konto", + "move_account_currency_mismatch": "Annan valuta ({currency})", + "move_account_cancel": "Avbryt", + "move_account_save": "Flytta", "method_line": "Betalsätt: {method}" }, "tx_method": { @@ -5323,6 +5330,8 @@ "delete_failed_description": "Transaktionen kunde inte tas bort. Försök igen.", "edit_title_saved": "Titeln uppdaterad", "edit_title_failed": "Kunde inte uppdatera titeln", + "move_account_saved": "Transaktionen flyttades", + "move_account_failed": "Kunde inte flytta transaktionen", "review_in_bookkeeping_description": "Granska och bokför verifikatet i Bokföring.", "bank_sync_attention_one": "1 bankanslutning behöver förnyas", "bank_sync_attention_many": "{count} bankanslutningar behöver förnyas",