← Back to blog
·9 min read

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.

ai agentbank statement parseremi detectionrecurring paymentsindian fintechdocument aitypescriptlending

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:

  • NACH/ECS debits — ACH debit descriptions vary by clearing house and originating NBFC. The merchant name often appears mid-string, abbreviated, or truncated to 18 characters.
  • Utility payments — BESCOM, MSEDCL, TATA Power, and municipal water boards each use different description formats even on the same bank's statement.
  • New-age NBFCs — KreditBee, MoneyView, Navi, Fibe, and CASHe all have non-standard NACH reference strings.
  • Insurance premiums — LIC, SBI Life, and HDFC Life are relatively identifiable; hundreds of smaller insurers are not.
  • 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 your totalMonthlyObligation 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 like UPI/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.