Skip to content

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 terminal custom (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.

  • A float never touches money: money.Amount in Go, NUMERIC(19,4) in Postgres, a string on the wire.
  • Every table has org_id, ENABLE and FORCE ROW LEVEL SECURITY, and a policy named org_isolation (same text as db/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 = terminal custom (ruling R4: committed is restricted to document kinds), Lost = cancelled. Nothing reads a state name.
  • Dependencies point downward only: modules/crm → platform/crm → kernel. modules/crm reaches receivables only through its contract.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 DELETE to erp_app; records are archived (archived_at).
  • Migrations are numbered 01170-01179 (add the range to migrationranges_test.go in the same commit as the first migration).
  • Endpoints are declared in api/openapi.yaml first; make generate must 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 1 once before the final commit. Database tests use testdb.Start.
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”

Files:

  • Modify: internal/kernel/db/migrationranges_test.go (add one row to migrationRanges)
  • 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 with id uuid, org_id uuid, created_at, created_by, archived_at.

  • Step 1: Read the pattern. Read db/migrations/00841_leave.sql lines 191-240 (a workflow-governed table: instance_id, composite FK to workflow_instance (org_id, id), the RLS block) and internal/kernel/db/retention_test.go (how a table is declared retention-bearing). Note exactly how org_id references organization and the exact CREATE POLICY org_isolation text.

  • 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 / Down as 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 SECURITY from crm_lead; run Step 6; read the failure; restore.

  • Step 8: Commit

Terminal window
git add db/migrations/01170_crm_schema.sql internal/kernel/db/migrationranges_test.go internal/platform/crm/schema_test.go
git commit -m "CRM: schema, RLS, no DELETE grant"

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) and internal/platform/workflow/validate.go (what the validator requires of the first state and terminals). Find workflowtest.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 the st/ed/why/ls helpers from trade/graphs.go (they are unexported there; duplicate them, three lines each). Register both kinds in init():
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 won to posting.Accrual; run Step 5; confirm TestNoCRMStateCanPost names the state; restore.

  • Step 7: Commit

Terminal window
git add internal/platform/crm
git commit -m "CRM: lead and opportunity workflow graphs on existing semantics"

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) error
    • func LinkCustomer(ctx, tx, id, customerID uuid.UUID) error
    • Contact and ContactInput{AccountID uuid.UUID; Name string; Email, Phone, Title *string} with CreateContact, ListContacts(ctx, tx, accountID), UpdateContact, ArchiveContact
    • var ErrNotFound; type FieldError struct { Rule string } implementing error
    • crmtest.New(t) *Env with Env.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/hrtest and one platform Create… in platform/hr/employee.go for how tx carries the tenant and how org_id is read (tenancy). Build crmtest the 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.QueryRow SQL against the Task 1 columns. org_id comes from the tenant setting the same way hr gets it. Validate name (strings.TrimSpace) and kind before the insert and return &FieldError{Rule: ...}. List search is name 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 GetAccount run through the admin pool (or disable RLS on crm_account in a scratch DB); confirm TestOneOrganizationCannotSeeAnothersAccounts fails; restore.

  • Step 7: Commit

