Files
accounted/lib/invoices/duplicate-payment-detection.ts
T
Mattsson d27c3dd3dc Bug/customer cron job (#516)
* fix(reminder-processor): filter out credited invoices in overdue reminders

* feat: implement linking of transactions to journal entries

- Added POST endpoint for linking a bank transaction to an existing journal entry without creating new bookkeeping.
- Implemented validation for required fields and error handling for various scenarios (e.g., missing journal_entry_id, transaction already linked, journal entry not found).
- Created tests for the new endpoint to cover various cases including successful linking, error responses, and invoice handling.
- Introduced duplicate payment detection logic to prevent double-booking of bank receipts.
- Added a new component for correction affordance in the UI to facilitate user corrections on journal entries.

* feat(invoice-matching): enhance force matching with expected journal entry validation
2026-05-18 13:17:15 +02:00

189 lines
7.1 KiB
TypeScript

/**
* Detect a "soft duplicate" payment voucher for a bank transaction.
*
* Scenario: the user manually booked the receipt as a verifikation
* (Dr 19xx / Cr 1510 or Cr 30xx) *outside* the match-invoice flow. The
* invoice's status stays 'sent', no `invoice_payments` row exists, and the
* matcher would happily propose a second payment voucher — double-booking
* the bank receipt.
*
* Heuristic: a posted journal entry within a tight date window whose lines
* debit a bank/cash account (BAS 19xx) for the same amount, and which is
* not already linked to any transaction or invoice payment, is almost
* certainly the manual booking. We surface it as a candidate; the API
* refuses the match unless the caller passes `force: true`.
*
* Mirrors `findDuplicatePaymentCandidatesForInvoice` (which scans for the
* reverse direction — unlinked transactions that look like a manually-marked
* invoice payment).
*/
import type { SupabaseClient } from '@supabase/supabase-js'
/** ± days around the transaction date considered "the same payment". */
const 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
export interface DuplicateVoucherCandidate {
journal_entry_id: string
voucher_label: string
entry_date: string
description: string | null
amount: number
bank_account_number: string
reason: 'exact_amount_same_date' | 'exact_amount_within_window'
}
interface DetectArgs {
companyId: string
transactionId: string
transactionDate: string
transactionAmount: number
}
/**
* Find the single most likely manual verifikation that already books this
* bank transaction. Returns null when no candidate is found.
*
* Filters applied:
* - posted status (drafts cannot be a duplicate by definition)
* - entry date within ±DATE_WINDOW_DAYS of transaction.date
* - has a line that debits a BAS 19xx (kassa/bank) account for the same
* rounded amount (within 0.01 SEK)
* - not already linked from `transactions.journal_entry_id` (for any row)
* - not already referenced by `invoice_payments.journal_entry_id`
* - not the storno/correction entry for any prior original (source_type
* excluded — those are valid second-line vouchers, not duplicates)
*/
export async function detectDuplicatePaymentVoucher(
supabase: SupabaseClient,
args: DetectArgs,
): Promise<DuplicateVoucherCandidate | null> {
const { companyId, transactionId, transactionDate, transactionAmount } = args
const targetAmount = Math.round(Math.abs(transactionAmount) * 100) / 100
if (targetAmount === 0) return null
const dateMs = new Date(transactionDate).getTime()
if (Number.isNaN(dateMs)) return null
const lowDate = new Date(dateMs - DATE_WINDOW_DAYS * 24 * 3600 * 1000)
.toISOString()
.split('T')[0]
const highDate = new Date(dateMs + DATE_WINDOW_DAYS * 24 * 3600 * 1000)
.toISOString()
.split('T')[0]
// Query journal_entry_lines for bank-account debits within the window.
// The join filters by company_id at the parent — RLS handles isolation,
// but we filter explicitly as defense-in-depth.
const { data: lines, error } = await supabase
.from('journal_entry_lines')
.select(
`account_number,
debit_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)
.gte('account_number', String(BANK_ACCOUNT_LOW))
.lte('account_number', String(BANK_ACCOUNT_HIGH))
.gt('debit_amount', 0)
.limit(50)
if (error || !lines || lines.length === 0) return null
// Narrow to lines whose debit matches the transaction amount within 0.01 SEK.
type LineRow = {
account_number: string
debit_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 debit = Math.round(Number(l.debit_amount) * 100) / 100
return Math.abs(debit - targetAmount) < 0.01
})
// System-generated payment vouchers (invoice_paid etc.) ARE valid
// duplicates to surface — those are exactly the case where the user
// already booked through a different flow. Only exclude reversals
// and corrections, which are bookkeeping noise rather than payment
// candidates the user would want to link to.
.filter((l) => l.journal_entry.source_type !== 'storno' && l.journal_entry.source_type !== 'correction')
if (candidates.length === 0) return null
// Exclude entries already linked from invoice_payments or any transaction.
const entryIds = candidates.map((l) => l.journal_entry.id)
const [{ data: paymentLinks }, { data: txLinks }] = await Promise.all([
supabase
.from('invoice_payments')
.select('journal_entry_id')
.eq('company_id', companyId)
.in('journal_entry_id', entryIds),
supabase
.from('transactions')
.select('id, journal_entry_id')
.eq('company_id', companyId)
.in('journal_entry_id', entryIds),
])
const linkedIds = new Set<string>()
for (const row of (paymentLinks ?? []) as { journal_entry_id: string | null }[]) {
if (row.journal_entry_id) linkedIds.add(row.journal_entry_id)
}
for (const row of (txLinks ?? []) as { id: string; journal_entry_id: string | null }[]) {
// A transaction can link to its own JE via the current match flow — but
// we're called *before* that link is created, so the caller's own
// transactionId shouldn't appear. Guard anyway in case of a retry.
if (row.journal_entry_id && row.id !== transactionId) {
linkedIds.add(row.journal_entry_id)
}
}
const unlinked = candidates.filter((l) => !linkedIds.has(l.journal_entry.id))
if (unlinked.length === 0) return null
// Pick the best candidate: same-date beats within-window; otherwise pick
// the closest by date difference.
const targetDateMs = new Date(transactionDate).getTime()
unlinked.sort((a, b) => {
const aDiff = Math.abs(new Date(a.journal_entry.entry_date).getTime() - targetDateMs)
const bDiff = Math.abs(new Date(b.journal_entry.entry_date).getTime() - targetDateMs)
return aDiff - bDiff
})
const best = unlinked[0]
const sameDate = best.journal_entry.entry_date === transactionDate
return {
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: Math.round(Number(best.debit_amount) * 100) / 100,
bank_account_number: best.account_number,
reason: sameDate ? 'exact_amount_same_date' : 'exact_amount_within_window',
}
}