Invoice Chasing Agent
Nobody likes chasing invoices, so nobody does it. The reminder you meant to send on day 7 goes out on day 40, apologetic and mistimed, and the client learns that your invoices are optional. Meanwhile the businesses that get paid on time are not braver than you. They just have a system that sends the awkward email so no human has to feel awkward.
This agent runs a three-rung ladder: a friendly nudge at 7 days, a firm note at 21, and a final notice at 45 that says what happens next. Tone is the whole game here. The day-7 email assumes the invoice slipped their mind, because it usually did. The day-45 email is still professional, because you may want this client again, and because an agent that threatens people on your behalf is a liability, not an employee.
Two safety rails are non-negotiable. First, every message is a draft for your approval; a payment that crossed in the mail plus an auto-sent final notice equals a lost client. Second, a sent-log makes the run idempotent, so running the script twice on Tuesday never double-reminds anyone. The agent also never invents late fees, discounts, or legal threats. If it's not in your invoice terms, it doesn't go in the email.
Prerequisites
- Python 3.10+ with CrewAI installed: `pip install crewai crewai-tools`.
- An LLM API key exported as an environment variable.
- An `invoices.csv` export from your invoicing tool with columns: `invoice_id, client_name, client_email, amount, due_date, status`.
"""
Invoice Chasing Agent
- Reads open invoices from a CSV export.
- Sorts each overdue invoice onto a reminder ladder by days overdue.
- Drafts the right reminder; a sent-log prevents double-sends.
- Every message is a DRAFT for approval. Nothing sends itself.
"""
import csv
import json
from datetime import date, datetime
from pathlib import Path
from crewai import Agent, Task, Crew, Process
from crewai_tools import tool
INVOICES = Path("invoices.csv")
SENT_LOG = Path("state/reminders_sent.jsonl") # {invoice_id, rung, date}
DO_NOT_CHASE = ["ACME-HOLDINGS"] # clients you handle personally
# The ladder: (days overdue, rung name, tone instruction)
LADDER = [
(7, "nudge", "Warm and brief. Assume they simply missed it. "
"Re-attach nothing; link the invoice."),
(21, "firm", "Polite but direct. State the amount, the original due "
"date, and ask for a payment date."),
(45, "final", "Professional final notice. State that the account is "
"on hold and describe the next step EXACTLY as written "
"in [your invoice terms]. No improvised threats."),
]
# ---------- Stub tools (replace with real implementations) ----------
@tool("read_overdue_invoices")
def read_overdue_invoices() -> str:
"""Return unpaid invoices with days_overdue computed."""
today = date.today()
rows = []
with open(INVOICES) as f:
for row in csv.DictReader(f):
if row["status"].lower() != "unpaid":
continue
due = datetime.strptime(row["due_date"], "%Y-%m-%d").date()
row["days_overdue"] = (today - due).days
rows.append(row)
return json.dumps([r for r in rows if r["days_overdue"] > 0])
@tool("check_already_sent")
def check_already_sent(invoice_id: str, rung: str) -> str:
"""Return 'yes' if this rung was already sent for this invoice.
This is the idempotency guard: one rung, one send, ever."""
if not SENT_LOG.exists():
return "no"
for line in SENT_LOG.read_text().splitlines():
rec = json.loads(line)
if rec["invoice_id"] == invoice_id and rec["rung"] == rung:
return "yes"
return "no"
@tool("draft_reminder")
def draft_reminder(invoice_id: str, client_email: str,
rung: str, message: str) -> str:
"""Queue a reminder draft for approval and record it in the sent-log.
Real implementation: create a Gmail draft, still never auto-send."""
print(f"\n[DRAFT: {rung}] to {client_email} re {invoice_id}")
print(message)
print("[END DRAFT - awaiting your approval]\n")
SENT_LOG.parent.mkdir(parents=True, exist_ok=True)
with open(SENT_LOG, "a") as f:
f.write(json.dumps({"invoice_id": invoice_id, "rung": rung,
"date": str(date.today())}) + "\n")
return "drafted"
# ---------- Agent ----------
collections_clerk = Agent(
role="Accounts Receivable Clerk",
goal=(
"Draft the correct reminder for each overdue invoice, exactly "
"once per ladder rung, in the tone the rung specifies."
),
backstory=(
"You work for [business name]. You are unfailingly polite and "
"never embarrassed to ask for money that is owed. You never "
"invent late fees, discounts, or consequences: if it is not in "
"the invoice terms, it does not go in the email."
),
tools=[read_overdue_invoices, check_already_sent, draft_reminder],
verbose=True,
)
chase_task = Task(
description=(
"Read overdue invoices. For each one:\n"
f"1. Skip clients in this list entirely: {DO_NOT_CHASE}.\n"
"2. Pick the highest ladder rung whose day threshold is met "
"(7=nudge, 21=firm, 45=final).\n"
"3. Call check_already_sent for that rung. If 'yes', skip.\n"
"4. Draft the reminder in the rung's tone. Include the invoice "
"ID, amount, original due date, and payment link "
"[your payment link or 'see invoice'].\n"
"Sign every message as [your name], [business name].\n\n"
"Output: JSON list of {invoice_id, rung, action_taken}."
),
agent=collections_clerk,
expected_output="JSON list of chase actions.",
)
crew = Crew(agents=[collections_clerk], tasks=[chase_task],
process=Process.sequential, verbose=True)
if __name__ == "__main__":
print(crew.kickoff())Adaptation notes:
- Swap the CSV for your invoicing tool's API (Stripe, Xero, QuickBooks all have one) once the CSV version has run cleanly for a few weeks. Same interface, same ladder.
- Tune the ladder days to your industry. Net-60 clients need a 67/81/105 ladder, not 7/21/45. The structure holds; the numbers are yours.
- Note the sent-log is written at draft time, not approval time. If you discard a draft, delete its line from
state/reminders_sent.jsonlor that rung never fires again. That default is deliberate: a duplicate reminder costs more goodwill than a missed one. - The do-not-chase list matters more as you grow. Your biggest client on a handshake payment arrangement should never receive a form letter.
- The mistake people make: automating the send before automating the drafting has proven itself. Read every draft for a full billing cycle first. The day you stop reading them should be a decision, not a drift.