On this page
- 01The rule: AI reads, code checks
- 02Step 1: make the AI copy, not think
- 03Step 2: one call through OpenRouter
- 04Step 3: the GSTIN check digit
- 05Step 4: the 10 checks
- 06Step 5: a person reviews, the server re-checks
- 07Step 6: one row in Google Sheets
- 08How I tested it
- 09What it doesn't do
- 10If you want this for your bills
In a lot of Indian businesses, someone's job is to take every purchase bill, type it into Excel or the accounting software, and check it by eye. Supplier name, GSTIN, invoice number, every line, the tax, the total. It's slow, it's boring, and a wrong total or the wrong type of GST can get all the way to payment before anyone notices.
So I built a small tool for it. You upload a GST purchase invoice, as a phone photo, a scan or a PDF. An AI model reads it. Then plain code runs 10 checks on it, and one click adds a row to Google Sheets, with any problem written next to it.
The rule: AI reads, code checks
The tempting version is to send the bill to an AI and ask "is this invoice correct?" I don't build it that way. A model asked to judge an invoice will sound sure of itself, and it can quietly "fix" a wrong total instead of telling you about it. You also can't test it: the same bill can get a different answer tomorrow.
So the work is split. The AI only copies what's printed into a fixed JSON shape. Every GST rule is ordinary code: same bill, same answer, every time, and when a bill fails you can read exactly why.
- 01Service
Upload the bill
Photo, scan or PDF, up to 15 MB. Code checks the file type and size.
- 02AI
AI reads it
Copies supplier, GSTIN, lines, tax and totals into fixed JSON. Never calculates.
- 03Your code
Code runs 10 checks
GSTIN check digit, GST type, line maths, tax, total, date, duplicates.
- 04Service
A person reviews
Problems are shown first. Any field can be corrected, and the checks re-run.
- 05Data
Row in Google Sheets
One row per bill, with what failed and what was corrected by hand.
Step 1: make the AI copy, not think
The model gets a JSON schema and must answer in exactly that shape. Every field that might not be printed is nullable, so the model has an honest way to say "not on the bill" instead of guessing.
The instructions matter as much as the schema. The key line is: if the printed total is wrong, return the wrong total. The whole point is to catch mistakes, so the reader must not correct them.
const text = { type: ["string", "null"] };
const money = { type: ["number", "null"] };
const party = {
type: "object",
properties: { name: text, gstin: text, address: text },
required: ["name", "gstin", "address"],
additionalProperties: false,
};
export const INVOICE_SCHEMA = {
type: "object",
properties: {
supplier: party,
buyer: party,
invoice_number: text,
invoice_date: { type: ["string", "null"], description: "YYYY-MM-DD" },
place_of_supply: { type: ["string", "null"], description: "As printed, e.g. 29-Karnataka" },
line_items: {
type: "array",
items: {
type: "object",
properties: {
description: text,
hsn: { type: ["string", "null"], description: "HSN or SAC code" },
quantity: money,
rate: { type: ["number", "null"], description: "Price per unit before tax" },
taxable_value: { type: ["number", "null"], description: "Line amount before tax" },
gst_rate: { type: ["number", "null"], description: "Total GST % for the line, e.g. 18 (not 9 + 9)" },
},
required: ["description", "hsn", "quantity", "rate", "taxable_value", "gst_rate"],
additionalProperties: false,
},
},
taxable_total: money,
cgst: money,
sgst: { type: ["number", "null"], description: "SGST or UTGST amount" },
igst: money,
cess: money,
round_off: { type: ["number", "null"], description: "Negative if rounded down" },
grand_total: money,
confidence: { type: "string", enum: ["high", "medium", "low"] },
unclear_fields: { type: "array", items: { type: "string" }, description: "Fields you could not read clearly" },
},
required: [
"supplier", "buyer", "invoice_number", "invoice_date", "place_of_supply", "line_items",
"taxable_total", "cgst", "sgst", "igst", "cess", "round_off", "grand_total",
"confidence", "unclear_fields",
],
additionalProperties: false,
};
export const SYSTEM = `You read Indian GST tax invoices for an accounts team.
Copy every value exactly as it is printed. Never calculate, correct or fill in anything:
if the printed total is wrong, return the wrong total. Our code checks the maths afterwards.
Use null for anything that is not printed. Amounts are plain numbers in rupees, no commas or symbols.
Dates as YYYY-MM-DD (Indian invoices print day first).
If the GST rate is only printed on the tax rows (e.g. "CGST @ 9%" and "SGST @ 9%"), give each line the combined rate (18).
The supplier is the business that issued the invoice. The buyer is who it is billed to.
List any field you had to guess in unclear_fields, and set confidence to low if the document is hard to read.`;Step 2: one call through OpenRouter
I send the request through OpenRouter instead of one provider's SDK. It's a single fetch, and switching from Gemini to an OpenAI or Claude model is one environment variable, AI_MODEL. The model has to read images and PDFs and support structured outputs.
Three details do the heavy lifting: PDFs go in as a file part and photos as an image_url part; require_parameters makes OpenRouter use only providers that actually enforce the schema; and the PDF engine is set to native so the model reads the PDF itself, with no extra OCR step.
// Reads the invoice through OpenRouter: one fetch, no SDK.
// Swap models (Gemini, GPT, Claude...) by changing AI_MODEL, nothing else.
import { INVOICE_SCHEMA, PROMPT, SYSTEM } from "../extract.js";
const ENDPOINT = "https://openrouter.ai/api/v1/chat/completions";
export const MODEL = () => process.env.AI_MODEL ?? "google/gemini-3.8-flash";
/** The document as a message part: PDFs as a file, photos as an image. */
function documentPart({ fileName, mimeType, base64 }) {
const dataUrl = `data:${mimeType};base64,${base64}`;
return mimeType === "application/pdf"
? { type: "file", file: { filename: fileName, file_data: dataUrl } }
: { type: "image_url", image_url: { url: dataUrl } };
}
export async function readInvoice(file) {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
"X-Title": "GST invoice intake",
},
body: JSON.stringify({
model: MODEL(),
temperature: 0,
messages: [
{ role: "system", content: SYSTEM },
{ role: "user", content: [documentPart(file), { type: "text", text: PROMPT }] },
],
// The model must answer in exactly this shape.
response_format: {
type: "json_schema",
json_schema: { name: "gst_invoice", strict: true, schema: INVOICE_SCHEMA },
},
// Only use providers that actually enforce the schema.
provider: { require_parameters: true },
// Let the model read the PDF itself (no extra OCR fee).
plugins: [{ id: "file-parser", pdf: { engine: "native" } }],
}),
signal: AbortSignal.timeout(90_000),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
console.error("OpenRouter error", response.status, detail.slice(0, 800));
const reason = { 401: "the OPENROUTER_API_KEY is wrong", 402: "your OpenRouter credits ran out", 404: `model "${MODEL()}" isn't available. Check AI_MODEL`, 429: "too many requests, wait a moment" }[response.status];
throw new Error(reason ? `Couldn't read it: ${reason}.` : `OpenRouter returned ${response.status}. See the terminal for details.`);
}
const data = await response.json();
const text = data.choices?.[0]?.message?.content;
if (!text) throw new Error("The model returned nothing. Try a clearer photo or scan.");
return JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
}Step 3: the GSTIN check digit
Every GSTIN is 15 characters: a 2-digit state code, the 10-character PAN, an entity number, a Z, and a final check character. That last character is calculated from the first 14, so a single typo almost always breaks it. Checking it takes a few lines and no network call.
/** The 15th character of a GSTIN, worked out from the first 14. */
export function gstinCheckChar(first14) {
let sum = 0;
for (let i = 0; i < 14; i++) {
const product = CHARS.indexOf(first14[i]) * (i % 2 === 0 ? 1 : 2);
sum += Math.floor(product / 36) + (product % 36);
}
return CHARS[(36 - (sum % 36)) % 36];
}
/** Returns { ok, reason, stateCode, pan } for a GSTIN as printed. */
export function validateGstin(value) {
const gstin = cleanGstin(value);
if (!gstin) return { ok: false, reason: "missing" };
if (gstin.length !== 15) return { ok: false, reason: `has ${gstin.length} characters, not 15` };
if (!FORMAT.test(gstin)) return { ok: false, reason: "is not in the GSTIN format" };
const stateCode = gstin.slice(0, 2);
if (!STATES[stateCode]) return { ok: false, reason: `starts with ${stateCode}, which is not a state code` };
const expected = gstinCheckChar(gstin.slice(0, 14));
if (gstin[14] !== expected) {
return { ok: false, reason: `check digit is ${gstin[14]} but should be ${expected}, so a character is wrong` };
}
return { ok: true, stateCode, pan: gstin.slice(2, 12) };
}This tells you the GSTIN is well formed. It doesn't tell you the GSTIN is active or belongs to that supplier; for that you'd look it up on the GST portal. For catching typos on a bill, the check digit is enough.
Step 4: the 10 checks
Each check returns pass, warn, fail or skip, with a sentence a person can act on. Money checks allow ₹1 for rounding.
| Check | Fails when |
|---|---|
| Key details present | No supplier GSTIN, invoice number, date or total |
| Supplier GSTIN is valid | Wrong length or format, unknown state code, or a bad check digit |
| Billed to your GSTIN | Buyer GSTIN missing, or not yours |
| Right type of GST | Same state charged IGST, or different states charged CGST + SGST |
| Line items add up | Qty × rate isn't the line amount, or lines don't add up to the taxable value |
| Tax worked out correctly | Tax at the printed rates isn't the tax charged |
| Grand total is right | Taxable value + tax + round off isn't the grand total |
| Date makes sense | Dated in the future (over a year old only warns) |
| Not entered before | Same supplier GSTIN and invoice number already saved |
| Clear enough to trust | The model said the scan was hard to read (warning only) |
The GST type check is a good example of why this belongs in code. The rule is simple and fixed: supplier and place of supply in the same state means CGST + SGST; different states means IGST. The supplier's state comes from the first two digits of their GSTIN.
function taxType(inv) {
const label = "Right type of GST";
const from = validateGstin(inv.supplier?.gstin);
if (!from.ok) return check("tax_type", label, "skip", "Needs a valid supplier GSTIN first.");
const buyer = validateGstin(inv.buyer?.gstin);
const to = stateCodeFromText(inv.place_of_supply) ?? (buyer.ok ? buyer.stateCode : null);
if (!to) return check("tax_type", label, "warn", "Couldn't tell the place of supply, so check CGST/SGST vs IGST by hand.");
const cgst = num(inv.cgst) ?? 0;
const sgst = num(inv.sgst) ?? 0;
const igst = num(inv.igst) ?? 0;
if (cgst + sgst + igst === 0) return check("tax_type", label, "warn", "No GST charged. Fine if the goods are exempt, otherwise ask the supplier.");
const fromName = STATES[from.stateCode];
const toName = STATES[to];
if (from.stateCode === to) {
if (igst > 0) return check("tax_type", label, "fail", `Supplier and place of supply are both in ${fromName}, so this should be CGST + SGST, not IGST.`);
if (!near(cgst, sgst)) return check("tax_type", label, "fail", `CGST (${inr(cgst)}) and SGST (${inr(sgst)}) should be equal.`);
return check("tax_type", label, "pass", `Both in ${fromName}, so CGST + SGST is right.`);
}
if (cgst > 0 || sgst > 0) return check("tax_type", label, "fail", `Supplier is in ${fromName} and place of supply is ${toName}, so this should be IGST, not CGST + SGST.`);
return check("tax_type", label, "pass", `${fromName} to ${toName}, so IGST is right.`);
}When the bill doesn't print a rate per line
If no line has a rate, the code checks whether the tax charged is exactly a standard GST rate of the taxable value. A bill charging ₹1,151.10 + ₹1,151.10 on ₹12,790 passes as 18%; a number that isn't any standard rate gets flagged for a person to look at.
const GST_RATES = [0.25, 1.5, 3, 5, 12, 18, 28, 40]; // % rates in use, old and new
// ...inside the tax check:
if (!priced.length) {
// No rate per line: see whether the tax charged is a standard GST rate of the taxable value.
const base = num(inv.taxable_total) ?? sum(lines.map((line) => line.taxable_value));
const charged = sum([inv.cgst, inv.sgst, inv.igst]);
if (!base || !charged) return check("tax_maths", label, "warn", "No GST rates printed, so the tax can't be recalculated.");
const rate = (charged / base) * 100;
const slab = GST_RATES.find((r) => near((base * r) / 100, charged));
return slab !== undefined
? check("tax_maths", label, "pass", `${inr(charged)} is exactly ${slab}% of ${inr(base)}, a standard GST rate.`)
: check("tax_maths", label, "warn", `Tax is ${rate.toFixed(2)}% of the taxable value, which isn't a standard GST rate. Check the bill.`);
}Step 5: a person reviews, the server re-checks
The review page shows the problems first, then every field the model read. If the model misread something, the person fixes that field and the checks re-run straight away.
Two small things keep this honest. When the bill is saved, the server runs every check again instead of trusting what the page says. And any field corrected by hand is written into its own column, so later you can see what the model got wrong.
app.post("/api/save", async (req, res) => {
const { id, invoice, edited = [] } = req.body ?? {};
const upload = pending.get(id);
if (!upload || !invoice) return res.status(404).json({ error: "Read the invoice again, then save." });
try {
const checked = await report(invoice); // checked again on the server, never trusted from the page
const corrected = Array.isArray(edited) ? edited.map(String).slice(0, 20) : [];
const saved = await saveInvoice(invoice, checked, upload.fileName, corrected);
pending.delete(id);
res.json({ ...saved, report: checked, sheetUrl: saved.where === "sheets" ? sheetUrl() : null });
} catch (error) {
console.error(error);
res.status(502).json({ error: `Couldn't save: ${error.message}` });
}
});Step 6: one row in Google Sheets
Saving uses a Google service account and the Sheets API's append call. Values are written as RAW, so an invoice number like 1-12 stays text instead of turning into a date. If Google isn't set up, the same row goes into a CSV file, so you can try the whole flow before touching Google Cloud.
async function appendSheet(client, row) {
const range = encodeURIComponent(`${TAB()}!A1`);
await client.request({
url: api(`/values/${range}:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS`),
method: "POST",
data: { values: [row] },
});
}- In Google Cloud, turn on the Google Sheets API and create a service account. Download its JSON key.
- Share your sheet with the service account's email (it ends in
iam.gserviceaccount.com) as Editor. - Set
GOOGLE_SHEET_IDandGOOGLE_SERVICE_ACCOUNT_FILE. The app creates anInvoicestab with a header row on the first save.
The duplicate check keeps a small list of every saved supplier GSTIN + invoice number. It ignores spaces, slashes and case, so KIS/26-27/0412 and kis / 26-27 / 0412 count as the same bill.
How I tested it
I made four fictional invoices, each marked as a sample, with the data they're drawn from kept next to them. One is clean. The other three each have one planted mistake:
- Wrong total: an interstate bill whose grand total is ₹900 more than taxable value plus IGST.
- Wrong GST type: a Pune supplier billing a Karnataka buyer with CGST + SGST instead of IGST, photographed at an angle on a desk.
- Bad GSTIN: a valid supplier GSTIN with one digit changed.
The tests check that the clean bill passes every check and that each broken bill fails only the check it was made to fail. A stand-in for the model returns exactly what's printed on each sample, so the tests run without an API key. Then I ran the broken ones live through OpenRouter with Gemini 3.8 Flash, and each was flagged for its planted mistake.
What it doesn't do
- It doesn't check whether a GSTIN is active on the GST portal, only that it's well formed.
- It doesn't check whether an item's GST rate is right for its HSN code, only that the maths matches the printed rate.
- It doesn't push to Tally or other accounting software yet. Google Sheets is the hand-off.
- It doesn't replace your accountant. It does the typing and the first round of checks, so they only look at what's flagged.
If you want this for your bills
Every business's bills are a little different: other states, other rates, credit notes, bills from abroad. The checks are where the value is, and they're easy to extend because they're just code.
If your team still types purchase bills by hand, send me one (with anything sensitive covered) and I'll show you what the tool reads and which checks it would flag.