PNB Bank Statement Parser: Extract Structured JSON with AI
Parse Punjab National Bank statements into structured JSON with AI. Handles PNB ONE PDFs, Hindi text, multi-page exports, and all account types. Full TypeScript code.
Punjab National Bank is India's second-largest public sector bank, with over 180 million customers and a footprint in every corner of the country. If you're building a lending app, a KYC workflow, or a personal finance tool for the Indian market, there's a high chance your users bank with PNB.
PNB statements are notoriously tricky to parse programmatically. They mix Hindi and English text, use terse transaction codes that differ by branch, and the PDF layout shifts between PNB ONE app exports and branch-generated statements. This guide shows you how to extract clean, structured JSON from any PNB statement in seconds using Lekha — no OCR configuration, no custom parsers, no maintenance.
Why PNB Statements Are Hard to Parse
Before jumping to code, it helps to understand what makes PNB PDFs challenging:
Mixed language headers. PNB's core banking system, Finacle, often prints account holder names and branch addresses in Devanagari. A naive regex or column-splitter breaks the moment it encountersखाता संख्या instead of "Account Number".
Inconsistent column layouts. The PNB ONE mobile app generates 5-column statements (Date, Description, Debit, Credit, Balance), but branch-printed PDFs sometimes use a 6-column format that adds a "Chq/Ref No." column in between. Column index–based parsers silently misalign amounts.
Terse transaction codes. PNB uses shorthand like CLG (clearing/cheque), ATWD (ATM withdrawal), NEFT CR, UPI-P2P, and ECS DEB without a lookup table in the PDF. A parser that passes raw descriptions to downstream code produces noisy, unusable data.
Multi-page balance-forward rows. Long statements insert a "Carried Forward" / "Brought Forward" summary row at page breaks. Parsers that treat every row as a transaction double-count those rows as debits or credits.
Lekha's vision AI layer handles all of these because it reads the document the same way a human analyst would — understanding layout, language, and context rather than extracting fixed column positions.
Quick Start: Parse a PNB Statement
Get your free API key at lekhadev.com, then call the extract endpoint:
import fs from "fs";
const pdf = fs.readFileSync("pnb-statement.pdf");
const base64 = pdf.toString("base64");
const response = await fetch("https://api.lekhadev.com/v1/extract", {
method: "POST",
headers: {
Authorization: Bearer ${process.env.LEKHA_API_KEY},
"Content-Type": "application/json",
},
body: JSON.stringify({
file: base64,
type: "bank_statement",
}),
});
const result = await response.json();
console.log(result.data);
A typical PNB statement response looks like this:
{
"bank": "Punjab National Bank",
"account_number": "XXXX XXXX 1234",
"account_type": "savings",
"account_holder": "Rajesh Kumar Singh",
"ifsc": "PUNB0123456",
"branch": "New Delhi Main Branch",
"statement_period": {
"from": "2026-01-01",
"to": "2026-03-31"
},
"opening_balance": 45230.5,
"closing_balance": 62184.75,
"currency": "INR",
"transactions": [
{
"date": "2026-01-03",
"description": "UPI-P2P/PHONEPE/9876543210/Rahul Sharma",
"type": "credit",
"amount": 5000.0,
"balance": 50230.5,
"reference": "316003756223"
},
{
"date": "2026-01-07",
"description": "NEFT CR/SBIN0001234/ABC Pvt Ltd/Salary",
"type": "credit",
"amount": 42000.0,
"balance": 92230.5,
"reference": "N007260100001234"
},
{
"date": "2026-01-10",
"description": "ATWD/ATM/PNB ATM CONNAUGHT PLACE",
"type": "debit",
"amount": 10000.0,
"balance": 82230.5,
"reference": "CLG1001234"
}
]
}
Notice that dates are ISO 8601, amounts are numbers (not strings), and the balance-forward rows are stripped — you get clean transaction data, ready to use.
Build a PNB Statement Analysis Tool
Let's turn this into a reusable TypeScript module that validates the response and adds helper methods:
// lib/pnb-parser.ts
interface Transaction {
date: string; // ISO 8601: YYYY-MM-DD
description: string;
type: "credit" | "debit";
amount: number;
balance: number;
reference?: string;
}
interface PNBStatement {
bank: string;
account_number: string;
account_type: string;
account_holder: string;
ifsc: string;
branch: string;
statement_period: { from: string; to: string };
opening_balance: number;
closing_balance: number;
currency: string;
transactions: Transaction[];
}
export async function parsePNBStatement(
pdfBuffer: Buffer,
): Promise {
const response = await fetch("https://api.lekhadev.com/v1/extract", {
method: "POST",
headers: {
Authorization: Bearer ${process.env.LEKHA_API_KEY},
"Content-Type": "application/json",
},
body: JSON.stringify({
file: pdfBuffer.toString("base64"),
type: "bank_statement",
}),
});
if (!response.ok) {
const err = await response.json();
throw new Error(
Lekha API error: ${err.error?.message ?? response.statusText},
);
}
const { data } = await response.json();
return data as PNBStatement;
}
// Helpers for common lending/KYC use cases
export function monthlyCredits(stmt: PNBStatement): Record {
const result: Record = {};
for (const txn of stmt.transactions) {
if (txn.type !== "credit") continue;
const month = txn.date.slice(0, 7); // "YYYY-MM"
result[month] = (result[month] ?? 0) + txn.amount;
}
return result;
}
export function averageMonthlyBalance(stmt: PNBStatement): number {
if (stmt.transactions.length === 0) return stmt.closing_balance;
const total = stmt.transactions.reduce((sum, t) => sum + t.balance, 0);
return Math.round(total / stmt.transactions.length);
}
export function salaryCredits(stmt: PNBStatement): Transaction[] {
const salaryKeywords = /salary|sal cr|neft cr.payroll|neft cr.salary/i;
return stmt.transactions.filter(
(t) => t.type === "credit" && salaryKeywords.test(t.description),
);
}
Handle All PNB Account Types
PNB offers savings accounts, current accounts, PPF accounts, and NRI accounts (NRE/NRO). Each generates a slightly different PDF — Lekha auto-detects the format. You can pass type: "auto" if you don't know the document type in advance:
// Auto-detect: works for any Indian financial document
const response = await fetch("https://api.lekhadev.com/v1/extract", {
method: "POST",
headers: {
Authorization: Bearer ${process.env.LEKHA_API_KEY},
"Content-Type": "application/json",
},
body: JSON.stringify({
file: base64,
type: "auto", // Lekha will detect bank_statement, salary_slip, etc.
}),
});
For NRE/NRO accounts, the response includes a currency field that may be "USD" or "GBP" for foreign currency sub-accounts, and amounts are in that currency — Lekha does not convert them, so your downstream logic can apply the right exchange rate.
Loan Eligibility Check with PNB Statements
Here's a practical end-to-end example: a loan eligibility function that takes a 3-month PNB statement bundle and returns an eligibility verdict with supporting evidence.
// lib/loan-eligibility.ts
import {
parsePNBStatement,
monthlyCredits,
salaryCredits,
averageMonthlyBalance,
} from "./pnb-parser";
interface EligibilityResult {
eligible: boolean;
reason: string;
avg_monthly_income: number;
avg_monthly_balance: number;
salary_credits_found: number;
recommended_emi_limit: number;
}
export async function checkLoanEligibility(
statementBuffers: Buffer[], // 3 months of PNB statements
requestedEMI: number,
): Promise {
const statements = await Promise.all(statementBuffers.map(parsePNBStatement));
// Aggregate salary credits across all statements
const allSalaryTxns = statements.flatMap(salaryCredits);
const avgMonthlyIncome =
allSalaryTxns.reduce((s, t) => s + t.amount, 0) /
Math.max(statements.length, 1);
// Average monthly balance across all statements
const avgBalance =
statements.reduce((s, stmt) => s + averageMonthlyBalance(stmt), 0) /
statements.length;
// Standard rule: EMI should not exceed 50% of net monthly income
const emiLimit = Math.round(avgMonthlyIncome * 0.5);
const eligible = requestedEMI <= emiLimit && avgBalance >= requestedEMI * 2;
return {
eligible,
reason: eligible
? "Income and balance are sufficient for the requested EMI."
: EMI of ₹${requestedEMI} exceeds the recommended limit of ₹${emiLimit} (50% of avg income ₹${Math.round(avgMonthlyIncome)}).,
avg_monthly_income: Math.round(avgMonthlyIncome),
avg_monthly_balance: Math.round(avgBalance),
salary_credits_found: allSalaryTxns.length,
recommended_emi_limit: emiLimit,
};
}
Usage in an API route:
// app/api/loan-check/route.ts (Next.js App Router)
import { NextRequest, NextResponse } from "next/server";
import { checkLoanEligibility } from "@/lib/loan-eligibility";
export async function POST(req: NextRequest) {
const formData = await req.formData();
const files = formData.getAll("statements") as File[];
const requestedEMI = Number(formData.get("emi"));
const buffers = await Promise.all(
files.map((f) => f.arrayBuffer().then((ab) => Buffer.from(ab))),
);
const result = await checkLoanEligibility(buffers, requestedEMI);
return NextResponse.json({ success: true, data: result });
}
PNB-Specific Edge Cases Lekha Handles
Password-protected PDFs. PNB ONE exports are often encrypted with the account holder's date of birth or a user-set password. Lekha accepts decrypted PDFs — decrypt client-side usingpdf-lib or ask users to export without a password before uploading.
Passbook scans. Branch staff sometimes scan physical PNB passbooks rather than generating PDF exports. Lekha's vision AI reads these image PDFs just as accurately as digital PDFs — no separate code path needed.
Multi-account statements. PNB occasionally issues consolidated statements for customers with linked accounts (savings + PPF, or joint accounts). Lekha returns a top-level account_number for the primary account and a linked_accounts array if additional accounts are detected.
Regional language branch codes. PNB branch names in tier-2 and tier-3 cities often appear in Hindi only. The branch field in the response is always English, transliterated from the Devanagari if needed.
Try any of these against the Lekha playground to see the output before writing any integration code.
Test with a Sample PNB Statement
The Lekha test suite ships with sanitized PNB fixtures you can use for development:
// tests/pnb.test.ts
import { describe, it, expect } from "vitest";
import { parsePNBStatement } from "../lib/pnb-parser";
import fs from "fs";
import path from "path";
describe("PNB statement parser", () => {
it("extracts transactions from a PNB ONE export", async () => {
const pdf = fs.readFileSync(
path.join(__dirname, "fixtures/pnb-savings-3mo.pdf"),
);
const stmt = await parsePNBStatement(pdf);
expect(stmt.bank).toBe("Punjab National Bank");
expect(stmt.transactions.length).toBeGreaterThan(0);
expect(stmt.closing_balance).toBeTypeOf("number");
// Dates must be ISO 8601
for (const txn of stmt.transactions) {
expect(txn.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(txn.amount).toBeTypeOf("number");
}
});
it("identifies salary credits correctly", async () => {
const pdf = fs.readFileSync(
path.join(__dirname, "fixtures/pnb-salary-account.pdf"),
);
const stmt = await parsePNBStatement(pdf);
const salaryTxns = stmt.transactions.filter(
(t) => t.type === "credit" && /salary|neft cr/i.test(t.description),
);
expect(salaryTxns.length).toBeGreaterThanOrEqual(1);
});
});
FAQ
Does Lekha support all PNB account types? Yes — savings, current, salary, PPF, NRE, and NRO accounts. Theaccount_type field in the response tells you which type was detected.
How does Lekha handle the "Brought Forward" and "Carried Forward" rows that PNB inserts at page breaks?
Lekha detects and strips these summary rows automatically. They do not appear in the transactions array, so you never accidentally count them as debits or credits.
Can I parse a PNB statement image (JPEG/PNG) instead of a PDF?
Yes. Send the base64-encoded image in the same request body — the API handles both PDFs and images. For multi-page statements, PDF is strongly preferred since it preserves the page order.
What if the PNB statement is in Hindi only?
Lekha supports Hindi-only and mixed Hindi-English PNB statements. Field values in the response are always returned in English (transliterated where necessary), with dates as ISO 8601 and amounts as numbers.
Next Steps
Lekha supports 28+ Indian bank formats out of the box. If you're building beyond PNB — HDFC, ICICI, Axis, Kotak, SBI, and more — the same API call works for all of them.