# Proposal PDF Export — Implementation Plan

**Spec:** docs/specs/2026-06-01-proposal-pdf-export.md  ·  **Slug:** proposal-pdf-export  ·  **Wave:** 14
**Depends on:** foundation-auth-rbac, marketing-catalogs-campaigns, proposal-editor, proposals-list

## Goal
Add a staff-triggered "Export PDF" action to proposals that renders the proposal's `content` JSONB into a branded PDF server-side and streams it to the client. PDFs are generated on demand (never stored in R2) so they always reflect the proposal's current content, mirroring the HTML-to-PDF worker pattern already used by `contracts-esignature` and `invoices-core`. The public proposal preview page additionally gets a client-side `window.print()` button driven by print CSS (no server endpoint). This spec adds NO new database columns or tables.

## Architecture
- New API route `GET /api/proposals/:id/pdf` lives in the marketing/proposals route group of `apps/zync-api` (Hono), alongside the existing `/api/proposals/*` routes defined by `marketing-catalogs-campaigns` and refined by `proposal-editor`/`proposals-list`.
- The route reads the `proposals` row (table defined by `marketing-catalogs-campaigns`, `total_amount` column added by `proposal-editor`) scoped by `tenant_id` from the session, joins the `customers` row for the recipient name, and reads the `tenants` row for logo/contact footer.
- A pure server-side renderer (`renderProposalHtml`) walks `proposals.content.sections[]` (shape defined by `proposal-editor`: `text | line_items | image | divider | testimonials | team`) plus `content.settings`, computes pricing totals + VAT via the upstream `getVatRate` export, and produces a self-contained HTML string with inline print-grade CSS.
- The HTML is POSTed to the internal Worker `https://api.html-to-pdf.zync.is` (same service `contracts-esignature` spec calls). The returned PDF bytes are streamed straight back to the client with `Content-Disposition: attachment` — no R2 object is created.
- UI triggers are wired into three existing surfaces: proposal editor toolbar (spec `proposal-editor`), proposals-list row kebab (spec `proposals-list`), and the public `/p/{token}` Astro preview on `zync-www` (client-side print only).
- Tenant scoping uses `tenantQuery` (locked export); route auth uses `authMiddleware` + `requirePermission('marketing:read')` (locked exports).

## Tech Stack
- **apps/zync-api** (Hono on Cloudflare Workers): new route handler + renderer module.
- **packages/types**: shared `ProposalPdfFilename` helper type re-uses `ProposalContent`/`ProposalSection`/`LineItem` types already exported by `proposal-editor`'s package; this plan only adds a renderer-local total type.
- **apps/zync-app** (Vite + React): export-PDF buttons in editor toolbar and list kebab; a `useProposalPdfDownload` hook that fetches the endpoint as a blob and triggers a browser download.
- **apps/zync-www / zync-www** (Astro): print button + `@media print` CSS block in the public proposal preview template.
- Cloudflare bindings: outbound `fetch` to `https://api.html-to-pdf.zync.is` (internal service binding or plain fetch, same as contracts). Uses existing `DB`/Hyperdrive binding via `createDb`. No new bindings, KV, R2, or queues.
- Libraries: no new heavy deps — HTML is assembled with template strings; PDF rendering is delegated to the external worker. Money formatting via `Intl.NumberFormat`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A | 1, 2 | `apps/zync-api/src/proposals/pdf-render.ts`, `apps/zync-api/src/proposals/pdf-totals.ts` | Yes (pure modules, no deps between them beyond shared types) |
| B | 3 | `apps/zync-api/src/proposals/routes.ts` (or proposals route file) | After A |
| C | 4, 5 | `apps/zync-app` editor toolbar + list kebab, `useProposalPdfDownload` hook | After B (needs live endpoint); 4 and 5 parallel |
| D | 6 | `zync-www` public proposal preview template + print CSS | Parallel with C (no server dep) |

## Tasks

