# Payment Retry & Dunning

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 93  
**Tier:** Business+  
**Depends on:** `invoices-core`, `payment-gateway-adapters`, `system-communications-notifications`, `foundation-auth-rbac`  
**Referenced by:** `payment-gateway-adapters`, `invoices-core`

---

## Overview

Spec 49 (`payment-gateway-adapters`) handles the payment initiation and webhook confirmation flow but leaves no follow-up when sessions fail or invoices go overdue. This spec adds: configurable dunning schedules (days-past-due → action), automatic reminder emails to customers, optional gateway retry links, and staff visibility into the dunning state of each invoice.

---

## Data Model

```sql
CREATE TABLE dunning_schedules (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id),
  offset_days INTEGER NOT NULL,     -- days after invoice due_date to trigger
  action      TEXT NOT NULL CHECK (action IN ('email_reminder', 'suspend_access', 'flag_for_review')),
  email_template_id UUID,           -- optional custom email template (spec 43)
  created_at  TIMESTAMPTZ DEFAULT now(),
  UNIQUE (tenant_id, offset_days)
);

CREATE TABLE dunning_log (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL,
  invoice_id  UUID NOT NULL REFERENCES invoices(id),
  offset_days INTEGER NOT NULL,
  action      TEXT NOT NULL,
  sent_at     TIMESTAMPTZ DEFAULT now(),
  result      TEXT,                 -- 'sent' | 'skipped' | 'error'
  error_msg   TEXT
);
CREATE INDEX idx_dunning_log_invoice ON dunning_log(invoice_id);
```

Default dunning schedule (seeded on Business+ upgrade):

| Day offset | Action |
|------------|--------|
| +3 | email_reminder |
| +7 | email_reminder |
| +14 | email_reminder |
| +21 | flag_for_review |

---

## Boundary with Payment Reminders (spec 79)

`invoice-payment-reminders` (spec 79, all tiers) and this spec both email customers about unpaid invoices. The boundary, stated identically in both specs, is **by offset relative to `due_date`**:

- **Reminders (spec 79) own the pre-due / at-due window** (`offset_days <= 0`) — they always run, on all tiers.
- **Dunning (this spec, Business+) owns the post-due escalation window** (`offset_days > 0`) — its `+3 / +7 / +14 / +21` schedule, all strictly after `due_date`.

A single invoice therefore cannot trigger both on the same offset. The dedup guard lives in spec 79's cron: its post-due stages are suppressed whenever the tenant has any `dunning_schedules` row, so when dunning is active it is the **only** post-due emailer. Freelancer tenants (no dunning) fall back to spec 79's overdue stages. The two crons are also time-separated — reminders at 07:00 UTC, dunning at 08:00 UTC — so even a configuration edge case never double-sends within a single morning.

## Dunning Cron

Cron: `invoice-dunning` — daily 08:00 UTC.

```sql
-- Find invoices past due with pending dunning steps:
SELECT i.id AS invoice_id, i.tenant_id, i.due_date, i.customer_id,
       ds.offset_days, ds.action, ds.email_template_id
FROM invoices i
JOIN dunning_schedules ds ON ds.tenant_id = i.tenant_id
WHERE i.status IN ('SENT', 'TAX_ISSUED', 'PARTIALLY_PAID')
  AND i.due_date IS NOT NULL
  AND i.due_date + ds.offset_days * interval '1 day' <= now()
  AND NOT EXISTS (
    SELECT 1 FROM dunning_log dl
    WHERE dl.invoice_id = i.id AND dl.offset_days = ds.offset_days
  )
```

Per matched row:
1. `email_reminder` → send reminder email to `customers.email` via Resend with "Pay now" link (new payment session if gateway configured, portal link otherwise)
2. `flag_for_review` → create in-app notification for OWNER/ADMIN
3. `suspend_access` → `UPDATE customer_portal_users SET status = 'frozen' WHERE customer_id = :customerId AND tenant_id = :tenantId` — freezes all portal users for that customer (gated: only if tenant has enabled "suspend on dunning" in settings)

Insert `dunning_log` row with result.

---

## Settings UI: `/settings/invoicing/dunning`

```
┌──────────────────────────────────────────────────────────────┐
│  Dunning Schedule                      (Business+ feature)   │
│                                                              │
│  Days after due date   Action           Email template       │
│  ──────────────────────────────────────────────────────────  │
│  [+3__] days           [Email reminder ▾] [Default ▾]  [✕]  │
│  [+7__] days           [Email reminder ▾] [Default ▾]  [✕]  │
│  [+14_] days           [Email reminder ▾] [Custom  ▾]  [✕]  │
│  [+21_] days           [Flag for review▾] [—      ]    [✕]  │
│                                                              │
│  [+ Add step]                                                │
│                                                              │
│  ☐ Suspend customer portal access on dunning (aggressive)   │
│                                                              │
│  [Save schedule]                                             │
└──────────────────────────────────────────────────────────────┘
```

`PUT /api/settings/dunning` → upsert all rows for tenant (replace all).

---

## Invoice Detail: Dunning Status

Invoice detail page (`/invoices/:id`) shows dunning history in collapsed section:

```
┌──────────────────────────────────────────────────────────────┐
│  Dunning activity                                    [▾ Show] │
│                                                              │
│  2026-06-03  +3 days    Reminder sent     ✓                  │
│  2026-06-07  +7 days    Reminder sent     ✓                  │
│  2026-06-14  +14 days   Reminder sent     ✓                  │
│  2026-06-21  +21 days   Flagged for review (pending)         │
│                                                              │
│  [Send manual reminder]                                      │
└──────────────────────────────────────────────────────────────┘
```

"Send manual reminder" → `POST /api/invoices/:id/dunning/remind` — sends email immediately regardless of schedule, inserts dunning_log row with offset_days = -1 (manual).

---

## tenant_settings Delta

```sql
ALTER TABLE tenant_settings ADD COLUMN IF NOT EXISTS dunning_suspend_access BOOLEAN NOT NULL DEFAULT false;
```

---

## API

```
GET /api/settings/dunning
    → tenant dunning schedule
      Requires: OWNER, ADMIN (Business+)

PUT /api/settings/dunning
    → replace dunning schedule
      body: { steps: [{ offset_days, action, email_template_id? }] }
      Requires: OWNER (Business+)

POST /api/invoices/:id/dunning/remind
     → manual reminder
       Requires: invoices:write

GET /api/invoices/:id/dunning
    → dunning log for invoice
      Requires: invoices:read
```

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| `dunning_log` per step per invoice | Not just per invoice | Idempotency: cron checks log to skip already-sent steps; prevents double-sending on re-run |
| offset_days from `due_date` | Not from `issued_at` | Dunning is about payment obligation, not invoice creation |
| Business+ gate | Not all tiers | Automated dunning requires gateway + template infra; Freelancer invoices are typically personally followed up |
| `suspend_access` opt-in | Not default | Aggressive action; most tenants want reminders only; suspension is opt-in to avoid customer relationship damage |
