* 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
189 lines
7.1 KiB
TypeScript
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',
|
|
}
|
|
}
|