### Task 1: Pricing + VAT totals computation
**Blocks:** 2, 3  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-api/src/proposals/pdf-totals.ts`
**Steps:**
- [ ] Implement `computeProposalTotals(content, vatRate)` that scans `content.sections[]` for every `line_items` section and aggregates all `items[]`.
- [ ] For each `LineItem`, compute `lineSubtotal = quantity * unit_price` (numbers as defined by `proposal-editor`).
- [ ] Apply `content.settings.discount_pct` (0–100) to the summed subtotal to get the discounted `subtotalExclVat`.
- [ ] Compute VAT: if a line's `tax_pct` is provided use it per-line; otherwise fall back to the tenant `vatRate` passed in. Sum per-line VAT into `vatTotal`. (Spec note: `tax_pct` is `0 | 17 | other`; tenant rate from `getVatRate` covers lines that omit it.)
- [ ] Compute `grandTotal = subtotalExclVat + vatTotal`.
- [ ] Round all money outputs to 2 decimals using a single `round2` helper to avoid float drift (Architecture Decision: VAT computation server-side, consistent with proposals-list `total_value`).
- [ ] Return a typed object; export the type.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/proposals/pdf-totals.ts
import type { ProposalContent, LineItem } from '@zync/types'; // shapes from proposal-editor

export interface ProposalTotals {
  currency: string;          // content.settings.currency (ISO 4217)
  lines: Array<{
    description: string;
    quantity: number;
    unitPrice: number;
    lineSubtotal: number;    // quantity * unit_price, rounded
  }>;
  subtotalExclVat: number;   // after discount_pct
  discountPct: number;       // content.settings.discount_pct
  vatTotal: number;
  grandTotal: number;
  showLineTax: boolean;      // content.settings.show_line_tax
  showSubtotal: boolean;     // content.settings.show_subtotal
}

export function computeProposalTotals(
  content: ProposalContent,
  tenantVatRate: number,     // decimal fraction, e.g. 0.18 from getVatRate
): ProposalTotals;
```
**Acceptance:**
- [ ] Given two `line_items` sections, totals aggregate across both.
- [ ] `discount_pct = 10` reduces `subtotalExclVat` by exactly 10%.
- [ ] A line with `tax_pct = 0` contributes 0 VAT regardless of tenant rate.
- [ ] All returned money values have at most 2 decimal places.

### Task 2: Proposal HTML render template
**Blocks:** 3  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/proposals/pdf-render.ts`
**Steps:**
- [ ] Implement `renderProposalHtml(input)` returning a complete, self-contained HTML document string (with `<style>` inline) matching the spec layout: tenant logo, "PROPOSAL" heading, `proposal.name` as title, "Prepared for: {customer.name}", formatted `created_at` and `expires_at`, a horizontal rule, then sections, then a contact footer line `{tenant.name} · {tenant.contact_email} · {tenant.phone}`.
- [ ] Render `content.sections[]` in array order. Map section types (authoritative shapes from `proposal-editor`):
  - `text` → body block; inject `html` ONLY after sanitizing against the allowed node allow-list (see Task 2 security step).
  - `line_items` → pricing table using `ProposalTotals` from Task 1: columns Item / Qty / Unit price / Total; then `Total (excl. VAT)`, `VAT (Npc)`, `Grand total` rows. Honor `showSubtotal`/`showLineTax` flags.
  - `image` → `<img src alt>` with `align` mapped to CSS `text-align`.
  - `divider` → `<hr>` plus optional `label`.
  - `testimonials` → list of `{ quote, author, company }`.
  - `team` → list of `{ user_id, role_label }` (render `role_label`; resolve member display names if a name map is passed, else fall back to role label only).
- [ ] Money formatting via `Intl.NumberFormat(locale, { style: 'currency', currency })` using `totals.currency`.
- [ ] Date formatting: ISO date (YYYY-MM-DD) for `created_at`/`expires_at`; show "—" when `expires_at` is null.
- [ ] **Security (CSP/XSS):** HTML-escape every text field (`proposal.name`, customer name, descriptions, testimonial fields, tenant footer fields). For `text` section `html`, run it through a node-type allow-list sanitizer matching `proposal-editor`'s `ALLOWED_NODE_TYPES` (headings, lists, bold/italic, paragraph, link) and strip `<script>`, event handlers, and inline `javascript:` URLs before embedding. Never embed raw untrusted HTML.
- [ ] **A11y/print:** include `<html lang>` and set `dir` from the proposal/tenant locale so Hebrew (RTL) proposals render right-aligned; use semantic `<table>` with `<th scope="col">` for the pricing table.
- [ ] Logo: render `<img>` only when a tenant logo URL is present; otherwise render the tenant name as text fallback.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/proposals/pdf-render.ts
import type { ProposalContent } from '@zync/types';
import type { ProposalTotals } from './pdf-totals';

export interface RenderProposalInput {
  proposalName: string;        // proposals.name
  customerName: string;        // customers.name (or accepted_by_name / customer_email fallback)
  createdAt: string;           // ISO from proposals.created_at
  expiresAt: string | null;    // proposals.expires_at
  content: ProposalContent;    // proposals.content JSONB
  totals: ProposalTotals;      // from computeProposalTotals()
  tenant: {
    name: string;              // tenants.name
    contactEmail: string | null;
    phone: string | null;
    logoUrl: string | null;
    locale: string;            // for Intl + dir (e.g. 'he' => rtl)
  };
}

export function renderProposalHtml(input: RenderProposalInput): string;
```
**Acceptance:**
- [ ] Output is a single valid HTML document with inline `<style>` (no external assets except optional logo `<img>`).
- [ ] A `<script>` injected inside a `text` section's `html` is stripped from the output.
- [ ] Sections appear in `content.sections[]` order.
- [ ] When `tenant.locale` is `he`, the root element carries `dir="rtl"`.
- [ ] Pricing table renders Total (excl. VAT), VAT, and Grand total rows.

