Install
openclaw skills install tycheTyche â Goddess of Fortune. Generate professional invoice documents, track payment status, calculate late fees, and send automated overdue reminders â all from a simple CSV list of clients and work. Each client gets a ready-to-send invoice. Fortune favours the organised.
openclaw skills install tycheGenerate professional invoices, track overdue payments, calculate late fees, and get 3-tier reminder scripts â all from a simple CSV file.
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
Runs entirely locally. No data transmitted. Your client data stays on your machine.
pip3 install rich --break-system-packages --quiet
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"
))
))