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.
98 lines
4.6 KiB
TypeScript
98 lines
4.6 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, source: 'module_renewal',
|
|
lines: [{ moduleId: cm.moduleId, qty: 1, kind: cm.kind, edition: cm.edition }],
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Ad-hoc bulk SMS credit purchase (top-up) — a PROFORMA priced through the same
|
|
* createDraft path as any other document, so the usage rate card (D23) resolves the
|
|
* per-SMS rate for `smsQty` the same way a subscription line would. Tagged
|
|
* `source: 'bulk_sms_topup'` so it reads distinctly from a subscription renewal
|
|
* (`module_renewal`) on the client-detail document list.
|
|
*/
|
|
export async function generateBulkSmsPurchaseQuote(
|
|
db: DB, userId: string, clientModuleId: string, smsQty: number,
|
|
): Promise<Doc> {
|
|
if (!Number.isInteger(smsQty) || smsQty <= 0) throw new Error('SMS quantity must be a positive integer')
|
|
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, source: 'bulk_sms_topup',
|
|
lines: [{ moduleId: cm.moduleId, qty: smsQty, kind: cm.kind, edition: cm.edition }],
|
|
})
|
|
}
|