Terminal window
git add internal/platform/crm
git 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/Semantic read 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) with OpportunityFilter{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.go lines 330-360 (workflow.Start beside 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, generate id, workflow.Start for KindOpportunity with Reporter: by and Amount: in.Amount, insert the row with the returned instance id, return via GetOpportunity. GetOpportunity joins workflow_instance and workflow_state (i.state_key, s.semantic) as leave_request.go line ~238 does. TransitionOpportunity calls workflow.Transition and re-reads.

  • Step 5: Run the tests

Run: go test ./internal/platform/crm/ -v Expected: PASS.

  • Step 6: Watch it fail once. Change CreateOpportunity to skip the currency check; confirm TestAnOpportunityRefusesANegativeAmountAndABadCurrency fails (the table CHECK is the second line of defence; the test must name the Go rule). Restore.

  • Step 7: Commit

Terminal window
git add internal/platform/crm
git commit -m "CRM: opportunities and stage transitions"

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 to converted, and records the three ids on the lead
    • type 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, CreateOpportunity inside the caller’s tx (conversion is not its own transaction, so a failure rolls back with the request). Lead transitions use workflow.Transition; ConvertLead moves the lead with Actor: by and edge qualified→converted — if the lead is still new, convert refuses with Rule "lead_not_qualified".

  • Step 4: Run go test ./internal/platform/crm/... -v — Expected: PASS.

  • Step 5: Watch TestAFailedConversionLeavesNothingBehind fail once by committing the account in a separate tx; confirm it fails; restore.

  • Step 6: Commit — git add internal/platform/crm && git commit -m "CRM: leads, conversion, activities"


Files:

  • Modify: internal/platform/moduleinstall/moduleinstall.go (append {Key: "crm"} to Catalog), 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) and db/migrations/00842_leave_grants.sql.

  • Step 2: Write failing tests in grants_test.go: (a) every constant is registered with member.RegisterPermission (assert via the registry the leave test uses); (b) a new organization’s Admin role holds all twelve keys and Employee holds none (presence: Admin holds them, so the empty Employee set is not vacuous); (c) with the module off, member.Permissions returns none of the keys, and after moduleinstall switches 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.go follows the leave file: init() registers every key with a one-line description and calls provisioning.RegisterRolePermissions("Admin", ...) for all twelve. 01171 inserts the same keys for the existing Admin roles, copying 00842’s statement. Add {Key: "crm"} to Catalog and fix any catalog-count assertion in moduleinstall_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 RegisterRolePermissions call; restore.

  • Step 7: Commit — git commit -m "CRM: module switch, permissions, role grants" (add the files above).

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/crm function from Tasks 3-5; httpx.Protected(g, pl, method, path, permission, fn); the permissionLoader adapter (copy it from internal/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.yaml from /leave/requests (line ~15788) for how a list, a create and a transitions sub-resource are declared, and internal/modules/leave/request_handlers.go createRequest/transitionRequest/inTx for the handler shape (tenant tx, apperr mapping, FieldError → 422 with a code). Note how the generated api.gen.go is used by the leave handlers; do the same.

  • Step 2: Declare the endpoints and schemas in api/openapi.yaml, then run make generate. Money fields are type: string. Every list response is {items: [...]} plus the cursor/limit parameters the neighbouring lists use. Each FieldError.Rule the 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 with crm.Deps{} and asserts the exact route→permission map above with require.Equal. A route registered under the wrong key must fail it.

  • Step 4: Write failing handler tests (use the harness leave/handlers_test.go uses): create account → 201; create with blank name → 422 with the rule code; member without crm.account.read → 403 on GET /crm/accounts (presence: the same call with the key → 200); module off → module_not_installed; POST /crm/leads/:id/convert → account, contact, opportunity exist; transition lost without a reason → 422; org B cannot GET org 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.Register undefined).

  • Step 6: Implement handlers. No handler reads an organization from a request header (drift test). Every handler body runs in inTx like the leave handlers.

  • Step 7: Run go test ./internal/modules/crm/ ./internal/platform/crm/... -v and make 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/:id under PermissionAccountRead; 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 (add CRM crm.Deps to the deps struct and crm.Register(optional("crm"), deps.CRM) beside leave.Register), cmd/api/main.go (build crm.Deps like Leave), cmd/migrate/main.go, cmd/worker/main.go, cmd/orgadmin/main.go (blank import _ "github.com/mcom/erp/internal/modules/crm" as leave has), and wiring_test.go in 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/crm and internal/modules/crm imports and assert that neither imports internal/modules/receivables/internal nor any internal/platform/receivables package other than via the contract the spec allows (if receivables has no contract.go yet, the account→customer link in v1 stores customer_id only 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 receivables import to account.go; restore.

  • Step 3: Wire the five files above exactly as leave is 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"

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 (spread crmRoutes beside leaveRoutes), web/src/app/shell/nav.ts (entries gated on crm.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 how nav.ts entries use permission: and how i18n namespaces are registered (find where leave is registered and add crm in 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 to lost opens a reason dialog and does not call the API with an empty reason; (c) money displays through the existing money formatter from the string, never Number(...); (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 zh crm.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() in leave/request_handlers.go returns Available with ToSemantic; 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 from 2026-10-06-list-search-sort-filter.md. Write en strings; write ms and zh strings and mark them in the review CSV the way docs/review/*.csv expects.

  • 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"

Files:

  • Modify: docs/ROADMAP.md

  • Step 1: Full suite once: go test ./... -p 1 and cd 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.md that 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"


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_id has 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/customer links 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.