GST Reconciliation Agent: Match Invoices to Bank Statements
Build an AI agent that automatically reconciles GST invoices against bank statements. Catch mismatches, flag missing payments, and export GSTR-ready reports.
GST reconciliation is one of the most painful monthly tasks for Indian businesses. Accountants manually cross-check hundreds of purchase invoices against bank transactions before filing GSTR-2B, and a single mismatch can trigger an ITC denial. This post shows you how to build an AI agent that does it automatically — upload your bank statement and GST invoices, get a reconciled report in seconds.
What the Agent Does
The reconciliation agent:
Prerequisites
Project Setup
mkdir gst-reconciliation-agent && cd gst-reconciliation-agent
bun init -y
bun add @lekhadev/sdk zod
Create your .env:
LEKHA_API_KEY=lk_live_xxxxxxxxxxxxxxxxxxxx
Step 1: Extract the Bank Statement
Lekha handles any Indian bank format — HDFC, SBI, ICICI, Axis, Canara, and 40+ others. Pass the PDF as a base64 string or a File object.
import Lekha from "@lekhadev/sdk";
import { readFileSync } from "fs";
const lekha = new Lekha({ apiKey: process.env.LEKHA_API_KEY! });
async function extractBankStatement(pdfPath: string) {
const pdfBuffer = readFileSync(pdfPath);
const base64 = pdfBuffer.toString("base64");
const result = await lekha.extract({
document: {
content: base64,
mimeType: "application/pdf",
},
documentType: "bank_statement",
});
if (!result.success) {
throw new Error(Extraction failed: ${result.error.message});
}
return result.data;
}
The returned data.transactions array looks like:
[
{
"date": "2026-06-04",
"description": "NEFT/RTGS-SHARMA TRADERS LLP",
"debit": 118000,
"credit": null,
"balance": 842000,
"reference": "NEFT2026060400001234"
}
]
Amounts are always numbers, dates always ISO 8601 — no cleanup needed.
Step 2: Parse GST Invoices
Lekha's GST invoice extractor pulls GSTIN, invoice number, taxable value, CGST, SGST, IGST, and total from scanned or digital invoices.
async function extractGstInvoice(pdfPath: string) {
const pdfBuffer = readFileSync(pdfPath);
const base64 = pdfBuffer.toString("base64");
const result = await lekha.extract({
document: {
content: base64,
mimeType: "application/pdf",
},
documentType: "gst_invoice",
});
if (!result.success) {
throw new Error(Invoice extraction failed: ${result.error.message});
}
return result.data;
}
Typical response:
{
"invoiceNumber": "INV-2026-0842",
"invoiceDate": "2026-06-03",
"supplierName": "Sharma Traders LLP",
"supplierGstin": "07AABCS1429B1ZB",
"buyerGstin": "27AAACR5055K1ZH",
"taxableValue": 100000,
"cgst": 9000,
"sgst": 9000,
"igst": 0,
"totalAmount": 118000,
"lineItems": [...]
}
Step 3: The Reconciliation Logic
With structured data from both sources, matching becomes a straightforward algorithm:
import { z } from "zod";
interface BankTransaction {
date: string;
description: string;
debit: number | null;
credit: number | null;
reference: string;
}
interface GstInvoice {
invoiceNumber: string;
invoiceDate: string;
supplierName: string;
totalAmount: number;
}
interface ReconciliationResult {
matched: Array<{
invoice: GstInvoice;
transaction: BankTransaction;
confidence: "high" | "medium" | "low";
daysDiff: number;
}>;
unmatched: GstInvoice[];
suspicious: Array<{
invoice: GstInvoice;
transaction: BankTransaction;
reason: string;
}>;
}
function reconcile(
invoices: GstInvoice[],
transactions: BankTransaction[],
windowDays = 7,
): ReconciliationResult {
const matched: ReconciliationResult["matched"] = [];
const unmatched: GstInvoice[] = [];
const suspicious: ReconciliationResult["suspicious"] = [];
const usedTxnIndices = new Set();
for (const invoice of invoices) {
const invoiceDate = new Date(invoice.invoiceDate);
let bestMatch: {
txn: BankTransaction;
index: number;
score: number;
} | null = null;
for (let i = 0; i < transactions.length; i++) {
if (usedTxnIndices.has(i)) continue;
const txn = transactions[i];
if (txn.debit === null) continue;
const txnDate = new Date(txn.date);
const daysDiff = Math.abs(
(txnDate.getTime() - invoiceDate.getTime()) / 86_400_000,
);
// Amount must match exactly (GST total = bank debit for most B2B payments)
if (Math.abs(txn.debit - invoice.totalAmount) > 1) continue;
if (daysDiff > windowDays) continue;
// Score: lower days diff = better, name match = bonus
const nameMatch = txn.description
.toLowerCase()
.includes(invoice.supplierName.split(" ")[0].toLowerCase());
const score = windowDays - daysDiff + (nameMatch ? 5 : 0);
if (!bestMatch || score > bestMatch.score) {
bestMatch = { txn, index: i, score };
}
}
if (!bestMatch) {
unmatched.push(invoice);
continue;
}
usedTxnIndices.add(bestMatch.index);
const daysDiff = Math.abs(
(new Date(bestMatch.txn.date).getTime() - invoiceDate.getTime()) /
86_400_000,
);
const nameMatch = bestMatch.txn.description
.toLowerCase()
.includes(invoice.supplierName.split(" ")[0].toLowerCase());
const confidence =
daysDiff <= 1 && nameMatch ? "high" : daysDiff <= 3 ? "medium" : "low";
// Flag if payment predates invoice (possible backdating)
if (new Date(bestMatch.txn.date) < new Date(invoice.invoiceDate)) {
suspicious.push({
invoice,
transaction: bestMatch.txn,
reason: "Payment date precedes invoice date — possible backdating",
});
}
matched.push({
invoice,
transaction: bestMatch.txn,
confidence,
daysDiff,
});
}
return { matched, unmatched, suspicious };
}
Step 4: Orchestrate the Full Agent
import { readdirSync } from "fs";
import path from "path";
async function runReconciliationAgent(
bankStatementPath: string,
invoiceDir: string,
) {
console.log("Extracting bank statement...");
const statement = await extractBankStatement(bankStatementPath);
const invoicePaths = readdirSync(invoiceDir)
.filter((f) => f.endsWith(".pdf"))
.map((f) => path.join(invoiceDir, f));
console.log(Extracting ${invoicePaths.length} invoices in parallel...);
const invoices = await Promise.all(invoicePaths.map(extractGstInvoice));
console.log("Reconciling...");
const report = reconcile(invoices, statement.transactions);
console.log("\n=== RECONCILIATION REPORT ===");
console.log(Matched: ${report.matched.length} invoices);
console.log(Unmatched: ${report.unmatched.length} invoices);
console.log(Suspicious: ${report.suspicious.length} transactions\n);
if (report.unmatched.length > 0) {
console.log("UNMATCHED INVOICES (ITC at risk):");
for (const inv of report.unmatched) {
console.log(
- ${inv.invoiceNumber} | ${inv.supplierName} | ₹${inv.totalAmount.toLocaleString("en-IN")},
);
}
}
return report;
}
// Run
await runReconciliationAgent(
"./data/june-2026-hdfc-statement.pdf",
"./data/invoices/",
);
Step 5: Export a GSTR-Ready Report
Once reconciled, generate a CSV your CA can directly import into the GST portal or Tally:
import { writeFileSync } from "fs";
function exportGstrReport(report: ReconciliationResult, outPath: string) {
const rows = [
[
"Invoice No",
"Invoice Date",
"Supplier",
"GSTIN",
"Amount",
"Payment Date",
"Status",
"Confidence",
],
];
for (const m of report.matched) {
rows.push([
m.invoice.invoiceNumber,
m.invoice.invoiceDate,
m.invoice.supplierName,
(m.invoice as any).supplierGstin ?? "",
String(m.invoice.totalAmount),
m.transaction.date,
"MATCHED",
m.confidence,
]);
}
for (const inv of report.unmatched) {
rows.push([
inv.invoiceNumber,
inv.invoiceDate,
inv.supplierName,
(inv as any).supplierGstin ?? "",
String(inv.totalAmount),
"",
"UNMATCHED",
"",
]);
}
const csv = rows.map((r) => r.join(",")).join("\n");
writeFileSync(outPath, csv, "utf-8");
console.log(Report saved to ${outPath});
}
exportGstrReport(report, "./reconciliation-june-2026.csv");
Handling Edge Cases
Round-trip payments (TDS deducted): Some vendors receive payment net of TDS. If your ₹118,000 invoice maps to a ₹107,560 bank debit (2% TDS), broaden the match window:const tdsTolerance = invoice.totalAmount * 0.03; // 3% tolerance
if (Math.abs(txn.debit - invoice.totalAmount) <= tdsTolerance) {
// Likely TDS deduction — flag for manual review
}
Advance payments: Pre-payments show up as debits before the invoice date. The suspicious array catches these automatically.
Bulk transfers: Some businesses batch multiple vendor payments into a single NEFT/RTGS. Break these down by splitting the bank transaction description — Lekha often surfaces the underlying vendor names from structured remarks.
Performance at Scale
| Documents | Lekha API time | Reconcile time | Total | | --------------------------- | ---------------- | -------------- | ----- | | 50 invoices + 1 statement | ~3.5s (parallel) | <50ms | ~4s | | 200 invoices + 3 statements | ~8s (parallel) | <200ms | ~9s | | 500 invoices + 6 statements | ~18s (parallel) | <500ms | ~19s |
The bottleneck is always document extraction, not reconciliation. Parallelize invoice extraction with Promise.all as shown above.
Production Checklist
FAQ
What document formats does Lekha accept for GST invoices? Lekha accepts PDF (scanned or digital) and common image formats (JPEG, PNG, WEBP). It handles both machine-readable PDFs from accounting software and scanned paper invoices. Can this agent work with a GST purchase register export instead of individual invoices? Yes — if you export your purchase register as a PDF or CSV from Tally/Zoho/Busy, you can feed it directly to Lekha's GST document extractor and get structured line-by-line data back. How accurate is the invoice-to-bank-statement matching? For standard B2B payments (exact amount, within 3 days), accuracy is above 95%. Edge cases like TDS deductions, advance payments, or bulk transfers require the tolerance logic shown above and may need human review. What banks does the bank statement extractor support? Lekha supports 40+ Indian banks including HDFC, SBI, ICICI, Axis, Kotak, Canara, PNB, Bank of Baroda, Yes Bank, IDFC First, Federal Bank, and IndusInd. See the full bank list.GST reconciliation is a solved problem once your documents are structured. The hard part — extracting clean data from messy PDFs — is what Lekha handles.
Try it free at lekhadev.com — no credit card required. The playground at lekhadev.com/playground lets you test extraction on your own invoices and statements before writing a line of code.