feat(d23): SMS volume-band billing (dated rate card, money locked)

usage_rate_band: a dated per-module rate card of volume bands (generic;
SMS packs the first user). resolveUsageRate picks the band by count
(highest min_qty <= count); below the lowest band = under the minimum
order. buildLines resolves price as: explicit per-line override >
tiered rate card > dated price book — so an SMS line prices by count
automatically, a save under 50,000 is refused, preview warns. Rates are
integer paise (0.40=40p), a new card is a new dated set of rows so issued
invoices keep their rate. Owner routes GET/POST /modules/:id/rate-card.
Tests lock the founder card to the paisa: 50k→₹23,600, 100k→₹43,660,
200k→₹80,240, 300k→₹1,06,200 incl 18% GST. Suite 372 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 13d2b5fabb
commit e91e1741b8

@ -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) {

@ -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

@ -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
);
`,
},
]

@ -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) {

@ -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: 50k0.40, 100k0.37, 200k0.34, 300k0.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<UsageRateResult> {
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<void> {
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<number>()
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<RateCard[]> {
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<string, RateBand[]>()
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 }))
}

@ -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')
})
})
Loading…
Cancel
Save