would collapse the vertical rhythm between it and
its neighbours. Every element below is bare, unclassed markup: bw-prose
styles h2/p/ul/table etc. at zero specificity, so adding a class here would
only be needed to override the floor, which nothing in this article does.
{% endcomment %}
Northwind sends three reminders before an invoice reaches collections.
This page explains the escalation order, how to change the schedule,
and how to test a change before it reaches real customers.
Escalation runs once a day, for every invoice with an open balance and
no dispute flag. Each stage is evaluated independently against the
invoice's due date, so an invoice can skip a stage entirely if a
payment or a dispute is recorded before that stage's day arrives.
Escalation order
#
The stage function is the single place this decision is made. Northwind
calls it once per invoice, per day, from a scheduled task:
{% comment %}
Real multi-line code, so it comes from the view as a context variable
rather than an inline {% templatetag openblock %} include {% templatetag closeblock %}
with= argument (see the WHY note above and _code.html's own header).
filename is supplied and language is a text label, never colour alone.
{% endcomment %}
{% include "brickwork/components/_code.html" with filename="billing/reminders.py" language="Python" copyable=True code=reminder_schedule_code %}
Stage order matters more than stage timing: a customer who pays between
the due date and the first reminder never sees a reminder at all, and
one who disputes at any point is pulled out of escalation on the next
daily run, not retroactively.
{% translate "Before you enable this" %}
Turning on automatic escalation sends to every account with an open
invoice, including ones already overdue under the old manual
process. Run a dry run first, with DRY_RUN=1, to see
exactly who would be contacted before the first real send.
{% translate "You should know" %}
Stages are evaluated in the account's own timezone, not the
server's. An invoice due on the 30th in Sydney is chased on the
30th in Sydney, even when the scheduler runs from London.
Changing the thresholds
#
The day counts in next_reminder are the only numbers most
teams ever need to change. Northwind's own support team moved the
overdue threshold from seven days to three after finding that most
disputes were raised in the first week regardless of when the reminder
arrived, so a later reminder was only delaying the useful signal.
- Lowering a threshold sends reminders sooner but raises the number a support team has to handle.
- Raising the final notice threshold delays collections handoff, which is rarely what a finance team wants.
- Thresholds are read once per run, not cached, so a change takes effect on the next scheduled task.
We assumed a longer overdue window would feel gentler. It mostly just
meant the first three days of a genuine dispute went unanswered.
Testing a schedule change
#
Every threshold change should ship with a test pinning the boundary
day, not just the middle of a stage. The boundary is where an
off-by-one silently moves a customer into the wrong stage a day early
or a day late:
{% comment %}
A second multi-line snippet, filename repeats the shape of the pattern
above but is a distinct file, so no id= collision risk here; supply id=
explicitly if a filename can repeat elsewhere on a page (see _code.html's
header on this exact trap). copyable=True shows the copy control, hidden
until bwCodeCopy's init() reveals it, so the no-JS floor still lets a
reader select and copy the text by hand.
{% endcomment %}
{% include "brickwork/components/_code.html" with filename="billing/reminders_test.py" language="Python" copyable=True code=reminder_test_code %}
Recovery by stage
#
The table below is why the overdue threshold moved. It reads left to
right as the escalation runs: the earlier a stage catches a payment,
the less likely it is to need a person.
{% comment %}
A wide table needs bw-prose__table-wrap, which carries contain: paint
alongside overflow-x (overflow-x alone does not stop a wide table
dragging the whole document sideways), plus tabindex="0" role="region"
aria-label so a keyboard user can reach the horizontal scroll (axe rule
scrollable-region-focusable). Shape copied from prose-block.html.
{% endcomment %}
{% translate "Median recovery across 600 Northwind accounts, 2025 to 2026." %}
| {% translate "Stage" %} |
{% translate "Paid within 7 days" %} |
{% translate "Needed a call" %} |
| {% translate "Due today" %} |
58% |
3% |
| {% translate "Overdue (3 days)" %} |
22% |
17% |
| {% translate "Final notice (14 days)" %} |
7% |
68% |
| {% translate "Collections handoff" %} |
2% |
91% |
Moving the overdue threshold from seven days to three shifted roughly a
fifth of accounts out of the "needed a call" column entirely, which is
the whole basis for the change described above.
Further reading: how late-payment prediction works
and how a dispute flag pauses escalation.
{% endblock %}
{% block docs_footer %}
{% comment %}
Prev/next: table stakes for a sequential documentation article. Direction
is stated in the link text itself ("Previous:"/"Next:"), never carried by
an icon alone, so the purpose of each link survives being read out of
context (WCAG 2.4.4).
brickwork ships a real pager treatment for this
(_pager.html, icvoss/django-brickwork#460), so this footer includes it
rather than composing its own workaround. previous_label/next_label are
passed as the destination TITLE alone ("Setting up a reminder schedule");
_pager.html renders the "Previous"/"Next" direction word itself, so the
full visible link text is still "Previous: Setting up a reminder
schedule", the same string WCAG 2.4.4 needs. Generating the neighbours
(which article comes before or after this one) is this view's own content
ordering, exactly as ADR-091 draws the boundary for the table of contents;
this template supplies the two destinations it already knows, nothing more.
{% endcomment %}
{% translate "More in Reminders" as bw_pager_aria_label %}
{% translate "Setting up a reminder schedule" as bw_pager_previous_label %}
{% translate "How late-payment prediction works" as bw_pager_next_label %}
{% include "brickwork/components/_pager.html" with aria_label=bw_pager_aria_label previous_href="/docs/reminders/schedules/" previous_label=bw_pager_previous_label next_href="/docs/reminders/predictions/" next_label=bw_pager_next_label %}
{% endblock %}
{% block docs_nav %}
{% comment %}
The rail: this section's own nav tree, composed the same way the app
shell's sidebar is (ADR-025). docs_nav_items is built in the view, already
visibility-filtered and active-resolved; docs_nav_active names the current
item.
{% endcomment %}
{% bw_nav items=docs_nav_items active=docs_nav_active %}
{% endblock %}