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