# R3 - the approval checklist (what "a human approves" actually means)

Written 2026-11-10, from `invoice_reminders.py` as it reads on that date. Every
statement below names the thing in the code it comes from, so you can check it
rather than take my word. `python3 invoice_reminders.py --dry-run-is-all-there-is`
prints the program's own reason for there being no other mode.

## Why this file exists, in one paragraph

The program prepares money language in **your** name to a person who owes you
money, and then stops. The docstring is explicit about the reason: the one fact
that would make a reminder safe to let go unreviewed — *"is this genuinely still
unpaid today, in my books?"* — **is not in the CSV and is not on the machine**.
Only you know it. So the machine's output is a draft, and the approval is the
step where a person supplies the fact the file cannot carry. This checklist is
that step written down, so it takes a minute and is the same minute every time.

Order of work: **read the run's report first, then the drafts.** The report
names what was drafted, what was held back, and why. A draft you never learn
about is not a draft you approved.

## The six checks, per draft

Each one is a comparison against something outside the draft. If you cannot do
the comparison, you have not approved the draft — you have accepted it.

1. **Still unpaid today?** Open your ledger or bank, not the CSV. The CSV is a
   snapshot of the day it was exported; the program cannot see a payment that
   arrived after it. *This is the check the whole design rests on.*
   (`NEVER_REMIND_STATUSES` in the code holds the statuses the program already
   refuses on the strength of your `status` column — `paid`, `paidinfull`,
   `settled`, `cleared`, `closed`, `void`, `voided`, `cancelled`, `canceled`,
   `duplicate`, `refunded`, `writtenoff`, `waived`, `credit`, `creditnote`,
   `disputed`, `in dispute`, `under dispute`, `do not remind`, `no remind`,
   `on hold`, `hold`, compared after folding case and punctuation, so
   `Paid in full` and `paid-in-full` both land on the refusal. That is your
   status column's word, not your bank's.)
2. **Is the amount the amount still owed?** The draft repeats
   `amount` **exactly as your file wrote it** — SETUP.md says so and the code
   does not change it. A CSV that holds the invoice total when part was paid is
   the failure the docstring names, and the program has no way to know. Compare
   the figure against what is outstanding, not against what was invoiced.
3. **Is the due date the date you told the customer?** A date that is not a
   real calendar date is refused outright (`31/11/2026` never becomes a guess),
   but a real date that is simply the wrong date is not the program's to catch.
4. **Is the days-past-due figure the schedule you want?** It is computed from
   `--as-of` and `--remind-days` (default **1** and **7** days past due;
   `0` is refused, because a reminder on the due date is not a reminder). If
   you would not chase somebody at that number of days, do not send it: change
   the schedule for the next run rather than fixing the wording of this one.
5. **Is the address the right human, and is now the right moment?** The program
   knows an email address. It does not know that the person is on leave, that
   you agreed to wait, or that this client is mid-negotiation. That judgement
   belongs here and nowhere else in the pack.
6. **Read it aloud.** It is the same three facts — invoice, lateness, amount —
   and the tone is yours to keep. Delete the draft if the answer is no; the
   file's own rule is that nothing has left until you press send.

## The flags that mean "look harder", and why they exist

The code sorts statuses into three sets and behaves differently for each, so
these are not opinions — they are branches you can read:

- **Status in `CAUTION_STATUSES`** (`partial`, `partiallypaid`, `partpaid`,
  `partialpayment`, `promised`, `promisedtopay`, `paymentplanned`,
  `paymentpromised`): drafted **and flagged**. Money is owed, but the quoted
  figure may not be the number to quote. Checks 1 and 2 are the whole check.
- **Status in none of the three sets** (not open, not paid, not caution):
  drafted **and reported as a status this recipe does not know**. That is
  deliberate: guessing at a status in money language is the wrong kind of
  clever, so the program hands you the unknown instead of deciding.
- **No `status` column at all** (`--map status=-`): every row is treated as
  *not known to be paid* and flagged. Usable, but it moves check 1 onto you for
  every row, which is most of the value of the recipe.
- **No `customer` column** (`--map customer=-`): the draft addresses the email
  address. Check 5 is then doing all the work.

## What the program already refused for you — do not re-check these

These are the failures that used to reach a customer as a wrong number. They now
stop the program instead, so re-checking them against a draft is wasted time:

- an **empty `amount`** is refused with its row and column named — never
  reminded as zero;
- a **`due_date` that is not a real date** is refused, never guessed;
- a **`status` saying paid, void, cancelled, disputed, refunded or on hold** is
  held and reported, not chased;
- a file **missing a column it needs** exits non-zero naming every missing one,
  and shows you how to map your own headers with `--map`;
- a **reminder already drafted** is not drafted twice: the outbox is the
  program's memory and the refusal names the file it found, which is what makes
  the queue auditable instead of hopeful;
- **`--as-of` that is not a date** is refused, because that one value decides
  every "how late" figure in the output.

Refusals print as `CHECK` lines with the file and the 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.

## The property that makes the rest of this safe

`assert_drafts_only()` reads this file's own source with `ast` at import and at
every run, and stops with a `DraftsOnlyViolation` if a mail, network or shell
import, an import of `subprocess`, or a function whose name means sending is
ever added. The only mode switch on the command line is
`--dry-run-is-all-there-is`, and what it prints is an explanation of why there
is no other mode. Drafts carry the banner

    DRAFT - WRITTEN, NOT SENT. This recipe cannot send anything.

so a draft cannot be mistaken for a sent message even in a crowded outbox.

## The honest limits of what a checked draft means

- A checked draft is **not** proof the invoice is unpaid. It is proof a person
  looked at check 1 on a given date. That is all any automation can give here.
- The program cannot see your customer relationship, your goodwill, or the fact
  that you already spoke to them yesterday.
- Nothing here is legal advice, and a reminder is not a late-payment penalty.
  If you add interest or fees, that is your own terms and your own lawyer, not
  this file.
- `about a minute for a normal run` is what `--dry-run-is-all-there-is` claims
  for the review. Treat it as the program's claim until you have timed your own.

## If you want the sending automated anyway

The program says this itself rather than me paraphrasing it: write it in a file
of your own — read the outbox, do your own approval step, let that file carry
the risk. That is allowed and expected. The promise in `invoice_reminders.py` is
about `invoice_reminders.py`, not about your machine. What it will not do is
quietly become something else.
