# R3 - payment reminders, drafted and never sent

Read-date 2026-11-10. Part of the Back-Office Autopilot pack.

## What it is for

You invoiced. The money did not arrive. Chasing it is the same three facts
repeated until it does: which invoice, how late, how much. `invoice_reminders.py`
reads your invoice export, works out which reminders are owed today, and writes
each one as a numbered draft you can read and send yourself.

## The decision this recipe is built on

**It prepares the words and never lets them go out by itself.** There is no mail
path in this file to switch on.

That is not shyness about automation. A reminder carries money language written
in *your* name to a person who owes you money, and it is the one place in this
pack where an unattended mistake costs a customer rather than a minute. A draft
that is wrong about the amount, the date, or whether the invoice was already paid
can end a client relationship on a Tuesday morning, and no log file gives it
back. So the program's last line tells a human the sending is theirs:
*"Nothing left this program. Check each draft ... then send it yourself or delete
it."*

The property is tested, not promised. `smoke.sh` checks that no file it produced
claims to have been sent, that the run hands the sending back, and that the guard
named in `SELF_CHECK` still exists in the source. The suite goes further and
reads the module's syntax tree: no import of `smtplib`, `urllib`, `socket`,
`http` or `requests`, and no defined function whose name contains send, smtp,
mail, http, post or socket. Both halves of the guard are asserted, because a
`SELF_CHECK` string naming a function that no longer exists is a dead promise.

## The schedule, stated so you can disagree with it

By default a reminder is owed **1 day** after the due date and a second **7 days**
after it. Ask for other days with `--remind-days 3,14`.

The day an invoice falls due is not a chase day, and a schedule that would put a
reminder on the due date itself (`0,7`) is refused: `--remind-days part '0' means
chasing somebody before the invoice is even late; both days must be at least 1`.

The outbox is its own memory. Having already drafted a reminder is what stops it
drafting that reminder again, and the refusal names the file it found, so the
queue is auditable rather than hopeful.

## What it refuses, and why that is the product

- An empty `amount` is refused with its row and column named, never reminded as
  zero. A reminder that states the wrong figure is the damage this recipe exists
  to prevent.
- A `due_date` that is not a real date (`31/11/2026`) is refused rather than
  guessed. Inventing a date puts a chase on the wrong day.
- An invoice whose `status` says `paid`, `void` or `cancelled` is held and
  reported - your status column is the seller's own word on whether to chase.
- A file missing the columns it needs exits non-zero and names every one of them,
  and says how to map them with `--map`.

Refusals print as `CHECK` lines with the file and row, and the run still finishes
the rows that are sound. One bad row in an export of forty does not cost you the
thirty-nine.

## Costs and lifetime

Nothing bills, nothing phones home, no account to keep open. What can break is
your export's shape, and the failure mode is a named column rather than a wrong
number sent to a customer.

## Run it

```
cd recipes/r3
bash smoke.sh                                    # 30-second smoke
python3 -m pytest test_invoice_reminders.py -q   # the full suite
```

Both offline, standard library and pytest only. The suite drives the real command
line, so what it proves is the behaviour in `SETUP.md` rather than the shape of
the internals.