### Task 3: PDF export API route
**Blocks:** 4, 5  ·  **Blocked by:** 1, 2
**Files:**
- Modify: `apps/zync-api/src/proposals/routes.ts` (the existing proposals route module from marketing-catalogs-campaigns / proposal-editor)
**Steps:**
- [ ] Register `GET /api/proposals/:id/pdf` behind `authMiddleware` and `requirePermission('marketing:read')` (locked exports).
- [ ] Load the proposal with `tenantQuery` scoped to the session `tenant_id`. If no row, return `404` (must not leak cross-tenant existence).
- [ ] Resolve recipient display name: prefer the joined `customers.name` (via `proposals.customer_id`); fall back to `proposals.accepted_by_name`, then `proposals.customer_email`, then "—".
- [ ] Load tenant footer fields from the `tenants` row (name, contact email, phone, logo url, locale/`default_currency`).
- [ ] Resolve the tenant VAT rate via `getVatRate` (locked export) for the proposal date; pass to `computeProposalTotals`.
- [ ] Call `computeProposalTotals(proposal.content, vatRate)`, then `renderProposalHtml` with the assembled `RenderProposalInput`.
- [ ] POST the HTML to `https://api.html-to-pdf.zync.is` (same internal Worker `contracts-esignature` uses); expect PDF bytes back. On non-200 from the worker, return `502` with a generic error (do not echo upstream body).
- [ ] Stream the PDF bytes back with headers: `Content-Type: application/pdf`, `Content-Disposition: attachment; filename="proposal-{safeName}.pdf"` where `safeName` is `proposals.name` sanitized to filename-safe ASCII (RFC 5987 `filename*` for non-ASCII/Hebrew names).
- [ ] Do NOT write to R2 and do NOT cache (Architecture Decision: on-demand, no storage). Set `Cache-Control: no-store`.
- [ ] Zod-validate the `:id` path param as a UUID (require-zod-validation-in-routes); 400 on malformed id.
**Schema / Interfaces:**
```
GET /api/proposals/:id/pdf
  Auth: authMiddleware + requirePermission('marketing:read')
  Path: id UUID (zod-validated)
  200: application/pdf
       Content-Disposition: attachment; filename="proposal-{name}.pdf"
       Cache-Control: no-store
  404: proposal not found OR belongs to another tenant
  400: malformed id
  502: html-to-pdf worker failure
```
*Schema Delta: NONE. Reads existing `proposals` (incl. `content JSONB`, `total_amount`, `name`, `customer_id`, `customer_email`, `accepted_by_name`, `expires_at`, `created_at`), `customers`, `tenants`, and `vat_rates` (via `getVatRate`). No new columns or tables.*
**Acceptance:**
- [ ] Requesting a proposal id from another tenant returns 404.
- [ ] A request without `marketing:read` is rejected by `requirePermission`.
- [ ] Response is `application/pdf` with an `attachment` Content-Disposition derived from `proposals.name`.
- [ ] No R2 object is created and `Cache-Control: no-store` is set.

