Skip to main content

BadCop

The polite-but-firm invoice chaser. You stay the good cop.

Website, email templates and late-fee calculator: https://volcarone.github.io/badcop/

BadCop is a zero-dependency command-line tool that chases unpaid invoices for you. Point it at a CSV of your invoices and it sends an escalating ladder of reminders from a separate "accounts" identity, applies the late fee your contract already allows, marks invoices paid from a bank or Stripe export, and hands you a one-page aging report. It never takes money and never invents a fee; it just does the awkward part on schedule.

Built for solo freelancers, consultants and small agencies who lose real money because chasing clients feels rude. One r/smallbusiness poster tallied $3,200 in late and unpaid invoices in a single year for exactly that reason.

$ badcop run --dry-run
DRY RUN INV-1001 -> ap@acme.example [firm, +7d]
  Subject: Overdue: invoice INV-1001 (7 days past due)
DRY RUN INV-1002 -> hello@bluefern.example [courtesy, -3d]
  Subject: Invoice INV-1002 is due on 2026-09-11
2 would be sent

How it works

  1. You keep a ledger. invoices.csv, one row per invoice. Export it from Wave, FreshBooks, Stripe, QuickBooks, or type it in.
  2. BadCop runs once a day (cron, systemd timer, GitHub Actions, Docker; anything that can run a command).
  3. Each open invoice climbs a ladder relative to its due date. Default: courtesy (3 days before), friendly (1 day after), firm (7), final with late fee (14), escalate to you (30). Each rung is sent exactly once. Nothing is sent twice, ever.
  4. When money lands, badcop match bank.csv --apply closes the matching invoices, and the ladder stops.
  5. badcop report tells you what is outstanding, how old it is, and what it is costing your clients.

Install

Requires Python 3.11+ and nothing else.

pip install .            # from a clone; or: pip install badcop
badcop --version

Or use the setup script, which creates a virtualenv, installs BadCop, runs the test suite and writes a sample workspace:

./setup.sh

Or Docker (see Deployment):

docker build -t badcop .
docker run --rm -v "$PWD/workspace:/data" badcop run --dry-run

Quick start

mkdir my-invoices && cd my-invoices
badcop init                     # writes badcop.toml, invoices.csv, templates/
$EDITOR badcop.toml             # sender identity, SMTP, your payment terms
$EDITOR invoices.csv            # your real invoices
export BADCOP_SMTP_PASSWORD='...'
badcop run --dry-run --verbose  # read every email that would go out
badcop run                      # send for real

Configuration: badcop.toml

[sender]
name        = "Accounts, Jane Doe Studio"   # the "bad cop"
email       = "accounts@janedoe.studio"
reply_to    = "jane@janedoe.studio"          # replies come back to you, the good cop
owner_email = "jane@janedoe.studio"          # receives the escalation step
owner_name  = "Jane"

[smtp]
host = "smtp.postmarkapp.com"
port = 587
username = "apikey"
password_env = "BADCOP_SMTP_PASSWORD"        # the secret lives in the environment, never in this file
starttls = true

[terms]
currency      = "USD"
net_days      = 14      # due_date defaults to issued_date + net_days
grace_days    = 3       # no late fee until this many days past due
late_fee_pct  = 1.5     # per month, simple interest on the principal; mirror YOUR contract
late_fee_flat = 0       # optional flat fee added once the grace period passes

[behaviour]
catch_up = false        # false: only the latest due step is sent on first run; true: send every missed step
templates_dir = "templates"

[[steps]]
offset_days = -3
name = "courtesy"

[[steps]]
offset_days = 1
name = "friendly"

[[steps]]
offset_days = 7
name = "firm"

[[steps]]
offset_days = 14
name = "final"
apply_late_fee = true   # the fee is applied from this step onward

[[steps]]
offset_days = 30
name = "escalate"
notify_owner = true     # this step goes to owner_email, not the client

Any number of steps, any offsets, any names. Each step needs a template of the same name (the five defaults ship with badcop init).

Late fees. BadCop computes fees only from the numbers you enter. Set late_fee_pct and grace_days to what your contract or invoice terms actually say; if your terms say nothing, leave it at 0 and the "final" email simply omits the fee.

The ledger: invoices.csv

Column Required Notes
invoice_id yes Unique. Used in subjects and for payment matching.
client_name yes
client_email yes
amount yes Principal, excluding fees.
currency Defaults to [terms].currency.
issued_date yes YYYY-MM-DD
due_date Defaults to issued_date + net_days.
status open (default), paid, or void. Only open invoices are chased.
paid_date Set by match --apply or by hand.
late_fee_pct, grace_days Per-invoice overrides.
pay_link Inserted into the emails when present.
notes Free text, never sent.

Validation is strict and line-numbered. A ledger with a bad row sends nothing (exit code 2).

Commands

Command What it does
badcop init [--dir D] [--force] Write a sample config, ledger and the five default templates.
badcop run [--dry-run] [-v] [--today YYYY-MM-DD] Send every reminder that is due. --dry-run prints instead and writes no state.
badcop preview INVOICE_ID [--step NAME] Render one email to stdout so you can tune the tone.
badcop match payments.csv [--apply] Match a bank / Stripe / PayPal export to open invoices. Without --apply it only reports.
badcop report [--format md|csv] Aging report: outstanding, buckets, fees, per client. Writes aging_report.md and .csv.

| badcop sync stripe [--dry-run] | Pull open and paid invoices from Stripe Invoicing into the ledger (adds new ones, updates paid status). Needs STRIPE_API_KEY set to a restricted key with Invoices: Read. |

Global options: --config badcop.toml --ledger invoices.csv --state state.json.

Google Sheets as the ledger

Keep your invoices in a sheet with the columns above, then File › Share › Publish to web, choose the sheet and CSV, and pass the link:

