Introduction

In the microfinance sector, repayment tracking is the lifeblood of the institution. With the rapid digitization of micro-loans, borrowers increasingly repay via mobile money. However, this digital shift has introduced sophisticated fraud vectors, such as Account Takeover (ATO) during the repayment process or collusive "ghost repayments" by field agents.

Detecting and investigating these anomalies requires more than just a static machine learning model; it requires dynamic, contextual reasoning. When we first built our fraud detection pipeline, we used plain LangChain. We quickly hit a wall. To build a truly robust, enterprise-grade investigation system for microfinance repayments, we had to migrate to LangGraph. This article explores why this architectural shift was necessary and provides a complete, end-to-end Proof of Concept (POC).

The Catalyst: Why LangGraph over Plain LangChain?

Plain LangChain (using LCEL or sequential chains) is excellent for deterministic, linear workflows: Prompt → LLM → Tool → Output. However, fraud investigation in microfinance repayment tracking is inherently non-linear and iterative.

We transitioned to LangGraph for three critical reasons:

  1. Cyclic Workflows (Loops): In plain LangChain, if an agent realizes it lacks information, the chain breaks or requires complex, messy workarounds. LangGraph natively supports cycles. If our RAG agent finds that a borrower's historical repayment pattern is ambiguous, it can loop back to the Data Extraction agent to pull deeper transaction logs.

  2. Persistent State and Checkpointing: Fraud investigations often require human-in-the-loop (HITL) intervention. Plain LangChain is largely stateless. LangGraph’s MemorySaver allows us to pause a graph mid-investigation, save the exact state to a database, and have a human loan officer resume or override the decision days later.

  3. Complex Multi-Agent Routing: Fraud detection requires specialized agents (e.g., Transaction Analyzer, RAG Policy Investigator, Risk Router). LangGraph’s conditional edges allow these agents to dynamically route to one another based on intermediate state, rather than following a rigid, pre-defined sequence.

Real-Time Use Case: Detecting Account Takeover in Digital Repayments

The Scenario: A borrower, typically repaying $50 via their own registered mobile number, suddenly makes a $500 repayment from an unregistered, newly activated mobile money account.

The Workflow: A plain chain would just flag this as "High Risk" and block it, causing massive friction for legitimate users who might be using a spouse's phone. Our LangGraph system, however, investigates:

  1. The Transaction Agent flags the anomaly.

  2. The RAG Investigator queries the vector database for the borrower's historical context and institutional fraud policies.

  3. If the RAG context is insufficient, the graph loops back to the Transaction Agent to check for secondary signals (e.g., device ID, GPS).

  4. Finally, the Router Agent decides whether to auto-approve, auto-block, or route to a human investigator, saving the state for audit.

Step-by-Step POC Implementation

Step 1: Defining State, Memory, and Checkpointing

We define a strict state schema and utilize LangGraph's MemorySaver to persist the investigation state across nodes and potential human interventions.

# backend/graph_state.py
from typing import TypedDict, List, Annotated, Literal
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.memory import MemorySaver

class FraudInvestigationState(TypedDict):
    transaction_id: str
    borrower_id: str
    transaction_data: dict
    investigation_findings: List[str]
    needs_more_data: bool
    final_decision: Literal["APPROVE", "BLOCK", "HUMAN_REVIEW"]
    messages: Annotated[List[str], "Audit trail of agent actions"]

# Initialize Checkpointer for Persistent State & Memory
memory = MemorySaver()

Step 2: Building the Cyclic Multi-Agent Workflow

Here we demonstrate the power of LangGraph: conditional edges and loops.

# backend/agents.py
from .graph_state import FraudInvestigationState, memory

# Mock Vector DB for RAG (Fraud Policies & Historical Context)
MOCK_RAG_DB = {
    "BOR-992": "Policy: Allow 1 alternate repayment device if borrower has >90% on-time history. History: 95% on-time.",
    "BOR-104": "Policy: Strict device matching required. History: 80% on-time, previous fraud flag."
}

def transaction_analyzer(state: FraudInvestigationState):
    """Analyzes raw transaction data for anomalies."""
    tx = state["transaction_data"]
    findings = state["investigation_findings"]
    
    if tx.get("device_mismatch"):
        findings.append("Transaction Agent: Flagged device mismatch. New mobile number used.")
        return {"investigation_findings": findings, "needs_more_data": True}
    return {"investigation_findings": findings, "needs_more_data": False}

def rag_investigator(state: FraudInvestigationState):
    """Retrieves historical context and policies via RAG."""
    borrower = state["borrower_id"]
    context = MOCK_RAG_DB.get(borrower, "No historical context found.")
    
    findings = state["investigation_findings"]
    findings.append(f"RAG Agent: Retrieved context -> {context}")
    
    # Simulate LLM reasoning: if context is generic, we need more data
    needs_more = "No historical context" in context or "Strict device" in context
    return {"investigation_findings": findings, "needs_more_data": needs_more}

def deep_data_extractor(state: FraudInvestigationState):
    """Loops back to get deeper data if RAG was inconclusive."""
    findings = state["investigation_findings"]
    findings.append("Deep Data Agent: Pulled secondary signals (GPS, Device IMEI). Device IMEI matches historical baseline.")
    # After pulling deep data, we resolve the need for more data
    return {"investigation_findings": findings, "needs_more_data": False}

