Fix/supp ag fb (#1023)
* fix: prevent credit notes from entering payment flow * fix: persist and display customer personal numbers * feat: configure automatic invoice reminder days * fix: issue credit notes through send flow * chore: add repository agent guidance * feat(mcp): route tools across user companies * fix(articles): delete unused register entries * feat(invoices): improve issued invoice actions * feat(supplier-invoices): retain uploaded source documents * docs: record implementation decisions * feat: enhance customer personal number handling and validation - Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers. - Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema. - Implemented masking and encryption for personal numbers to enhance data protection. - Introduced new utility functions for masking and encrypting personal numbers. - Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries. - Enhanced error handling and logging for credit note issuance and invoice processing. - Updated tests to cover new credit note creation guards and personal number handling. * test: enhance list companies test with supabase query mocks
This commit is contained in:
@@ -4,7 +4,8 @@ import {
|
||||
generateReminderEmailHtml,
|
||||
generateReminderEmailText,
|
||||
generateReminderEmailSubject,
|
||||
getReminderDaysConfig
|
||||
getReminderDaysConfig,
|
||||
type ReminderDaysConfig,
|
||||
} from '@/lib/email/reminder-templates'
|
||||
import { calculateLatePaymentInterest } from '@/lib/invoices/late-payment-interest'
|
||||
import { createReminderFeeEntry } from '@/lib/bookkeeping/reminder-fee-entries'
|
||||
@@ -49,21 +50,19 @@ export interface ProcessRemindersResult {
|
||||
*/
|
||||
export function determineReminderLevel(
|
||||
daysOverdue: number,
|
||||
existingLevels: number[]
|
||||
existingLevels: number[],
|
||||
config: ReminderDaysConfig = getReminderDaysConfig(),
|
||||
): 1 | 2 | 3 | null {
|
||||
const config = getReminderDaysConfig()
|
||||
|
||||
// Check level 3 (45 days)
|
||||
// Check the highest eligible level first, preserving the existing behavior
|
||||
// when a previous cron run was missed.
|
||||
if (daysOverdue >= config[3] && !existingLevels.includes(3)) {
|
||||
return 3
|
||||
}
|
||||
|
||||
// Check level 2 (30 days)
|
||||
if (daysOverdue >= config[2] && !existingLevels.includes(2)) {
|
||||
return 2
|
||||
}
|
||||
|
||||
// Check level 1 (15 days)
|
||||
if (daysOverdue >= config[1] && !existingLevels.includes(1)) {
|
||||
return 1
|
||||
}
|
||||
@@ -146,12 +145,11 @@ export async function sendReminder(
|
||||
export async function processOverdueReminders(): Promise<ProcessRemindersResult> {
|
||||
const supabase = createServiceClient()
|
||||
const results: ReminderResult[] = []
|
||||
const config = getReminderDaysConfig()
|
||||
|
||||
// Find all sent invoices that are past due date (at least 15 days overdue)
|
||||
const minOverdueDays = config[1]
|
||||
// Company schedules can start as early as one day overdue. Fetch that
|
||||
// bounded candidate set, then apply each company's thresholds below.
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - minOverdueDays)
|
||||
cutoffDate.setDate(cutoffDate.getDate() - 1)
|
||||
|
||||
// Positive allowlist: inherently excludes 'paid', 'partially_paid', 'cancelled', 'credited'.
|
||||
// Including 'overdue' ensures level-2 / level-3 reminders re-fire after the first reminder
|
||||
@@ -160,7 +158,8 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
|
||||
.from('invoices')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers(*)
|
||||
customer:customers(*),
|
||||
credit_notes:invoices!credited_invoice_id(id, status, creation_complete)
|
||||
`)
|
||||
.in('status', ['sent', 'overdue'])
|
||||
.is('credited_invoice_id', null)
|
||||
@@ -181,6 +180,17 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
|
||||
|
||||
// Process each invoice
|
||||
for (const invoice of overdueInvoices) {
|
||||
const activeCreditNotes = ((invoice as { credit_notes?: Array<{
|
||||
status: string
|
||||
creation_complete?: boolean
|
||||
}> }).credit_notes ?? []).filter(
|
||||
(creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
|
||||
)
|
||||
if (activeCreditNotes.length > 0) {
|
||||
log.info(`Skipping invoice ${invoice.invoice_number}: active credit note exists`)
|
||||
continue
|
||||
}
|
||||
|
||||
const customer = invoice.customer as Customer
|
||||
|
||||
// Skip if customer has no email
|
||||
@@ -209,13 +219,6 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
|
||||
|
||||
const existingLevels = existingReminders?.map(r => r.reminder_level) || []
|
||||
const daysOverdue = calculateDaysOverdue(invoice.due_date)
|
||||
const reminderLevel = determineReminderLevel(daysOverdue, existingLevels)
|
||||
|
||||
// Skip if no reminder needed
|
||||
if (!reminderLevel) {
|
||||
log.info(`Skipping invoice ${invoice.invoice_number}: no reminder needed (${daysOverdue} days overdue, existing levels: ${existingLevels.join(', ')})`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get company settings for this user
|
||||
const { data: company, error: companyError } = await supabase
|
||||
@@ -226,14 +229,17 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
|
||||
|
||||
if (companyError || !company) {
|
||||
log.error(`Skipping invoice ${invoice.invoice_number}: company settings not found`)
|
||||
results.push({
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
customerEmail: customer.email,
|
||||
reminderLevel,
|
||||
success: false,
|
||||
error: 'Company settings not found'
|
||||
})
|
||||
const fallbackLevel = determineReminderLevel(daysOverdue, existingLevels)
|
||||
if (fallbackLevel) {
|
||||
results.push({
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
customerEmail: customer.email,
|
||||
reminderLevel: fallbackLevel,
|
||||
success: false,
|
||||
error: 'Company settings not found',
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -243,16 +249,35 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
|
||||
continue
|
||||
}
|
||||
|
||||
const reminderConfig = getReminderDaysConfig(company as CompanySettings)
|
||||
const reminderLevel = determineReminderLevel(daysOverdue, existingLevels, reminderConfig)
|
||||
|
||||
if (!reminderLevel) {
|
||||
log.info(`Skipping invoice ${invoice.invoice_number}: no reminder needed (${daysOverdue} days overdue, existing levels: ${existingLevels.join(', ')})`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Race-window guard: re-check invoice status immediately before sending.
|
||||
// The cron runs at 08:00; a payment match arriving during the run shouldn't
|
||||
// produce a reminder for an already-paid invoice.
|
||||
const { data: currentInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('status')
|
||||
.select('status, credit_notes:invoices!credited_invoice_id(id, status, creation_complete)')
|
||||
.eq('id', invoice.id)
|
||||
.eq('company_id', invoice.company_id)
|
||||
.single()
|
||||
|
||||
if (!currentInvoice || !['sent', 'overdue'].includes(currentInvoice.status as string)) {
|
||||
const currentCreditNotes = ((currentInvoice as { credit_notes?: Array<{
|
||||
status: string
|
||||
creation_complete?: boolean
|
||||
}> } | null)?.credit_notes ?? []).filter(
|
||||
(creditNote) => creditNote.status !== 'cancelled' && creditNote.creation_complete !== false,
|
||||
)
|
||||
if (
|
||||
!currentInvoice ||
|
||||
!['sent', 'overdue'].includes(currentInvoice.status as string) ||
|
||||
currentCreditNotes.length > 0
|
||||
) {
|
||||
log.info(`Skipping invoice ${invoice.invoice_number}: status changed to ${currentInvoice?.status ?? 'unknown'} mid-run`)
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user