CRM (Deals, Contacts, Accounts)
The CRM is a staff-internal module that replaces the Zoho CRM / Zoho Flow / Make estate. It is workspace-agnostic: a deal is not owned by a workspace, and CRM records are never exposed on the public or partner API.
Its defining property is direction. The CRM sits upstream of the platform — a Deal becomes a Project when it closes won — so it is a source of platform state rather than a mirror of it.
Entities
| Entity | What it is |
|---|---|
| Deal | A sales opportunity. Carries commercial terms, a pipeline stage, and the join keys back to Zoho and Monday. |
| DealStage | A stage in the pipeline. A table, not an enum — probability, ordering and record category are ops-editable. |
| DealStageHistory | One row per transition, snapshotting commercial terms at that moment. |
| Contact | A person at an account. The platform's first record about a non-user person. |
| DealContactRole | Which contact plays which role on a deal, with an explicit fallback order. |
| CrmAccountProfile | CRM-only sales attributes for an Organization, held in a sidecar. |
| CrmNote, CrmAttachment, DealTag | Activity and labelling. |
| AutomationRun, AutomationBinding | The automation ledger and its configuration. |
There is no CRM "Account" model
Deal.account points at the existing organizations.Organization. That model
already carries name, website, address, phone, email, description, logo, a
unique web_domain, and a parent/child hierarchy for corporate groups.
CRM-only sales attributes — industry, account type, annual revenue, deal owner —
live in a CrmAccountProfile sidecar instead of being added to Organization.
The reason is exposure, not tidiness: Organization is an integer-PK
Mongo-compatibility model that both backends read and that is serialised into
public API payloads. Sales lifecycle state must not be able to leak there.
A related consequence for account matching: Organization.name is not unique
and web_domain is unique but nullable. Matching a Zoho account by name
alone will collide, and auto-creating an Organization for a deal whose Zoho
record has no website will duplicate one on every import run.
Stages are data, not code
DealStage is seeded with nine rows and edited in Django admin thereafter.
| Key | Probability | Category |
|---|---|---|
internal_analysis | 20 | open |
on_hold | 40 | open |
proposal_price_quote | 75 | open |
in_client_review | 75 | open |
negotiation_review | 90 | open |
awaiting_po | 99 | open |
closed_won | 100 | closed_won |
closed_lost | 0 | closed_lost |
closed_lost_to_competition | 0 | closed_lost |
record_category is deliberately separate from the stage key. It is the coarse
"is this still open" bucket, it survives ops adding or renaming stages, and a
Zoho automation already triggers on category change rather than stage change.
sort_order is unique with Deferrable.DEFERRED. Reordering swaps two rows'
values inside one transaction, which transiently duplicates one; an immediate
constraint would reject the first write of the pair.
Deferral changes when Postgres checks, not when Django does: full_clean()
runs its own validate_unique() query and is indifferent to deferrability, so
it still catches a plain duplicate. The consequence is the opposite of the
obvious one — a reorder service must not call full_clean() on the
intermediate values, because that check would reject a legitimate mid-swap state
the database is happy to accept.
Expected revenue is computed, never stored
expected_revenue = amount × COALESCE(probability_percent,
confidence_percent,
stage.probability) / 100
probability_percent is Zoho's per-record override, imported verbatim. It
exists because Zoho lets a salesperson adjust an individual deal's probability,
and recomputing from the stage default alone would silently change expected
revenue for every hand-adjusted deal at cutover — while the cutover check
compares our pipeline total against Zoho's.
The expression is annotated by crm.selectors.deal_with_expected_revenue_qs()
so it cannot go stale. It is not a GeneratedField: Postgres STORED
generated columns cannot reference another table, and this reaches
stage.probability across a foreign key.
Because expected revenue is computed on read, changing DealStage.probability
retroactively changes it for every deal without an override. That is correct for
a table-driven stage model, and it is why DealStage is audit-tracked and why
DealStageHistory snapshots probability_at_change.
Deal numbering
Deals carry a human-facing deal_number of the form SEPD-1301, allocated from
a Postgres sequence and unique at the database level. Numbering continues Zoho's
live run, so the format is byte-identical to what is already on existing records.
Allocation self-heals. If the sequence is found to be behind the table — the
state a partially-failed database restore leaves behind — the allocator takes an
advisory lock, recomputes from the highest number in use, fast-forwards the
sequence, and logs a warning. It never fails the create. Grep for
crm_deal_number_seq was behind if deal numbers ever look wrong.
The API surface
Every CRM route is mounted under /api/v2/crm/ and is staff-only. There is
no feature flag: @require_staff is the access control, and it returns 404
rather than 403, so the surface never advertises its own existence to a
customer, verifier or outsider. CRM endpoints are excluded from the published
OpenAPI specification for the same reason.
| Area | Routes |
|---|---|
| Pipeline metadata | GET /stages/ · GET /picklists/ · GET /owners/ |
| Deals | GET /deals/board/ · GET /deals/ · POST /deals/ · GET|PATCH /deals/{id}/ · POST /deals/{id}/stage/ · GET /deals/{id}/stage-history/ |
| Contacts | GET|POST /contacts/ · GET|PATCH /contacts/{id}/ · POST /contacts/{id}/archive/ · POST /contacts/{id}/unarchive/ |
| Accounts | GET|PATCH /accounts/{org_id}/ |
| Operations | GET /automation-runs/ |
Concurrency: version is a required token, not a hint
Deal.version is an integer counter bumped by every write. A write sends the
version it last read as expected_version; the service compares it against a
row-locked deal inside the transaction and returns 409 on a mismatch, with
the current value in details so the client can resync in one round-trip.
Two consequences that are easy to get wrong:
- A new deal is
version: 0, and 0 is a valid token. A client-side truthiness check (if (deal.version)) reads it as absent and will omit the field on the deal's very first write. - The board and the deal form share one counter. Both must read and echo the
same
version, or a user with the board open in one tab and the detail page in another will conflict with themselves on the next drag.
Deal.stage_seq sits next to version on the model and is not a concurrency
token — it is the automation dedupe key and moves only on stage changes. It is
not serialised, and confusing the two silently breaks either concurrency
detection or automation dedupe.
Stage changes are their own route
A stage move is POST /deals/{id}/stage/ with
{to_stage_key, expected_version, reason?} — never a PATCH on the deal. The
transition is guarded, writes a DealStageHistory row and an audit event, and
enqueues automations; sharing a handler with routine field edits would either
make every edit pay those costs or let the guard be bypassed.
There is one reason field on the wire. Deal.closed_lost_reason is a model
column populated from the transition, not a request field — two fields that can
disagree is exactly the ambiguity this contract exists to remove. A blank
reason moving to a closed_lost stage is a 400.
POST and not PATCH, and deliberately without idempotency-key handling:
expected_version already makes a replay a correct no-op-or-409, whereas the
shared idempotency cache stores responses — including errors — for 24 hours and
would replay a stale success or a long-resolved conflict.
Moving out of a terminal stage is allowed. Transition validation is
deliberately permissive about which stage may follow which, because ops reorder
and add stages in Django admin and a hard-coded adjacency table would go stale
the first time they did. Pipeline discipline belongs in reporting, not in a
400 — and a client must not add a guard the API does not enforce.
The board is capped, not paginated
GET /deals/board/ returns every stage as a column, including empty ones, each
holding at most 25 cards plus a truncated flag. The column's deal_count is
the true total from the aggregate, so a column can honestly report 96 deals
while rendering 25 — which is what ops reconcile against Zoho during the mirror
period.
There are no per-column cursors. Shared cursor pagination is forward-only and
ascending, and every list on this surface is newest-first; a stage history read
oldest-first is not a view anyone wants. Lists return a hard-capped slice and a
truncated boolean instead.
/picklists/ serves three lists, and only three
deal_type, lifecycle and record_category — the code-owned TextChoices,
served without a database query. Stages are not included: DealStage is an
ops-editable table with its own endpoint, and folding it in would make a stage
rename look like it needed a deploy.
Region, forecast category, client type and material category are not served
because those fields do not exist on any CRM model, and lead_source is
deliberately choices-free. Adding any of them is a model change and a
migration, not an API change — see "What is deliberately not here yet" below.
Owners are staff, and the write path agrees with the picker
GET /crm/owners/ lists active staff, and owner_id on any write is validated
against that same set. Assigning anyone else is a 400 naming the field.
This is not cosmetic. Commercial edits are narrowed to the deal's owner or a superuser, so a deal owned by someone who cannot reach the CRM would be editable by nobody short of a superuser — a state the picker can never produce and the UI cannot undo.
Contact.user_id is deliberately not narrowed: it means "this person also has
a platform account", which is a customer by definition.
Erased contacts are immutable, on every write path
PATCH, archive and unarchive all refuse a contact whose erased_at is set,
returning 400. Archiving an erased row accomplished nothing in any case —
erased contacts are already excluded from every list — so the only thing it did
was mutate an Article-17 tombstone and echo its surviving account and owner
associations for a record whose detail route returns 404.
Erasure itself is not an HTTP operation; it runs through the crm_contact_erase
management command.
Error conventions
| Situation | Response |
|---|---|
| Caller is not staff | 404 — never 403, and never a hint that the route exists |
| Path resource missing | 404 not_found |
Body field names something absent (to_stage_key, stage_key, account_id) | 400 validation_error, with the field in details |
Stale expected_version | 409 version_conflict, current version in details |
| Required field omitted | 422 from schema validation |
The third row is a deliberate divergence from the platform's usual
"DoesNotExist → 404" handling. Every other instance of that convention is a
path-param lookup, where the addressed resource really is missing. Here the
deal exists and a body field is wrong, so keeping 404 to mean exactly "no such
deal" lets a client branch on status alone instead of parsing error codes.
Text-vs-FK
Any entity that also exists in the platform gets both a foreign key and a
*_text shadow — account / account_text, pcr / pcr_text, owner /
owner_text. The shadow is written only by the importer and by pre-qualification
data entry, and is cleared the moment the FK resolves.
This exists because a join key with no writer is worse than no join key: it looks usable, and every consumer silently reads an empty column.
Audit trail
Deal, DealStage, Contact, DealContactRole and CrmNote are tracked with
django-pghistory using append_only=True.
append_only is a database triggerIt compiles to a Postgres trigger that raises on UPDATE or DELETE of an event
row. Deleting audit history therefore errors — it does not silently succeed.
The escape hatch is pgtrigger.ignore("crm.ContactEvent:append_only"), and the
explicit URI is mandatory: a bare ignore() disables every trigger on the
connection, including the ones writing audit rows for unrelated models in the
same transaction.
This matters most for Contact, which holds personal data for people who are not
platform users. Every historical value of a contact's name and email is retained
in crm_contactevent, so an erasure request has to reach that table too.
Closed Won fulfils on-platform
Winning a deal is not just a column move. When a deal enters a Closed-Won
stage, an automation (rule closed_won_platform_fulfilment, off until ops
enables its binding in admin) fulfils the sale on the platform itself:
- Commercial freeze. Once a deal carries a
won_at, its commercial figures (amount, the sold-scope counts,annual_fee) can only be changed by a superuser, and every such change is recorded as acommercials_amendedaudit event. Reopening a deal does not unfreeze it. - Account resolution. An unlinked deal is matched to an existing organization by domain, then by an unambiguous name match. Nothing is ever auto-created — an unmatchable deal lands in the failed-runs list for a human.
- Entitlement. An EPD deal's organization gains
can_create_workspaces. The automation-run ledger row is the audit trail, and nothing in the CRM ever revokes the flag — not Closed Lost, not a rollback. - New engagement vs change order. A change order (a deal with a parent)
inherits its parent's project. A new engagement does not auto-create
anything — a
Projectneeds human judgment — so the deal becomes provisioning required and staff runPOST /crm/deals/{id}/provision-project/, which creates workspace → PCC → project, writes the reporting owner, and links the deal. - Verifier need.
needs_verifierposts a realVerificationOpportunityonto the Verifier Hive board once a project exists — at fulfilment for a change order, at provisioning for a new engagement.
The dispatch can be delayed by CRM_FULFIL_UNDO_SECONDS (default 0): the task
re-checks the deal is still won at execution, so within a configured window,
dragging the deal back cancels the fulfilment. The
crm_entitlement_reconcile management command reports won EPD deals whose
organization still lacks the entitlement — imported (pre-platform) wins land
there by design, since the bulk import suppresses automations.
What is deliberately not here yet
- Zoho pick-list vocabularies.
DealContactRole.role,closed_lost_reason,deal_type_textandcampaign_textare plain text fields with nochoices. The Zoho estate dump that defines them has not been captured yet, and pick-lists key onactual_valuewhile ops recognisedisplay_value— a vocabulary built from labels reviews as correct and then fails to match at import. Addingchoiceslater is a metadata-only migration. - Contact deduplication. Zoho permits duplicate-email contacts and no merge
policy exists, so there is no unique constraint on
Contact.email. - Multi-currency. Every deal is asserted to be USD at validation time.
currency,exchange_rateandamount_baseare modelled so enabling it is a code change rather than a schema change.