How GST Is Calculated on an Invoice: CGST, SGST, IGST and the Code Behind BillBuddy
When I built BillBuddy, a GST invoicing app for small businesses in India, the most important code turned out to be one small file, gst.ts. It turns a list of line items into the numbers printed on a tax invoice. Every one of those numbers has a rule behind it, and getting any of them wrong produces an invoice your customer's accountant will send back.
This article walks through those rules in the order the code applies them: line totals, discounts, tax-inclusive prices, the CGST/SGST versus IGST decision, the HSN summary, rounding and the amount in words. The examples use the real function from the app, so the figures are exactly what BillBuddy prints.
Note: this is an explanation of invoice arithmetic, not tax advice. Rates depend on the HSN/SAC code of each product. Check the current notifications on the GST portal or with your CA for your specific items.
Step 1: From quantity and rate to taxable value
Each line on an invoice starts with quantity × rate. A line discount is applied to that gross amount, and what remains is the taxable value: the amount GST is charged on. In code it is three lines:
const gross = qty * rate;
const discountAmt = (gross * discountPct) / 100;
const net = gross - discountAmt;
The discount has to come off before tax. GST is levied on the transaction value, and a discount shown on the invoice at the time of sale reduces that value. Applying the discount after tax is a common bug in hand-rolled billing spreadsheets, and it overstates both the tax and the total.
Step 2: Prices that already include GST
Retail shops usually think in MRP: the ₹1,180 on the tag already includes tax. The invoice still has to show the taxable value and the tax separately, so the app works backwards. If the price includes tax at rate r, the taxable value is the price divided by (1 + r/100), and the tax is whatever is left over:
const taxable = r2(pricesIncludeTax ? net / (1 + rate / 100) : net);
const tax = r2(pricesIncludeTax ? net - taxable : (taxable * rate) / 100);
For a ₹1,180 item at 18%, that gives a taxable value of ₹1,000 and tax of ₹180, which is easy to check by hand. A ₹999 item at 18% gives ₹846.61 and ₹152.39. Notice that the tax is calculated as net - taxable, not as taxable × 18%. Both are "correct", but after rounding the second can be off by a paisa, and then taxable plus tax no longer equals the price on the tag. Subtracting guarantees the two parts always add back up to the MRP the customer actually paid.
Step 3: CGST + SGST, or IGST?
This is the rule that confuses people most. India's GST has three components that matter on an ordinary invoice:
- CGST (Central GST) and SGST (State GST) are charged together on an intra-state supply, when the seller and the place of supply are in the same state. The rate is split equally: 18% becomes 9% CGST + 9% SGST.
- IGST (Integrated GST) is charged on an inter-state supply, at the full rate: 18% IGST.
The customer pays the same total either way. What changes is which government receives the tax, which is why the split matters for both parties' returns. The decision depends on the place of supply, not on where the buyer's office happens to be. BillBuddy stores the two-digit GST state code for the business (27 for Maharashtra, 29 for Karnataka, and so on) and asks for the place of supply on each invoice. The comparison is one line:
const interstate = !!doc.placeOfSupply && !!businessState
&& doc.placeOfSupply !== businessState;
Those two-digit codes are the same ones that start every GSTIN, so a business registered in Maharashtra has a GSTIN beginning with 27. Using codes instead of state names avoids a whole category of bugs: "Delhi" vs "NCT of Delhi", or "Orissa" vs "Odisha", stop mattering.
A worked example
Suppose a business in Maharashtra bills two items:
- 10 keyboards (HSN 8471) at ₹1,200 each, with a 5% discount, taxed at 18%
- 3 notebooks (HSN 4820) at ₹450 each, taxed at 5%
| Figure | Place of supply: Maharashtra | Place of supply: Karnataka |
|---|---|---|
| Subtotal (before discount) | ₹13,350.00 | ₹13,350.00 |
| Discount | ₹600.00 | ₹600.00 |
| Taxable value | ₹12,750.00 | ₹12,750.00 |
| CGST | ₹1,059.75 | – |
| SGST | ₹1,059.75 | – |
| IGST | – | ₹2,119.50 |
| Round off | +₹0.50 | +₹0.50 |
| Grand total | ₹14,870.00 | ₹14,870.00 |
The keyboards come to ₹12,000, less ₹600 discount, for a taxable ₹11,400 and ₹2,052 tax at 18%. The notebooks are ₹1,350 taxable with ₹67.50 tax at 5%. Total tax: ₹2,119.50. Same customer, same goods, same total; only the tax columns change with the place of supply.
Step 4: The HSN-wise tax summary
A proper tax invoice shows a summary grouped by HSN code and rate, which is what the buyer's accountant uses when matching your invoice in their returns. The app builds it with a map keyed on hsn|rate, so two lines with the same HSN code but different rates (which does happen) stay in separate rows:
const key = `${l.hsn}|${l.gstRate}`;
const row = rows.get(key) ?? { hsn: l.hsn, rate: l.gstRate, taxable: 0, cgst: 0, sgst: 0, igst: 0 };
row.taxable = r2(row.taxable + c.taxable);
if (interstate) row.igst = r2(row.igst + c.tax);
else {
row.cgst = r2(row.cgst + c.tax / 2);
row.sgst = r2(row.sgst + c.tax / 2);
}
For the example above, that produces two rows: 8471 at 18% (₹11,400 taxable, ₹1,026 CGST, ₹1,026 SGST) and 4820 at 5% (₹1,350 taxable, ₹33.75 each).
Step 5: Rounding, and why 0.1 + 0.2 is a billing problem
JavaScript numbers are binary floating point. In a browser console, 0.1 + 0.2 gives 0.30000000000000004, and Math.round(1.005 * 100) / 100 gives 1 rather than 1.01, because 1.005 is stored as 1.00499999…. On a 40-line invoice, those tiny errors add up to a total that is a paisa off from the sum of its lines.
BillBuddy rounds every intermediate money value to two decimals with a small helper that nudges the number by Number.EPSILON first, which fixes the 1.005 case:
export const r2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100;
There is one more detail in the CGST/SGST split. Halving an odd number of paise can't be done evenly, so the app computes CGST as half the tax and SGST as the rest (totalTax - cgst). That way the two always add up to the total tax, even when the split can't be exactly equal.
Finally, invoices in India are customarily rounded to the nearest rupee. The app keeps the exact figure, rounds the grand total, and prints the difference as a separate "Round off" line (+₹0.50 in the example). Showing the round-off explicitly means anyone can re-add the invoice and land on the same number.
Step 6: Indian number formatting and the amount in words
Two small features matter a lot to people who read invoices every day. First, amounts use Indian digit grouping: ₹12,34,567.50 rather than ₹1,234,567.50. The browser's Intl API can do this with the en-IN locale, but BillBuddy uses a small formatter of its own so the output doesn't depend on each device's locale support. It splits off the last three digits, then groups the rest in pairs with a regular expression.
Second, the amount in words uses lakh and crore. The function peels the number apart into crore, lakh, thousand and the last three digits, and spells each part:
amountInWords(1234567.5)
// "Rupees Twelve Lakh Thirty Four Thousand Five Hundred
// Sixty Seven and Fifty Paise Only"
The crore part calls the same function recursively, so very large amounts like "One Hundred Twenty Crore" still come out right.
Which GST rates the app offers, and why
On 22 September 2025 GST rates were rationalised. The 12% and 28% slabs were removed for nearly all goods, most items moved to 5% or 18%, and a 40% rate was introduced for luxury and "sin" goods. BillBuddy's rate list is [0, 3, 5, 12, 18, 28, 40]. The 3% rate covers gold and jewellery. The 12% and 28% entries stay because the app also has to reopen, reprint and correct invoices issued before the change. Removing them would silently change old documents. The rate is stored on each line item, so changing your product catalogue never rewrites an invoice you have already sent.
Numbering invoices by financial year
Invoice numbers must be unique and sequential within a financial year. India's financial year runs April to March, so BillBuddy's counters are keyed on the year label, and numbers look like INV/2026-27/0001. On 1 April the key changes to 2027-28 and numbering restarts at 0001 automatically, without anyone remembering to reset it. Quotations (QT/) and receipts (RCT/) have their own independent series.
What I'd tell anyone writing their own billing code
- Keep the maths in pure functions.
calcDoc()takes a document and a state code and returns numbers. It has no UI and no storage, which makes it trivial to test, and the same function feeds the editor, the PDF and the dashboard. - Round at every step, the same way, in one place. One helper, used everywhere.
- Derive, don't store. Invoice totals and payment status (paid, partly paid, overdue) are recalculated from line items and receipts each time. A stored total can drift out of sync with its lines; a computed one can't.
- Make every figure re-checkable. Showing the discount, the HSN summary and the round-off line means a customer can verify the invoice with a calculator. That builds more trust than any design polish.
If you run a small business, BillBuddy is free to use in the browser and keeps all data on your own device. If you're a developer building something similar, I hope this walkthrough saves you some debugging.