|
|
import { describe, expect, it } from 'vitest'
|
|
|
import {
|
|
|
B2CL_THRESHOLD_PAISE, computeGstr1, computeGstr3b, reconcile, taxOf,
|
|
|
type GstDocLine, type GstDocument,
|
|
|
} from '@sims/billing-engine'
|
|
|
|
|
|
/**
|
|
|
* GST-returns engine — hand-computed golden set. All line amounts are stated as integer paise
|
|
|
* so the expected values are transparent, and every amount is a CAPTURED amount (the engine never
|
|
|
* recomputes tax). The centrepiece is the reconciliation test: Σ section taxables (returns
|
|
|
* negative) == Σ source == HSN total == GالسTR-3B outward, and likewise for total tax.
|
|
|
*
|
|
|
* Store state '32'. A registered inter-state buyer sits in state 27.
|
|
|
*/
|
|
|
const STORE = '32'
|
|
|
const CTIN = '27ABCDE1234F1Z0'
|
|
|
const OPTS = { storeStateCode: STORE }
|
|
|
|
|
|
/** A tax-inclusive line's captured amounts. Intra ⇒ pass cgst/sgst; inter ⇒ pass igst. */
|
|
|
function line(
|
|
|
hsn: string, rateBp: number, taxable: number,
|
|
|
t: { cgst?: number; sgst?: number; igst?: number; cess?: number }, qty = 1, unitCode = 'PCS',
|
|
|
): GstDocLine {
|
|
|
return {
|
|
|
hsn, taxRateBp: rateBp, qty, unitCode, taxablePaise: taxable,
|
|
|
cgstPaise: t.cgst ?? 0, sgstPaise: t.sgst ?? 0, igstPaise: t.igst ?? 0, cessPaise: t.cess ?? 0,
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// GST18 net ₹1,180 → taxable 100000, tax 18000. GST12 net ₹1,120 → taxable 100000, tax 12000.
|
|
|
// GST5 net ₹1,050 → taxable 100000, tax 5000. ZERO net ₹1,000 → taxable 100000, no tax.
|
|
|
function docSet(): GstDocument[] {
|
|
|
return [
|
|
|
// 1 — B2B inter-state (buyer in 27), GST18: taxable 100000, IGST 18000.
|
|
|
{ docType: 'SALE', docNo: 'ST1C22026-000001', businessDate: '2026-07-02', buyerGstin: CTIN, placeOfSupply: '27', invoiceValuePaise: 118000, lines: [line('3402', 1800, 100000, { igst: 18000 })] },
|
|
|
// 2 — B2B inter-state, GST12: taxable 100000, IGST 12000 (same buyer → invoiceCount 2).
|
|
|
{ docType: 'SALE', docNo: 'ST1C22026-000002', businessDate: '2026-07-03', buyerGstin: CTIN, placeOfSupply: '27', invoiceValuePaise: 112000, lines: [line('0405', 1200, 100000, { igst: 12000 })] },
|
|
|
// 3 — B2C intra small, GST18: taxable 100000, CGST 9000, SGST 9000.
|
|
|
{ docType: 'SALE', docNo: 'ST1C22026-000003', businessDate: '2026-07-04', placeOfSupply: '32', invoiceValuePaise: 118000, lines: [line('3402', 1800, 100000, { cgst: 9000, sgst: 9000 })] },
|
|
|
// 4 — B2C intra small, GST18 + GST5.
|
|
|
{ docType: 'SALE', docNo: 'ST1C22026-000004', businessDate: '2026-07-05', placeOfSupply: '32', invoiceValuePaise: 223000, lines: [line('3402', 1800, 100000, { cgst: 9000, sgst: 9000 }), line('1905', 500, 100000, { cgst: 2500, sgst: 2500 })] },
|
|
|
// 5 — B2C intra, ZERO-rated: taxable 100000, no tax.
|
|
|
{ docType: 'SALE', docNo: 'ST1C22026-000005', businessDate: '2026-07-06', placeOfSupply: '32', invoiceValuePaise: 100000, lines: [line('1101', 0, 100000, {})] },
|
|
|
// 6 — B2C inter-state LARGE (> ₹2,50,000): taxable 30,000,000, IGST 5,400,000, value 35,400,000.
|
|
|
{ docType: 'SALE', docNo: 'ST1C22026-000006', businessDate: '2026-07-07', placeOfSupply: '27', invoiceValuePaise: 35_400_000, lines: [line('3402', 1800, 30_000_000, { igst: 5_400_000 }, 3)] },
|
|
|
// 7 — B2C intra small RETURN against #4's GST18 line → nets into B2CS (negative).
|
|
|
{ docType: 'SALE_RETURN', docNo: 'CNST1C22026-0001', businessDate: '2026-07-08', placeOfSupply: '32', invoiceValuePaise: 118000, originalDocNo: 'ST1C22026-000004', lines: [line('3402', 1800, 100000, { cgst: 9000, sgst: 9000 })] },
|
|
|
// 8 — B2B RETURN against #1 (buyer 27) → CDNR.
|
|
|
{ docType: 'SALE_RETURN', docNo: 'CNST1C22026-0002', businessDate: '2026-07-09', buyerGstin: CTIN, placeOfSupply: '27', invoiceValuePaise: 118000, originalDocNo: 'ST1C22026-000001', lines: [line('3402', 1800, 100000, { igst: 18000 })] },
|
|
|
]
|
|
|
}
|
|
|
|
|
|
describe('computeGstr1 — section bucketing', () => {
|
|
|
const g1 = computeGstr1(docSet(), OPTS)
|
|
|
|
|
|
it('B2B groups by GSTIN with a rate-wise split and an invoice count', () => {
|
|
|
expect(g1.b2b).toHaveLength(1)
|
|
|
const g = g1.b2b[0]!
|
|
|
expect(g.ctin).toBe(CTIN)
|
|
|
expect(g.invoiceCount).toBe(2)
|
|
|
expect(g.rates.map((r) => r.rateBp)).toEqual([1200, 1800])
|
|
|
expect(g.totals.taxablePaise).toBe(200000)
|
|
|
expect(g.totals.igstPaise).toBe(30000)
|
|
|
expect(g.totals.cgstPaise).toBe(0)
|
|
|
})
|
|
|
|
|
|
it('B2CL captures only the inter-state invoice above ₹2,50,000, per invoice', () => {
|
|
|
expect(g1.b2cl).toHaveLength(1)
|
|
|
const inv = g1.b2cl[0]!
|
|
|
expect(inv.docNo).toBe('ST1C22026-000006')
|
|
|
expect(inv.pos).toBe('27')
|
|
|
expect(inv.invoiceValuePaise).toBe(35_400_000)
|
|
|
expect(inv.totals.taxablePaise).toBe(30_000_000)
|
|
|
expect(inv.totals.igstPaise).toBe(5_400_000)
|
|
|
})
|
|
|
|
|
|
it('B2CS aggregates small B2C by (pos, rate) and is NET of a small return', () => {
|
|
|
// GST18 intra: sold 100000 (#3) + 100000 (#4) − 100000 returned (#7) = 100000 net.
|
|
|
const gst18 = g1.b2cs.find((b) => b.rateBp === 1800 && b.supplyType === 'INTRA')!
|
|
|
expect(gst18.pos).toBe('32')
|
|
|
expect(gst18.taxablePaise).toBe(100000)
|
|
|
expect(gst18.cgstPaise).toBe(9000)
|
|
|
expect(gst18.sgstPaise).toBe(9000)
|
|
|
// GST5 and ZERO buckets are untouched by returns.
|
|
|
expect(g1.b2cs.find((b) => b.rateBp === 500)!.taxablePaise).toBe(100000)
|
|
|
expect(g1.b2cs.find((b) => b.rateBp === 0)!.taxablePaise).toBe(100000)
|
|
|
})
|
|
|
|
|
|
it('CDNR reverses a registered credit note at the original rate', () => {
|
|
|
expect(g1.cdnr).toHaveLength(1)
|
|
|
const n = g1.cdnr[0]!
|
|
|
expect(n.ctin).toBe(CTIN)
|
|
|
expect(n.originalDocNo).toBe('ST1C22026-000001')
|
|
|
expect(n.totals.taxablePaise).toBe(100000)
|
|
|
expect(n.totals.igstPaise).toBe(18000)
|
|
|
expect(g1.cdnur).toHaveLength(0)
|
|
|
})
|
|
|
|
|
|
it('HSN summary nets sales and returns by (hsn, rate)', () => {
|
|
|
const h = g1.hsn.find((r) => r.hsn === '3402' && r.rateBp === 1800)!
|
|
|
// taxable: 100000(#1)+100000(#3)+100000(#4)+30,000,000(#6) − 100000(#7) − 100000(#8).
|
|
|
expect(h.taxablePaise).toBe(30_100_000)
|
|
|
expect(h.igstPaise).toBe(5_400_000)
|
|
|
expect(h.cgstPaise).toBe(9000)
|
|
|
expect(h.qty).toBe(4)
|
|
|
expect(g1.totals.hsn.taxablePaise).toBe(30_400_000)
|
|
|
})
|
|
|
|
|
|
it('documents-issued lists invoice and credit-note ranges with counts', () => {
|
|
|
const inv = g1.docs.find((d) => d.natureOfDocument === 'Invoices for outward supply')!
|
|
|
expect(inv.totalNumber).toBe(6)
|
|
|
expect(inv.fromDocNo).toBe('ST1C22026-000001')
|
|
|
expect(inv.toDocNo).toBe('ST1C22026-000006')
|
|
|
const cdn = g1.docs.find((d) => d.natureOfDocument === 'Credit notes')!
|
|
|
expect(cdn.totalNumber).toBe(2)
|
|
|
})
|
|
|
})
|
|
|
|
|
|
describe('computeGstr3b — 3.1(a) outward, net of credit notes', () => {
|
|
|
const g3 = computeGstr3b(docSet())
|
|
|
it('outward taxable and tax are sales minus returns at captured rates', () => {
|
|
|
expect(g3.salesTax.taxablePaise).toBe(30_600_000)
|
|
|
expect(g3.returnsTax.taxablePaise).toBe(200000)
|
|
|
expect(g3.outward.taxablePaise).toBe(30_400_000)
|
|
|
expect(g3.outward.igstPaise).toBe(5_412_000) // 84000 sales − 18000 return
|
|
|
expect(g3.outward.cgstPaise).toBe(11500)
|
|
|
expect(g3.outward.sgstPaise).toBe(11500)
|
|
|
expect(taxOf(g3.outward)).toBe(5_435_000)
|
|
|
})
|
|
|
it('ITC block is an explicit stub (follow-up)', () => {
|
|
|
expect(g3.itc.igstPaise).toBe(0)
|
|
|
expect(g3.itc.note).toMatch(/follow-up/i)
|
|
|
})
|
|
|
})
|
|
|
|
|
|
describe('reconcile — the compliance gate', () => {
|
|
|
it('GSTR-1 sections == source == HSN == GSTR-3B, taxable and tax', () => {
|
|
|
const docs = docSet()
|
|
|
const g1 = computeGstr1(docs, OPTS)
|
|
|
const g3 = computeGstr3b(docs)
|
|
|
const r = reconcile(docs, g1, g3)
|
|
|
expect(r.ok).toBe(true)
|
|
|
expect(r.mismatches).toEqual([])
|
|
|
expect(r.sourceTaxablePaise).toBe(30_400_000)
|
|
|
expect(r.gstr1NetTaxablePaise).toBe(30_400_000)
|
|
|
expect(r.hsnTaxablePaise).toBe(30_400_000)
|
|
|
expect(r.gstr3bTaxablePaise).toBe(30_400_000)
|
|
|
expect(r.sourceTaxPaise).toBe(5_435_000)
|
|
|
expect(r.gstr1NetTaxPaise).toBe(5_435_000)
|
|
|
expect(r.hsnTaxPaise).toBe(5_435_000)
|
|
|
expect(r.gstr3bTaxPaise).toBe(5_435_000)
|
|
|
})
|
|
|
|
|
|
it('flags a mismatch loudly if a section total is wrong (guard against silent drift)', () => {
|
|
|
const docs = docSet()
|
|
|
const g1 = computeGstr1(docs, OPTS)
|
|
|
const g3 = computeGstr3b(docs)
|
|
|
// Corrupt a section total the way a bug would; reconcile must catch it.
|
|
|
g1.totals.net.taxablePaise += 1
|
|
|
const r = reconcile(docs, g1, g3)
|
|
|
expect(r.ok).toBe(false)
|
|
|
expect(r.mismatches.some((m) => m.includes('GSTR-1 net taxable'))).toBe(true)
|
|
|
})
|
|
|
})
|
|
|
|
|
|
describe('B2CL / CDNUR threshold behaviour', () => {
|
|
|
it('a B2C inter-state invoice BELOW the threshold is B2CS, not B2CL', () => {
|
|
|
const docs: GstDocument[] = [{
|
|
|
docType: 'SALE', docNo: 'ST1C22026-000010', businessDate: '2026-07-02',
|
|
|
placeOfSupply: '27', invoiceValuePaise: B2CL_THRESHOLD_PAISE, // exactly at threshold ⇒ NOT above
|
|
|
lines: [line('3402', 1800, 100000, { igst: 18000 })],
|
|
|
}]
|
|
|
const g1 = computeGstr1(docs, OPTS)
|
|
|
expect(g1.b2cl).toHaveLength(0)
|
|
|
const bucket = g1.b2cs.find((b) => b.supplyType === 'INTER')!
|
|
|
expect(bucket.pos).toBe('27')
|
|
|
expect(bucket.igstPaise).toBe(18000)
|
|
|
})
|
|
|
|
|
|
it('a large inter-state B2C return becomes CDNUR', () => {
|
|
|
const docs: GstDocument[] = [{
|
|
|
docType: 'SALE_RETURN', docNo: 'CNST1C22026-0009', businessDate: '2026-07-08',
|
|
|
placeOfSupply: '27', invoiceValuePaise: 35_400_000, originalDocNo: 'ST1C22026-000006',
|
|
|
lines: [line('3402', 1800, 30_000_000, { igst: 5_400_000 }, 3)],
|
|
|
}]
|
|
|
const g1 = computeGstr1(docs, OPTS)
|
|
|
expect(g1.cdnr).toHaveLength(0)
|
|
|
expect(g1.cdnur).toHaveLength(1)
|
|
|
expect(g1.cdnur[0]!.totals.igstPaise).toBe(5_400_000)
|
|
|
})
|
|
|
})
|