GST (India)
GSTIN capture, HSN/SAC codes, and the GSTR-1 / GSTR-3B filing-format export. The direct-upload path to the GST portal is stubbed pending production credentials.
1. GSTIN validator
src/lib/gstin.ts — two tiny pure functions. This is the shape check
every frontend form should run before sending to the API; it is
not a checksum validator.
Format
27ABCDE1234F1Z5
└┬┘└───┬────┘└┘└┘
│ │ │ │
│ │ │ └── checksum (1 alphanumeric)
│ │ └──── literal 'Z'
│ └────────── entity number (1 alphanumeric)
│ PAN (10 chars: 5 letters, 4 digits, 1 letter)
└── state code (2 digits)
The regex lives at src/lib/gstin.ts:19:
API
normalizeGstin(value)— trim + uppercase; empty/nullish →''.isValidGstin(value)— shape check. Returnsfalseonnull, wrong length, or regex miss.
Checksum is GST portal's job
We deliberately do not validate the code32 checksum client-side — it requires the GSTN code32 table. The GST portal rejects bad checksums at upload. Our job is the form-level sanity check.
2. Party-level GSTIN
Columns added by supabase/migrations/20260418150000_gst_primitives.sql:
| Table | Column | Notes |
|---|---|---|
Customer |
gstNumber |
Optional 15-char GSTIN. |
Supplier |
gstNumber |
Optional 15-char GSTIN. |
Agent |
gstNumber |
Added earlier (migration 20260409130000). |
All optional metadata. No DB CHECK constraint — validation is
client-side via isValidGstin().
3. HSN / SAC on line items
- HSN — Harmonized System of Nomenclature (goods).
- SAC — Services Accounting Code (services).
Columns added to the following tables (gst_primitives.sql):
| Table | Columns |
|---|---|
QuotationLineItem |
hsnCode, sacCode |
SupplierTransaction |
hsnCode, sacCode |
Invoice |
hsnCode, sacCode |
No DB CHECK on HSN XOR SAC
The migration comment calls out that the business rule (typically one or the other) is enforced by the UI, not by a DB CHECK, because existing tables hold mixed goods + services historically.
GroupInvoice and AgentInvoice store line items as JSONB; HSN/SAC
lives inside the JSON payload.
4. GSTR-1 / GSTR-3B payload generator
src/lib/gstReports.ts builds the JSON payloads that the GSTN portal /
offline tool ingests. Schema field names match the GSTN v2 offline-utility.
Sections implemented
| Section | What it is |
|---|---|
b2b |
Registered B2B (buyer has GSTIN) |
b2cl |
B2C-Large — unregistered inter-state, invoice ≥ ₹2,50,000 |
b2cs |
B2C-Small — everything else B2C, aggregated per rate × state |
cdnr |
Credit / debit notes to registered parties |
hsn |
HSN summary aggregated by HSN × rate |
Sections stubbed / out of scope
exp— Exports. Stub placeholders only.cdnur— Credit / debit notes to unregistered parties. Stub.at,atadj,txp,doc_issue,nil— not implemented (we don't have the data).
What the generator DOESN'T do
Three deliberate omissions (src/lib/gstReports.ts:18-24):
- No GSTIN checksum validation. That's the portal's job on upload.
- No HSN lookup. We emit whatever the invoice line carries. Lines without an HSN roll into a blank bucket — UI should warn.
- No reverse-charge handling beyond the section stubs.
5. GSTN portal API client
src/lib/gstnApiClient.ts is a stub. The real upload path needs:
- Production GSTN developer credentials (GSP tier).
- The OTP-based auth handshake.
- Request / response encryption per the GSTN ASP/GSP spec (RSA + AES-ECB + HMAC-SHA256 on each payload).
None of those are implemented. uploadGstr1() and uploadGstr3b() both
throw with a clear "not yet enabled" message so the UI never silently
pretends to file.
Env vars (placeholder names)
| Variable | Purpose |
|---|---|
VITE_GSTN_API_BASE |
https://api.sandbox.gst.gov.in or https://api.gst.gov.in |
VITE_GSTN_CLIENT_ID |
GSP client ID |
VITE_GSTN_CLIENT_SECRET |
GSP client secret (move to Supabase secrets for prod) |
VITE_GSTN_FILER_GSTIN |
Our GSTIN |
VITE_GSTN_DEVICE_ID |
Registered device identifier |
isGstnConfigured() returns true only when all five are set. The UI
uses it to disable the "File to GSTN" button.
TODO list (follow-ups)
- OTP handshake against
/taxpayerapi/v1.3/authenticate. - Session-key derivation (RSA-encrypted ephemeral AES key).
- Encrypt payload before POST.
- Persist session state in a new
GstnSessionSupabase table (noted inline atgstnApiClient.ts:23). - Status polling via the returned
reference_id.
Until then, operators use "Download JSON" in the UI and upload via the GST portal's offline utility.
6. UI
Finance page → Reports tab → GST Returns button
(Finance.tsx:6492, permission finance.reports.gst_summary.view).
The rendered view exposes:
- GSTR-1 JSON download — runs
generateGstr1Payload()fromsrc/lib/gstReports.ts, previews the section counts, offers download. - GSTR-3B JSON download — same flow with
generateGstr3bPayload(). - GST Summary — totals per rate, per state. Backed by
/finance/gst-summary(src/lib/api.ts→handleFinanceGstSummary). - File to GSTN button — disabled unless
isGstnConfigured()is true AND the user holds the relevant permission. Currently always disabled (stub).
7. Endpoints
| Method | Path | Handler | Permission |
|---|---|---|---|
GET |
/finance/gst-summary |
handleFinanceGstSummary (src/lib/api.ts) |
finance.reports.gst_summary.view |
GET |
/finance/gstr-1 |
handleFinanceGstr1 (src/lib/api.ts:24846) |
finance.reports.gst_summary.view |
GET |
/finance/gstr-3b |
handleFinanceGstr3b (src/lib/api.ts:25049) |
finance.reports.gst_summary.view |
gst-summary / gstr-1 / gstr-3b return the computed payload; the
UI wraps them in a download action.
8. Permissions
| Action | Permission |
|---|---|
| View GST Summary / Returns | finance.reports.gst_summary.view |
| Export / download GST JSON | finance.reports.gst_summary.export |
| File to GSTN (future) | finance.reports.gst_summary.export (+ GSTN creds) |
| Create / edit invoices with GST | finance.create / finance.edit |
9. Tests
src/lib/gstin.test.ts— regex + normalisation coverage.src/lib/gstReports.test.ts— section bucketing (b2b vs b2cl vs b2cs), HSN summary aggregation, credit-note back-links.
Run: npx vitest run src/lib/gstin.test.ts src/lib/gstReports.test.ts.