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:
Mattsson
2026-07-15 15:53:15 +02:00
committed by GitHub
parent a558c75678
commit 072aedeaf9
116 changed files with 5708 additions and 669 deletions
+54 -29
View File
@@ -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
}