# Proposal PDF Export

**Date:** 2026-06-01
**Status:** Draft
**Spec:** 159
**Tier:** All tiers
**Depends on:** `proposal-editor`, `marketing-catalogs-campaigns`, `proposals-list`, `foundation-auth-rbac`
**Referenced by:** `proposal-editor`, `proposals-list`

---

## Overview

Spec 130 (`proposal-editor`) defines the proposal content as sections-based JSONB and notes "sections enable structured rendering, mobile responsiveness, and **PDF generation**." No spec defines that PDF generation. Spec 48 (`contracts-esignature`) generates PDFs at signing time via an internal Worker. Spec 15 (`invoices-core`) generates invoice PDFs via the same pattern.

This spec defines:
1. Staff-triggered "Export as PDF" action on a proposal
2. Server-side PDF rendering from `proposals.content` JSONB
3. Proposal PDF download endpoint

Unlike contracts (PDF generated at signing) and invoices (PDF generated at `TAX_ISSUED`), proposal PDFs are generated on-demand and not stored permanently — proposals are living documents until accepted, and regenerating on each download ensures the PDF reflects current content.

---

## Route

No new page. PDF export is an action on existing proposal surfaces:
- Proposal editor (spec 130): toolbar button "Export PDF"
- Proposals list (spec 156): row kebab menu → "Download PDF"
- Proposal detail view (`/proposals/:id`, spec 130 non-DRAFT state): action-bar button "Export PDF ↓"

---

## UI Trigger

### In Proposal Editor (spec 130)

Toolbar right side, alongside "Preview" and "Send":

```
[Preview]  [Export PDF ↓]  [Send proposal]
```

### In Proposals List (spec 156)

Row kebab menu:
```
Edit
Copy link
Resend
Mark accepted
Duplicate
Download PDF    ← new
Delete
```

### In Proposal Preview (public `/p/{token}`)

Footer action bar (after proposal content):

```
[Print / Save as PDF]   [Accept this proposal]
```

The public "Print / Save as PDF" button triggers `window.print()` with print CSS — no server-side endpoint needed for the public page (client-side print uses `@media print` styles defined in the proposal preview template).

---

## PDF Rendering

Uses the same HTML-to-PDF internal Worker pattern as spec 48 (contracts) and spec 15 (invoices):
- POST rendered HTML to internal `https://api.html-to-pdf.zync.is` Worker
- Worker returns PDF bytes
- Stream directly to client (no R2 storage — proposals are regenerated on demand)

### Proposal HTML Template

The PDF render HTML:

```
┌──────────────────────────────────────────────────────────────┐
│  [Tenant logo]                                               │
│                                                              │
│  PROPOSAL                                                    │
│  {proposal.title}                                            │
│  Prepared for: {customer.name}                               │
│  Date: {created_at formatted}       Expires: {expires_at}   │
│                                                              │
│  ─────────────────────────────────────────────────────────  │
│                                                              │
│  [Cover section text from content.sections[type='cover']]    │
│                                                              │
│  [Scope section]                                             │
│  [Deliverables section]                                      │
│  [Timeline section]                                          │
│                                                              │
│  ─── Pricing ──────────────────────────────────────────────  │
│  Item                           Qty    Unit price    Total   │
│  Website redesign                 1    ₪20,000    ₪20,000    │
│  SEO audit                        1     ₪4,000     ₪4,000    │
│  ─────────────────────────────────────────────────────────   │
│  Total (excl. VAT):                              ₪24,000     │
│  VAT (18%):                                       ₪4,320     │
│  Grand total:                                    ₪28,320     │
│                                                              │
│  [Terms section]                                             │
│                                                              │
│  ─────────────────────────────────────────────────────────  │
│  {tenant.name} · {tenant.contact_email} · {tenant.phone}    │
└──────────────────────────────────────────────────────────────┘
```

Sections are rendered in the order they appear in `proposals.content.sections[]`. Section types:
- `cover` — rendered as a full-width intro block
- `scope` / `deliverables` / `timeline` — rendered as body content
- `line_items` — rendered as pricing table with totals
- `terms` — rendered as footer before contact info
- `custom` — rendered as body content

---

## API

```
GET /api/proposals/:id/pdf
    → generate and stream proposal PDF
      Response: application/pdf with Content-Disposition: attachment; filename="proposal-{proposal.title}.pdf"
      Requires: marketing:read
      Notes:
        - 404 if proposal not found or belongs to different tenant
        - PDF generated on demand (no caching/storage)
        - title, customer_name, expires_at pulled from proposals row
        - content (sections + line_items) from proposals.content JSONB
        - line_items total computed from `content.sections[type='line_items']` items (on-demand; `total_amount` denorm may lag last edit), VAT rate from tenant tax table
```

---

## Schema Delta

None. Proposal content is already in `proposals.content JSONB` (spec 23). No new columns.

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| On-demand generation, no R2 storage | Not cached PDF in R2 | Proposals can be edited after sending (DRAFT/SENT); cached PDF would be stale. Contracts and invoices are immutable at signing/issuing — proposals are not |
| Stream directly to client | Not presigned R2 URL | On-demand generation means no R2 object exists to sign; stream the PDF bytes through the Worker response |
| Public page uses `window.print()` | Not server-side PDF | Public proposal page runs on zync-www (Astro); calling authenticated API from a public page requires token handling; client-side print achieves the same result for proposal recipients |
| VAT computation server-side | Not client-side | Consistent with `total_value` computation in proposals list (spec 156); avoids floating point drift between list view and PDF |
