# Project Milestones

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 132  
**Tier:** All tiers  
**Depends on:** `projects-module`, `invoices-core`, `foundation-auth-rbac`  
**Referenced by:** `projects-module`, `invoices-core`

---

## Overview

Spec 7 (`projects-module`) defines `billing_config` JSONB for fixed-fee projects with `{ "total_amount": 5000, "deposit_pct": 30 }`. This is insufficient for multi-milestone fixed-price contracts. This spec adds a `project_milestones` table for tracking deliverable-based payment schedules and triggering invoice generation per milestone.

---

## Data Model

```sql
CREATE TABLE project_milestones (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  description TEXT,
  amount NUMERIC(10,2) NOT NULL,
  due_date DATE,
  completed_at TIMESTAMPTZ,             -- null = incomplete
  invoice_id UUID REFERENCES invoices(id) ON DELETE SET NULL,
  position INTEGER NOT NULL DEFAULT 0,  -- display order
  created_at TIMESTAMPTZ DEFAULT now(),
  created_by UUID NOT NULL REFERENCES users(id)
);
CREATE INDEX idx_project_milestones_project ON project_milestones(project_id);
```

---

## Milestones Tab on Project Detail

New **[Milestones]** tab on `/projects/:id` (appears only for `billing_type = 'fixed'` projects):

```
┌──────────────────────────────────────────────────────────────┐
│  Website Redesign — Milestones            [+ Add milestone]  │
│                                                              │
│  Total project value:  ₪10,000                               │
│  Invoiced:             ₪3,000  (30%)                         │
│  Remaining:            ₪7,000  (70%)                         │
│                                                              │
│  #  Milestone              Amount    Due        Status       │
│  ─────────────────────────────────────────────────────────   │
│  1  Project deposit        ₪3,000    —          ✓ Invoiced   │
│     INV-0023 · Paid                                          │
│                                                              │
│  2  Design approval        ₪2,000    Jun 15     ○ Pending    │
│     [Mark complete]  [Create invoice]                        │
│                                                              │
│  3  Development delivery   ₪4,000    Jul 31     ○ Pending    │
│     [Mark complete]  [Create invoice]                        │
│                                                              │
│  4  Final handoff          ₪1,000    Aug 15     ○ Pending    │
│     [Mark complete]  [Create invoice]                        │
│                                                              │
│  ─────────────────────────────────────────────────────────   │
│  Total defined:  ₪10,000                                      │
└──────────────────────────────────────────────────────────────┘
```

**Total validation:** sum of milestone amounts should equal `billing_config.total_amount`. Warning shown (not error) if they diverge.

---

## Add / Edit Milestone

Slide-in sheet:

```
┌──────────────────────────────────────────────────────────────┐
│  Add milestone                                               │
│                                                              │
│  Name        [Design approval_____________________]          │
│  Description [Client sign-off on all mockups_______]         │
│  Amount      [₪2,000_____]                                   │
│  Due date    [2026-06-15__]                                  │
│                                                              │
│  [Cancel]          [Save milestone]                          │
└──────────────────────────────────────────────────────────────┘
```

---

## Mark Complete

**[Mark complete]** → confirmation:

```
Mark "Design approval" as complete?
This records completion and enables invoice generation for ₪2,000.

[Cancel]    [Mark complete]
```

Sets `project_milestones.completed_at = now()`. Does NOT auto-generate invoice.

---

## Reopen (Undo Completion)

A milestone completed by mistake must be recoverable. Completed milestones (and the milestone edit sheet) expose a **[Reopen]** control:

```
│  2  Design approval        ₪2,000    Jun 15     ✓ Complete   │
│     [Reopen]  [Create invoice]                               │
```

**[Reopen]** → confirmation:

```
Reopen "Design approval"?
This clears the completion date and returns the milestone to Pending.

[Cancel]    [Reopen milestone]
```

Sets `project_milestones.completed_at = NULL`. The row returns to `○ Pending`.

**Invoice-linked guard:** if the milestone has a linked invoice (`invoice_id IS NOT NULL`), reopening is blocked while the invoice is live — the API returns 422 `milestone_invoiced` with message "Void or delete the linked invoice before reopening this milestone." Once the linked invoice is voided/deleted (so `invoice_id` is cleared via the `ON DELETE SET NULL` FK, or the invoice is in a voided state), reopen is permitted. This gives a recovery path for the previously-unrecoverable case where a mistakenly-completed milestone already carries an invoice: void the invoice, then reopen.

---

## Invoice Generation from Milestone

**[Create invoice]** (on completed milestone without invoice):

1. Opens invoice create sheet (spec 15) pre-filled:
   - Customer: project's customer
   - Line item: milestone name, amount
   - Description: auto-populated with project name + milestone name
2. On save: set `project_milestones.invoice_id` to new invoice ID
3. Milestone row shows invoice link and status

If milestone is not yet completed: button shows **[Create invoice]** with warning "Milestone not marked complete. Generate invoice anyway?" — override allowed for OWNER/ADMIN.

---

## Project Overview — Milestone Progress

On the project Overview tab, for fixed-fee projects:

```
Milestones: ██░░░░ 1 of 4 complete · ₪3,000 invoiced of ₪10,000
```

---

## Deposit Handling

`billing_config.deposit_pct` auto-creates a "Project deposit" milestone on project creation (if `deposit_pct > 0`):
- Amount = `total_amount × deposit_pct / 100`
- Name: "Project deposit"
- Due: project start date
- No `due_date` set (payable immediately)

This replaces the existing deposit-only workflow and integrates it with the milestone system.

---

## API

```
GET /api/projects/:id/milestones
    → list milestones for project, ordered by position
      Requires: projects:read

POST /api/projects/:id/milestones
     → create milestone
       body: { name, description?, amount, due_date?, position? }
       Requires: projects:write

PATCH /api/projects/:id/milestones/:milestoneId
      → update milestone
        body: { name?, description?, amount?, due_date?, position? }
        Requires: projects:write

POST /api/projects/:id/milestones/:milestoneId/complete
     → mark milestone as complete
       Requires: projects:write

POST /api/projects/:id/milestones/:milestoneId/reopen
     → clear completed_at (return milestone to Pending)
       Returns 422 'milestone_invoiced' if invoice_id references a live
       (non-voided) invoice — void/delete the linked invoice first
       Requires: projects:write

DELETE /api/projects/:id/milestones/:milestoneId
       → delete milestone (only if no linked invoice)
         Returns 422 if invoice_id is set
         Requires: projects:write
```

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Separate `project_milestones` table | Not extend `billing_config` JSONB | JSONB milestones can't have FK to `invoices`; separate table enables relational integrity + querying |
| Fixed-only milestones | Not hourly/retainer | Milestones are payment checkpoints for fixed-price work; hourly = time-based billing; retainer = recurring — neither maps to milestone completion |
| Manual invoice generation | Not auto-generate on complete | Invoicing is a deliberate action; auto-generation on completion could surprise staff and send premature invoices |
| `invoice_id` FK | Not status string | FK gives relational integrity; enables direct link to invoice from milestone row |
| Warning on sum mismatch | Not hard error | Milestones may not cover the full project value (e.g., final payment pending scope finalization) — block is too strict |
