|
|
// 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')
|
|
|
})
|
|
|
})
|