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/repos-renewals.ts

79 lines
3.7 KiB
TypeScript

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 — <coverage>'
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<RenewalRow[]> {
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<Doc> {
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 }],
})
}