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/packages/billing-engine/test/gst-returns.test.ts

192 lines
9.3 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.

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