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.
239 lines
12 KiB
TypeScript
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 }
|
|
}
|