Worked Example: Payment Tracker
On this page
A Complete End-to-End Project
Previous lessons isolated each mechanism: tool definitions, the call cycle, error handling, and security. Now it’s time to assemble them. You will build a complete conversational payment tracker where users ask questions in natural language and the system queries a Pandas DataFrame via function calling. This use case draws directly from the official Mistral documentation. It fits in about a hundred lines while exposing the five building blocks of a real application: data, business functions, JSON specifications, dispatch, and the conversation loop.
The Data
A DataFrame with five transactions covers the interesting scenarios: a customer with multiple payments, a pending payment, and a failed payment.
import pandas as pd
import json
import os
from mistralai.client import Mistral
from functools import partial
# Transactions DataFrame
data = {
"transaction_id": ["T1001", "T1002", "T1003", "T1004", "T1005"],
"customer_id": ["C001", "C002", "C003", "C002", "C001"],
"payment_amount": [125.50, 89.99, 350.00, 45.00, 210.75],
"payment_date": ["2026-03-15", "2026-03-16", "2026-03-17", "2026-03-17", "2026-03-18"],
"payment_status": ["paid", "paid", "pending", "paid", "failed"]
}
df = pd.DataFrame(data)
Business Functions
Four functions cover typical payment tracking needs. The first three operate on a single transaction and differ by granularity: status only, date only, or full details. This apparent redundancy is deliberate—it lets the model request only what it needs, reducing unnecessary data in the context. The fourth shifts perspective and operates by customer.
Note the common contract: all return a JSON string, even on failure, and all handle missing identifiers instead of letting Pandas raise an exception on an empty index.
def retrieve_payment_status(df: pd.DataFrame, transaction_id: str) -> str:
"""Retrieves the status of a payment."""
if transaction_id in df["transaction_id"].values:
row = df[df["transaction_id"] == transaction_id].iloc[0]
return json.dumps({
"transaction_id": transaction_id,
"status": row["payment_status"]
})
return json.dumps({"error": f"Transaction {transaction_id} not found"})
def retrieve_payment_date(df: pd.DataFrame, transaction_id: str) -> str:
"""Retrieves the date of a payment."""
if transaction_id in df["transaction_id"].values:
row = df[df["transaction_id"] == transaction_id].iloc[0]
return json.dumps({
"transaction_id": transaction_id,
"date": row["payment_date"]
})
return json.dumps({"error": f"Transaction {transaction_id} not found"})
def retrieve_payment_details(df: pd.DataFrame, transaction_id: str) -> str:
"""Retrieves all details of a payment."""
if transaction_id in df["transaction_id"].values:
row = df[df["transaction_id"] == transaction_id].iloc[0]
return json.dumps({
"transaction_id": transaction_id,
"customer_id": row["customer_id"],
"amount": float(row["payment_amount"]),
"date": row["payment_date"],
"status": row["payment_status"]
})
return json.dumps({"error": f"Transaction {transaction_id} not found"})
def list_customer_transactions(df: pd.DataFrame, customer_id: str) -> str:
"""Lists all transactions for a given customer."""
customer_df = df[df["customer_id"] == customer_id]
if customer_df.empty:
return json.dumps({"error": f"Customer {customer_id} not found"})
transactions = []
for _, row in customer_df.iterrows():
transactions.append({
"transaction_id": row["transaction_id"],
"amount": float(row["payment_amount"]),
"date": row["payment_date"],
"status": row["payment_status"]
})
return json.dumps({"customer_id": customer_id, "transactions": transactions})
The float(row["payment_amount"]) conversion deserves a second look: without it, json.dumps fails on NumPy types. This is the kind of detail you only catch at runtime.
JSON Specifications
The model sees none of the code above. It only knows these descriptions. Explicitly listing the possible statuses in retrieve_payment_status and reminding the model of the T1001 format in every parameter description enables it to choose the right function and extract the identifier correctly.
tools = [
{
"type": "function",
"function": {
"name": "retrieve_payment_status",
"description": "Retrieves the status of a payment (paid, pending, failed)",
"parameters": {
"type": "object",
"properties": {
"transaction_id": {
"type": "string",
"description": "Transaction identifier (e.g., T1001)"
}
},
"required": ["transaction_id"]
}
}
},
{
"type": "function",
"function": {
"name": "retrieve_payment_date",
"description": "Retrieves the date of a payment",
"parameters": {
"type": "object",
"properties": {
"transaction_id": {
"type": "string",
"description": "Transaction identifier (e.g., T1001)"
}
},
"required": ["transaction_id"]
}
}
},
{
"type": "function",
"function": {
"name": "retrieve_payment_details",
"description": "Retrieves all details of a payment: amount, date, status, customer",
"parameters": {
"type": "object",
"properties": {
"transaction_id": {
"type": "string",
"description": "Transaction identifier (e.g., T1001)"
}
},
"required": ["transaction_id"]
}
}
},
{
"type": "function",
"function": {
"name": "list_customer_transactions",
"description": "Lists all transactions for a given customer",
"parameters": {
"type": "object",
"properties": {
"customer_id": {
"type": "string",
"description": "Customer identifier (e.g., C001)"
}
},
"required": ["customer_id"]
}
}
}
]
The Dispatch Dictionary
Here’s the catch: your functions expect df as the first argument, but the model will never provide it—it doesn’t know it exists, and that’s fine. functools.partial solves this elegantly by pre-binding the DataFrame, so each entry in the dictionary becomes callable with only the arguments the model produces.
available_functions = {
"retrieve_payment_status": partial(retrieve_payment_status, df),
"retrieve_payment_date": partial(retrieve_payment_date, df),
"retrieve_payment_details": partial(retrieve_payment_details, df),
"list_customer_transactions": partial(list_customer_transactions, df),
}
The Conversation Loop
The system message sets the role, language, and expected formatting for amounts. The chat function orchestrates the rest, and its structure deserves close attention: the while message.tool_calls loop handles a single call, multiple parallel calls, or a chain where the model requests another function after seeing the first result. The inner loop executes all calls for the current turn; the outer loop re-invokes the model as long as it keeps requesting tools.
client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])
model = "mistral-large-latest"
messages = [
{
"role": "system",
"content": "You are a payment tracking assistant. "
"Respond in English. Use the available tools "
"to answer questions about transactions. "
"Format amounts in euros with 2 decimal places."
}
]
def chat(user_input: str) -> str:
"""Complete conversation turn with tool_calls handling."""
messages.append({"role": "user", "content": user_input})
response = client.chat.complete(
model=model, messages=messages, tools=tools
)
message = response.choices[0].message
# Loop while the model wants to call functions
while message.tool_calls:
messages.append(message)
for tool_call in message.tool_calls:
fn_name = tool_call.function.name
fn_params = json.loads(tool_call.function.arguments)
if fn_name in available_functions:
result = available_functions[fn_name](**fn_params)
else:
result = json.dumps({"error": f"Unknown function {fn_name}"})
messages.append({
"role": "tool",
"name": fn_name,
"content": result,
"tool_call_id": tool_call.id
})
response = client.chat.complete(
model=model, messages=messages, tools=tools
)
message = response.choices[0].message
messages.append(message)
return message.content
What the Conversation Reveals
Test this with four consecutive turns. The second is the most instructive: “this transaction” contains no identifier, yet the model calls retrieve_payment_details with T1001. It resolved the anaphora using the history preserved in messages, which would be impossible if you reset the list on every call. The fourth turn shows the other side: no function searches for failed payments, but the model queries the relevant customer and filters the result itself.
# Turn 1
print(chat("What is the status of payment T1001?"))
# -> "Payment T1001 has status: paid."
# Turn 2
print(chat("Give me all the details for this transaction."))
# -> "Here are the details for payment T1001:
# - Customer: C001
# - Amount: 125.50 euros
# - Date: March 15, 2026
# - Status: paid"
# Turn 3
print(chat("What are all the transactions for customer C002?"))
# -> "Customer C002 has 2 transactions:
# - T1002: 89.99 euros (paid, March 16, 2026)
# - T1004: 45.00 euros (paid, March 17, 2026)"
# Turn 4
print(chat("Are there any failed payments?"))
# -> "Yes, payment T1005 for customer C001 (210.75 euros) failed."
The formatting of amounts with a decimal point and dates written out in full comes from no line of code: it follows from the system prompt. This is the most useful takeaway from this chapter—half of the perceived quality happens in the descriptions and the system message, not in the calling mechanics.
Key Takeaways
- A complete payment tracker requires 4 functions covering the main use cases
functools.partialbinds the DataFrame to the functions for clean dispatch- The
while message.tool_callsloop automatically handles sequential and parallel calls - The system message guides the model on tone, language, and formatting
- Test with multi-turn conversations to verify the model maintains context