In microfinance QR payment ecosystems, Click-Through Rate (CTR) is not just misleading it’s economically dangerous. A borrower clicking "Pay Now" but failing at checkout due to insufficient balance, network timeout, or incorrect merchant mapping represents a broken promise that erodes trust in both the lender and the payment rail. For MFIs serving underbanked merchants and borrowers, recommendation quality must be measured by successful transaction completion, repayment velocity, and merchant retention not engagement. This article demonstrates how to build and evaluate a QR Payment Recommendation Engine where quality measurement is embedded directly into the LangGraph orchestration layer, using ChromaDB for contextual retrieval and persistent state for longitudinal outcome tracking.
The Real-Time Use Case: Smart QR Payment Routing for Borrower-Merchants
Scenario: An MFI serves 150K sari-sari store owners who are both borrowers and QR payment acceptance points. When a customer scans their QR code, the system must recommend:
Optimal payment routing (e.g., GCash vs. Maya vs. direct bank) based on real-time success rates
Repayment-linked promotions ("Pay ₱200 now via this QR to reduce your loan interest by 0.5%")
Merchant upsells ("Customer frequently buys rice; suggest bundle with canned goods")
Why CTR Fails Here:
High CTR on payment prompts with low completion = failed transactions + borrower frustration
Promotional clicks without linked repayment = marketing cost with zero financial return
Merchant recommendations that don’t convert to repeat scans = churn risk

