Skip to main content

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​

EntityWhat it is
DealA sales opportunity. Carries commercial terms, a pipeline stage, and the join keys back to Zoho and Monday.
DealStageA stage in the pipeline. A table, not an enum — probability, ordering and record category are ops-editable.
DealStageHistoryOne row per transition, snapshotting commercial terms at that moment.
ContactA person at an account. The platform's first record about a non-user person.
DealContactRoleWhich contact plays which role on a deal, with an explicit fallback order.
CrmAccountProfileCRM-only sales attributes for an Organization, held in a sidecar.
CrmNote, CrmAttachment, DealTagActivity and labelling.
AutomationRun, AutomationBindingThe 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.

KeyProbabilityCategory
internal_analysis20open
on_hold40open
proposal_price_quote75open
in_client_review75open
negotiation_review90open
awaiting_po99open
closed_won100closed_won
closed_lost0closed_lost
closed_lost_to_competition0closed_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.

Do not validate a half-finished swap

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.

Editing a stage's probability moves historical-looking numbers

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.

AreaRoutes
Pipeline metadataGET /stages/ · GET /picklists/ · GET /owners/
DealsGET /deals/board/ · GET /deals/ · POST /deals/ · GET|PATCH /deals/{id}/ · POST /deals/{id}/stage/ · GET /deals/{id}/stage-history/
ContactsGET|POST /contacts/ · GET|PATCH /contacts/{id}/ · POST /contacts/{id}/archive/ · POST /contacts/{id}/unarchive/
AccountsGET|PATCH /accounts/{org_id}/
OperationsGET /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​

SituationResponse
Caller is not staff404 — never 403, and never a hint that the route exists
Path resource missing404 not_found
Body field names something absent (to_stage_key, stage_key, account_id)400 validation_error, with the field in details
Stale expected_version409 version_conflict, current version in details
Required field omitted422 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 trigger

It 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 a commercials_amended audit 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 Project needs human judgment — so the deal becomes provisioning required and staff run POST /crm/deals/{id}/provision-project/, which creates workspace → PCC → project, writes the reporting owner, and links the deal.
  • Verifier need. needs_verifier posts a real VerificationOpportunity onto 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_text and campaign_text are plain text fields with no choices. The Zoho estate dump that defines them has not been captured yet, and pick-lists key on actual_value while ops recognise display_value — a vocabulary built from labels reviews as correct and then fails to match at import. Adding choices later 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_rate and amount_base are modelled so enabling it is a code change rather than a schema change.