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).
1. Scope
Section titled “1. Scope”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.
2. Decisions (owner, 2026-10-08)
Section titled “2. Decisions (owner, 2026-10-08)”- 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).
3. Layout
Section titled “3. Layout”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.
4. Data
Section titled “4. Data”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.
5. Workflow
Section titled “5. Workflow”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.
6. Lead conversion
Section titled “6. Lead conversion”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).
7. Module switch and permissions
Section titled “7. Module switch and permissions”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.
8. API and frontend
Section titled “8. API and frontend”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.
9. Tests
Section titled “9. Tests”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.
10. Phases
Section titled “10. Phases”- Schema, platform package, workflow definitions.
- API and module (permissions, module switch).
- UI and translations.
11. Open points
Section titled “11. Open points”- 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 readsanonymised_atand says “Erased”), email, phone and title become NULL, the activities’ subject becomes the placeholder and their body NULL,anonymised_at/anonymised_byare 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 inplatform/crmunder 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 (itslegal_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. Migration01172. - 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-
draftfirst stage passes the graph validator is settled by the plan’s validator test, not assumed.