bottleneckcrm/docs/api/opportunities.md

232 lines
15 KiB
Markdown

# Opportunities
Routes in `backend/opportunity/urls.py`, views split across `opportunity/views/opportunity_views.py`
(list and detail) and `opportunity/views/kanban_views.py` (stage board and move). See
[Conventions](conventions.md) for pagination, filtering and response-shape rules assumed rather than
repeated here.
## List opportunities
`GET /api/opportunities/` (`OpportunityListView.get`, `backend/opportunity/views/opportunity_views.py:272-274`)
returns a single paginated list plus org-wide `totals` computed over the whole filtered queryset, not
just the current page (`get_totals`, `:73-110`):
```json
{
"opportunities_count": 23,
"totals": {
"count": 23,
"amount_sum": "184500.00",
"weighted_sum": "97250.00",
"by_currency": [{"currency": "USD", "amount_sum": "184500.00", "weighted_sum": "97250.00"}],
"stalled_count": 2
},
"offset": 10,
"per_page": 10,
"page_number": [1],
"opportunities": [ { "...": "OpportunitySerializer" } ],
"accounts_list": [ { "...": "AccountSerializer" } ],
"contacts_list": [ { "...": "ContactSerializer" } ],
"tags": [ { "...": "..." } ],
"stage": [["PROSPECTING", "Prospecting"], ["QUALIFICATION", "Qualification"]],
"lead_source": [["NONE", "NONE"], ["CALL", "CALL"], ["WEBSITE", "WEBSITE"]],
"currency": [["USD", "USD, Dollar"]]
}
```
`weighted_sum` is the forecast, `SUM(amount * probability / 100)`, as opposed to `amount_sum`,
which is what every open and closed deal in the filtered set is worth at face value.
Deals in different currencies are never added together, since there are no exchange rates: both
figures are the plain amount when the filtered deals use at most one currency and `null` when they
use several, and `by_currency` lists `{currency, amount_sum, weighted_sum}` per currency, ordered
by code. A deal with no currency counts in the org's default currency. `stalled_count` uses the same per-stage aging thresholds as `?rotten=true` below and as
`Opportunity.get_aging_status()`; see [Stages](#stages). A non-admin caller only sees deals they
created or are assigned to (`:119-131`).
Filters: `name`, `search` (both `icontains` against `name` only; `search` here does **not** OR
across multiple fields the way it does on [Leads](leads.md#list-leads)); `account` (exact); `stage`,
`lead_source` (**`__contains`, not exact**. See [Stages](#stages) for why this matters); `tags`,
`assigned_to` (repeatable, id lists); `created_at__gte/lte`, `closed_on__gte/lte`,
`amount__gte/lte`; `cf_<key>`; `pipeline` (a pipeline id, exact); and two boolean flags,
`open=true` (excludes won and lost stages) and `rotten=true` (stalled deals only). See
[Stages](#stages). The `stage` key in the response lists the default pipeline's stages as
`[code, label]` pairs.
## Create an opportunity
`POST /api/opportunities/` (`OpportunityListView.post`, `opportunity_views.py:292-413`) is validated
by `OpportunityCreateSerializer` (`opportunity/serializer.py:199-297`); see [Fields](#fields). On
success (`200`):
```json
{"error": false, "message": "Opportunity Created Successfully", "id": "<uuid>"}
```
**`name` must be unique per org, case-insensitive** (`validate_name`,
`opportunity/serializer.py:213-228`). The serializer also enforces, at the API layer, the two rules
`Opportunity.clean()` states at the model layer. Deals in a won or lost stage need a close date,
and deals in a won stage need an amount (`validate()`; see [Stages](#stages)), because DRF's
`ModelSerializer` never calls `Model.clean()`, so without this the model's own rules would only be
enforced by unit tests against the model, never through the API.
`contacts`, `tags`, `teams` and `assigned_to` are resolved from the request body the same
list-of-ids way as [Leads](leads.md#create-a-lead) and [Accounts](accounts.md#create-an-account)
(`opportunity_views.py:317-376`); `opportunity_attachment` is accepted as a multipart file.
## Retrieve, update, delete
`GET /api/opportunities/{id}/` (`OpportunityDetailView.get`, `opportunity_views.py:661-736`) looks the
deal up scoped to the caller's org. Unlike leads/accounts/contacts, this lookup returns `None` rather
than raising, so a missing or cross-org id comes back as an explicit `404` response
(`"error": true, "errors": "Opportunity not found."`, `:663-667`) instead of Django's
`get_object_or_404`. `assert_deal_access` (`:423-447`) then requires the caller to be an admin, the
deal's creator, or one of its assignees, or the request is refused with `403`. The response nests the
record under `opportunity_obj`, and also returns `comments`, `attachments`, `contacts`,
`users`, `stage` (the deal's own pipeline's stages as `[code, label]` pairs), `lead_source`,
`currency`, `comment_permission`, `users_mention` and `custom_field_definitions`.
`PUT /api/opportunities/{id}/` (`:464-596`) and `PATCH /api/opportunities/{id}/` (`:831-953`) both
enforce `assert_deal_access`, and both stamp `closed_by = request.profile` and persist it with an
explicit `.save()` whenever the request names a stage whose kind is won or lost, so a deal moved
into a closed stage always records who closed it. Both clear
and re-add `contacts`/`tags`/`teams`/`assigned_to` from the request body the same way create does.
Success: `{"error": false, "message": "Opportunity Updated Successfully"}`.
`DELETE /api/opportunities/{id}/` (`:612-637`) allows an admin, a superuser, or the deal's own creator
(`request.profile.user != self.object.created_by`, a `User`-to-`User` comparison, `:624-632`).
Anyone else gets `403`.
## Stages
**Stages belong to a pipeline, and every org configures its own.** `DealPipeline` and `DealStage`
(`opportunity/models.py`) are org-scoped and RLS-protected. A stage has a `code` (the string stored
on `Opportunity.stage`, unique within its pipeline, derived from the label when the stage is created
and never changed afterwards), a `label`, an `order`, a `kind` (`open`, `won` or `lost`) and its
rotting thresholds, `expected_days` and `warning_days` (both empty for a stage that never rots, and
always empty for won and lost stages). Each deal has a `pipeline` and a `stage` code of that pipeline.
Every org has one default pipeline, created by migration `opportunity/0020` for existing orgs and
lazily on first use for new ones, holding the six stages the app has always had:
`PROSPECTING`, `QUALIFICATION`, `PROPOSAL`, `NEGOTIATION` (open), `CLOSED_WON` (won) and
`CLOSED_LOST` (lost). A deal saved without a pipeline lands in it, which is how clients that predate
pipelines keep working.
**Everything that counts wins reads the kind, not the code.** Goals, the dashboard, account and
contact rollups, `?open=true` and the invoice-from-deal check all treat a deal as won when its stage's
kind is `won`, whatever the stage is called (`stage_kind_q`, `opportunity/models.py`). Entering a won
or lost stage requires `closed_on`; entering a won stage also requires `amount`. `probability` is
auto-filled when it is `0` or unset: 100 for a won stage, 0 for a lost one, and a per-code default
for the seeded open stages (`stage_probability`, `opportunity/workflow.py`).
**Filtering by stage is a substring match, not an exact one**: `?stage=` and `?lead_source=` both
compile to `__contains`, unlike `Lead.status`/`Lead.source`, which are exact matches (see
[Leads](leads.md#list-leads)). `?stage=CLOSED` therefore matches both `CLOSED_WON` and
`CLOSED_LOST`; `?stage=closed` (lowercase) matches neither, because `__contains` on a `CharField` is
case-sensitive.
`aging_status` is `yellow` once a deal has sat `warning_days` (or else `expected_days`) whole days in
an open stage, and `red` (stalled, rotting) at `ceil(expected_days * 1.5)` (`aging_thresholds`,
`workflow.py`). `?rotten=true` and the `stalled_count` in `totals` use the same thresholds as
`Opportunity.get_aging_status()`, as queryset cutoffs (`opportunity/stages.py`, `aging_q`).
The daily stale-deal email (`check_stale_opportunities` in `opportunity/tasks.py`, run by Celery
beat) uses the same `red` threshold. It goes to each stalled deal's assignees, or to the org's
admins when nobody is assigned. It skips a pack's sample deals (`is_sample`), and it skips an org
nobody has used in the last 30 days: no activity and no member sign-in, so a dormant org is not
sent the same email every day.
### Pipeline administration
Reads are open to every member of the org; every write is admin-only (`403` otherwise). An id from
another org is a `404`. All live in `opportunity/views/pipeline_views.py`.
| Endpoint | Method | Body | Answer |
| --- | --- | --- | --- |
| `/api/opportunities/pipelines/` | GET | | `{"pipelines": [pipeline, ...]}`, default first |
| `/api/opportunities/pipelines/` | POST | `{"name"}` | `201`, the pipeline, seeded with the six default stages |
| `/api/opportunities/pipelines/{id}/` | GET, PATCH, DELETE | PATCH `{"name"}` | DELETE is `204`, or `400` for the default pipeline or while any deal is in it |
| `/api/opportunities/pipelines/{id}/stages/` | POST | `{"label", "kind", "expected_days"?, "warning_days"?}` | `201`, the whole pipeline; the stage is appended last |
| `/api/opportunities/pipelines/{id}/stages/reorder/` | POST | `{"stage_ids": [...]}` | the pipeline; `400` unless the list is every stage of the pipeline exactly once |
| `/api/opportunities/stages/{id}/` | PATCH, DELETE | PATCH any of the POST fields | PATCH answers the pipeline; DELETE is `204` |
A pipeline is `{"id", "name", "is_default", "stages": [{"id", "code", "label", "order", "kind",
"expected_days", "warning_days"}]}`. Every pipeline keeps at least one open, one won and one lost
stage: deleting, or changing the kind of, its last stage of a kind is a `400`. So is deleting a stage
or changing its kind while deals are in it, a label another stage of the pipeline already has, and
days outside 1 to 3650.
`GET /api/opportunities/aging-config/` and `PUT` predate pipelines and still work: they read and write
the rotting days of the default pipeline's open stages, keyed by `stage` code.
### Board and move
`GET /api/opportunities/kanban/?pipeline={id}` (`OpportunityKanbanView.get`) returns one column per
stage of that pipeline (the default pipeline when `pipeline` is absent). Another org's pipeline id is
a `404`. From django-crm 1.13.0 the board starts from `deal_list_queryset`, the function behind
`GET /api/opportunities/`, its totals and its CSV export, so it takes **every** list filter described
in [List opportunities](#list-opportunities) (`name`, `search`, `account`, `stage`, `lead_source`,
`tags`, `assigned_to`, `created_at__gte/lte`, `closed_on__gte/lte`, `amount__gte/lte`, `cf_<key>`,
`open=true`, `rotten=true`) with the same visibility rule, and a filter narrows the board and the
list the same way. Before 1.13.0 it took only `search`, `account`, `assigned_to`, `tags` and
`closed_on__gte/lte`. Each column holds at most 100 cards; `item_count` is the column's full count.
```json
{
"mode": "status",
"pipeline": {"id": "<uuid>", "name": "Sales", "is_default": true},
"columns": [
{"id": "PROSPECTING", "name": "Prospecting", "order": 1, "kind": "open", "color": "#3B82F6", "stage_type": "open", "expected_days": 14, "warning_days": null, "is_status_column": true, "wip_limit": null, "item_count": 4, "items": [ { "...": "OpportunityKanbanCardSerializer" } ]}
],
"total_items": 23
}
```
Each card (`OpportunityKanbanCardSerializer`) carries `id`, `name`, `pipeline`, `stage`,
`stage_label`, `stage_kind`, `amount`, `opportunity_amount`, `currency`, `probability`, `closed_on`,
`kanban_order`, `account` (`{id, name}`), `assigned_to`, `tags`, `line_items`, `days_in_stage`,
`aging_status`, `stage_changed_at`, `next_activity`, `can_move`, `created_at` and `updated_at`.
`tags`, `line_items`, `stage_changed_at` and `updated_at` are new in 1.13.0, for the phone app's
board, which reads this endpoint too. A card's `line_items` are plain rows (`id`, `name`,
`quantity`, `unit_price`, `discount_type`, `discount_value`), not the full line item the detail
view returns. `can_move` is whether the caller may move the card, by the same rule the move endpoint
applies.
`PATCH /api/opportunities/{id}/move/` (`OpportunityMoveView.patch`) requires `column_id`, a stage
code of the deal's own pipeline (anything else is a `400`; the board never moves a deal between
pipelines, the edit form does). `above_id`/`below_id` or an explicit `kanban_order` place the card
within the column. Access is the same rule as the detail view (admin, creator or assignee). Dragging
into a won or lost stage stamps `closed_by` (and `closed_on` if the deal had none); dragging a closed
deal back to an open stage clears `closed_by`. Dragging into a won stage without an amount is a
`400`.
## Fields
`OpportunityCreateSerializer.Meta.fields` (`opportunity/serializer.py:270-289`) is what
`POST /api/opportunities/`, `PUT /api/opportunities/{id}/` and `PATCH /api/opportunities/{id}/`
accept. Only `name` is required at the model level (`opportunity/models.py:46`, no `blank=True`).
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | **required** | Unique per org, case-insensitive |
| `account` | uuid | optional | An account in the caller's org that the caller can open (an admin can open any). Anything else, an unknown id included, is a 400 `"No such account."`. On edit, the account the deal already has is kept whoever saves |
| `pipeline` | uuid | optional | One of the org's pipelines. Defaults to the deal's current pipeline, or the org's default on create. Changing it requires `stage` too |
| `stage` | a stage code of the deal's pipeline | optional | Defaults to the pipeline's first open stage on create; see [Stages](#stages) |
| `opportunity_type` | one of `OPPORTUNITY_TYPES` | optional | `NEW_BUSINESS`, `EXISTING_BUSINESS`, `RENEWAL`, `UPSELL`, `CROSS_SELL` |
| `currency` | one of `CURRENCY_CODES` | optional | Defaults from the org's `default_currency` if omitted |
| `amount` | decimal | **required for a won stage** | Otherwise optional; non-negative (DB constraint) |
| `probability` | integer, 0-100 | optional | Auto-filled from `stage` if `0`/unset. See [Stages](#stages) |
| `closed_on` | date | **required for a won or lost stage** | Otherwise optional |
| `lead_source` | one of `SOURCES` | optional | Uppercase, `NONE`, `CALL`, `EMAIL`, `EXISTING CUSTOMER`, `PARTNER`, `PUBLIC RELATIONS`, `CAMPAIGN`, `WEBSITE`, `OTHER`. **Not** `Lead.source`'s `LEAD_SOURCE`. See [Leads](leads.md#list-leads) |
| `description` | text | optional | |
| `is_active` | boolean | optional | Defaults `true` |
Not part of the serializer, but accepted in the same request and resolved by the view (see
[Create an opportunity](#create-an-opportunity)): `contacts`, `tags`, `teams`, `assigned_to` (each a
list of ids, org-scoped), and `opportunity_attachment` (a multipart file).
`GET /api/opportunities/` and `GET /api/opportunities/{id}/` additionally return, but never accept as
input: `id`, `closed_by`, `created_by`, `created_at`, `org` (nested), `created_on_arrow`,
`amount_source` (`MANUAL` or `CALCULATED`, server-derived by `recalculate_amount()` from whether the
deal has line items, `opportunity/models.py:68-73,185-202`; not writable through this serializer),
`stage_label`, `stage_kind`, `stage_changed_at`, `days_in_stage`, `aging_status` (`green`/`yellow`/`red`, from
`Opportunity.get_aging_status()`), `line_items`, `line_items_total`, and `custom_fields` (validated
separately against the org's `CustomFieldDefinition` rows).