From 84ee7b9d3f7d3a455d40831eb2341225bff98690 Mon Sep 17 00:00:00 2001 From: Raiyan Date: Mon, 14 Sep 2026 17:53:14 +0530 Subject: [PATCH] feat: add FinCall invoice collection app --- apps/python/fincall/README.md | 105 +++ apps/python/fincall/app.py | 689 ++++++++++++++++++ .../python/fincall/data/invoices.example.json | 32 + apps/python/fincall/fincall.py | 114 +++ apps/python/fincall/requirements.txt | 3 + 5 files changed, 943 insertions(+) create mode 100644 apps/python/fincall/README.md create mode 100644 apps/python/fincall/app.py create mode 100644 apps/python/fincall/data/invoices.example.json create mode 100644 apps/python/fincall/fincall.py create mode 100644 apps/python/fincall/requirements.txt diff --git a/apps/python/fincall/README.md b/apps/python/fincall/README.md new file mode 100644 index 0000000..24f76b8 --- /dev/null +++ b/apps/python/fincall/README.md @@ -0,0 +1,105 @@ +# FinCall + +AI-powered financial operations phone agent for overdue invoice follow-up using CALL-E. + +## What it does + +FinCall helps finance teams prioritize overdue invoices and conduct structured payment follow-up calls. + +The workflow is: + +1. Load overdue invoices +2. Calculate collection priority +3. Select an invoice +4. Initiate an authorized CALL-E phone call +5. Extract payment status and expected payment date +6. Store the call outcome +7. Recommend the next finance action + +## Features + +- Multiple overdue invoices +- Collection prioritization +- CALL-E outbound phone calls +- Structured payment-status extraction +- Expected payment-date extraction +- Call evidence +- Persistent call history +- Recommended next actions +- Streamlit dashboard + +## Setup + +Install the dependencies: + +```bash +pip install -r requirements.txt +``` + +Create a .env file: + +```bash +CALLE_API_KEY=your_api_key +``` + +Run the application: + +```bash +streamlit run app.py +``` + +## Credentials + +The CALL-E API key must be provided through the `CALLE_API_KEY` environment variable. + +Never commit API keys, credentials, or other secrets. + +## Phone numbers + +The example invoice data uses masked phone numbers. + +For live testing, replace the example phone number with a phone number that you are authorized to call. + +Do not use unauthorized or third-party phone numbers. + +## Side effects + +Live mode places a real outbound phone call through CALL-E. + +A phone call is initiated only when the operator explicitly presses the call button. + +Review the selected invoice and recipient before initiating a live call. + +## Cancellation and follow-up + +FinCall does not automatically initiate recurring calls. + +If a call requires follow-up, the previous outcome is displayed so that the operator can decide what action to take. + +Any additional call should be explicitly initiated by the operator. + +## Preview / demo behavior + +The invoice prioritization and dashboard workflow can be reviewed without placing a phone call. + +Live phone execution occurs only when the operator explicitly initiates the CALL-E action. + +## Safety + +- Do not commit credentials or API keys. +- Use authorized phone numbers only. +- Example data uses masked phone numbers. +- Review call outcomes before taking consequential financial action. +- FinCall does not automatically make recurring collection calls. + +## Project structure + +```text +fincall/ +├── app.py +├── fincall.py +├── requirements.txt +├── README.md +└── data/ + └── invoices.example.json +``` diff --git a/apps/python/fincall/app.py b/apps/python/fincall/app.py new file mode 100644 index 0000000..36dc017 --- /dev/null +++ b/apps/python/fincall/app.py @@ -0,0 +1,689 @@ +import json +from datetime import date, datetime +from pathlib import Path + +import streamlit as st + +from fincall import FinCall + + +# ============================================================ +# PAGE CONFIG +# ============================================================ + +st.set_page_config( + page_title="FinCall", + page_icon="📞", + layout="wide", +) + + +# ============================================================ +# CUSTOM CSS +# ============================================================ + +st.markdown( + """ + +""", + unsafe_allow_html=True, +) + + +# ============================================================ +# PATHS +# ============================================================ + +DATA_PATH = Path("data/invoices.example.json") +HISTORY_PATH = Path("data/call_history.json") + + +# ============================================================ +# DATA FUNCTIONS +# ============================================================ + +def load_invoices(): + with open(DATA_PATH, "r", encoding="utf-8") as file: + return json.load(file) + + +def load_call_history(): + if not HISTORY_PATH.exists(): + return {} + + try: + with open(HISTORY_PATH, "r", encoding="utf-8") as file: + return json.load(file) + except Exception: + return {} + + +def save_call_history(history): + with open(HISTORY_PATH, "w", encoding="utf-8") as file: + json.dump(history, file, indent=2) + + +def calculate_priority(invoice): + """ + Calculate collection priority using: + - Outstanding amount + - Number of days overdue + """ + + due_date = datetime.strptime( + invoice["due_date"], + "%Y-%m-%d" + ).date() + + today = date.today() + + days_overdue = max( + 0, + (today - due_date).days + ) + + amount = float(invoice["amount"]) + + # Normalize amount roughly to a 0-100 range. + amount_score = min( + 100, + (amount / 150000) * 100 + ) + + # More overdue days = higher priority. + overdue_score = min( + 100, + days_overdue * 15 + ) + + # Amount is weighted slightly more heavily. + score = ( + amount_score * 0.6 + + overdue_score * 0.4 + ) + + if score >= 65: + priority = "HIGH" + elif score >= 35: + priority = "MEDIUM" + else: + priority = "LOW" + + return score, priority, days_overdue + + +# ============================================================ +# LOAD DATA +# ============================================================ + +try: + invoices = load_invoices() + call_history = load_call_history() + +except Exception as error: + st.error(f"Could not load application data: {error}") + st.stop() + + +# ============================================================ +# RANK INVOICES +# ============================================================ + +ranked_invoices = [] + +for invoice in invoices: + + score, priority, days_overdue = calculate_priority( + invoice + ) + + invoice_copy = invoice.copy() + + invoice_copy["priority_score"] = score + invoice_copy["priority"] = priority + invoice_copy["days_overdue"] = days_overdue + + ranked_invoices.append(invoice_copy) + + +ranked_invoices.sort( + key=lambda x: x["priority_score"], + reverse=True +) + + +# ============================================================ +# HEADER +# ============================================================ + +st.markdown( + '
📞 FinCall
', + unsafe_allow_html=True, +) + +st.markdown( + '
' + 'AI-powered financial operations phone agent' + '
', + unsafe_allow_html=True, +) + +st.divider() + + +# ============================================================ +# TOP METRICS +# ============================================================ + +total_overdue = sum( + invoice["amount"] + for invoice in ranked_invoices +) + +high_priority_count = sum( + 1 + for invoice in ranked_invoices + if invoice["priority"] == "HIGH" +) + +col1, col2, col3 = st.columns(3) + +with col1: + st.metric( + "Overdue Invoices", + len(ranked_invoices) + ) + +with col2: + st.metric( + "Outstanding Amount", + f"₹{total_overdue:,.0f}" + ) + +with col3: + st.metric( + "High Priority", + high_priority_count + ) + + +st.divider() + + +# ============================================================ +# COLLECTION PRIORITY +# ============================================================ + +st.markdown( + '
' + 'COLLECTION PRIORITY' + '
', + unsafe_allow_html=True, +) + +st.subheader("Who should be called first?") + + +for index, invoice in enumerate(ranked_invoices): + + priority = invoice["priority"] + + if priority == "HIGH": + icon = "🔴" + priority_class = "priority-high" + + elif priority == "MEDIUM": + icon = "🟠" + priority_class = "priority-medium" + + else: + icon = "🟢" + priority_class = "priority-low" + + # Show latest call outcome if available. + history = call_history.get( + invoice["invoice_id"], + {} + ) + + last_status = history.get( + "payment_status", + "Not contacted" + ) + + if last_status != "Not contacted": + last_status = last_status.replace( + "_", + " " + ).title() + + st.markdown( + f""" +**{icon} {invoice["customer"]}** +Invoice `{invoice["invoice_id"]}` · +₹{invoice["amount"]:,.0f} · +{invoice["days_overdue"]} day(s) overdue · +{priority} PRIORITY + +Last outcome: **{last_status}** +""", + unsafe_allow_html=True, + ) + + if index < len(ranked_invoices) - 1: + st.divider() + + +st.divider() + + +# ============================================================ +# SELECT CUSTOMER +# ============================================================ + +st.markdown( + '
' + 'AI PAYMENT FOLLOW-UP' + '
', + unsafe_allow_html=True, +) + +invoice_options = { + f'{invoice["invoice_id"]} · {invoice["customer"]} · ' + f'₹{invoice["amount"]:,.0f}': invoice["invoice_id"] + for invoice in ranked_invoices +} + + +selected_label = st.selectbox( + "Select an invoice to contact", + options=list(invoice_options.keys()), +) + +selected_invoice_id = invoice_options[selected_label] + +selected_invoice = next( + invoice + for invoice in ranked_invoices + if invoice["invoice_id"] == selected_invoice_id +) + + +# ============================================================ +# SELECTED INVOICE +# ============================================================ + +st.markdown( + f""" +
+ +

