feat(d27): #1 renewals command center + one-click module renewal quote (backend)
renewalsDue(from,to,today): unified list of module (client_module.next_renewal, priced by the dated price book) + AMC (period_to, fixed amount) renewals with amount + days-until, earliest-first. generateModuleRenewalQuote: one-click proforma carrying the client's module kind/edition forward at the price-book rate (AMC keeps its existing renewal-invoice). Routes GET /renewals, POST /client-modules/:id/renewal-quote. Renewal reminders already fire from the scheduler (renewal_due/amc_expiring) — this is the worklist + action. Tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
18e47eb448
commit
02d6f13c95
@ -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 — <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 }],
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue