# Pipeline (CRM-lite)

Every customer account carries a small sales / customer-success record that only staff
can see: a funnel **stage**, an **owner** on the team, a **next step** with a due date, a
**source**, and dated **notes**. It lives on the account (`store.Account.Pipeline`) and is
persisted with it, but never appears in any customer-facing response or in
`GET /v1/admin/accounts/*` (the JSON tag is `-`).

- Admin API: `GET /v1/admin/pipeline`, `GET /v1/admin/pipeline/due`,
  `GET|PATCH /v1/admin/accounts/{id}/pipeline`,
  `POST /v1/admin/accounts/{id}/pipeline/notes`,
  `DELETE /v1/admin/accounts/{id}/pipeline/notes/{nid}`
- Permissions (`docs/ROLES.md`): reads need `pipeline.read` (every staff role);
  writes need `pipeline.write` (superadmin, support, sales). Finance, ops and viewer are
  read-only.
- Code: `internal/api/ext_pipeline.go`; no configuration variables.

## Stages

In funnel order: `lead`, `contacted`, `call_booked`, `integrating`, `live`, `paying`,
`churned`, `lost`.

An account that no staff member has touched yet gets a **derived** stage, so the list is
useful from day one:

| Condition (checked in this order) | Derived stage |
|---|---|
| a `paid` invoice exists for the current month | `paying` |
| `live_enabled` (live access approved) | `live` |
| otherwise | `lead` |

As soon as staff set a stage through `PATCH`, that explicit stage wins and is never
overridden by the derivation (`stage_derived: false` in the responses). Setting an owner,
next step or source without a stage keeps the stage derived.

Sources are optional: `organic`, `outbound`, `referral`, `partner`, `paid` (or empty).

## Endpoints

All responses are JSON; errors are `{"error":{"code","message"}}`, forbidden is
`{"error":{"code":"forbidden","message":"forbidden: requires pipeline.write"}}`.
Timestamps are RFC 3339 UTC.

### `GET /v1/admin/pipeline?stage=&owner=&q=`

Lists every account as a pipeline row, most recently active first.

```json
{
  "accounts": [
    {
      "account_id": "acct_…", "email": "ops@astro.example", "company": "Astro Talk",
      "created_at": "2026-10-01T08:12:00Z", "live_enabled": true, "plan_tier": "starter",
      "stage": "integrating", "stage_derived": false,
      "owner_staff": "stf_…", "owner_name": "Priya", "source": "outbound",
      "next_step": "Check webhook setup", "next_step_at": "2026-10-30T09:00:00Z", "overdue": false,
      "last_note": "Shared the mobile SDK guide", "notes_count": 3,
      "last_activity": "2026-10-27T14:03:11Z"
    }
  ],
  "stages": ["lead", "contacted", "call_booked", "integrating", "live", "paying", "churned", "lost"],
  "counts": {"lead": 12, "contacted": 4, "call_booked": 1, "integrating": 2, "live": 3, "paying": 5, "churned": 0, "lost": 1},
  "total": 28
}
```

- `stage` filters on the effective stage (400 for an unknown stage).
- `owner` is a staff id; `owner=unassigned` lists accounts without an owner.
- `q` is a case-insensitive substring match on email, company and account id.
- `counts` always covers **all** accounts (not the filtered subset) so stage tabs show totals;
  `total` is the number of accounts.
- `plan_tier` is empty when no plan is set; `overdue` is true when `next_step_at` is in the
  past; `last_activity` is the latest of account creation, the last pipeline change and the
  last note.

### `GET /v1/admin/pipeline/due`

Next steps dated today (UTC) or earlier, oldest first. Feeds the attention list on the
metrics page.

```json
{"due": [ …pipeline rows… ], "count": 3, "overdue": 2, "as_of": "2026-10-28T06:00:00Z"}
```

### `GET /v1/admin/accounts/{id}/pipeline`

```json
{
  "account": { …the pipeline row… },
  "pipeline": {
    "stage": "contacted", "stage_derived": false,
    "owner_staff": "stf_…", "owner_name": "Priya",
    "next_step": "Demo call", "next_step_at": "2026-10-30T09:00:00Z", "source": "outbound",
    "notes": [{"id": "note_…", "at": "2026-10-27T14:03:11Z", "by": "stf_…", "text": "…"}],
    "updated_at": "2026-10-27T14:03:11Z"
  },
  "stages": [ … ], "sources": [ … ]
}
```

`notes` are newest first and always present (`[]` when empty); `updated_at` is `null` until
staff first touch the record. `404` for an unknown account.

### `PATCH /v1/admin/accounts/{id}/pipeline`

Body: any of `stage`, `owner_staff`, `next_step`, `next_step_at`, `source` (all optional,
at least one required). Returns the same shape as `GET`.

| Field | Rules |
|---|---|
| `stage` | one of the eight stages |
| `owner_staff` | id of an existing, enabled staff account, or `""` to clear |
| `next_step` | free text (trimmed) |
| `next_step_at` | RFC 3339 timestamp, or `""` to clear |
| `source` | `organic`, `outbound`, `referral`, `partner`, `paid`, or `""` |

Invalid values answer `400`; unknown JSON fields are rejected.

### `POST /v1/admin/accounts/{id}/pipeline/notes`

Body `{"text": "…"}` (trimmed, 1–4000 characters). `201 {"note": {id, at, by, text}}`;
`by` is the staff id (or `admin` for the admin key).

### `DELETE /v1/admin/accounts/{id}/pipeline/notes/{nid}`

`204` for the note's author or a superadmin (the admin key counts as superadmin),
`403 forbidden` for anyone else, `404` for an unknown note.

## Audit

Stage, owner and source changes, and note additions and deletions are recorded in the
audit log (`GET /v1/admin/audit?actor=<staff id>`) with target `account:<id>` and a summary
such as `pipeline: stage lead -> contacted, owner stf_…` or `pipeline: note added (note_…)`.
The generic request observer additionally logs every admin write.

## Day-to-day

1. New signups appear as `lead`; the sales owner claims them (`owner_staff`) and sets a
   `next_step` with a date.
2. `GET /v1/admin/pipeline/due` each morning is the call list; anything `overdue` is red.
3. When live access is approved the derived stage becomes `live`, and `paying` once the
   month's invoice is paid; set `churned` or `lost` by hand (an explicit stage sticks).
