Case Study 19-2: Maya's Late Payment Reminder System

The Situation

Maya Reyes runs a boutique data consulting practice. She works with six to twelve clients at any given time, billing on net-30 terms. Late payments are her biggest operational headache. Some clients pay within a week. Others stretch to net-45 or net-60 without any communication. One client paid four months late after receiving three reminder emails, each one increasingly difficult to write.

The problem with chasing payments manually is not just the time — it is the emotional friction. Writing a firm-but-professional reminder email to a client you want to keep is genuinely uncomfortable. The temptation is to soften the language so much that the email has no effect. Or to delay sending it because you are not in the mood for the awkwardness. Either way, the money arrives later than it should.

Maya's realization: "The email itself is not the hard part. The hard part is noticing which invoices are overdue, deciding what to say, and actually sitting down to send it. If I automate the first two parts, the third becomes much easier."

Her system does not auto-send emails. Maya is too aware of the relationship risks. Instead, it generates draft emails, named and organized by client and invoice, ready for her to review, personalize if needed, and send from her regular email client. She can run the script at any time, open the drafts folder, and deal with overdue invoices in ten minutes instead of sixty.


The Invoice Tracker

Maya maintains a simple CSV file as her invoice tracker. She updates it each time she sends an invoice or receives a payment:

invoice_id,client_name,client_email,amount,invoice_date,due_date,paid_date,status
INV-2024-031,Thornfield Media,accounts@thornfieldmedia.com,3500.00,2024-10-01,2024-10-31,,unpaid
INV-2024-032,Bellmore Logistics,finance@bellmore.co,6200.00,2024-10-08,2024-11-07,,unpaid
INV-2024-033,Ortega & Partners,billing@ortegapartners.com,2800.00,2024-10-15,2024-11-14,2024-11-10,paid
INV-2024-034,Novak Digital,ap@novakdigital.com,4100.00,2024-10-22,2024-11-21,,unpaid
INV-2024-035,Thornfield Media,accounts@thornfieldmedia.com,3500.00,2024-11-01,2024-12-01,,unpaid
INV-2024-036,Cascade Analytics,billing@cascadeanalytics.io,7800.00,2024-11-10,2024-12-10,,unpaid

The key columns are due_date, paid_date, and status. An invoice is overdue if today is past its due_date and paid_date is empty (or status is not "paid").


The Reminder Tiers

Maya uses three escalating reminder templates based on how overdue an invoice is:

Days Overdue Tier Tone
1–14 days Gentle reminder Friendly, assumes oversight
15–30 days Firm reminder Professional, direct
31+ days Final notice Formal, mentions consequences

The Script

"""
payment_reminders.py
====================
Maya Reyes Consulting — Late Payment Reminder Generator

Reads the invoice tracker CSV, identifies overdue invoices, and generates
personalized reminder email drafts as text files.

IMPORTANT: This script does NOT send any emails automatically.
All drafts are saved to the 'reminders/' folder for Maya to review,
edit if needed, and send manually through her email client.

Usage:
    python payment_reminders.py
    python payment_reminders.py --tracker invoices_2024.csv
    python payment_reminders.py --dry-run   (shows what would be generated)
"""

import argparse
import csv
import re
import sys
from dataclasses import dataclass
from datetime import date, datetime
from pathlib import Path
from typing import Optional


# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

TRACKER_CSV = Path("invoices.csv")
DRAFTS_DIR = Path("reminders")
SENDER_NAME = "Maya Reyes"
SENDER_EMAIL = "maya@mayareyesconsulting.com"
SENDER_PHONE = "+1 (415) 555-0192"


# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------

@dataclass
class Invoice:
    """Represents a single invoice record from the tracker."""
    invoice_id: str
    client_name: str
    client_email: str
    amount: float
    invoice_date: date
    due_date: date
    paid_date: Optional[date]
    status: str

    @property
    def is_paid(self) -> bool:
        return self.status.lower() == "paid" or self.paid_date is not None

    @property
    def days_overdue(self) -> int:
        """Days past due. Negative means not yet due."""
        if self.is_paid:
            return 0
        return (date.today() - self.due_date).days

    @property
    def overdue_tier(self) -> Optional[str]:
        """
        Returns the reminder tier, or None if not overdue.
        'gentle', 'firm', or 'final'
        """
        days = self.days_overdue
        if days <= 0:
            return None
        elif days <= 14:
            return "gentle"
        elif days <= 30:
            return "firm"
        else:
            return "final"

    @property
    def amount_display(self) -> str:
        return f"${self.amount:,.2f}"

    @property
    def due_date_display(self) -> str:
        return self.due_date.strftime("%B %d, %Y")

    @property
    def invoice_date_display(self) -> str:
        return self.invoice_date.strftime("%B %d, %Y")


