feat(hq): daily scan detection rules with idempotent reminders
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
3204a9af97
commit
2d3b1a52a9
@ -0,0 +1,90 @@
|
||||
import type { DB } from './db'
|
||||
import { outstandingPaise } from './repos-payments'
|
||||
import { getNumberSetting, upsertReminder } from './repos-reminders'
|
||||
import 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. Recurring generation + auto-send is added in the
|
||||
* next task; this pass covers the four detection rules (all land in the manual queue).
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function bump(created: Record<string, number>, kind: string): void {
|
||||
created[kind] = (created[kind] ?? 0) + 1
|
||||
}
|
||||
|
||||
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 ---
|
||||
const overdueDays = getNumberSetting(db, 'reminders.overdue_days', 7)
|
||||
const overdueCutoff = addDaysIso(today, -overdueDays)
|
||||
const period = today.slice(0, 7)
|
||||
const overdue = db.prepare(
|
||||
`SELECT id, client_id 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 }[]
|
||||
for (const inv of overdue) {
|
||||
if (outstandingPaise(db, inv.id) <= 0) continue
|
||||
if (upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: period, 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')
|
||||
}
|
||||
}
|
||||
|
||||
return { today, created, autoSent: 0, autoFailed: 0 }
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
// apps/hq/test/scheduler-scan.test.ts
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { openDb } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { createClient } from '../src/repos-clients'
|
||||
import { assignModule, createModule, setPrice, updateClientModule } from '../src/repos-modules'
|
||||
import { createDraft, issueDocument } from '../src/repos-documents'
|
||||
import { createAmc } from '../src/repos-amc'
|
||||
import { createInteraction } from '../src/repos-interactions'
|
||||
import { listReminders } from '../src/repos-reminders'
|
||||
import { runDailyScan, type ScanDeps } from '../src/scheduler'
|
||||
|
||||
const deps: ScanDeps = {
|
||||
gmail: { f: (async () => new Response('{}')) as typeof fetch, clientId: '', clientSecret: '', keyHex: '' },
|
||||
renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }),
|
||||
now: () => '2026-07-10T00:00:00Z',
|
||||
}
|
||||
|
||||
function world() {
|
||||
const db = openDb(':memory:'); seedIfEmpty(db)
|
||||
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
|
||||
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' })
|
||||
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
|
||||
return { db, c, m }
|
||||
}
|
||||
|
||||
describe('runDailyScan — detection rules', () => {
|
||||
it('raises overdue, renewal, amc and follow-up reminders, and is idempotent', async () => {
|
||||
const { db, c, m } = world()
|
||||
// Overdue invoice: issued 2026-06-01, unpaid, > 7 days before 2026-07-10.
|
||||
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id)
|
||||
db.prepare(`UPDATE document SET doc_date='2026-06-01' WHERE id=?`).run(inv.id)
|
||||
// Renewal within 15 days.
|
||||
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
|
||||
updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-20' })
|
||||
// AMC expiring within 30 days.
|
||||
createAmc(db, 'u1', { clientId: c.id, coverage: 'Support', periodFrom: '2025-08-01', periodTo: '2026-08-01', amountPaise: 20_000_00 })
|
||||
// Follow-up due.
|
||||
createInteraction(db, 'u1', { clientId: c.id, typeCode: 'call', onDate: '2026-07-01', followUpOn: '2026-07-09' })
|
||||
|
||||
const res = await runDailyScan(db, deps, '2026-07-10')
|
||||
expect(res.created['invoice_overdue']).toBe(1)
|
||||
expect(res.created['renewal_due']).toBe(1)
|
||||
expect(res.created['amc_expiring']).toBe(1)
|
||||
expect(res.created['follow_up']).toBe(1)
|
||||
expect(listReminders(db, { status: 'queued' })).toHaveLength(4)
|
||||
|
||||
// Re-run same day → the idempotency key blocks every duplicate.
|
||||
const again = await runDailyScan(db, deps, '2026-07-10')
|
||||
expect(Object.values(again.created).reduce((a, b) => a + b, 0)).toBe(0)
|
||||
expect(listReminders(db, {})).toHaveLength(4)
|
||||
})
|
||||
it('does not raise a reminder for an invoice that is not yet overdue', async () => {
|
||||
const { db, c, m } = world()
|
||||
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id)
|
||||
db.prepare(`UPDATE document SET doc_date='2026-07-08' WHERE id=?`).run(inv.id) // only 2 days old
|
||||
const res = await runDailyScan(db, deps, '2026-07-10')
|
||||
expect(res.created['invoice_overdue'] ?? 0).toBe(0)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue