Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .github/workflows/update-gst-rates.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: Update GST Rates

on:
schedule:
- cron: '0 8 * * 1' # Every Monday 8 AM UTC
workflow_dispatch:
inputs:
force_update:
description: 'Force update even if no changes detected'
required: false
default: 'false'
type: boolean

jobs:
update-rates:
runs-on: ubuntu-latest
permissions:
contents: write
issues: write

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Run GST rates update script
id: update
run: |
node scripts/update-gst-rates.js
continue-on-error: true

- name: Check for changes
id: check
run: |
if git diff --quiet data/gst_rates.json data/metadata.json 2>/dev/null; then
echo "changed=false" >> $GITHUB_OUTPUT
else
echo "changed=true" >> $GITHUB_OUTPUT
fi

- name: Commit and push if changed
if: steps.check.outputs.changed == 'true' || github.event.inputs.force_update == 'true'
run: |
git config user.name "hsn-rate-bot"
git config user.email "bot@hsn-code-package.dev"
git add data/gst_rates.json data/metadata.json
git commit -m "chore: update GST rates [$(date -u '+%Y-%m-%d')]"
git push origin HEAD

- name: Open issue on failure
if: steps.update.outcome == 'failure'
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[Bot] GST rates update failed on ${new Date().toISOString().split('T')[0]}`,
body: `The weekly GST rates update job failed.\n\nWorkflow run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}\n\nPlease check the logs and manually run the update if needed.`,
labels: ['bug', 'automated']
});
182 changes: 182 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,188 @@ Rounds a monetary amount to 2 decimals (half-up).

---

## Advanced HSN lookups

### `getChapterSummary(chapter)`
Returns a summary for a chapter (code count, range, and the full list), or `null` if the chapter has no codes.

```js
const { getChapterSummary } = require('hsn-code-package');

getChapterSummary('52');
// {
// chapter: '52',
// totalCodes: 120,
// codeRange: { from: '52010011', to: '52129990' },
// codes: [ { code, description }, ... ]
// }
```

### `findCodesByDescription(keywords)`
Returns codes whose description contains **all** of the supplied keywords (AND match).

```js
const { findCodesByDescription } = require('hsn-code-package');

findCodesByDescription(['cotton', 'carded']);
// All codes mentioning both "cotton" and "carded"
```

### `bulkValidateHsnCodes(codes)`
Validates many codes at once.

```js
const { bulkValidateHsnCodes } = require('hsn-code-package');

bulkValidateHsnCodes(['52010011', '00000000']);
// {
// valid: ['52010011'],
// invalid: ['00000000'],
// summary: { total: 2, validCount: 1, invalidCount: 1 }
// }
```

---

## GSTIN & PAN validation

```js
const {
validateGSTIN, formatGSTIN, getStateFromGSTIN, isValidPAN, getGSTINComponents
} = require('hsn-code-package');
```

### `validateGSTIN(gstin)`
Validates a GSTIN structurally and via its MOD-36 checksum. Returns a result object.

```js
validateGSTIN('27AAPFU0939F1ZV');
// {
// isValid: true,
// stateCode: '27',
// stateName: 'Maharashtra',
// panNumber: 'AAPFU0939F',
// entityNumber: '1',
// checkDigit: 'V'
// }

validateGSTIN('27AAPFU0939F1Z'); // { isValid: false, error: 'GSTIN must be 15 characters, got 14' }
```

### `isValidPAN(pan)`
Returns `true` for a structurally valid PAN (`AAAAA0000A`).

```js
isValidPAN('AAPFU0939F'); // true
isValidPAN('12345'); // false
```

### `formatGSTIN(gstin)` / `getStateFromGSTIN(gstin)` / `getGSTINComponents(gstin)`

```js
formatGSTIN(' 27aapfu0939f1zv '); // '27AAPFU0939F1ZV'
getStateFromGSTIN('27AAPFU0939F1ZV'); // 'Maharashtra'
getGSTINComponents('27AAPFU0939F1ZV');
// { stateCode: '27', stateName: 'Maharashtra', pan: 'AAPFU0939F', entityNumber: '1', checkDigit: 'V' }
```

---

## GST rate data

The package ships pre-built GST rate data sourced from **CBIC Notification No. 09/2025-CT(Rate)** (effective 22 Sep 2025). Rates are kept up-to-date via a weekly automated job.

### `getGstRateByCode(code)`
Returns the GST rate for an exact HSN code, or `null` if no rate is available.

```js
getGstRateByCode('52010011');
// {
// code: '52010011',
// igstRate: 5,
// cgstRate: 2.5,
// sgstRate: 2.5,
// cessRate: 0,
// effectiveFrom: '2025-09-22',
// notificationRef: 'Notification No. 09/2025-CT(Rate)'
// }
```

### `getHsnByExactCodeWithRate(code)`
Returns the HSN entry merged with its GST rate data.

```js
getHsnByExactCodeWithRate('52010011');
// { code: '52010011', description: '...', igstRate: 5, cgstRate: 2.5, ... }
```

### `getHsnByRateSlabs(igstRate)`
Returns all HSN codes under a given IGST rate slab.

```js
getHsnByRateSlabs(5); // All items taxed at 5% IGST
getHsnByRateSlabs(18); // All items taxed at 18% IGST
```

### Automated rate updates

A GitHub Actions workflow runs every Monday at 08:00 UTC to check for and apply any CBIC rate changes. If the update fails, a GitHub issue is automatically opened.

To manually trigger an update: **Actions → Update GST Rates → Run workflow**.

---

## SAC codes (services)

SAC (Services Accounting Code) lookups, backed by `data/sac_codes.json`.

```js
const { getAllSac, getSacByCode, searchSac, getCodeDetails } = require('hsn-code-package');

getSacByCode('9954'); // { code: '9954', description: 'Construction services' }
searchSac('education', { limit: 5 });
getCodeDetails('9954'); // { code, description, type: 'SAC' }
getCodeDetails('52010011');// { code, description, type: 'HSN' }
```

---

## Export utilities

```js
const { exportToCSV, exportToJSON, generateGSTR1Summary } = require('hsn-code-package');

exportToCSV([{ code: '52010011', description: 'COTTON' }]);
// "code,description\n52010011,COTTON"

exportToJSON(getSacByCode('9954'), { pretty: true });

generateGSTR1Summary([
{ taxableValue: 10000, gstRate: 18, isInterState: false },
{ taxableValue: 3000, gstRate: 12, isInterState: false }
]);
// Tax-rate-wise breakdown with cgst/sgst/igst/cess/totalTax
```

---

## CLI

A `hsn` command is installed with the package (or run via `npx hsn`).

```bash
hsn search cotton --limit 10
hsn validate 52010011
hsn chapter 52
hsn stats
hsn gstin 27AAPFU0939F1ZV
hsn sac education --limit 5
hsn export silk --format csv
hsn help
```

---

## TypeScript

Type definitions are bundled. No `@types/` package needed.
Expand Down
134 changes: 134 additions & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env node
'use strict';

const {
searchHsn, getHsnByExactCode, isValidHsnCode, getStats,
getChapterSummary
} = require('../index');
const { validateGSTIN, getGSTINComponents } = require('../gstin');
const { searchSac } = require('../sac');
const { exportToCSV } = require('../export');

const args = process.argv.slice(2);
const command = args[0];

function bold(s) { return `\x1b[1m${s}\x1b[0m`; }
function green(s) { return `\x1b[32m${s}\x1b[0m`; }
function red(s) { return `\x1b[31m${s}\x1b[0m`; }
function dim(s) { return `\x1b[2m${s}\x1b[0m`; }

function printHelp() {
console.log(`
${bold('hsn')} — HSN/SAC code lookup and GST utilities

${bold('USAGE')}
hsn search <query> [--limit N] [--type contains|startsWith|exact]
hsn validate <code>
hsn chapter <chapter>
hsn stats
hsn gstin <gstin>
hsn sac <query> [--limit N]
hsn export <query> [--format csv|json]
hsn help
`);
}

function getFlag(name, defaultVal) {
const idx = args.indexOf(name);
if (idx === -1) return defaultVal;
return args[idx + 1];
}

switch (command) {
case 'search': {
const query = args[1];
if (!query) { console.error('Usage: hsn search <query>'); process.exit(1); }
const limit = parseInt(getFlag('--limit', '20'), 10);
const matchType = getFlag('--type', 'contains');
const results = searchHsn(query, { limit, matchType });
if (results.length === 0) { console.log(red('No results found.')); break; }
console.log(bold(`Found ${results.length} results for "${query}":\n`));
results.forEach(r => console.log(` ${green(r.code.padEnd(12))} ${r.description}`));
break;
}
case 'validate': {
const code = args[1];
if (!code) { console.error('Usage: hsn validate <code>'); process.exit(1); }
if (isValidHsnCode(code)) {
const entry = getHsnByExactCode(code);
console.log(green(`✓ Valid HSN code: ${code}`));
console.log(` Description: ${entry.description}`);
} else {
console.log(red(`✗ Invalid HSN code: ${code}`));
}
break;
}
case 'chapter': {
const ch = args[1];
if (!ch) { console.error('Usage: hsn chapter <chapter>'); process.exit(1); }
const summary = getChapterSummary(ch);
if (!summary) { console.log(red(`No codes found for chapter ${ch}`)); break; }
console.log(bold(`Chapter ${summary.chapter}: ${summary.totalCodes} codes\n`));
summary.codes.slice(0, 20).forEach(r => console.log(` ${green(r.code.padEnd(12))} ${r.description}`));
if (summary.totalCodes > 20) console.log(dim(` ... and ${summary.totalCodes - 20} more`));
break;
}
case 'stats': {
const s = getStats();
console.log(bold('\nHSN Dataset Stats'));
console.log(` Version: ${s.version}`);
console.log(` Last Updated: ${s.lastUpdated}`);
console.log(` Total Codes: ${s.totalCodes}`);
console.log(` Chapters: ${s.chapterCount}`);
console.log(` Source: ${s.source}\n`);
break;
}
case 'gstin': {
const gstin = args[1];
if (!gstin) { console.error('Usage: hsn gstin <gstin>'); process.exit(1); }
const result = validateGSTIN(gstin);
if (result.isValid) {
const c = getGSTINComponents(gstin);
console.log(green(`✓ Valid GSTIN: ${gstin.toUpperCase()}`));
console.log(` State: ${c.stateName} (${c.stateCode})`);
console.log(` PAN: ${c.pan}`);
console.log(` Entity Number: ${c.entityNumber}`);
console.log(` Check Digit: ${c.checkDigit}`);
} else {
console.log(red(`✗ Invalid GSTIN: ${result.error}`));
}
break;
}
case 'sac': {
const query = args[1];
if (!query) { console.error('Usage: hsn sac <query>'); process.exit(1); }
const limit = parseInt(getFlag('--limit', '20'), 10);
const results = searchSac(query, { limit });
if (results.length === 0) { console.log(red('No SAC results found.')); break; }
console.log(bold(`Found ${results.length} SAC results for "${query}":\n`));
results.forEach(r => console.log(` ${green(r.code.padEnd(10))} ${r.description}`));
break;
}
case 'export': {
const query = args[1];
if (!query) { console.error('Usage: hsn export <query>'); process.exit(1); }
const fmt = getFlag('--format', 'csv');
const results = searchHsn(query, { limit: 0 });
if (fmt === 'json') {
console.log(JSON.stringify(results, null, 2));
} else {
console.log(exportToCSV(results));
}
break;
}
case 'help':
case '--help':
case '-h':
case undefined:
printHelp();
break;
default:
console.error(`Unknown command: ${command}`);
printHelp();
process.exit(1);
}
1 change: 1 addition & 0 deletions data/gst_rates.json

Large diffs are not rendered by default.

Loading
Loading