fix(transactions): close the booking duplicate guard's blind spots (#1573)
* fix(transactions): close booking duplicate guard blind spots G1-G3 The booking-time duplicate guard missed the most common bank-fee twin shapes: - G1: the sibling scan matched on the EXACT date only, so a duplicate import with a drifted date (CSV bokforingsdag vs PSD2 valutadag) was invisible. The scan now uses a +-3 day window with a deterministic ranking where exact-date candidates always outrank drifted ones (force=true re-detection stays bound to the reviewed candidate). - G2: booked-ness required transactions.journal_entry_id, so bulk-booked (transaction_voucher_links) and multi-allocated (invoice_payments / supplier_invoice_payments) siblings read as unbooked. The scan now batch-fetches the anchor rows and resolves the verifikat via getPrimaryJournalEntryId (is_transaction_booked semantics). - G3: the ledger scan excluded every voucher linked to any transaction, so a voucher booked from a date-drifted duplicate row escaped BOTH halves and the booking proceeded with no warning. A voucher whose linking transaction itself matches the target (same ore in the same currency, compatible cash account, date in the window) is now returned as the twin with transaction_id set. All candidate picks keep explicit total-order tiebreakers so a force re-detect returns the same candidate the user reviewed, and the SEK-or-null amount contract is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): offer match/ignore for sibling duplicates and route all 409s into the dialog The duplicate dialog hid its match action for sibling-transaction candidates (canMatch required transaction_id === null), so the user who most needed steering saw only 'Bokfor anda'. manualLink explicitly allows N:1 links, so the match action is now offered for both candidate kinds. Sibling candidates get question-form body copy ('vill du matcha mot verifikatet i stallet?') and an additional 'Ignorera transaktionen' action via the existing POST /api/transactions/[id]/ignore, which is the correct resolution when the row itself is a duplicate import (matching would double-count the bank side, booking the ledger side). Two clients dead-ended the TRANSACTION_BOOK_POSSIBLE_DUPLICATE 409 in a destructive toast with no way forward: - the counterparty-template branch of handleQuickReviewConfirm now sets the shared duplicateWarning state exactly like runCategorize, with the force retry bound to the reviewed candidate's voucher - BankReconciliationView's quick-book now opens the same dialog, with match/ignore refreshing the reconciliation lists New sv/en strings: dialog_duplicate_body_sibling, dialog_duplicate_ignore, dialog_duplicate_ignore_failed. File-level parity tests pin the 409 routing and the dialog affordances. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): duplicate guard on the bulk-book samlingsverifikation path /api/transactions/bulk-book never called detectBookingDuplicate, so a batch containing an already-booked twin minted a second verifikat with no warning. The route now runs the shared per-tx guard before the RPC, with intra-batch exclusions (the other selected txs are distinct events the user picked, and the link-existing target voucher is the batch's own destination), returning 409 TRANSACTION_BOOK_POSSIBLE_DUPLICATE with the candidate and the flagged tx id. BulkBookDialog routes the 409 into DuplicateBookingDialog for review (view voucher / cancel / book anyway) instead of a dead-end toast; 'Bokfor anda' re-runs the batch with force=true. On force the route re-detects and records each dismissed candidate as BankTransactionDuplicateDismissed in behandlingshistorik (BFNAR 2013:2 kap 8), parity with the /categorize bypass. Detection failures stay fail-open. Note: the MCP RPC twin (gnubok_bulk_book_transactions) bypasses this route and remains unguarded; guarding inside the RPC needs a migration and is out of scope here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): gate the duplicate-dialog ignore hint on the action being present The sibling body copy mentioned ignoring the row, but two render sites (the manual booking form and the bulk dialog) show sibling candidates without the ignore action. The guidance now lives in a separate dialog_duplicate_ignore_hint string rendered only when the Ignorera button itself renders, so copy never points at a button that is not there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- 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
Claude Fable 5
Jakob Wennberg
parent
1b829883ae
commit
0d3ba5268d
@@ -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() {
|
||||
</ToastAction>
|
||||
) : 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: (
|
||||
<ToastAction altText="Ångra ignorera" onClick={() => void handleUnignoreTransaction(transactionId)}>
|
||||
Ångra
|
||||
</ToastAction>
|
||||
),
|
||||
})
|
||||
}}
|
||||
onBookAnyway={async () => {
|
||||
const retry = duplicateWarning?.retry
|
||||
setDuplicateProcessing(true)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<Transaction, 'id' | 'amount' | 'currency' | 'description' | 'date'>[]
|
||||
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.
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(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<void>
|
||||
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 && (
|
||||
<DuplicateBookingDialog
|
||||
candidate={duplicateWarning.candidate}
|
||||
processing={duplicateProcessing}
|
||||
onCancel={() => 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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DestructiveConfirmDialog {...confirmDialogProps} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<ManualLine[]>([])
|
||||
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<BookedDuplicateCandidate | null>(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({
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!canConfirm}>
|
||||
<Button onClick={() => handleConfirm()} disabled={!canConfirm}>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
{/* 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 && (
|
||||
<DuplicateBookingDialog
|
||||
candidate={duplicateCandidate}
|
||||
processing={submitting}
|
||||
onCancel={() => setDuplicateCandidate(null)}
|
||||
onBookAnyway={() => {
|
||||
setDuplicateCandidate(null)
|
||||
void handleConfirm({ force: true })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Dialog
|
||||
@@ -151,7 +200,17 @@ export default function DuplicateBookingDialog({
|
||||
<DialogTitle>{t('dialog_duplicate_title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t('dialog_duplicate_body')}</p>
|
||||
{/* 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). */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isSiblingCandidate ? t('dialog_duplicate_body_sibling') : t('dialog_duplicate_body')}
|
||||
{canIgnore && <> {t('dialog_duplicate_ignore_hint')}</>}
|
||||
</p>
|
||||
{candidate && (
|
||||
<div className="space-y-1 rounded-md border bg-muted/30 p-3">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
@@ -240,6 +299,16 @@ export default function DuplicateBookingDialog({
|
||||
<Button variant="outline" onClick={onBookAnyway} disabled={busy}>
|
||||
{t('dialog_duplicate_book_anyway')}
|
||||
</Button>
|
||||
{/* 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 && (
|
||||
<Button variant="outline" onClick={handleIgnore} disabled={busy}>
|
||||
{ignoring && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('dialog_duplicate_ignore')}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleMatch} disabled={busy}>
|
||||
{matching && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('dialog_duplicate_match')}
|
||||
|
||||
@@ -41,8 +41,9 @@ describe('transactions page booking feedback', () => {
|
||||
|
||||
it('routes every successful booking through it', () => {
|
||||
// runCategorize (category / catalog template / library template), the
|
||||
// counterparty-template booking, and the counterparty activate-and-retry.
|
||||
expect(PAGE_SRC.match(/finishBooking\(\{/g) ?? []).toHaveLength(3)
|
||||
// counterparty-template booking, the counterparty activate-and-retry, and
|
||||
// the counterparty duplicate-warning "Bokför ändå" retry.
|
||||
expect(PAGE_SRC.match(/finishBooking\(\{/g) ?? []).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('no longer ends the counterparty path on a bare exitingIds add', () => {
|
||||
@@ -74,10 +75,11 @@ describe('transactions page booking feedback', () => {
|
||||
|
||||
it('decrements the unbooked count on every path that removes a row', () => {
|
||||
// finishBooking, handleTransactionBooked (manual booking dialog / voucher
|
||||
// match), and the three other single-row exits already on the page.
|
||||
// match), the three other single-row exits already on the page, and the
|
||||
// duplicate-dialog "Ignorera transaktionen" tail.
|
||||
expect(
|
||||
PAGE_SRC.match(/setTotalUncategorizedCount\(\(prev\) => Math\.max\(0, \(prev \?\? 1\) - 1\)\)/g) ?? [],
|
||||
).toHaveLength(5)
|
||||
).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('ships the undo strings it renders in both locales', () => {
|
||||
@@ -96,3 +98,43 @@ describe('transactions page booking feedback', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Duplicate-guard feedback parity: every client of POST /categorize must route
|
||||
* a TRANSACTION_BOOK_POSSIBLE_DUPLICATE 409 into DuplicateBookingDialog (which
|
||||
* offers match / ignore / book-anyway), never into a destructive toast that
|
||||
* names no way forward. Two clients used to dead-end: the counterparty-
|
||||
* template branch of handleQuickReviewConfirm, and BankReconciliationView's
|
||||
* quick-book.
|
||||
*/
|
||||
const BANK_RECON_SRC = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../reports/BankReconciliationView.tsx'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('duplicate-guard 409 routing parity', () => {
|
||||
it('handles the duplicate code on both booking paths of the transactions page', () => {
|
||||
// runCategorize AND the counterparty-template branch.
|
||||
expect(
|
||||
PAGE_SRC.match(/error\?\.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE'/g) ?? [],
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('opens the dialog from the counterparty branch with a force-bound retry', () => {
|
||||
// The branch must set the shared duplicateWarning state (the dialog) and
|
||||
// bind the retry to the reviewed candidate's voucher.
|
||||
expect(PAGE_SRC).toMatch(
|
||||
/TRANSACTION_BOOK_POSSIBLE_DUPLICATE'[\s\S]{0,600}cpCategorize\(\{\s*\n?\s*expectedDuplicateJournalEntryId: candidate\.journal_entry_id,/,
|
||||
)
|
||||
})
|
||||
|
||||
it('routes the quick-book 409 on the reconciliation page into the shared dialog', () => {
|
||||
expect(BANK_RECON_SRC).toContain("'TRANSACTION_BOOK_POSSIBLE_DUPLICATE'")
|
||||
expect(BANK_RECON_SRC).toContain('<DuplicateBookingDialog')
|
||||
expect(BANK_RECON_SRC).toMatch(/setDuplicateWarning\(\{/)
|
||||
// The retry re-runs the quick-book with force bound to the candidate.
|
||||
expect(BANK_RECON_SRC).toMatch(
|
||||
/handleQuickBook\(transactionId, templateId, \{\s*\n?\s*expectedDuplicateJournalEntryId: candidate\.journal_entry_id,/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,3 +83,62 @@ describe('DuplicateBookingDialog rateless-sibling warning', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('DuplicateBookingDialog sibling candidates', () => {
|
||||
it('no longer gates the match action on transaction_id being null', () => {
|
||||
// manualLink explicitly allows N:1 (a second bank line on one voucher), so
|
||||
// sibling-transaction candidates must get "Matcha mot verifikatet" too:
|
||||
// hiding it dead-ended the user in "Bokför ändå".
|
||||
expect(BOOKING_DIALOG_SRC).toMatch(
|
||||
/const canMatch = candidate !== null && !!matchTransaction && !!onMatched/,
|
||||
)
|
||||
expect(BOOKING_DIALOG_SRC).not.toMatch(/canMatch =[\s\S]{0,120}transaction_id === null/)
|
||||
})
|
||||
|
||||
it('offers Ignorera for sibling candidates via the existing ignore endpoint', () => {
|
||||
// The sibling shape is very often a duplicate IMPORT of one real movement;
|
||||
// ignoring the row is then the correct resolution (matching would
|
||||
// double-count the bank side, booking would double-count the ledger side).
|
||||
expect(BOOKING_DIALOG_SRC).toMatch(/\/api\/transactions\/\$\{matchTransaction\.id\}\/ignore/)
|
||||
expect(BOOKING_DIALOG_SRC).toContain("t('dialog_duplicate_ignore')")
|
||||
// Gated on a sibling candidate: never shown for ledger-only vouchers.
|
||||
expect(BOOKING_DIALOG_SRC).toMatch(
|
||||
/canIgnore = isSiblingCandidate && !!matchTransaction && !!onIgnored/,
|
||||
)
|
||||
})
|
||||
|
||||
it('switches the body copy to the "matcha i stället" question for sibling candidates', () => {
|
||||
expect(BOOKING_DIALOG_SRC).toContain("t('dialog_duplicate_body_sibling')")
|
||||
expect(BOOKING_DIALOG_SRC).toMatch(
|
||||
/isSiblingCandidate \? t\('dialog_duplicate_body_sibling'\) : t\('dialog_duplicate_body'\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('ships the sibling strings in both locales, without dashes', () => {
|
||||
for (const locale of ['sv', 'en'] as const) {
|
||||
const messages = readMessages(locale, 'transactions')
|
||||
for (const key of [
|
||||
'dialog_duplicate_body_sibling',
|
||||
'dialog_duplicate_ignore',
|
||||
'dialog_duplicate_ignore_hint',
|
||||
'dialog_duplicate_ignore_failed',
|
||||
]) {
|
||||
expect(messages[key], `${locale}.transactions.${key}`).toBeTruthy()
|
||||
expect(messages[key]).not.toMatch(/–|—/)
|
||||
}
|
||||
// The ignore guidance lives in the gated hint, never in the body: two
|
||||
// render sites (manual booking form, bulk dialog) show sibling
|
||||
// candidates without the ignore action, and body copy must not point at
|
||||
// a button that is not there.
|
||||
expect(messages.dialog_duplicate_body_sibling).not.toMatch(/ignorera|ignore it/i)
|
||||
}
|
||||
// The requested copy pattern: an offer to match instead, in question form.
|
||||
expect(readMessages('sv', 'transactions').dialog_duplicate_body_sibling).toMatch(
|
||||
/matcha[\s\S]*i stället/i,
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the ignore hint only when the ignore action is offered', () => {
|
||||
expect(BOOKING_DIALOG_SRC).toMatch(/canIgnore && <> \{t\('dialog_duplicate_ignore_hint'\)\}<\/>/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1657,6 +1657,11 @@ export const BulkBookSchema = z
|
||||
// BOTH the template and manual paths (per-line bags win per key). The
|
||||
// route merges before calling the RPC.
|
||||
default_dimensions: DimensionsBagSchema.optional(),
|
||||
// Bypass the booking-time duplicate guard after the user reviewed the
|
||||
// flagged candidate (TRANSACTION_BOOK_POSSIBLE_DUPLICATE). Bulk-book has
|
||||
// no per-tx candidate binding: force skips the guard for the whole batch,
|
||||
// and the route records each dismissed candidate in behandlingshistorik.
|
||||
force: z.boolean().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const hasExisting = !!data.existing_journal_entry_id
|
||||
|
||||
@@ -23,16 +23,27 @@ type TxRow = {
|
||||
exchange_rate?: number | string | null
|
||||
description: string | null
|
||||
cash_account_id: string | null
|
||||
journal_entry_id: string
|
||||
/** NULL = no 1:1 anchor; booked-ness may still come from links/payments. */
|
||||
journal_entry_id: string | null
|
||||
}
|
||||
type JeRow = { voucher_series: string | null; voucher_number: number | null; entry_date: string | null }
|
||||
|
||||
/** Anchor rows for the is_transaction_booked resolution (batch-fetched). */
|
||||
type SiblingAnchors = {
|
||||
voucherLinks?: { transaction_id: string; journal_entry_id: string }[]
|
||||
invoicePayments?: { transaction_id: string | null; journal_entry_id: string | null }[]
|
||||
supplierPayments?: { transaction_id: string | null; journal_entry_id: string | null }[]
|
||||
}
|
||||
|
||||
function txChain(data: TxRow[]) {
|
||||
const c: Record<string, unknown> = {}
|
||||
c.select = () => c
|
||||
c.eq = () => c
|
||||
c.not = () => c
|
||||
c.neq = () => c
|
||||
c.gte = () => c
|
||||
c.lte = () => c
|
||||
c.order = () => c
|
||||
c.limit = () => Promise.resolve({ data, error: null })
|
||||
return c
|
||||
}
|
||||
@@ -43,9 +54,34 @@ function jeChain(data: JeRow | null) {
|
||||
c.maybeSingle = () => Promise.resolve({ data, error: null })
|
||||
return c
|
||||
}
|
||||
function makeSupabase(txData: TxRow[], jeData: JeRow | null = { voucher_series: 'A', voucher_number: 142, entry_date: '2025-12-19' }) {
|
||||
/** Chain for the anchor lookups, terminal on `.in()`. */
|
||||
function anchorChain(data: unknown[]) {
|
||||
const c: Record<string, unknown> = {}
|
||||
c.select = () => c
|
||||
c.eq = () => c
|
||||
c.in = () => Promise.resolve({ data, error: null })
|
||||
return c
|
||||
}
|
||||
function makeSupabase(
|
||||
txData: TxRow[],
|
||||
jeData: JeRow | null = { voucher_series: 'A', voucher_number: 142, entry_date: '2025-12-19' },
|
||||
anchors: SiblingAnchors = {},
|
||||
) {
|
||||
return {
|
||||
from: (table: string) => (table === 'transactions' ? txChain(txData) : jeChain(jeData)),
|
||||
from: (table: string) => {
|
||||
switch (table) {
|
||||
case 'transactions':
|
||||
return txChain(txData)
|
||||
case 'transaction_voucher_links':
|
||||
return anchorChain(anchors.voucherLinks ?? [])
|
||||
case 'invoice_payments':
|
||||
return anchorChain(anchors.invoicePayments ?? [])
|
||||
case 'supplier_invoice_payments':
|
||||
return anchorChain(anchors.supplierPayments ?? [])
|
||||
default:
|
||||
return jeChain(jeData)
|
||||
}
|
||||
},
|
||||
} as never
|
||||
}
|
||||
|
||||
@@ -140,6 +176,94 @@ describe('detectBookedDuplicateTransaction', () => {
|
||||
expect(result?.transaction_id).toBe('sib-2')
|
||||
})
|
||||
|
||||
// ── Date window (a duplicate import often drifts: bokföringsdag vs valutadag) ──
|
||||
it('flags a booked sibling one day earlier (window recall, gap G1)', async () => {
|
||||
const supabase = makeSupabase([sibling({ date: '2025-12-18' })])
|
||||
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
|
||||
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
|
||||
})
|
||||
expect(result?.transaction_id).toBe('sib-1')
|
||||
})
|
||||
|
||||
it('does NOT flag a booked sibling outside the ±3 day window', async () => {
|
||||
// 5 days out: repeated monthly/weekly charges must not warn.
|
||||
const supabase = makeSupabase([sibling({ date: '2025-12-14' })])
|
||||
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
|
||||
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
|
||||
})
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers the exact-date sibling over a drifted one regardless of id order', async () => {
|
||||
// The drifted sibling carries the LOWER id: a bare lowest-id pick would
|
||||
// choose it, so this pins the date-distance-first ranking (force=true is
|
||||
// bound to the reviewed candidate and re-detection must return the same
|
||||
// one).
|
||||
const supabase = makeSupabase([
|
||||
sibling({ id: 'sib-0-drifted', date: '2025-12-17' }),
|
||||
sibling({ id: 'sib-9-exact', date: '2025-12-19' }),
|
||||
])
|
||||
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
|
||||
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
|
||||
})
|
||||
expect(result?.transaction_id).toBe('sib-9-exact')
|
||||
})
|
||||
|
||||
// ── Booked-ness via is_transaction_booked semantics (gap G2) ────────────
|
||||
it('detects a bulk-booked sibling anchored only via transaction_voucher_links', async () => {
|
||||
// journal_entry_id NULL is the documented shape for N-tx-to-1-JE bulk
|
||||
// bookings (lib/transactions/is-booked.ts); the old bare-column filter
|
||||
// made these twins invisible.
|
||||
const supabase = makeSupabase(
|
||||
[sibling({ journal_entry_id: null })],
|
||||
{ voucher_series: 'A', voucher_number: 142, entry_date: '2025-12-19' },
|
||||
{ voucherLinks: [{ transaction_id: 'sib-1', journal_entry_id: 'je-bulk' }] },
|
||||
)
|
||||
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
|
||||
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
|
||||
})
|
||||
expect(result?.transaction_id).toBe('sib-1')
|
||||
expect(result?.journal_entry_id).toBe('je-bulk')
|
||||
})
|
||||
|
||||
it('detects a multi-allocated sibling anchored only via invoice_payments', async () => {
|
||||
const supabase = makeSupabase(
|
||||
[sibling({ journal_entry_id: null })],
|
||||
{ voucher_series: 'A', voucher_number: 142, entry_date: '2025-12-19' },
|
||||
{ invoicePayments: [{ transaction_id: 'sib-1', journal_entry_id: 'je-alloc' }] },
|
||||
)
|
||||
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
|
||||
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
|
||||
})
|
||||
expect(result?.transaction_id).toBe('sib-1')
|
||||
expect(result?.journal_entry_id).toBe('je-alloc')
|
||||
})
|
||||
|
||||
it('does NOT flag an unbooked sibling (no anchor in any of the three sources)', async () => {
|
||||
const supabase = makeSupabase([sibling({ journal_entry_id: null })])
|
||||
const result = await detectBookedDuplicateTransaction(supabase, COMPANY, {
|
||||
id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null,
|
||||
})
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('excludes a voucher-link-anchored sibling whose entry is in excludeJournalEntryIds', async () => {
|
||||
// A sibling anchored to a JE minted THIS batch is the batch's own fresh
|
||||
// booking, mirroring the ledger half's entry-id exclusion.
|
||||
const supabase = makeSupabase(
|
||||
[sibling({ journal_entry_id: null })],
|
||||
{ voucher_series: 'A', voucher_number: 142, entry_date: '2025-12-19' },
|
||||
{ voucherLinks: [{ transaction_id: 'sib-1', journal_entry_id: 'je-batch' }] },
|
||||
)
|
||||
const result = await detectBookedDuplicateTransaction(
|
||||
supabase,
|
||||
COMPANY,
|
||||
{ id: 'self', date: '2025-12-19', amount: -1616, currency: 'SEK', cash_account_id: null },
|
||||
{ excludeJournalEntryIds: ['je-batch'] },
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// ── Intra-batch exclusion (bulk-book false-positive fix) ────────────────
|
||||
it('excludes a same-batch sibling whose id is in excludeTransactionIds', async () => {
|
||||
const supabase = makeSupabase([sibling({ id: 'sib-batch' })])
|
||||
@@ -276,6 +400,7 @@ function ledgerChain(result: { data: unknown; error: unknown }) {
|
||||
c.gt = () => c
|
||||
c.gte = () => c
|
||||
c.lte = () => c
|
||||
c.order = () => c
|
||||
c.limit = () => Promise.resolve(result)
|
||||
c.maybeSingle = () => Promise.resolve(result)
|
||||
c.single = () => Promise.resolve(result)
|
||||
@@ -303,10 +428,25 @@ function entryLinesChain(rows: unknown[], singleRow: unknown = null) {
|
||||
/** Tables the ledger scan touches, in call order. */
|
||||
const tablesTouched: string[] = []
|
||||
|
||||
/**
|
||||
* A row from the ledger scan's link-exclusion lookup on `transactions`. The
|
||||
* bare `{ journal_entry_id }` shape (legacy fixtures) reads as a linking
|
||||
* transaction with no amount, which never matches the target: exactly the
|
||||
* "reconciled to something else" exclusion behaviour the old tests pinned.
|
||||
*/
|
||||
type LedgerTxLink = {
|
||||
journal_entry_id: string
|
||||
id?: string
|
||||
date?: string
|
||||
amount?: number | string
|
||||
currency?: string | null
|
||||
cash_account_id?: string | null
|
||||
}
|
||||
|
||||
function makeLedgerSupabase(opts: {
|
||||
ledgerAccount?: string | null
|
||||
lines?: Jel[]
|
||||
txLinks?: { journal_entry_id: string }[]
|
||||
txLinks?: LedgerTxLink[]
|
||||
payLinks?: { journal_entry_id: string }[]
|
||||
transactionRows?: TxRow[] // siblings for the orchestrator fall-through
|
||||
}) {
|
||||
@@ -465,6 +605,73 @@ describe('detectLedgerDuplicateVoucher', () => {
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// ── De-exclusion (gap G3): the linking transaction is itself the twin ─────
|
||||
it('returns a voucher whose linking transaction itself matches the target, with transaction_id set', async () => {
|
||||
// The date-drifted duplicate-import shape: one copy of the bank fee is
|
||||
// booked (tx 'twin-1' → je-2), the target is a re-imported copy with a
|
||||
// drifted date. The old exclusion dropped je-2 as "reconciled" and BOTH
|
||||
// guard halves stayed silent.
|
||||
const supabase = makeLedgerSupabase({
|
||||
lines: [jel()],
|
||||
txLinks: [{
|
||||
id: 'twin-1', date: '2026-03-30', amount: 98565, currency: null,
|
||||
cash_account_id: null, journal_entry_id: 'je-2',
|
||||
}],
|
||||
})
|
||||
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
|
||||
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
|
||||
})
|
||||
expect(result?.journal_entry_id).toBe('je-2')
|
||||
expect(result?.transaction_id).toBe('twin-1')
|
||||
expect(result?.account_number).toBe('1930')
|
||||
})
|
||||
|
||||
it('still excludes a voucher whose linking transaction differs in amount', async () => {
|
||||
// Regression for the exclusion invariant: a voucher reconciled to a
|
||||
// NON-matching transaction is genuinely settled by something else.
|
||||
const supabase = makeLedgerSupabase({
|
||||
lines: [jel()],
|
||||
txLinks: [{
|
||||
id: 'other-1', date: '2026-03-30', amount: 50000, currency: null,
|
||||
cash_account_id: null, journal_entry_id: 'je-2',
|
||||
}],
|
||||
})
|
||||
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
|
||||
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
|
||||
})
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('still excludes a voucher whose matching-amount linking transaction is far outside the window', async () => {
|
||||
// Same öre but weeks away: a recurring same-amount fee, not this movement.
|
||||
const supabase = makeLedgerSupabase({
|
||||
lines: [jel()],
|
||||
txLinks: [{
|
||||
id: 'far-1', date: '2026-04-20', amount: 98565, currency: null,
|
||||
cash_account_id: null, journal_entry_id: 'je-2',
|
||||
}],
|
||||
})
|
||||
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
|
||||
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: null,
|
||||
})
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('still excludes a voucher whose linking transaction sits on a different known cash account', async () => {
|
||||
const supabase = makeLedgerSupabase({
|
||||
ledgerAccount: '1930',
|
||||
lines: [jel()],
|
||||
txLinks: [{
|
||||
id: 'acct-1', date: '2026-03-30', amount: 98565, currency: null,
|
||||
cash_account_id: 'ca-other', journal_entry_id: 'je-2',
|
||||
}],
|
||||
})
|
||||
const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, {
|
||||
id: 'self', date: '2026-03-26', amount: 98565, currency: 'SEK', cash_account_id: 'ca-self',
|
||||
})
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores storno/correction vouchers (valid second vouchers, not duplicates)', async () => {
|
||||
const stornoLine = jel({ journal_entry: { ...jel().journal_entry, source_type: 'storno' } })
|
||||
const supabase = makeLedgerSupabase({ lines: [stornoLine] })
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { resolveSekAmountOrNull } from '@/lib/bookkeeping/currency-utils'
|
||||
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
|
||||
import { getPrimaryJournalEntryId } from '@/lib/transactions/is-booked'
|
||||
|
||||
/** Integer öre: representation-agnostic amount key (mirrors the ingest dedup). */
|
||||
function toOre(amount: number | string): number {
|
||||
@@ -118,9 +119,13 @@ export function resolveTransactionAmountSek(tx: TransactionAmountFields): number
|
||||
/** An already-booked transaction OR voucher that looks like the same real movement. */
|
||||
export interface BookedDuplicateCandidate {
|
||||
/**
|
||||
* The sibling transaction that is already booked, or `null` when the duplicate
|
||||
* is a ledger-only voucher (a payment/payout booked straight to the cash
|
||||
* account with no transaction row behind it: see detectLedgerDuplicateVoucher).
|
||||
* The already-booked twin TRANSACTION, or `null` when the duplicate is a
|
||||
* ledger-only voucher (a payment/payout booked straight to the cash account
|
||||
* with no transaction row behind it: see detectLedgerDuplicateVoucher). Set
|
||||
* by the sibling detector, and ALSO by the ledger detector when the matched
|
||||
* voucher's linking transaction is itself the target's twin (the
|
||||
* date-drifted duplicate-import shape): consumers must branch on this field,
|
||||
* not on which detector produced the candidate.
|
||||
*/
|
||||
transaction_id: string | null
|
||||
/** Its verifikat. */
|
||||
@@ -149,10 +154,11 @@ export interface BookedDuplicateCandidate {
|
||||
amount: number | null
|
||||
/**
|
||||
* The 19xx settlement account of the voucher leg that matched, set for
|
||||
* ledger-only candidates so the match action can link on the exact account
|
||||
* the voucher was booked to (a legacy transaction without cash_account_id
|
||||
* would otherwise resolve by currency and can pick the wrong 19xx). Null for
|
||||
* sibling-transaction candidates, whose legs are not fetched.
|
||||
* every ledger-detected candidate (twin-linked or not) so the match action
|
||||
* can link on the exact account the voucher was booked to (a legacy
|
||||
* transaction without cash_account_id would otherwise resolve by currency
|
||||
* and can pick the wrong 19xx). Null for sibling-transaction candidates,
|
||||
* whose legs are not fetched.
|
||||
*/
|
||||
account_number: string | null
|
||||
/**
|
||||
@@ -229,8 +235,29 @@ export interface BookingDuplicateExclusions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an already-booked sibling transaction sharing (date, amount, account).
|
||||
* Returns the single best candidate, or null.
|
||||
* ± days around the target's date an already-booked SIBLING TRANSACTION may
|
||||
* sit and still be "the same" movement. A duplicate import of one real
|
||||
* movement often carries a drifted date (CSV bokföringsdag vs PSD2 valutadag,
|
||||
* a weekend in between), so an exact-date match missed exactly the twin this
|
||||
* guard exists to catch. Deliberately tighter than the voucher window below:
|
||||
* a sibling match additionally requires the exact öre amount in the same
|
||||
* currency, but adjacent-day repeated payments (Swish, weekly SaaS charges)
|
||||
* are common enough that a wide window would over-warn.
|
||||
*/
|
||||
const SIBLING_DUPLICATE_DATE_WINDOW_DAYS = 3
|
||||
|
||||
/**
|
||||
* Find an already-booked sibling transaction sharing (date-window, amount,
|
||||
* account). Returns the single best candidate, or null.
|
||||
*
|
||||
* Booked-ness uses the full is_transaction_booked semantics
|
||||
* (lib/transactions/is-booked.ts): a bare `transactions.journal_entry_id`
|
||||
* check misses bulk-booked rows (anchored via transaction_voucher_links) and
|
||||
* multi-allocated rows (anchored via invoice_payments /
|
||||
* supplier_invoice_payments), which read as "unbooked" and made the guard
|
||||
* blind to their twins. The anchor rows are batch-fetched for the windowed
|
||||
* candidates and the candidate's verifikat resolves via
|
||||
* getPrimaryJournalEntryId.
|
||||
*
|
||||
* Account guard mirrors the import dedup bridge: when BOTH sides know their
|
||||
* cash_account_id they must match; a null on either side is treated as
|
||||
@@ -249,9 +276,10 @@ export interface BookingDuplicateExclusions {
|
||||
* the raw foreign number: see the resolution at the bottom.
|
||||
*
|
||||
* Fail-open: a query error returns null rather than throwing: a detection
|
||||
* failure must never block a legitimate booking. The pick is deterministic
|
||||
* (lowest id) so a re-detection under force=true returns the same candidate the
|
||||
* user reviewed.
|
||||
* failure must never block a legitimate booking. The pick is deterministic:
|
||||
* smallest date distance first (an exact-date sibling always outranks a
|
||||
* drifted one), then earliest date, then lowest id, so a re-detection under
|
||||
* force=true returns the same candidate the user reviewed.
|
||||
*/
|
||||
export async function detectBookedDuplicateTransaction(
|
||||
supabase: SupabaseClient,
|
||||
@@ -262,22 +290,36 @@ export async function detectBookedDuplicateTransaction(
|
||||
const targetOre = toOre(target.amount)
|
||||
if (targetOre === 0 || Number.isNaN(targetOre)) return null
|
||||
// Siblings booked earlier in this same bulk run are distinct events the user
|
||||
// selected, not duplicates: never flag one against another.
|
||||
// selected, not duplicates: never flag one against another. Same for
|
||||
// vouchers minted earlier in the run: a bulk-booked sibling resolves its
|
||||
// verifikat via transaction_voucher_links below, so the entry-id exclusion
|
||||
// applies here too.
|
||||
const excludeTransactionIds = new Set(opts?.excludeTransactionIds ?? [])
|
||||
const excludeJournalEntryIds = new Set(opts?.excludeJournalEntryIds ?? [])
|
||||
const targetCurrency = (target.currency || 'SEK').toUpperCase()
|
||||
|
||||
// Same company, same date, already booked, not the target row itself. The
|
||||
// amount, currency and account match is applied in JS so a numeric-string
|
||||
// amount from PostgREST ("-1616.00") collapses to the same öre as the number
|
||||
// (-1616).
|
||||
const targetDateMs = new Date(target.date).getTime()
|
||||
if (Number.isNaN(targetDateMs)) return null
|
||||
const siblingWindowMs = SIBLING_DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000
|
||||
const siblingLowDate = new Date(targetDateMs - siblingWindowMs).toISOString().split('T')[0]
|
||||
const siblingHighDate = new Date(targetDateMs + siblingWindowMs).toISOString().split('T')[0]
|
||||
|
||||
// Same company, date inside the sibling window, not the target row itself.
|
||||
// The amount, currency and account match is applied in JS so a
|
||||
// numeric-string amount from PostgREST ("-1616.00") collapses to the same
|
||||
// öre as the number (-1616). Booked-ness is resolved AFTER the fetch (see
|
||||
// below): filtering on journal_entry_id here would drop bulk-booked and
|
||||
// multi-allocated siblings whose column is NULL. Ordered so the row set is
|
||||
// deterministic even at the limit.
|
||||
const { data, error } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, date, amount, currency, amount_sek, exchange_rate, description, cash_account_id, journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('date', target.date)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
.gte('date', siblingLowDate)
|
||||
.lte('date', siblingHighDate)
|
||||
.neq('id', target.id)
|
||||
.limit(100)
|
||||
.order('id', { ascending: true })
|
||||
.limit(500)
|
||||
|
||||
if (error || !data || data.length === 0) return null
|
||||
|
||||
@@ -290,7 +332,7 @@ export async function detectBookedDuplicateTransaction(
|
||||
exchange_rate: number | string | null
|
||||
description: string | null
|
||||
cash_account_id: string | null
|
||||
journal_entry_id: string
|
||||
journal_entry_id: string | null
|
||||
}
|
||||
const targetAccount = target.cash_account_id ?? null
|
||||
const matches = (data as unknown as Row[]).filter((r) => {
|
||||
@@ -304,12 +346,71 @@ export async function detectBookedDuplicateTransaction(
|
||||
if (targetAccount !== null && r.cash_account_id !== null && r.cash_account_id !== targetAccount) {
|
||||
return false
|
||||
}
|
||||
return r.journal_entry_id != null
|
||||
// Window re-check in JS (the query already ranged on date): keeps the
|
||||
// window authoritative in one place and drops rows with unparseable dates.
|
||||
const rowMs = new Date(r.date).getTime()
|
||||
return Number.isFinite(rowMs) && Math.abs(rowMs - targetDateMs) <= siblingWindowMs
|
||||
})
|
||||
if (matches.length === 0) return null
|
||||
|
||||
matches.sort((a, b) => a.id.localeCompare(b.id))
|
||||
const best = matches[0]
|
||||
// Resolve booked-ness with the full is_transaction_booked semantics: the
|
||||
// anchor may live on the row itself (journal_entry_id), in
|
||||
// transaction_voucher_links (bulk-book), or in invoice_payments /
|
||||
// supplier_invoice_payments (multi-allocation). Batch-fetched only for the
|
||||
// rows whose own column is NULL. All lookups filter by company_id (defense
|
||||
// in depth alongside RLS).
|
||||
const unanchoredIds = matches.filter((r) => r.journal_entry_id == null).map((r) => r.id)
|
||||
let paymentRows: { transaction_id: string | null; journal_entry_id: string | null }[] = []
|
||||
let voucherLinkRows: { transaction_id: string; journal_entry_id: string }[] = []
|
||||
if (unanchoredIds.length > 0) {
|
||||
const [vl, ip, sp] = await Promise.all([
|
||||
supabase
|
||||
.from('transaction_voucher_links')
|
||||
.select('transaction_id, journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('transaction_id', unanchoredIds),
|
||||
supabase
|
||||
.from('invoice_payments')
|
||||
.select('transaction_id, journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('transaction_id', unanchoredIds),
|
||||
supabase
|
||||
.from('supplier_invoice_payments')
|
||||
.select('transaction_id, journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('transaction_id', unanchoredIds),
|
||||
])
|
||||
voucherLinkRows = (vl.data ?? []) as { transaction_id: string; journal_entry_id: string }[]
|
||||
paymentRows = [...(ip.data ?? []), ...(sp.data ?? [])] as {
|
||||
transaction_id: string | null
|
||||
journal_entry_id: string | null
|
||||
}[]
|
||||
}
|
||||
|
||||
const booked = matches
|
||||
.map((r) => ({
|
||||
row: r,
|
||||
journalEntryId: getPrimaryJournalEntryId(r, paymentRows, voucherLinkRows),
|
||||
}))
|
||||
.filter(
|
||||
(x): x is { row: Row; journalEntryId: string } =>
|
||||
x.journalEntryId != null && !excludeJournalEntryIds.has(x.journalEntryId),
|
||||
)
|
||||
if (booked.length === 0) return null
|
||||
|
||||
// Deterministic pick, and exact-date candidates MUST outrank drifted ones:
|
||||
// force=true re-detection is bound to the candidate the user reviewed
|
||||
// (TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH otherwise), so the ranking has
|
||||
// explicit total-order tiebreakers.
|
||||
booked.sort((a, b) => {
|
||||
const ad = Math.abs(new Date(a.row.date).getTime() - targetDateMs)
|
||||
const bd = Math.abs(new Date(b.row.date).getTime() - targetDateMs)
|
||||
if (ad !== bd) return ad - bd
|
||||
if (a.row.date !== b.row.date) return a.row.date < b.row.date ? -1 : 1
|
||||
return a.row.id.localeCompare(b.row.id)
|
||||
})
|
||||
const best = booked[0].row
|
||||
const bestJournalEntryId = booked[0].journalEntryId
|
||||
|
||||
// Resolve the voucher label for the warning (best-effort: a missing label
|
||||
// still yields a usable candidate the UI can render by date/amount).
|
||||
@@ -318,7 +419,7 @@ export async function detectBookedDuplicateTransaction(
|
||||
const { data: je } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('voucher_series, voucher_number, entry_date')
|
||||
.eq('id', best.journal_entry_id)
|
||||
.eq('id', bestJournalEntryId)
|
||||
.maybeSingle()
|
||||
if (je) {
|
||||
const j = je as { voucher_series: string | null; voucher_number: number | null; entry_date: string | null }
|
||||
@@ -351,7 +452,7 @@ export async function detectBookedDuplicateTransaction(
|
||||
|
||||
return {
|
||||
transaction_id: best.id,
|
||||
journal_entry_id: best.journal_entry_id,
|
||||
journal_entry_id: bestJournalEntryId,
|
||||
voucher_label: voucherLabel,
|
||||
entry_date: entryDate,
|
||||
description: best.description,
|
||||
@@ -422,11 +523,15 @@ const BANK_ACCOUNT_HIGH = 1949
|
||||
* must be on that account's ledger account; otherwise any 19xx leg matches
|
||||
* (single-account companies, legacy rows with no cash_account_id).
|
||||
*
|
||||
* Excludes vouchers already linked to a transaction or an invoice_payment (those
|
||||
* are reconciled, not orphans) and storno/correction entries (valid second
|
||||
* vouchers, not duplicates). Fail-open: a query error returns null so a
|
||||
* detection failure never blocks a legitimate booking. The pick is deterministic
|
||||
* (closest date, then lowest journal_entry id) so a force re-detect is stable.
|
||||
* Excludes vouchers already linked to a transaction or an invoice_payment
|
||||
* (those are reconciled, not orphans) and storno/correction entries (valid
|
||||
* second vouchers, not duplicates): EXCEPT a voucher whose linking transaction
|
||||
* itself matches the target (same öre in the same currency, compatible
|
||||
* account, date in the window), which is returned as the twin with
|
||||
* `transaction_id` set. Fail-open: a query error returns null so a detection
|
||||
* failure never blocks a legitimate booking. The pick is deterministic
|
||||
* (closest date, then lowest journal_entry id, then account) so a force
|
||||
* re-detect is stable.
|
||||
*/
|
||||
export async function detectLedgerDuplicateVoucher(
|
||||
supabase: SupabaseClient,
|
||||
@@ -443,6 +548,12 @@ export async function detectLedgerDuplicateVoucher(
|
||||
// be compared at all, which is handled below rather than swept under a pass.
|
||||
const targetSek = resolveTransactionAmountSek(target)
|
||||
const inbound = targetOre > 0
|
||||
// For the linked-transaction twin test in the exclusion block below: the
|
||||
// linking transaction's `amount` is denominated in ITS OWN currency, so the
|
||||
// öre comparison is only like-with-like when the labels agree (same guard
|
||||
// as the sibling detector).
|
||||
const targetCurrency = (target.currency || 'SEK').toUpperCase()
|
||||
const targetAccount = target.cash_account_id ?? null
|
||||
|
||||
const dateMs = new Date(target.date).getTime()
|
||||
if (Number.isNaN(dateMs)) return null
|
||||
@@ -556,32 +667,93 @@ export async function detectLedgerDuplicateVoucher(
|
||||
// Drop vouchers already reconciled to a transaction or an invoice payment:
|
||||
// those aren't orphans. Both lookups are filtered by company_id (defense in
|
||||
// depth alongside RLS).
|
||||
//
|
||||
// EXCEPTION: a voucher whose linking transaction ITSELF looks like the
|
||||
// target's twin (same öre in the same currency, compatible cash account,
|
||||
// date inside the window) is NOT "reconciled to something else": that shape
|
||||
// is a date-drifted duplicate import row whose copy is already booked.
|
||||
// Excluding it made the guard blind to exactly that double-booking (the
|
||||
// sibling scan missed on date, this scan dropped the voucher as linked).
|
||||
// Such a voucher is returned as the twin WITH transaction_id set so the UI
|
||||
// can offer match/ignore instead of a blind second booking.
|
||||
const entryIds = candidates.map((l) => l.journal_entry.id)
|
||||
const [{ data: txLinks }, { data: payLinks }] = await Promise.all([
|
||||
supabase.from('transactions').select('journal_entry_id').eq('company_id', companyId).in('journal_entry_id', entryIds),
|
||||
supabase
|
||||
.from('transactions')
|
||||
.select('id, date, amount, currency, cash_account_id, journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('journal_entry_id', entryIds),
|
||||
supabase.from('invoice_payments').select('journal_entry_id').eq('company_id', companyId).in('journal_entry_id', entryIds),
|
||||
])
|
||||
const linked = new Set<string>()
|
||||
for (const r of (txLinks ?? []) as { journal_entry_id: string | null }[]) {
|
||||
if (r.journal_entry_id) linked.add(r.journal_entry_id)
|
||||
|
||||
type LinkedTxRow = {
|
||||
id: string
|
||||
date: string
|
||||
amount: number | string
|
||||
currency: string | null
|
||||
cash_account_id: string | null
|
||||
journal_entry_id: string | null
|
||||
}
|
||||
const linkedTxMatchesTarget = (r: LinkedTxRow): boolean => {
|
||||
if (r.id === target.id) return false
|
||||
if ((r.currency || 'SEK').toUpperCase() !== targetCurrency) return false
|
||||
if (toOre(r.amount) !== targetOre) return false
|
||||
if (targetAccount !== null && r.cash_account_id !== null && r.cash_account_id !== targetAccount) {
|
||||
return false
|
||||
}
|
||||
const linkedMs = new Date(r.date).getTime()
|
||||
return Number.isFinite(linkedMs) && Math.abs(linkedMs - dateMs) <= windowMs
|
||||
}
|
||||
|
||||
const linkedTxByEntry = new Map<string, LinkedTxRow[]>()
|
||||
for (const r of (txLinks ?? []) as LinkedTxRow[]) {
|
||||
if (!r.journal_entry_id) continue
|
||||
const arr = linkedTxByEntry.get(r.journal_entry_id) ?? []
|
||||
arr.push(r)
|
||||
linkedTxByEntry.set(r.journal_entry_id, arr)
|
||||
}
|
||||
const paymentLinked = new Set<string>()
|
||||
for (const r of (payLinks ?? []) as { journal_entry_id: string | null }[]) {
|
||||
if (r.journal_entry_id) linked.add(r.journal_entry_id)
|
||||
if (r.journal_entry_id) paymentLinked.add(r.journal_entry_id)
|
||||
}
|
||||
|
||||
const unlinked = candidates.filter((l) => !linked.has(l.journal_entry.id))
|
||||
if (unlinked.length === 0) return null
|
||||
const survivors: { line: (typeof candidates)[number]; twinTransactionId: string | null }[] = []
|
||||
for (const l of candidates) {
|
||||
const entryId = l.journal_entry.id
|
||||
const links = linkedTxByEntry.get(entryId) ?? []
|
||||
// Deterministic twin pick within one voucher: lowest matching tx id.
|
||||
const matchingTwinIds = links
|
||||
.filter(linkedTxMatchesTarget)
|
||||
.map((r) => r.id)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
if (matchingTwinIds.length > 0) {
|
||||
survivors.push({ line: l, twinTransactionId: matchingTwinIds[0] })
|
||||
continue
|
||||
}
|
||||
// Linked to a non-matching transaction or to an invoice payment: genuinely
|
||||
// reconciled to something else, not an orphan. Excluded as before.
|
||||
if (links.length > 0 || paymentLinked.has(entryId)) continue
|
||||
survivors.push({ line: l, twinTransactionId: null })
|
||||
}
|
||||
if (survivors.length === 0) return null
|
||||
|
||||
unlinked.sort((a, b) => {
|
||||
const ad = Math.abs(new Date(a.journal_entry.entry_date).getTime() - dateMs)
|
||||
const bd = Math.abs(new Date(b.journal_entry.entry_date).getTime() - dateMs)
|
||||
survivors.sort((a, b) => {
|
||||
const ad = Math.abs(new Date(a.line.journal_entry.entry_date).getTime() - dateMs)
|
||||
const bd = Math.abs(new Date(b.line.journal_entry.entry_date).getTime() - dateMs)
|
||||
if (ad !== bd) return ad - bd
|
||||
return a.journal_entry.id.localeCompare(b.journal_entry.id)
|
||||
if (a.line.journal_entry.id !== b.line.journal_entry.id) {
|
||||
return a.line.journal_entry.id.localeCompare(b.line.journal_entry.id)
|
||||
}
|
||||
// Same entry can expose two 19xx legs (own-account transfer): total order
|
||||
// keeps the force re-detect stable.
|
||||
return a.line.account_number.localeCompare(b.line.account_number)
|
||||
})
|
||||
const best = unlinked[0]
|
||||
const best = survivors[0].line
|
||||
|
||||
return {
|
||||
transaction_id: null,
|
||||
// Set when the voucher's linking transaction is itself the target's twin
|
||||
// (the de-excluded shape above); null for a true ledger-only orphan.
|
||||
transaction_id: survivors[0].twinTransactionId,
|
||||
journal_entry_id: best.journal_entry.id,
|
||||
voucher_label: `${best.journal_entry.voucher_series ?? 'A'}${best.journal_entry.voucher_number ?? ''}`,
|
||||
entry_date: best.journal_entry.entry_date,
|
||||
|
||||
@@ -5300,6 +5300,10 @@
|
||||
"dialog_duplicate_sek_unavailable": "The voucher books the same amount in {currency}, but its value in kronor cannot be determined because no exchange rate is stored.",
|
||||
"dialog_duplicate_amount_unverified": "The amounts could not be compared because the transaction has no value in kronor. The candidate matches on date, account and direction.",
|
||||
"dialog_duplicate_view_voucher": "View the voucher",
|
||||
"dialog_duplicate_body_sibling": "A voucher with the same amount on the same or an adjacent date already exists, linked to another bank transaction. Do you want to match this transaction to that voucher instead of booking the same business event twice? If these are two separate events you can book anyway.",
|
||||
"dialog_duplicate_ignore_hint": "If this row is a duplicate import of the same event you can ignore it instead.",
|
||||
"dialog_duplicate_ignore": "Ignore the transaction",
|
||||
"dialog_duplicate_ignore_failed": "Could not ignore",
|
||||
"dialog_duplicate_match": "Match to the voucher",
|
||||
"dialog_duplicate_match_failed": "Could not match",
|
||||
"dialog_duplicate_book_anyway": "Book anyway",
|
||||
|
||||
@@ -5300,6 +5300,10 @@
|
||||
"dialog_duplicate_sek_unavailable": "Verifikatet bokför samma belopp i {currency}, men värdet i kronor kan inte fastställas eftersom växelkurs saknas.",
|
||||
"dialog_duplicate_amount_unverified": "Beloppen kunde inte jämföras eftersom transaktionen saknar värde i kronor. Kandidaten matchar på datum, konto och riktning.",
|
||||
"dialog_duplicate_view_voucher": "Visa verifikatet",
|
||||
"dialog_duplicate_body_sibling": "Det finns redan ett verifikat på samma belopp och samma eller närliggande datum, kopplat till en annan banktransaktion. Vill du matcha mot verifikatet i stället för att bokföra samma affärshändelse två gånger? Om det gäller två separata händelser kan du bokföra ändå.",
|
||||
"dialog_duplicate_ignore_hint": "Om raden är en dubblettimport av samma händelse kan du ignorera den i stället.",
|
||||
"dialog_duplicate_ignore": "Ignorera transaktionen",
|
||||
"dialog_duplicate_ignore_failed": "Kunde inte ignorera",
|
||||
"dialog_duplicate_match": "Matcha mot verifikatet",
|
||||
"dialog_duplicate_match_failed": "Kunde inte matcha",
|
||||
"dialog_duplicate_book_anyway": "Bokför ändå",
|
||||
|
||||
Reference in New Issue
Block a user