You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/test/sms-rate-card.test.ts

106 lines
5.1 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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