// Flips past-due 'sent' invoices to 'overdue' and emails the client a payment reminder. // The email goes out only on the sent→overdue transition, so re-running never double-sends. // Run daily from the monorepo root (cron / scheduled task): npx tsx scripts/send-overdue-reminders.ts import path from 'node:path'; import { fileURLToPath } from 'node:url'; import dotenv from 'dotenv'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); dotenv.config({ path: path.resolve(__dirname, '../.env') }); import { and, eq, lt } from 'drizzle-orm'; import { getDb, getPool, invoices, clients, firms } from '@lawdesk/db'; import { sendEmail, invoiceOverdueEmail } from '../apps/api/src/lib/email'; async function main() { const db = getDb(); const now = new Date(); const due = await db .select({ id: invoices.id, number: invoices.number, total: invoices.total, dueAt: invoices.dueAt, clientName: clients.name, clientEmail: clients.email, firmName: firms.name, }) .from(invoices) .innerJoin(clients, eq(invoices.clientId, clients.id)) .innerJoin(firms, eq(invoices.firmId, firms.id)) .where(and(eq(invoices.status, 'sent'), lt(invoices.dueAt, now))); console.log(`Found ${due.length} past-due invoice(s) to mark overdue.`); let flipped = 0; let emailed = 0; for (const inv of due) { // Guard on status='sent' so a concurrent run can't flip (and email) the same invoice twice. const [row] = await db .update(invoices) .set({ status: 'overdue', updatedAt: new Date() }) .where(and(eq(invoices.id, inv.id), eq(invoices.status, 'sent'))) .returning({ id: invoices.id }); if (!row) continue; flipped++; if (!inv.clientEmail || !inv.dueAt) { console.log(` ${inv.number}: marked overdue, no reminder (missing client email or due date)`); continue; } const totalFmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format( Number(inv.total), ); const dueDate = inv.dueAt.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', }); const tpl = invoiceOverdueEmail({ clientName: inv.clientName, firmName: inv.firmName, invoiceNumber: inv.number, total: totalFmt, dueDate, }); const result = await sendEmail({ to: inv.clientEmail, ...tpl }); if (result.ok && !result.skipped) { emailed++; console.log(` ${inv.number}: marked overdue, reminder sent to ${inv.clientEmail}`); } else { console.log(` ${inv.number}: marked overdue, reminder ${result.skipped ? 'skipped (no API key)' : `FAILED: ${result.error}`}`); } } console.log(`Done. ${flipped} invoice(s) marked overdue, ${emailed} reminder(s) sent.`); await getPool().end(); } main().catch((err) => { console.error(err); process.exit(1); });