diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index e506c37..eef53c3 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -60,6 +60,7 @@ import { import { pullMonthlyCosts } from './aws-costs' import { clientProfitability, duesAging, gstSummary, moduleRevenue, type DateRange } from './repos-reports' import { listUsageRateCards, setUsageRateCard } from './repos-rate-card' +import { renewalsDue, generateModuleRenewalQuote } from './repos-renewals' import { makeRateLimiter } from './rate-limit' import { addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard, @@ -1511,6 +1512,27 @@ export function apiRouter( } }) + // ---------- renewals command center (D27) ---------- + r.get('/renewals', requireAuth, async (req, res) => { + try { + const today = new Date().toISOString().slice(0, 10) + const from = typeof req.query['from'] === 'string' ? req.query['from'] : today + // Default window: next 90 days from today. + const to = typeof req.query['to'] === 'string' ? req.query['to'] + : new Date(Date.now() + 90 * 86_400_000).toISOString().slice(0, 10) + res.json({ ok: true, rows: await renewalsDue(db, from, to, today) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.post('/client-modules/:id/renewal-quote', requireAuth, async (req, res) => { + try { + res.json({ ok: true, document: await generateModuleRenewalQuote(db, staffId(res), String(req.params['id'] ?? '')) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + // ---------- bulk operations (D26 — cleanup at scale, owner-only) ---------- r.post('/clients/bulk', requireAuth, requireOwner, async (req, res) => { try { diff --git a/apps/hq/src/repos-renewals.ts b/apps/hq/src/repos-renewals.ts new file mode 100644 index 0000000..a5edc42 --- /dev/null +++ b/apps/hq/src/repos-renewals.ts @@ -0,0 +1,78 @@ +import type { DB } from './db' +import { createDraft, type Doc } from './repos-documents' +import { getClientModule, priceOn, type Kind } from './repos-modules' + +/** + * Renewals command center (D27). One unified list of what's coming up for renewal — + * module subscriptions (client_module.next_renewal, priced by the dated price book) and + * AMC contracts (amc_contract.period_to, its fixed amount) — with days-until and amount, + * so recurring revenue is a worklist, not a memory test. Read-only. The renewal REMINDERS + * are already scanned by the scheduler (rule kinds renewal_due / amc_expiring); this is the + * human-facing view + the one-click "raise the renewal paper" action. + */ + +function daysBetween(fromIso: string, toIso: string): number { + return Math.floor((Date.parse(toIso) - Date.parse(fromIso)) / 86_400_000) +} + +export interface RenewalRow { + kind: 'module' | 'amc' + id: string // client_module id (module) or amc_contract id (amc) + clientId: string; clientName: string + label: string // module code, or 'AMC — ' + dueOn: string // YYYY-MM-DD + daysUntil: number // negative = already lapsed + amountPaise: number | null // renewal value (price book / AMC amount); null if no price row +} + +export async function renewalsDue(db: DB, from: string, to: string, today: string): Promise { + const rows: RenewalRow[] = [] + const mods = await db.all<{ + id: string; client_id: string; module_id: string; kind: string; edition: string + next_renewal: string; client_name: string; code: string + }>( + `SELECT cm.id, cm.client_id, cm.module_id, cm.kind, cm.edition, cm.next_renewal, + c.name AS client_name, m.code + FROM client_module cm JOIN client c ON c.id = cm.client_id JOIN module m ON m.id = cm.module_id + WHERE cm.active=1 AND cm.next_renewal IS NOT NULL AND cm.next_renewal >= ? AND cm.next_renewal <= ?`, + from, to, + ) + for (const r of mods) { + rows.push({ + kind: 'module', id: r.id, clientId: r.client_id, clientName: r.client_name, + label: r.code, dueOn: r.next_renewal, daysUntil: daysBetween(today, r.next_renewal), + amountPaise: await priceOn(db, r.module_id, r.kind as Kind, r.edition, r.next_renewal), + }) + } + const amcs = await db.all<{ + id: string; client_id: string; coverage: string; period_to: string; amount_paise: number; client_name: string + }>( + `SELECT a.id, a.client_id, a.coverage, a.period_to, a.amount_paise, c.name AS client_name + FROM amc_contract a JOIN client c ON c.id = a.client_id + WHERE a.active=1 AND a.period_to >= ? AND a.period_to <= ?`, + from, to, + ) + for (const r of amcs) { + rows.push({ + kind: 'amc', id: r.id, clientId: r.client_id, clientName: r.client_name, + label: `AMC — ${r.coverage !== '' ? r.coverage : 'contract'}`, + dueOn: r.period_to, daysUntil: daysBetween(today, r.period_to), amountPaise: r.amount_paise, + }) + } + return rows.sort((a, b) => a.dueOn.localeCompare(b.dueOn) || a.clientName.localeCompare(b.clientName)) +} + +/** + * One-click renewal PROFORMA for a module subscription — carries the client's kind/edition + * forward into a fresh proforma draft, priced by the dated price book (via createDraft). + * The staffer reviews + issues + sends it like any other document. (AMC renewals use the + * existing generateAmcRenewalInvoice.) + */ +export async function generateModuleRenewalQuote(db: DB, userId: string, clientModuleId: string): Promise { + const cm = await getClientModule(db, clientModuleId) + if (cm === null) throw new Error('Client module not found') + return createDraft(db, userId, { + docType: 'PROFORMA', clientId: cm.clientId, + lines: [{ moduleId: cm.moduleId, qty: 1, kind: cm.kind, edition: cm.edition }], + }) +} diff --git a/apps/hq/test/renewals.test.ts b/apps/hq/test/renewals.test.ts new file mode 100644 index 0000000..eed08a2 --- /dev/null +++ b/apps/hq/test/renewals.test.ts @@ -0,0 +1,50 @@ +// apps/hq/test/renewals.test.ts — D27: renewals command center + one-click module quote. +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, setPrice, assignModule, updateClientModule } from '../src/repos-modules' +import { createAmc } from '../src/repos-amc' +import { renewalsDue, generateModuleRenewalQuote } from '../src/repos-renewals' +import { getDocument } from '../src/repos-documents' + +async function world() { + const db = openDb(':memory:'); await seedIfEmpty(db) + const c = await createClient(db, 'u1', { name: 'Karapuzha SCB', stateCode: '32' }) + const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS', allowedKinds: ['yearly'] }) + await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) + const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) + await updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-08-15' }) + return { db, c, m, cm } +} + +describe('renewalsDue (D27)', () => { + it('lists module + AMC renewals in a window with amount and days-until, earliest first', async () => { + const { db, c, cm } = await world() + await createAmc(db, 'u1', { + clientId: c.id, coverage: 'Support', periodFrom: '2025-09-01', periodTo: '2026-08-31', amountPaise: 15_000_00, + }) + const rows = await renewalsDue(db, '2026-07-01', '2026-09-30', '2026-08-01') + expect(rows).toHaveLength(2) + const mod = rows.find((r) => r.kind === 'module')! + expect(mod).toMatchObject({ id: cm.id, label: 'SMS', dueOn: '2026-08-15', amountPaise: 10_000_00 }) + expect(mod.daysUntil).toBe(14) + const amc = rows.find((r) => r.kind === 'amc')! + expect(amc).toMatchObject({ dueOn: '2026-08-31', amountPaise: 15_000_00 }) + expect(rows[0]!.dueOn <= rows[1]!.dueOn).toBe(true) // sorted earliest-first + // A renewal outside the window is excluded. + expect(await renewalsDue(db, '2026-07-01', '2026-08-10', '2026-08-01')).toHaveLength(0) + }) +}) + +describe('generateModuleRenewalQuote (D27)', () => { + it('creates a proforma draft carrying the module line at the price-book rate', async () => { + const { db, cm } = await world() + const draft = await generateModuleRenewalQuote(db, 'u1', cm.id) + expect(draft.docType).toBe('PROFORMA') + expect(draft.status).toBe('draft') + const full = (await getDocument(db, draft.id))! + expect(full.taxablePaise).toBe(10_000_00) // one SMS-yearly line at the dated price + expect(full.payablePaise).toBe(11_800_00) // + 18% GST + }) +})