### Task 4: Editor toolbar "Export PDF" button + download hook
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-app/src/features/proposals/useProposalPdfDownload.ts`
- Modify: proposal editor toolbar component (`apps/zync-app/src/features/proposals/ProposalEditorToolbar.tsx` from `proposal-editor`)
**Steps:**
- [ ] Implement `useProposalPdfDownload(proposalId)` returning `{ download, isDownloading }`. `download()` calls `GET /api/proposals/:id/pdf` with credentials, reads the response as a blob, creates an object URL, triggers an `<a download>` click, then revokes the URL.
- [ ] On non-OK response, surface a `toast` (locked export) error and do not trigger a download.
- [ ] Add an `Export PDF ↓` `Button` (locked export) to the editor toolbar, placed between `Preview` and `Send proposal` per the spec layout, wired to `download`; show a `Spinner`/disabled state while `isDownloading`.
- [ ] **A11y:** button has an accessible name "Export PDF"; reflect `aria-busy` while downloading; respect `prefers-reduced-motion` for any spinner animation (reuse the design-system `Spinner`).
**Acceptance:**
- [ ] Clicking "Export PDF" downloads a file named `proposal-*.pdf`.
- [ ] A failed request shows an error toast and no file is saved.
- [ ] Button is keyboard-focusable with a visible focus ring and an accessible name.

### Task 5: Proposals-list kebab "Download PDF" item
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Modify: proposals-list row kebab menu component (`apps/zync-app/src/features/proposals/ProposalRowActions.tsx` from `proposals-list`)
**Steps:**
- [ ] Add a `Download PDF` item to the row kebab `DropdownMenu` (locked export), positioned between `Duplicate` and `Delete` per the spec list.
- [ ] Wire it to the same `useProposalPdfDownload(row.id)` hook from Task 4.
- [ ] Disable the item / show inline progress while that row's download is in flight; show an error `toast` on failure.
- [ ] **A11y:** menu item is reachable via keyboard, has role `menuitem`, and a clear label "Download PDF".
**Acceptance:**
- [ ] Kebab → "Download PDF" downloads the same PDF as the editor button for that proposal.
- [ ] Menu item order matches the spec (Edit, Copy link, Resend, Mark accepted, Duplicate, Download PDF, Delete).

### Task 6: Public preview "Print / Save as PDF" (client-side)
**Blocks:** —  ·  **Blocked by:** —
**Files:**
- Modify: public proposal preview template on `zync-www` (`apps/zync-www/src/pages/p/[token].astro` or its preview component from `marketing-catalogs-campaigns`)
**Steps:**
- [ ] Add a footer action-bar button `Print / Save as PDF` next to `Accept this proposal`, calling `window.print()` (no server endpoint — Architecture Decision: public page uses client-side print).
- [ ] Add an `@media print` CSS block to the preview template: hide the action bar, nav, and any interactive chrome; expand content to full width; ensure the pricing table and section text print cleanly with page-break-avoidance on table rows.
- [ ] **A11y/i18n:** button has an accessible label; localize the button text via the i18n layer; honor RTL direction for Hebrew proposals in print layout.
- [ ] **Reduced motion:** no animated transitions on the print button trigger.
- [ ] Do NOT call the authenticated `/api/proposals/:id/pdf` endpoint from the public page (no token handling on the public surface).
**Acceptance:**
- [ ] Clicking "Print / Save as PDF" opens the browser print dialog.
- [ ] In print preview, the action bar and site chrome are hidden and proposal content fills the page.
- [ ] The public page makes no authenticated API call for PDF generation.
