# Contracts & E-Signature

**Date:** 2026-05-31  
**Status:** Draft  
**Depends on:** `foundation-auth-rbac`, `customers-module`, `invoices-core`, `marketing-leads-pipeline`, `kb-module`  
**Referenced by:** `marketing-leads-pipeline` (lead → contract), `invoices-core` (contract → invoice), `tenant-portals`

---

## Overview

Enables tenants to create contracts, send them to customers for electronic signature, and collect legally valid signatures without third-party services. Sits in the workflow between winning a lead and issuing an invoice.

### Mobile route behavior

At phone widths, the contracts list uses 16px inline padding and its header actions wrap without horizontal overflow.

Rationale: preserve contract creation and status review within the mobile app frame.

**V1 scope:** Self-hosted simple e-signature (typed/drawn name + timestamp + IP). No DocuSign/HelloSign — eliminates per-document SaaS cost and per-tenant integration complexity.

**Legal basis:** Israeli Electronic Signature Law 5761-2001. A typed or drawn signature combined with a timestamp, IP address, and audit trail constitutes a "regular electronic signature" (חתימה אלקטרונית רגילה), legally binding for standard commercial contracts. It does not qualify as a "secure electronic signature" (חתימה אלקטרונית מאובטחת), which requires PKI infrastructure — acceptable for invoices, service agreements, and NDAs but not for property transfers or court filings.

---

## Data Model

```sql
-- Contract templates (reusable, per tenant)
CREATE TABLE contract_templates (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name        TEXT NOT NULL,
  content     JSONB NOT NULL,             -- Tiptap JSON document
  variables   JSONB NOT NULL DEFAULT '[]', -- array of { key, label, type, required }
  created_by  UUID NOT NULL REFERENCES users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  deleted_at  TIMESTAMPTZ                -- soft delete
);

CREATE INDEX idx_contract_templates_tenant ON contract_templates(tenant_id)
  WHERE deleted_at IS NULL;

-- Contracts (instances, per tenant)
CREATE TABLE contracts (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id    UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  customer_id  UUID REFERENCES customers(id) ON DELETE SET NULL,
  template_id  UUID REFERENCES contract_templates(id) ON DELETE SET NULL,
  title        TEXT NOT NULL,
  content      JSONB NOT NULL,            -- resolved Tiptap JSON (variables substituted)
  status       TEXT NOT NULL DEFAULT 'DRAFT'
                 CHECK (status IN ('DRAFT', 'SENT', 'VIEWED', 'SIGNED', 'VOIDED')),
  signed_pdf_r2_key TEXT,                 -- R2 object key of final signed PDF
  created_by   UUID NOT NULL REFERENCES users(id),
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  sent_at      TIMESTAMPTZ,
  signed_at    TIMESTAMPTZ,               -- last signatory signed_at (all complete)
  voided_at    TIMESTAMPTZ,
  voided_by    UUID REFERENCES users(id),
  void_reason  TEXT
);

CREATE INDEX idx_contracts_tenant_status ON contracts(tenant_id, status);
CREATE INDEX idx_contracts_customer ON contracts(customer_id);

-- Signatories (up to 3 per contract)
CREATE TABLE contract_signatories (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  contract_id     UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
  name            TEXT NOT NULL,
  email           TEXT NOT NULL,
  "order"         INTEGER NOT NULL DEFAULT 1,  -- signing order (1 = first)
  token           TEXT NOT NULL UNIQUE,        -- UUID v4, plaintext (not hashed — used in URL)
  token_expires_at TIMESTAMPTZ NOT NULL,       -- 30 days from sent_at
  viewed_at       TIMESTAMPTZ,
  signed_at       TIMESTAMPTZ,
  signature_data  TEXT,                        -- base64 PNG of signature (drawn or typed-rendered)
  signature_type  TEXT CHECK (signature_type IN ('drawn', 'typed')),
  ip_address      TEXT,
  user_agent      TEXT,
  declined_at     TIMESTAMPTZ,
  decline_reason  TEXT,
  UNIQUE (contract_id, email)
);

CREATE INDEX idx_signatories_token ON contract_signatories(token);
CREATE INDEX idx_signatories_contract ON contract_signatories(contract_id);

-- Audit log for contracts (separate from global audit_log for granularity)
CREATE TABLE contract_audit_log (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
  event       TEXT NOT NULL,  -- 'created', 'sent', 'viewed', 'signed', 'voided', 'downloaded'
  actor_type  TEXT NOT NULL CHECK (actor_type IN ('user', 'signatory', 'system')),
  actor_id    TEXT,           -- user_id (UUID) or signatory email
  actor_name  TEXT,
  ip_address  TEXT,
  user_agent  TEXT,
  metadata    JSONB NOT NULL DEFAULT '{}',
  timestamp   TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_contract_audit_contract ON contract_audit_log(contract_id);
```