# ---------------------------------------------------------------------------
# CSV loading
# ---------------------------------------------------------------------------

def load_invoices(csv_path: Path) -> list[Invoice]:
    """
    Load invoice data from the tracker CSV.

    Expected columns:
        invoice_id, client_name, client_email, amount,
        invoice_date, due_date, paid_date, status

    Returns:
        List of Invoice objects.
    """
    if not csv_path.exists():
        print(f"ERROR: Invoice tracker not found: {csv_path}")
        print("       Create a CSV file with these columns:")
        print("       invoice_id,client_name,client_email,amount,")
        print("       invoice_date,due_date,paid_date,status")
        sys.exit(1)

    invoices = []
    with open(csv_path, newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        for row_num, row in enumerate(reader, start=2):  # 2 = first data row
            try:
                paid_date = None
                if row.get("paid_date", "").strip():
                    paid_date = datetime.strptime(
                        row["paid_date"].strip(), "%Y-%m-%d"
                    ).date()

                invoices.append(Invoice(
                    invoice_id=row["invoice_id"].strip(),
                    client_name=row["client_name"].strip(),
                    client_email=row["client_email"].strip(),
                    amount=float(row["amount"]),
                    invoice_date=datetime.strptime(
                        row["invoice_date"].strip(), "%Y-%m-%d"
                    ).date(),
                    due_date=datetime.strptime(
                        row["due_date"].strip(), "%Y-%m-%d"
                    ).date(),
                    paid_date=paid_date,
                    status=row["status"].strip().lower(),
                ))
            except (KeyError, ValueError) as e:
                print(f"WARNING: Skipping row {row_num} — {e}: {dict(row)}")

    return invoices


def get_overdue_invoices(invoices: list[Invoice]) -> list[Invoice]:
    """Filter to unpaid, overdue invoices only."""
    return [inv for inv in invoices if not inv.is_paid and inv.days_overdue > 0]


# ---------------------------------------------------------------------------
# Email template generators
# ---------------------------------------------------------------------------

def build_gentle_reminder(invoice: Invoice) -> tuple[str, str]:
    """
    Build a gentle (1–14 days overdue) reminder.
    Returns (subject, body) tuple.
    """
    subject = (
        f"Friendly Reminder: Invoice {invoice.invoice_id} — "
        f"{invoice.amount_display} — Due {invoice.due_date_display}"
    )

    body = f"""Hi,

I hope this message finds you well. I wanted to follow up regarding \
invoice {invoice.invoice_id} issued on {invoice.invoice_date_display} \
for {invoice.amount_display}, which was due on {invoice.due_date_display}.

It appears payment may not have come through yet — I know things get \
busy, and it is easy for these to slip through the cracks.

If payment is already on its way, please disregard this message and \
accept my thanks. If not, could you let me know the expected payment \
date? I am happy to resend the invoice or answer any questions.

Payment can be made via:
  - ACH/Bank Transfer: [Bank Details on Original Invoice]
  - Check payable to: Maya Reyes Consulting
  - Invoice reference: {invoice.invoice_id}

Thank you for your continued business. I look forward to hearing from you.

Best regards,
{SENDER_NAME}
{SENDER_EMAIL}
{SENDER_PHONE}

---
Invoice Details:
  Invoice Number: {invoice.invoice_id}
  Invoice Date:   {invoice.invoice_date_display}
  Due Date:       {invoice.due_date_display}
  Amount Due:     {invoice.amount_display}
  Days Past Due:  {invoice.days_overdue}
"""
    return subject, body


def build_firm_reminder(invoice: Invoice) -> tuple[str, str]:
    """
    Build a firm (15–30 days overdue) reminder.
    Returns (subject, body) tuple.
    """
    subject = (
        f"Second Notice: Invoice {invoice.invoice_id} — "
        f"{invoice.amount_display} — {invoice.days_overdue} Days Overdue"
    )

    body = f"""Hi,

I am writing to follow up on invoice {invoice.invoice_id} for \
{invoice.amount_display}, which was due on {invoice.due_date_display} \
and is now {invoice.days_overdue} days past due.

I previously sent a reminder and have not received a response or payment. \
I would appreciate it if you could arrange payment or contact me to \
discuss this matter.

To resolve this promptly, please:
  1. Process payment for {invoice.amount_display} referencing {invoice.invoice_id}
  2. OR contact me at {SENDER_EMAIL} or {SENDER_PHONE} to discuss your timeline

Payment options are listed on the original invoice. If you have \
misplaced it, please let me know and I will resend immediately.

I value our working relationship and would like to resolve this without \
further follow-up. I look forward to your response within the next five \
business days.

Best regards,
{SENDER_NAME}
{SENDER_EMAIL}
{SENDER_PHONE}

---
Invoice Details:
  Invoice Number: {invoice.invoice_id}
  Invoice Date:   {invoice.invoice_date_display}
  Due Date:       {invoice.due_date_display}
  Amount Due:     {invoice.amount_display}
  Days Past Due:  {invoice.days_overdue}
"""
    return subject, body


def build_final_notice(invoice: Invoice) -> tuple[str, str]:
    """
    Build a final (31+ days overdue) notice.
    Returns (subject, body) tuple.
    """
    subject = (
        f"FINAL NOTICE: Invoice {invoice.invoice_id} — "
        f"{invoice.amount_display} — {invoice.days_overdue} Days Overdue"
    )

    body = f"""Hi,

This is a final notice regarding invoice {invoice.invoice_id} \
for {invoice.amount_display}, issued on {invoice.invoice_date_display} \
and due on {invoice.due_date_display}. This invoice is now \
{invoice.days_overdue} days past due.

Despite previous reminders, I have not received payment or any \
communication regarding this balance.

I require full payment of {invoice.amount_display} within \
five (5) business days of this notice.

If I do not receive payment or a written payment plan by that date, \
I will have no alternative but to pursue collection through available \
legal and professional channels, which may include engaging a collections \
agency or pursuing small claims action.

I sincerely hope we can resolve this professionally. Please contact me \
immediately at {SENDER_EMAIL} or {SENDER_PHONE} if you wish to discuss \
a payment arrangement.

Sincerely,
{SENDER_NAME}
{SENDER_EMAIL}
{SENDER_PHONE}
Maya Reyes Consulting

---
INVOICE DETAILS:
  Invoice Number: {invoice.invoice_id}
  Client:         {invoice.client_name}
  Invoice Date:   {invoice.invoice_date_display}
  Due Date:       {invoice.due_date_display}
  Amount Due:     {invoice.amount_display}
  Days Past Due:  {invoice.days_overdue}
"""
    return subject, body


# ---------------------------------------------------------------------------
# Draft generation
# ---------------------------------------------------------------------------

TIER_BUILDERS = {
    "gentle": build_gentle_reminder,
    "firm": build_firm_reminder,
    "final": build_final_notice,
}

TIER_LABELS = {
    "gentle": "Gentle Reminder (1-14 days)",
    "firm": "Firm Reminder (15-30 days)",
    "final": "Final Notice (31+ days)",
}


def generate_draft(invoice: Invoice, output_dir: Path) -> Path:
    """
    Generate a reminder email draft file for the given invoice.

    The file is named: {tier}_{invoice_id}_{client_slug}.txt
    It contains the To address, Subject, and Body, ready to copy into
    an email client.

    Returns the path to the created file.
    """
    tier = invoice.overdue_tier
    if tier is None:
        raise ValueError(f"Invoice {invoice.invoice_id} is not overdue.")

    builder = TIER_BUILDERS[tier]
    subject, body = builder(invoice)

    # Create a filesystem-safe client name slug
    client_slug = re.sub(r"[^a-zA-Z0-9]+", "_", invoice.client_name)
    today_str = date.today().strftime("%Y%m%d")
    filename = f"{tier}_{invoice.invoice_id}_{client_slug}_{today_str}.txt"
    file_path = output_dir / filename

    content = f"""DRAFT EMAIL — REVIEW BEFORE SENDING
Generated: {date.today().strftime('%B %d, %Y')}
{'=' * 60}

TO:      {invoice.client_email}
FROM:    {SENDER_NAME} <{SENDER_EMAIL}>
SUBJECT: {subject}

{'=' * 60}
BODY:

{body}
{'=' * 60}
NOTE: This draft was generated automatically. Review and personalize
before sending. Delete this header block before pasting into your
email client.
"""

    file_path.write_text(content, encoding="utf-8")
    return file_path


def generate_all_drafts(
    overdue: list[Invoice],
    output_dir: Path,
    dry_run: bool = False,
) -> list[tuple[Invoice, Path]]:
    """
    Generate draft reminder files for all overdue invoices.

    Args:
        overdue: List of overdue Invoice objects.
        output_dir: Directory to save draft files.
        dry_run: If True, print what would happen without writing files.

    Returns:
        List of (Invoice, Path) tuples for each draft created.
    """
    if not dry_run:
        output_dir.mkdir(parents=True, exist_ok=True)

    results = []
    for invoice in overdue:
        tier = invoice.overdue_tier
        tier_label = TIER_LABELS.get(tier, tier)

        if dry_run:
            print(
                f"  [DRY RUN] Would generate {tier_label} for "
                f"{invoice.client_name} ({invoice.invoice_id}) — "
                f"{invoice.amount_display} — {invoice.days_overdue} days overdue"
            )
            results.append((invoice, Path(f"[DRY RUN] {invoice.invoice_id}.txt")))
        else:
            draft_path = generate_draft(invoice, output_dir)
            results.append((invoice, draft_path))
            print(
                f"  Created: {draft_path.name} "
                f"({tier_label}, {invoice.days_overdue} days overdue)"
            )

    return results


# ---------------------------------------------------------------------------
# Summary report
# ---------------------------------------------------------------------------

def print_summary(
    all_invoices: list[Invoice],
    overdue: list[Invoice],
    drafts: list[tuple[Invoice, Path]],
    dry_run: bool,
) -> None:
    """Print a summary of the run to the console."""
    paid = [inv for inv in all_invoices if inv.is_paid]
    not_due = [
        inv for inv in all_invoices
        if not inv.is_paid and inv.days_overdue <= 0
    ]

    print()
    print("=" * 60)
    print("PAYMENT REMINDER SUMMARY")
    print(f"Date: {date.today().strftime('%B %d, %Y')}")
    print("=" * 60)
    print(f"Total invoices in tracker:  {len(all_invoices)}")
    print(f"  Paid:                     {len(paid)}")
    print(f"  Not yet due:              {len(not_due)}")
    print(f"  Overdue:                  {len(overdue)}")
    print()

    if not overdue:
        print("No overdue invoices. Great work!")
        return

    total_overdue_amount = sum(inv.amount for inv in overdue)
    print(f"Total amount overdue: ${total_overdue_amount:,.2f}")
    print()
    print("OVERDUE INVOICES:")
    print(f"  {'Invoice':<16} {'Client':<25} {'Amount':>10} {'Days':>6} {'Tier'}")
    print(f"  {'-'*16} {'-'*25} {'-'*10} {'-'*6} {'-'*20}")
    for invoice in sorted(overdue, key=lambda x: x.days_overdue, reverse=True):
        tier_label = TIER_LABELS.get(invoice.overdue_tier, "")
        print(
            f"  {invoice.invoice_id:<16} {invoice.client_name:<25} "
            f"{invoice.amount_display:>10} {invoice.days_overdue:>6}  "
            f"{tier_label}"
        )

    print()
    if dry_run:
        print(f"[DRY RUN] Would generate {len(drafts)} draft(s).")
        print("Run without --dry-run to create draft files.")
    else:
        print(f"Drafts generated: {len(drafts)}")
        print(f"Drafts saved to:  {DRAFTS_DIR}/")
        print()
        print("Next steps:")
        print("  1. Open the 'reminders/' folder")
        print("  2. Review each draft file")
        print("  3. Personalize if needed")
        print("  4. Copy the body into your email client and send")
        print("  5. Mark invoices as paid in invoices.csv when payment arrives")


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(
        description="Generate late payment reminder drafts from invoice tracker."
    )
    parser.add_argument(
        "--tracker",
        default=str(TRACKER_CSV),
        help=f"Path to invoice CSV tracker (default: {TRACKER_CSV})",
    )
    parser.add_argument(
        "--output-dir",
        default=str(DRAFTS_DIR),
        help=f"Directory to save draft files (default: {DRAFTS_DIR})",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Show what would be generated without creating files",
    )
    args = parser.parse_args()

    tracker_path = Path(args.tracker)
    output_dir = Path(args.output_dir)

    print(f"Maya Reyes Consulting — Payment Reminder Generator")
    print(f"Loading invoice tracker: {tracker_path}")
    print()

    all_invoices = load_invoices(tracker_path)
    overdue = get_overdue_invoices(all_invoices)

    print(f"Invoices loaded: {len(all_invoices)}")
    print(f"Overdue found:   {len(overdue)}")

    if not overdue:
        print("\nNo overdue invoices today. Nothing to do.")
        return

    print()
    print("Generating drafts...")
    drafts = generate_all_drafts(overdue, output_dir, dry_run=args.dry_run)
    print_summary(all_invoices, overdue, drafts, dry_run=args.dry_run)


if __name__ == "__main__":
    main()

Sample Script Output

When Maya runs python payment_reminders.py, she sees:

Maya Reyes Consulting — Payment Reminder Generator
Loading invoice tracker: invoices.csv

Invoices loaded: 6
Overdue found:   3

Generating drafts...
  Created: gentle_INV-2024-034_Novak_Digital_20241205.txt (Gentle Reminder (1-14 days), 14 days overdue)
  Created: firm_INV-2024-031_Thornfield_Media_20241205.txt (Firm Reminder (15-30 days), 35 days overdue)
  Created: final_INV-2024-032_Bellmore_Logistics_20241205.txt (Final Notice (31+ days), 28 days overdue)

============================================================
PAYMENT REMINDER SUMMARY
Date: December 05, 2024
============================================================
Total invoices in tracker:  6
  Paid:                     1
  Not yet due:              2
  Overdue:                  3

Total amount overdue: $13,800.00

OVERDUE INVOICES:
  Invoice          Client                    Amount   Days Tier
  ---------------- ------------------------- ---------- ------ --------------------
  INV-2024-031     Thornfield Media           $3,500.00     35  Firm Reminder (15-30 days)
  INV-2024-032     Bellmore Logistics         $6,200.00     28  Firm Reminder (15-30 days)
  INV-2024-034     Novak Digital              $4,100.00     14  Gentle Reminder (1-14 days)

Drafts generated: 3
Drafts saved to:  reminders/

Next steps:
  1. Open the 'reminders/' folder
  2. Review each draft file
  3. Personalize if needed
  4. Copy the body into your email client and send
  5. Mark invoices as paid in invoices.csv when payment arrives

A Sample Draft File

The file firm_INV-2024-031_Thornfield_Media_20241205.txt opens in any text editor:

DRAFT EMAIL — REVIEW BEFORE SENDING
Generated: December 05, 2024
============================================================

TO:      accounts@thornfieldmedia.com
FROM:    Maya Reyes <maya@mayareyesconsulting.com>
SUBJECT: Second Notice: Invoice INV-2024-031 — $3,500.00 — 35 Days Overdue

============================================================
BODY:

Hi,

I am writing to follow up on invoice INV-2024-031 for $3,500.00, which
was due on October 31, 2024 and is now 35 days past due.
...

Maya reads it, decides to add a personal note since Thornfield Media is a long-term client, and updates the opening paragraph. She then copies the body into Gmail and sends it. Total time: about three minutes.


Why No Auto-Send?

Maya considered making the script send emails automatically. She decided against it for three reasons:

Relationship risk. A client who paid yesterday might still appear as overdue if Maya has not updated the CSV yet. An automated final notice to a client who just paid would be embarrassing.

Tone calibration. The firm reminder template is appropriate for most clients. But for Thornfield Media — a long-term client who has never been late before and where Maya suspects this is an accounts payable oversight — she wants to soften it slightly. Human review preserves that option.

Legal caution. The final notice mentions collections and legal action. Maya's lawyer advised her to review any communication that includes those words before sending.

The semi-automated approach — generate, review, send — captures most of the efficiency gains while preserving human judgment where it matters.


Key Lessons from This Case Study

Design for the actual workflow, not the ideal workflow. Auto-sending would be faster. But it would also create problems Maya does not want. The script serves Maya's actual needs, not a hypothetical ideal.

Tiered templates scale with the situation. A single reminder template would require Maya to adjust the tone manually each time. Three templates — gentle, firm, final — cover the realistic range of situations.

The invoice tracker is the system of record. Everything the script does flows from the CSV file. This means Maya's workflow requires only one data-entry discipline: keeping the CSV up to date. When a client pays, she adds the paid date. The script handles everything else.

Dry-run mode is not optional. The --dry-run flag lets Maya see exactly what the script would do before committing. For any script that generates external communications, a preview mode is essential.

Make the next steps explicit. The summary at the end of the script tells Maya exactly what to do next. Good automation does not just do work — it leaves you in a clear position to continue.