diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 4c0f0e67..43e83908 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -43,6 +43,7 @@ import TransactionBookingDialog from '@/components/transactions/TransactionBooki import TransactionAttachDocumentDialog from '@/components/transactions/TransactionAttachDocumentDialog' import QuickReviewDialog from '@/components/transactions/QuickReviewDialog' import EditTransactionTitleDialog from '@/components/transactions/EditTransactionTitleDialog' +import DuplicateBookingDialog from '@/components/transactions/DuplicateBookingDialog' import TemplatePicker from '@/components/transactions/TemplatePicker' import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' @@ -189,6 +190,29 @@ export default function TransactionsPage() { } | null>(null) const [ciMatchProcessing, setCiMatchProcessing] = useState(false) + // Booking-time duplicate guard (TRANSACTION_BOOK_POSSIBLE_DUPLICATE): the + // server found this affärshändelse already booked — either another booked + // transaction sharing this one's date+amount+bank account, OR an unlinked + // voucher that already books the amount on the bank account (a paid invoice, + // a salary payout). Surface the existing verifikat and let the user book + // anyway — genuinely repeated same-day payments (e.g. identical Swish + // transfers) are legitimate. "Bokför ändå" retries with force bound to the + // reviewed candidate via expected_duplicate_journal_entry_id (present on both + // candidate kinds), which the server re-detects so a stale id can't wave it. + const [duplicateWarning, setDuplicateWarning] = useState<{ + transactionId: string + retry: () => Promise + candidate: { + transaction_id: string | null + journal_entry_id: string + voucher_label: string + entry_date: string + description: string | null + amount: number + } + } | null>(null) + const [duplicateProcessing, setDuplicateProcessing] = useState(false) + // Entity type for tooltip context const [entityType, setEntityType] = useState('enskild_firma') @@ -602,8 +626,14 @@ export default function TransactionsPage() { templateId?: string inboxItemId?: string confirmNoMatch: boolean + // Set after the user confirms the booking-time duplicate warning. force + // bypasses the guard; the bypass is bound to the reviewed candidate's + // voucher (journal_entry_id), present on both a sibling-transaction and a + // ledger-only voucher candidate. + force?: boolean + expectedDuplicateJournalEntryId?: string }): Promise { - const { id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId, confirmNoMatch } = args + const { id, isBusiness, category, vatTreatment, accountOverride, templateId, inboxItemId, confirmNoMatch, force, expectedDuplicateJournalEntryId } = args try { setProcessingId(id) const response = await fetch(`/api/transactions/${id}/categorize`, { @@ -617,6 +647,9 @@ export default function TransactionsPage() { template_id: templateId, inbox_item_id: inboxItemId, ...(confirmNoMatch ? { confirm_no_match: true } : {}), + ...(force && expectedDuplicateJournalEntryId + ? { force: true, expected_duplicate_journal_entry_id: expectedDuplicateJournalEntryId } + : {}), }), }) @@ -784,6 +817,35 @@ export default function TransactionsPage() { setProcessingId(null) return null } + if ( + result?.error?.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE' && + result.error.details?.candidate + ) { + // Booking-time duplicate guard fired. Don't dead-end on a toast that + // merely says "book anyway" with no way to do so — open a dialog with + // the already-booked sibling and let the user confirm. "Bokför ändå" + // re-runs with force bound to this candidate (server re-detects it). + const candidate = result.error.details.candidate as { + transaction_id: string | null + journal_entry_id: string + voucher_label: string + entry_date: string + description: string | null + amount: number + } + setDuplicateWarning({ + transactionId: id, + retry: () => + runCategorize({ + ...args, + force: true, + expectedDuplicateJournalEntryId: candidate.journal_entry_id, + }), + candidate, + }) + setProcessingId(null) + return null + } toast({ title: 'Kategorisering misslyckades', description: getErrorMessage(result, { context: 'transaction', statusCode: response.status }), @@ -2531,6 +2593,22 @@ export default function TransactionsPage() { + setDuplicateWarning(null)} + onBookAnyway={async () => { + const retry = duplicateWarning?.retry + setDuplicateProcessing(true) + try { + setDuplicateWarning(null) + if (retry) await retry() + } finally { + setDuplicateProcessing(false) + } + }} + /> + ) } diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index fb12ac06..dfc206a1 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -98,19 +98,24 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 }) } - // VAT rules are customer-type-driven; the seller's registration status no - // longer constrains the preview. A non-momsregistrerad seller who chose a - // non-zero rate sees the rate they picked rendered — the form surfaces the - // ML 16 kap. 23 § warning at submit time. + // VAT rules are customer-type-driven and only know the customer side. const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated) const docType: InvoiceDocumentType = document_type || 'invoice' const isDeliveryNote = docType === 'delivery_note' + // VAT registration gate — mirror the server-side write gate + // (lib/invoices/build-invoice-write.ts) so the preview never shows output VAT + // for a non-momsregistrerad seller. Without this the per-item fallback below + // (`?? vatRules.rate`) would render 25% for a Swedish customer even though the + // created invoice books no VAT, misleading the user at the review step. + const notVatRegistered = (company as { vat_registered?: boolean }).vat_registered === false + const zeroVat = notVatRegistered && !isDeliveryNote + // Build items with line totals and per-item VAT const invoiceItems: InvoiceItem[] = items.map((item: { description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number }, index: number) => { const lineTotal = Math.round(item.quantity * item.unit_price * 100) / 100 - const rate = item.vat_rate ?? vatRules.rate + const rate = zeroVat ? 0 : (item.vat_rate ?? vatRules.rate) return { id: `preview-${index}`, invoice_id: 'preview', diff --git a/app/api/transactions/[id]/book/__tests__/route.test.ts b/app/api/transactions/[id]/book/__tests__/route.test.ts index 860311c8..14bf7755 100644 --- a/app/api/transactions/[id]/book/__tests__/route.test.ts +++ b/app/api/transactions/[id]/book/__tests__/route.test.ts @@ -37,7 +37,7 @@ vi.mock('@/lib/bookkeeping/engine', () => ({ // lib/transactions/__tests__/booking-duplicate-detection.test.ts. const mockDetectDup = vi.fn() vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({ - detectBookedDuplicateTransaction: (...args: unknown[]) => mockDetectDup(...args), + detectBookingDuplicate: (...args: unknown[]) => mockDetectDup(...args), })) // Behandlingshistorik append — mocked so we can assert the dismissal is @@ -52,6 +52,7 @@ import { POST } from '../route' const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000' const SIBLING_UUID = '660e8400-e29b-41d4-a716-446655440111' const OTHER_UUID = '770e8400-e29b-41d4-a716-446655440222' +const VOUCHER_JE_UUID = '880e8400-e29b-41d4-a716-446655440333' describe('POST /api/transactions/[id]/book', () => { const mockUser = { id: 'user-1', email: 'test@test.se' } @@ -289,6 +290,68 @@ describe('POST /api/transactions/[id]/book', () => { ) }) + it('returns 409 when a ledger-only voucher (no sibling transaction) already books this movement', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 98565, journal_entry_id: null }) + enqueue({ data: tx, error: null }) // fetch + // A voucher-keyed candidate has no transaction_id — it's bound by je id. + mockDetectDup.mockResolvedValue({ + transaction_id: null, + journal_entry_id: VOUCHER_JE_UUID, + voucher_label: 'A2', + entry_date: '2026-03-30', + description: 'Inbetalning kundfaktura 2026001', + amount: 98565, + }) + + const request = createMockRequest('/api/transactions/tx-1/book', { method: 'POST', body: validBody }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { candidate: { transaction_id: string | null; journal_entry_id: string } } } + }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('TRANSACTION_BOOK_POSSIBLE_DUPLICATE') + expect(body.error.details.candidate.transaction_id).toBeNull() + expect(body.error.details.candidate.journal_entry_id).toBe(VOUCHER_JE_UUID) + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + }) + + it('books a voucher-keyed duplicate when force=true binds the expected journal_entry_id', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 98565, journal_entry_id: null }) + const je = makeJournalEntry({ id: 'je-new' }) + enqueue({ data: tx, error: null }) // fetch + enqueue({ data: null, error: null }) // update + mockDetectDup.mockResolvedValue({ + transaction_id: null, + journal_entry_id: VOUCHER_JE_UUID, + voucher_label: 'A2', + entry_date: '2026-03-30', + description: null, + amount: 98565, + }) + mockCreateJournalEntry.mockResolvedValue(je) + + const request = createMockRequest('/api/transactions/tx-1/book', { + method: 'POST', + body: { ...validBody, force: true, expected_duplicate_journal_entry_id: VOUCHER_JE_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(mockCreateJournalEntry).toHaveBeenCalledTimes(1) + expect(mockAppendProcessingHistory).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'BankTransactionDuplicateDismissed', + payload: expect.objectContaining({ + dismissed_transaction_id: null, + dismissed_journal_entry_id: VOUCHER_JE_UUID, + }), + }), + ) + }) + it('rejects force=true when the expected sibling no longer matches the detected one', async () => { const tx = makeTransaction({ id: 'tx-1', amount: -500, journal_entry_id: null }) enqueue({ data: tx, error: null }) // fetch diff --git a/app/api/transactions/[id]/book/route.ts b/app/api/transactions/[id]/book/route.ts index 481acc08..17feb3a4 100644 --- a/app/api/transactions/[id]/book/route.ts +++ b/app/api/transactions/[id]/book/route.ts @@ -8,7 +8,7 @@ import { validateBody } from '@/lib/api/validate' import { BookTransactionSchema } from '@/lib/api/schemas' import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' -import { detectBookedDuplicateTransaction } from '@/lib/transactions/booking-duplicate-detection' +import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { createLogger } from '@/lib/logger' import { appendProcessingHistory } from '@/lib/processing-history/append' @@ -36,7 +36,7 @@ export async function POST( const validation = await validateBody(request, BookTransactionSchema) if (!validation.success) return validation.response - const { fiscal_period_id, entry_date, description, lines, force, expected_duplicate_transaction_id } = validation.data + const { fiscal_period_id, entry_date, description, lines, force, expected_duplicate_transaction_id, expected_duplicate_journal_entry_id } = validation.data // Fetch transaction (validates ownership) const { data: transaction, error: fetchError } = await supabase @@ -65,7 +65,7 @@ export async function POST( // match-invoice soft-duplicate guard. const dupLog = createLogger('transactions.book', { companyId, userId: user.id }) try { - const candidate = await detectBookedDuplicateTransaction(supabase, companyId, { + const candidate = await detectBookingDuplicate(supabase, companyId, { id, date: transaction.date, amount: transaction.amount, @@ -77,13 +77,24 @@ export async function POST( details: { candidate }, }) } - } else if (!candidate || candidate.transaction_id !== expected_duplicate_transaction_id) { - // force=true is bound to a specific candidate. Re-detect and refuse the - // bypass unless it still matches, so a guessed id can't wave the guard. + } else if ( + // force=true is bound to the reviewed candidate. A sibling-transaction + // candidate carries a transaction_id; a ledger-only voucher candidate does + // not, so both are bound by journal_entry_id. Either echoed id confirms. + // Re-detect and refuse the bypass unless it still matches, so a guessed id + // can't wave the guard. + !candidate || + !( + (candidate.journal_entry_id && candidate.journal_entry_id === expected_duplicate_journal_entry_id) || + (candidate.transaction_id && candidate.transaction_id === expected_duplicate_transaction_id) + ) + ) { return errorResponseFromCode('TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH', dupLog, { details: { expected_duplicate_transaction_id: expected_duplicate_transaction_id ?? null, + expected_duplicate_journal_entry_id: expected_duplicate_journal_entry_id ?? null, detected_transaction_id: candidate?.transaction_id ?? null, + detected_journal_entry_id: candidate?.journal_entry_id ?? null, }, }) } else { diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index 5d7466dd..62935bcd 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -43,7 +43,7 @@ vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ // unit-tested in lib/transactions/__tests__/booking-duplicate-detection.test.ts. const mockDetectDup = vi.fn() vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({ - detectBookedDuplicateTransaction: (...args: unknown[]) => mockDetectDup(...args), + detectBookingDuplicate: (...args: unknown[]) => mockDetectDup(...args), })) // Behandlingshistorik append — mocked so we can assert the dismissal is diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index 013bc9dc..cf81acd2 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -5,7 +5,7 @@ import { ensureInitialized } from '@/lib/init' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEntity } from '@/lib/bookkeeping/booking-templates' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' -import { detectBookedDuplicateTransaction } from '@/lib/transactions/booking-duplicate-detection' +import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection' import { appendProcessingHistory } from '@/lib/processing-history/append' import { saveUserMappingRule, applySettlementAccount } from '@/lib/bookkeeping/mapping-engine' import { upsertCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates' @@ -153,7 +153,7 @@ export const POST = withRouteContext( // bound to the reviewed sibling. Mirrors the match-invoice soft-duplicate // guard. Runs before any categorization work so the user resolves it first. try { - const candidate = await detectBookedDuplicateTransaction(supabase, companyId, { + const candidate = await detectBookingDuplicate(supabase, companyId, { id, date: transaction.date, amount: transaction.amount, @@ -166,12 +166,24 @@ export const POST = withRouteContext( details: { candidate }, }) } - } else if (!candidate || candidate.transaction_id !== body.expected_duplicate_transaction_id) { + } else if ( + // force=true is bound to the reviewed candidate. A sibling-transaction + // candidate carries a transaction_id; a ledger-only voucher candidate + // does not, so both are bound by journal_entry_id. Re-detect and refuse + // the bypass unless it still matches, so a guessed id can't wave it away. + !candidate || + !( + (candidate.journal_entry_id && candidate.journal_entry_id === body.expected_duplicate_journal_entry_id) || + (candidate.transaction_id && candidate.transaction_id === body.expected_duplicate_transaction_id) + ) + ) { return errorResponseFromCode('TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH', txLog, { requestId, details: { expected_duplicate_transaction_id: body.expected_duplicate_transaction_id ?? null, + expected_duplicate_journal_entry_id: body.expected_duplicate_journal_entry_id ?? null, detected_transaction_id: candidate?.transaction_id ?? null, + detected_journal_entry_id: candidate?.journal_entry_id ?? null, }, }) } else { diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index 2f6e4539..8a9e97c7 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -20,6 +20,7 @@ import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicke import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog' import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog' import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog' +import DuplicateBookingDialog from '@/components/transactions/DuplicateBookingDialog' import { Skeleton } from '@/components/ui/skeleton' import { useSubmitWithAccountActivation, @@ -32,6 +33,7 @@ import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import { useCompany } from '@/contexts/CompanyContext' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency } from '@/types' +import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection' const CURRENCIES: { value: Currency; label: string }[] = [ { value: 'SEK', label: 'SEK' }, @@ -109,6 +111,13 @@ export default function JournalEntryForm({ const [voucherSeries, setVoucherSeries] = useState(initialVoucherSeries ?? 'A') const [nextVoucherNumber, setNextVoucherNumber] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) + // Booking-time duplicate guard (TRANSACTION_BOOK_POSSIBLE_DUPLICATE): the + // /book endpoint flags an already-booked sibling sharing date+amount+account. + // Surface it and let the user book anyway. The override is bound to the + // reviewed candidate via a ref the next submit reads — force is sent ONLY on + // that retry, never on a normal submit or to the manual journal-entry endpoint. + const [duplicateCandidate, setDuplicateCandidate] = useState(null) + const forceDuplicateRef = useRef<{ force: true; expected_duplicate_journal_entry_id: string } | null>(null) const [showReview, setShowReview] = useState(false) const [isSavingDraft, setIsSavingDraft] = useState(false) const saveAsDraftRef = useRef(false) @@ -569,6 +578,10 @@ export default function JournalEntryForm({ voucher_series: voucherSeries || 'A', notes: notes || undefined, lines: entryLines, + // Set only when retrying past the booking-time duplicate guard (see + // handleBookAnyway). Stripped by schemas that don't declare it, so a + // stray value never reaches the manual journal-entry endpoint. + ...(forceDuplicateRef.current ?? {}), }), }) return (await throwOnStructuredError(res)) as { data?: { id?: string; voucher_series?: string; voucher_number?: number }; journal_entry_id?: string } @@ -628,18 +641,51 @@ export default function JournalEntryForm({ if (err instanceof Error && err.message === 'cancelled') { // User dismissed the activation dialog — no toast needed } else { - const anyErr = err as { body?: unknown; status?: number } - toast({ - title: t('toast_create_failed'), - description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }), - variant: 'destructive', - }) + const anyErr = err as { + body?: { error?: { code?: string; details?: { candidate?: BookedDuplicateCandidate } } } + status?: number + } + const candidate = anyErr.body?.error?.details?.candidate + if (anyErr.body?.error?.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE' && candidate) { + // Soft duplicate guard fired — don't dead-end on a toast that merely + // says "book anyway". Open the dialog so the user can review the + // existing verifikat or confirm. handleBookAnyway re-submits with + // force bound to this candidate. + setDuplicateCandidate(candidate) + } else { + toast({ + title: t('toast_create_failed'), + description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }), + variant: 'destructive', + }) + } } } finally { setIsSubmitting(false) } } + // Retry the booking past the duplicate guard. force is bound to the reviewed + // candidate via the ref; cleared afterwards so a later normal submit can't + // inherit it. handleConfirm runs its full success path (toast, reset, + // onEntryCreated) exactly as a first-try booking would. + const handleBookAnyway = async () => { + const candidate = duplicateCandidate + if (!candidate) return + forceDuplicateRef.current = { + force: true, + // Bind on the voucher id — present on both a sibling-transaction candidate + // and a ledger-only voucher candidate (which has no transaction_id). + expected_duplicate_journal_entry_id: candidate.journal_entry_id, + } + setDuplicateCandidate(null) + try { + await handleConfirm() + } finally { + forceDuplicateRef.current = null + } + } + const handleSaveDraft = async () => { if (!selectedPeriod || !description || !isBalanced || periodMismatch) return setIsSavingDraft(true) @@ -1420,6 +1466,13 @@ export default function JournalEntryForm({ + + setDuplicateCandidate(null)} + onBookAnyway={handleBookAnyway} + /> ) diff --git a/components/settings/BankDetailsForm.tsx b/components/settings/BankDetailsForm.tsx index 34ad0cb7..5c8f6a89 100644 --- a/components/settings/BankDetailsForm.tsx +++ b/components/settings/BankDetailsForm.tsx @@ -5,7 +5,7 @@ import { useState } from 'react' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { BankNameCombobox } from '@/components/settings/BankNameCombobox' -import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn' +import { validateBankgiroNumber, formatBankgiroNumber, validatePlusgiroNumber, formatPlusgiroNumber } from '@/lib/bankgiro/luhn' import { normaliseSwish, isValidSwish } from '@/lib/payments/swish' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import type { CompanySettings } from '@/types' @@ -17,6 +17,7 @@ interface BankDetailsFormProps { export function BankDetailsForm({ settings }: BankDetailsFormProps) { const t = useTranslations('settings_bank_details_form') const [bankgiroError, setBankgiroError] = useState(null) + const [plusgiroError, setPlusgiroError] = useState(null) const [clearingError, setClearingError] = useState(null) const [accountNumberError, setAccountNumberError] = useState(null) const [swishError, setSwishError] = useState(null) @@ -81,7 +82,7 @@ export function BankDetailsForm({ settings }: BankDetailsFormProps) { -
+
{bankgiroError}

}
+
+ + { + const val = e.target.value.trim() + if (!val) { setPlusgiroError(null); return } + if (validatePlusgiroNumber(val)) { + e.target.value = formatPlusgiroNumber(val) + setPlusgiroError(null) + } else { + setPlusgiroError(t('plusgiro_error')) + } + }} + /> + {plusgiroError &&

{plusgiroError}

} +
+
void + onCancel: () => void +}) { + const t = useTranslations('transactions') + + return ( + { + if (!open) onCancel() + }} + > + + + {t('dialog_duplicate_title')} + +
+

{t('dialog_duplicate_body')}

+ {candidate && ( +
+
+ + {candidate.voucher_label + ? t('dialog_duplicate_voucher_label', { label: candidate.voucher_label }) + : t('dialog_duplicate_voucher_generic')} + + {formatCurrency(candidate.amount)} +
+
+ {formatDate(candidate.entry_date)} +
+ {candidate.description && ( +
{candidate.description}
+ )} +
+ )} +
+ {candidate && ( + + )} +
+ + +
+
+
+
+
+ ) +} diff --git a/components/transactions/TemplatePicker.tsx b/components/transactions/TemplatePicker.tsx index 134e68c2..ab02c265 100644 --- a/components/transactions/TemplatePicker.tsx +++ b/components/transactions/TemplatePicker.tsx @@ -345,21 +345,36 @@ export default function TemplatePicker({ onSelect(template) } - // Click a raw library card. Convertible → fast QuickReview path via onSelect. - // Non-convertible → route to manual booking dialog pre-filled via the new - // onPickLibraryTemplate callback. MRU is only bumped after we confirm the - // click will actually do something — otherwise consumers that omit the - // callback would corrupt MRU ordering for templates the user never applied. + // Click a raw library card. Always book a user's mall from its LITERAL lines + // via the journal-entry editor (onPickLibraryTemplate → applyTemplate → /book), + // for both convertible and non-convertible shapes. + // + // The old "convertible → onSelect(converted)" branch routed through the + // QuickReview fast path, which books a single category + one account_override + // and silently discards the template's chosen debit/credit. A kundinbetalning + // mall (D 1930 / K 1510) came out as a generic cost (D 6991 / K 1930), or with + // a VAT line as D 1930 / K 1930 / K 2611 — and the result flipped with the + // direction the converter happened to infer from the business/settlement tags. + // Routing every library template through the editor books exactly the accounts + // the user defined, independent of those tags. See template-library.test.ts. + // + // MRU is only bumped once we know the click will do something — otherwise a + // consumer that omits onPickLibraryTemplate would reorder MRU for a template + // the user never actually applied. const handleSelectLibraryRaw = (raw: BookingTemplateLibrary) => { + if (onPickLibraryTemplate) { + bumpLibraryMru(raw.id) + onPickLibraryTemplate(raw) + return + } + // Fallback only for consumers that didn't wire the editor path: fall back to + // the lossy converted shape rather than leaving the click dead. The single + // render site (the transactions page) always passes onPickLibraryTemplate, + // so this branch is not reached in the app today. const converted = convertedById.get(raw.id) ?? null if (converted) { bumpLibraryMru(raw.id) onSelect(converted) - return - } - if (onPickLibraryTemplate) { - bumpLibraryMru(raw.id) - onPickLibraryTemplate(raw) } } diff --git a/components/ui/info-tooltip.tsx b/components/ui/info-tooltip.tsx index 5c7d9eb0..80e5a2af 100644 --- a/components/ui/info-tooltip.tsx +++ b/components/ui/info-tooltip.tsx @@ -15,15 +15,20 @@ const TooltipContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, sideOffset = 4, ...props }, ref) => ( - + // Portal the content to document.body so the tooltip is never clipped by an + // ancestor with overflow (e.g. a scrollable DialogContent — the send-invoice + // and journal-review dialogs use overflow-y-auto, which otherwise crops it). + + + )) TooltipContent.displayName = TooltipPrimitive.Content.displayName diff --git a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts index be9ccb85..f9d218d5 100644 --- a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts +++ b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts @@ -109,6 +109,13 @@ vi.mock('@/lib/transactions/category-suggestions', () => ({ getSuggestedCategories: vi.fn(), })) +// The categorize tool runs the booking-time duplicate guard before staging. +// These tests don't exercise that path, so stub it to "no duplicate" — otherwise +// its detection queries would consume the queued supabase mock results. +vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({ + detectBookingDuplicate: vi.fn().mockResolvedValue(null), +})) + vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({ upsertCounterpartyTemplate: vi.fn(), findCounterpartyTemplatesBatch: vi.fn(), diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index ab0df8ee..8d366bff 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -65,6 +65,8 @@ import { generateSIEExport } from '@/lib/reports/sie-export' import { generateFullArchive, estimateArchiveSize } from '@/lib/reports/full-archive-export' import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' import { getSuggestedCategories } from '@/lib/transactions/category-suggestions' +import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection' +import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates' import { renderToBuffer } from '@react-pdf/renderer' import { InvoicePDF } from '@/lib/invoices/pdf-template' import { getEmailService } from '@/lib/email/service' @@ -2675,6 +2677,7 @@ export const tools: McpTool[] = [ vat_treatment: { type: 'string', description: 'VAT treatment override. Defaults to standard_25 for business expenses. Set reverse_charge ONLY when the underlag confirms the seller did NOT charge VAT (omvänd skattskyldighet). An invoice with foreign VAT already debited is NOT reverse charge.', enum: [...VALID_VAT_TREATMENTS] }, vat_amount: { type: 'number', exclusiveMinimum: 0, description: 'The underlag\'s exact moms (> 0) when it differs from rate × belopp — e.g. dricks carries no VAT. Requires a rate-based vat_treatment. Swedish moms only — foreign VAT is never deductible. For a 0-moms document use vat_treatment="exempt".' }, notes: { type: 'string', description: 'Audit-trail context appended to the verifikation description. For category=representation use this to record deltagare + syfte ("Anna Andersson (Acme AB), kundmöte om Y"). For project work, include the project ref. Keep under 200 chars; pure metadata, not a re-description of the transaction.' }, + allow_duplicate: { type: 'boolean', description: 'Override the duplicate-booking guard (default false). Set true ONLY after the user confirms this bank line is a genuinely separate event — the guard blocks a second verifikat for an event already booked (e.g. a paid invoice or a salary payout).' }, }, required: ['transaction_id', 'category'], }, @@ -2711,11 +2714,34 @@ export const tools: McpTool[] = [ // Fetch transaction description (and date for period_status) for the title const { data: tx } = await supabase .from('transactions') - .select('description, merchant_name, amount, currency, date') + .select('description, merchant_name, amount, currency, date, cash_account_id') .eq('id', args.transaction_id as string) .eq('company_id', companyId) .single() + // Booking-time duplicate guard — surface a likely double-booking to the + // agent NOW (before staging) so it can link to the existing verifikat + // instead of queuing a second one for approval. The commit executor + // re-checks as the hard gate; this is the early, actionable signal. + // Mirrors the web /categorize route's guard. + if (args.allow_duplicate !== true && tx) { + const dup = await detectBookingDuplicate(supabase, companyId, { + id: args.transaction_id as string, + date: tx.date, + amount: tx.amount, + cash_account_id: (tx as { cash_account_id?: string | null }).cash_account_id ?? null, + }) + if (dup) { + const amountAbs = roundOre(Math.abs(Number(tx.amount))) + const voucher = dup.voucher_label ? `verifikat ${dup.voucher_label}` : 'en befintlig verifikation' + throw new Error( + `Möjlig dubblettbokföring: ${voucher} (${dup.entry_date}) bokför redan ${amountAbs} kr på bankkontot. ` + + `Den här affärshändelsen ser redan ut att vara bokförd — länka transaktionen till den befintliga ` + + `verifikationen i stället. Anropa igen med allow_duplicate=true först om det är en genuint separat affärshändelse.`, + ) + } + } + const txDesc = tx ? `${tx.merchant_name || tx.description || 'Transaktion'} ${tx.amount} ${tx.currency}` : String(args.transaction_id) @@ -2731,6 +2757,7 @@ export const tools: McpTool[] = [ notes: typeof args.notes === 'string' && args.notes.trim().length > 0 ? (args.notes as string).trim() : null, + allow_duplicate: args.allow_duplicate === true, }, { debit_account: result.debit_account, @@ -3705,6 +3732,7 @@ export const tools: McpTool[] = [ properties: { invoice_id: { type: 'string', description: 'UUID of the invoice' }, payment_date: { type: 'string', description: 'Payment date YYYY-MM-DD (default: today)' }, + allow_duplicate: { type: 'boolean', description: 'Override the duplicate-payment guard (default false). Set true ONLY after the user confirms; the guard blocks marking paid when an unlinked bank transaction already looks like this invoice\'s payment — match that transaction instead.' }, }, required: ['invoice_id'], }, @@ -3733,9 +3761,32 @@ export const tools: McpTool[] = [ const paymentDate = (args.payment_date as string) || new Date().toISOString().split('T')[0] + // Duplicate-payment guard — surface a likely existing bank payment to the + // agent before staging, so it matches the transaction to the invoice + // instead of booking a parallel payment voucher (the orphan that later + // double-counts the receipt). The commit executor re-checks as the hard + // gate. Mirrors the web mark-paid route's guard. + if (args.allow_duplicate !== true && invoice.customer?.name) { + const remainingAmount = + (invoice as { remaining_amount?: number }).remaining_amount ?? invoice.total + const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { + companyId, + invoice: { invoice_number: invoice.invoice_number, customer_name: invoice.customer.name }, + paymentAmount: remainingAmount, + paymentDate, + }) + if (candidates.length > 0) { + throw new Error( + `Möjlig dubbelbetalning: en obokförd banktransaktion ser ut att vara betalningen för faktura ` + + `${invoice.invoice_number}. Matcha banktransaktionen mot fakturan med gnubok_match_transaction_to_invoice ` + + `i stället. Anropa igen med allow_duplicate=true om det verkligen är en separat betalning.`, + ) + } + } + return stagePendingOperation(supabase, companyId, userId, 'mark_invoice_paid', `Betald: ${invoice.invoice_number} ${invoice.customer?.name || ''} ${invoice.total} ${invoice.currency}`, - { invoice_id: invoiceId, payment_date: paymentDate }, + { invoice_id: invoiceId, payment_date: paymentDate, allow_duplicate: args.allow_duplicate === true }, { invoice_number: invoice.invoice_number, customer_name: invoice.customer?.name, diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index d575f908..efe6369e 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -748,14 +748,18 @@ export const CategorizeTransactionSchema = z confirm_no_match: z.boolean().optional(), // Booking-time duplicate guard (TRANSACTION_BOOK_POSSIBLE_DUPLICATE). force // bypasses it after the user reviews the candidate; the bypass is bound to - // the specific already-booked sibling via expected_duplicate_transaction_id - // (re-detected server-side, so a guessed id can't wave the guard away). + // the specific already-booked candidate (re-detected server-side, so a + // guessed id can't wave the guard away). The candidate is either a sibling + // transaction (expected_duplicate_transaction_id) or a ledger-only voucher + // with no transaction behind it (expected_duplicate_journal_entry_id) — both + // carry a journal_entry_id, so new callers bind on that. force: z.boolean().optional(), expected_duplicate_transaction_id: uuid.optional(), + expected_duplicate_journal_entry_id: uuid.optional(), }) - .refine((v) => !v.force || !!v.expected_duplicate_transaction_id, { - message: 'expected_duplicate_transaction_id is required when force=true', - path: ['expected_duplicate_transaction_id'], + .refine((v) => !v.force || !!v.expected_duplicate_transaction_id || !!v.expected_duplicate_journal_entry_id, { + message: 'expected_duplicate_transaction_id or expected_duplicate_journal_entry_id is required when force=true', + path: ['expected_duplicate_journal_entry_id'], }) export const BookTransactionSchema = z @@ -767,10 +771,11 @@ export const BookTransactionSchema = z // Booking-time duplicate guard — see CategorizeTransactionSchema. force: z.boolean().optional(), expected_duplicate_transaction_id: uuid.optional(), + expected_duplicate_journal_entry_id: uuid.optional(), }) - .refine((v) => !v.force || !!v.expected_duplicate_transaction_id, { - message: 'expected_duplicate_transaction_id is required when force=true', - path: ['expected_duplicate_transaction_id'], + .refine((v) => !v.force || !!v.expected_duplicate_transaction_id || !!v.expected_duplicate_journal_entry_id, { + message: 'expected_duplicate_transaction_id or expected_duplicate_journal_entry_id is required when force=true', + path: ['expected_duplicate_journal_entry_id'], }) /** diff --git a/lib/bankgiro/__tests__/luhn.test.ts b/lib/bankgiro/__tests__/luhn.test.ts index a2c99e3c..81dc0347 100644 --- a/lib/bankgiro/__tests__/luhn.test.ts +++ b/lib/bankgiro/__tests__/luhn.test.ts @@ -3,6 +3,8 @@ import { luhnValidate, validateBankgiroNumber, formatBankgiroNumber, + validatePlusgiroNumber, + formatPlusgiroNumber, generateOcrReference, validateOcrReference, } from '../luhn' @@ -92,6 +94,68 @@ describe('formatBankgiroNumber', () => { }) }) +// -- Plusgiro -- + +describe('validatePlusgiroNumber', () => { + it('validates plusgiro with hyphen', () => { + // 4567 → Luhn check digit 4 → "4567-4" + expect(validatePlusgiroNumber('4567-4')).toBe(true) + }) + + it('validates raw digits without hyphen', () => { + expect(validatePlusgiroNumber('45674')).toBe(true) + expect(validatePlusgiroNumber('1234566')).toBe(true) + }) + + it('validates short (2-digit) plusgiro', () => { + // "0" → check digit 0 → "00" + expect(validatePlusgiroNumber('00')).toBe(true) + }) + + it('validates full 8-digit plusgiro', () => { + expect(validatePlusgiroNumber('55555551')).toBe(true) + }) + + it('rejects wrong check digit', () => { + expect(validatePlusgiroNumber('4567-5')).toBe(false) + }) + + it('rejects too long (>8 digits)', () => { + expect(validatePlusgiroNumber('123456789')).toBe(false) + }) + + it('rejects single-digit input', () => { + expect(validatePlusgiroNumber('5')).toBe(false) + }) + + it('rejects non-numeric input', () => { + expect(validatePlusgiroNumber('abc-d')).toBe(false) + }) + + it('handles spaces', () => { + expect(validatePlusgiroNumber('4567 4')).toBe(true) + }) +}) + +describe('formatPlusgiroNumber', () => { + it('places hyphen before the check digit', () => { + expect(formatPlusgiroNumber('45674')).toBe('4567-4') + }) + + it('formats 8-digit as XXXXXXX-X', () => { + expect(formatPlusgiroNumber('55555551')).toBe('5555555-1') + }) + + it('handles already-formatted input', () => { + expect(formatPlusgiroNumber('4567-4')).toBe('4567-4') + }) + + it('returns input unchanged for invalid lengths', () => { + expect(formatPlusgiroNumber('5')).toBe('5') + expect(formatPlusgiroNumber('123456789')).toBe('123456789') + }) +}) + // -- OCR reference -- describe('generateOcrReference', () => { diff --git a/lib/bankgiro/luhn.ts b/lib/bankgiro/luhn.ts index e1177e4c..5a7dcd5b 100644 --- a/lib/bankgiro/luhn.ts +++ b/lib/bankgiro/luhn.ts @@ -55,6 +55,30 @@ export function formatBankgiroNumber(input: string): string { return input } +// -- Plusgiro -- + +/** + * Validate a Swedish Plusgiro number (2-8 digits, Luhn check digit). + * The final digit is the Luhn check digit. Accepts formats: + * "XXXXXXX-X", spaced, or raw digits. + */ +export function validatePlusgiroNumber(input: string): boolean { + const digits = input.replace(/[-\s]/g, '') + if (!/^\d+$/.test(digits)) return false + if (digits.length < 2 || digits.length > 8) return false + return luhnValidate(digits) +} + +/** + * Format a Plusgiro number with the standard hyphen before the check digit. + * e.g. "45674" → "4567-4". Returns input unchanged for invalid lengths. + */ +export function formatPlusgiroNumber(input: string): string { + const digits = input.replace(/[-\s]/g, '') + if (digits.length < 2 || digits.length > 8) return input + return digits.slice(0, -1) + '-' + digits.slice(-1) +} + // -- OCR reference -- /** diff --git a/lib/bookkeeping/__tests__/template-library.test.ts b/lib/bookkeeping/__tests__/template-library.test.ts index ac0d204c..895c551c 100644 --- a/lib/bookkeeping/__tests__/template-library.test.ts +++ b/lib/bookkeeping/__tests__/template-library.test.ts @@ -245,3 +245,52 @@ describe('applyTemplate on shapes the converter rejects', () => { expect(result[1].credit_amount).toBe('250.00') }) }) + +describe('library mall books its literal accounts (regression)', () => { + // A user's "Inbetalning från kund" mall is D 1930 (bank) / K 1510 + // (kundfordran). The QuickReview fast path used to reduce a library template + // to a category + one account_override and book D 6991 / K 1930 — or, with a + // VAT line, D 1930 / K 1930 / K 2611 — silently dropping the chosen accounts. + // The transaction picker now routes EVERY library template through the + // journal-entry editor, whose lines come from applyTemplate. These tests pin + // the guarantee the editor path relies on: applyTemplate books exactly the + // accounts/sides the user defined, and never re-derives a counter account. + const AMOUNT = 5000 + + const customerPaymentLines: BookingTemplateLibraryLine[] = [ + { account: '1930', label: 'Inbetalning', side: 'debit', type: 'settlement', ratio: 1 }, + { account: '1510', label: 'Kundfordran', side: 'credit', type: 'business', ratio: 1 }, + ] + + it('books exactly D 1930 / K 1510 with no re-derived accounts', () => { + const result = applyTemplate(customerPaymentLines, AMOUNT) + expect(result).toEqual([ + { account_number: '1930', debit_amount: '5000.00', credit_amount: '', line_description: 'Inbetalning' }, + { account_number: '1510', debit_amount: '', credit_amount: '5000.00', line_description: 'Kundfordran' }, + ]) + // The accounts the lossy fast path used to inject must never appear. + const accounts = result.map((l) => l.account_number) + expect(accounts).not.toContain('6991') + expect(accounts).not.toContain('3001') + expect(accounts).not.toContain('2611') + }) + + it('is blind to business/settlement tagging — same accounts either way', () => { + // The old converter keyed "direction" (and thus the whole booking) off which + // leg was tagged business vs settlement. applyTemplate must not: swapping the + // tags leaves the same accounts on the same sides. + const swappedTags: BookingTemplateLibraryLine[] = [ + { account: '1930', label: 'Inbetalning', side: 'debit', type: 'business', ratio: 1 }, + { account: '1510', label: 'Kundfordran', side: 'credit', type: 'settlement', ratio: 1 }, + ] + expect(applyTemplate(swappedTags, AMOUNT)).toEqual(applyTemplate(customerPaymentLines, AMOUNT)) + }) + + it('stays balanced (sum debit === sum credit)', () => { + const result = applyTemplate(customerPaymentLines, 4999.99) + const sumDebit = result.reduce((s, l) => s + (parseFloat(l.debit_amount || '0') || 0), 0) + const sumCredit = result.reduce((s, l) => s + (parseFloat(l.credit_amount || '0') || 0), 0) + expect(sumDebit).toBeCloseTo(sumCredit, 2) + expect(sumDebit).toBeCloseTo(4999.99, 2) + }) +}) diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 8b7ebc9d..f0bb26df 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -298,16 +298,16 @@ const TRANSACTIONS: Record = { TRANSACTION_BOOK_POSSIBLE_DUPLICATE: { httpStatus: 409, message_sv: - 'En annan transaktion på samma datum och belopp är redan bokförd. Det här ser ut som en dubblett — bokför inte samma affärshändelse två gånger. Granska den befintliga verifikationen, eller bokför ändå om transaktionerna inte hör ihop.', + 'Den här affärshändelsen ser redan ut att vara bokförd — antingen en annan transaktion på samma datum och belopp, eller en verifikation som redan bokar samma belopp på bankkontot (t.ex. en betald faktura eller en lönekörning). Bokför inte samma affärshändelse två gånger. Granska den befintliga verifikationen och länka transaktionen till den, eller bokför ändå om de inte hör ihop.', message_en: - 'Another transaction with the same date and amount is already booked. This looks like a duplicate — do not book the same business event twice. Review the existing voucher, or pass force=true to book it anyway if they are genuinely unrelated.', + 'This business event already appears to be booked — either another transaction with the same date and amount, or a voucher that already books the same amount on the bank account (e.g. a paid invoice or a salary run). Do not book the same business event twice. Review the existing voucher and link this transaction to it, or pass force=true to book it anyway if they are genuinely unrelated.', }, TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH: { httpStatus: 409, message_sv: - 'Den möjliga dubbletten som visades matchar inte längre. Ladda om och försök igen så att rätt transaktion visas.', + 'Den möjliga dubbletten som visades matchar inte längre. Ladda om och försök igen så att rätt kandidat visas.', message_en: - 'The duplicate transaction echoed in expected_duplicate_transaction_id no longer matches the one detected at request time. Re-run the booking pre-flight to obtain the current candidate, then retry.', + 'The duplicate candidate echoed in expected_duplicate_transaction_id / expected_duplicate_journal_entry_id no longer matches the one detected at request time. Re-run the booking pre-flight to obtain the current candidate, then retry.', }, TX_CATEGORIZE_TX_NOT_FOUND: { httpStatus: 404, diff --git a/lib/pending-operations/__tests__/commit-duplicate-guard.test.ts b/lib/pending-operations/__tests__/commit-duplicate-guard.test.ts new file mode 100644 index 00000000..33efcc26 --- /dev/null +++ b/lib/pending-operations/__tests__/commit-duplicate-guard.test.ts @@ -0,0 +1,237 @@ +/** + * The agent/MCP commit path (lib/pending-operations/commit.ts) must run the same + * duplicate guards as the web routes — it previously bypassed them entirely, + * which let an approved staged op double-book an affärshändelse already in the + * ledger (the production case: a bank line booked on top of an invoice + * "markera som betald" voucher or a salary payout). + * + * These tests drive the public `commitPendingOperation` dispatcher (the executor + * functions are private) and assert the op is auto-rejected (409) when a + * duplicate is detected. The detection functions themselves are unit-tested in + * lib/transactions/__tests__/booking-duplicate-detection.test.ts and + * lib/invoices/__tests__/duplicate-payment-detection.test.ts. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' +import type { PendingOperation } from '@/types' + +const mockDetectBookingDuplicate = vi.fn() +vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({ + detectBookingDuplicate: (...args: unknown[]) => mockDetectBookingDuplicate(...args), +})) + +const mockFindDupPayments = vi.fn() +vi.mock('@/lib/invoices/duplicate-payment-candidates', () => ({ + findDuplicatePaymentCandidatesForInvoice: (...args: unknown[]) => mockFindDupPayments(...args), +})) + +const mockAppendProcessingHistory = vi.fn() +vi.mock('@/lib/processing-history/append', () => ({ + appendProcessingHistory: (...args: unknown[]) => mockAppendProcessingHistory(...args), +})) + +import { commitPendingOperation } from '../commit' + +/** Queue-based supabase mock: each `from()` resolves to the next queued result. */ +function queuedSupabase(results: Array<{ data?: unknown; error?: unknown }>) { + const queue = [...results] + const from = vi.fn(() => { + const raw = queue.shift() ?? { data: null, error: null } + const result = { data: raw.data ?? null, error: raw.error ?? null } + const chain: object = new Proxy( + {}, + { + get(_t, prop) { + if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(result) + return () => chain + }, + }, + ) + return chain + }) + return { from } as never +} + +function makePendingOp(overrides: Partial): PendingOperation { + return { + id: 'op-1', + user_id: 'user-1', + company_id: 'company-1', + operation_type: 'categorize_transaction', + status: 'pending', + title: 'test', + params: {}, + preview_data: {}, + result_data: null, + actor_type: 'user', + actor_id: null, + actor_label: null, + risk_level: 'medium', + created_at: '2026-05-03T00:00:00Z', + resolved_at: null, + updated_at: '2026-05-03T00:00:00Z', + ...overrides, + } as PendingOperation +} + +const voucherCandidate = { + transaction_id: null, + journal_entry_id: 'je-existing', + voucher_label: 'A2', + entry_date: '2026-03-30', + description: 'Inbetalning kundfaktura 2026001', + amount: 98565, +} + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() +}) + +describe('commit duplicate guard: categorize_transaction (reverse / book the bank line)', () => { + it('auto-rejects (409) when a ledger voucher already books this movement', async () => { + mockDetectBookingDuplicate.mockResolvedValue(voucherCandidate) + // claim → transaction fetch → reject update + const supabase = queuedSupabase([ + { data: { id: 'op-1' } }, + { data: { id: 'tx-1', date: '2026-03-26', amount: 98565, cash_account_id: null, journal_entry_id: null } }, + { data: null }, + ]) + + const op = makePendingOp({ + operation_type: 'categorize_transaction', + params: { transaction_id: 'tx-1', category: 'income' }, + }) + + const result = await commitPendingOperation(supabase, 'user-1', 'company-1', op) + + expect(mockDetectBookingDuplicate).toHaveBeenCalledTimes(1) + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(409) + }) + + it('does not enforce the guard when allow_duplicate=true, but records the dismissal to behandlingshistorik', async () => { + mockDetectBookingDuplicate.mockResolvedValue(voucherCandidate) + // The booking proceeds past the guard (not auto-rejected); the downstream + // booking is allowed to fail against the bare mock. Before that, the bypass + // must leave a durable BankTransactionDuplicateDismissed record so an + // auditor can reconstruct why the duplicate was allowed (BFNAR 2013:2 kap 8). + const supabase = queuedSupabase([ + { data: { id: 'op-1' } }, + { data: { id: 'tx-1', date: '2026-03-26', amount: 98565, cash_account_id: null, journal_entry_id: null } }, + { data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }, + { data: [] }, + ]) + + const op = makePendingOp({ + operation_type: 'categorize_transaction', + params: { transaction_id: 'tx-1', category: 'income', allow_duplicate: true }, + }) + + const result = await commitPendingOperation(supabase, 'user-1', 'company-1', op) + + // Guard not enforced: the op is not auto-rejected at the duplicate guard. + expect(result.status).not.toBe('rejected') + // Detection still runs once — to capture the dismissed candidate for audit. + expect(mockDetectBookingDuplicate).toHaveBeenCalledTimes(1) + expect(mockAppendProcessingHistory).toHaveBeenCalledTimes(1) + const event = mockAppendProcessingHistory.mock.calls[0][0] + expect(event).toMatchObject({ + companyId: 'company-1', + aggregateType: 'BankTransaction', + aggregateId: 'tx-1', + eventType: 'BankTransactionDuplicateDismissed', + actor: { type: 'user', id: 'user-1' }, + }) + expect(event.payload).toMatchObject({ + transaction_id: 'tx-1', + dismissed_journal_entry_id: 'je-existing', + via: 'allow_duplicate', + }) + }) + + it('records no dismissal when allow_duplicate=true but no duplicate is actually present', async () => { + mockDetectBookingDuplicate.mockResolvedValue(null) + const supabase = queuedSupabase([ + { data: { id: 'op-1' } }, + { data: { id: 'tx-1', date: '2026-03-26', amount: 98565, cash_account_id: null, journal_entry_id: null } }, + { data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }, + { data: [] }, + ]) + + const op = makePendingOp({ + operation_type: 'categorize_transaction', + params: { transaction_id: 'tx-1', category: 'income', allow_duplicate: true }, + }) + + await commitPendingOperation(supabase, 'user-1', 'company-1', op) + + expect(mockDetectBookingDuplicate).toHaveBeenCalledTimes(1) + expect(mockAppendProcessingHistory).not.toHaveBeenCalled() + }) +}) + +describe('commit duplicate guard: mark_invoice_paid (forward / book the payment)', () => { + it('auto-rejects (409) when an unlinked bank transaction already looks like the payment', async () => { + mockFindDupPayments.mockResolvedValue([ + { id: 'tx-9', date: '2026-03-26', amount: 98565, description: '2026001', merchant_name: null, reference: null, match_reason: 'ocr_exact', match_confidence: 0.99 }, + ]) + // claim → invoice fetch → reject update + const supabase = queuedSupabase([ + { data: { id: 'op-1' } }, + { data: { id: 'inv-1', invoice_number: '2026001', status: 'sent', total: 98565, remaining_amount: 98565, customer: { name: 'Arcim Technology AB' } } }, + { data: null }, + ]) + + const op = makePendingOp({ + operation_type: 'mark_invoice_paid', + params: { invoice_id: 'inv-1', payment_date: '2026-03-30' }, + }) + + const result = await commitPendingOperation(supabase, 'user-1', 'company-1', op) + + expect(mockFindDupPayments).toHaveBeenCalledTimes(1) + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(409) + }) + + it('does not enforce the guard when allow_duplicate=true, but records the dismissal to behandlingshistorik', async () => { + mockFindDupPayments.mockResolvedValue([ + { id: 'tx-9', date: '2026-03-26', amount: 98565, description: '2026001', merchant_name: null, reference: null, match_reason: 'ocr_exact', match_confidence: 0.99 }, + ]) + // claim → invoice fetch → company_settings → bare downstream (allowed to fail) + const supabase = queuedSupabase([ + { data: { id: 'op-1' } }, + { data: { id: 'inv-1', invoice_number: '2026001', status: 'sent', total: 98565, remaining_amount: 98565, customer: { name: 'Arcim Technology AB' } } }, + { data: { accounting_method: 'accrual', entity_type: 'aktiebolag' } }, + ]) + + const op = makePendingOp({ + operation_type: 'mark_invoice_paid', + params: { invoice_id: 'inv-1', payment_date: '2026-03-30', allow_duplicate: true }, + }) + + const result = await commitPendingOperation(supabase, 'user-1', 'company-1', op) + + // Guard not enforced: not auto-rejected at the duplicate-payment guard. + expect(result.status).not.toBe('rejected') + expect(mockFindDupPayments).toHaveBeenCalledTimes(1) + expect(mockAppendProcessingHistory).toHaveBeenCalledTimes(1) + const event = mockAppendProcessingHistory.mock.calls[0][0] + expect(event).toMatchObject({ + companyId: 'company-1', + aggregateType: 'System', + aggregateId: 'inv-1', + eventType: 'InvoiceDuplicatePaymentDismissed', + actor: { type: 'user', id: 'user-1' }, + }) + expect(event.payload).toMatchObject({ + invoice_id: 'inv-1', + dismissed_transaction_ids: ['tx-9'], + candidate_count: 1, + via: 'allow_duplicate', + }) + // PII-safe: no customer or merchant name in the payload. + expect(JSON.stringify(event.payload)).not.toContain('Arcim') + }) +}) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 892f9eef..ab27cb19 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -43,6 +43,8 @@ import { } from '@/lib/bookkeeping/supplier-invoice-entries' import { linkInvoiceToVoucher } from '@/lib/invoices/voucher-matching' import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment' +import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection' +import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates' import { linkSupplierInvoiceToVoucher } from '@/lib/invoices/supplier-voucher-matching' import { linkTransactionToJournalEntry } from '@/lib/transactions/link-journal-entry' import { getErrorEntry } from '@/lib/errors/structured-errors' @@ -69,6 +71,7 @@ import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' import { createLogger } from '@/lib/logger' import { appendProcessingHistory } from '@/lib/processing-history/append' +import { roundOre } from '@/lib/money' import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier' import { CreateArticleParamsSchema, UpdateArticleParamsSchema } from '@/lib/pending-operations/schemas/article' import { ensureArticleNumber } from '@/lib/articles/ensure-article-number' @@ -274,6 +277,78 @@ async function commitCategorizeTransaction( return { error: 'Transaction already has a journal entry — it was categorized in the meantime.', status: 409 } } + // Booking-time duplicate guard — parity with the web /categorize route, which + // the agent path otherwise bypassed entirely. Refuse to mint a second + // verifikat for an affärshändelse already in the ledger: an already-booked + // sibling transaction, OR an unlinked voucher that already books this amount + // on the bank account (invoice "markera som betald", the salary run's net-wage + // payout, a manual verifikat). The agent has no interactive "Bokför ändå", so + // it fails closed; re-stage with allow_duplicate=true after the user confirms + // in chat that the bank line is a genuinely separate event. Fail-open on a + // detection error so a transient query failure never blocks a real booking. + if (params.allow_duplicate !== true) { + let dup = null + try { + dup = await detectBookingDuplicate(supabase, companyId, { + id: txId, + date: transaction.date, + amount: transaction.amount, + cash_account_id: transaction.cash_account_id ?? null, + }) + } catch (err) { + log.warn('booking-time duplicate detection failed (continuing)', err) + } + if (dup) { + const amountAbs = roundOre(Math.abs(Number(transaction.amount))) + const voucher = dup.voucher_label ? `verifikat ${dup.voucher_label}` : 'en befintlig verifikation' + return { + error: + `Möjlig dubblettbokföring: ${voucher} (${dup.entry_date}) bokför redan ${amountAbs} kr på bankkontot. ` + + `Den här affärshändelsen ser redan ut att vara bokförd — länka transaktionen till den befintliga ` + + `verifikationen i stället för att bokföra den igen. Om banktransaktionen verkligen är en separat ` + + `affärshändelse, kör om med allow_duplicate=true.`, + status: 409, + } + } + } else { + // allow_duplicate=true bypassed the guard. Booking over a possible + // double-booking is a bookkeeping act that must leave a durable + // behandlingshistorik record (BFNAR 2013:2 kap 8) — the web /book and + // /categorize routes log BankTransactionDuplicateDismissed, and the agent + // commit path must reach parity so an auditor can reconstruct why the + // duplicate was allowed. Re-detect to capture the dismissed candidate; + // best-effort, a logging failure must never block a legitimate booking. + try { + const dismissed = await detectBookingDuplicate(supabase, companyId, { + id: txId, + date: transaction.date, + amount: transaction.amount, + cash_account_id: transaction.cash_account_id ?? null, + }) + if (dismissed) { + await appendProcessingHistory({ + 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, + amount_ore: Math.round(dismissed.amount * 100), + entry_date: dismissed.entry_date, + via: 'allow_duplicate', + }, + actor: { type: 'user', id: userId }, + occurredAt: new Date(), + }) + } + } catch (logErr) { + log.warn('failed to record duplicate-dismissal behandlingshistorik', logErr) + } + } + const isBusiness = category !== 'private' const { data: settings } = await supabase @@ -851,6 +926,82 @@ async function commitMarkInvoicePaid( return { error: 'Invoice can only be marked as paid when status is "sent" or "overdue"', status: 409 } } + // Duplicate-payment guard — parity with the web mark-paid route, which the + // agent path otherwise bypassed. If an unlinked inbound bank transaction + // already looks like this invoice's payment, booking a parallel payment + // voucher here creates exactly the orphan that later double-counts the + // receipt. Fail closed; the agent re-stages with allow_duplicate=true (after + // the user confirms) or, better, matches the transaction to the invoice + // instead. Fail-open on a detection error so it never blocks a real payment. + if (params.allow_duplicate !== true) { + const customerName = (invoice as { customer?: { name?: string } }).customer?.name + if (customerName) { + const remainingAmount = + (invoice as { remaining_amount?: number }).remaining_amount ?? invoice.total + let candidates: Awaited> = [] + try { + candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { + companyId, + invoice: { invoice_number: invoice.invoice_number, customer_name: customerName }, + paymentAmount: remainingAmount, + paymentDate, + }) + } catch (err) { + log.warn('duplicate-payment detection failed (continuing)', err) + } + if (candidates.length > 0) { + return { + error: + `Möjlig dubbelbetalning: en obokförd banktransaktion ser ut att vara betalningen för faktura ` + + `${invoice.invoice_number}. Matcha banktransaktionen mot fakturan (gnubok_match_transaction_to_invoice) ` + + `i stället för att bokföra en separat betalning. Om det verkligen rör sig om en annan betalning, ` + + `kör om med allow_duplicate=true.`, + status: 409, + } + } + } + } else { + // allow_duplicate=true bypassed the duplicate-payment guard. The decision + // to book a payment over a possible existing one must leave a durable + // behandlingshistorik record (BFNAR 2013:2 kap 8) so an auditor can see why + // the duplicate was allowed. Re-detect to capture the dismissed candidate; + // best-effort, never blocks the payment. Payload stays PII-safe + // (ids/amounts/dates only — no customer or merchant name). + const customerName = (invoice as { customer?: { name?: string } }).customer?.name + if (customerName) { + try { + const remainingAmount = + (invoice as { remaining_amount?: number }).remaining_amount ?? invoice.total + const dismissed = await findDuplicatePaymentCandidatesForInvoice(supabase, { + companyId, + invoice: { invoice_number: invoice.invoice_number, customer_name: customerName }, + paymentAmount: remainingAmount, + paymentDate, + }) + if (dismissed.length > 0) { + await appendProcessingHistory({ + companyId, + correlationId: invoiceId, + aggregateType: 'System', + aggregateId: invoiceId, + eventType: 'InvoiceDuplicatePaymentDismissed', + payload: { + invoice_id: invoiceId, + payment_date: paymentDate, + dismissed_transaction_ids: dismissed.map((c) => c.id), + candidate_count: dismissed.length, + via: 'allow_duplicate', + }, + actor: { type: 'user', id: userId }, + occurredAt: new Date(), + }) + } + } catch (logErr) { + log.warn('failed to record duplicate-payment-dismissal behandlingshistorik', logErr) + } + } + } + const { data: settings } = await supabase .from('company_settings').select('accounting_method, entity_type').eq('company_id', companyId).single() diff --git a/lib/transactions/__tests__/booking-duplicate-detection.test.ts b/lib/transactions/__tests__/booking-duplicate-detection.test.ts index 257707ab..371ced0b 100644 --- a/lib/transactions/__tests__/booking-duplicate-detection.test.ts +++ b/lib/transactions/__tests__/booking-duplicate-detection.test.ts @@ -6,7 +6,11 @@ * label from `journal_entries`. The mock returns the rows each query yields. */ import { describe, it, expect } from 'vitest' -import { detectBookedDuplicateTransaction } from '../booking-duplicate-detection' +import { + detectBookedDuplicateTransaction, + detectLedgerDuplicateVoucher, + detectBookingDuplicate, +} from '../booking-duplicate-detection' type TxRow = { id: string @@ -126,3 +130,206 @@ describe('detectBookedDuplicateTransaction', () => { expect(result?.transaction_id).toBe('sib-2') }) }) + +// ── Ledger-only voucher guard (the orphan with no sibling transaction) ─────── + +type Jel = { + account_number: string + debit_amount: number | string + credit_amount: number | string + journal_entry: { + id: string + entry_date: string + description: string | null + voucher_series: string | null + voucher_number: number | null + status: string + source_type: string | null + } +} + +/** A chain whose terminals all resolve to the SAME canned result for a table. */ +function ledgerChain(result: { data: unknown; error: unknown }) { + const c: Record = {} + c.select = () => c + c.eq = () => c + c.neq = () => c + c.not = () => c + c.gt = () => c + c.gte = () => c + c.lte = () => c + c.limit = () => Promise.resolve(result) + c.maybeSingle = () => Promise.resolve(result) + c.single = () => Promise.resolve(result) + c.in = () => Promise.resolve(result) // terminal for the link-exclusion lookups + return c +} + +function makeLedgerSupabase(opts: { + ledgerAccount?: string | null + lines?: Jel[] + txLinks?: { journal_entry_id: string }[] + payLinks?: { journal_entry_id: string }[] + transactionRows?: TxRow[] // siblings for the orchestrator fall-through +}) { + return { + from: (table: string) => { + switch (table) { + case 'cash_accounts': + return ledgerChain({ + data: opts.ledgerAccount != null ? { ledger_account: opts.ledgerAccount } : null, + error: null, + }) + case 'journal_entry_lines': + return ledgerChain({ data: opts.lines ?? [], error: null }) + case 'invoice_payments': + return ledgerChain({ data: opts.payLinks ?? [], error: null }) + case 'transactions': + // Same table backs the sibling scan (.limit) and the link-exclusion + // lookup (.in). The sibling scan returns transactionRows; the link + // lookup returns txLinks. With a shape-only mock both share one canned + // result, so tests that need a sibling set transactionRows and leave + // txLinks empty (and vice versa). + return ledgerChain({ data: opts.transactionRows ?? opts.txLinks ?? [], error: null }) + default: + return ledgerChain({ data: null, error: null }) + } + }, + } as never +} + +const jel = (over: Partial = {}): Jel => ({ + account_number: over.account_number ?? '1930', + debit_amount: over.debit_amount ?? 98565, + credit_amount: over.credit_amount ?? 0, + journal_entry: { + id: 'je-2', + entry_date: '2026-03-30', + description: 'Inbetalning kundfaktura 2026001', + voucher_series: 'A', + voucher_number: 2, + status: 'posted', + source_type: 'invoice_paid', + ...over.journal_entry, + }, +}) + +describe('detectLedgerDuplicateVoucher', () => { + it('flags an inbound receipt already booked as a 19xx debit voucher (no sibling tx)', async () => { + const supabase = makeLedgerSupabase({ lines: [jel()] }) + const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, { + id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null, + }) + expect(result).toEqual({ + transaction_id: null, + journal_entry_id: 'je-2', + voucher_label: 'A2', + entry_date: '2026-03-30', + description: 'Inbetalning kundfaktura 2026001', + amount: 98565, + }) + }) + + it('flags an outbound payout already booked as a 19xx credit voucher (salary case)', async () => { + const salaryLine = jel({ + debit_amount: 0, + credit_amount: 16609, + journal_entry: { + id: 'je-3', entry_date: '2026-05-04', description: 'Lön 2026-05 — Nettolön', + voucher_series: 'A', voucher_number: 3, status: 'posted', source_type: 'salary', + }, + }) + const supabase = makeLedgerSupabase({ lines: [salaryLine] }) + const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, { + id: 'self', date: '2026-05-04', amount: -16609, cash_account_id: null, + }) + expect(result?.journal_entry_id).toBe('je-3') + expect(result?.transaction_id).toBeNull() + expect(result?.amount).toBe(16609) + }) + + it('does NOT flag an inbound receipt against a credit-only voucher (wrong direction)', async () => { + // A 19xx CREDIT is a payout, not the receipt the inbound line is looking for. + const supabase = makeLedgerSupabase({ lines: [jel({ debit_amount: 0, credit_amount: 98565 })] }) + const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, { + id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null, + }) + expect(result).toBeNull() + }) + + it('does NOT flag when the amount differs', async () => { + const supabase = makeLedgerSupabase({ lines: [jel({ debit_amount: 90000 })] }) + const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, { + id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null, + }) + expect(result).toBeNull() + }) + + it('excludes a voucher already linked to a transaction', async () => { + const supabase = makeLedgerSupabase({ lines: [jel()], txLinks: [{ journal_entry_id: 'je-2' }] }) + const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, { + id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null, + }) + expect(result).toBeNull() + }) + + it('excludes a voucher already linked to an invoice payment', async () => { + const supabase = makeLedgerSupabase({ lines: [jel()], payLinks: [{ journal_entry_id: 'je-2' }] }) + const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, { + id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null, + }) + 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] }) + const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, { + id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null, + }) + expect(result).toBeNull() + }) + + it('matches a numeric-string leg amount from PostgREST (öre)', async () => { + const supabase = makeLedgerSupabase({ lines: [jel({ debit_amount: '98565.00' })] }) + const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, { + id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null, + }) + expect(result?.journal_entry_id).toBe('je-2') + }) + + it('returns null for a zero-amount target without querying', async () => { + const supabase = makeLedgerSupabase({ lines: [jel()] }) + const result = await detectLedgerDuplicateVoucher(supabase, COMPANY, { + id: 'self', date: '2026-03-26', amount: 0, cash_account_id: null, + }) + expect(result).toBeNull() + }) +}) + +describe('detectBookingDuplicate (orchestrator)', () => { + it('returns the sibling transaction when one exists (voucher scan not needed)', async () => { + const supabase = makeLedgerSupabase({ transactionRows: [sibling()] }) + const result = await detectBookingDuplicate(supabase, COMPANY, { + id: 'self', date: '2025-12-19', amount: -1616, cash_account_id: null, + }) + expect(result?.transaction_id).toBe('sib-1') + }) + + it('falls through to the ledger voucher when there is no sibling transaction', async () => { + const supabase = makeLedgerSupabase({ transactionRows: [], lines: [jel()] }) + const result = await detectBookingDuplicate(supabase, COMPANY, { + id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null, + }) + expect(result?.transaction_id).toBeNull() + expect(result?.journal_entry_id).toBe('je-2') + }) + + it('returns null when neither a sibling nor a voucher matches', async () => { + const supabase = makeLedgerSupabase({ transactionRows: [], lines: [] }) + const result = await detectBookingDuplicate(supabase, COMPANY, { + id: 'self', date: '2026-03-26', amount: 98565, cash_account_id: null, + }) + expect(result).toBeNull() + }) +}) diff --git a/lib/transactions/booking-duplicate-detection.ts b/lib/transactions/booking-duplicate-detection.ts index 5df1dca2..c0e22362 100644 --- a/lib/transactions/booking-duplicate-detection.ts +++ b/lib/transactions/booking-duplicate-detection.ts @@ -24,16 +24,21 @@ * sibling TRANSACTION rather than a manually-posted journal entry. */ import type { SupabaseClient } from '@supabase/supabase-js' +import { roundOre } from '@/lib/money' /** Integer öre — representation-agnostic amount key (mirrors the ingest dedup). */ function toOre(amount: number | string): number { return Math.round(Number(amount) * 100) } -/** An already-booked transaction that looks like the same real movement. */ +/** An already-booked transaction OR voucher that looks like the same real movement. */ export interface BookedDuplicateCandidate { - /** The sibling transaction that is already booked. */ - transaction_id: string + /** + * 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). + */ + transaction_id: string | null /** Its verifikat. */ journal_entry_id: string /** Human label, e.g. "A142" (voucher_series + voucher_number). */ @@ -129,6 +134,183 @@ export async function detectBookedDuplicateTransaction( voucher_label: voucherLabel, entry_date: entryDate, description: best.description, - amount: Math.round(Number(best.amount) * 100) / 100, + amount: roundOre(Number(best.amount)), } } + +/** ± days around the bank-tx date a voucher may be dated and still be "the same" movement. */ +const VOUCHER_DUPLICATE_DATE_WINDOW_DAYS = 7 + +/** BAS "kassa och bank" range. 1910-1919 = kassa, 1920-1949 = bank/giro. */ +const BANK_ACCOUNT_LOW = 1910 +const BANK_ACCOUNT_HIGH = 1949 + +/** + * Find an unlinked posted voucher whose bank/cash (19xx) leg already books this + * exact bank movement — the ledger-only twin of the bank line. + * + * This is the second half of the booking-time duplicate guard. The first half + * (detectBookedDuplicateTransaction) only finds an already-booked SIBLING + * TRANSACTION. But the most damaging orphan has NO sibling transaction at all: + * the affärshändelse was booked through a flow that posts straight to the ledger + * and never creates or links a bank-transaction row — invoice "markera som + * betald" (Dr 19xx / Cr 1510), the salary run's net-wage payout (Cr 19xx), a + * hand-posted verifikat. Booking the bank line on top of that double-counts the + * movement on the cash account: two verifikationer for one affärshändelse, + * felaktig bokföring per BFL. Because the import dedup and the sibling guard + * both only see the `transactions` table, neither catches this — only matching + * the bank line against the ledger does. + * + * Direction-aware so it works both ways: + * - inbound (target.amount > 0, money in) → a 19xx DEBIT of the same amount + * - outbound (target.amount < 0, money out) → a 19xx CREDIT of the same amount + * + * Account-aware: when the bank line knows its cash account, the matching leg + * 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. + */ +export async function detectLedgerDuplicateVoucher( + supabase: SupabaseClient, + companyId: string, + target: BookingTarget, +): Promise { + const targetOre = toOre(target.amount) + if (targetOre === 0 || Number.isNaN(targetOre)) return null + const targetAmount = roundOre(Math.abs(Number(target.amount))) + const inbound = targetOre > 0 + + const dateMs = new Date(target.date).getTime() + if (Number.isNaN(dateMs)) return null + const windowMs = VOUCHER_DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000 + const lowDate = new Date(dateMs - windowMs).toISOString().split('T')[0] + const highDate = new Date(dateMs + windowMs).toISOString().split('T')[0] + + // Resolve the bank line's settlement ledger account, when known, so a movement + // on one bank account never deduplicates a voucher on a different account of + // the same company (the 19xx leg below is matched against it). + let settlementAccount: string | null = null + if (target.cash_account_id) { + const { data: ca } = await supabase + .from('cash_accounts') + .select('ledger_account') + .eq('company_id', companyId) + .eq('id', target.cash_account_id) + .maybeSingle() + settlementAccount = ((ca as { ledger_account?: string } | null)?.ledger_account) ?? null + } + + const amountColumn = inbound ? 'debit_amount' : 'credit_amount' + let query = supabase + .from('journal_entry_lines') + .select( + `account_number, + debit_amount, + credit_amount, + journal_entry:journal_entries!inner( + id, + entry_date, + description, + voucher_series, + voucher_number, + status, + source_type, + company_id + )`, + ) + .eq('journal_entry.company_id', companyId) + .eq('journal_entry.status', 'posted') + .gte('journal_entry.entry_date', lowDate) + .lte('journal_entry.entry_date', highDate) + .gt(amountColumn, 0) + + query = settlementAccount + ? query.eq('account_number', settlementAccount) + : query.gte('account_number', String(BANK_ACCOUNT_LOW)).lte('account_number', String(BANK_ACCOUNT_HIGH)) + + const { data: lines, error } = await query.limit(50) + if (error || !lines || lines.length === 0) return null + + type LineRow = { + account_number: string + debit_amount: number | string + credit_amount: number | string + journal_entry: { + id: string + entry_date: string + description: string | null + voucher_series: string | null + voucher_number: number | null + status: string + source_type: string | null + } + } + const candidates = (lines as unknown as LineRow[]) + .filter((l) => { + const legAmount = roundOre(Number(inbound ? l.debit_amount : l.credit_amount)) + return Math.abs(legAmount - targetAmount) < 0.01 + }) + // Reversals/corrections are valid second vouchers, not duplicate bookings. + .filter((l) => l.journal_entry.source_type !== 'storno' && l.journal_entry.source_type !== 'correction') + + if (candidates.length === 0) return null + + // 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). + 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('invoice_payments').select('journal_entry_id').eq('company_id', companyId).in('journal_entry_id', entryIds), + ]) + const linked = new Set() + for (const r of (txLinks ?? []) as { journal_entry_id: string | null }[]) { + if (r.journal_entry_id) linked.add(r.journal_entry_id) + } + for (const r of (payLinks ?? []) as { journal_entry_id: string | null }[]) { + if (r.journal_entry_id) linked.add(r.journal_entry_id) + } + + const unlinked = candidates.filter((l) => !linked.has(l.journal_entry.id)) + if (unlinked.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) + if (ad !== bd) return ad - bd + return a.journal_entry.id.localeCompare(b.journal_entry.id) + }) + const best = unlinked[0] + + return { + transaction_id: null, + 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, + description: best.journal_entry.description, + amount: roundOre(Number(inbound ? best.debit_amount : best.credit_amount)), + } +} + +/** + * Unified booking-time duplicate guard. Returns the single best already-booked + * candidate for this bank line — a sibling transaction first (the cheaper, + * higher-confidence signal), then a ledger-only voucher. Null when neither + * fires. This is the function every booking chokepoint should call (web /book + + * /categorize routes and the agent commit executors) so all paths reject the + * same double-bookings. + */ +export async function detectBookingDuplicate( + supabase: SupabaseClient, + companyId: string, + target: BookingTarget, +): Promise { + const sibling = await detectBookedDuplicateTransaction(supabase, companyId, target) + if (sibling) return sibling + return detectLedgerDuplicateVoucher(supabase, companyId, target) +} diff --git a/messages/en.json b/messages/en.json index 5b99e719..05f8f927 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1223,6 +1223,8 @@ "account_number_error": "Must be 6-12 digits", "bankgiro_label": "Bankgiro", "bankgiro_error": "Invalid bankgiro number", + "plusgiro_label": "Plusgiro", + "plusgiro_error": "Invalid plusgiro number", "swish_label": "Swish", "swish_placeholder": "123 XXX XX XX or 07X XXX XX XX", "swish_error": "Invalid Swish number (business number 123XXXXXXX or mobile number 07XXXXXXXX)" @@ -3576,6 +3578,13 @@ "dialog_match_supplier_invoice": "Match with supplier invoice?", "dialog_match_customer_invoice": "Match with customer invoice?", "badge_exact_ocr": "Exact OCR match", + "dialog_duplicate_title": "Possible duplicate", + "dialog_duplicate_body": "This business event already appears to be booked — as another transaction or an existing voucher (e.g. a paid invoice or a salary run) with the same date and amount. Review it and link this transaction there rather than booking the same event twice — or book it anyway if they're unrelated.", + "dialog_duplicate_voucher_label": "Voucher {label}", + "dialog_duplicate_voucher_generic": "Existing voucher", + "dialog_duplicate_view_voucher": "View the voucher", + "dialog_duplicate_book_anyway": "Book anyway", + "dialog_duplicate_cancel": "Cancel", "load_failed_title": "Could not load transactions", "load_failed_description": "Check your connection and try again.", "undone_title": "Undone", diff --git a/messages/sv.json b/messages/sv.json index 4eead51d..6ba61ec0 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -1223,6 +1223,8 @@ "account_number_error": "Måste vara 6-12 siffror", "bankgiro_label": "Bankgiro", "bankgiro_error": "Ogiltigt bankgironummer", + "plusgiro_label": "Plusgiro", + "plusgiro_error": "Ogiltigt plusgironummer", "swish_label": "Swish", "swish_placeholder": "123 XXX XX XX eller 07X XXX XX XX", "swish_error": "Ogiltigt Swish-nummer (företagsnummer 123XXXXXXX eller mobilnummer 07XXXXXXXX)" @@ -3576,6 +3578,13 @@ "dialog_match_supplier_invoice": "Matcha mot leverantörsfaktura?", "dialog_match_customer_invoice": "Matcha mot kundfaktura?", "badge_exact_ocr": "Exakt OCR-träff", + "dialog_duplicate_title": "Möjlig dubblett", + "dialog_duplicate_body": "Den här affärshändelsen ser redan ut att vara bokförd — som en annan transaktion eller en befintlig verifikation (t.ex. en betald faktura eller en lönekörning) med samma datum och belopp. Granska den och länka hellre transaktionen dit än att bokföra samma affärshändelse två gånger — eller bokför ändå om de inte hör ihop.", + "dialog_duplicate_voucher_label": "Verifikat {label}", + "dialog_duplicate_voucher_generic": "Befintlig verifikation", + "dialog_duplicate_view_voucher": "Visa verifikatet", + "dialog_duplicate_book_anyway": "Bokför ändå", + "dialog_duplicate_cancel": "Avbryt", "load_failed_title": "Kunde inte ladda transaktioner", "load_failed_description": "Kontrollera din anslutning och försök igen.", "undone_title": "Ångrad",