You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/scheduler.ts

239 lines
12 KiB
TypeScript

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<string, number>
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<string, number>, 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, number>): 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<ScanResult> {
const now = deps.now?.() ?? new Date().toISOString()
const created: Record<string, number> = {}
// --- invoice_overdue (dN day-past-due milestones from the dated schedule; spec §8, D18) ---
// Age anchors on the due date when the invoice carries one (stamped at issue from
// billing.payment_terms_days), else the issue date — the pre-D18 behavior.
// 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, due_date FROM document
WHERE doc_type='INVOICE' AND doc_no IS NOT NULL
AND status NOT IN ('paid','cancelled','lost')
AND COALESCE(due_date, doc_date) <= ?`,
).all(overdueCutoff) as { id: string; client_id: string; doc_date: string; due_date: string | null }[]
for (const inv of overdue) {
if (outstandingPaise(db, inv.id) <= 0) continue
const age = daysBetweenIso(inv.due_date ?? 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 }
}