{selected_invoice["customer"]}

+

+ Invoice {selected_invoice["invoice_id"]} · + Due {selected_invoice["due_date"]} +

+

₹{selected_invoice["amount"]:,.0f}

+

+ {selected_invoice["days_overdue"]} day(s) overdue · + {selected_invoice["priority"]} PRIORITY +

+
+""", + unsafe_allow_html=True, +) + + +# ============================================================ +# PREVIOUS CALL HISTORY +# ============================================================ + +previous_call = call_history.get( + selected_invoice_id +) + +if previous_call: + + st.write("") + + st.markdown( + '
' + 'PREVIOUS CALL' + '
', + unsafe_allow_html=True, + ) + + status = previous_call.get( + "payment_status", + "unknown" + ) + + status_display = status.replace( + "_", + " " + ).title() + + expected_payment = previous_call.get( + "expected_payment_date", + "Unknown" + ) + + follow_up = previous_call.get( + "follow_up_required", + False + ) + + call_time = previous_call.get( + "called_at", + "Unknown" + ) + + col1, col2, col3 = st.columns(3) + + with col1: + st.metric( + "Payment Status", + status_display + ) + + with col2: + st.metric( + "Expected Payment", + expected_payment + ) + + with col3: + st.metric( + "Follow-up", + "Required" + if follow_up + else "Not Required" + ) + + st.caption( + f"Last contacted: {call_time}" + ) + + +st.write("") + + +# ============================================================ +# CALL CUSTOMER +# ============================================================ + +if st.button( + "📞 Start AI Payment Follow-up", + type="primary", + use_container_width=True, +): + + fincall = FinCall() + + with st.spinner( + "CALL-E is calling the customer..." + ): + + try: + + result = fincall.call_customer( + selected_invoice_id + ) + + st.success( + "Call completed successfully" + ) + + structured = result.get( + "structured_result", + {} + ) + + payment_status = structured.get( + "payment_status", + "unknown" + ) + + expected_date = structured.get( + "expected_payment_date", + "Unknown" + ) + + follow_up = structured.get( + "follow_up_required", + False + ) + + evidence = result.get( + "evidence", + [] + ) + + # ==================================================== + # SAVE CALL HISTORY + # ==================================================== + + call_history[selected_invoice_id] = { + "payment_status": payment_status, + "expected_payment_date": expected_date, + "follow_up_required": follow_up, + "evidence": evidence, + "called_at": datetime.now().strftime( + "%Y-%m-%d %H:%M:%S" + ) + } + + save_call_history(call_history) + + # ==================================================== + # CALL OUTCOME + # ==================================================== + + st.divider() + + st.markdown( + '
' + 'CALL OUTCOME' + '
', + unsafe_allow_html=True, + ) + + col1, col2, col3 = st.columns(3) + + with col1: + + st.metric( + "Payment Status", + payment_status.replace( + "_", + " " + ).title(), + ) + + with col2: + + st.metric( + "Expected Payment", + expected_date, + ) + + with col3: + + st.metric( + "Follow-up", + "Required" + if follow_up + else "Not Required", + ) + + # ==================================================== + # AI EVIDENCE + # ==================================================== + + st.markdown( + '
' + 'AI CALL EVIDENCE' + '
', + unsafe_allow_html=True, + ) + + if evidence: + + for item in evidence: + + st.write( + "✓", + item + ) + + else: + + st.write( + "No evidence returned." + ) + + # ==================================================== + # RECOMMENDED ACTION + # ==================================================== + + if payment_status == "payment_promised": + + next_action = ( + "Recommended next action" + "

" + "Customer committed to payment. " + "Follow up after the promised payment " + "date if payment has not been confirmed." + ) + + elif payment_status == "delayed": + + next_action = ( + "Recommended next action" + "

" + "Payment is delayed. " + "Schedule another follow-up and review " + "the customer's stated reason." + ) + + elif payment_status == "disputed": + + next_action = ( + "Recommended next action" + "

" + "Customer disputed the invoice. " + "Route the case to the finance team " + "for manual review." + ) + + elif payment_status == "paid": + + next_action = ( + "Recommended next action" + "

" + "Payment has been confirmed. " + "No further collection call is required." + ) + + else: + + next_action = ( + "Recommended next action" + "

" + "Call outcome is inconclusive. " + "Schedule another attempt or manual review." + ) + + st.markdown( + f""" +
+{next_action} +
+""", + unsafe_allow_html=True, + ) + + # ==================================================== + # REFRESH DATA + # ==================================================== + + st.info( + "Call outcome saved to FinCall history." + ) + + except Exception as error: + + st.error( + f"Call failed: {error}" + ) + + +# ============================================================ +# FOOTER +# ============================================================ + +st.divider() + +st.caption( + "FinCall · AI-powered financial operations " + "using CALL-E" +) \ No newline at end of file diff --git a/apps/python/fincall/data/invoices.example.json b/apps/python/fincall/data/invoices.example.json new file mode 100644 index 0000000..ad49d42 --- /dev/null +++ b/apps/python/fincall/data/invoices.example.json @@ -0,0 +1,32 @@ +[ + { + "invoice_id": "INV-1042", + "customer": "ABC Industries", + "contact_name": "Rahul", + "phone": "+91XXXXXXXXXX", + "amount": 125000, + "currency": "INR", + "due_date": "2026-09-10", + "status": "overdue" + }, + { + "invoice_id": "INV-1043", + "customer": "Zenith Solutions", + "contact_name": "Priya", + "phone": "+91XXXXXXXXXX", + "amount": 78000, + "currency": "INR", + "due_date": "2026-09-12", + "status": "overdue" + }, + { + "invoice_id": "INV-1044", + "customer": "Nova Systems", + "contact_name": "Arjun", + "phone": "+91XXXXXXXXXX", + "amount": 18000, + "currency": "INR", + "due_date": "2026-09-13", + "status": "overdue" + } +] diff --git a/apps/python/fincall/fincall.py b/apps/python/fincall/fincall.py new file mode 100644 index 0000000..0b67fbe --- /dev/null +++ b/apps/python/fincall/fincall.py @@ -0,0 +1,114 @@ +import json +import os +from pathlib import Path + +from dotenv import load_dotenv +from calle import CalleClient + + +load_dotenv() + + +class FinCall: + def __init__(self): + api_key = os.getenv("CALLE_API_KEY") + + if not api_key: + raise RuntimeError("CALLE_API_KEY not found in .env") + + self.client = CalleClient(api_key=api_key) + + def load_invoice(self, invoice_id: str): + data_path = Path("data/invoices.json") + + with open(data_path, "r", encoding="utf-8") as f: + invoices = json.load(f) + + for invoice in invoices: + if invoice["invoice_id"] == invoice_id: + return invoice + + raise ValueError(f"Invoice {invoice_id} not found") + + def call_customer(self, invoice_id: str): + invoice = self.load_invoice(invoice_id) + + task = f""" +You are FinCall, an AI financial operations assistant. + +Your goal is to follow up with a customer regarding an overdue invoice. + +Customer: +{invoice["customer"]} + +Contact: +{invoice["contact_name"]} + +Invoice: +{invoice["invoice_id"]} + +Outstanding amount: +{invoice["currency"]} {invoice["amount"]:,} + +Original due date: +{invoice["due_date"]} + +Call the customer at: +{invoice["phone"]} + +Start politely. + +Explain that you are calling regarding the outstanding invoice. + +Ask whether they are aware of the outstanding payment. + +Ask when they expect to make the payment. + +If they cannot make the payment, politely ask for the reason +or expected timeline. + +Do not pressure, threaten, or misrepresent anything. + +The purpose of the call is to understand the payment status +and obtain a realistic expected payment date if possible. + +Before ending the call, make sure you have gathered the +best available payment status. + +Then end the call politely. +""" + + result = self.client.calls.create_and_wait( + task=task, + result_schema={ + "type": "object", + "required": [ + "payment_status", + "expected_payment_date", + "follow_up_required" + ], + "properties": { + "payment_status": { + "type": "string", + "enum": [ + "paid", + "payment_promised", + "delayed", + "disputed", + "unknown" + ] + }, + "expected_payment_date": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "follow_up_required": { + "type": "boolean" + } + } + } + ) + + return result \ No newline at end of file diff --git a/apps/python/fincall/requirements.txt b/apps/python/fincall/requirements.txt new file mode 100644 index 0000000..b2fb5da --- /dev/null +++ b/apps/python/fincall/requirements.txt @@ -0,0 +1,3 @@ +calle-ai +python-dotenv +streamlit \ No newline at end of file