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:
Mattsson
2026-05-18 13:17:15 +02:00
committed by GitHub
parent a652dcae1a
commit d27c3dd3dc
22 changed files with 2364 additions and 13 deletions
@@ -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()
})
})
+188
View File
@@ -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',
}
}
+26 -2
View File
@@ -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 ||
+1
View File
@@ -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.
+1
View File
@@ -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 })