|
|
import { describe, expect, it } from 'vitest'
|
|
|
import {
|
|
|
allocateProRata, computeBill, resolveTaxClass,
|
|
|
type LineInput, type TaxClassRow,
|
|
|
} from '@sims/billing-engine'
|
|
|
|
|
|
// Rates as dated config — includes a GST-2.0-style change on 2025-09-22.
|
|
|
const RATES: TaxClassRow[] = [
|
|
|
{ classCode: 'STD18', ratePctBp: 1800, cessPctBp: 0, effectiveFrom: '2017-07-01' },
|
|
|
{ classCode: 'ZERO', ratePctBp: 0, cessPctBp: 0, effectiveFrom: '2017-07-01' },
|
|
|
{ classCode: 'FMCG', ratePctBp: 1200, cessPctBp: 0, effectiveFrom: '2017-07-01', effectiveTo: '2025-09-21' },
|
|
|
{ classCode: 'FMCG', ratePctBp: 500, cessPctBp: 0, effectiveFrom: '2025-09-22' },
|
|
|
{ classCode: 'DEMERIT', ratePctBp: 2800, cessPctBp: 1200, effectiveFrom: '2017-07-01' },
|
|
|
]
|
|
|
|
|
|
const INTRA = {
|
|
|
businessDate: '2026-07-09',
|
|
|
supplyStateCode: '32',
|
|
|
placeOfSupplyStateCode: '32',
|
|
|
roundToRupee: true,
|
|
|
}
|
|
|
|
|
|
const line = (over: Partial<LineInput>): LineInput => ({
|
|
|
itemId: 'i1',
|
|
|
name: 'Item',
|
|
|
hsn: '1101',
|
|
|
qty: 1,
|
|
|
unitCode: 'PCS',
|
|
|
unitPricePaise: 10_000,
|
|
|
priceIncludesTax: false,
|
|
|
taxClassCode: 'STD18',
|
|
|
...over,
|
|
|
})
|
|
|
|
|
|
describe('resolveTaxClass — dated rates', () => {
|
|
|
it('resolves the rate in force on the business date', () => {
|
|
|
expect(resolveTaxClass(RATES, 'FMCG', '2025-09-21').ratePctBp).toBe(1200)
|
|
|
expect(resolveTaxClass(RATES, 'FMCG', '2025-09-22').ratePctBp).toBe(500)
|
|
|
})
|
|
|
it('fails loudly when no rate covers the date', () => {
|
|
|
expect(() => resolveTaxClass(RATES, 'MISSING', '2026-01-01')).toThrow(/No tax rate/)
|
|
|
})
|
|
|
})
|
|
|
|
|
|
describe('computeBill — golden cases (exact paise)', () => {
|
|
|
it('A: MRP-inclusive B2C intra-state, 18% — sticker total is preserved', () => {
|
|
|
// ₹285 incl. 18%: taxable = round(28500·10000/11800) = 24153, tax = 4347
|
|
|
const { lines, totals } = computeBill(
|
|
|
[line({ unitPricePaise: 28_500, priceIncludesTax: true, mrpPaise: 28_500 })],
|
|
|
INTRA,
|
|
|
RATES,
|
|
|
)
|
|
|
expect(lines[0]).toMatchObject({
|
|
|
taxablePaise: 24_153,
|
|
|
cgstPaise: 2_173,
|
|
|
sgstPaise: 2_174,
|
|
|
igstPaise: 0,
|
|
|
lineTotalPaise: 28_500,
|
|
|
})
|
|
|
expect(totals.payablePaise).toBe(28_500)
|
|
|
expect(totals.roundOffPaise).toBe(0)
|
|
|
expect(totals.savingsVsMrpPaise).toBe(0)
|
|
|
})
|
|
|
|
|
|
it('B: tax-exclusive B2B inter-state — IGST, no split', () => {
|
|
|
const ctx = { ...INTRA, placeOfSupplyStateCode: '27', roundToRupee: false }
|
|
|
const { lines, totals } = computeBill([line({})], ctx, RATES)
|
|
|
expect(lines[0]).toMatchObject({
|
|
|
taxablePaise: 10_000,
|
|
|
igstPaise: 1_800,
|
|
|
cgstPaise: 0,
|
|
|
sgstPaise: 0,
|
|
|
lineTotalPaise: 11_800,
|
|
|
})
|
|
|
expect(totals.payablePaise).toBe(11_800)
|
|
|
})
|
|
|
|
|
|
it('C: fractional weighed qty at 0% — 1.25 kg × ₹32/kg', () => {
|
|
|
const { lines } = computeBill(
|
|
|
[line({ qty: 1.25, unitCode: 'KG', unitPricePaise: 3_200, taxClassCode: 'ZERO', priceIncludesTax: true })],
|
|
|
INTRA,
|
|
|
RATES,
|
|
|
)
|
|
|
expect(lines[0]!.lineTotalPaise).toBe(4_000)
|
|
|
expect(lines[0]!.taxablePaise).toBe(4_000)
|
|
|
})
|
|
|
|
|
|
it('D: bill discount allocates pro-rata (largest remainder), taxes recompute per line', () => {
|
|
|
const { lines, totals } = computeBill(
|
|
|
[
|
|
|
line({ unitPricePaise: 28_500, priceIncludesTax: true }),
|
|
|
line({ itemId: 'i2', unitPricePaise: 4_000, taxClassCode: 'ZERO', priceIncludesTax: true }),
|
|
|
],
|
|
|
{ ...INTRA, billDiscountPaise: 1_000 },
|
|
|
RATES,
|
|
|
)
|
|
|
// shares of 1000 over weights 28500:4000 → 877 + 123
|
|
|
expect(lines[0]!.discountPaise).toBe(877)
|
|
|
expect(lines[1]!.discountPaise).toBe(123)
|
|
|
// line 1 net 27623 incl 18% → taxable 23409, tax 4214 split 2107/2107
|
|
|
expect(lines[0]).toMatchObject({ taxablePaise: 23_409, cgstPaise: 2_107, sgstPaise: 2_107 })
|
|
|
expect(totals.payablePaise).toBe(31_500)
|
|
|
expect(totals.discountPaise).toBe(1_000)
|
|
|
})
|
|
|
|
|
|
it('E: rupee round-off — ₹99.50 + 18% = ₹117.41 → payable ₹117.00', () => {
|
|
|
const { totals } = computeBill([line({ unitPricePaise: 9_950 })], INTRA, RATES)
|
|
|
expect(totals.roundOffPaise).toBe(-41)
|
|
|
expect(totals.payablePaise).toBe(11_700)
|
|
|
})
|
|
|
|
|
|
it('F: cess rides on top and splits out exactly (28% + 12% cess)', () => {
|
|
|
const { lines } = computeBill([line({ taxClassCode: 'DEMERIT' })], INTRA, RATES)
|
|
|
expect(lines[0]).toMatchObject({
|
|
|
taxablePaise: 10_000,
|
|
|
cessPaise: 1_200,
|
|
|
cgstPaise: 1_400,
|
|
|
sgstPaise: 1_400,
|
|
|
lineTotalPaise: 14_000,
|
|
|
})
|
|
|
})
|
|
|
|
|
|
it('G: savings vs MRP reported when selling below sticker', () => {
|
|
|
const { totals } = computeBill(
|
|
|
[line({ unitPricePaise: 27_000, priceIncludesTax: true, mrpPaise: 28_500 })],
|
|
|
INTRA,
|
|
|
RATES,
|
|
|
)
|
|
|
expect(totals.savingsVsMrpPaise).toBe(1_500)
|
|
|
})
|
|
|
|
|
|
it('rejects empty bills, non-positive qty, and negative discounts', () => {
|
|
|
expect(() => computeBill([], INTRA, RATES)).toThrow(/empty/)
|
|
|
expect(() => computeBill([line({ qty: 0 })], INTRA, RATES)).toThrow(/positive/)
|
|
|
expect(() =>
|
|
|
computeBill([line({ discount: { kind: 'amount', paise: -5 } })], INTRA, RATES),
|
|
|
).toThrow(/non-negative/)
|
|
|
})
|
|
|
})
|
|
|
|
|
|
describe('allocateProRata', () => {
|
|
|
it('always sums exactly to the amount', () => {
|
|
|
for (const [amount, weights] of [
|
|
|
[1000, [28_500, 4_000]],
|
|
|
[999, [1, 1, 1]],
|
|
|
[7, [100, 200, 300, 400]],
|
|
|
] as const) {
|
|
|
const shares = allocateProRata(amount, [...weights])
|
|
|
expect(shares.reduce((a, s) => a + s, 0)).toBe(amount)
|
|
|
}
|
|
|
})
|
|
|
it('refuses to drop money on zero total weight', () => {
|
|
|
expect(() => allocateProRata(100, [0, 0])).toThrow(/zero total weight/)
|
|
|
})
|
|
|
})
|