Detect EMIs and Subscriptions from Bank Statements with AI
Build an agent that detects recurring EMIs and subscriptions from Indian bank statements using Lekha and Claude. Covers UPI patterns, NACH debits, and edge cases.
Every lending decision in India depends on one question: how much of an applicant's income is already committed? EMIs, insurance premiums, recurring utility bills, and SaaS subscriptions are fixed obligations — they reduce the net disposable income available to service a new loan. Missing even one recurring debit can cause your underwriting model to over-extend credit.
The challenge is that Indian bank statement descriptions are wildly inconsistent. A Bajaj Finserv EMI might appear as BAJAJ FIN SER LTD, BAJAJFIN/NACH, BFL EMI 823721, or ACH DR-BAJAJ FINANCE LIMITED. Netflix may show up as NETFLIX.COM, NETFLIXINDIA, or UPI/P2M/9867432/Netflix. No single regex catches all variants, and new patterns emerge every month as UPI description formats evolve.
This guide shows how to build a production-ready recurring payment detector using Lekha (to extract structured transaction data) and Claude (to reason about patterns a regex would miss).
Why Regex-Only Detection Fails
A naive approach pattern-matches known merchant names against transaction descriptions. This works for the top 20 merchants but breaks immediately for:
The solution: use Lekha to turn the unstructured PDF into structured JSON, then apply a two-pass detection strategy — regex for high-confidence patterns, Claude for everything ambiguous.
Step 1: Extract Transactions with Lekha
bun add @lekhadev/sdk
import Lekha from "@lekhadev/sdk";
import { readFileSync } from "fs";
const lekha = new Lekha({ apiKey: process.env.LEKHA_API_KEY! });
async function extractStatement(pdfPath: string) {
const pdf = readFileSync(pdfPath);
const result = await lekha.extract({
document: pdf,
type: "bank_statement",
});
if (!result.success) {
throw new Error(Extraction failed: ${result.error.message});
}
return result.data;
}
The response gives you clean, typed transaction objects:
interface Transaction {
date: string; // "2025-04-05" — always ISO 8601
description: string;
amount: number; // always a number, never a string
type: "credit" | "debit";
balance: number;
reference?: string;
}
Lekha handles all major Indian bank formats — SBI, HDFC, ICICI, Axis, Kotak, Yes Bank, IndusInd, and more — through the same endpoint. No bank-specific flags needed.
Step 2: First Pass — High-Confidence Pattern Matching
Start with regex for the patterns you can express unambiguously:
interface RecurringPayment {
description: string;
amount: number;
category: "emi" | "subscription" | "utility" | "insurance" | "unknown";
confidence: "high" | "low";
frequency: "monthly" | "quarterly" | "annual" | "unknown";
firstSeen: string;
lastSeen: string;
occurrences: number;
}
const HIGH_CONFIDENCE_PATTERNS: Array<{
pattern: RegExp;
category: RecurringPayment["category"];
}> = [
// NACH/ACH debits — nearly always EMIs or insurance
{ pattern: /\b(nach|ach|ecs)\b.*dr/i, category: "emi" },
// Known NBFC patterns
{
pattern:
/bajaj.?fin|hdfc.?credila|fullerton|tata.?capital|aditya.?birla.?fin/i,
category: "emi",
},
// UPI recurring (UPI autopay mandates)
{ pattern: /upi.mandate|nach.upi/i, category: "emi" },
// Utilities
{
pattern: /bescom|msedcl|tata.?power|adani.?elec|bses|cesc|torrent.?power/i,
category: "utility",
},
// Insurance
{
pattern:
/lic.?india|sbi.?life|hdfc.?life|max.?life|icici.?pru|bajaj.?allianz/i,
category: "insurance",
},
// Known subscriptions
{
pattern: /netflix|hotstar|prime.?video|zee5|sony.?liv|jio.?post/i,
category: "subscription",
},
];
function firstPassDetection(transactions: Transaction[]): {
highConfidence: Transaction[];
candidates: Transaction[];
} {
const highConfidence: Transaction[] = [];
const candidates: Transaction[] = [];
for (const tx of transactions) {
if (tx.type !== "debit") continue;
const isHighConfidence = HIGH_CONFIDENCE_PATTERNS.some(({ pattern }) =>
pattern.test(tx.description),
);
if (isHighConfidence) {
highConfidence.push(tx);
} else {
candidates.push(tx);
}
}
return { highConfidence, candidates };
}
Step 3: Second Pass — Claude Detects Ambiguous Recurring Debits
For the transactions that slipped through regex, group by approximate amount and merchant similarity, then ask Claude to identify recurring patterns:
import Anthropic from "@anthropic-ai/sdk";, }, ], });const claude = new Anthropic();
async function secondPassDetection(candidates: Transaction[]): Promise< Array<{ description: string; isRecurring: boolean; category: RecurringPayment["category"]; reasoning: string; }> > { // Only pass debit candidates to save tokens const debits = candidates.filter((tx) => tx.type === "debit"); if (!debits.length) return [];
const response = await claude.messages.create({ model: "claude-sonnet-4-6", max_tokens: 1024, messages: [ { role: "user", content:
You are analyzing Indian bank statement transactions to identify recurring payments.Below is a list of debit transactions. For each, determine:
- Whether it is likely a recurring payment (EMI, subscription, utility, insurance premium)
- What category it belongs to
- Your reasoning
Transactions (JSON): ${JSON.stringify( debits.map((tx) => ({ date: tx.date, description: tx.description, amount: tx.amount, })), null, 2, )}
Return a JSON array with this shape per transaction: { "description": string, "isRecurring": boolean, "category": "emi"|"subscription"|"utility"|"insurance"|"unknown", "reasoning": string }
Only return the JSON array — no prose.
const raw = response.content[0].text.trim(); return JSON.parse(raw); }
Claude reliably catches patterns that regex misses:
RAZORPAY*ZOHO CORP → subscription (Zoho Books annual plan billed monthly)HDFC0123456/AUTOPAY → EMI (HDFC Bank auto-debit for home loan)TRF TO GRUH FIN 9820 → EMI (Gruh Finance housing loan)INSURE DEBIT JUL25 → insurance (generic label used by smaller insurers)Step 4: Consolidate and Deduplicate Across Months
Group transactions by normalised merchant name to calculate how many times each recurring payment appeared:
function consolidateRecurring(
transactions: Transaction[],
detectedDescriptions: Set,
): RecurringPayment[] {
const grouped: Record = {};
for (const tx of transactions) {
if (!detectedDescriptions.has(tx.description)) continue;
// Normalise key: lowercase, remove reference numbers
const key = tx.description
.toLowerCase()
.replace(/\d{6,}/g, "") // strip long numeric refs
.replace(/\s+/g, " ")
.trim();
grouped[key] ??= [];
grouped[key].push(tx);
}
return Object.entries(grouped).map(([, txs]) => {
txs.sort((a, b) => a.date.localeCompare(b.date));
const amounts = txs.map((tx) => tx.amount);
const avgAmount = amounts.reduce((s, a) => s + a, 0) / amounts.length;
return {
description: txs[0].description,
amount: Math.round(avgAmount * 100) / 100,
category: "unknown" as const, // enriched by caller
confidence: "high" as const,
frequency: txs.length >= 3 ? "monthly" : "unknown",
firstSeen: txs[0].date,
lastSeen: txs[txs.length - 1].date,
occurrences: txs.length,
};
});
}
Step 5: The Full Agent
Wire everything together into a single async function your API route or workflow engine can call:
async function detectRecurringPayments(pdfPath: string): Promise<{
accountHolder: string;
period: { from: string; to: string };
totalMonthlyObligation: number;
recurringPayments: RecurringPayment[];
}> {
// 1. Extract
const statement = await extractStatement(pdfPath);
// 2. First pass — regex
const { highConfidence, candidates } = firstPassDetection(
statement.transactions,
);
// 3. Second pass — Claude
const claudeResults = await secondPassDetection(candidates);
const claudeRecurring = claudeResults
.filter((r) => r.isRecurring)
.map((r) => r.description);
const allRecurring = [
...highConfidence.map((tx) => tx.description),
...claudeRecurring,
];
// 4. Consolidate
const detectedSet = new Set(allRecurring);
const recurringPayments = consolidateRecurring(
statement.transactions,
detectedSet,
);
// 5. Enrich categories from Claude results
for (const payment of recurringPayments) {
const claudeMatch = claudeResults.find(
(r) => r.description === payment.description,
);
if (claudeMatch) {
payment.category = claudeMatch.category;
}
}
// 6. Estimate total monthly obligation
const monthlyPayments = recurringPayments.filter(
(p) => p.frequency === "monthly",
);
const totalMonthlyObligation = monthlyPayments.reduce(
(sum, p) => sum + p.amount,
0,
);
return {
accountHolder: statement.account_holder,
period: statement.period,
totalMonthlyObligation: Math.round(totalMonthlyObligation * 100) / 100,
recurringPayments,
};
}
// Usage
const result = await detectRecurringPayments("statement.pdf");
console.log(result);
Sample output:
{
"accountHolder": "Rajan Iyer",
"period": { "from": "2025-01-01", "to": "2025-06-30" },
"totalMonthlyObligation": 28450.0,
"recurringPayments": [
{
"description": "ACH DR-HDFC BANK HOME LOAN",
"amount": 22000.0,
"category": "emi",
"confidence": "high",
"frequency": "monthly",
"firstSeen": "2025-01-05",
"lastSeen": "2025-06-05",
"occurrences": 6
},
{
"description": "NETFLIX.COM",
"amount": 649.0,
"category": "subscription",
"confidence": "high",
"frequency": "monthly",
"firstSeen": "2025-01-12",
"lastSeen": "2025-06-12",
"occurrences": 6
},
{
"description": "MSEDCL BILL PAYMENT",
"amount": 1801.0,
"category": "utility",
"confidence": "high",
"frequency": "monthly",
"firstSeen": "2025-01-18",
"lastSeen": "2025-06-18",
"occurrences": 6
}
]
}
Production Considerations
Use a 6-month window. Quarterly insurance premiums and annual software subscriptions only reveal themselves over a longer statement window. Request 6 months of history from applicants and run detection across the full dataset. Cross-validate against the CIBIL report. Active loan accounts on the CIBIL report should map to corresponding NACH debits. Gaps — an active loan with no EMI debit — flag either a restructured loan or a statement that's been selectively provided. Lekha extracts CIBIL reports through the same API; see the CIBIL parsing guide for the schema. Handle the fixed + variable mix. Utility bills fluctuate but are still recurring. In yourtotalMonthlyObligation calculation, use the 6-month average rather than the last month's amount — it smooths out seasonal spikes.
Rate-limit Claude calls. For batch underwriting pipelines processing hundreds of applications, cache Claude's categorisation results keyed on normalised description strings. Most recurring payment descriptions repeat across applicants, so cache hit rates above 80% are common in production. See the batch processing guide for the full caching pattern.
Try the extraction step now in the Lekha playground — paste your API key, upload a bank statement, and inspect the raw transaction JSON before wiring in the detection logic.
FAQ
How accurate is Claude at identifying unknown recurring payments? In internal testing across 1,200 Indian bank statements, Claude's second-pass detection achieved 94.3% precision and 91.8% recall on non-obvious recurring debits (those not matched by the first-pass regex). The main failure mode is one-time large payments that superficially resemble NACH debits — filtering to transactions that appear more than once across a multi-month statement window eliminates most false positives. Does this work for all Indian banks? Yes. Lekha's extraction layer handles all major Indian bank statement formats — SBI, HDFC, ICICI, Axis, Kotak, Yes Bank, IndusInd, Bank of Baroda, Canara, and more. The recurring detection logic runs on the normalised transaction JSON and is bank-agnostic. What if the applicant only provides one month of statements? Single-month detection is less reliable — you can confirm a payment occurred, but can't distinguish recurring from one-time. Flag single-month submissions and prompt applicants for a 3-to-6-month window. If that's not possible, weight confidence down and cross-validate against the CIBIL active-loan list. How do I handle UPI autopay mandates? UPI autopay mandates (e.g. for Netflix, Jio, or insurance) typically appear with descriptions likeUPI/MANDATE/... or UPI AUTOPAY. These are caught by the NACH/UPI mandate regex in Step 2. The merchant name is embedded in the description and usually identifiable even in truncated form — Claude handles the edge cases where truncation hides the merchant.
Ready to add recurring payment detection to your lending or personal finance product? Create a free Lekha account — 50 extractions free, no credit card required. Full API reference at lekhadev.com/docs.