CRM v1 Implementation Plan
Executed 2026-10-08 on branch
crm-v1. Where this plan and the code disagree, the rulings R1-R9 in the SDD ledger and the amended spec win: Won/Converted are terminalcustom(R4), CRM is on by default (R6), the record owner is the workflow reporter (R7), existing orgs need provisioning re-run (R8).
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Leads, accounts, contacts, opportunities with workflow stages, and activities, as an optional per-tenant module.
Architecture: Domain logic in internal/platform/crm; HTTP, permissions and the module switch in internal/modules/crm; UI in web/src/features/crm. Opportunity and lead stages are seeded workflow graphs reusing the existing closed semantic set. CRM never writes a journal line and never writes a receivables table. internal/modules/leave (module), internal/platform/hr/leave_request.go (platform) and internal/platform/trade/graphs.go (graphs) are the patterns to copy.
Tech Stack: Go, gin, pgx/v5 (hand-written SQL in tx.Exec/Query, as platform/hr does), Postgres with RLS, goose migrations, oapi-codegen, React + vitest.
Spec: docs/superpowers/specs/2026-10-08-crm-design.md. Read it, then AGENTS.md, before Task 1.
Global Constraints
Section titled “Global Constraints”- A float never touches money:
money.Amountin Go,NUMERIC(19,4)in Postgres, a string on the wire. - Every table has
org_id,ENABLEandFORCE ROW LEVEL SECURITY, and a policy namedorg_isolation(same text asdb/migrations/00841_leave.sql). - Every request runs in a transaction (
db.InTx). - Vocabulary values (kinds, sources, stages) are strings, never integers.
- CRM states reuse existing workflow semantics only (CD-3): open =
draft/pending/custom, Won = terminalcustom(ruling R4:committedis restricted to document kinds), Lost =cancelled. Nothing reads a state name. - Dependencies point downward only:
modules/crm→platform/crm→kernel.modules/crmreaches receivables only through itscontract.go. - CRM registers no posting rules and writes no journal lines.
- Every user-facing name is a translation row, never a column on the entity. Strings in en, ms, zh.
- No CRM table grants
DELETEtoerp_app; records are archived (archived_at). - Migrations are numbered 01170-01179 (add the range to
migrationranges_test.goin the same commit as the first migration). - Endpoints are declared in
api/openapi.yamlfirst;make generatemust leave no diff. - A drift test is not trusted until it has been watched failing once: break the thing it guards, read the message, restore.
- A test that asserts absence first proves presence (AGENTS.md).
- While iterating test only the touched packages; run the full
go test ./... -p 1once before the final commit. Database tests usetestdb.Start.
File Structure
Section titled “File Structure”| File | Responsibility |
|---|---|
db/migrations/01170_crm_schema.sql |
the five tables, RLS, indexes, grants |
db/migrations/01171_crm_module_roles.sql |
role permissions for existing organizations |
internal/kernel/db/migrationranges_test.go |
modify: add {1170, 1179, "CRM v1 (...)"} |
internal/platform/crm/doc.go |
package doc |
internal/platform/crm/graphs.go |
KindLead, KindOpportunity, the two seeded graphs |
internal/platform/crm/account.go |
Account, Contact: create, get, list, update, archive |
internal/platform/crm/lead.go |
Lead: create, get, list, update, Convert |
internal/platform/crm/opportunity.go |
Opportunity: create, get, list, update, transition |
internal/platform/crm/activity.go |
Activity: create, list |
internal/platform/crm/errors.go |
ErrNotFound, FieldError |
internal/platform/crm/crmtest/env.go |
test environment (copy the shape of internal/platform/hr/hrtest) |
internal/modules/crm/permissions.go |
permission keys and registration |
internal/modules/crm/routes.go |
Deps, Register, route table |
internal/modules/crm/*_handlers.go |
account, lead, opportunity, activity handlers and DTOs |
internal/platform/moduleinstall/moduleinstall.go |
modify: add {Key: "crm"} to Catalog |
internal/app/app.go, cmd/api, cmd/migrate, cmd/worker, cmd/orgadmin |
wiring |
api/openapi.yaml, api/public_routes.txt |
contract |
web/src/features/crm/**, web/src/app/routes.ts, web/src/app/shell/nav.ts |
UI |
web/src/lib/i18n/locales/{en,ms,zh}/crm.json |
strings |
PHASE 1 — Schema, platform package, workflow
Section titled “PHASE 1 — Schema, platform package, workflow”Task 1: Migration range and schema
Section titled “Task 1: Migration range and schema”Files:
- Modify:
internal/kernel/db/migrationranges_test.go(add one row tomigrationRanges) - Create:
db/migrations/01170_crm_schema.sql - Test:
internal/platform/crm/schema_test.go
Interfaces:
-
Produces: tables
crm_account,crm_contact,crm_lead,crm_opportunity,crm_activity, each withid uuid,org_id uuid,created_at,created_by,archived_at. -
Step 1: Read the pattern. Read
db/migrations/00841_leave.sqllines 191-240 (a workflow-governed table:instance_id, composite FK toworkflow_instance (org_id, id), the RLS block) andinternal/kernel/db/retention_test.go(how a table is declared retention-bearing). Note exactly howorg_idreferencesorganizationand the exactCREATE POLICY org_isolationtext. -
Step 2: Write the failing test in
internal/platform/crm/schema_test.go:
package crm_test
import ( "context" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/db/testdb")
var crmTables = []string{"crm_account", "crm_contact", "crm_lead", "crm_opportunity", "crm_activity"}
func TestEveryCRMTableIsTenantIsolated(t *testing.T) { pool := testdb.Start(t) for _, tbl := range crmTables { var rls, force bool var policies int err := pool.Admin.QueryRow(context.Background(), ` SELECT c.relrowsecurity, c.relforcerowsecurity, (SELECT count(*) FROM pg_policies p WHERE p.tablename = c.relname AND p.policyname = 'org_isolation') FROM pg_class c WHERE c.relname = $1 AND c.relkind = 'r'`, tbl).Scan(&rls, &force, &policies) require.NoError(t, err, tbl) // PRESENCE: the table exists at all require.True(t, rls, tbl) require.True(t, force, tbl) require.Equal(t, 1, policies, tbl) }}
func TestNoCRMTableGrantsDelete(t *testing.T) { pool := testdb.Start(t) for _, tbl := range crmTables { var n int require.NoError(t, pool.Admin.QueryRow(context.Background(), ` SELECT count(*) FROM information_schema.role_table_grants WHERE table_name = $1 AND grantee = 'erp_app' AND privilege_type = 'SELECT'`, tbl).Scan(&n)) require.Equal(t, 1, n, "%s: erp_app must hold SELECT (presence)", tbl) require.NoError(t, pool.Admin.QueryRow(context.Background(), ` SELECT count(*) FROM information_schema.role_table_grants WHERE table_name = $1 AND grantee = 'erp_app' AND privilege_type = 'DELETE'`, tbl).Scan(&n)) require.Zero(t, n, tbl) }}Adjust pool.Admin to whatever testdb.Start returns (check internal/kernel/db/testdb); the assertions stay.
- Step 3: Run it to verify it fails
Run: go test ./internal/platform/crm/ -run 'CRMTable' -v
Expected: FAIL, no rows in result set for crm_account.
- Step 4: Add the range to
migrationRanges:
{1170, 1179, "CRM v1 (docs/superpowers/specs/2026-10-08-crm-design.md)"},- Step 5: Write
01170_crm_schema.sql(goose-- +goose Up/Downas the neighbouring files do). Tables, in order (FKs must resolve):
CREATE TABLE crm_account ( id uuid PRIMARY KEY, org_id uuid NOT NULL REFERENCES organization (id), kind text NOT NULL CHECK (kind IN ('company', 'person')), name text NOT NULL CHECK (btrim(name) <> ''), email text, phone text, customer_id uuid, -- optional receivables customer owner_id uuid NOT NULL, -- member created_at timestamptz NOT NULL, created_by uuid NOT NULL, archived_at timestamptz, UNIQUE (org_id, id));CREATE TABLE crm_contact ( id uuid PRIMARY KEY, org_id uuid NOT NULL REFERENCES organization (id), account_id uuid NOT NULL, name text NOT NULL CHECK (btrim(name) <> ''), email text, phone text, title text, created_at timestamptz NOT NULL, created_by uuid NOT NULL, archived_at timestamptz, UNIQUE (org_id, id), FOREIGN KEY (org_id, account_id) REFERENCES crm_account (org_id, id));CREATE TABLE crm_lead ( id uuid PRIMARY KEY, org_id uuid NOT NULL REFERENCES organization (id), name text NOT NULL CHECK (btrim(name) <> ''), email text, phone text, source text NOT NULL DEFAULT 'other', owner_id uuid NOT NULL, instance_id uuid NOT NULL, converted_account_id uuid, converted_contact_id uuid, converted_opportunity_id uuid, created_at timestamptz NOT NULL, created_by uuid NOT NULL, archived_at timestamptz, UNIQUE (org_id, id), FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id), FOREIGN KEY (org_id, converted_account_id) REFERENCES crm_account (org_id, id), FOREIGN KEY (org_id, converted_contact_id) REFERENCES crm_contact (org_id, id));CREATE TABLE crm_opportunity ( id uuid PRIMARY KEY, org_id uuid NOT NULL REFERENCES organization (id), account_id uuid NOT NULL, name text NOT NULL CHECK (btrim(name) <> ''), owner_id uuid NOT NULL, instance_id uuid NOT NULL, amount NUMERIC(19,4) NOT NULL CHECK (amount >= 0), currency text NOT NULL CHECK (currency ~ '^[A-Z]{3}$'), expected_close date, created_at timestamptz NOT NULL, created_by uuid NOT NULL, archived_at timestamptz, UNIQUE (org_id, id), FOREIGN KEY (org_id, account_id) REFERENCES crm_account (org_id, id), FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id));ALTER TABLE crm_lead ADD FOREIGN KEY (org_id, converted_opportunity_id) REFERENCES crm_opportunity (org_id, id);CREATE TABLE crm_activity ( id uuid PRIMARY KEY, org_id uuid NOT NULL REFERENCES organization (id), kind text NOT NULL CHECK (kind IN ('call', 'meeting', 'task', 'note')), subject text NOT NULL CHECK (btrim(subject) <> ''), body text, due_on date, done_at timestamptz, lead_id uuid, opportunity_id uuid, account_id uuid, CHECK (num_nonnulls(lead_id, opportunity_id, account_id) = 1), created_at timestamptz NOT NULL, created_by uuid NOT NULL, FOREIGN KEY (org_id, lead_id) REFERENCES crm_lead (org_id, id), FOREIGN KEY (org_id, opportunity_id) REFERENCES crm_opportunity (org_id, id), FOREIGN KEY (org_id, account_id) REFERENCES crm_account (org_id, id));Then for each of the five tables the RLS block copied verbatim from 00841_leave.sql (ENABLE, FORCE, CREATE POLICY org_isolation), GRANT SELECT, INSERT, UPDATE ON <t> TO erp_app (no DELETE), and indexes (org_id, owner_id) on account/lead/opportunity, (org_id, account_id) on contact/opportunity, (org_id, opportunity_id) and (org_id, lead_id) on activity. If customer_id has a receivables table with (org_id, id) uniqueness, add the composite FK; if not, leave it unconstrained and say so in a SQL comment. Down drops in reverse order.
- Step 6: Run the tests
Run: go test ./internal/platform/crm/ ./internal/kernel/db/ -run 'CRMTable|Migration|RLS|Retention|Tenant' -v
Expected: PASS. If retention_test.go or a tenant-predicate/RLS drift list needs the new tables named, add them there (read the failure message first).
-
Step 7: Watch it fail once. In a scratch copy of the migration drop
FORCE ROW LEVEL SECURITYfromcrm_lead; run Step 6; read the failure; restore. -
Step 8: Commit
git add db/migrations/01170_crm_schema.sql internal/kernel/db/migrationranges_test.go internal/platform/crm/schema_test.gogit commit -m "CRM: schema, RLS, no DELETE grant"Task 2: Workflow graphs
Section titled “Task 2: Workflow graphs”Files:
- Create:
internal/platform/crm/doc.go,internal/platform/crm/graphs.go - Test:
internal/platform/crm/graphs_test.go
Interfaces:
-
Produces:
crm.KindLead = "crm_lead",crm.KindOpportunity = "crm_opportunity",crm.LeadGraph(uuid.UUID) workflow.Graph,crm.OpportunityGraph(uuid.UUID) workflow.Graph. -
Step 1: Read
internal/platform/trade/graphs.go(all of it) andinternal/platform/workflow/validate.go(what the validator requires of the first state and terminals). Findworkflowtest.RequireSeededGraphValid. -
Step 2: Write the failing test:
package crm_test
import ( "testing"
"github.com/google/uuid" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/crm" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow" "github.com/mcom/erp/internal/platform/workflow/workflowtest")
func TestTheGraphsAreValid(t *testing.T) { workflowtest.RequireSeededGraphValid(t, crm.LeadGraph(uuid.Nil)) workflowtest.RequireSeededGraphValid(t, crm.OpportunityGraph(uuid.Nil))}
// CD-3: no CRM state may take a posting semantic.func TestNoCRMStateCanPost(t *testing.T) { for _, g := range []workflow.Graph{crm.LeadGraph(uuid.Nil), crm.OpportunityGraph(uuid.Nil)} { require.NotEmpty(t, g.States) // PRESENCE for _, s := range g.States { require.NotContains(t, []posting.Semantic{posting.Accrual, posting.Payout, posting.Effect}, s.Semantic, s.Key) } }}
func TestAnOpportunityEndsWonOrLost(t *testing.T) { var won, lost bool for _, s := range crm.OpportunityGraph(uuid.Nil).States { if s.IsTerminal && s.Semantic == posting.Custom { won = true } if s.IsTerminal && s.Semantic == posting.Cancelled { lost = true } } require.True(t, won) require.True(t, lost)}- Step 3: Run it to verify it fails
Run: go test ./internal/platform/crm/ -run 'Graph|CanPost|WonOrLost' -v
Expected: FAIL, build error (undefined: crm.LeadGraph).
- Step 4: Write
graphs.go, copying thest/ed/why/lshelpers fromtrade/graphs.go(they are unexported there; duplicate them, three lines each). Register both kinds ininit():
func init() { workflow.RegisterSeededGraph(KindLead, LeadGraph) workflow.RegisterSeededGraph(KindOpportunity, OpportunityGraph)}OpportunityGraph: states prospecting (posting.Draft), qualified, proposal, negotiation (posting.Custom), won (posting.Custom, terminal; R4), lost (posting.Cancelled, terminal); edges forward one step, back one step, any open stage to won, any open stage to lost via why(...) (reason required), all workflow.GateReporter. Labels en/zh/ms for every state and edge. LeadGraph: new (Draft), qualified (Pending), converted (Committed, terminal), disqualified (Cancelled, terminal, reason required). If the validator refuses a non-Draft first state or a Custom mid-state, change the offending state’s semantic to the nearest accepted one (Pending) and record that in the test comment.
- Step 5: Run the tests
Run: go test ./internal/platform/crm/ ./internal/app/ -run 'Graph|CanPost|WonOrLost|SeededGraph' -v
Expected: PASS. internal/app.TestEverySeededGraphPassesTheValidator validates every linked graph; it needs internal/platform/crm linked (done by Task 8’s import; until then it will not see these graphs, which is why Step 2’s own test calls the validator directly).
-
Step 6: Watch it fail once. Change
wontoposting.Accrual; run Step 5; confirmTestNoCRMStateCanPostnames the state; restore. -
Step 7: Commit
git add internal/platform/crmgit commit -m "CRM: lead and opportunity workflow graphs on existing semantics"Task 3: Accounts and contacts
Section titled “Task 3: Accounts and contacts”Files:
- Create:
internal/platform/crm/account.go,internal/platform/crm/errors.go,internal/platform/crm/crmtest/env.go - Test:
internal/platform/crm/account_test.go
Interfaces:
-
Produces:
type Account struct { ID, OrgID uuid.UUID; Kind, Name string; Email, Phone *string; CustomerID *uuid.UUID; OwnerID uuid.UUID; CreatedAt time.Time; ArchivedAt *time.Time }type AccountInput struct { Kind, Name string; Email, Phone *string; OwnerID uuid.UUID }func CreateAccount(ctx context.Context, tx pgx.Tx, c clock.Clock, by uuid.UUID, in AccountInput) (Account, error)func GetAccount(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Account, error)func ListAccounts(ctx context.Context, tx pgx.Tx, f ListFilter) ([]Account, error),type ListFilter struct { Search string; OwnerID *uuid.UUID; IncludeArchived bool; Limit int }func UpdateAccount(ctx, tx, id uuid.UUID, in AccountInput) (Account, error),func ArchiveAccount(ctx, tx, c, id) errorfunc LinkCustomer(ctx, tx, id, customerID uuid.UUID) errorContactandContactInput{AccountID uuid.UUID; Name string; Email, Phone, Title *string}withCreateContact,ListContacts(ctx, tx, accountID),UpdateContact,ArchiveContactvar ErrNotFound;type FieldError struct { Rule string }implementingerrorcrmtest.New(t) *EnvwithEnv.DB,Env.Org,Env.OrgB(a second organization),Env.As(member) context.Context,Env.Staff(a member),Env.Clock
-
Step 1: Read
internal/platform/hr/hrtestand one platformCreate…inplatform/hr/employee.gofor howtxcarries the tenant and howorg_idis read (tenancy). Buildcrmtestthe same way; do not invent a new harness. -
Step 2: Write failing tests:
func TestAnAccountIsCreatedAndRead(t *testing.T) { /* create as Staff; GetAccount returns the same Name, Kind, OwnerID */ }
func TestAnAccountRefusesABlankNameAndAnUnknownKind(t *testing.T) { // FieldError{Rule: "name_required"} and FieldError{Rule: "kind_invalid"}}
// Tenant isolation, presence first.func TestOneOrganizationCannotSeeAnothersAccounts(t *testing.T) { e := crmtest.New(t) idA := createAccountIn(t, e, e.Org, "Acme") require.NotEmpty(t, listIn(t, e, e.Org), "fixture empty; the absence below would be vacuous") require.Empty(t, listIn(t, e, e.OrgB)) _, err := getIn(t, e, e.OrgB, idA) require.ErrorIs(t, err, crm.ErrNotFound)}
func TestAContactMustBelongToAnAccountInTheSameOrganization(t *testing.T) { /* cross-org account_id fails with the FK */ }
func TestArchivedAccountsLeaveTheListUnlessAsked(t *testing.T) { /* presence: listed before archive; absent after; present with IncludeArchived */ }
func TestLinkingACustomerIsRecorded(t *testing.T) { /* LinkCustomer sets customer_id; a second link to a different customer is refused with Rule "customer_already_linked" */ }Write each body in full (the sketch above is the contract; no body may stay a comment).
- Step 3: Run to verify they fail
Run: go test ./internal/platform/crm/ -run 'Account|Contact|Organization' -v
Expected: FAIL, build error (undefined: crm.CreateAccount).
-
Step 4: Implement with plain
tx.Exec/tx.QueryRowSQL against the Task 1 columns.org_idcomes from the tenant setting the same wayhrgets it. Validatename(strings.TrimSpace) andkindbefore the insert and return&FieldError{Rule: ...}. List search isname ILIKE '%'||$n||'%'with the term escaped as the neighbouring list queries escape it. -
Step 5: Run the tests
Run: go test ./internal/platform/crm/ -v
Expected: PASS.
-
Step 6: Watch the isolation test fail once. Temporarily make
GetAccountrun through the admin pool (or disable RLS oncrm_accountin a scratch DB); confirmTestOneOrganizationCannotSeeAnothersAccountsfails; restore. -
Step 7: Commit
git add internal/platform/crmgit commit -m "CRM: accounts and contacts"Task 4: Opportunities and stage transitions
Section titled “Task 4: Opportunities and stage transitions”Files:
- Create:
internal/platform/crm/opportunity.go - Test:
internal/platform/crm/opportunity_test.go
Interfaces:
-
Consumes:
workflow.Start(ctx, tx, c, workflow.StartInput{Kind, SourceID, Reporter, Amount}) (Instance, error),workflow.Transition(ctx, tx, c, workflow.TransitionInput{InstanceID, From, To, Actor, Reason}),workflow.AvailableTransitions(ctx, tx, c, instanceID, who). -
Produces:
type Opportunity struct { ID, OrgID, AccountID, OwnerID, InstanceID uuid.UUID; Name string; Amount money.Amount; Currency string; ExpectedClose *time.Time; State, Semantic string; CreatedAt time.Time; ArchivedAt *time.Time }(State/Semanticread from the instance, not stored)type OpportunityInput struct { AccountID, OwnerID uuid.UUID; Name string; Amount money.Amount; Currency string; ExpectedClose *time.Time }func CreateOpportunity(ctx, tx, c, by uuid.UUID, in OpportunityInput) (Opportunity, error)func GetOpportunity(ctx, tx, id) (Opportunity, error),func ListOpportunities(ctx, tx, f OpportunityFilter) ([]Opportunity, error)withOpportunityFilter{Search string; OwnerID, AccountID *uuid.UUID; Semantic string; Limit int}func UpdateOpportunity(ctx, tx, id, in OpportunityInput) (Opportunity, error)(refused once the stage’s semantic is terminal:Rule "opportunity_closed")func TransitionOpportunity(ctx, tx, c, id uuid.UUID, in workflow.TransitionInput) (Opportunity, error)
-
Step 1: Read
platform/hr/leave_request.golines 330-360 (workflow.Startbeside the row insert) and 455-480 (workflow.Transition), and copy their ordering and error mapping. -
Step 2: Write failing tests:
func TestANewOpportunityStartsInTheFirstStage(t *testing.T) { /* State == "prospecting", Semantic == "draft" */ }func TestAnOpportunityAmountIsExactMoney(t *testing.T) { /* create with "1234567.8901"; read back the identical string; no float round trip */ }func TestAnOpportunityRefusesANegativeAmountAndABadCurrency(t *testing.T) { /* Rule "amount_invalid", "currency_invalid" */ }func TestAnOpportunityMovesThroughItsStagesAndClosesWon(t *testing.T) { /* prospecting→qualified→proposal→won; Semantic "custom" (R4) */ }func TestLosingAnOpportunityNeedsAReason(t *testing.T) { /* Transition with Reason "" is refused; with a reason it ends "lost" (semantic "cancelled") */ }func TestAClosedOpportunityCannotBeEditedOrMoved(t *testing.T) {}func TestWinningAnOpportunityWritesNoJournalLine(t *testing.T) { // PRESENCE: count journal_entry rows after a posting-producing action elsewhere in the same fixture is not required; // instead assert the count is 0 before AND after, and that SELECT count(*) FROM journal_entry succeeds against the real table.}func TestOpportunitiesAreTenantIsolated(t *testing.T) { /* presence in Org, absence in OrgB */ }Write all bodies in full.
- Step 3: Run to verify they fail
Run: go test ./internal/platform/crm/ -run Opportunit -v
Expected: FAIL, build error.
-
Step 4: Implement.
CreateOpportunity: validate, generateid,workflow.StartforKindOpportunitywithReporter: byandAmount: in.Amount, insert the row with the returned instance id, return viaGetOpportunity.GetOpportunityjoinsworkflow_instanceandworkflow_state(i.state_key,s.semantic) asleave_request.goline ~238 does.TransitionOpportunitycallsworkflow.Transitionand re-reads. -
Step 5: Run the tests
Run: go test ./internal/platform/crm/ -v
Expected: PASS.
-
Step 6: Watch it fail once. Change
CreateOpportunityto skip thecurrencycheck; confirmTestAnOpportunityRefusesANegativeAmountAndABadCurrencyfails (the tableCHECKis the second line of defence; the test must name the Go rule). Restore. -
Step 7: Commit
git add internal/platform/crmgit commit -m "CRM: opportunities and stage transitions"Task 5: Leads, conversion and activities
Section titled “Task 5: Leads, conversion and activities”Files:
- Create:
internal/platform/crm/lead.go,internal/platform/crm/activity.go - Test:
internal/platform/crm/lead_test.go,internal/platform/crm/activity_test.go
Interfaces:
-
Consumes: Tasks 3 and 4.
-
Produces:
type Lead struct { ID, OrgID, OwnerID, InstanceID uuid.UUID; Name string; Email, Phone *string; Source string; State, Semantic string; ConvertedAccountID, ConvertedContactID, ConvertedOpportunityID *uuid.UUID; CreatedAt time.Time; ArchivedAt *time.Time }type LeadInput struct { Name string; Email, Phone *string; Source string; OwnerID uuid.UUID }CreateLead,GetLead,ListLeads(ctx, tx, f LeadFilter),UpdateLead,TransitionLead(ctx, tx, c, id, in workflow.TransitionInput)type ConvertInput struct { AccountName string; AccountKind string; CreateOpportunity *OpportunityInput }func ConvertLead(ctx, tx, c clock.Clock, by uuid.UUID, id uuid.UUID, in ConvertInput) (Lead, error): one transaction creates the account, a contact from the lead’s name/email/phone, optionally an opportunity, moves the lead toconverted, and records the three ids on the leadtype Activity struct { ID uuid.UUID; Kind, Subject string; Body *string; DueOn *time.Time; DoneAt *time.Time; LeadID, OpportunityID, AccountID *uuid.UUID; CreatedAt time.Time; CreatedBy uuid.UUID },CreateActivity(ctx, tx, c, by, in ActivityInput),ListActivities(ctx, tx, subject ActivitySubject),CompleteActivity(ctx, tx, c, id)
-
Step 1: Write failing tests:
func TestConvertingALeadCreatesAccountContactAndLinksThem(t *testing.T) { /* all three converted_* ids set; lead State "converted"; contact belongs to the account */ }func TestConvertingWithAnOpportunityCreatesItInTheFirstStage(t *testing.T) {}func TestAConvertedLeadCannotBeConvertedTwice(t *testing.T) { /* Rule "lead_already_converted"; account count unchanged (presence: 1 before) */ }func TestAFailedConversionLeavesNothingBehind(t *testing.T) { /* OpportunityInput with a bad currency: the whole tx fails; no account, contact or state change (presence: the lead exists, state "new") */ }func TestADisqualifiedLeadNeedsAReason(t *testing.T) {}func TestAnActivityAttachesToExactlyOneSubject(t *testing.T) { /* zero or two subjects → Rule "subject_required" */ }func TestActivitiesListNewestFirstAndAreTenantIsolated(t *testing.T) {}Write every body in full.
-
Step 2: Run to verify they fail —
go test ./internal/platform/crm/ -run 'Lead|Convert|Activit' -v— Expected: FAIL, build error. -
Step 3: Implement reusing
CreateAccount,CreateContact,CreateOpportunityinside the caller’stx(conversion is not its own transaction, so a failure rolls back with the request). Lead transitions useworkflow.Transition;ConvertLeadmoves the lead withActor: byand edgequalified→converted— if the lead is stillnew, convert refuses withRule "lead_not_qualified". -
Step 4: Run
go test ./internal/platform/crm/... -v— Expected: PASS. -
Step 5: Watch
TestAFailedConversionLeavesNothingBehindfail once by committing the account in a separatetx; confirm it fails; restore. -
Step 6: Commit —
git add internal/platform/crm && git commit -m "CRM: leads, conversion, activities"
PHASE 2 — API and module
Section titled “PHASE 2 — API and module”Task 6: Module switch, permissions, roles
Section titled “Task 6: Module switch, permissions, roles”Files:
- Modify:
internal/platform/moduleinstall/moduleinstall.go(append{Key: "crm"}toCatalog),internal/platform/moduleinstall/moduleinstall_test.go - Create:
internal/modules/crm/permissions.go,db/migrations/01171_crm_module_roles.sql - Test:
internal/modules/crm/grants_test.go
Interfaces:
-
Produces permission constants (first segment must be
crm, which is the module key):PermissionAccountRead = "crm.account.read",PermissionAccountCreate = "crm.account.create",PermissionAccountEdit = "crm.account.edit",PermissionLeadRead,PermissionLeadCreate,PermissionLeadEdit,PermissionLeadTransition,PermissionOpportunityRead,PermissionOpportunityCreate,PermissionOpportunityEdit,PermissionOpportunityTransition,PermissionActivityCreate(same naming scheme). -
Step 1: Read
internal/modules/leave/permissions.go,internal/modules/leave/grants_test.go(all) anddb/migrations/00842_leave_grants.sql. -
Step 2: Write failing tests in
grants_test.go: (a) every constant is registered withmember.RegisterPermission(assert via the registry the leave test uses); (b) a new organization’sAdminrole holds all twelve keys andEmployeeholds none (presence: Admin holds them, so the empty Employee set is not vacuous); (c) with the module off,member.Permissionsreturns none of the keys, and aftermoduleinstallswitches it on it returns them. -
Step 3: Run to verify they fail —
go test ./internal/modules/crm/ ./internal/platform/moduleinstall/ -v— Expected: FAIL (package does not build). -
Step 4: Implement.
permissions.gofollows the leave file:init()registers every key with a one-line description and callsprovisioning.RegisterRolePermissions("Admin", ...)for all twelve.01171inserts the same keys for the existingAdminroles, copying00842’s statement. Add{Key: "crm"}toCatalogand fix any catalog-count assertion inmoduleinstall_test.go. -
Step 5: Run the same command — Expected: PASS. Then
go test ./internal/kernel/db/ -run Migration -v. -
Step 6: Watch (b) fail once by removing one
RegisterRolePermissionscall; restore. -
Step 7: Commit —
git commit -m "CRM: module switch, permissions, role grants"(add the files above).
Task 7: Contract: OpenAPI and handlers
Section titled “Task 7: Contract: OpenAPI and handlers”Files:
- Modify:
api/openapi.yaml,api/public_routes.txt(only if a route is deliberately permission-less; the plan has none) - Create:
internal/modules/crm/routes.go,account_handlers.go,lead_handlers.go,opportunity_handlers.go,activity_handlers.go,dto.go - Test:
internal/modules/crm/routes_test.go,internal/modules/crm/handlers_test.go
Interfaces:
- Consumes: every
platform/crmfunction from Tasks 3-5;httpx.Protected(g, pl, method, path, permission, fn); thepermissionLoaderadapter (copy it frominternal/modules/leave/routes.go). - Produces:
crm.Deps{DB *db.DB; Clock clock.Clock},crm.Register(g *gin.RouterGroup, deps Deps), and these routes (exact permission per route):
| Route | Permission |
|---|---|
GET /crm/accounts, GET /crm/accounts/:id, GET /crm/accounts/:id/contacts |
PermissionAccountRead |
POST /crm/accounts, POST /crm/accounts/:id/contacts |
PermissionAccountCreate |
PUT /crm/accounts/:id, PUT /crm/contacts/:id, POST /crm/accounts/:id/archive, POST /crm/accounts/:id/customer (link) |
PermissionAccountEdit |
GET /crm/leads, GET /crm/leads/:id |
PermissionLeadRead |
POST /crm/leads |
PermissionLeadCreate |
PUT /crm/leads/:id |
PermissionLeadEdit |
POST /crm/leads/:id/transitions, POST /crm/leads/:id/convert |
PermissionLeadTransition |
GET /crm/opportunities, GET /crm/opportunities/:id |
PermissionOpportunityRead |
POST /crm/opportunities |
PermissionOpportunityCreate |
PUT /crm/opportunities/:id |
PermissionOpportunityEdit |
POST /crm/opportunities/:id/transitions |
PermissionOpportunityTransition |
GET /crm/activities?lead_id=&opportunity_id=&account_id= |
the read key of the subject kind |
POST /crm/activities, POST /crm/activities/:id/complete |
PermissionActivityCreate |
-
Step 1: Read
api/openapi.yamlfrom/leave/requests(line ~15788) for how a list, a create and a transitions sub-resource are declared, andinternal/modules/leave/request_handlers.gocreateRequest/transitionRequest/inTxfor the handler shape (tenant tx,apperrmapping,FieldError→ 422 with a code). Note how the generatedapi.gen.gois used by the leave handlers; do the same. -
Step 2: Declare the endpoints and schemas in
api/openapi.yaml, then runmake generate. Money fields aretype: string. Every list response is{items: [...]}plus the cursor/limit parameters the neighbouring lists use. EachFieldError.Rulethe platform package returns appears as an error code in the spec. -
Step 3: Write the failing route test, copying
leave/routes_test.go: it registers withcrm.Deps{}and asserts the exact route→permission map above withrequire.Equal. A route registered under the wrong key must fail it. -
Step 4: Write failing handler tests (use the harness
leave/handlers_test.gouses): create account → 201; create with blank name → 422 with the rule code; member withoutcrm.account.read→ 403 onGET /crm/accounts(presence: the same call with the key → 200); module off →module_not_installed;POST /crm/leads/:id/convert→ account, contact, opportunity exist; transitionlostwithout a reason → 422; org B cannotGETorg A’s opportunity (presence: org A can → 200; org B → 404). -
Step 5: Run to verify they fail —
go test ./internal/modules/crm/ -v— Expected: FAIL (crm.Registerundefined). -
Step 6: Implement handlers. No handler reads an organization from a request header (drift test). Every handler body runs in
inTxlike the leave handlers. -
Step 7: Run
go test ./internal/modules/crm/ ./internal/platform/crm/... -vandmake generate && git diff --exit-code internal/gen web/src/lib/api/schema.d.ts— Expected: PASS and no diff. -
Step 8: Watch the route test fail once by registering
PUT /crm/accounts/:idunderPermissionAccountRead; read the diff it prints; restore. -
Step 9: Commit —
git commit -m "CRM: API contract and handlers"
Task 8: Wiring and the cross-cutting drift tests
Section titled “Task 8: Wiring and the cross-cutting drift tests”Files:
-
Modify:
internal/app/app.go(addCRM crm.Depsto the deps struct andcrm.Register(optional("crm"), deps.CRM)besideleave.Register),cmd/api/main.go(buildcrm.DepslikeLeave),cmd/migrate/main.go,cmd/worker/main.go,cmd/orgadmin/main.go(blank import_ "github.com/mcom/erp/internal/modules/crm"asleavehas), andwiring_test.goin the three cmd packages if they list modules. -
Test:
internal/app/routes_test.go(existing),internal/platform/crm/receivables_boundary_test.go -
Step 1: Write the failing boundary test: parse
internal/platform/crmandinternal/modules/crmimports and assert that neither importsinternal/modules/receivables/internalnor anyinternal/platform/receivablespackage other than via the contract the spec allows (if receivables has nocontract.goyet, the account→customer link in v1 storescustomer_idonly and the test asserts there is no receivables import at all). Assert it parsed a non-zero number of files first. -
Step 2: Run to verify it passes on the clean tree and watch it fail by adding a stray
receivablesimport toaccount.go; restore. -
Step 3: Wire the five files above exactly as
leaveis wired. -
Step 4: Run the integration drift tests
Run: go test ./internal/app/ ./cmd/... ./internal/kernel/... ./internal/platform/workflow/... -p 1
Expected: PASS: every route behind httpx.Authorize, TestEverySeededGraphPassesTheValidator now sees the two CRM graphs, migration ranges, tenant isolation, generated-code and env-var drift tests all green. Read each failure message before changing anything; a failing drift test names the file to fix.
- Step 5: Commit —
git commit -m "CRM: wire the module into the app, migrate, worker and orgadmin"
PHASE 3 — UI and translations
Section titled “PHASE 3 — UI and translations”Task 9: Frontend
Section titled “Task 9: Frontend”Files:
- Create:
web/src/features/crm/routes.tsx,api.ts,AccountsPage.tsx,AccountDrawer.tsx,LeadsPage.tsx,ConvertLeadDialog.tsx,PipelinePage.tsx,OpportunityDrawer.tsx,ActivityTimeline.tsx,__tests__/*.test.tsx - Modify:
web/src/app/routes.ts(spreadcrmRoutesbesideleaveRoutes),web/src/app/shell/nav.ts(entries gated oncrm.account.read,crm.lead.read,crm.opportunity.read),web/src/lib/api/schema.d.ts(regenerated) - Create:
web/src/lib/i18n/locales/{en,ms,zh}/crm.json
Interfaces:
-
Consumes: the generated client types for the Task 7 endpoints.
-
Produces: routes
/crm/accounts,/crm/leads,/crm/pipeline. -
Step 1: Read
web/src/features/leave/{routes.tsx,api.ts,MyLeavePage.tsx,RequestDrawer.tsx}and one__tests__file; read hownav.tsentries usepermission:and howi18nnamespaces are registered (find whereleaveis registered and addcrmin the same place). Read the Drawer and list components the leave pages import and reuse them; do not add a component library. -
Step 2: Write failing vitest tests: (a) the pipeline renders one column per workflow state returned by the API, ordered by display order, with names from the state labels, not hard-coded (feed it a graph with a renamed stage and assert the new name shows; presence: the default name does not); (b) dragging or “Move to” on a card calls the transitions endpoint with the edge’s
to; moving tolostopens a reason dialog and does not call the API with an empty reason; (c) money displays through the existing money formatter from the string, neverNumber(...); (d) the nav entry is absent without the permission and present with it; (e) every key used by the components exists in en, ms and zhcrm.json(a key-parity test in the style of the existing i18n parity test). -
Step 3: Run to verify they fail —
cd web && npx vitest run src/features/crm— Expected: FAIL (modules not found). -
Step 4: Implement the pages. Kanban: columns from the opportunity’s available transitions and the definition’s states (see how
view()inleave/request_handlers.goreturnsAvailablewithToSemantic; expose the same on the opportunity DTO in Task 7 if Task 7 left it out, and add it to that task’s handler test). Lists use the server-side search/sort/filter pattern from2026-10-06-list-search-sort-filter.md. Write en strings; write ms and zh strings and mark them in the review CSV the waydocs/review/*.csvexpects. -
Step 5: Run
cd web && npx vitest run && npx tsc --noEmit && npm run lint— Expected: PASS. -
Step 6: Verify in a browser (AGENTS.md: do not stop at “build passes”). Start the local stack (
scripts/dev-stack.sh), confirm the CRM module is on at Manage → Modules (it is on by default, R6), re-run provisioning so the CRM workflow definitions exist (R8), then create an account, a lead, convert it with an opportunity, drag the opportunity to Won, and try to move another to Lost without a reason. Check en, ms and zh, and 375px width. Record what was and was not checked. -
Step 7: Commit —
git commit -m "CRM: accounts, leads and pipeline UI with en/ms/zh strings"
Task 10: Close out
Section titled “Task 10: Close out”Files:
-
Modify:
docs/ROADMAP.md -
Step 1: Full suite once:
go test ./... -p 1andcd web && npx vitest run. Expected: PASS. Report any failure with its output; do not claim green otherwise. -
Step 2: Tick the CRM v1 items in
docs/ROADMAP.mdthat are actually done and verified; leave the ms/zh native review and any browser check not performed unticked, with a one-line note each. -
Step 3: Commit —
git commit -m "CRM v1: roadmap"
Self-Review
Section titled “Self-Review”Spec coverage: section 1 scope → Tasks 3-5; section 2 CD-1/2/3 → Tasks 3 (own tables, optional customer_id), 2 (existing semantics); section 3 layout → file structure and Task 8 boundary test; section 4 data → Task 1; section 5 workflow → Task 2, 4, 5; section 6 conversion → Task 5; section 7 module switch and permissions → Task 6; section 8 API and frontend → Tasks 7, 9; section 9 tests → every task; section 10 phases → Phases 1-3.
Known gaps, stated rather than hidden:
crm_account.customer_idhas no foreign key unless receivables exposes(org_id, id)uniqueness; Task 1 Step 5 checks this and records the outcome.- Creating a receivables customer from an account is not in this plan: the spec calls it an explicit later step through receivables’
contract.go, and Task 8’s boundary test enforces that v1 has no receivables import.POST /crm/accounts/:id/customerlinks an existing customer only. - Phase 2 and 3 steps give routes, interfaces and test contracts rather than every line of handler and component code; the executor must read the named leave files first, as each task’s Step 1 says.
- Contact data retention/erasure rules (spec section 11) are unresolved; v1 archives and never deletes.