The Solution: A multi-agent system where a Transactional Quality Evaluator runs inline, computing a composite Payment Recommendation Quality Score (PRQS) across four dimensions before any QR prompt or promotion is rendered.
Part 1: The Four-Dimension PRQS Framework
| Dimension | Metric | Why It Matters in Microfinance QR |
|---|---|---|
| Transaction Completable | Real-time balance check + rail availability + amount validity | Prevents failed payments that damage trust |
| Repayment Linkage | % of recommended payment that maps to active loan obligation | Ensures payments serve dual purpose: commerce + debt reduction |
| Merchant Viability | Historical QR scan-to-completion rate for this merchant + time-of-day pattern | Avoids recommending routes/amounts this merchant can’t reliably process |
| Borrower Affordability | Post-payment residual balance ≥ household buffer threshold | Prevents over-payment that triggers new borrowing cycle |
These are computed per-recommendation in real-time and persisted for offline cohort analysis.
Part 2: Data Representation for QR Payment Context
Merchant-Borrower Profile (ChromaDB Collection: merchant_borrowers)
merchant_document = {
"id": "mb_sari_22847",
"page_content": "Sari-sari store, Barangay San Isidro. Active QR since 2025-11.
Avg daily QR volume: ₱3,200. Peak hours: 6-9AM, 5-8PM.
Current loan: ₱18,500 outstanding, weekly installment ₱1,200 due Fridays.
Preferred rail: GCash (92% success rate). Maya failures: 3x last month.",
"metadata": {
"type": "merchant_borrower",
"business_type": "sari_sari_store",
"region": "NCR",
"qr_active_days": 284,
"avg_daily_volume_php": 3200,
"peak_hours": ["06-09", "17-20"],
"outstanding_loan_php": 18500,
"next_installment_php": 1200,
"next_due_date": "2026-08-14",
"preferred_rail": "gcash",
"rail_success_rates": {"gcash": 0.92, "maya": 0.71, "bank_direct": 0.88},
"household_buffer_php": 1500, # Min post-payment liquidity
"last_failed_transaction_reason": "insufficient_balance"
}
}Payment Rail & Promotion Catalog (ChromaDB Collection: qr_payment_options)
payment_option_document = {
"id": "promo_repay_gcash_aug12",
"page_content": "Pay loan installment via GCash QR today: 0.5% interest reduction
on next cycle. Instant posting. No extra fees. Valid until 2026-08-12 23:59.",
"metadata": {
"type": "repayment_promotion",
"rail": "gcash",
"promotion_type": "interest_reduction",
"min_payment_php": 500,
"max_payment_php": 5000,
"requires_sufficient_balance": True,
"real_time_balance_check": True, # Triggers online validation
"merchant_eligible": True,
"time_window": {"start": "06:00", "end": "23:59"},
"historical_completion_rate": 0.87,
"avg_processing_time_sec": 4.2
}
}Part 3: Multi-Agent Architecture with Inline PRQS Evaluation
Step 1: State Schema with PRQS Fields
from typing import Annotated, List, Dict, Optional, Literal
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
from langchain_core.documents import Document
class QRPaymentRecState(TypedDict):
messages: Annotated[List, add_messages]
merchant_borrower_id: str
merchant_profile: Optional[Document]
# Retrieval
candidate_payment_options: List[Document]
# === ONLINE VALIDATION STATE ===
real_time_balance_php: Optional[float]
rail_available: Dict[str, bool] # Real-time API check
transaction_completable: bool
# === PRQS EVALUATION STATE ===
completeness_score: float # 0-1: Can this txn actually complete?
repayment_linkage_score: float # 0-1: How much reduces loan?
merchant_viability_score: float # 0-1: Historical success for this merchant
affordability_score: float # 0-1: Post-payment buffer preserved?
overall_prqs: float # Weighted composite
prqs_reasoning: str
# Output
selected_recommendation: Dict
agent_trace: List[str]Step 2: Transaction Completabilty Validator (Hard Gate)
This node makes real-time API calls before any LLM evaluation. No amount of semantic relevance matters if the transaction will fail.
import httpx
from datetime import datetime
async def transaction_validator_node(state: QRPaymentRecState):
"""Real-time validation BEFORE LLM evaluation. Non-negotiable gate."""
profile = state["merchant_profile"]
if not profile:
return {"transaction_completable": False, "agent_trace": ["validator: no profile"]}
meta = profile.metadata
# 1. Real-time balance check (via core banking/payment API)
async with httpx.AsyncClient(timeout=3.0) as client:
try:
resp = await client.get(
f"https://core-api.mfi.internal/balance/{state['merchant_borrower_id']}"
)
balance = resp.json()["available_balance_php"]
except Exception:
balance = None # Treat as incomplete if API fails
# 2. Real-time rail availability
rail_status = {}
for rail in ["gcash", "maya", "bank_direct"]:
try:
resp = await client.get(f"https://rail-monitor.internal/status/{rail}")
rail_status[rail] = resp.json()["operational"]
except Exception:
rail_status[rail] = False
# 3. Basic completable check
completable = (
balance is not None and
balance >= meta.get("next_installment_php", 0) * 0.5 and # At least partial payment possible
any(rail_status.values())
)
return {
"real_time_balance_php": balance,
"rail_available": rail_status,
"transaction_completable": completable,
"agent_trace": state["agent_trace"] + [
f"validator: balance={balance}, rails={rail_status}, completable={completable}"
]
}Step 3: PRQS Evaluation Agent (Composite Scoring)
Runs ONLY if transaction is completable. Computes the four-dimension score.
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
prqs_prompt = ChatPromptTemplate.from_messages([
("system", """You are a Microfinance QR Payment Quality Auditor.
Score each candidate payment option on FOUR dimensions (0-1 each):
1. COMPLETENESS: Given real-time balance ({balance}), rail status ({rails}),
and option requirements, will this transaction complete successfully?
2. REPAYMENT LINKAGE: What % of the recommended payment amount directly reduces
the outstanding loan ({outstanding})? Pure commerce = 0, full installment = 1.
3. MERCHANT VIABILITY: Based on merchant's historical success rate for this rail
({rail_success}) and current time vs peak hours ({current_hour}, {peak_hours}),
how likely is successful processing?
4. AFFORDABILITY: After this payment, does residual balance ({balance} - payment)
remain ≥ household buffer ({buffer})? Below buffer = 0, well above = 1.
WEIGHTS: completeness=0.35, repayment=0.30, viability=0.20, affordability=0.15
Return JSON: {{
"completeness": float, "repayment_linkage": float,
"merchant_viability": float, "affordability": float,
"overall_prqs": float, "reasoning": string
}}"""),
("human", "Candidates: {candidates}\nMerchant: {profile}")
])
async def prqs_evaluation_node(state: QRPaymentRecState):
if not state["transaction_completable"]:
return {
"overall_prqs": 0.0,
"prqs_reasoning": "Transaction not completable; skipping PRQS evaluation",
"selected_recommendation": None
}
chain = prqs_prompt | llm.with_structured_output(dict)
result = chain.invoke({
"balance": state["real_time_balance_php"],
"rails": state["rail_available"],
"outstanding": state["merchant_profile"].metadata["outstanding_loan_php"],
"rail_success": state["merchant_profile"].metadata["rail_success_rates"],
"current_hour": datetime.now().strftime("%H"),
"peak_hours": state["merchant_profile"].metadata["peak_hours"],
"buffer": state["merchant_profile"].metadata["household_buffer_php"],
"candidates": "\n---\n".join(d.page_content for d in state["candidate_payment_options"]),
"profile": state["merchant_profile"].page_content
})
return {
"completeness_score": result["completeness"],
"repayment_linkage_score": result["repayment_linkage"],
"merchant_viability_score": result["merchant_viability"],
"affordability_score": result["affordability"],
"overall_prqs": result["overall_prqs"],
"prqs_reasoning": result["reasoning"],
"agent_trace": state["agent_trace"] + [f"prqs_eval: score={result['overall_prqs']:.3f}"]
}Step 4: Conditional Rendering + Outcome Logging
from langgraph.graph import StateGraph, START, END
workflow = StateGraph(QRPaymentRecState)
workflow.add_node("load_context", load_merchant_context_node)
workflow.add_node("retrieve_options", payment_option_retriever_node)
workflow.add_node("validate_transaction", transaction_validator_node)
workflow.add_node("evaluate_prqs", prqs_evaluation_node)
workflow.add_node("render_recommendation", render_qr_prompt_node)
workflow.add_node("safe_fallback", generic_payment_fallback_node)
workflow.add_node("log_outcome_hook", log_prqs_event_node) # ← Feeds offline pipeline
workflow.add_edge(START, "load_context")
workflow.add_edge("load_context", "retrieve_options")
workflow.add_edge("retrieve_options", "validate_transaction")
workflow.add_edge("validate_transaction", "evaluate_prqs")
def prqs_gate(state: QRPaymentRecState):
if state["overall_prqs"] >= 0.70:
return "render_recommendation"
return "safe_fallback"
workflow.add_conditional_edges("evaluate_prqs", prqs_gate)
workflow.add_edge("render_recommendation", "log_outcome_hook")
workflow.add_edge("safe_fallback", "log_outcome_hook")
workflow.add_edge("log_outcome_hook", END)
from langgraph.checkpoint.postgres import PostgresSaver
app = workflow.compile(checkpointer=PostgresSaver.from_conn_string("postgresql://..."))Step 5: Offline PRQS Calibration Pipeline
import pandas as pd
from sqlalchemy import create_engine
engine = create_engine("postgresql://...")
def calibrate_prqs_weekly():
"""Correlate PRQS scores with actual transaction outcomes."""
df = pd.read_sql("""
SELECT overall_prqs, completeness_score, repayment_linkage_score,
merchant_viability_score, affordability_score,
transaction_completed, repayment_amount_php,
merchant_retained_30d, created_at
FROM qr_recommendation_outcomes
WHERE created_at >= NOW() - INTERVAL '7 days'
AND transaction_completed IS NOT NULL
""", engine)
# 1. Validate PRQS predictive power
prqs_auc = roc_auc_score(df["transaction_completed"], df["overall_prqs"])
# 2. Dimension-level calibration: which dimension best predicts completion?
dimension_correlations = {
col: pearsonr(df[col], df["transaction_completed"])[0]
for col in ["completeness_score", "repayment_linkage_score",
"merchant_viability_score", "affordability_score"]
}
# 3. Adjust weights if dimension predictive power has shifted
current_weights = load_prqs_weights()
total_corr = sum(abs(v) for v in dimension_correlations.values())
new_weights = {k: abs(v)/total_corr for k, v in dimension_correlations.items()}
# 4. Merchant-level alerts: identify merchants with persistently low viability
merchant_viability = df.groupby("merchant_borrower_id")["merchant_viability_score"].mean()
low_viability_merchants = merchant_viability[merchant_viability < 0.4].index.tolist()
if low_viability_merchants:
alert_field_officers(low_viability_merchants, reason="Low QR completion viability")
# 5. Deploy updated weights with safety guardrails
if prqs_auc > 0.65: # Only update if model is still predictive
save_prqs_weights(new_weights)
log_calibration_run(prqs_auc, dimension_correlations, new_weights)
else:
send_alert("PRQS AUC dropped below 0.65; manual review required")Production Considerations for Microfinance QR
Real-Time API Budget: Balance/rail checks add latency. Cache rail status (TTL: 30s). Use optimistic rendering with async validation—show generic QR immediately, upgrade to personalized promo only after validation completes within 800ms SLA.
Offline Outcome Attribution: Link recommendations to transactions via
recommendation_idpassed through the QR payload. Handle attribution windows carefully—repayment linkage may manifest 24-48h post-payment.Regulatory Disclosure: When repayment-linked promotions are shown, clearly disclose interest reduction terms per BSP consumer protection rules. Store disclosure acknowledgment in state for audit.
Network Resilience: In areas with intermittent connectivity, pre-cache top-3 payment options per merchant. Fall back to cached PRQS scores when real-time validation APIs are unreachable. Log fallback events separately for offline analysis.
Field Officer Integration: Low-viability merchant alerts should integrate with the MFI’s field officer mobile app—not just email. Include suggested interventions (QR repositioning, staff retraining, rail switch recommendation).
Conclusion
In microfinance QR payments, recommendation quality isn’t about engagement—it’s about transactional integrity and financial inclusion sustainability. By embedding a four-dimension PRQS evaluator directly into the LangGraph orchestration layer, validating transaction completability before LLM scoring, and closing the loop with offline calibration tied to actual repayment and merchant retention outcomes, you build a system that optimizes for what truly matters: successful payments that reduce debt and sustain merchant livelihoods. CTR tells you what was clicked. PRQS tells you whether that click created real financial value. In microfinance, that distinction determines whether technology serves people—or merely extracts attention from them.

Join the conversation! Your thoughts help the community grow.