### Variables system

Template variables use `{{key}}` syntax in Tiptap content. The `variables` JSONB column on `contract_templates` declares metadata:

```json
[
  { "key": "customer_name", "label": "Customer Name", "type": "text", "required": true },
  { "key": "project_name", "label": "Project Name", "type": "text", "required": true },
  { "key": "amount", "label": "Total Amount", "type": "currency", "required": true },
  { "key": "date", "label": "Contract Date", "type": "date", "required": true },
  { "key": "payment_terms", "label": "Payment Terms", "type": "text", "required": false }
]
```

Built-in auto-fill variables (populated from customer/project context):
- `{{customer_name}}`, `{{customer_email}}`, `{{customer_company}}`
- `{{tenant_name}}`, `{{tenant_email}}`
- `{{date}}` (today's date, IL format DD/MM/YYYY)

### Editor Accessibility and RTL

**Accessibility:**
- Editor div: `role="textbox"` `aria-multiline="true"` `aria-label="Contract body editor"`
- Toolbar: `role="toolbar"` `aria-label="Text formatting"`
- Toolbar buttons: `aria-pressed` for toggle states (Bold, Italic, Underline, etc.)
- Keyboard: full formatting via `⌘B`/`⌘I`/`⌘U`; shortcuts must not be overridden
- Focus: Tab enters editor; Escape exits to last focused element outside editor
- Variable insertion picker: `role="listbox"`, Arrow keys navigate, Enter selects, Escape closes
- Variable highlight spans: `aria-label="Variable: {key}"` on each `{{variable}}` node

**RTL Configuration:**

```ts
import { Direction } from '@tiptap/extension-text-direction'

const extensions = [
  // ...other extensions
  Direction.configure({
    defaultDirection: locale === 'he-IL' ? 'rtl' : 'ltr',
    // Per-paragraph direction override via toolbar toggle (↔ icon)
  }),
]
// Direction persists as dir attribute on paragraph nodes in stored JSONB
```

---

## Contract Lifecycle

```
DRAFT → [send] → SENT → [signatory views] → VIEWED → [all sign] → SIGNED
      ↘                                                           ↗
        ────────────────── [void] ────────────────────► VOIDED
```

State rules:
- `DRAFT`: editable, not yet sent. **Deletable** — the only state with a hard-delete path (`DELETE /api/contracts/:id`, requires `contracts:delete`). Nothing has been dispatched, so the row can be removed outright rather than VOIDed. Any status ≥ `SENT` can only exit via `[void]` (a record is retained for audit).
- `SENT`: frozen content; email dispatched to all signatories
- `VIEWED`: at least one signatory opened the signing link
- `SIGNED`: all signatories have signed (or skipped, if marked optional — future)
- `VOIDED`: cancelled; existing signature links invalidated; reason stored

Transitions are validated server-side. Frontend only shows valid next actions.

---

## Features

### Contract Templates

- Rich text editor (Tiptap, same component as KB module — reuse `packages/ui/src/components/rich-editor`)
- Variable insertion: toolbar button → picker shows declared variables → inserts `{{key}}` placeholder
- Template preview: renders with sample values
- Templates list at `/contracts/templates`
- System templates: Zync ships 3 starter templates (Service Agreement IL, NDA IL, Fixed-Price Project IL) — seeded per tenant on creation

### Contract Creation

Entry: `/contracts/new`

1. Choose: "From Template" (select template → variable fill form) or "Blank" (editor)
2. Fill variables (pre-populated from customer context if entering from lead/customer page)
3. Add signatories: name + email, up to 3; drag to reorder (sets signing order)
4. Preview rendered contract
5. Save as DRAFT or Send immediately

"From Lead" entry (marketing module):
- Customer name/email/company pre-filled from lead
- Redirects to `/contracts/new?lead_id={id}`

### Sending

`POST /api/contracts/:id/send` transitions status to `SENT` and dispatches signature request emails.

Email to each signatory:
- Subject: `[{tenant_name}] Please sign: {contract_title}`
- Body: contract summary, due date (30 days), "Review & Sign" button linking to `/sign/{token}`
- Sent via communications adapter (Resend)

### Signature Capture Page (`/sign/:token`)

Public URL. No account required. The token uniquely identifies a specific signatory on a specific contract.

**Page layout:**
```
┌─────────────────────────────────────────────────┐
│  {tenant_name} logo                              │
│                                                  │
│  [{contract_title}]                              │
│  Requested by {tenant_name} · Expires {date}    │
│                                                  │
│  ┌─────────────────────────────────────────────┐│
│  │  [Contract HTML content — scrollable]       ││
│  └─────────────────────────────────────────────┘│
│                                                  │
│  Your Signature                                  │
│  ┌──────────┐  ┌──────────┐                     │
│  │  Draw    │  │  Type    │  (tabs)              │
│  └──────────┘  └──────────┘                     │
│                                                  │
│  [Signature canvas — 400×150px]                 │
│  [Clear]                                         │
│                                                  │
│  Full name: [____________________]               │
│  Email: [____________________]                   │
│  ☐ I have read and agree to the above contract  │
│                                                  │
│  [Sign Document]                                 │
│  [Decline]                                       │
└─────────────────────────────────────────────────┘
```

**Draw tab:** HTML5 Canvas with touch support. Uses `signature_pad` library (MIT, small, no server dependency).

**Type tab:** User types name → rendered in a cursive web font (e.g. Dancing Script from Google Fonts) → captured as canvas image on submit.

**On submit:**
1. Client validates: agreement checkbox ticked, name field filled, signature drawn/typed
2. POST `/api/sign/{token}` with `{ signature_data: "data:image/png;base64,...", signature_type: "drawn"|"typed", name, email }`
3. Worker: validate token, record signature, update `contract_signatories.signed_at`
4. If all signatories signed → trigger completion flow (see below)
5. Client shows: "Thank you! Your signature has been recorded." + confirmation with timestamp

**Decline flow:** Signatory can click "Decline" → modal to enter reason → POST `/api/sign/{token}/decline` → tenant notified by email.

### Completion Flow (all signatories signed)

Triggered in same Worker transaction as final signature:

1. `contracts.status` → `SIGNED`, `contracts.signed_at` = now()
2. Enqueue PDF generation job (or generate synchronously if small contract)
3. PDF generation: render contract HTML + signature blocks → print CSS → Puppeteer-compatible → but Workers can't run Puppeteer

**PDF generation approach for Cloudflare Workers:**
- Workers cannot run headless browsers
- Same constraint as `invoices-core` spec — use HTML-to-PDF service or pre-generate
- **Implementation:** POST to `https://api.html-to-pdf.zync.is` (internal Cloudflare Worker using `@cloudflare/puppeteer` via Workers AI browser rendering — or external service like `pdfshift.io`)
- Contract content + signature blocks rendered to PDF
- PDF stored in R2: key `contracts/{tenant_id}/{contract_id}/signed.pdf`
- `contracts.signed_pdf_r2_key` updated

4. Signed PDF emailed to all signatories + tenant OWNER (via Resend)
5. Audit log entry: `event = 'signed'`, metadata includes all signatory IP addresses + timestamps
6. If contract was linked to a lead: fire `contract.signed` event (updates lead — future hook)

### Signing Order

When `order` differs across signatories, only the lowest-order unsigned signatory receives an active link. Higher-order signatories receive links but see "Waiting for {name} to sign first" if accessed before their turn. After each signature, next signatory is notified by email.

---

## Routes

| Path | Auth | Description |
|------|------|-------------|
| `/contracts` | Tenant session | List all contracts |
| `/contracts/new` | Tenant session | Create from template or blank |
| `/contracts/templates` | Tenant session | Manage templates |
| `/contracts/templates/new` | Tenant session | Create template |
| `/contracts/templates/:id/edit` | Tenant session | Edit template |
| `/contracts/:id` | Tenant session | Contract detail: status, signatories, timeline |
| `/contracts/:id/edit` | Tenant session | Edit DRAFT contract |
| `/sign/:token` | **Public (no auth)** | Signature capture page |

---

## API Endpoints

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/contracts` | `contracts:read` | List contracts (paginated, filterable by status) |
| POST | `/api/contracts` | `contracts:write` | Create contract |
| GET | `/api/contracts/:id` | `contracts:read` | Get contract detail |
| PATCH | `/api/contracts/:id` | `contracts:write` | Update DRAFT contract |
| DELETE | `/api/contracts/:id` | `contracts:delete` | Delete a DRAFT contract — rejected (HTTP 422) for any status other than `DRAFT`; sent/signed contracts must be VOIDed instead |
| POST | `/api/contracts/:id/send` | `contracts:write` | Send for signatures |
| POST | `/api/contracts/:id/void` | `contracts:write` | Void contract |
| POST | `/api/contracts/:id/resend/:signatoryId` | `contracts:write` | Resend reminder to signatory |
| GET | `/api/contracts/:id/pdf` | `contracts:read` | Download signed PDF (signed URL from R2) |
| GET | `/api/contract-templates` | `contracts:read` | List templates |
| POST | `/api/contract-templates` | `contracts:write` | Create template |
| GET | `/api/contract-templates/:id` | `contracts:read` | Get template |
| PATCH | `/api/contract-templates/:id` | `contracts:write` | Update template |
| DELETE | `/api/contract-templates/:id` | `contracts:write` | Delete template |
| GET | `/api/sign/:token` | **Public** | Get contract content + signatory info for signing page |
| POST | `/api/sign/:token` | **Public** | Submit signature |
| POST | `/api/sign/:token/decline` | **Public** | Decline to sign |

### Public endpoint security

`/api/sign/:token` is unauthenticated:
- Token is UUID v4 (128 bits of entropy) — brute force infeasible
- Rate limited: 10 requests/min per IP via `RATE_LIMITER_AUTH` (existing binding)
- Token expiry enforced: 30 days from `sent_at` (stored as `token_expires_at`)
- After expiry: returns 410 Gone; tenant must re-send

---

## Screens

### `/contracts` — Contract List

DataTable columns:
- Title
- Customer
- Status (badge: DRAFT/SENT/VIEWED/SIGNED/VOIDED)
- Signatories (X of Y signed)
- Created date / Sent date
- Actions: View, Resend, Void, Download PDF

Filters: status, date range, customer

### `/contracts/:id` — Contract Detail

Left panel: contract HTML preview (read-only after SENT)

Right panel:
- Status timeline (created → sent → viewed → signed)
- Signatories list: each shows name, email, status, signed_at / "Waiting" / "Declined"
- Resend individual reminder button
- Audit log tab: chronological event list with timestamps + IPs

Action bar:
- DRAFT: Edit, Send, Delete
- SENT/VIEWED: Void, Resend All, View Contract
- SIGNED: Download PDF, Create Invoice from Contract
- VOIDED: View Contract (read-only)

### `/contracts/new` — Create Contract

Step 1: Source
- "From Template" → template picker (cards with name + preview)
- "Blank" → jump to editor

Step 2: Content
- If template: variable fill form (auto-populated from URL params if `?customer_id=...` or `?lead_id=...`)
- Editor with variables highlighted (yellow background in Tiptap)

Step 3: Signatories
- Add up to 3 signatories: name + email
- Drag handles to reorder (sets signing order)
- Option to add self as signatory (useful for NDA — both parties sign)

Step 4: Preview
- Full rendered HTML preview
- "Edit" back link
- "Send Now" or "Save as Draft"

---

## Integration Hooks

### Marketing: Lead → Contract

On lead detail page (status = Won):
- **"Create Contract"** button → opens contract creation flow
- URL: `/contracts/new?lead_id={id}&customer_id={customerId}`
- Auto-fills: customer name, email, company, date
- After contract created: lead activity logged: "Contract created"
- After contract signed: lead activity logged: "Contract signed"; `leads.stage` can auto-advance (configurable)

### Invoices: Contract → Invoice

On signed contract detail page:
- **"Create Invoice from Contract"** button
- URL: `/invoices/new?contract_id={id}`
- Pre-fills: customer, line item description (contract title), amount (from `{{amount}}` variable if present)
- Invoice linked to contract: `invoices.contract_id UUID REFERENCES contracts(id)` (schema delta)

---

## Permissions

New permission keys (added to `permissions` seed data):

| Key | Description |
|-----|-------------|
| `contracts:read` | View contracts and templates |
| `contracts:write` | Create, edit, send, void contracts |
| `contracts:delete` | Delete draft contracts |

Default role assignments:

| Role | Permissions |
|------|-------------|
| OWNER | `contracts:read`, `contracts:write`, `contracts:delete` |
| ADMIN | `contracts:read`, `contracts:write`, `contracts:delete` |
| MEMBER | `contracts:read`, `contracts:write` |
| VIEWER | `contracts:read` |
| CONTRACTOR | _(none)_ |

---

## Content Security

Contract content (JSONB in `contracts.content` and `contract_templates.content`) is created by staff but rendered in an unauthenticated public context (signing page). Follow the canonical Tiptap security pattern from `kb-article-editor` (spec 101):

**Server-side (before storage):** reject any node type not in `ALLOWED_NODE_TYPES` (same set as spec 101). Return 422 if unknown node found.

**Client-side (before render):** contract HTML is generated and displayed in two places — the signing page (`ContractSigningIsland`) and the staff preview. Both must pipe through DOMPurify before `dangerouslySetInnerHTML`:

```ts
import { generateHTML } from '@tiptap/html'
import DOMPurify from 'dompurify'
import { extensions } from '@zync/ui/rich-editor'

function renderContractHTML(content: JSONValue): string {
  const html = generateHTML(content, extensions)
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p','br','strong','em','u','s','h1','h2','h3',
                   'ul','ol','li','blockquote','code','pre','a','img',
                   'table','thead','tbody','tr','th','td'],
    ALLOWED_ATTR: ['href','src','alt','target','rel','class'],
    FORBID_ATTR: ['onerror','onload','onclick'],
  })
}
```

`img src` must be restricted to the tenant's R2 domain (same DOMPurify hook as spec 101).

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Self-hosted signature vs DocuSign/HelloSign | Self-hosted | Eliminates per-document cost; no per-tenant API key management; IL law supports typed/drawn signature |
| Signature capture | Canvas (drawn) + typed-name-rendered | Universal device support; mobile touch works; no plugin required |
| Signature storage | base64 PNG in `signature_data` TEXT column | Small images (~10–20KB); direct embed in PDF; no separate R2 object per signature |
| Signing token | Plaintext UUID in URL | Token is a capability (like a signed URL); no account needed; 128-bit entropy sufficient |
| Token hashing | NOT hashed (unlike invitation tokens) | Token in URL IS the access credential; server needs plaintext for lookup; mitigated by entropy + expiry |
| PDF generation | External HTML-to-PDF service | CF Workers cannot run headless browser; same constraint as invoices; reuse same service |
| Signing order | Optional, max 3 | Covers most B2B scenarios (service provider signs, customer countersigns); more than 3 rare enough to not justify complexity |
| Variables | `{{key}}` in Tiptap content | Simple, grep-friendly; substituted server-side at creation time (content snapshot) |
| Content freeze on send | Yes — snapshot at send time | Contract must not change after signatures begin |
| IL legal compliance | Regular electronic signature (Law 5761-2001) | Sufficient for 99% of commercial contracts; secure e-signature would require PKI (out of scope for v1) |
| Multi-language signature page | Hebrew + English | Portal respects tenant locale; signing page respects browser locale with fallback to tenant locale |

---

## Foundation Delta Additions

### New permission keys

```sql
INSERT INTO permissions (id, key, description) VALUES
  (gen_random_uuid(), 'contracts:read',   'View contracts and templates'),
  (gen_random_uuid(), 'contracts:write',  'Create, edit, send, and void contracts'),
  (gen_random_uuid(), 'contracts:delete', 'Delete draft contracts');
```

### New tables

- `contract_templates`
- `contracts`
- `contract_signatories`
- `contract_audit_log`

### Schema deltas on existing tables

```sql
-- invoices: link back to originating contract
ALTER TABLE invoices ADD COLUMN contract_id UUID REFERENCES contracts(id) ON DELETE SET NULL;

-- leads: track contract lifecycle
ALTER TABLE leads ADD COLUMN contract_id UUID REFERENCES contracts(id) ON DELETE SET NULL;
```

### R2 key namespace

`contracts/{tenant_id}/{contract_id}/signed.pdf`

Uses existing `R2` binding. No new binding needed.

### Secrets

No new secrets. Uses existing:
- `RESEND_API_KEY` — signature request + confirmation emails
- `R2` binding — PDF storage

### Module dependency

Contracts module depends on Tiptap rich editor package (`packages/ui/src/components/rich-editor`). No new package needed — already pulled in by KB module.

### Cron

No cron needed for v1. Token expiry enforced at access time (not proactively). Future: add `contract-reminders` cron to resend nudge emails to unsigned signatories.
