Tyche â Invoice & Payment Tracker
Generate professional invoices, track overdue payments, calculate late fees, and get 3-tier reminder scripts â all from a simple CSV file.
What you get
- Professional invoice text per client â ready to paste into email or PDF
- Payment status dashboard â paid vs. outstanding vs. overdue at a glance
- Overdue report â who owes what, how many days late, and late fees owed
- 3-tier reminder templates â Friendly · Firm · Final Notice
- Revenue summary â total invoiced, received, and outstanding
Input CSV format
Create a file invoices.csv:
client_name,client_email,description,amount,due_date,status
Acme Corp,billing@acme.com,Website redesign,2500,2025-02-01,unpaid
Globex Inc,accounts@globex.com,Consulting Feb,1800,2025-01-15,overdue
Initech Ltd,pay@initech.com,Logo design,750,2025-02-10,paid
Status values: paid · unpaid · overdue · partial
ð Security
Runs entirely locally. No data transmitted. Your client data stays on your machine.
Step 1 â Install
pip3 install rich --break-system-packages --quiet
Step 2 â Generate invoices and payment reminders
import os, csv, re
from datetime import datetime, timedelta
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich import box
console = Console()
INVOICES_FILE = os.environ.get("INVOICES_FILE", "").strip()
YOUR_NAME = os.environ.get("YOUR_NAME", "Your Name / Company")
YOUR_EMAIL = os.environ.get("YOUR_EMAIL", "")
YOUR_ADDRESS = os.environ.get("YOUR_ADDRESS", "")
PAYMENT_TERMS = os.environ.get("PAYMENT_TERMS", "Net 30")
CURRENCY = os.environ.get("CURRENCY", "USD").upper()
PAYMENT_METHOD = os.environ.get("PAYMENT_METHOD", "")
try:
TAX_RATE = float(os.environ.get("TAX_RATE", "0"))
except ValueError:
console.print("[yellow]â ï¸ TAX_RATE must be a number â defaulting to 0[/yellow]")
TAX_RATE = 0.0
try:
LATE_FEE_RATE = float(os.environ.get("LATE_FEE_RATE", "0"))
except ValueError:
console.print("[yellow]â ï¸ LATE_FEE_RATE must be a number â defaulting to 0[/yellow]")
LATE_FEE_RATE = 0.0
CURRENCY_SYMBOL = {"USD": "$", "EUR": "â¬", "GBP": "£", "CAD": "CA$", "AUD": "AU$"}.get(CURRENCY, "$")
def fmt(amount: float) -> str:
return f"{CURRENCY_SYMBOL}{amount:,.2f}"
def parse_amount(raw: str) -> float:
cleaned = re.sub(r"[^0-9.]", "", str(raw)) or "0"
try:
return float(cleaned) if cleaned.count(".") <= 1 else 0.0
except ValueError:
return 0.0
def parse_date(raw: str) -> datetime | None:
for fmt_str in ("%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%d-%m-%Y", "%b %d %Y", "%B %d %Y"):
try:
return datetime.strptime(raw.strip(), fmt_str)
except ValueError:
pass
return None
invoices = []
if INVOICES_FILE and os.path.exists(INVOICES_FILE):
with open(INVOICES_FILE, encoding="utf-8", errors="replace") as f:
reader = csv.DictReader(f)
if not reader.fieldnames:
console.print("[red]â CSV file is empty or has no headers.[/red]")
raise SystemExit(1)
headers = [h.lower().strip() for h in reader.fieldnames]
required = ["client_name", "amount"]
missing = [r for r in required if not any(r in h for h in headers)]
if missing:
console.print(f"[red]â CSV is missing required columns: {missing}\nFound: {headers}[/red]")
raise SystemExit(1)
for i, row in enumerate(reader, 1):
rk = {k.lower().strip(): v for k, v in row.items()}
invoices.append({
"inv_number": rk.get("inv_number", f"INV-{i:04d}"),
"client_name": rk.get("client_name", "Client"),
"client_email": rk.get("client_email", ""),
"description": rk.get("description", "Services rendered"),
"amount": parse_amount(rk.get("amount", "0")),
"due_date": rk.get("due_date", ""),
"status": rk.get("status", "unpaid").lower().strip(),
})
elif INVOICES_FILE:
console.print(f"[red]â File not found: {INVOICES_FILE}[/red]")
raise SystemExit(1)
else:
console.print("[yellow]â¹ï¸ No INVOICES_FILE set â running with demo data.[/yellow]")
console.print("[dim]Set INVOICES_FILE=path/to/invoices.csv to use your own data.\n[/dim]")
today = datetime.now()
invoices = [
{"inv_number": "INV-0001", "client_name": "Acme Corp", "client_email": "billing@acme.com", "description": "Website redesign â Q1 2025", "amount": 2500.00, "due_date": (today - timedelta(days=15)).strftime("%Y-%m-%d"), "status": "overdue"},
{"inv_number": "INV-0002", "client_name": "Globex Inc", "client_email": "accounts@globex.com", "description": "Monthly consulting retainer", "amount": 1800.00, "due_date": (today + timedelta(days=10)).strftime("%Y-%m-%d"), "status": "unpaid"},
{"inv_number": "INV-0003", "client_name": "Initech Ltd", "client_email": "pay@initech.com", "description": "Logo & brand identity package", "amount": 750.00, "due_date": (today - timedelta(days=5)).strftime("%Y-%m-%d"), "status": "paid"},
{"inv_number": "INV-0004", "client_name": "Umbrella Co", "client_email": "finance@umbrella.co", "description": "SEO audit and content strategy", "amount": 1200.00, "due_date": (today - timedelta(days=32)).strftime("%Y-%m-%d"), "status": "overdue"},
{"inv_number": "INV-0005", "client_name": "Soylent Corp", "client_email": "ap@soylent.com", "description": "Mobile app UX design, 3 screens", "amount": 3400.00, "due_date": (today + timedelta(days=20)).strftime("%Y-%m-%d"), "status": "unpaid"},
]
if not invoices:
console.print("[yellow]No invoices found â check your CSV.[/yellow]")
raise SystemExit(0)
now = datetime.now()
# ââ Enrich with computed fields âââââââââââââââââââââââââââââââââââââââââââââââ
for inv in invoices:
sub = inv["amount"]
tax = sub * TAX_RATE / 100
inv["subtotal"] = sub
inv["tax"] = tax
inv["total"] = sub + tax
due = parse_date(inv["due_date"])
inv["due_dt"] = due
inv["days_late"] = max(0, (now - due).days) if due and inv["status"] in ("overdue", "unpaid") else 0
inv["late_fee"] = inv["total"] * LATE_FEE_RATE / 100 * (inv["days_late"] / 30) if inv["days_late"] > 0 and LATE_FEE_RATE else 0
# ââ Header ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
total_invoiced = sum(i["total"] for i in invoices)
total_paid = sum(i["total"] for i in invoices if i["status"] == "paid")
total_outstanding = total_invoiced - total_paid
overdue_invoices = [i for i in invoices if i["status"] == "overdue" or i["days_late"] > 0]
console.print()
console.print(Panel.fit(
f"[bold yellow]âï¸ Tyche â Invoice Dashboard[/bold yellow]\n"
f"Total invoiced: [white]{fmt(total_invoiced)}[/white] | "
f"Received: [green]{fmt(total_paid)}[/green] | "
f"Outstanding: [red]{fmt(total_outstanding)}[/red] | "
f"Overdue: [red]{len(overdue_invoices)}[/red]",
border_style="yellow"
))
# ââ Payment status table ââââââââââââââââââââââââââââââââââââââââââââââââââââââ
console.print()
status_table = Table(title="Invoice Status", box=box.ROUNDED, border_style="yellow")
status_table.add_column("Inv #", style="dim", width=10)
status_table.add_column("Client", style="cyan", width=18)
status_table.add_column("Description",style="white", width=30)
status_table.add_column("Total", style="white", width=12, justify="right")
status_table.add_column("Due Date", style="dim", width=12)
status_table.add_column("Days Late", style="red", width=10, justify="right")
status_table.add_column("Status", style="white", width=12)
STATUS_COLOURS = {"paid": "green", "unpaid": "yellow", "overdue": "red", "partial": "cyan"}
for inv in sorted(invoices, key=lambda x: -(x["days_late"] or 0)):
sc = STATUS_COLOURS.get(inv["status"], "white")
late = f"[red]{inv['days_late']}d[/red]" if inv["days_late"] > 0 else "â"
status_table.add_row(
inv["inv_number"],
inv["client_name"][:16],
inv["description"][:28],
fmt(inv["total"]),
inv["due_date"] or "â",
late,
f"[{sc}]{inv['status'].title()}[/{sc}]",
)
console.print(status_table)
# ââ Invoice texts âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
console.print()
for inv in invoices:
tax_line = f"\nTax ({TAX_RATE}%): {fmt(inv['tax'])}" if TAX_RATE else ""
payment_line = f"\n\nPayment method:\n{PAYMENT_METHOD}" if PAYMENT_METHOD else ""
address_line = f"\n{YOUR_ADDRESS}" if YOUR_ADDRESS else ""
invoice_text = (
f"{'â'*50}\n"
f"INVOICE {inv['inv_number']}\n"
f"{'â'*50}\n"
f"From: {YOUR_NAME}\n"
f" {YOUR_EMAIL}{address_line}\n\n"
f"To: {inv['client_name']}\n"
f" {inv['client_email']}\n\n"
f"Description: {inv['description']}\n\n"
f"Subtotal: {fmt(inv['subtotal'])}{tax_line}\n"
f"TOTAL DUE: {fmt(inv['total'])}\n\n"
f"Due date: {inv['due_date'] or 'Upon receipt'}\n"
f"Terms: {PAYMENT_TERMS}\n"
f"{payment_line}"
)
status_colour = STATUS_COLOURS.get(inv["status"], "white")
console.print(Panel(
invoice_text,
title=f"[bold]{inv['inv_number']} â {inv['client_name']}[/bold] [{status_colour}]({inv['status'].title()})[/{status_colour}]",
border_style="cyan"
))
# ââ Reminder templates ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
overdue_with_reminders = [i for i in invoices if i["status"] in ("overdue", "unpaid") and i["days_late"] > 0]
if overdue_with_reminders:
console.print()
for inv in overdue_with_reminders:
late_line = f" A late fee of {fmt(inv['late_fee'])} has been applied." if inv["late_fee"] else ""
if inv["days_late"] <= 7:
tier, colour = "Friendly Reminder", "yellow"
body = (
f"I hope this message finds you well. This is a friendly reminder that invoice "
f"{inv['inv_number']} for {fmt(inv['total'])} was due on {inv['due_date']}. "
f"If you've already sent payment, please disregard this message. "
f"Otherwise, I'd appreciate payment at your earliest convenience."
)
elif inv["days_late"] <= 21:
tier, colour = "Firm Reminder", "orange3"
body = (
f"This is a follow-up regarding invoice {inv['inv_number']} for {fmt(inv['total'])}, "
f"which is now {inv['days_late']} days past its due date of {inv['due_date']}.{late_line} "
f"Please arrange payment or contact me to discuss if there is an issue."
)
else:
tier, colour = "Final Notice", "red"
body = (
f"FINAL NOTICE: Invoice {inv['inv_number']} for {fmt(inv['total'])} is {inv['days_late']} days overdue.{late_line} "
f"Please remit payment within 48 hours to avoid further action. "
f"Contact me immediately if you wish to discuss a payment arrangement."
)
reminder_text = (
f"To: {inv['client_email']}\n"
f"Subject: {tier} â Invoice {inv['inv_number']} â {inv['client_name']}\n\n"
f"Dear {inv['client_name']},\n\n{body}\n\n"
f"Kind regards,\n{YOUR_NAME}"
)
console.print(Panel(
reminder_text,
title=f"[b.][{colour}]{tier}[/{colour}] â {inv['client_name']} ({inv['days_late']}d late)[/b.]",
border_style=colour
))
# ââ Save report âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
report_date = now.strftime("%Y-%m-%d")
report_file = f"invoice_report_{report_date}.md"
with open(report_file, "w", encoding="utf-8") as f:
f.write(f"# â®ï¸ Tyche Invoice Report â {report_date}\n\n")
f.write(f"**Total invoiced:** {fmt(total_invoiced)} ")
f.write(f"**Received:** {fmt(total_paid)} ")
f.write(f"**Outstanding:** {fmt(total_outstanding)} ")
f.write(f"**Overdue count:** {len(overdue_invoices)}\n\n")
f.write("## Invoice Status\n\n| Inv # | Client | Total | Due | Days Late | Status |\n")
f.write("|-------|--------|-------|-----|-----------|--------|\n")
for inv in sorted(invoices, key=lambda x: -(x["days_late"] or 0)):
late_str = f"{inv['days_late']}d" if inv["days_late"] else "â"
f.write(f"| {inv['inv_number']} | {inv['client_name']} | {fmt(inv['total'])} | {inv['due_date']} | {late_str} | {inv['status'].title()} |\n")
f.write("\n## Invoice Texts\n\n")
for inv in invoices:
tax_line = f"\nTax ({TAX_RATE}%): {fmt(inv['tax'])}" if TAX_RATE else ""
f.write(f"### {inv['inv_number']} â {inv['client_name']}\n\n```\n")
f.write(f"INVOICE {inv['inv_number']}\nFrom: {YOUR_NAME}\nTo: {inv['client_name']} <{inv['client_email']}>\n")
f.write(f"Description: {inv['description']}\nSubtotal: {fmt(inv['subtotal'])}{tax_line}\nTOTAL: {fmt(inv['total'])}\n")
f.write(f"Due: {inv['due_date']} Terms: {PAYMENT_TERMS}\n```\n\n")
console.print()
console.print(Panel(
f"[green]â
Done![/green] Report saved to [cyan]{report_file}[/cyan]",
border_style="green"
))
))