diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index ae93c8f0..c3042e76 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -2781,7 +2781,11 @@ export default function TransactionsPage() { let journalEntryId: string | null if (!templateId && quickReview?.template?.id && isCounterpartyTemplateId(quickReview.template.id)) { const cpTemplateId = extractCounterpartyId(quickReview.template.id) - const cpCategorize = async (): Promise<{ ok: boolean; journalEntryId: string | null; result: { error?: { code?: string; account_numbers?: string[]; details?: { account_numbers?: string[] } }; journal_entry_id?: string | null; journal_entry_created?: boolean; journal_entry_error?: string | null; category?: TransactionCategory }; status: number }> => { + const cpCategorize = async ( + // Set after the user confirmed the duplicate warning: force is bound + // to the reviewed candidate's voucher, same contract as runCategorize. + forceOpts?: { expectedDuplicateJournalEntryId: string }, + ): Promise<{ ok: boolean; journalEntryId: string | null; result: { error?: { code?: string; account_numbers?: string[]; details?: { account_numbers?: string[]; candidate?: BookedDuplicateCandidate } }; journal_entry_id?: string | null; journal_entry_created?: boolean; journal_entry_error?: string | null; category?: TransactionCategory }; status: number }> => { const r = await fetch(`/api/transactions/${id}/categorize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -2789,6 +2793,9 @@ export default function TransactionsPage() { is_business: true, counterparty_template_id: cpTemplateId, ...(dimensions && Object.keys(dimensions).length > 0 ? { dimensions } : {}), + ...(forceOpts + ? { force: true, expected_duplicate_journal_entry_id: forceOpts.expectedDuplicateJournalEntryId } + : {}), }), }) const b = await r.json() @@ -2857,11 +2864,42 @@ export default function TransactionsPage() { ) : undefined, }) + } else if ( + result?.error?.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE' && + result.error.details?.candidate + ) { + // Booking-feedback parity with runCategorize: route the duplicate + // guard into the dialog (match / ignore / book anyway) instead of + // dead-ending it in a destructive toast that offers no way forward. + const candidate = result.error.details.candidate + setDuplicateWarning({ + transactionId: id, + retry: async () => { + const retry = await cpCategorize({ + expectedDuplicateJournalEntryId: candidate.journal_entry_id, + }) + if (retry.ok) { + finishBooking({ + id, + isBusiness: true, + category: retry.result?.category, + journalEntryId: retry.journalEntryId, + journalEntryCreated: retry.result?.journal_entry_created, + journalEntryError: retry.result?.journal_entry_error, + }) + return retry.journalEntryId + } + toast({ title: 'Kategorisering misslyckades', description: getErrorMessage(retry.result, { context: 'transaction', statusCode: retry.status }), variant: 'destructive' }) + return null + }, + candidate, + }) } else { toast({ title: 'Kategorisering misslyckades', description: getErrorMessage(result, { context: 'transaction', statusCode: cpStatus }), variant: 'destructive' }) } // Close the review dialog on hard errors: the toast (with action if // ACCOUNTS_NOT_IN_CHART) carries the message and the recovery path. + // The duplicate branch closes it too: the duplicate dialog takes over. setQuickReviewOpen(false) setQuickReview(null) return null @@ -3674,6 +3712,37 @@ export default function TransactionsPage() { setDuplicateWarning(null) handleVoucherLinked(transactionId, journalEntryId, voucherLabel) }} + // Sibling candidate resolved as a duplicate import: the dialog already + // ran POST /ignore; this is the same success tail as + // handleIgnoreTransaction (which additionally owns a pre-confirm the + // dialog context replaces). + onIgnored={(transactionId) => { + setDuplicateWarning(null) + const ignoredTx = transactions.find((t) => t.id === transactionId) + setExitingIds((prev) => new Set(prev).add(transactionId)) + setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1)) + setTimeout(() => { + setTransactions((prev) => + prev.map((t) => (t.id === transactionId ? { ...t, is_ignored: true } : t)) + ) + setExitingIds((prev) => { + const next = new Set(prev) + next.delete(transactionId) + return next + }) + }, 350) + toast({ + title: 'Transaktionen ignorerad', + description: ignoredTx + ? `${ignoredTx.description}, ${formatCurrency(ignoredTx.amount, ignoredTx.currency)}` + : undefined, + action: ( + void handleUnignoreTransaction(transactionId)}> + Ångra + + ), + }) + }} onBookAnyway={async () => { const retry = duplicateWarning?.retry setDuplicateProcessing(true) diff --git a/app/api/transactions/bulk-book/__tests__/route.test.ts b/app/api/transactions/bulk-book/__tests__/route.test.ts index eaa02b2b..24de0a3d 100644 --- a/app/api/transactions/bulk-book/__tests__/route.test.ts +++ b/app/api/transactions/bulk-book/__tests__/route.test.ts @@ -33,16 +33,30 @@ vi.mock('@/lib/bookkeeping/template-library', () => ({ applyTemplate: vi.fn(), })) -// Stubbed so the queued supabase mock stays aligned with the route's own +// Stubbed so the queued mock stays aligned with the route's own // queries; the helper's behaviour is covered in // lib/transactions/__tests__/inbox-underlag.test.ts. vi.mock('@/lib/transactions/inbox-underlag', () => ({ propagateUnderlagForBookedTransaction: vi.fn().mockResolvedValue(undefined), })) +// The booking-time duplicate guard runs several queries of its own per tx; +// stubbing it keeps the queued supabase mock aligned with the route's queries. +// Detection behaviour is covered in +// lib/transactions/__tests__/booking-duplicate-detection.test.ts. +vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({ + detectBookingDuplicate: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/lib/processing-history/append', () => ({ + appendProcessingHistory: vi.fn().mockResolvedValue(undefined), +})) + import { POST } from '../route' import { applyTemplate } from '@/lib/bookkeeping/template-library' import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag' +import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection' +import { appendProcessingHistory } from '@/lib/processing-history/append' +import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection' const TX1 = '11111111-1111-4111-8111-111111111111' const TX2 = '22222222-2222-4222-8222-222222222222' @@ -56,6 +70,17 @@ describe('POST /api/transactions/bulk-book', () => { vi.clearAllMocks() reset() mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + vi.mocked(detectBookingDuplicate).mockResolvedValue(null) + }) + + it('returns 401 when unauthenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null }, error: null }) + const request = createMockRequest('/api/transactions/bulk-book', { + method: 'POST', + body: { tx_ids: [TX1], existing_journal_entry_id: JE }, + }) + const response = await POST(request) + expect(response.status).toBe(401) }) it('returns 400 when neither template_id nor existing_journal_entry_id is set', async () => { @@ -431,3 +456,142 @@ describe('POST /api/transactions/bulk-book: mixed-currency guard', () => { expect(response.status).toBe(200) }) }) + +/** + * Booking-time duplicate guard on the samlingsverifikation path (parity with + * /categorize and /book): before this, bulk-book never called + * detectBookingDuplicate at all, so a batch containing an already-booked + * twin minted a second verifikat with no warning. + */ +describe('POST /api/transactions/bulk-book: duplicate guard', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + const SEK_TXS = [ + { id: TX1, amount: 100, currency: 'SEK', description: 'Swish 1', date: '2026-06-05' }, + { id: TX2, amount: 200, currency: 'SEK', description: 'Swish 2', date: '2026-06-05' }, + ] + + const CANDIDATE: BookedDuplicateCandidate = { + transaction_id: null, + journal_entry_id: '55555555-5555-4555-8555-555555555555', + voucher_label: 'A17', + entry_date: '2026-06-05', + description: 'Swish inbetalning', + amount: 200, + account_number: '1930', + currency: null, + amount_in_currency: null, + amount_verified: true, + unverified_reason: null, + } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + vi.mocked(detectBookingDuplicate).mockResolvedValue(null) + }) + + it('returns 409 with the candidate and the flagged tx, before the RPC', async () => { + enqueue({ data: SEK_TXS, error: null }) + // TX1 clean, TX2 flagged (checked in tx_ids order). + vi.mocked(detectBookingDuplicate) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(CANDIDATE) + + const request = createMockRequest('/api/transactions/bulk-book', { + method: 'POST', + body: { tx_ids: [TX1, TX2], existing_journal_entry_id: JE }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details?: { transaction_id?: string; candidate?: { journal_entry_id: string } } } + }>(response) + expect(status).toBe(409) + expect(body.error.code).toBe('TRANSACTION_BOOK_POSSIBLE_DUPLICATE') + expect(body.error.details?.transaction_id).toBe(TX2) + expect(body.error.details?.candidate?.journal_entry_id).toBe(CANDIDATE.journal_entry_id) + expect(mockSupabase.rpc).not.toHaveBeenCalled() + }) + + it('passes intra-batch exclusions so siblings and the link target never flag each other', async () => { + enqueue({ data: SEK_TXS, error: null }) + // RPC + event re-fetch for the clean pass-through. + enqueue({ + data: { + ok: true, mode: 'link_existing', journal_entry_id: JE, + voucher_series: 'A', voucher_number: 12, linked_tx_count: 2, tx_sum: 300, + }, + error: null, + }) + enqueue({ data: [], error: null }) + + const request = createMockRequest('/api/transactions/bulk-book', { + method: 'POST', + body: { tx_ids: [TX1, TX2], existing_journal_entry_id: JE }, + }) + const response = await POST(request) + expect(response.status).toBe(200) + expect(detectBookingDuplicate).toHaveBeenCalledTimes(2) + expect(detectBookingDuplicate).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + expect.objectContaining({ id: TX1, date: '2026-06-05', amount: 100 }), + { excludeTransactionIds: [TX1, TX2], excludeJournalEntryIds: [JE] }, + ) + }) + + it('force=true books through and records each dismissed candidate in behandlingshistorik', async () => { + enqueue({ data: SEK_TXS, error: null }) + enqueue({ + data: { + ok: true, mode: 'link_existing', journal_entry_id: JE, + voucher_series: 'A', voucher_number: 12, linked_tx_count: 2, tx_sum: 300, + }, + error: null, + }) + enqueue({ data: [], error: null }) + // Re-detection under force: TX1 clean, TX2 had the candidate. + vi.mocked(detectBookingDuplicate) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(CANDIDATE) + + const request = createMockRequest('/api/transactions/bulk-book', { + method: 'POST', + body: { tx_ids: [TX1, TX2], existing_journal_entry_id: JE, force: true }, + }) + const response = await POST(request) + expect(response.status).toBe(200) + expect(appendProcessingHistory).toHaveBeenCalledTimes(1) + expect(appendProcessingHistory).toHaveBeenCalledWith( + expect.objectContaining({ + aggregateId: TX2, + eventType: 'BankTransactionDuplicateDismissed', + payload: expect.objectContaining({ + dismissed_journal_entry_id: CANDIDATE.journal_entry_id, + via: 'bulk_book_force', + }), + }), + ) + }) + + it('fails open when detection itself throws (a guard failure never blocks a booking)', async () => { + enqueue({ data: SEK_TXS, error: null }) + enqueue({ + data: { + ok: true, mode: 'link_existing', journal_entry_id: JE, + voucher_series: 'A', voucher_number: 12, linked_tx_count: 2, tx_sum: 300, + }, + error: null, + }) + enqueue({ data: [], error: null }) + vi.mocked(detectBookingDuplicate).mockRejectedValue(new Error('detector down')) + + const request = createMockRequest('/api/transactions/bulk-book', { + method: 'POST', + body: { tx_ids: [TX1, TX2], existing_journal_entry_id: JE }, + }) + const response = await POST(request) + expect(response.status).toBe(200) + }) +}) diff --git a/app/api/transactions/bulk-book/route.ts b/app/api/transactions/bulk-book/route.ts index 8036dcce..a3119e54 100644 --- a/app/api/transactions/bulk-book/route.ts +++ b/app/api/transactions/bulk-book/route.ts @@ -12,6 +12,8 @@ import { } from '@/lib/bookkeeping/dimension-rules' import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag' +import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection' +import { appendProcessingHistory } from '@/lib/processing-history/append' import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' import type { BookingTemplateLibraryLine, Transaction } from '@/types' @@ -88,7 +90,7 @@ export const POST = withRouteContext( // need the currencies for the homogeneity gate below. const { data: txs, error: txError } = await supabase .from('transactions') - .select('id, amount, currency, description, date') + .select('id, amount, currency, description, date, amount_sek, exchange_rate, cash_account_id') .in('id', body.tx_ids) .eq('company_id', companyId) @@ -102,7 +104,10 @@ export const POST = withRouteContext( }) } - const txTyped = txs as Pick[] + const txTyped = txs as Pick< + Transaction, + 'id' | 'amount' | 'currency' | 'description' | 'date' | 'amount_sek' | 'exchange_rate' | 'cash_account_id' + >[] // Currency homogeneity, enforced BEFORE the branch split so it covers // all three paths (template, manual_lines, existing_journal_entry_id). @@ -143,6 +148,100 @@ export const POST = withRouteContext( }) } + // Booking-time duplicate guard, parity with /categorize and /book: each + // selected tx is about to be anchored to a verifikat, and a twin already + // in the ledger means one affärshändelse gets booked twice (felaktig + // bokföring per BFL). Per-tx detection with intra-batch exclusions: the + // OTHER selected txs are distinct events the user explicitly picked, and + // the link-existing target voucher is the batch's own destination, so + // neither may flag. Checked in tx_ids order so the flagged tx is + // deterministic. Soft guard: the caller re-runs with force=true after the + // user reviews the candidate; detection failures never block a booking. + const txById = new Map(txTyped.map((tx) => [tx.id, tx])) + const duplicateExclusions = { + excludeTransactionIds: body.tx_ids, + excludeJournalEntryIds: body.existing_journal_entry_id ? [body.existing_journal_entry_id] : [], + } + const detectForTx = (tx: (typeof txTyped)[number]) => + detectBookingDuplicate( + supabase, + companyId!, + { + id: tx.id, + date: tx.date, + // `amount` is denominated in `currency`; the guard's FX contract + // needs the row's own conversion fields alongside. + amount: tx.amount, + currency: tx.currency ?? null, + amount_sek: tx.amount_sek ?? null, + exchange_rate: tx.exchange_rate ?? null, + cash_account_id: tx.cash_account_id ?? null, + }, + duplicateExclusions, + ) + if (body.force !== true) { + for (const txId of body.tx_ids) { + const tx = txById.get(txId) + if (!tx) continue + let candidate = null + try { + candidate = await detectForTx(tx) + } catch (err) { + opLog.warn('bulk-book duplicate detection failed (continuing)', { err, txId }) + } + if (candidate) { + return errorResponseFromCode('TRANSACTION_BOOK_POSSIBLE_DUPLICATE', opLog, { + requestId, + details: { candidate, transaction_id: txId }, + }) + } + } + } else { + // force=true bypassed the guard. Booking over a DETECTED possible + // double-booking is a bookkeeping decision that needs a durable + // behandlingshistorik record (BFNAR 2013:2 kap 8), parity with the + // /categorize and agent bypass paths. Best-effort; never blocks. + for (const txId of body.tx_ids) { + const tx = txById.get(txId) + if (!tx) continue + try { + const dismissed = await detectForTx(tx) + if (!dismissed) continue + opLog.warn('bulk-book duplicate guard bypassed', { + reason: 'force=true', + requestId, + txId, + dismissedJournalEntryId: dismissed.journal_entry_id, + }) + await appendProcessingHistory({ + companyId: companyId!, + correlationId: txId, + aggregateType: 'BankTransaction', + aggregateId: txId, + eventType: 'BankTransactionDuplicateDismissed', + payload: { + transaction_id: txId, + dismissed_transaction_id: dismissed.transaction_id, + dismissed_journal_entry_id: dismissed.journal_entry_id, + // Null when the candidate's SEK value could not be established; + // the foreign figures below then carry the durable record. + amount_ore: dismissed.amount != null ? Math.round(dismissed.amount * 100) : null, + dismissed_currency: dismissed.currency, + dismissed_amount_in_currency: dismissed.amount_in_currency, + entry_date: dismissed.entry_date, + amount_verified: dismissed.amount_verified, + unverified_reason: dismissed.unverified_reason, + via: 'bulk_book_force', + }, + actor: { type: 'user', id: user.id }, + occurredAt: new Date(), + }) + } catch (err) { + opLog.error('failed to append duplicate-dismissal behandlingshistorik', err as Error) + } + } + } + // Three paths now (PR #608): // 1. existing_journal_entry_id → null new_entry, RPC links txs to JE. // 2. template_id → route expands template per mode, builds lines. diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx index b312b174..b7d5bf7e 100644 --- a/components/reports/BankReconciliationView.tsx +++ b/components/reports/BankReconciliationView.tsx @@ -20,6 +20,8 @@ import { formatCurrency, formatDate } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { CashAccountSelector } from '@/components/common/CashAccountSelector' import { MatchVerifikationPicker, type UnlinkedGLLine } from '@/components/reconciliation/MatchVerifikationPicker' +import DuplicateBookingDialog from '@/components/transactions/DuplicateBookingDialog' +import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection' import { DropdownMenu, DropdownMenuContent, @@ -241,6 +243,15 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank // Per-verifikat loading for the "Märk som ingående balans" re-tag action. const [markLoading, setMarkLoading] = useState(null) const [actionLoading, setActionLoading] = useState(null) + // Booking-time duplicate guard (TRANSACTION_BOOK_POSSIBLE_DUPLICATE) fired + // for a quick-book: opened as the shared match/ignore/book-anyway dialog + // instead of a dead-end toast; this page's whole purpose is matching. + const [duplicateWarning, setDuplicateWarning] = useState<{ + transactionId: string + retry: () => Promise + candidate: BookedDuplicateCandidate + } | null>(null) + const [duplicateProcessing, setDuplicateProcessing] = useState(false) // Opt-in: also surface vouchers already matched to a bank transaction as // candidates, so a second/third transaction can be attached to the same @@ -797,7 +808,13 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank * leg to the transaction's actual settlement account, so this is correct on * any cash account. */ - const handleQuickBook = async (transactionId: string, templateId: string) => { + const handleQuickBook = async ( + transactionId: string, + templateId: string, + // Set after the user confirmed the duplicate warning: force is bound to + // the reviewed candidate's voucher and re-detected server-side. + forceOpts?: { expectedDuplicateJournalEntryId: string }, + ) => { setActionLoading(transactionId) try { const res = await fetch(`/api/transactions/${transactionId}/categorize`, { @@ -807,10 +824,29 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank is_business: true, template_id: templateId, confirm_no_match: true, + ...(forceOpts + ? { force: true, expected_duplicate_journal_entry_id: forceOpts.expectedDuplicateJournalEntryId } + : {}), }), }) const result = await res.json() if (!res.ok || result.error) { + const candidate = result?.error?.details?.candidate as BookedDuplicateCandidate | undefined + if (result?.error?.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE' && candidate) { + // The affärshändelse already looks booked. On the reconciliation + // page the right resolutions (match the voucher, ignore a duplicate + // import, or book anyway) all live in the shared dialog: never + // dead-end in a toast with no way forward. + setDuplicateWarning({ + transactionId, + retry: () => + handleQuickBook(transactionId, templateId, { + expectedDuplicateJournalEntryId: candidate.journal_entry_id, + }), + candidate, + }) + return + } toast({ variant: 'destructive', title: 'Kunde inte bokföra transaktionen', @@ -1602,6 +1638,43 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank /> )} + {duplicateWarning && ( + setDuplicateWarning(null)} + matchTransaction={{ + id: duplicateWarning.transactionId, + // The view is scoped to one ledger account; resolve its cash + // account so the match links on the account being reconciled. + cash_account_id: cashAccounts.find((a) => a.ledger_account === accountNumber)?.id ?? null, + currency: + unmatchedTx.find((t) => t.id === duplicateWarning.transactionId)?.currency ?? + accountCurrency, + }} + onMatched={async () => { + setDuplicateWarning(null) + toast({ variant: 'success', title: 'Transaktionen matchades mot verifikatet' }) + await fetchAll({ silent: true }) + }} + onIgnored={async () => { + setDuplicateWarning(null) + toast({ variant: 'success', title: 'Transaktionen ignorerad' }) + await fetchAll({ silent: true }) + }} + onBookAnyway={async () => { + const retry = duplicateWarning?.retry + setDuplicateProcessing(true) + try { + setDuplicateWarning(null) + if (retry) await retry() + } finally { + setDuplicateProcessing(false) + } + }} + /> + )} + ) diff --git a/components/transactions/BulkBookDialog.tsx b/components/transactions/BulkBookDialog.tsx index a7380ad9..3623608c 100644 --- a/components/transactions/BulkBookDialog.tsx +++ b/components/transactions/BulkBookDialog.tsx @@ -25,8 +25,10 @@ import { getErrorMessage } from '@/lib/errors/get-error-message' import { applyTemplate } from '@/lib/bookkeeping/template-library' import { formatCurrency, formatDate, cn } from '@/lib/utils' import LineDimensionFields from '@/components/dimensions/LineDimensionFields' +import DuplicateBookingDialog from '@/components/transactions/DuplicateBookingDialog' import { Loader2, FileText, AlertTriangle, Check, Plus, Trash2, Paperclip } from 'lucide-react' import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types' +import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection' import type { TransactionWithInvoice } from './transaction-types' interface BulkBookDialogProps { @@ -90,6 +92,12 @@ export default function BulkBookDialog({ const [description, setDescription] = useState('') const [manualLines, setManualLines] = useState([]) const [submitting, setSubmitting] = useState(false) + // Booking-time duplicate guard fired for one of the selected txs + // (TRANSACTION_BOOK_POSSIBLE_DUPLICATE): surface the candidate for review + // with "Bokför ändå" (re-runs the whole batch with force=true) instead of + // dead-ending in a toast. Match/ignore are not offered here: resolving one + // row differently belongs on the transaction list, outside the batch. + const [duplicateCandidate, setDuplicateCandidate] = useState(null) // Dimension tagging (kostnadsställe/projekt): the pair renders only when // company_settings.dimensions_enabled, same gate as JournalEntryForm. One // header-level default bag applies to both tabs; the server tags the @@ -384,8 +392,11 @@ export default function BulkBookDialog({ ]) } - async function handleConfirm() { + // `opts` is only ever passed by the duplicate-dialog retry; the footer + // button's onClick hands over a click event, which carries no `force`. + async function handleConfirm(opts?: { force?: boolean }) { if (!canConfirm) return + const force = opts?.force === true setSubmitting(true) try { // Build the payload per the active tab. Template path uses the @@ -410,6 +421,7 @@ export default function BulkBookDialog({ line_description: l.line_description ?? undefined, })), ...defaultDimensions, + ...(force ? { force: true } : {}), } : { tx_ids: transactions.map((tx) => tx.id), @@ -417,6 +429,7 @@ export default function BulkBookDialog({ mode, entry_description: description.trim(), ...defaultDimensions, + ...(force ? { force: true } : {}), } const response = await fetch('/api/transactions/bulk-book', { method: 'POST', @@ -425,6 +438,14 @@ export default function BulkBookDialog({ }) if (!response.ok) { const body = await response.json().catch(() => null) + const candidate = body?.error?.details?.candidate as BookedDuplicateCandidate | undefined + if (body?.error?.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE' && candidate) { + // One of the selected txs already looks booked: open the review + // dialog instead of a dead-end toast. "Bokför ändå" re-runs the + // batch with force=true. + setDuplicateCandidate(candidate) + return + } toast({ title: t('error_title'), description: getErrorMessage(body, { statusCode: response.status }), @@ -860,12 +881,28 @@ export default function BulkBookDialog({ - + + {/* Booking-time duplicate guard review. Rendered inside the bulk dialog + so cancelling it returns to the batch as-is; "Bokför ändå" re-runs + the whole batch with force=true (the server re-detects and records + the dismissal in behandlingshistorik). */} + {duplicateCandidate && ( + setDuplicateCandidate(null)} + onBookAnyway={() => { + setDuplicateCandidate(null) + void handleConfirm({ force: true }) + }} + /> + )} ) } diff --git a/components/transactions/DuplicateBookingDialog.tsx b/components/transactions/DuplicateBookingDialog.tsx index 519daf04..058553cf 100644 --- a/components/transactions/DuplicateBookingDialog.tsx +++ b/components/transactions/DuplicateBookingDialog.tsx @@ -41,9 +41,17 @@ export interface DuplicateMatchTransaction { * when the caller supplies `matchTransaction` + `onMatched`: it links the bank * line to the existing voucher via /api/reconciliation/bank/link (the same path * MatchVoucherDialog uses) instead of double-booking the affärshändelse. - * "Bokför ändå" stays available but demoted. Sibling-transaction candidates - * keep booking as the primary action: matching a second bank line onto a - * voucher that already has one is the N:1 edge case, not the default. + * "Bokför ändå" stays available but demoted. + * + * Sibling-transaction candidates (candidate.transaction_id set: the verifikat + * is already linked to ANOTHER bank transaction) get the match action too: + * manualLink (lib/reconciliation/bank-reconciliation.ts) explicitly allows a + * second transaction on one voucher (split settlements), so hiding the action + * dead-ended the user in "Bokför ändå". Their body copy asks "vill du matcha i + * stället?" and, because that shape is very often a duplicate IMPORT of one + * real movement (where matching would double-count the bank side), they + * additionally get "Ignorera transaktionen" via /api/transactions/[id]/ignore + * when the caller supplies `onIgnored`. */ export default function DuplicateBookingDialog({ candidate, @@ -52,6 +60,7 @@ export default function DuplicateBookingDialog({ onCancel, matchTransaction, onMatched, + onIgnored, }: { /** The already-booked sibling, or null to keep the dialog closed. */ candidate: BookedDuplicateCandidate | null @@ -66,17 +75,29 @@ export default function DuplicateBookingDialog({ * caller owns the success toast and state refresh (and closes the dialog by * clearing `candidate`). */ onMatched?: (transactionId: string, journalEntryId: string, voucherLabel: string) => void + /** Called after POST /api/transactions/[id]/ignore succeeds for a + * sibling-transaction candidate (the row is likely a duplicate import). + * The caller owns the refresh and closes the dialog by clearing + * `candidate`. Omit to hide the ignore action. */ + onIgnored?: (transactionId: string) => void }) { const t = useTranslations('transactions') const locale = useLocale() as ErrorLocale const { toast } = useToast() const [matching, setMatching] = useState(false) + const [ignoring, setIgnoring] = useState(false) - // The match action is offered only for ledger-only voucher candidates: the - // voucher has no bank transaction linked yet, so linking THIS one to it is - // the right default (one affärshändelse, one verifikat). - const canMatch = - candidate !== null && candidate.transaction_id === null && !!matchTransaction && !!onMatched + // A sibling-transaction candidate: the twin bank row is already booked, so + // the target is either a duplicate import (ignore), the second leg of an + // N:1 settlement (match), or a genuinely separate identical event (book). + const isSiblingCandidate = candidate !== null && candidate.transaction_id !== null + + // Matching links THIS bank line to the existing voucher instead of minting a + // second verifikat (one affärshändelse, one verifikat). Offered for both + // candidate kinds: for ledger-only vouchers it is the right default, and for + // sibling candidates manualLink explicitly permits N:1 links. + const canMatch = candidate !== null && !!matchTransaction && !!onMatched + const canIgnore = isSiblingCandidate && !!matchTransaction && !!onIgnored async function handleMatch() { if (!candidate || !matchTransaction || !onMatched || matching) return @@ -137,7 +158,35 @@ export default function DuplicateBookingDialog({ } } - const busy = processing || matching + async function handleIgnore() { + if (!candidate || !matchTransaction || !onIgnored || ignoring) return + setIgnoring(true) + try { + const res = await fetch(`/api/transactions/${matchTransaction.id}/ignore`, { + method: 'POST', + }) + const result = await res.json().catch(() => null) + if (!res.ok || result?.error) { + toast({ + title: t('dialog_duplicate_ignore_failed'), + description: getErrorMessage(result, { context: 'transaction', statusCode: res.status, locale }), + variant: 'destructive', + }) + return + } + onIgnored(matchTransaction.id) + } catch { + toast({ + title: t('dialog_duplicate_ignore_failed'), + description: getErrorMessage(null, { context: 'transaction', locale }), + variant: 'destructive', + }) + } finally { + setIgnoring(false) + } + } + + const busy = processing || matching || ignoring return ( {t('dialog_duplicate_title')}
-

{t('dialog_duplicate_body')}

+ {/* Sibling candidates get the "vill du matcha i stället?" copy: the + generic body's "en annan transaktion eller en befintlig + verifikation" hedge reads as noise once the twin is known. The + ignore hint renders only when the action itself does (callers + without onIgnored, e.g. the manual booking form and the bulk + dialog, must not have copy pointing at a button that is not + there). */} +

+ {isSiblingCandidate ? t('dialog_duplicate_body_sibling') : t('dialog_duplicate_body')} + {canIgnore && <> {t('dialog_duplicate_ignore_hint')}} +

{candidate && (
@@ -240,6 +299,16 @@ export default function DuplicateBookingDialog({ + {/* Sibling candidates only: when the row is a duplicate + import of the already-booked twin, ignoring it is the + correct resolution (matching would double-count the bank + side, booking would double-count the ledger side). */} + {canIgnore && ( + + )}