diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 9eee431..d3f44a9 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -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) => { diff --git a/apps/hq/src/repos-documents.ts b/apps/hq/src/repos-documents.ts index ba02a91..7245131 100644 --- a/apps/hq/src/repos-documents.ts +++ b/apps/hq/src/repos-documents.ts @@ -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 { 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, })) } diff --git a/apps/hq/src/repos-renewals.ts b/apps/hq/src/repos-renewals.ts index a5edc42..e3e8180 100644 --- a/apps/hq/src/repos-renewals.ts +++ b/apps/hq/src/repos-renewals.ts @@ -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 { + 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 }], + }) +} diff --git a/apps/hq/test/bulk-sms-purchase.test.ts b/apps/hq/test/bulk-sms-purchase.test.ts new file mode 100644 index 0000000..fdee684 --- /dev/null +++ b/apps/hq/test/bulk-sms-purchase.test.ts @@ -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 + }) +})