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
parent
13d2b5fabb
commit
e91e1741b8
@ -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<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 }))
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue