diff --git a/README.md b/README.md index 91586ef..d20ae90 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,58 @@ getStats(); --- +## GST calculation utilities + +Pure, zero-dependency helpers for GST tax math. These are **rate-agnostic** — you pass the GST rate in (automatic rate lookup from HSN codes is tracked in a separate enhancement). + +### `calculateTax(taxableAmount, rate)` +```js +calculateTax(10000, 18); +// { taxableAmount: 10000, rate: 18, taxAmount: 1800, total: 11800 } +``` + +### `calculateGSTBreakdown(taxableAmount, gstRate, options?)` +Splits GST into CGST/SGST (intra-state) or IGST (inter-state), with optional cess. +```js +calculateGSTBreakdown(10000, 18); +// { taxableAmount: 10000, cgst: 900, sgst: 900, igst: 0, cess: 0, totalTax: 1800, grandTotal: 11800 } + +calculateGSTBreakdown(10000, 28, { isInterState: true, cessRate: 25 }); +// { ..., igst: 2800, cess: 2500, totalTax: 5300, grandTotal: 15300 } +``` + +### `reverseCalculateTax(grandTotal, rate)` +Extracts base + tax from a tax-inclusive amount. +```js +reverseCalculateTax(11800, 18); +// { grandTotal: 11800, rate: 18, taxableAmount: 10000, taxAmount: 1800 } +``` + +### `getApplicableTaxType(supplierStateCode, placeOfSupplyStateCode)` +```js +getApplicableTaxType('33', '33'); // 'CGST_SGST' +getApplicableTaxType('33', '29'); // 'IGST' +``` + +### `calculateInvoiceTotals(items, isInterState)` +Sums line items into invoice totals with GST round-off. +```js +calculateInvoiceTotals([ + { taxableValue: 10000, gstRate: 18 }, + { taxableValue: 5000, gstRate: 12 } +], false); +// { totalTaxableValue: 15000, totalCGST: 1200, totalSGST: 1200, totalIGST: 0, +// totalCess: 0, totalTax: 2400, grandTotal: 17400, roundOff: 0 } +``` + +### `groupItemsByTaxRate(items)` +Groups line items by GST rate (useful for GSTR-1 summaries). + +### `applyRoundOffRules(amount)` +Rounds a monetary amount to 2 decimals (half-up). + +--- + ## TypeScript Type definitions are bundled. No `@types/` package needed. diff --git a/gst.js b/gst.js new file mode 100644 index 0000000..27d385c --- /dev/null +++ b/gst.js @@ -0,0 +1,196 @@ +'use strict'; + +/** + * GST calculation utilities — pure, rate-agnostic helpers. + * + * These functions do NOT look up tax rates from HSN codes (that depends on + * rate data being added to the dataset — see issue #2). You pass the rate in. + */ + +function _assertNumber(value, name) { + if (typeof value !== 'number' || Number.isNaN(value)) { + throw new TypeError(`${name} must be a number, got ${value}`); + } + if (value < 0) { + throw new RangeError(`${name} must not be negative, got ${value}`); + } +} + +/** + * Rounds a monetary amount to 2 decimal places using standard half-up rounding. + * GST invoices are expressed to 2 decimals. + * @param {number} amount + * @returns {number} + */ +function applyRoundOffRules(amount) { + _assertNumber(amount, 'amount'); + return Math.round((amount + Number.EPSILON) * 100) / 100; +} + +/** + * Calculates tax on a taxable amount at a given rate. + * @param {number} taxableAmount + * @param {number} rate - percentage, e.g. 18 for 18% + * @returns {{ taxableAmount: number, rate: number, taxAmount: number, total: number }} + */ +function calculateTax(taxableAmount, rate) { + _assertNumber(taxableAmount, 'taxableAmount'); + _assertNumber(rate, 'rate'); + const taxAmount = applyRoundOffRules((taxableAmount * rate) / 100); + return { + taxableAmount: applyRoundOffRules(taxableAmount), + rate, + taxAmount, + total: applyRoundOffRules(taxableAmount + taxAmount) + }; +} + +/** + * Splits GST into CGST/SGST (intra-state) or IGST (inter-state) plus optional cess. + * @param {number} taxableAmount + * @param {number} gstRate - total GST percentage (e.g. 18) + * @param {{ isInterState?: boolean, cessRate?: number }} [options] + * @returns {{ taxableAmount: number, cgst: number, sgst: number, igst: number, cess: number, totalTax: number, grandTotal: number }} + */ +function calculateGSTBreakdown(taxableAmount, gstRate, options) { + _assertNumber(taxableAmount, 'taxableAmount'); + _assertNumber(gstRate, 'gstRate'); + + const { isInterState = false, cessRate = 0 } = options || {}; + _assertNumber(cessRate, 'cessRate'); + + let cgst = 0; + let sgst = 0; + let igst = 0; + + if (isInterState) { + igst = applyRoundOffRules((taxableAmount * gstRate) / 100); + } else { + cgst = applyRoundOffRules((taxableAmount * (gstRate / 2)) / 100); + sgst = applyRoundOffRules((taxableAmount * (gstRate / 2)) / 100); + } + + const cess = applyRoundOffRules((taxableAmount * cessRate) / 100); + const totalTax = applyRoundOffRules(cgst + sgst + igst + cess); + const grandTotal = applyRoundOffRules(taxableAmount + totalTax); + + return { + taxableAmount: applyRoundOffRules(taxableAmount), + cgst, + sgst, + igst, + cess, + totalTax, + grandTotal + }; +} + +/** + * Extracts the base amount and tax from a tax-inclusive total. + * @param {number} grandTotal - amount that already includes tax + * @param {number} rate - percentage, e.g. 18 + * @returns {{ grandTotal: number, rate: number, taxableAmount: number, taxAmount: number }} + */ +function reverseCalculateTax(grandTotal, rate) { + _assertNumber(grandTotal, 'grandTotal'); + _assertNumber(rate, 'rate'); + const taxableAmount = applyRoundOffRules((grandTotal * 100) / (100 + rate)); + const taxAmount = applyRoundOffRules(grandTotal - taxableAmount); + return { + grandTotal: applyRoundOffRules(grandTotal), + rate, + taxableAmount, + taxAmount + }; +} + +/** + * Determines whether a supply attracts IGST or CGST+SGST. + * @param {string} supplierStateCode - 2-digit GST state code, e.g. '33' + * @param {string} placeOfSupplyStateCode - 2-digit GST state code, e.g. '29' + * @returns {'IGST' | 'CGST_SGST'} + */ +function getApplicableTaxType(supplierStateCode, placeOfSupplyStateCode) { + if (!supplierStateCode || !placeOfSupplyStateCode) { + throw new TypeError('Both supplierStateCode and placeOfSupplyStateCode are required'); + } + return String(supplierStateCode).trim() === String(placeOfSupplyStateCode).trim() + ? 'CGST_SGST' + : 'IGST'; +} + +/** + * Calculates totals for an invoice of line items. + * Each item: { taxableValue, gstRate, cessRate? } + * @param {Array<{ taxableValue: number, gstRate: number, cessRate?: number }>} items + * @param {boolean} isInterState + * @returns {{ totalTaxableValue: number, totalCGST: number, totalSGST: number, totalIGST: number, totalCess: number, totalTax: number, grandTotal: number, roundOff: number }} + */ +function calculateInvoiceTotals(items, isInterState) { + if (!Array.isArray(items)) { + throw new TypeError(`items must be an array, got ${typeof items}`); + } + + const acc = { + totalTaxableValue: 0, + totalCGST: 0, + totalSGST: 0, + totalIGST: 0, + totalCess: 0, + totalTax: 0 + }; + + for (const item of items) { + const breakdown = calculateGSTBreakdown(item.taxableValue, item.gstRate, { + isInterState: !!isInterState, + cessRate: item.cessRate || 0 + }); + acc.totalTaxableValue += breakdown.taxableAmount; + acc.totalCGST += breakdown.cgst; + acc.totalSGST += breakdown.sgst; + acc.totalIGST += breakdown.igst; + acc.totalCess += breakdown.cess; + acc.totalTax += breakdown.totalTax; + } + + const rawGrandTotal = acc.totalTaxableValue + acc.totalTax; + const grandTotal = applyRoundOffRules(Math.round(rawGrandTotal)); + const roundOff = applyRoundOffRules(grandTotal - rawGrandTotal); + + return { + totalTaxableValue: applyRoundOffRules(acc.totalTaxableValue), + totalCGST: applyRoundOffRules(acc.totalCGST), + totalSGST: applyRoundOffRules(acc.totalSGST), + totalIGST: applyRoundOffRules(acc.totalIGST), + totalCess: applyRoundOffRules(acc.totalCess), + totalTax: applyRoundOffRules(acc.totalTax), + grandTotal, + roundOff + }; +} + +/** + * Groups invoice line items by their GST rate (useful for GSTR-1 summaries). + * @param {Array<{ gstRate: number }>} items + * @returns {Object} keyed by rate + */ +function groupItemsByTaxRate(items) { + if (!Array.isArray(items)) { + throw new TypeError(`items must be an array, got ${typeof items}`); + } + return items.reduce((groups, item) => { + const key = String(item.gstRate); + (groups[key] = groups[key] || []).push(item); + return groups; + }, {}); +} + +module.exports = { + applyRoundOffRules, + calculateTax, + calculateGSTBreakdown, + reverseCalculateTax, + getApplicableTaxType, + calculateInvoiceTotals, + groupItemsByTaxRate +}; diff --git a/index.d.ts b/index.d.ts index 37c7151..a3a6cd2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -43,3 +43,84 @@ export function searchHsn(query: string, options?: SearchOptions): HsnCode[]; /** Returns metadata about the bundled dataset (version, date, totals). */ export function getStats(): HsnStats; + +// ── GST calculation utilities (issue #3) ──────────────────────────────────── + +export interface TaxResult { + taxableAmount: number; + rate: number; + taxAmount: number; + total: number; +} + +export interface GSTBreakdown { + taxableAmount: number; + cgst: number; + sgst: number; + igst: number; + cess: number; + totalTax: number; + grandTotal: number; +} + +export interface ReverseTaxResult { + grandTotal: number; + rate: number; + taxableAmount: number; + taxAmount: number; +} + +export interface InvoiceLineItem { + taxableValue: number; + gstRate: number; + cessRate?: number; +} + +export interface InvoiceTotals { + totalTaxableValue: number; + totalCGST: number; + totalSGST: number; + totalIGST: number; + totalCess: number; + totalTax: number; + grandTotal: number; + roundOff: number; +} + +export interface GSTBreakdownOptions { + isInterState?: boolean; + cessRate?: number; +} + +/** Rounds a monetary amount to 2 decimal places (half-up). */ +export function applyRoundOffRules(amount: number): number; + +/** Calculates tax on a taxable amount at a given percentage rate. */ +export function calculateTax(taxableAmount: number, rate: number): TaxResult; + +/** Splits GST into CGST/SGST (intra-state) or IGST (inter-state) plus optional cess. */ +export function calculateGSTBreakdown( + taxableAmount: number, + gstRate: number, + options?: GSTBreakdownOptions +): GSTBreakdown; + +/** Extracts the base amount and tax from a tax-inclusive total. */ +export function reverseCalculateTax(grandTotal: number, rate: number): ReverseTaxResult; + +/** Determines whether a supply attracts IGST or CGST+SGST based on state codes. */ +export function getApplicableTaxType( + supplierStateCode: string, + placeOfSupplyStateCode: string +): 'IGST' | 'CGST_SGST'; + +/** Calculates totals for an invoice of line items. */ +export function calculateInvoiceTotals( + items: InvoiceLineItem[], + isInterState: boolean +): InvoiceTotals; + +/** Groups invoice line items by their GST rate (useful for GSTR-1 summaries). */ +export function groupItemsByTaxRate( + items: T[] +): Record; diff --git a/index.js b/index.js index 75d9181..bf9e2df 100644 --- a/index.js +++ b/index.js @@ -120,6 +120,8 @@ function getStats() { return require(path.join(__dirname, 'data', 'metadata.json')); } +const gst = require('./gst'); + module.exports = { getAllHsn, getCodeByTxt, @@ -128,5 +130,13 @@ module.exports = { isValidHsnCode, getHsnChapter, searchHsn, - getStats + getStats, + // GST calculation utilities (issue #3) + applyRoundOffRules: gst.applyRoundOffRules, + calculateTax: gst.calculateTax, + calculateGSTBreakdown: gst.calculateGSTBreakdown, + reverseCalculateTax: gst.reverseCalculateTax, + getApplicableTaxType: gst.getApplicableTaxType, + calculateInvoiceTotals: gst.calculateInvoiceTotals, + groupItemsByTaxRate: gst.groupItemsByTaxRate }; diff --git a/package.json b/package.json index 199475e..9f49f34 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "hsn-code-package", - "version": "2.0.0", - "description": "Fast, zero-runtime-dependency HSN code lookup. Search 12,000+ codes by description or code number, validate HSN codes, and browse by chapter.", + "version": "2.1.0", + "description": "Fast, zero-runtime-dependency HSN code lookup with GST calculation utilities. Search 12,000+ codes, validate HSN codes, browse by chapter, and compute CGST/SGST/IGST.", "main": "index.js", "types": "index.d.ts", "files": [ "index.js", + "gst.js", "index.d.ts", "data/" ], diff --git a/tests/gst.test.js b/tests/gst.test.js new file mode 100644 index 0000000..3974803 --- /dev/null +++ b/tests/gst.test.js @@ -0,0 +1,136 @@ +'use strict'; + +const { + applyRoundOffRules, + calculateTax, + calculateGSTBreakdown, + reverseCalculateTax, + getApplicableTaxType, + calculateInvoiceTotals, + groupItemsByTaxRate +} = require('../gst'); + +describe('applyRoundOffRules', () => { + test('rounds to 2 decimals (half-up)', () => { + expect(applyRoundOffRules(10.005)).toBe(10.01); + expect(applyRoundOffRules(10.004)).toBe(10); + expect(applyRoundOffRules(250)).toBe(250); + }); + test('throws on non-number', () => { + expect(() => applyRoundOffRules('x')).toThrow(TypeError); + }); + test('throws on negative', () => { + expect(() => applyRoundOffRules(-1)).toThrow(RangeError); + }); +}); + +describe('calculateTax', () => { + test('computes tax and total', () => { + const r = calculateTax(10000, 18); + expect(r.taxAmount).toBe(1800); + expect(r.total).toBe(11800); + expect(r.rate).toBe(18); + }); + test('handles zero rate', () => { + const r = calculateTax(500, 0); + expect(r.taxAmount).toBe(0); + expect(r.total).toBe(500); + }); + test('throws on invalid input', () => { + expect(() => calculateTax(null, 18)).toThrow(TypeError); + expect(() => calculateTax(100, 'x')).toThrow(TypeError); + }); +}); + +describe('calculateGSTBreakdown', () => { + test('intra-state splits into CGST + SGST', () => { + const r = calculateGSTBreakdown(10000, 18); + expect(r.cgst).toBe(900); + expect(r.sgst).toBe(900); + expect(r.igst).toBe(0); + expect(r.totalTax).toBe(1800); + expect(r.grandTotal).toBe(11800); + }); + test('inter-state uses IGST', () => { + const r = calculateGSTBreakdown(10000, 18, { isInterState: true }); + expect(r.igst).toBe(1800); + expect(r.cgst).toBe(0); + expect(r.sgst).toBe(0); + expect(r.grandTotal).toBe(11800); + }); + test('applies cess', () => { + const r = calculateGSTBreakdown(10000, 28, { isInterState: true, cessRate: 25 }); + expect(r.igst).toBe(2800); + expect(r.cess).toBe(2500); + expect(r.totalTax).toBe(5300); + expect(r.grandTotal).toBe(15300); + }); +}); + +describe('reverseCalculateTax', () => { + test('extracts base and tax from inclusive total', () => { + const r = reverseCalculateTax(11800, 18); + expect(r.taxableAmount).toBe(10000); + expect(r.taxAmount).toBe(1800); + }); + test('round trip with calculateTax', () => { + const fwd = calculateTax(2500, 12); + const rev = reverseCalculateTax(fwd.total, 12); + expect(rev.taxableAmount).toBe(2500); + }); +}); + +describe('getApplicableTaxType', () => { + test('same state -> CGST_SGST', () => { + expect(getApplicableTaxType('33', '33')).toBe('CGST_SGST'); + }); + test('different state -> IGST', () => { + expect(getApplicableTaxType('33', '29')).toBe('IGST'); + }); + test('throws when a code is missing', () => { + expect(() => getApplicableTaxType('33')).toThrow(TypeError); + }); +}); + +describe('calculateInvoiceTotals', () => { + const items = [ + { taxableValue: 10000, gstRate: 18 }, + { taxableValue: 5000, gstRate: 12 } + ]; + test('sums intra-state invoice', () => { + const t = calculateInvoiceTotals(items, false); + expect(t.totalTaxableValue).toBe(15000); + expect(t.totalCGST).toBe(1200); + expect(t.totalSGST).toBe(1200); + expect(t.totalIGST).toBe(0); + expect(t.totalTax).toBe(2400); + }); + test('sums inter-state invoice', () => { + const t = calculateInvoiceTotals(items, true); + expect(t.totalIGST).toBe(2400); + expect(t.totalCGST).toBe(0); + }); + test('produces a round-off and integer grand total', () => { + const t = calculateInvoiceTotals([{ taxableValue: 100.5, gstRate: 18 }], false); + expect(Number.isInteger(t.grandTotal)).toBe(true); + expect(typeof t.roundOff).toBe('number'); + }); + test('throws on non-array', () => { + expect(() => calculateInvoiceTotals('x', false)).toThrow(TypeError); + }); +}); + +describe('groupItemsByTaxRate', () => { + test('groups by gstRate', () => { + const grouped = groupItemsByTaxRate([ + { gstRate: 18, name: 'a' }, + { gstRate: 12, name: 'b' }, + { gstRate: 18, name: 'c' } + ]); + expect(grouped['18']).toHaveLength(2); + expect(grouped['12']).toHaveLength(1); + }); + test('throws on non-array', () => { + expect(() => groupItemsByTaxRate(null)).toThrow(TypeError); + }); +});