Skip to content

CRM v1 — core pipeline

Status: built on branch crm-v1 (2026-10-08); browser pass and native ms/zh review outstanding. Amended by rulings R4-R9 (see sections 5-8).

Leads, accounts, contacts, opportunities with workflow stages, and activities.

Out of scope for v1: quotes (trade already owns /sales/quotations; an opportunity may link to one later), the project_profile link (project-usage spec PU7), email sync, campaigns, lead scoring.

  • CD-1 First build is the core pipeline only; quotes follow as a later phase.
  • CD-2 CRM accounts and contacts live in their own tables. An account optionally points at a receivables customer. The shared party table is not extended, so receivables, payables and HR are untouched.
  • CD-3 Stage tags reuse the existing workflow semantics (section 5).

internal/platform/crm holds the domain logic; internal/modules/crm holds the HTTP layer and permissions, as every other module does. Dependencies point downward only. CRM reaches receivables only through its contract.go; it never writes a receivables table and never writes a journal line. CRM registers no posting rules.

All tables carry org_id, ENABLE and FORCE ROW LEVEL SECURITY, and a policy named org_isolation. Vocabulary values are strings.

Table Notes
crm_account company or person; nullable customer_id to the receivables customer
crm_contact belongs to an account; name, email, phone, title only
crm_lead source, owner, converted-to references
crm_opportunity account, owner, amount NUMERIC(19,4), currency, expected close date
crm_activity call / meeting / task / note, attached to a lead, opportunity or account

Money is money.Amount in Go and a string on the wire; no float anywhere. Every user-facing name is a translation row.

Opportunity stages are a workflow definition of kind crm_opportunity, not a status column; leads use crm_lead. Both are registered with workflow.RegisterSeededGraph, so a customer may rename, add or reorder stages.

The workflow semantic set is closed (constants.yaml workflowSemantics, a CHECK on workflow_state, generated vocab), and every member is posting-oriented. CD-3 (owner, 2026-10-08): CRM reuses it and adds nothing. Open stages are draft, pending or custom; Won (and a lead’s Converted) is a terminal custom state, because the validator allows committed only for document kinds (workflow.CommitmentKinds) (ruling R4); Lost (and Disqualified) is a terminal cancelled state. Win and loss are read from is_terminal plus the semantic, never from a state key. No journal line is ever written, because no CRM state is an accrual. Nothing in the application reads a state name. Stage moves go to the reporter’s own gate, and the reporter is the record’s owner at creation (ruling R7; reassigning the owner later does not change who may move it, a known limitation), and are permission-gated at the route; they are not approvals. Moves are explicit transitions sub-resources, as elsewhere.

One action creates an account, a contact and optionally an opportunity, and links them on the lead. In v1 an account can only be linked to an existing party id (customer_id); creating a receivables customer from an account is a later step through a receivables contract (none exists yet), and a boundary test keeps CRM free of any receivables import. Converting needs crm.lead.transition plus crm.account.create (and crm.opportunity.create when it creates an opportunity).

An optional module in moduleinstall. The platform has no default-off mechanism (a missing install row means on), so CRM is on for existing organizations once deployed (ruling R6); existing organizations need provisioning re-run to receive the two CRM workflow definitions (ruling R8, docs/DEPLOY_NOTES.md). Receivables is needed only for the customer link. Module-registered permission keys: read, create, edit and transition for each of lead, account and opportunity; activity create.

Endpoints are declared in api/openapi.yaml first: /crm/accounts (contacts are listed and created under /crm/accounts/{id}/contacts; edited at /crm/contacts/{id}), /crm/leads, /crm/opportunities, /crm/activities. Lists take the server-side search, sort and filter used elsewhere. Frontend web/src/features/crm: lists, a detail drawer, a kanban pipeline built from workflow states, an activity timeline; en/ms/zh.

Real Postgres via testdb.Start. Existing drift tests (tenant isolation, route authorization, env vars, generated code) must cover the new tables and routes. New: cross-tenant read tests that first prove the fixture is non-empty, then prove absence; each guard watched failing once.

  1. Schema, platform package, workflow definitions.
  2. API and module (permissions, module switch).
  3. UI and translations.
  • CD-4 (decided 2026-10-09, owner): erasure is an anonymise action. Contact data is limited to name, email, phone, title. A holder of crm.data.anonymise (Owner and Admin) may erase a contact, a lead or a person account, and the activities attached directly to that lead or account. The row is kept so history stays consistent: the name becomes a fixed placeholder (crm.ErasedName, never shown; a screen reads anonymised_at and says “Erased”), email, phone and title become NULL, the activities’ subject becomes the placeholder and their body NULL, anonymised_at/anonymised_by are stamped and the row is archived. Irreversible; a second request is a no-op. No minimum retention (CRM is not statutory) and still no DELETE grant. One audit entry per action names the actor, the record kind and id, never the erased values. No cascade: a converted lead’s account and contact are separate records, each erased on its own request; a company account is refused (account_not_person). An erased record stays erased: editing it, linking it to a customer, converting it, or adding a contact or activity to it is refused (record_erased, checked in platform/crm under a share lock); moving and archiving stay open. Not erased: transition reasons are free text in the immutable workflow log and are not erased; staff should not record personal data in reasons. Also not erased: a person account’s customer link is kept, and the linked receivables party (its legal_name) is receivables data, statutory and handled in receivables, not here; the account’s opportunities and the activities on them are not erased, and opportunity names commonly carry the person’s name. There is no opportunity anonymise action (a known gap, ROADMAP CRM follow-ups): staff should not put personal data in opportunity names. An erased account’s screen shows only that it is linked to a receivables customer, never the party’s name. Migration 01172.
  • Until linked, one real company can exist both as an account and as a customer.
  • Migrations take range 1170-1179 (free in migrationranges_test.go; the range is added in the same change as the first migration).
  • Whether a non-draft first stage passes the graph validator is settled by the plan’s validator test, not assumed.