← Back to blog
·8 min read

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 reconciliationai agentbank statementinvoice parsingindian fintechgst complianceaccounts payable

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:

  • Extracts transactions from a bank statement (any Indian bank)
  • Parses GST invoices (or a GST purchase register export)
  • Matches each invoice payment to a bank debit by amount, date proximity, and vendor name
  • Flags unmatched invoices and suspicious amounts
  • Returns a structured JSON report you can push into your accounting system
  • Total turnaround: under 5 seconds for a month's data.

    Prerequisites

  • Node.js 18+ or Bun
  • A Lekha API key (free tier covers 50 documents/month)
  • TypeScript project (or plain JS — just drop the types)
  • 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

  • [ ] Store extracted JSON, not raw PDFs (DPDP compliance — don't persist document bytes)
  • [ ] Log extraction confidence scores; flag low-confidence extractions for human review
  • [ ] Add idempotency — cache invoice extraction results by file hash so reruns don't double-bill API credits
  • [ ] Implement webhook-based workflows: trigger reconciliation when new invoices land in an S3/GCS bucket
  • [ ] Send unmatched invoice alerts via email or Slack before the 20th (GSTR-3B deadline)
  • 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.