import { writeAudit } from './audit' import type { DB } from './db' import { createDraft, issueDocument } from './repos-documents' import { getClientModule } from './repos-modules' import { outstandingPaise } from './repos-payments' import { CADENCE_KIND, getRecurringPlan } from './repos-recurring' import { getNumberSetting, getSetting, resolveSchedule, setReminderStatus, upsertReminder } from './repos-reminders' import { sendReminder, type SendReminderDeps } from './send-reminder' /** * Daily scan — pure w.r.t. the clock: `today` (YYYY-MM-DD) is injected, so tests * are deterministic. Every reminder is created through upsertReminder (INSERT OR * IGNORE on the idempotency key), which makes re-runs and catch-up after downtime * safe with no extra bookkeeping. The four detection rules all land in the manual * queue; recurring generation is transactional (claim + generate + advance next_run * all-or-nothing) with the auto-send strictly after commit — at most once per period. */ export type ScanDeps = SendReminderDeps export interface ScanResult { today: string created: Record autoSent: number autoFailed: number } /** ISO date arithmetic on YYYY-MM-DD (UTC), lexical-compare safe. */ export function addDaysIso(dateIso: string, days: number): string { const [y, m, d] = dateIso.split('-').map(Number) return new Date(Date.UTC(y!, m! - 1, d! + days)).toISOString().slice(0, 10) } /** Whole days from `fromIso` to `toIso` (UTC midnights; negative when from > to). */ export function daysBetweenIso(fromIso: string, toIso: string): number { const at = (s: string): number => { const [y, m, d] = s.split('-').map(Number) return Date.UTC(y!, m! - 1, d!) } return Math.round((at(toIso) - at(fromIso)) / 86_400_000) } /** Advance a YYYY-MM-DD anchor by one cadence (UTC; month/day overflow normalizes). */ export function addCadence(dateIso: string, cadence: 'monthly' | 'yearly'): string { const [y, m, d] = dateIso.split('-').map(Number) const ms = cadence === 'yearly' ? Date.UTC(y! + 1, m! - 1, d!) : Date.UTC(y!, m!, d!) return new Date(ms).toISOString().slice(0, 10) } function bump(created: Record, kind: string): void { created[kind] = (created[kind] ?? 0) + 1 } type ClaimResult = 'done' | { created: boolean; auto: boolean; reminderId: string } /** One period for one plan, atomically. Callers wrap in db.transaction. */ function claimAndGenerate(db: DB, planId: string, today: string, now: string): ClaimResult { const plan = getRecurringPlan(db, planId) if (plan === null || !plan.active || plan.nextRun > today) return 'done' const duePeriod = plan.nextRun const up = upsertReminder(db, { ruleKind: 'recurring_generated', subjectId: plan.id, duePeriod, clientId: plan.clientId, policyApplied: plan.policy, now, }) const nextRun = addCadence(plan.nextRun, plan.cadence) if (!up.created) { // Defensive: the period's reminder already exists but next_run wasn't advanced. // Only reachable if generation were ever non-transactional; advance, never regenerate. db.prepare(`UPDATE recurring_plan SET next_run=? WHERE id=?`).run(nextRun, planId) return { created: false, auto: false, reminderId: up.id } } if (plan.clientModuleId === null) throw new Error(`recurring_plan ${plan.id}: client_module required to generate`) const cm = getClientModule(db, plan.clientModuleId) if (cm === null) throw new Error(`recurring_plan ${plan.id}: client_module not found`) const kind = CADENCE_KIND[plan.cadence] const draft = createDraft(db, 'system', { docType: 'INVOICE', clientId: plan.clientId, lines: [{ moduleId: cm.moduleId, qty: 1, kind, edition: cm.edition, ...(plan.amountPaise !== null ? { unitPricePaise: plan.amountPaise } : {}), }], }) const inv = issueDocument(db, 'system', draft.id) db.prepare(`UPDATE reminder SET doc_id=? WHERE id=?`).run(inv.id, up.id) db.prepare(`UPDATE recurring_plan SET next_run=? WHERE id=?`).run(nextRun, planId) writeAudit(db, 'system', 'generate', 'recurring_plan', plan.id, { nextRun: plan.nextRun }, { nextRun, invoiceId: inv.id }) return { created: true, auto: plan.policy === 'auto', reminderId: up.id } } /** Generate every due period for every active plan; collect auto reminders to send. */ function generateRecurring(db: DB, today: string, now: string, created: Record): string[] { const plans = db.prepare( `SELECT id FROM recurring_plan WHERE active=1 AND next_run <= ?`, ).all(today) as { id: string }[] const autoQueue: string[] = [] for (const { id } of plans) { let guard = 0 for (;;) { if (guard++ > 240) break // safety cap: ~20 years of monthly against a corrupt next_run const claim = db.transaction(() => claimAndGenerate(db, id, today, now))() if (claim === 'done') break if (claim.created) { bump(created, 'recurring_generated') if (claim.auto) autoQueue.push(claim.reminderId) } } } return autoQueue } export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promise { const now = deps.now?.() ?? new Date().toISOString() const created: Record = {} // --- invoice_overdue (dN day-past-issue milestones from the dated schedule; spec §8) --- // A7: there is no due-date column — age anchors on doc_date (the issue date). // Catch-up guard (F7): only the HIGHEST crossed milestone enqueues per scan, so a // scan gap (or the old monthly→dN cutover) nudges once, never the whole ladder; // under daily scans each milestone still fires exactly once as it is crossed. const invOffsets = resolveSchedule(db, 'invoice_overdue', today).dayOffsets // ascending const overdueCutoff = addDaysIso(today, -invOffsets[0]!) const overdue = db.prepare( `SELECT id, client_id, doc_date FROM document WHERE doc_type='INVOICE' AND doc_no IS NOT NULL AND status NOT IN ('paid','cancelled','lost') AND doc_date <= ?`, ).all(overdueCutoff) as { id: string; client_id: string; doc_date: string }[] for (const inv of overdue) { if (outstandingPaise(db, inv.id) <= 0) continue const age = daysBetweenIso(inv.doc_date, today) const crossed = invOffsets.filter((d) => age >= d) if (crossed.length === 0) continue const highest = crossed[crossed.length - 1]! if (upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: `d${highest}`, clientId: inv.client_id, docId: inv.id, now }).created) { bump(created, 'invoice_overdue') } } // --- renewal_due --- const renewalDays = getNumberSetting(db, 'reminders.renewal_days', 15) const renewalHorizon = addDaysIso(today, renewalDays) const renewals = db.prepare( `SELECT id, client_id, next_renewal FROM client_module WHERE active=1 AND next_renewal IS NOT NULL AND next_renewal >= ? AND next_renewal <= ?`, ).all(today, renewalHorizon) as { id: string; client_id: string; next_renewal: string }[] for (const cm of renewals) { if (upsertReminder(db, { ruleKind: 'renewal_due', subjectId: cm.id, duePeriod: cm.next_renewal, clientId: cm.client_id, now }).created) { bump(created, 'renewal_due') } } // --- amc_expiring (per-contract window) --- const amcs = db.prepare( `SELECT id, client_id, period_to, renewal_reminder_days FROM amc_contract WHERE active=1`, ).all() as { id: string; client_id: string; period_to: string; renewal_reminder_days: number }[] for (const a of amcs) { const horizon = addDaysIso(today, a.renewal_reminder_days) if (a.period_to < today || a.period_to > horizon) continue if (upsertReminder(db, { ruleKind: 'amc_expiring', subjectId: a.id, duePeriod: a.period_to, clientId: a.client_id, now }).created) { bump(created, 'amc_expiring') } } // --- follow_up (internal) --- const followUps = db.prepare( `SELECT id, client_id, follow_up_on FROM interaction WHERE follow_up_on IS NOT NULL AND follow_up_on <= ?`, ).all(today) as { id: string; client_id: string; follow_up_on: string }[] for (const f of followUps) { if (upsertReminder(db, { ruleKind: 'follow_up', subjectId: f.id, duePeriod: f.follow_up_on, clientId: f.client_id, now }).created) { bump(created, 'follow_up') } } // --- quote_followup (escalating chase on sent quotations — spec §7) --- // Age anchors on the FIRST 'sent' event (F15). The dated schedule (rule 3) yields // the day offsets; send policy is a flat operational setting, default manual. const followupSchedule = resolveSchedule(db, 'quote_followup', today) const followupPolicy: 'auto' | 'manual' = getSetting(db, 'quote.followup.policy') === 'auto' ? 'auto' : 'manual' // A lost client is dead pipeline — never chase (their queued rows can be dismissed // by hand). NOT EXISTS covers quotes converted before status-flip-on-convert landed: // a live forward child means the sale moved on, whatever the quote row still says. const sentQuotes = db.prepare( `SELECT d.id, d.client_id, (SELECT MIN(e.at_wall) FROM document_event e WHERE e.document_id = d.id AND e.kind = 'sent') AS first_sent FROM document d JOIN client c ON c.id = d.client_id WHERE d.doc_type='QUOTATION' AND d.status='sent' AND c.status != 'lost' AND NOT EXISTS (SELECT 1 FROM document ch WHERE ch.ref_doc_id = d.id AND ch.status != 'cancelled')`, ).all() as { id: string; client_id: string; first_sent: string | null }[] const followupAuto: string[] = [] for (const q of sentQuotes) { if (q.first_sent === null) continue const age = Math.floor((Date.parse(today) - Date.parse(q.first_sent.slice(0, 10))) / 86_400_000) const crossed = followupSchedule.dayOffsets.filter((o) => age >= o) if (crossed.length === 0) continue // Catch-up burst guard (F7): enqueue ONLY the single highest crossed milestone — // a quote already past several offsets gets one nudge, never the whole ladder. // Milestones consumed on their own day stay consumed (threshold-day bucket in the // unique key), so daily operation still fires each interval exactly once. const highest = crossed[crossed.length - 1]! const up = upsertReminder(db, { ruleKind: 'quote_followup', subjectId: q.id, duePeriod: `d${highest}`, clientId: q.client_id, docId: q.id, policyApplied: followupPolicy, now, }) if (up.created) { bump(created, 'quote_followup') if (followupPolicy === 'auto') followupAuto.push(up.id) } } // --- recurring_generated (transactional generation, then async auto-send) --- const autoQueue = [...followupAuto, ...generateRecurring(db, today, now, created)] let autoSent = 0 let autoFailed = 0 for (const reminderId of autoQueue) { // Send outside the generation transaction: a failed send never rolls back the // (already-issued) invoice or the advanced schedule — it just parks the reminder. try { const out = await sendReminder(db, deps, reminderId, 'system') if (out.ok) autoSent += 1 else autoFailed += 1 } catch (err) { // A hard guard (no recipient email, share.base_url unset) must not kill the // drain: park THIS reminder as failed — loudly, with the reason — and keep going. setReminderStatus(db, 'system', reminderId, 'failed', { error: err instanceof Error ? err.message : String(err), }) autoFailed += 1 } } return { today, created, autoSent, autoFailed } }