# Proposal Expiry & Deadline

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 96  
**Tier:** All tiers  
**Depends on:** `marketing-catalogs-campaigns`, `public-proposal-view`, `system-communications-notifications`, `foundation-auth-rbac`  
**Referenced by:** `marketing-catalogs-campaigns`, `public-proposal-view`

---

## Overview

Spec 23 (`marketing-catalogs-campaigns`) defines `proposals.expires_at TIMESTAMPTZ` and `status = 'EXPIRED'`, and spec 53 (`public-proposal-view`) shows an expired state banner. However, neither spec defines: the UI to set expiry when composing a proposal, auto-expiry via cron, countdown display on the public view, or expiry reminder notifications to staff. This spec fills those gaps.

---

## Data Model

No new tables. Schema already has `proposals.expires_at TIMESTAMPTZ` and `proposals.status = 'EXPIRED'` (spec 23).

---

## Compose UI: Expiry Date

When creating or editing a proposal (`/proposals/new`, `/proposals/:id/edit`):

```
┌──────────────────────────────────────────────────────────────┐
│  Proposal settings                                           │
│                                                              │
│  Valid until:                                                │
│  ● No expiry                                                 │
│  ○ Expires on  [2026-06-30]                                  │
│                                                              │
│  ⓘ Expired proposals lock acceptance and show an expiry     │
│    banner to the recipient.                                  │
└──────────────────────────────────────────────────────────────┘
```

`PATCH /api/proposals/:id` — body includes `expires_at?: string | null`.

Default: no expiry. Tenant default configurable in `/settings/proposals`.

---

## Settings: Default Expiry

`/settings/proposals` (new section):

```
┌──────────────────────────────────────────────────────────────┐
│  Proposal defaults                                           │
│                                                              │
│  Default valid-for:                                          │
│  ● No default expiry                                         │
│  ○ [30__] days after sending                                 │
│                                                              │
│  [Save]                                                      │
└──────────────────────────────────────────────────────────────┘
```

```sql
ALTER TABLE tenant_settings ADD COLUMN proposal_default_valid_days INTEGER;
-- NULL = no default expiry
```

When a proposal is sent (`status → SENT`), if `expires_at` is NULL and `proposal_default_valid_days` is set: `expires_at = sent_at + proposal_default_valid_days * interval '1 day'`.

---

## Auto-Expiry Cron

Cron: `proposal-expiry` — daily 06:00 UTC.

```sql
UPDATE proposals
SET status = 'EXPIRED'
WHERE expires_at IS NOT NULL
  AND expires_at < now()
  AND status IN ('SENT', 'VIEWED')
RETURNING id, tenant_id, title, customer_id, lead_id
```

For each expired proposal:
- In-app notification to OWNER/ADMIN: "Proposal "{title}" expired — customer did not respond."
- **Lead state-exit (spec 146 `lead-to-proposal-flow`):** if `lead_id` is set, run the `proposal.expired` lead-stage rule — a lead still in `PROPOSAL` stage is reverted to `QUALIFIED` for follow-up (leads past PROPOSAL, or with another live SENT/VIEWED proposal, are untouched). Without this, a lead whose only proposal expired is stranded in PROPOSAL with no live proposal. The cron already returns `lead_id` for this handoff.

---

## Expiry Reminder

Cron: `proposal-expiry-reminder` — daily 08:00 UTC. Notifies staff 3 days before expiry:

```sql
SELECT id, tenant_id, title, customer_id, lead_id, expires_at
FROM proposals
WHERE expires_at BETWEEN now() AND now() + interval '3 days'
  AND status IN ('SENT', 'VIEWED')
  AND id NOT IN (
    SELECT entity_id FROM notifications
    WHERE type = 'proposal_expiring' AND created_at > now() - interval '3 days'
  )
```

In-app notification: "Proposal "{title}" expires in {N} days — follow up now."

---

## Public Proposal View: Countdown

When `expires_at` is set and proposal is SENT/VIEWED, public view shows countdown:

```
┌──────────────────────────────────────────────────────────────┐
│  Service Proposal — Acme Corp                                │
│                                                              │
│  ⏳ This proposal expires in 4 days (2026-06-30)             │
│                                                              │
│  ...proposal content...                                      │
│                                                              │
│  [Accept proposal]    [Decline]                              │
└──────────────────────────────────────────────────────────────┘
```

Countdown variants:
- `> 7 days`: no countdown shown
- `3–7 days`: amber badge "Expires in N days"
- `< 3 days`: red badge "Expires in N days"
- `= today`: red banner "Expires today"
- Expired: "This proposal expired on {date}" (per spec 53 — CTAs hidden)

---

## Proposal List: Expiry Column

`/proposals` list gains expiry column and filter:

```
│ Title                │ Recipient  │ Status  │ Expires      │
│ Q2 Proposal — Acme   │ Acme Corp  │ VIEWED  │ ⏳ 4 days    │
│ Website Plan — Beta  │ Beta Ltd   │ SENT    │ 2026-07-15   │
│ Brand Package        │ Gama Inc   │ DRAFT   │ —            │
│ Old proposal         │ X Corp     │ EXPIRED │ ✗ 2026-05-01 │
```

Filter chip: "Expiring soon (7 days)" — shows SENT/VIEWED with `expires_at` within 7 days.

### Extend / reactivate an EXPIRED proposal

An EXPIRED proposal is not a dead end. Its row (and detail) offers **[Extend expiry]**: set a new `expires_at` (defaults to today + `proposal_default_valid_days`), status → `SENT`, and optionally re-send the email. This preserves the existing `public_token`, view history, and `accepted_by_name`/recipient — unlike **Duplicate** (spec 156), which mints a fresh DRAFT and a new token. The same action is available from the EXPIRED public-view dead state for staff (not the recipient). Reactivation is logged to the proposal activity feed.

---

## API

```
PATCH /api/proposals/:id
      → update expires_at (among other fields)
        body: { expires_at?: string | null }
        Requires: proposals:write

POST /api/proposals/:id/extend
      → reactivate an EXPIRED proposal: set new expires_at, status→SENT, optional re-send
        body: { expires_at?: string, resend?: boolean }
        Requires: proposals:write

GET /api/settings/proposals
    → get proposal defaults including proposal_default_valid_days
      Requires: OWNER, ADMIN

PUT /api/settings/proposals
    → update proposal defaults
      body: { proposal_default_valid_days?: number | null }
      Requires: OWNER
```

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| `expires_at` already in spec 23 | Not new column | Schema is already correct; this spec adds the behaviour |
| Auto-expiry via cron | Not real-time check | Real-time expiry check on every proposal GET would require querying per-request; cron is simpler and daily expiry granularity is acceptable |
| 3-day reminder | Not configurable | Fixed window; staff don't need configurable reminder cadence for proposals |
| Countdown hidden for >7 days | Not always visible | Urgency display is counter-productive when deadline is far away; only show when actionable |
