diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 485630a..e611e37 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -59,6 +59,7 @@ import { } from './repos-aws' import { pullMonthlyCosts } from './aws-costs' import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports' +import { listUsageRateCards, setUsageRateCard } from './repos-rate-card' import { commitImport, stageCsv, verificationReport } from './import-apex' import { documentHtml, documentHtmlSample } from './templates' import { renderPdf } from './pdf' @@ -376,6 +377,30 @@ export function apiRouter( res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) + // D23 quantity-tiered rate card (SMS packs and future usage-billed modules). + r.get('/modules/:id/rate-card', requireAuth, async (req, res) => { + const id = String(req.params['id'] ?? '') + if ((await getModule(db, id)) === null) { + res.status(404).json({ ok: false, error: 'Module not found' }); return + } + res.json({ ok: true, rateCards: await listUsageRateCards(db, id) }) + }) + r.post('/modules/:id/rate-card', requireAuth, requireOwner, async (req, res) => { + const id = String(req.params['id'] ?? '') + if ((await getModule(db, id)) === null) { + res.status(404).json({ ok: false, error: 'Module not found' }); return + } + try { + const body = req.body as { effectiveFrom?: unknown; bands?: unknown } + if (typeof body.effectiveFrom !== 'string' || !Array.isArray(body.bands)) { + throw new Error('Provide effectiveFrom (YYYY-MM-DD) and bands [{ minQty, ratePaise }]') + } + await setUsageRateCard(db, staffId(res), id, body.effectiveFrom, body.bands as { minQty: number; ratePaise: number }[]) + res.json({ ok: true, rateCards: await listUsageRateCards(db, id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) r.post('/modules/:id/prices', requireAuth, requireOwner, async (req, res) => { const id = String(req.params['id'] ?? '') if ((await getModule(db, id)) === null) { diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index f7503a7..6a7f140 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -105,6 +105,15 @@ CREATE TABLE IF NOT EXISTS module_price_book ( id TEXT PRIMARY KEY, module_id TEXT NOT NULL, edition TEXT NOT NULL DEFAULT 'standard', kind TEXT NOT NULL, price_paise INTEGER NOT NULL, effective_from TEXT NOT NULL ); +-- D23 quantity-tiered pricing (SMS packs and any future usage-billed module). A dated +-- "rate card" = all bands sharing (module_id, effective_from). A purchase of N units +-- resolves to the band with the highest min_qty <= N; N below the lowest min_qty is +-- under the minimum order. Rates are integer paise per unit; a new card is a new dated +-- set of rows, never an edit (config over code — old invoices keep their old rate). +CREATE TABLE IF NOT EXISTS usage_rate_band ( + id TEXT PRIMARY KEY, module_id TEXT NOT NULL, effective_from TEXT NOT NULL, + min_qty INTEGER NOT NULL, rate_paise INTEGER NOT NULL +); CREATE TABLE IF NOT EXISTS client_module ( id TEXT PRIMARY KEY, client_id TEXT NOT NULL, module_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'quoted' CHECK (status IN diff --git a/apps/hq/src/migrations-pg.ts b/apps/hq/src/migrations-pg.ts index 3dfa859..6e893f5 100644 --- a/apps/hq/src/migrations-pg.ts +++ b/apps/hq/src/migrations-pg.ts @@ -215,6 +215,15 @@ CREATE TABLE IF NOT EXISTS ticket ( ALTER TABLE module ADD COLUMN IF NOT EXISTS field_spec text NOT NULL DEFAULT '[]'; ALTER TABLE client_module ADD COLUMN IF NOT EXISTS field_values text NOT NULL DEFAULT '{}'; ALTER TABLE client_module ADD COLUMN IF NOT EXISTS secrets_enc text; +`, + }, + { + id: '004-usage-rate-band', + sql: ` +CREATE TABLE IF NOT EXISTS usage_rate_band ( + id text PRIMARY KEY, module_id text NOT NULL, effective_from text NOT NULL, + min_qty bigint NOT NULL, rate_paise bigint NOT NULL +); `, }, ] diff --git a/apps/hq/src/repos-documents.ts b/apps/hq/src/repos-documents.ts index 22e4692..98f28a8 100644 --- a/apps/hq/src/repos-documents.ts +++ b/apps/hq/src/repos-documents.ts @@ -4,6 +4,7 @@ import { writeAudit } from './audit' import type { DB } from './db' import { getClient } from './repos-clients' import { getModule, priceOn, type Kind } from './repos-modules' +import { resolveUsageRate } from './repos-rate-card' import { dismissQuoteFollowups, getNumberSetting } from './repos-reminders' import { nextDocNo } from './series' @@ -179,8 +180,24 @@ async function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warn const mod = await getModule(db, line.moduleId) if (mod === null) throw new Error(`Module not found: ${line.moduleId}`) const edition = line.edition ?? 'standard' - let unitPricePaise = line.unitPricePaise - ?? await priceOn(db, line.moduleId, line.kind, edition, onDate) + // Price resolution order: (1) an explicit per-line override always wins (special deal); + // (2) a quantity-tiered rate card if the module has one (D23 — SMS packs price by count); + // (3) the dated price book. A tiered module below its minimum order blocks the save. + let unitPricePaise = line.unitPricePaise ?? null + if (unitPricePaise === null) { + const tiered = await resolveUsageRate(db, line.moduleId, line.qty, onDate) + if (tiered === null) { + unitPricePaise = await priceOn(db, line.moduleId, line.kind, edition, onDate) + } else if (tiered.kind === 'below_min') { + if (warnings === undefined) { + throw new Error(`${mod.code}: minimum order is ${tiered.minQty.toLocaleString('en-IN')} units — enter at least that many`) + } + warnings.push(`${mod.code}: below the ${tiered.minQty.toLocaleString('en-IN')} minimum order`) + unitPricePaise = 0 + } else { + unitPricePaise = tiered.ratePaise + } + } if (unitPricePaise === null) { // Strict (no warnings sink): refuse — same message save has always thrown. if (warnings === undefined) { diff --git a/apps/hq/src/repos-rate-card.ts b/apps/hq/src/repos-rate-card.ts new file mode 100644 index 0000000..923cee6 --- /dev/null +++ b/apps/hq/src/repos-rate-card.ts @@ -0,0 +1,88 @@ +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import type { DB } from './db' + +/** + * Quantity-tiered pricing (D23) — a dated per-module "rate card" of volume bands. + * SMS packs are the first user: 50k→₹0.40, 100k→₹0.37, 200k→₹0.34, 300k→₹0.30 per SMS, + * with 50,000 the minimum order. Generic on purpose — any usage-billed module can have + * one. Rates are integer paise per unit; a new card is a new dated set of rows (config + * over code), so issued invoices keep the rate they were billed at. + */ + +export interface RateBand { minQty: number; ratePaise: number } + +export type UsageRateResult = + | { kind: 'rate'; ratePaise: number } + | { kind: 'below_min'; minQty: number } + | null // no rate card for this module/date — caller falls back to the price book + +/** + * Resolve the per-unit rate for `qty` units of a module on `onDate`. Picks the latest + * dated card, then the band with the highest `min_qty <= qty`. Returns null when the + * module has no card (not usage-tiered); `below_min` when qty is under the minimum order. + */ +export async function resolveUsageRate( + db: DB, moduleId: string, qty: number, onDate: string, +): Promise { + const card = await db.get<{ effective_from: string }>( + `SELECT effective_from FROM usage_rate_band + WHERE module_id=? AND effective_from<=? ORDER BY effective_from DESC LIMIT 1`, + moduleId, onDate, + ) + if (card === undefined) return null + const bands = await db.all<{ min_qty: number; rate_paise: number }>( + `SELECT min_qty, rate_paise FROM usage_rate_band + WHERE module_id=? AND effective_from=? ORDER BY min_qty`, + moduleId, card.effective_from, + ) + const minQty = bands[0]!.min_qty + if (qty < minQty) return { kind: 'below_min', minQty } + let chosen = bands[0]! + for (const b of bands) if (b.min_qty <= qty) chosen = b + return { kind: 'rate', ratePaise: chosen.rate_paise } +} + +/** Replace (or create) the dated card for (module, effectiveFrom). Owner-gated at the route. */ +export async function setUsageRateCard( + db: DB, userId: string, moduleId: string, effectiveFrom: string, bands: RateBand[], +): Promise { + if (bands.length === 0) throw new Error('A rate card needs at least one band') + if (!/^\d{4}-\d{2}-\d{2}$/.test(effectiveFrom)) throw new Error('effectiveFrom must be YYYY-MM-DD') + const sorted = [...bands].sort((a, b) => a.minQty - b.minQty) + const seen = new Set() + for (const b of sorted) { + if (!Number.isInteger(b.minQty) || b.minQty <= 0) throw new Error('min_qty must be a positive integer') + if (seen.has(b.minQty)) throw new Error(`duplicate band min_qty: ${b.minQty}`) + seen.add(b.minQty) + if (!Number.isInteger(b.ratePaise) || b.ratePaise < 0) throw new Error('rate_paise must be a non-negative integer (paise)') + } + await db.transaction(async () => { + await db.run(`DELETE FROM usage_rate_band WHERE module_id=? AND effective_from=?`, moduleId, effectiveFrom) + for (const b of sorted) { + await db.run( + `INSERT INTO usage_rate_band (id, module_id, effective_from, min_qty, rate_paise) VALUES (?, ?, ?, ?, ?)`, + uuidv7(), moduleId, effectiveFrom, b.minQty, b.ratePaise, + ) + } + await writeAudit(db, userId, 'set_rate_card', 'module', moduleId, undefined, { effectiveFrom, bands: sorted }) + }) +} + +export interface RateCard { effectiveFrom: string; bands: RateBand[] } + +/** All dated cards for a module, newest first. */ +export async function listUsageRateCards(db: DB, moduleId: string): Promise { + const rows = await db.all<{ effective_from: string; min_qty: number; rate_paise: number }>( + `SELECT effective_from, min_qty, rate_paise FROM usage_rate_band + WHERE module_id=? ORDER BY effective_from DESC, min_qty`, + moduleId, + ) + const byDate = new Map() + for (const r of rows) { + const list = byDate.get(r.effective_from) ?? [] + list.push({ minQty: r.min_qty, ratePaise: r.rate_paise }) + byDate.set(r.effective_from, list) + } + return [...byDate.entries()].map(([effectiveFrom, bands]) => ({ effectiveFrom, bands })) +} diff --git a/apps/hq/test/sms-rate-card.test.ts b/apps/hq/test/sms-rate-card.test.ts new file mode 100644 index 0000000..dcb92c4 --- /dev/null +++ b/apps/hq/test/sms-rate-card.test.ts @@ -0,0 +1,105 @@ +// apps/hq/test/sms-rate-card.test.ts — D23: SMS volume-band pricing, money locked to the paisa. +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule } from '../src/repos-modules' +import { resolveUsageRate, setUsageRateCard, listUsageRateCards } from '../src/repos-rate-card' +import { createDraft, issueDocument, getDocument, prepareDraft } 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) + return { db, c, sms } +} + +describe('SMS volume-band resolution (D23)', () => { + it('picks the band by count and enforces the minimum order', async () => { + const { db, sms } = await world() + const rate = async (n: number) => resolveUsageRate(db, sms.id, n, '2026-07-16') + expect(await rate(50_000)).toEqual({ kind: 'rate', ratePaise: 40 }) + expect(await rate(75_000)).toEqual({ kind: 'rate', ratePaise: 40 }) // in [50k,100k) + expect(await rate(100_000)).toEqual({ kind: 'rate', ratePaise: 37 }) + expect(await rate(150_000)).toEqual({ kind: 'rate', ratePaise: 37 }) + expect(await rate(200_000)).toEqual({ kind: 'rate', ratePaise: 34 }) + expect(await rate(300_000)).toEqual({ kind: 'rate', ratePaise: 30 }) + expect(await rate(500_000)).toEqual({ kind: 'rate', ratePaise: 30 }) // >= top band + expect(await rate(49_999)).toEqual({ kind: 'below_min', minQty: 50_000 }) + // A module with no card is not usage-tiered. + const other = await createModule(db, 'u1', { code: 'X', name: 'X' }) + expect(await resolveUsageRate(db, other.id, 100_000, '2026-07-16')).toBeNull() + }) + + it('resolves by business date — an older card stays in force before the new one', async () => { + const { db, sms } = await world() + await setUsageRateCard(db, 'u1', sms.id, '2027-04-01', [{ minQty: 50_000, ratePaise: 45 }]) + expect(await resolveUsageRate(db, sms.id, 50_000, '2026-12-31')).toEqual({ kind: 'rate', ratePaise: 40 }) + expect(await resolveUsageRate(db, sms.id, 50_000, '2027-05-01')).toEqual({ kind: 'rate', ratePaise: 45 }) + }) +}) + +describe('SMS billing through the engine (money locked)', () => { + const cases = [ + { count: 50_000, taxable: 20_000_00, total: 23_600_00 }, + { count: 100_000, taxable: 37_000_00, total: 43_660_00 }, + { count: 200_000, taxable: 68_000_00, total: 80_240_00 }, + { count: 300_000, taxable: 90_000_00, total: 1_06_200_00 }, + ] + for (const tc of cases) { + it(`${tc.count.toLocaleString('en-IN')} SMS → ₹${tc.taxable / 100} + 18% GST = ₹${tc.total / 100}`, async () => { + const { db, c, sms } = await world() + const draft = await createDraft(db, 'u1', { + docType: 'INVOICE', clientId: c.id, + lines: [{ moduleId: sms.id, qty: tc.count, kind: 'usage' }], + }) + const inv = await issueDocument(db, 'u1', draft.id) + const full = (await getDocument(db, inv.id))! + expect(full.taxablePaise).toBe(tc.taxable) + expect(full.payablePaise).toBe(tc.total) + // Intra-state (client 32 = company 32): CGST + SGST split, IGST zero. + expect(full.cgstPaise + full.sgstPaise).toBe(tc.total - tc.taxable) + expect(full.igstPaise).toBe(0) + }) + } + + it('a save below the minimum order is refused; preview warns instead of throwing', async () => { + const { db, c, sms } = await world() + await expect(createDraft(db, 'u1', { + docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: sms.id, qty: 10_000, kind: 'usage' }], + })).rejects.toThrow(/minimum order is 50,000/) + const prev = await prepareDraft(db, { + docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: sms.id, qty: 10_000, kind: 'usage' }], + }, { permissive: true }) + expect(prev.warnings.some((w) => /minimum/.test(w))).toBe(true) + expect(prev.totals.payablePaise).toBe(0) + }) + + it('an explicit per-line unit price overrides the card (special deal)', async () => { + const { db, c, sms } = await world() + const draft = await createDraft(db, 'u1', { + docType: 'INVOICE', clientId: c.id, + lines: [{ moduleId: sms.id, qty: 100_000, kind: 'usage', unitPricePaise: 35 }], // ₹0.35 special + }) + const inv = await issueDocument(db, 'u1', draft.id) + expect((await getDocument(db, inv.id))!.taxablePaise).toBe(35_000_00) // 100k × 0.35, not the 0.37 band + }) + + it('lists dated cards newest first', async () => { + const { db, sms } = await world() + const cards = await listUsageRateCards(db, sms.id) + expect(cards).toHaveLength(1) + expect(cards[0]!.bands).toHaveLength(4) + expect(cards[0]!.effectiveFrom).toBe('2026-04-01') + }) +})