How-to
Automating Reporting WorkflowsDeep dive

Build a Credit Control Dunning Letter Run

Run credit control reminders from an open receivables export with pandas: decide each customer's reminder level from their most overdue invoice and their letter history, leave out disputed invoices, payment plans and small balances, never escalate faster than the policy allows, produce a run workbook for review and a letter for each customer listing what they owe, and record what was sent.

Chasing overdue invoices works best when it is regular and consistent: a polite reminder a week after the due date, a firmer letter a fortnight later, a final notice, and only then a referral to collections or a stop on the account. That sequence — dunning — is easy to describe and fiddly to run by hand from an aged debt listing. Each customer's level depends on their most overdue invoice and on which letter they last received; disputed invoices must not be chased; customers on an agreed payment plan should not get a final notice; a 12.50 balance is not worth a letter. This page builds the weekly run with pandas from an open receivables export and a letter history: it decides each customer's level, applies the exclusions and a minimum gap between letters, produces a workbook for credit control to review, generates a letter per customer listing the overdue invoices, and records the run so the next one escalates correctly. It belongs to Building Finance and Operations Reports.

Prerequisites

Bash
pip install pandas openpyxl

Three inputs: the open receivables listing (one row per unpaid invoice with due date, outstanding amount and a status column where disputes are marked), the customer list with contact email and a payment-plan flag, and the history of letters already sent. The run date is Friday 2 October 2026.

Step 1 — The policy, as data

Write the dunning policy down as a table: the days overdue at which each level applies, what it is called, and the rules around it:

Python
import pandas as pd

RUN_DATE = pd.Timestamp("2026-10-02")
LEVELS = pd.DataFrame({"level": [1, 2, 3, 4], "min_days": [7, 21, 35, 50],
                       "name": ["Friendly reminder", "Second reminder", "Final notice", "Referral to collections"]})
MIN_GAP_DAYS = 14          # at least two weeks between letters
MIN_BALANCE = 50.00        # do not chase smaller overdue balances
MAX_LEVEL_ON_PLAN = 1      # customers on a payment plan only get friendly reminders
print(LEVELS.to_string(index=False))

Keeping the thresholds in one table makes the policy visible to the people who own it — credit control, not the developer — and lets it change without hunting through code. Level 4 is not a letter to the customer but an internal referral; it appears in the run so someone acts on it.

The dunning sequence by days overdue A friendly reminder at 7 days overdue, a second reminder at 21 days, a final notice at 35 days and a referral to collections at 50 days, with at least 14 days between letters to the same customer. 7 days friendly reminder 21 days second reminder 35 days final notice 50 days refer to collections

Step 2 — Overdue position per customer

Python
items = pd.read_excel("ar_open_items.xlsx")
customers = pd.read_excel("customers.xlsx")
history = pd.read_excel("dunning_history.xlsx")

items["Days overdue"] = (RUN_DATE - items["Due date"]).dt.days
chaseable = items[(items["Days overdue"] > 0) & (items["Status"].fillna("") != "disputed")]
position = (chaseable.groupby(["Customer", "Name"], as_index=False)
            .agg(Overdue=("Outstanding", "sum"), Invoices=("Invoice", "count"), Oldest_days=("Days overdue", "max")))
last = history.sort_values("Sent").groupby("Customer").tail(1).set_index("Customer")
position = (position.merge(customers, on="Customer", how="left")
            .join(last.rename(columns={"Sent": "Last sent", "Level": "Last level"}), on="Customer"))
position["Last level"] = position["Last level"].fillna(0).astype(int)
print(position[["Customer", "Name", "Overdue", "Oldest_days", "Last level", "Last sent"]].to_string(index=False))

Disputed invoices are removed before anything else — chasing an invoice the customer has queried damages the relationship and rarely gets it paid — and invoices not yet due are not overdue at all. Each customer's position is the total overdue, the number of overdue invoices and the age of the oldest, joined to the last letter they received. Zenith's 2,250 disputed invoice is not counted, although their other invoice is.

Step 3 — Decide each customer's letter

The level a customer qualifies for comes from their oldest overdue invoice. What they actually get also depends on history and exclusions: at most one level above their last letter, not within two weeks of it, nothing for tiny balances, and only friendly reminders on a payment plan:

Python
def decide(r):
    qualifies = int(LEVELS.loc[LEVELS["min_days"] <= r["Oldest_days"], "level"].max()) if r["Oldest_days"] >= LEVELS["min_days"].min() else 0
    if qualifies == 0:
        return 0, "not yet overdue enough"
    if r["Overdue"] < MIN_BALANCE:
        return 0, f"overdue balance below {MIN_BALANCE:.0f}"
    if pd.notna(r["Last sent"]) and (RUN_DATE - r["Last sent"]).days < MIN_GAP_DAYS:
        return 0, f"last letter {(RUN_DATE - r['Last sent']).days} days ago"
    level = min(qualifies, r["Last level"] + 1)
    if r["Payment plan"]:
        level = min(level, MAX_LEVEL_ON_PLAN)
        if level <= r["Last level"]:
            return 0, "on payment plan — no escalation"
    return level, LEVELS.set_index("level").loc[level, "name"]

position[["Level", "Action"]] = position.apply(decide, axis=1, result_type="expand")
print(position[["Name", "Overdue", "Oldest_days", "Last level", "Level", "Action"]].to_string(index=False))

