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 => ({ 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/) }) })