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
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { detectDuplicatePaymentVoucher } from '../duplicate-payment-detection'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
describe('detectDuplicatePaymentVoucher', () => {
|
||||
beforeEach(() => {
|
||||
reset()
|
||||
})
|
||||
|
||||
function makeLineRow(opts: {
|
||||
je_id: string
|
||||
account: string
|
||||
debit: number
|
||||
date: string
|
||||
voucher_label?: string
|
||||
source_type?: string | null
|
||||
description?: string | null
|
||||
}) {
|
||||
const [series, ...numParts] = (opts.voucher_label ?? 'A1').split('')
|
||||
const num = parseInt(numParts.join(''), 10) || 1
|
||||
return {
|
||||
account_number: opts.account,
|
||||
debit_amount: opts.debit,
|
||||
journal_entry: {
|
||||
id: opts.je_id,
|
||||
entry_date: opts.date,
|
||||
description: opts.description ?? `Voucher ${opts.je_id}`,
|
||||
voucher_series: series,
|
||||
voucher_number: num,
|
||||
status: 'posted',
|
||||
source_type: opts.source_type ?? 'manual',
|
||||
company_id: 'company-1',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
it('returns null when transaction amount is 0', async () => {
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 0,
|
||||
})
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when transaction date is invalid', async () => {
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: 'not-a-date',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when no lines are found', async () => {
|
||||
enqueue({ data: [], error: null })
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the candidate when an unlinked manual JE matches exactly on the same date', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-1',
|
||||
account: '1930',
|
||||
debit: 1000,
|
||||
date: '2026-05-15',
|
||||
voucher_label: 'A12',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// invoice_payments link check (no links)
|
||||
enqueue({ data: [], error: null })
|
||||
// transactions link check (no links)
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.journal_entry_id).toBe('je-1')
|
||||
expect(result!.bank_account_number).toBe('1930')
|
||||
expect(result!.reason).toBe('exact_amount_same_date')
|
||||
expect(result!.amount).toBe(1000)
|
||||
})
|
||||
|
||||
it('returns within_window reason when JE date is close but not equal', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-2',
|
||||
account: '1930',
|
||||
debit: 500,
|
||||
date: '2026-05-12',
|
||||
voucher_label: 'A5',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 500,
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.reason).toBe('exact_amount_within_window')
|
||||
})
|
||||
|
||||
it('excludes JEs that are already linked via invoice_payments', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-3',
|
||||
account: '1930',
|
||||
debit: 1000,
|
||||
date: '2026-05-15',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
// invoice_payments has a row linking this JE
|
||||
enqueue({ data: [{ journal_entry_id: 'je-3' }], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('excludes JEs already linked from another transaction', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-4',
|
||||
account: '1930',
|
||||
debit: 1000,
|
||||
date: '2026-05-15',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
// another transaction already links this JE
|
||||
enqueue({ data: [{ id: 'tx-other', journal_entry_id: 'je-4' }], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('excludes storno entries', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-storno',
|
||||
account: '1930',
|
||||
debit: 1000,
|
||||
date: '2026-05-15',
|
||||
source_type: 'storno',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('excludes correction entries', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-corr',
|
||||
account: '1930',
|
||||
debit: 1000,
|
||||
date: '2026-05-15',
|
||||
source_type: 'correction',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('picks the same-date candidate over a within-window candidate', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-far',
|
||||
account: '1930',
|
||||
debit: 1000,
|
||||
date: '2026-05-12',
|
||||
voucher_label: 'A1',
|
||||
}),
|
||||
makeLineRow({
|
||||
je_id: 'je-same',
|
||||
account: '1930',
|
||||
debit: 1000,
|
||||
date: '2026-05-15',
|
||||
voucher_label: 'A2',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.journal_entry_id).toBe('je-same')
|
||||
expect(result!.reason).toBe('exact_amount_same_date')
|
||||
})
|
||||
|
||||
it('matches absolute value for negative transaction amounts (expense)', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-x',
|
||||
account: '1930',
|
||||
debit: 250,
|
||||
date: '2026-05-15',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: -250,
|
||||
})
|
||||
|
||||
// Note: while the match-invoice route only handles income, the
|
||||
// detector itself is amount-direction agnostic — it just finds JEs
|
||||
// that book the same magnitude on the bank side. Callers gate by
|
||||
// direction.
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.amount).toBe(250)
|
||||
})
|
||||
|
||||
it('skips lines whose amount differs by more than 0.01', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-off',
|
||||
account: '1930',
|
||||
debit: 1001,
|
||||
date: '2026-05-15',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-1',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores the caller transaction even if it carries a journal_entry_id link', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
makeLineRow({
|
||||
je_id: 'je-caller',
|
||||
account: '1930',
|
||||
debit: 1000,
|
||||
date: '2026-05-15',
|
||||
}),
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: [], error: null })
|
||||
// The caller transaction itself links the JE (defensive — shouldn't happen
|
||||
// in normal flow because we call this before the link, but a retry could).
|
||||
enqueue({ data: [{ id: 'tx-caller', journal_entry_id: 'je-caller' }], error: null })
|
||||
|
||||
const result = await detectDuplicatePaymentVoucher(supabase as never, {
|
||||
companyId: 'company-1',
|
||||
transactionId: 'tx-caller',
|
||||
transactionDate: '2026-05-15',
|
||||
transactionAmount: 1000,
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.journal_entry_id).toBe('je-caller')
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
getBestInvoiceMatch,
|
||||
} from '../invoice-matching'
|
||||
import type { Transaction, Invoice, Customer } from '@/types'
|
||||
import { makeTransaction, makeInvoice, makeCustomer, createMockSupabase } from '@/tests/helpers'
|
||||
import {
|
||||
makeTransaction,
|
||||
makeInvoice,
|
||||
makeCustomer,
|
||||
createMockSupabase,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
// ============================================================
|
||||
// amountsMatchExact
|
||||
@@ -353,3 +359,82 @@ describe('getBestInvoiceMatch', () => {
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// findMatchingInvoices — paid-voucher status-leak guard
|
||||
// ============================================================
|
||||
//
|
||||
// Defensive filter added because manual verifikationer (booked outside the
|
||||
// match-invoice flow) leave the invoice in 'sent' status even though a
|
||||
// payment voucher exists via invoice_payments. Matching such an invoice
|
||||
// would double-book the bank receipt. Tests verify that:
|
||||
// - sent/overdue invoices with an invoice_payments.journal_entry_id are
|
||||
// excluded from the candidate list
|
||||
// - partially_paid invoices remain candidates regardless (they may take
|
||||
// more payments legitimately)
|
||||
// - invoices without payment rows still pass through unchanged
|
||||
|
||||
describe('findMatchingInvoices — status-leak guard', () => {
|
||||
it('excludes a sent invoice that already has a payment voucher', async () => {
|
||||
const { supabase: queuedSupabase, enqueue } = createQueuedMockSupabase()
|
||||
const inv = {
|
||||
...makeInvoice({
|
||||
id: 'inv-leaked',
|
||||
total: 1000,
|
||||
status: 'sent',
|
||||
remaining_amount: 1000,
|
||||
currency: 'SEK',
|
||||
}),
|
||||
customer: makeCustomer({ name: 'Acme AB' }),
|
||||
}
|
||||
enqueue({ data: [inv], error: null })
|
||||
enqueue({ data: [{ invoice_id: 'inv-leaked' }], error: null })
|
||||
|
||||
const tx = makeTransaction({ amount: 1000, description: 'Acme payment', reference: null })
|
||||
const result = await findMatchingInvoices(queuedSupabase as never, 'company-1', tx)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a partially_paid invoice as a candidate even with a prior payment voucher', async () => {
|
||||
const { supabase: queuedSupabase, enqueue } = createQueuedMockSupabase()
|
||||
const inv = {
|
||||
...makeInvoice({
|
||||
id: 'inv-partial',
|
||||
total: 1000,
|
||||
status: 'partially_paid',
|
||||
remaining_amount: 400,
|
||||
currency: 'SEK',
|
||||
}),
|
||||
customer: makeCustomer({ name: 'Acme AB' }),
|
||||
}
|
||||
enqueue({ data: [inv], error: null })
|
||||
// The status-leak guard only queries when there are sent/overdue rows;
|
||||
// partially_paid invoices skip the second query, so no enqueue needed.
|
||||
|
||||
const tx = makeTransaction({ amount: 400, description: 'Acme partial', reference: null })
|
||||
const result = await findMatchingInvoices(queuedSupabase as never, 'company-1', tx)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].invoice.id).toBe('inv-partial')
|
||||
})
|
||||
|
||||
it('passes sent invoices through when no payment rows exist for them', async () => {
|
||||
const { supabase: queuedSupabase, enqueue } = createQueuedMockSupabase()
|
||||
const inv = {
|
||||
...makeInvoice({
|
||||
id: 'inv-clean',
|
||||
total: 1000,
|
||||
status: 'sent',
|
||||
remaining_amount: 1000,
|
||||
currency: 'SEK',
|
||||
}),
|
||||
customer: makeCustomer({ name: 'Acme AB' }),
|
||||
}
|
||||
enqueue({ data: [inv], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const tx = makeTransaction({ amount: 1000, description: 'Acme payment', reference: null })
|
||||
const result = await findMatchingInvoices(queuedSupabase as never, 'company-1', tx)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].invoice.id).toBe('inv-clean')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const chainCalls: Array<{ method: string; args: unknown[] }> = []
|
||||
|
||||
vi.mock('@supabase/ssr', () => {
|
||||
const buildChain = (): unknown =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: [], error: null, count: null })
|
||||
}
|
||||
return (...args: unknown[]) => {
|
||||
chainCalls.push({ method: String(prop), args })
|
||||
return buildChain()
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
createServerClient: vi.fn(() => ({
|
||||
from: vi.fn(() => buildChain()),
|
||||
rpc: vi.fn(() => buildChain()),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/email/service', () => ({
|
||||
getEmailService: () => ({
|
||||
sendEmail: vi.fn().mockResolvedValue({ success: true }),
|
||||
}),
|
||||
}))
|
||||
|
||||
import {
|
||||
processOverdueReminders,
|
||||
determineReminderLevel,
|
||||
calculateDaysOverdue,
|
||||
} from '../reminder-processor'
|
||||
|
||||
describe('determineReminderLevel', () => {
|
||||
it('returns null below the level-1 threshold', () => {
|
||||
expect(determineReminderLevel(10, [])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns 1 at 15 days overdue', () => {
|
||||
expect(determineReminderLevel(15, [])).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 2 at 30 days when level 1 already sent', () => {
|
||||
expect(determineReminderLevel(30, [1])).toBe(2)
|
||||
})
|
||||
|
||||
it('returns 3 at 45 days when 1 and 2 already sent', () => {
|
||||
expect(determineReminderLevel(45, [1, 2])).toBe(3)
|
||||
})
|
||||
|
||||
it('returns null when all levels have been sent', () => {
|
||||
expect(determineReminderLevel(60, [1, 2, 3])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateDaysOverdue', () => {
|
||||
it('returns a positive number for a past due date', () => {
|
||||
const tenDaysAgo = new Date()
|
||||
tenDaysAgo.setDate(tenDaysAgo.getDate() - 10)
|
||||
const days = calculateDaysOverdue(tenDaysAgo.toISOString().split('T')[0])
|
||||
expect(days).toBeGreaterThanOrEqual(9)
|
||||
expect(days).toBeLessThanOrEqual(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('processOverdueReminders — credit-note filter', () => {
|
||||
beforeEach(() => {
|
||||
chainCalls.length = 0
|
||||
})
|
||||
|
||||
it('excludes credit notes via .is("credited_invoice_id", null)', async () => {
|
||||
await processOverdueReminders()
|
||||
|
||||
const isCall = chainCalls.find(
|
||||
(c) => c.method === 'is' && c.args[0] === 'credited_invoice_id',
|
||||
)
|
||||
|
||||
expect(
|
||||
isCall,
|
||||
'overdue-invoice query must filter out credit notes — credit notes have a negative total and must never trigger a payment reminder (e.g. KR-F2026002)',
|
||||
).toBeDefined()
|
||||
expect(isCall?.args[1]).toBeNull()
|
||||
})
|
||||
|
||||
it('combines the credit-note filter with status=sent and due_date cutoff', async () => {
|
||||
await processOverdueReminders()
|
||||
|
||||
const eqStatus = chainCalls.find(
|
||||
(c) => c.method === 'eq' && c.args[0] === 'status',
|
||||
)
|
||||
const isCreditedNull = chainCalls.find(
|
||||
(c) => c.method === 'is' && c.args[0] === 'credited_invoice_id',
|
||||
)
|
||||
const lteDueDate = chainCalls.find(
|
||||
(c) => c.method === 'lte' && c.args[0] === 'due_date',
|
||||
)
|
||||
|
||||
expect(eqStatus?.args[1]).toBe('sent')
|
||||
expect(isCreditedNull?.args[1]).toBeNull()
|
||||
expect(lteDueDate).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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',
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,30 @@ export async function findMatchingInvoices(
|
||||
return []
|
||||
}
|
||||
|
||||
// Defensive filter: exclude invoices that already have a payment voucher
|
||||
// attached but whose status leaked (still 'sent'/'overdue'). Partially-paid
|
||||
// invoices can legitimately take more payments, so they pass through.
|
||||
// Without this, a status leak would double-book the receipt.
|
||||
const fullCandidateIds = invoices
|
||||
.filter((inv) => inv.status === 'sent' || inv.status === 'overdue')
|
||||
.map((inv) => inv.id as string)
|
||||
const paidIds = new Set<string>()
|
||||
if (fullCandidateIds.length > 0) {
|
||||
const { data: paymentRows } = await supabase
|
||||
.from('invoice_payments')
|
||||
.select('invoice_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('invoice_id', fullCandidateIds)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
for (const row of paymentRows ?? []) {
|
||||
paidIds.add((row as { invoice_id: string }).invoice_id)
|
||||
}
|
||||
}
|
||||
const filteredInvoices = invoices.filter((inv) => !paidIds.has(inv.id as string))
|
||||
if (filteredInvoices.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const matches: InvoiceMatch[] = []
|
||||
|
||||
// OCR/Bankgiro reference matching — highest confidence
|
||||
@@ -150,7 +174,7 @@ export async function findMatchingInvoices(
|
||||
const txReference = (transaction as Transaction & { reference?: string | null }).reference
|
||||
if (txReference) {
|
||||
const normalizedRef = txReference.replace(/\s+/g, '')
|
||||
for (const invoice of invoices) {
|
||||
for (const invoice of filteredInvoices) {
|
||||
// Match against invoice_number (used as OCR reference in Swedish payments)
|
||||
const invoiceRef = invoice.invoice_number?.replace(/\s+/g, '')
|
||||
if (invoiceRef && normalizedRef === invoiceRef) {
|
||||
@@ -168,7 +192,7 @@ export async function findMatchingInvoices(
|
||||
}
|
||||
}
|
||||
|
||||
for (const invoice of invoices) {
|
||||
for (const invoice of filteredInvoices) {
|
||||
// Currency filter - must match or be SEK equivalent
|
||||
const currencyMatch =
|
||||
invoice.currency === transaction.currency ||
|
||||
|
||||
@@ -6,6 +6,7 @@ type MatchAction =
|
||||
| 'auto_suggested'
|
||||
| 'suggestion_cleared'
|
||||
| 'storno_conflict_resolved'
|
||||
| 'linked_to_existing_voucher'
|
||||
|
||||
/**
|
||||
* Log a payment match event to the append-only audit trail.
|
||||
|
||||
@@ -143,6 +143,7 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
|
||||
customer:customers(*)
|
||||
`)
|
||||
.eq('status', 'sent')
|
||||
.is('credited_invoice_id', null)
|
||||
.lte('due_date', cutoffDate.toISOString().split('T')[0])
|
||||
.order('due_date', { ascending: true })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user