badcop --ledger "https://docs.google.com/spreadsheets/d/e/…/pub?output=csv" run --dry-run

run, preview and report work from a URL ledger. match --apply and sync need a local file, because they write to it.

Payment matching

badcop match chase-export.csv            # dry run: prints a match report
badcop match chase-export.csv --apply    # mark high-confidence matches paid; writes invoices.csv.bak first

Column names are auto-detected from common exports (Date/Posted, Amount/Credit, Description/Memo/Payee); force them with --date-col, --amount-col, --desc-col. Outgoing (negative) lines are ignored.

A line is matched with high confidence only when its amount equals exactly one open invoice within the date window, or when several invoices share the amount and the description carries the invoice id or the client's name. Everything else is listed as ambiguous or unmatched for you to handle by hand. Each invoice is closed at most once.

Templates

Plain text files in templates/<step>.txt: a Subject: line, a blank line, then the body.

Subject: Overdue: invoice {invoice_id} ({days_overdue} days past due)

Hi {client_name},

Invoice {invoice_id} for {currency} {amount} is now {days_overdue} days past its due date of {due_date}.
{pay_link}
...

Placeholders: {client_name} {invoice_id} {amount} {currency} {issued_date} {due_date} {days_overdue} {late_fee} {total_due} {pay_link} {sender_name} {owner_name} {steps_sent}. An unknown placeholder is an error, not a blank.

State and idempotency

state.json records every step sent per invoice, with timestamp and Message-ID. Running badcop run ten times a day sends each step once. Delete an invoice's entry to resend; delete the file to start over.

Exit codes

Code Meaning
0 OK, including "nothing due"
1 Config or usage error
2 Ledger, payments file or template validation error (nothing sent)
3 One or more sends failed (the rest were sent and recorded)

Deployment

BadCop is a one-shot command; schedule it.

cron (daily at 08:00):

0 8 * * * cd /home/jane/invoices && BADCOP_SMTP_PASSWORD=... /usr/local/bin/badcop run >> badcop.log 2>&1

systemd timer: a badcop.service with ExecStart=/usr/local/bin/badcop run, WorkingDirectory=/home/jane/invoices, EnvironmentFile=/home/jane/invoices/.env, and a badcop.timer with OnCalendar=daily.

Docker: the image runs badcop run --dry-run by default; mount your workspace at /data and pass the real command.

docker build -t badcop .
docker run --rm -v "$PWD/workspace:/data" -e BADCOP_SMTP_PASSWORD badcop run
docker run --rm -v "$PWD/workspace:/data" badcop report

Files written into the mount (state.json, reports, invoices.csv.bak) are owned by root unless you add --user "$(id -u):$(id -g)".

GitHub Actions, the easy way: use the badcop-template repository. Click Use this template, make it private, add your invoices and one secret, and it runs the ladder every morning on GitHub's free minutes. Dry-run by default until you flip a variable.

GitHub Actions, by hand (ledger in a private repo, secret in repository settings):

on:
  schedule: [{cron: "0 8 * * *"}]
jobs:
  chase:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: {python-version: "3.12"}
      - run: pip install badcop
      - run: badcop run
        env: {BADCOP_SMTP_PASSWORD: "${{ secrets.SMTP_PASSWORD }}"}
      - run: git config user.name badcop && git config user.email badcop@users.noreply.github.com && git add state.json && git commit -m "badcop state" || true && git push

Email provider. Use a transactional SMTP service (Postmark, Amazon SES, Mailgun, Resend) with a real accounts@ mailbox on your domain so replies and bounces reach you. Reminders are transactional mail about an existing business relationship, not marketing.

Development

python -m unittest discover -s tests        # 99 tests, standard library only
python -m badcop --help                     # run from the source tree with PYTHONPATH=src

Layout: src/badcop/ (config, ledger, schedule, templates, state, mailer, matcher, report, cli), tests/, Dockerfile, setup.sh.

FAQ

Will this make my clients angry? The default copy is courteous through "firm", states facts in "final", and the last rung goes to you, not to your client's boss. Preview every step with badcop preview and rewrite anything you would not say yourself.

Can I charge late fees? Only if your contract or invoice terms say so. BadCop applies whatever percentage and grace period you enter and nothing else. Set it to 0 if you have no such terms.

Does it create or send invoices? No. It chases the ones you already sent. Keep using whatever you invoice with.

Does it need my bank login? No. match reads a CSV you export yourself.

License

MIT.

Metadata

Release files for badcop 0.1.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for badcop 0.1.1
File Size Uploaded Provenance
badcop-0.1.1.tar.gz 38.6 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for badcop 0.1.1
File Interpreter ABI Platform
badcop-0.1.1-py3-none-any.whl Python 3 none any Details

Total release size: 65.8 kB

Release files / badcop-0.1.1.tar.gz

Download URL badcop-0.1.1.tar.gz
Size 38.6 kB
Tags Source
SHA-256 checksum
How to use checksums
9d3e7995fa932b8e4fccecbebd997a37b356efe3456394e6266b86c9bde6ce2b
BLAKE2b-256 checksum
How to use checksums
87b1611488804b08c9fe0c99b0e59add9bf2d9bc2ed5e1b03d07eab42c6c100a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.

Transparency log

Release files / badcop-0.1.1-py3-none-any.whl

Download URL badcop-0.1.1-py3-none-any.whl
Size 27.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
67702040f04457c5d062dc503d05ebdf67e11d7eedc60f6b779a7dcae3afb869
BLAKE2b-256 checksum
How to use checksums
db4ca524edd427076cc835be08761bd2548da4be4e6c2af5167e832bbebc0938
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.

Transparency log

Security for badcop 0.1.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for badcop-0.1.1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page