Three letters go out this week. Oakridge's oldest invoice is 49 days overdue, which qualifies for a final notice, but their last letter was a friendly reminder, so they get the second reminder; Café Señor gets a first reminder; Zenith, at 63 days with a second reminder already sent, gets the final notice. Brightside is 84 days overdue but on a payment plan, Granite had a letter a week ago, Harbour Print's 12.50 is below the threshold and Northwind is only three days late. Never jumping more than one level above the last letter matters most for customers who were never chased: an account that slipped through for 80 days still gets a friendly reminder first, not a referral out of nowhere — the sequence is what makes the final notice credible. The two-week gap stops a customer who received a second reminder last Friday from getting a final notice today. Each decision carries its reason, including the ones that result in no letter, so a reviewer can see why a large overdue balance is not being chased this week.

Which letter a customer gets this week Customers whose overdue balance is too small, or who received a letter in the last 14 days, get none. Otherwise the level is the one their oldest overdue invoice qualifies for, but at most one above their last letter, and only a friendly reminder if they are on a payment plan. Customer with overdue invoices tiny balance / recent letter no letter reason recorded payment plan friendly reminder only no escalation otherwise min(qualifies, last + 1) one step at a time This week's decisions Oakridge Farms qualifies for a final notice but gets a second reminder. Café Señor gets a friendly reminder and Zenith Logistics a final notice. Brightside is on a payment plan, Granite Tools had a letter seven days ago, Harbour Print's balance is too small and Northwind is only three days overdue, so they get no letter. days overdue last letter this week Oakridge Farms 49 level 1 second reminder Café Señor 12 none friendly reminder Zenith Logistics 63 level 2 final notice Brightside & Co 84 level 2 none — payment plan Granite Tools 77 level 2 none — 7 days ago Harbour Print 42 none none — 12.50

Step 4 — Review workbook, letters and the log

Python
from pathlib import Path
from string import Template

run = position[position["Level"] > 0].copy()
LETTER = Template("""Dear $name,

$opening

Invoice      Due date      Days overdue      Amount
$lines
Total overdue: $total

$closing

Credit Control
""")
TEXT = {1: ("Our records show the following invoices are now overdue. If you have already paid, please ignore this reminder.",
            "We would be grateful for payment at your earliest convenience."),
        2: ("We wrote to you recently about the invoices below, which remain unpaid.", "Please arrange payment within 7 days."),
        3: ("Despite previous reminders, the invoices below remain unpaid.",
            "Unless payment is received within 7 days we will refer the debt for collection and may suspend your account.")}
out = Path("letters"); out.mkdir(exist_ok=True)
for r in run.itertuples(index=False):
    if r.Level == 4:
        continue                                   # internal referral, no customer letter
    inv = chaseable[chaseable["Customer"] == r.Customer].sort_values("Due date")
    lines = "\n".join(f"{inv_no:<12} {due:%d %b %Y}   {days:>12}   {amount:>10,.2f}"
                      for inv_no, due, days, amount in inv[["Invoice", "Due date", "Days overdue", "Outstanding"]].itertuples(index=False))
    opening, closing = TEXT[r.Level]
    (out / f"{RUN_DATE:%Y-%m-%d}_{r.Customer}_L{r.Level}.txt").write_text(
        LETTER.substitute(name=r.Name, opening=opening, lines=lines, total=f"{r.Overdue:,.2f}", closing=closing))

with pd.ExcelWriter(f"dunning_run_{RUN_DATE:%Y-%m-%d}.xlsx", engine="openpyxl", date_format="dd mmm yyyy", datetime_format="dd mmm yyyy") as xw:
    run.to_excel(xw, sheet_name="Letters", index=False)
    position[position["Level"] == 0].to_excel(xw, sheet_name="No letter", index=False)
    chaseable.to_excel(xw, sheet_name="Overdue items", index=False)

log = pd.concat([history, run[["Customer"]].assign(Sent=RUN_DATE, Level=run["Level"].values)], ignore_index=True)
log.to_excel("dunning_history.xlsx", index=False)
print(sorted(p.name for p in out.iterdir()))
print((out / f"{RUN_DATE:%Y-%m-%d}_C003_L3.txt").read_text())

The review workbook separates customers getting a letter — with level, amount and reason — from those who are not, so credit control can check both lists before anything is sent. Letters are generated from a template per level with the customer's overdue invoices in a table; Zenith's final notice lists only the undisputed invoice. Plain text keeps the example short; the same data fills a Word letter in Generate Word Documents from Excel Rows with Python or an HTML email. Finally, the run is appended to the letter history — only after review and sending in a real process — which is what makes next week's run escalate correctly rather than starting everyone again at level 1.

Common pitfalls

  • Chasing disputed invoices. Remove them before working out levels.
  • Skipping levels. Escalate at most one level above the last letter sent.
  • Letters every run. Enforce a minimum gap since the last letter.
  • Final notices to customers on a payment plan. Cap their level.
  • No history. Record what was sent, or every run starts from the beginning.

Conclusion

A dunning run is a small set of rules applied consistently: compute each customer's overdue position from undisputed invoices, find the level their oldest invoice qualifies for, cap it at one above their last letter, respect a minimum gap, a minimum balance and payment plans, and record every decision with its reason. Write a review workbook, generate a letter per customer from a template per level, and append the run to the letter history so the next run continues the sequence.

Frequently asked questions

How do I decide which dunning letter to send? Base the level on the days overdue of the customer's oldest undisputed invoice, but never more than one level above the last letter sent to them.

How do I stop customers getting reminders too often? Keep a history of letters sent and skip customers who received one within a minimum gap, such as 14 days.

Should disputed invoices be included in reminder letters? No. Remove invoices marked as disputed before calculating the overdue balance and level.

How do I generate a letter for each customer from Excel data? Group the overdue invoices by customer and fill a text, Word or HTML template per dunning level with the customer's name, invoice table and total.

More on report layouts & templates

More on report layouts & templates →