# agent dunning loop re-sent past-due emails every poll cycle: 400 emails to one customer

## TL;DR
Make dunning sends idempotent: record each sent reminder per invoice (invoice id + dunning stage) and skip any stage already recorded, no matter how many times the poller runs. If a flood already happened, pause the job, apologize once, and fix the guard before resuming. The poller should ask "what is newly due?" not "what is due?"

```text
agent dunning loop re-sent past-due emails every poll cycle: 400 emails to one customer
```

## Fix it

1. **Record every send.** Before sending, write (invoice id, stage, timestamp) to durable storage; check it first and skip on a hit. Success check: running the poller twice in a row sends zero emails the second time.
2. **Stage the dunning, don't blast it.** Define stages (day 3, day 7, day 14) and only send when the invoice crosses into a new stage. Success check: an invoice 10 days overdue has exactly 2 reminders on record.
3. **Add a circuit breaker.** If one customer would receive more than 2 emails in 24h, halt and page a human instead. Success check: a forced flood in staging trips the breaker and sends an alert, not 400 emails.
4. **Recover from a flood.** Stop the job, send one apology with a correct statement of what is owed, and confirm the guard is live before restarting. Success check: no customer receives more than one apology plus their normal staged reminders.

## When this applies

- A cron, agent, or billing worker sends payment reminders on a schedule.
- A customer reports receiving the same past-due email repeatedly.

## When it doesn't

- The reminder schedule itself is wrong (too aggressive by design); that is a policy decision, not a loop bug.
- Emails are bouncing or landing in spam; that is deliverability, not duplication.

## Compatibility

- Pattern: agent-driven dunning on Stripe Billing, Chargebee, Recurly, or any system where the agent decides when to email.
- Works with any mail provider; the guard lives in your code, not the mailer.

### Variant: agent re-sent "payment failed" emails on every webhook retry

Webhook retries re-fire the same event. Same fix: dedupe on event id before sending.

### Variant: two agents both ran dunning

Overlapping jobs each think they own reminders. Same fix plus a single owner: one job, or a distributed lock around the send step.

## Why it happens

The poller queries "all overdue invoices" every cycle and emails each one, with no memory of what it already sent. Every cycle is a fresh blast to the same customers.

## Edge cases

- Invoice state changes: if an invoice is paid then goes past-due again later, allow a new dunning sequence; key the record on (invoice id, stage) and reset when status returns to paid.
- Partial payments: stage on remaining balance thresholds, not just age, so a customer who paid 90% doesn't get the day-14 tone.

## Provenance

Resolved from the public thread: https://vectle.com/posts/pst_usdDUBX4e2fimkHWAHGehQ
