Expense Categorization Agent for Indian Bank Statements
Auto-categorize expenses from Indian bank statements with AI. Build a full TypeScript agent using Lekha that tags spending and surfaces budget insights.
Knowing where money goes is the foundation of every personal finance product, lending decision, and tax workflow. But raw bank statement transactions are messy — "NEFT/CR/HDFC000123456789/RENT" and "UPI/918100001234/SWIGGY ORDER" look very different even though both are straightforward to a human. Getting an AI agent to reliably tag 200+ transactions per month across 50 Indian bank formats is the hard part.
This guide walks you through building an expense categorization agent in TypeScript. It uses Lekha to extract structured transactions from any Indian bank statement, then layers a categorization pass on top to tag, aggregate, and surface spending insights — all in a single API-driven pipeline.
What You'll Build
A TypeScript agent that:
Typical output: a JSON object with categories, monthly_summary, and top_merchants — exactly what a personal finance app, NBFC underwriter, or tax prep tool needs.
Prerequisites
bun add @anthropic-ai/sdk
Step 1: Extract Transactions with Lekha
The first step is getting clean, structured transaction data. Lekha handles the hard parts — PDF parsing, multi-format normalization, debit/credit detection, and running balance extraction — so you don't have to.
// agent/extract.ts
import fs from "fs";
interface Transaction {
date: string;
description: string;
amount: number;
type: "credit" | "debit";
balance: number;
reference?: string;
}
interface LekhaStatementResponse {
success: boolean;
data: {
account_holder: string;
account_number: string;
bank_name: string;
period: { from: string; to: string };
transactions: Transaction[];
summary: {
opening_balance: number;
closing_balance: number;
total_credits: number;
total_debits: number;
};
};
}
async function extractTransactions(
pdfPath: string,
apiKey: string,
): Promise {
const pdfBuffer = fs.readFileSync(pdfPath);
const base64 = pdfBuffer.toString("base64");
const response = await fetch("https://lekhadev.com/api/v1/extract", {
method: "POST",
headers: {
Authorization: Bearer ${apiKey},
"Content-Type": "application/json",
},
body: JSON.stringify({
document: base64,
type: "bank_statement",
}),
});
const result: LekhaStatementResponse = await response.json();
if (!result.success) {
throw new Error(Lekha extraction failed: ${JSON.stringify(result)});
}
return result.data;
}
Lekha normalizes transactions from all major Indian banks — HDFC, SBI, ICICI, Axis, Kotak, PNB, and 40+ others — into the same schema. You get ISO 8601 dates, numeric amounts, and clean debit/credit flags regardless of which bank issued the statement. Try it at lekhadev.com/playground.
Step 2: Define Indian Expense Categories
Generic expense categories don't map well to Indian spending patterns. A robust categorization schema needs to handle UPI transfers, Indian grocery chains, toll payments, and domestic help payments that Western taxonomies miss entirely.
// agent/categories.ts
export const EXPENSE_CATEGORIES = {
FOOD_DINING: {
label: "Food & Dining",
keywords: [
"swiggy",
"zomato",
"blinkit",
"dominos",
"mcdonalds",
"kfc",
"restaurant",
"hotel",
"cafe",
"tea stall",
"canteen",
],
},
GROCERIES: {
label: "Groceries",
keywords: [
"bigbasket",
"dmart",
"reliance fresh",
"more supermarket",
"spencer",
"nature basket",
"zepto",
"jiomart",
"grofers",
"blinkit",
"grocery",
"kirana",
],
},
TRANSPORT: {
label: "Transport",
keywords: [
"ola",
"uber",
"rapido",
"namma yatri",
"irctc",
"railways",
"metro",
"bus",
"petrol",
"fuel",
"fastag",
"toll",
"parking",
],
},
UTILITIES: {
label: "Utilities",
keywords: [
"electricity",
"bescom",
"msedcl",
"tneb",
"bses",
"water",
"gas",
"piped gas",
"mahanagar gas",
"indane",
"hp gas",
"bharat gas",
"broadband",
"jio fiber",
"airtel xstream",
],
},
TELECOM: {
label: "Mobile & Internet",
keywords: [
"jio",
"airtel",
"vi ",
"vodafone",
"bsnl",
"recharge",
"mobile bill",
"data plan",
],
},
RENT_HOUSING: {
label: "Rent & Housing",
keywords: [
"rent",
"maintenance",
"society",
"housing",
"nobroker",
"magicbricks",
"99acres",
"landlord",
],
},
HEALTH: {
label: "Health & Medical",
keywords: [
"pharmacy",
"medplus",
"apollo pharmacy",
"1mg",
"practo",
"netmeds",
"hospital",
"clinic",
"doctor",
"lab",
"diagnostic",
"insurance",
"star health",
"niva bupa",
],
},
EDUCATION: {
label: "Education",
keywords: [
"school fees",
"college fees",
"tuition",
"udemy",
"coursera",
"byju",
"unacademy",
"vedantu",
"books",
],
},
ENTERTAINMENT: {
label: "Entertainment",
keywords: [
"netflix",
"prime video",
"hotstar",
"disney",
"sonyliv",
"zee5",
"spotify",
"gaana",
"wynk",
"bookmyshow",
"pvr",
"inox",
],
},
SHOPPING: {
label: "Shopping",
keywords: [
"amazon",
"flipkart",
"myntra",
"ajio",
"meesho",
"nykaa",
"tata cliq",
"croma",
"vijay sales",
"snapdeal",
],
},
INVESTMENTS: {
label: "Investments & Savings",
keywords: [
"mutual fund",
"mf",
"zerodha",
"groww",
"upstox",
"coin",
"sip",
"fd",
"rd",
"ppf",
"nps",
"lic",
"elss",
],
},
EMI: {
label: "Loan EMI",
keywords: [
"emi",
"loan",
"nach",
"ecs",
"nach debit",
"auto debit",
"repayment",
],
},
DOMESTIC_HELP: {
label: "Domestic Help",
keywords: [
"maid",
"cook",
"driver",
"watchman",
"helper",
"bai",
"kaam wali",
],
},
TRANSFERS: {
label: "Transfers",
keywords: ["neft", "rtgs", "imps", "upi", "transfer", "self transfer"],
},
OTHER: {
label: "Other",
keywords: [],
},
} as const;
export type CategoryKey = keyof typeof EXPENSE_CATEGORIES;
Step 3: Build the Categorization Pass
With transactions extracted and categories defined, the categorization step uses Claude to handle descriptions that keyword matching misses — regional merchants, handwritten references, and ambiguous UPI notes.
// agent/categorize.ts import Anthropic from "@anthropic-ai/sdk"; import { EXPENSE_CATEGORIES, CategoryKey } from "./categories";;const anthropic = new Anthropic();
interface CategorizedTransaction { date: string; description: string; amount: number; type: "credit" | "debit"; category: CategoryKey; merchant?: string; }
async function categorizeTransactions( transactions: Array<{ date: string; description: string; amount: number; type: "credit" | "debit"; }>, ): Promise
{ // Filter to debits only — credits are income, not expenses const debits = transactions.filter((t) => t.type === "debit"); const categoryList = Object.entries(EXPENSE_CATEGORIES) .map(([key, val]) =>
${key}: ${val.label}) .join("\n");const prompt =
You are a financial transaction categorizer for Indian bank statements.Categorize each transaction below into exactly one of these categories: ${categoryList}
Rules:
- UPI transfers to individuals are TRANSFERS unless the note clearly indicates a purchase
- NACH/ECS auto-debits for recurring amounts are likely EMI
- Small round amounts to individuals may be DOMESTIC_HELP
- Return ONLY valid JSON with no explanation
Transactions: ${JSON.stringify(debits, null, 2)}
Return a JSON array where each object has:
- index: number (0-based, matching input order)
- category: string (one of the category keys above)
- merchant: string (cleaned merchant name, or null)
const message = await anthropic.messages.create({ model: "claude-haiku-4-5-20251001", max_tokens: 4096, messages: [{ role: "user", content: prompt }], });
const content = message.content[0]; if (content.type !== "text") throw new Error("Unexpected response type");
const jsonMatch = content.text.match(/\[[\s\S]*\]/); if (!jsonMatch) throw new Error("No JSON array in response");
const results: Array<{ index: number; category: CategoryKey; merchant: string | null; }> = JSON.parse(jsonMatch[0]);
return debits.map((t, i) => { const result = results.find((r) => r.index === i); return { ...t, category: result?.category ?? "OTHER", merchant: result?.merchant ?? undefined, }; }); }
Using claude-haiku-4-5-20251001 keeps this step fast and cheap — a 200-transaction statement costs under ₹1 to categorize.
Step 4: Aggregate Spending Insights
Raw categorized transactions become useful when aggregated into monthly summaries and merchant rankings.
// agent/insights.ts
import {
CategorizedTransaction,
CategoryKey,
EXPENSE_CATEGORIES,
} from "./categories";
interface SpendingInsights {
period: { from: string; to: string };
total_spend: number;
categories: Array<{
key: CategoryKey;
label: string;
total: number;
percentage: number;
transaction_count: number;
}>;
top_merchants: Array<{
name: string;
total: number;
count: number;
}>;
monthly_breakdown: Record<
string,
{ total: number; by_category: Partial> }
>;
}
function buildInsights(
transactions: CategorizedTransaction[],
period: { from: string; to: string },
): SpendingInsights {
const totalSpend = transactions.reduce((sum, t) => sum + t.amount, 0);
// Category aggregation
const categoryMap = new Map();
for (const t of transactions) {
const existing = categoryMap.get(t.category) ?? { total: 0, count: 0 };
categoryMap.set(t.category, {
total: existing.total + t.amount,
count: existing.count + 1,
});
}
const categories = Array.from(categoryMap.entries())
.map(([key, { total, count }]) => ({
key,
label: EXPENSE_CATEGORIES[key].label,
total: Math.round(total),
percentage: Math.round((total / totalSpend) 100 10) / 10,
transaction_count: count,
}))
.sort((a, b) => b.total - a.total);
// Merchant aggregation
const merchantMap = new Map();
for (const t of transactions) {
if (!t.merchant) continue;
const existing = merchantMap.get(t.merchant) ?? { total: 0, count: 0 };
merchantMap.set(t.merchant, {
total: existing.total + t.amount,
count: existing.count + 1,
});
}
const topMerchants = Array.from(merchantMap.entries())
.map(([name, { total, count }]) => ({
name,
total: Math.round(total),
count,
}))
.sort((a, b) => b.total - a.total)
.slice(0, 10);
// Monthly breakdown
const monthlyMap = new Map<
string,
{ total: number; by_category: Map }
>();
for (const t of transactions) {
const month = t.date.slice(0, 7); // YYYY-MM
const existing = monthlyMap.get(month) ?? {
total: 0,
by_category: new Map(),
};
existing.total += t.amount;
existing.by_category.set(
t.category,
(existing.by_category.get(t.category) ?? 0) + t.amount,
);
monthlyMap.set(month, existing);
}
const monthlyBreakdown: SpendingInsights["monthly_breakdown"] = {};
for (const [month, { total, by_category }] of Array.from(
monthlyMap.entries(),
)) {
const byCategoryObj: Partial> = {};
for (const [cat, amt] of Array.from(by_category.entries())) {
byCategoryObj[cat] = Math.round(amt);
}
monthlyBreakdown[month] = {
total: Math.round(total),
by_category: byCategoryObj,
};
}
return {
period,
total_spend: Math.round(totalSpend),
categories,
top_merchants: topMerchants,
monthly_breakdown: monthlyBreakdown,
};
}
Step 5: Wire It All Together
// agent/index.ts
import { extractTransactions } from "./extract";
import { categorizeTransactions } from "./categorize";
import { buildInsights } from "./insights";
async function analyzeExpenses(pdfPath: string) {
const lekhaKey = process.env.LEKHA_API_KEY!;
console.log("Step 1: Extracting transactions...");
const statement = await extractTransactions(pdfPath, lekhaKey);
console.log( Found ${statement.transactions.length} transactions);
console.log(
Bank: ${statement.bank_name}, Account: ${statement.account_holder},
);
console.log("Step 2: Categorizing expenses...");
const categorized = await categorizeTransactions(statement.transactions);
console.log( Categorized ${categorized.length} debit transactions);
console.log("Step 3: Building insights...");
const insights = buildInsights(categorized, statement.period);
console.log("\n=== Spending Summary ===");
console.log(Period: ${insights.period.from} to ${insights.period.to});
console.log(Total Spend: ₹${insights.total_spend.toLocaleString("en-IN")});
console.log("\nTop Categories:");
for (const cat of insights.categories.slice(0, 5)) {
console.log(
${cat.label}: ₹${cat.total.toLocaleString("en-IN")} (${cat.percentage}%),
);
}
return insights;
}
// Run
analyzeExpenses(process.argv[2]).then((insights) =>
console.log("\nFull output:\n", JSON.stringify(insights, null, 2)),
);
Run it with any Indian bank statement:
LEKHA_API_KEY=lk_live_... bun run agent/index.ts ./statement.pdf
Extending the Agent
Once you have the categorization pipeline, a few extensions are straightforward:
Budget alerts. Compare category totals against user-defined budgets and fire a notification when spending exceeds a threshold. Income vs. expense reconciliation. Lekha also returns credit transactions. Cross-referencing salary credits with expense totals gives a savings rate — useful for NBFC underwriters assessing disposable income. Recurring transaction detection. Fixed-amount debits on the same day each month are almost always EMIs or subscriptions. Flag them separately from variable expenses for cleaner budgeting. Multi-statement aggregation. Call Lekha for each of six monthly statements, concatenate thetransactions arrays, then run the full pipeline once. The monthly breakdown handles the grouping automatically.
For the full API reference, see lekhadev.com/docs.
FAQ
Which Indian banks does Lekha support for this workflow? Lekha supports 50+ Indian bank formats including SBI, HDFC, ICICI, Axis, Kotak, PNB, Bank of Baroda, Canara Bank, Federal Bank, IndusInd, Yes Bank, IDFC First, and all major regional cooperative banks. The extracted transaction schema is identical regardless of bank, so your categorization agent runs unchanged across all formats. How accurate is the AI categorization on Indian transaction descriptions? Keyword matching handles roughly 70–75% of transactions correctly. The Claude pass lifts that to 90–95% on typical consumer bank statements. Accuracy is lower on statements with many peer-to-peer UPI transfers where the note field is absent or cryptic — for those, a fallback rule ("UPI to individual = TRANSFER") keeps the error rate low. Can I run this without the Claude API for categorization? Yes. Swap the Claude categorization step for a pure keyword-matching pass using theEXPENSE_CATEGORIES.keywords arrays. Accuracy drops but the pipeline has no external dependency beyond Lekha. This is useful for high-volume batch processing where per-transaction LLM cost is a constraint.
Does this work for business bank statements, not just personal accounts?
It works, but the category taxonomy needs adjustment. Business statements have categories like GST payments, vendor payments, payroll, and government levies that don't appear in the schema above. Extend EXPENSE_CATEGORIES with business-specific buckets and update the Claude prompt accordingly.
Ready to try it? Get your Lekha API key at lekhadev.com — the free tier includes 50 document extractions per month, enough to build and test this pipeline end to end.