# Lead Qualification Scoring

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 118  
**Tier:** Business+  
**Depends on:** `marketing-leads-pipeline`, `leads-detail-view`, `foundation-auth-rbac`  
**Referenced by:** `marketing-leads-pipeline`, `leads-detail-view`

---

## Overview

Automatic lead score (0–100) derived from BANT-inspired criteria and activity signals. Score displayed on lead cards and detail views. Used for filtering and prioritizing the pipeline. Tenant-configurable scoring weights.

---

## Scoring Model

Score = sum of criterion weights, capped at 100.

### Default Criteria

| Criterion | Max points | How scored |
|-----------|-----------|------------|
| Estimated value set | 10 | `estimated_value IS NOT NULL` → 10 |
| Estimated value range | 20 | ≥ tenant threshold (configurable) → 20, else proportional |
| Stage advancement | 15 | CONTACTED=5, QUALIFIED=10, PROPOSAL=15 |
| Recent activity | 20 | Activity in last 7 days → 20; last 30 days → 10; last 90 days → 5 |
| Form source (inbound) | 10 | `source = 'form'` or `source = 'zapier'` → 10 |
| Company name present | 5 | `company IS NOT NULL` → 5 |
| Phone number present | 5 | `phone IS NOT NULL` → 5 |
| Viewed proposal | 15 | `proposals.status = 'VIEWED'` linked to lead → 15 |

Weights stored in `tenant_settings.lead_scoring_criteria JSONB` (configurable).

---

## Score Display

### On Lead Card (Kanban)

```
┌──────────────────────────────────────────────────────────────┐
│  Acme Corp                                     [🔥 82]        │
│  Dana Cohen  ·  ₪24,000  ·  3d                               │
└──────────────────────────────────────────────────────────────┘
```

Score badge color:
- 70–100: green (hot lead)
- 40–69: amber (warm)
- 0–39: muted (cold)

Badge hidden if score = 0 and lead is newly created (no activity yet).

### On Lead Detail View

```
┌──────────────────────────────────────────────────────────────┐
│  Lead Score: 82/100                        ● Hot lead        │
│                                                              │
│  ████████████████████░░░░  82%                               │
│                                                              │
│  Score breakdown:                                            │
│  ✓ Estimated value set                        +10            │
│  ✓ High value (≥ ₪20k threshold)              +20            │
│  ✓ Stage: PROPOSAL                            +15            │
│  ✓ Proposal viewed                            +15            │
│  ✓ Activity in last 7 days                    +20            │
│  ✗ Phone not provided                           0            │
│  ✗ No company name                              0            │
│                                                              │
│  [Recalculate]                                               │
└──────────────────────────────────────────────────────────────┘
```

**[Recalculate]** → triggers `POST /api/leads/:id/score` (immediate sync recalculation).

---

## Score Recalculation Triggers

Score auto-recalculates (async, background) on:
1. `leads.stage` changes
2. New `lead_activities` entry created
3. `leads.estimated_value` updated
4. `leads.company` or `leads.phone` updated
5. Linked `proposals.status` changes to `VIEWED`
6. Daily background sweep (nightly cron `lead-score-refresh`) recalculates all leads with `score_updated_at < now() - interval '24 hours'`

Background recalculation: enqueue to Cloudflare Queue (non-blocking); update `leads.score` and `leads.score_updated_at`.

---

## Lead List Filters

`/leads` list view gains:
- **Sort by score** (high → low)
- **Filter: Hot leads only** (score ≥ 70)
- **Score column** in table view

---

## Configuration

`/settings/crm` → **Lead Scoring** section:

```
┌──────────────────────────────────────────────────────────────┐
│  Lead Scoring Configuration                                  │
│                                                              │
│  High value threshold:  [₪20,000___]                         │
│                                                              │
│  Criteria weights:                                           │
│  Estimated value set     [10___] pts                         │
│  High value (≥ threshold) [20___] pts                        │
│  Recent activity (7d)    [20___] pts                         │
│  Proposal viewed         [15___] pts                         │
│  ...                                                         │
│                                                              │
│  [Save]   [Reset to defaults]                                │
└──────────────────────────────────────────────────────────────┘
```

---

## Schema Delta

```sql
ALTER TABLE leads ADD COLUMN score INTEGER DEFAULT 0
  CHECK (score >= 0 AND score <= 100);
ALTER TABLE leads ADD COLUMN score_updated_at TIMESTAMPTZ;

CREATE INDEX idx_leads_score ON leads(tenant_id, score DESC)
  WHERE stage NOT IN ('LOST', 'WON');

ALTER TABLE tenant_settings ADD COLUMN IF NOT EXISTS lead_scoring_criteria JSONB NOT NULL DEFAULT
  '{"estimated_value_set":10,"high_value":20,"stage_advancement":15,"recent_activity":20,"inbound_source":10,"company_present":5,"phone_present":5,"proposal_viewed":15,"high_value_threshold":20000}'::jsonb;
```

---

## API

```
GET /api/leads
    → extended: includes score, score_updated_at
      query: { sort_by?: 'score', min_score?: number }
      Requires: marketing:read

POST /api/leads/:id/score
     → recalculate score immediately
       Returns: { score, breakdown: [{criterion, points}] }
       Requires: marketing:write

GET /api/leads/:id/score
    → score + breakdown
      Requires: marketing:read
```

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Stored score | Not computed at query time | Query-time scoring requires joining 4+ tables per lead; stored score with async refresh is fast and scalable |
| BANT-inspired criteria | Not ML model | ML scoring requires training data; rule-based scoring is transparent (staff can see why), configurable, and works with zero historical data |
| Background recalculation via Queue | Not synchronous | Score updates are side effects of other actions; sync would add latency to stage changes, activity creation; async is sufficient |
| Configurable weights | Not hardcoded | Different industries have different lead value drivers; a ₪5k threshold for a freelancer is very different from ₪200k for an enterprise reseller |
