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
@@ -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<TransactionWithInvoice | null>(null)
|
||||
// Bank transaction being moved to another cash account (null = dialog closed).
|
||||
const [moveAccountTarget, setMoveAccountTarget] = useState<TransactionWithInvoice | null>(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<boolean> {
|
||||
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<boolean> {
|
||||
@@ -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 && (
|
||||
<MoveTransactionCashAccountDialog
|
||||
open
|
||||
onOpenChange={(v) => {
|
||||
if (!v) setMoveAccountTarget(null)
|
||||
}}
|
||||
cashAccounts={cashAccounts}
|
||||
currentCashAccountId={moveAccountTarget.cash_account_id}
|
||||
currency={moveAccountTarget.currency}
|
||||
onMove={handleMoveCashAccount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{skvMatchTarget && (
|
||||
<SkattekontoMatchDialog
|
||||
row={skvMatchTarget}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
makeTransaction,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
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<string, unknown> = {}) {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
)
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -392,6 +392,25 @@ const TRANSACTIONS: Record<string, StructuredErrorEntry> = {
|
||||
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.',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user