def risk_router(state: FraudInvestigationState):
    """Makes the final decision based on accumulated state."""
    findings = " | ".join(state["investigation_findings"])
    
    if "Strict device" in findings and "Device IMEI matches" not in findings:
        decision = "BLOCK"
    elif "95% on-time" in findings and "Device IMEI matches" in findings:
        decision = "APPROVE"
    else:
        decision = "HUMAN_REVIEW"
        
    return {"final_decision": decision, "messages": [f"Router Agent: Final decision is {decision}."]}

# Routing Logic for Conditional Edges (The "LangGraph Magic")
def route_after_rag(state: FraudInvestigationState):
    if state["needs_more_data"]:
        return "deep_data" # LOOP BACK to get more info
    return "router" # PROCEED to final decision

# Build the Cyclic Graph
workflow = StateGraph(FraudInvestigationState)

workflow.add_node("analyzer", transaction_analyzer)
workflow.add_node("rag", rag_investigator)
workflow.add_node("deep_data", deep_data_extractor)
workflow.add_node("router", risk_router)

workflow.set_entry_point("analyzer")
workflow.add_edge("analyzer", "rag")

# Add the conditional loop!
workflow.add_conditional_edges("rag", route_after_rag, {
    "deep_data": "deep_data",
    "router": "router"
})

# Loop deep_data back to router once data is gathered
workflow.add_edge("deep_data", "router")
workflow.add_edge("router", END)

app = workflow.compile(checkpointer=memory)

Step 3: The FastAPI Backend

We expose the graph, utilizing thread_id to maintain the state, allowing for future human-in-the-loop integrations.

# backend/main.py
from fastapi import FastAPI
from pydantic import BaseModel
from .agents import app

app_api = FastAPI(title="Microfinance Fraud Detection POC")

class FraudRequest(BaseModel):
    transaction_id: str
    borrower_id: str
    transaction_data: dict
    thread_id: str = "fraud_audit_01"

@app_api.post("/investigate-repayment")
async def investigate_repayment(req: FraudRequest):
    config = {"configurable": {"thread_id": req.thread_id}}
    
    initial_state = {
        "transaction_id": req.transaction_id,
        "borrower_id": req.borrower_id,
        "transaction_data": req.transaction_data,
        "investigation_findings": [],
        "needs_more_data": False,
        "final_decision": "",
        "messages": []
    }
    
    final_state = app.invoke(initial_state, config)
    return {
        "decision": final_state["final_decision"],
        "audit_trail": final_state["investigation_findings"],
        "state_saved": True # Indicates memory is checkpointed
    }

Step 4: The Streamlit Frontend

A dashboard for fraud analysts to trigger investigations and view the cyclic reasoning of the agents.

# frontend/app.py
import streamlit as st
import requests

st.set_page_config(page_title="Repayment Fraud Investigator", layout="wide")
st.title(" Microfinance Repayment Fraud Investigator")
st.markdown("*Powered by LangGraph Cyclic Multi-Agent RAG*")

st.sidebar.header("Transaction Input")
tx_id = st.sidebar.text_input("Transaction ID", "TXN-99281")
borrower_id = st.sidebar.text_input("Borrower ID", "BOR-992")
thread_id = st.sidebar.text_input("Audit Thread ID", "audit_thread_01")

device_mismatch = st.sidebar.checkbox("Simulate Device Mismatch?", True)

if st.sidebar.button("Run Investigation"):
    tx_data = {"amount": 500, "device_mismatch": device_mismatch}
    
    with st.spinner("Agents are investigating (watch for loops)..."):
        response = requests.post(
            "http://localhost:8000/investigate-repayment", 
            json={"transaction_id": tx_id, "borrower_id": borrower_id, 
                  "transaction_data": tx_data, "thread_id": thread_id}
        )
        
        if response.status_code == 200:
            data = response.json()
            
            col1, col2 = st.columns(2)
            with col1:
                st.subheader("Final Routing Decision")
                if data["decision"] == "APPROVE":
                    st.success(f" {data['decision']}")
                elif data["decision"] == "BLOCK":
                    st.error(f" {data['decision']}")
                else:
                    st.warning(f" {data['decision']}")
                    
                st.caption(f"State Checkpointed: {data['state_saved']} (Ready for Human Review)")
                
            with col2:
                st.subheader("Agent Audit Trail (Cyclic Reasoning)")
                for finding in data["audit_trail"]:
                    st.info(f" {finding}")

Conclusion

The transition from plain LangChain to LangGraph was not just a technical upgrade; it was a fundamental requirement for enterprise fraud detection in microfinance. Plain chains force a linear, rigid path that breaks down when faced with the ambiguity of real-world financial fraud. By leveraging LangGraph, we introduced cyclic workflows that allow agents to iteratively gather data until they reach a high-confidence conclusion. We utilized persistent state to ensure that every investigation is fully auditable and can be seamlessly handed off to a human loan officer. Combined with RAG for institutional policy retrieval, this multi-agent architecture ensures that legitimate microfinance borrowers experience minimal friction, while sophisticated repayment fraud is dynamically and accurately intercepted.