feat(sms): bulk SMS purchase proforma + document source tagging (renewal vs top-up)

Threads an optional `source` through DraftInput/insertDocRow (default 'hq',
preserving current behaviour when omitted), tags module renewals as
'module_renewal', and adds generateBulkSmsPurchaseQuote — an ad-hoc bulk SMS
credit top-up priced via the existing usage rate card, tagged
'bulk_sms_topup' so the client-detail document list can distinguish it from a
subscription renewal. New route: POST /client-modules/:id/bulk-sms-quote.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 days ago
parent fdf31b4dbd
commit 195d821bef

@ -60,7 +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 { renewalsDue, generateModuleRenewalQuote, generateBulkSmsPurchaseQuote } from './repos-renewals'
import { misCockpit } from './repos-mis'
import { smsBalances, refreshSmsBalance, refreshAllSmsBalances, DEFAULT_SMS_BALANCE_API } from './repos-sms'
import { globalSearch } from './repos-search'
@ -1665,6 +1665,16 @@ export function apiRouter(
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// Ad-hoc bulk SMS credit purchase (top-up) — distinct from a subscription renewal.
r.post('/client-modules/:id/bulk-sms-quote', requireAuth, async (req, res) => {
try {
const qty = Number((req.body as { smsQty?: unknown }).smsQty)
const doc = await generateBulkSmsPurchaseQuote(db, staffId(res), String(req.params['id'] ?? ''), qty)
res.json({ ok: true, document: doc })
} 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) => {

@ -152,6 +152,8 @@ export interface DraftInput {
terms?: string
/** INVOICE only (D18): payment due date; when absent, issue stamps doc_date + terms. */
dueDate?: string
/** Provenance tag for the frontend's Quote/Renewal/Bulk-top-up label; default: schema default ('hq'). */
source?: string
}
const todayIso = (): string => new Date().toISOString().slice(0, 10)
@ -240,18 +242,18 @@ async function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warn
/** Insert a draft row + created event + audit. Callers wrap in a transaction. */
async function insertDocRow(db: DB, userId: string, a: {
docType: DocType; clientId: string; refDocId: string | null; docDate: string
totals: BillTotals; payload: DocPayload; dueDate?: string | null
totals: BillTotals; payload: DocPayload; dueDate?: string | null; source?: string
}): Promise<Doc> {
const id = uuidv7()
const t = a.totals
await db.run(
`INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, due_date, status, ref_doc_id,
taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise,
payload, created_by, created_at)
VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
payload, source, created_by, created_at)
VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
id, a.docType, fyOf(a.docDate), a.clientId, a.docDate, a.dueDate ?? null, a.refDocId,
t.taxablePaise, t.cgstPaise, t.sgstPaise, t.igstPaise, t.roundOffPaise, t.payablePaise,
JSON.stringify(a.payload), userId, new Date().toISOString())
JSON.stringify(a.payload), a.source ?? 'hq', userId, new Date().toISOString())
await addEvent(db, id, 'created', a.refDocId !== null ? { refDocId: a.refDocId } : {})
const doc = (await getDocument(db, id))!
await writeAudit(db, userId, 'create', 'document', id, undefined, {
@ -334,7 +336,7 @@ export async function createDraft(db: DB, userId: string, input: DraftInput): Pr
return db.transaction(() => insertDocRow(db, userId, {
docType: prepared.docType, clientId: prepared.clientId, refDocId: null,
docDate: prepared.docDate, totals: prepared.totals, payload: prepared.payload,
dueDate: prepared.dueDate ?? null,
dueDate: prepared.dueDate ?? null, source: input.source,
}))
}

@ -72,7 +72,26 @@ export async function generateModuleRenewalQuote(db: DB, userId: string, clientM
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,
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 }],
})
}

@ -0,0 +1,74 @@
// apps/hq/test/bulk-sms-purchase.test.ts — Task 7: ad-hoc bulk-SMS top-up documents,
// tagged distinctly from subscription renewals via document.source.
import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { createModule, assignModule, setPrice, updateClientModule } from '../src/repos-modules'
import { setUsageRateCard } from '../src/repos-rate-card'
import { generateModuleRenewalQuote, generateBulkSmsPurchaseQuote } from '../src/repos-renewals'
import { createDraft, getDocument } from '../src/repos-documents'
// The founder's real card: ₹0.40 / 0.37 / 0.34 / 0.30 per SMS = 40/37/34/30 paise.
const BANDS = [
{ minQty: 50_000, ratePaise: 40 },
{ minQty: 100_000, ratePaise: 37 },
{ minQty: 200_000, ratePaise: 34 },
{ minQty: 300_000, ratePaise: 30 },
]
async function world() {
const db = openDb(':memory:')
await seedIfEmpty(db) // company state 32, GST18
const c = await createClient(db, 'u1', { name: 'Karapuzha SCB', stateCode: '32' })
const sms = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS', allowedKinds: ['usage'] })
await setUsageRateCard(db, 'u1', sms.id, '2026-04-01', BANDS)
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: sms.id, kind: 'usage' })
return { db, c, sms, cm }
}
describe('generateBulkSmsPurchaseQuote', () => {
it('raises a proforma tagged bulk_sms_topup for the given SMS quantity', async () => {
const { db, cm } = await world()
const doc = await generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 100_000)
expect(doc.docType).toBe('PROFORMA')
expect(doc.source).toBe('bulk_sms_topup')
expect(doc.payload.lines[0]!.qty).toBe(100_000)
expect(doc.payablePaise).toBeGreaterThan(0)
// Persisted correctly — a re-fetch from the DB carries the same tag.
const full = (await getDocument(db, doc.id))!
expect(full.source).toBe('bulk_sms_topup')
})
it('rejects a non-positive or fractional quantity', async () => {
const { db, cm } = await world()
await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 0)).rejects.toThrow()
await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, -5)).rejects.toThrow()
await expect(generateBulkSmsPurchaseQuote(db, 'u1', cm.id, 1.5)).rejects.toThrow()
})
it('rejects an unknown client module', async () => {
const { db } = await world()
await expect(generateBulkSmsPurchaseQuote(db, 'u1', 'nope', 100_000)).rejects.toThrow(/not found/)
})
})
describe('document.source tagging', () => {
it('tags a renewal proforma module_renewal, and a manual draft keeps the default', async () => {
const { db, c } = await world()
// A separate yearly-priced module for the renewal path (usage modules enforce a
// minimum-order qty that a qty:1 renewal line would fail).
const yearly = await createModule(db, 'u1', { code: 'CORE', name: 'Core App', allowedKinds: ['yearly'] })
await setPrice(db, 'u1', { moduleId: yearly.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
const cm2 = await assignModule(db, 'u1', { clientId: c.id, moduleId: yearly.id, kind: 'yearly' })
await updateClientModule(db, 'u1', cm2.id, { nextRenewal: '2026-08-15' })
const renewal = await generateModuleRenewalQuote(db, 'u1', cm2.id)
expect(renewal.source).toBe('module_renewal')
const manual = await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id,
lines: [{ moduleId: yearly.id, qty: 1, kind: 'yearly' }],
})
expect(manual.source).toBe('hq') // default preserved when source is omitted
})
})
Loading…
Cancel
Save