# Sales & customer relationships — module spec (generated from modules/crm/module.yaml)
# A starting vocabulary for phase 2: correct it with the customer, then write ERP_CRM.md.
schema: dodil.module-spec/v1
id: crm
name: Sales & customer relationships
short: CRM
version: 1
maturity: tested
summary: Who your customers are, what you are trying to sell them, and where each deal stands — shared by the whole sales team instead of living in one person's spreadsheet or inbox.
facets:
  domain: sales
  industries:
    - software
    - manufacturing
    - finserv
    - real-estate
  customer_words:
    - track our customers
    - a sales pipeline
    - stop losing leads
    - who is talking to which client
    - quotes and discounts
    - an app for my sales team
  replaces:
    - spreadsheets
    - shared inbox
    - HubSpot
    - Salesforce
    - Pipedrive
  pillars:
    - sql
    - graph
    - vector
  first_module_fit: high
  depends_on:
    - module: business-partner
      why: the customer record is shared with invoicing and purchasing; CRM references it
      required: recommended
  feeds:
    - ar
    - gl
  owns_master_data: []
  references_master_data:
    - business_partner
    - product
    - employee
actors:
  - id: rep
    name: Sales rep
    does: works their own accounts, leads and deals; drafts quotes
    sees: only what they own
    permissions:
      - crm:account:read
      - crm:account:write
      - crm:lead:write
      - crm:opportunity:write
      - crm:quote:draft
      - crm:task:write
  - id: manager
    name: Sales manager
    does: sees the whole team's book, reassigns work, owns the forecast
    sees: everything in the team
    permissions:
      - crm:*:read
      - crm:account:write
      - crm:opportunity:assign
      - crm:forecast:override
      - crm:pipeline_config:write
  - id: deal_desk
    name: Deal desk / commercial director
    does: approves quotes above the discount threshold, sets the discount policy
    sees: every quote
    permissions:
      - crm:quote:approve
      - crm:discount_policy:write
  - id: marketing
    name: Marketing
    does: runs campaigns, imports lists, reads attribution
    sees: campaigns, leads, attribution — not deal amounts unless granted
    permissions:
      - crm:campaign:write
      - crm:lead:import
      - crm:attribution:read
  - id: system
    name: The system
    does: scheduled and event-driven work — scoring, forecasting, sequences, rollups
    sees: everything, through its own service account
    permissions: []
stories:
  - id: CRM-01
    kind: person
    actor: rep
    story: As a sales rep, I want to see only the accounts and deals I own, so that my book is not buried under everyone else's.
    given: reps Tom and Sofia each own accounts
    when: Tom opens Accounts, or opens one of Sofia's accounts by URL
    then: he sees only his own; Sofia's returns not found (not forbidden, which would confirm it exists)
    screen: Accounts
    reference: partial — crm-suite-app ships the owner_scope helper; wiring it into each owned read is a build step, and many customers choose 'everyone sees everything' instead
    customer_varies: whether rows are owned at all — ask, and record the answer
  - id: CRM-02
    kind: person
    actor: rep
    story: As a sales rep, I want to log a call or email against a contact in one step, so that the next person who picks up the account knows what was said.
    given: a contact on an account I own
    when: I log an activity with a subject and a note
    then: it appears on the contact, the account and the open deal, newest first
    screen: Account
    reference: implemented
  - id: CRM-03
    kind: person
    actor: rep
    story: As a sales rep, I want to turn a qualified lead into an account, contact and opportunity at once, so that I do not re-type the same company three times.
    given: a lead with a company domain
    when: I convert it
    then: one account (reused if the domain already exists), one contact, one opportunity in the first stage; converting again changes nothing
    screen: Lead
    reference: implemented
  - id: CRM-04
    kind: person
    actor: rep
    story: As a sales rep, I want to move a deal through our stages, so that the pipeline shows where it really is.
    given: an open opportunity in "discovery"
    when: I move it to "proposal"
    then: its probability and forecast category follow the stage definition, and the change is visible to my manager immediately
    screen: Pipeline board
    reference: implemented
    customer_varies: the stage names, their order and their probabilities are the most commonly changed thing in a CRM
  - id: CRM-05
    kind: person
    actor: rep
    story: As a sales rep, I want to build a quote from our price book with line discounts, so that the customer gets a consistent price.
    given: an opportunity and an active price book
    when: I add three products with a 5% discount on one
    then: the quote totals are computed by the system, not typed, and the quote is a draft
    screen: Quote builder
    reference: implemented
  - id: CRM-06
    kind: person
    actor: deal_desk
    story: As the commercial director, I want quotes above our discount threshold routed to me, so that margin is protected without chasing approvals by email.
    given: a threshold of 15% and a quote with an 18% effective discount
    when: the rep submits it
    then: it is needs_approval, it appears in my queue, and the rep cannot approve it; above the maximum discount it is refused outright
    screen: Approvals
    reference: implemented
    customer_varies: the thresholds, and whether approval is one level or a chain (manager, then director)
  - id: CRM-07
    kind: person
    actor: rep
    story: As a sales rep, I want an approved quote to be locked, so that what the customer signed is what finance invoices.
    given: an approved quote
    when: anyone edits a line
    then: the edit is refused with a reason, and a new revision must be created instead
    screen: Quote builder
    reference: implemented
  - id: CRM-08
    kind: person
    actor: manager
    story: As a sales manager, I want to see every open deal across a corporate group on one screen, so that a discount given to one subsidiary is not quoted back to another.
    given: three subsidiaries of one group owned by different reps
    when: I open any one of them
    then: I see all three sites' pipeline and past discounts
    screen: Account
    reference: implemented
  - id: CRM-09
    kind: person
    actor: manager
    story: As a sales manager, I want a weighted forecast by rep and by period, so that I can commit a number to leadership.
    given: open deals with stages and close dates
    when: I open the forecast for this quarter
    then: I see open, weighted and committed amounts per rep, and I can override a category with a reason
    screen: Forecast
    reference: implemented
  - id: CRM-12
    kind: person
    actor: rep
    story: As a sales rep, I want my tasks grouped into overdue, today and this week, so that I start the day knowing what is late.
    given: tasks assigned to me with due dates
    when: I open Home
    then: I see counts and lists for overdue, today and this week
    screen: Home
    reference: implemented
  - id: CRM-13
    kind: person
    actor: marketing
    story: As a marketer, I want to import a list of contacts from a spreadsheet, so that a trade-show list becomes leads the same day.
    given: a CSV with name, email and company
    when: I import it twice
    then: each person exists once; rows with no email are reported, not silently dropped
    screen: Accounts
    reference: implemented
  - id: CRM-14
    kind: person
    actor: rep
    story: As a sales rep, I want to find customers similar to one we just won, so that I know who to call next.
    given: a won account
    when: I ask for lookalikes
    then: I get a ranked list of accounts I can see, excluding existing customers
    screen: Account
    reference: implemented
  - id: CRM-10
    kind: system
    actor: system
    trigger: every 6 hours
    story: The system re-scores open leads against the current scoring rules, so that the call list reflects this morning's activity rather than last week's.
    given: a lead that opened a pricing email an hour ago
    when: the next run completes
    then: its score reflects it, the run is recorded, and running it twice changes nothing
    reference: route exists (POST /leads/{id}/score); the schedule does not — Ignite has no scheduler
  - id: CRM-11
    kind: system
    actor: system
    trigger: when a quote is approved or rejected
    story: The system notifies the rep who submitted it, so that nobody refreshes a queue waiting.
    given: a quote in needs_approval
    when: the director approves it
    then: the submitting rep is told, once
    reference: not implemented — no notification channel in the reference
  - id: CRM-15
    kind: system
    actor: system
    trigger: nightly
    story: The system snapshots the forecast, so that "what did we think on the 1st" can be answered at quarter end.
    given: open pipeline
    when: the nightly run completes
    then: one snapshot per rep, period and category exists for that date; a re-run replaces it rather than doubling it
    reference: route exists (POST /forecast/recompute); the schedule does not
  - id: CRM-16
    kind: system
    actor: system
    trigger: nightly
    story: The system flags deals that have gone quiet, so that a manager sees risk before the close date passes.
    given: a deal with no activity for 21 days in a late stage
    when: the nightly run completes
    then: it is marked at risk with the reason
    reference: route exists (POST /opportunities/{id}/risk); the rule and schedule are yours
  - id: CRM-17
    kind: system
    actor: system
    trigger: when a contact is enrolled in a sequence, then on each step's delay
    story: The system sends the next step of an outreach sequence when it is due, and stops when the contact replies.
    given: a contact enrolled in a three-step sequence
    when: step one's delay elapses
    then: step one is sent once; a reply stops the enrollment
    reference: implemented as a polled engine — needs an always-on worker or an external clock
  - id: CRM-20
    kind: integration
    actor: system
    trigger: when a quote is accepted
    story: The system creates a draft invoice in Finance for the same customer and amount, so that nothing is re-keyed.
    given: an accepted quote
    when: Finance opens drafts
    then: the invoice references the quote and the same business partner; posting it is refused if the period is closed
    owner: ar
    reference: not implemented — requires the AR module
  - id: CRM-21
    kind: integration
    actor: rep
    trigger: when a rep opens an account
    story: As a sales rep, I want to see whether the customer has overdue invoices, so that I do not offer a discount to someone who has not paid.
    given: a customer with an invoice 40 days past due
    when: I open the account
    then: I see the overdue amount (read-only), and the quote builder warns me
    owner: ar
    reference: "not implemented — and the join does not exist yet either: GL's subledger_items carries a free-text party, not bp_id, so either it gains bp_id or invoicing serves this read from its own route (see ar AR-12)"
  - id: CRM-22
    kind: integration
    actor: rep
    trigger: when a new company is created
    story: The system creates the company once in the shared customer master, so that finance and sales mean the same customer.
    given: business-partner is installed
    when: a rep converts a lead for a new domain
    then: a business partner exists (created or reused), and the CRM account points at it
    owner: business-partner
    reference: implemented (POST /partners/promote)
workflows:
  - entity: lead
    states:
      - new
      - working
      - qualified
      - disqualified
      - converted
    transitions:
      - from: new
        to: working
        by: rep
      - from: working
        to: qualified
        by:
          - rep
          - system
        gate: score >= policy.threshold_qualify
      - from:
          - new
          - working
        to: disqualified
        by: rep
        requires: reason
      - from: qualified
        to: converted
        by: rep
        effect: creates account + contact + opportunity (CRM-03)
  - entity: opportunity
    states: defined per customer in pipeline_stages (name, order, probability, forecast category, won/closed flags)
    transitions:
      - from: any open
        to: any open
        by:
          - rep
          - manager
      - from: any open
        to: closed_won
        by: rep
        effect: "customer_varies: often creates the order/invoice (CRM-20)"
      - from: any open
        to: closed_lost
        by: rep
        requires: loss_reason
  - entity: quote
    states:
      - draft
      - needs_approval
      - approved
      - rejected
      - accepted
      - expired
    transitions:
      - from: draft
        to: approved
        by: system
        gate: effective discount <= approval_threshold_pct
      - from: draft
        to: needs_approval
        by: system
        gate: approval_threshold_pct < discount <= max_discount_pct
      - from: draft
        to: rejected
        by: system
        gate: discount > max_discount_pct
      - from: needs_approval
        to:
          - approved
          - rejected
        by: deal_desk
      - from: approved
        to: accepted
        by: rep
        effect: locked (CRM-07); triggers CRM-20
      - from:
          - draft
          - approved
        to: expired
        by: system
        gate: valid_until passed
  - entity: task
    states:
      - open
      - done
      - cancelled
    transitions:
      - from: open
        to:
          - done
          - cancelled
        by:
          - assignee
          - manager
      - from:
          - done
          - cancelled
        to: open
        by:
          - assignee
          - manager
entities:
  - id: account
    meaning: a company you sell to (one row per site/legal entity)
    natural_key: org_domain in the reference; bp_id when business-partner is installed
    key_attributes:
      - name
      - parent
      - tier
      - country
      - industry
      - owner
    relationships:
      - subsidiary_of -> account (typed edge)
      - 1..n contact
      - 1..n opportunity
    master_data: references business_partner
    reference_table: crm_accounts
  - id: contact
    meaning: a person at an account
    natural_key: email
    key_attributes:
      - full_name
      - title
      - lifecycle_stage
      - owner
      - subscribed
    relationships:
      - -> account
      - 1..n activity
    master_data: module-owned
    reference_table: contacts
    recommended_table: crm_contacts
  - id: lead
    meaning: a person or company not yet worked into a deal
    natural_key: lead_id (derive it from email or domain so a re-import is idempotent)
    key_attributes:
      - source
      - status
      - owner
      - score
    master_data: module-owned
    reference_table: leads
    recommended_table: crm_leads
  - id: opportunity
    meaning: a specific deal you are trying to win
    natural_key: opportunity_id
    key_attributes:
      - account
      - amount
      - stage
      - close_date
      - owner
      - forecast_category
      - probability
    relationships:
      - -> account
      - -> primary contact
      - 1..n quote
      - 0..1 lead_score
    master_data: module-owned
    reference_table: opportunities
    recommended_table: crm_opportunities
  - id: activity
    meaning: a call, email, meeting or note
    natural_key: activity_id
    key_attributes:
      - kind
      - subject
      - direction
      - ts
      - owner
    relationships:
      - -> contact
      - -> opportunity
    master_data: module-owned
    reference_table: activities
    recommended_table: crm_activities
  - id: pipeline_stage
    meaning: one step of YOUR sales process, as data
    natural_key: pipeline:name
    key_attributes:
      - position
      - probability
      - forecast_category
      - is_won
      - is_closed
    master_data: module-owned (configuration)
    reference_table: pipeline_stages
  - id: product
    meaning: something you sell
    natural_key: product_id (or SKU)
    key_attributes:
      - name
      - sku
      - category
      - unit
      - list_price
      - active
    master_data: SHARED — sales quotes it, invoicing bills it, purchasing buys it
    reference_table: products
    note: the reference lets CRM write it; once a second module needs products, give it one owner
  - id: price_book
    meaning: a price list for a currency/region, with per-product prices
    natural_key: price_book_id; entry_id
    master_data: module-owned
    reference_table:
      - price_books
      - price_book_entries
  - id: quote
    meaning: a priced offer with lines
    natural_key: quote_id; quote_line_id
    key_attributes:
      - status
      - currency
      - subtotal
      - discount_total
      - total
      - valid_until
    relationships:
      - -> opportunity
      - -> account
      - -> price_book
      - 1..n quote_line -> product
    master_data: module-owned
    reference_table:
      - quotes
      - quote_lines
  - id: discount_policy
    meaning: the thresholds that decide what auto-approves
    natural_key: policy_id
    master_data: module-owned (configuration; changing it is a management act)
    reference_table: discount_policy
  - id: lead_score
    meaning: how good a lead is, and why (BANT or MEDDIC)
    natural_key: lead_id
    master_data: module-owned (derived)
    reference_table:
      - lead_scores
      - scoring_policy
  - id: campaign
    meaning: a marketing push, its members and touchpoints
    natural_key: campaign_id; member_id; touchpoint_id
    master_data: module-owned
    reference_table:
      - campaigns
      - campaign_members
      - touchpoints
      - attribution
  - id: task
    meaning: a to-do for a person, attached to any record
    natural_key: task_id
    master_data: module-owned (a candidate for a shared "work" module later)
    reference_table: tasks
  - id: forecast_snapshot
    meaning: the forecast as it stood on a date
    natural_key: snapshot_id
    master_data: module-owned (derived)
    reference_table:
      - forecast_snapshots
      - deal_risk
  - id: sequence
    meaning: an automated outreach cadence
    natural_key: flow_id; step_id; enrollment_id
    master_data: module-owned
    reference_table:
      - flows
      - flow_steps
      - flow_enrollments
      - flow_actions
    optional: true
master_data:
  owns: []
  references:
    - entity: business_partner
      owner: business-partner
      how: crm_accounts.account_id = business_partner.bp_id
      if_absent: CRM keys accounts on web domain alone — fine for a sales-only system, a problem the day finance arrives
    - entity: product
      owner: decide per customer — the reference lets CRM write it
      how: quote_lines.product_id
    - entity: employee
      owner: the pool (identity) — rep/owner columns hold the pool user's sub or email
      how: owner columns
  warning: The most expensive mistake is a CRM-only customer list. If invoicing or purchasing is on the roadmap at all, install business-partner first and let CRM reference it.
jobs:
  - story: CRM-10
    trigger: every 6 hours
    runs: re-score open leads
    where_options:
      - always-on worker app (--reserved 1 --max-replicas 1) with its own loop
      - external scheduler calling a route
    idempotent: required
  - story: CRM-15
    trigger: nightly
    runs: POST /forecast/recompute
    where_options:
      - always-on worker app
      - external scheduler
    idempotent: required
  - story: CRM-16
    trigger: nightly
    runs: risk rule over late-stage opportunities
    where_options:
      - always-on worker app
      - external scheduler
    idempotent: required
  - story: CRM-17
    trigger: continuous (per-step delays)
    runs: sequence engine
    where_options:
      - always-on worker app — polling is the only option without a scheduler
    idempotent: required
  - story: CRM-11
    trigger: on quote decision
    runs: notify submitter
    where_options:
      - inline in the approval route
seams:
  - story: CRM-20
    with: ar
    owner: ar
    tested: code/crm-suite-app/v1/tests/test_suite.py — accepting a quote raises the invoice, once, at the right money
    rule: CRM calls the invoicing module's route; it never writes invoice tables
  - story: CRM-21
    with: ar
    owner: ar
    rule: read-only join through business_partner
  - story: CRM-22
    with: business-partner
    owner: business-partner
    rule: CRM calls promote; it never writes business_partner
screens:
  - id: home
    name: Home
    stories:
      - CRM-12
      - CRM-16
    actors:
      - rep
      - manager
  - id: accounts
    name: Accounts
    stories:
      - CRM-01
      - CRM-13
    actors:
      - rep
      - manager
  - id: account_360
    name: Account
    stories:
      - CRM-02
      - CRM-08
      - CRM-14
      - CRM-21
    actors:
      - rep
      - manager
  - id: pipeline
    name: Pipeline board
    stories:
      - CRM-04
    actors:
      - rep
      - manager
  - id: lead
    name: Lead
    stories:
      - CRM-03
      - CRM-10
    actors:
      - rep
      - marketing
  - id: quote
    name: Quote builder
    stories:
      - CRM-05
      - CRM-07
      - CRM-20
    actors:
      - rep
  - id: approvals
    name: Approvals
    stories:
      - CRM-06
      - CRM-11
    actors:
      - deal_desk
  - id: forecast
    name: Forecast
    stories:
      - CRM-09
      - CRM-15
    actors:
      - manager
  - id: campaigns
    name: Campaigns
    stories:
      - CRM-17
    actors:
      - marketing
    optional: true
  - id: settings
    name: Settings
    stories: []
    actors:
      - manager
      - deal_desk
    note: every config_row below lives here — this screen is what keeps the module customizable without a fork
permissions:
  catalog:
    - crm:account:read
    - crm:account:write
    - crm:lead:write
    - crm:opportunity:write
    - crm:opportunity:assign
    - crm:quote:draft
    - crm:quote:approve
    - crm:discount_policy:write
    - crm:pipeline_config:write
    - crm:forecast:override
    - crm:campaign:write
    - crm:attribution:read
    - crm:task:write
  enforced_today:
    - orgs:qualify
    - leads:score
    - quotes:approve
    - quotes:accept
    - forecast:override
  row_visibility:
    rule: a rep sees rows they own; a manager sees the team's
    enforced: every owned read is scoped by the caller's identity in the route
    not_found_not_forbidden: true
config_rows:
  - table: pipeline_stages
    changes: the stages of your sales process, their order, probability and forecast category
  - table: discount_policy
    changes: the discount that auto-approves, the maximum, and who approves between them
  - table: price_books
    changes: price lists per currency or region
  - table: scoring_policy
    changes: how a lead is qualified (BANT, MEDDIC or your own weights and thresholds)
  - table: crm_settings
    changes: quote validity, follow-up days, loss reasons, company details on the PDF
in_code_not_rows:
  - which transitions exist and who may make them
  - that a sent quote freezes and edits create a revision
  - that the discount cap is checked on every edit AND again when the quote is sent
  - that a lost deal requires a reason
tests:
  acceptance: one per story id above, named for it
  access:
    - a rep opening another rep's account gets not found, not forbidden
    - a rep cannot approve a quote above the threshold
    - a request with no gateway identity is rejected
    - a forged X-Dodil-User header is stripped at the edge
  invariants:
    - quote totals are computed, never typed — line discounts and header discount compose
    - an approved quote refuses edits; a revision is a new row
    - re-importing the same contacts changes nothing
    - amounts are never summed across currencies
    - "every job is idempotent: a second run changes nothing"
scale:
  reads: the tables reader serves 16 concurrent reads; hold a client-side semaphore below it (DB_CONCURRENCY=10) and retry on SQLSTATE 40001/08006/58030/XX000 plus the message markers for misclassified blips
  hot_paths:
    - pipeline board (all open opportunities + stage config)
    - account 360 (family rollup via the graph)
    - forecast recompute
  workers: "any polling engine (sequences, re-scoring) runs pinned warm: --reserved 1 --max-replicas 1"
  volumes: designed for tens of thousands of accounts and hundreds of thousands of activities; above that, snapshot the forecast rather than recomputing on read
customer_varies:
  - question: What are the steps of your sales process, and how likely is a deal to close at each?
    changes: pipeline_stages
    typical:
      b2b_considered_sale: qualify, discovery, proposal, negotiation, closed — five or six stages
      transactional: "two or three: enquiry, quote, won"
      warning: a stage nobody can define the EXIT criteria for is a stage that will not be updated
  - question: Who can give what discount, and who approves above that?
    changes: discount_policy, the quote workflow (single or chained approval)
    typical:
      common: a rep discounts to ~10%, a manager to ~25%, anything beyond needs the owner
      note: the numbers matter less than there being exactly one threshold per approver
  - question: Does each rep only see their own customers, or does everyone see everything?
    changes: the actors' "sees", CRM-01
  - question: Do you sell to groups of companies (parents and subsidiaries)?
    changes: the account family edge, CRM-08
  - question: Do you send quotes and invoices from this system, or elsewhere?
    changes: whether CRM-20/21 are in scope; whether business-partner is needed now
  - question: What do you use today, and what must come across on day one?
    changes: CRM-13 import mapping, migration scope
  - question: Do you run marketing campaigns or outreach sequences?
    changes: whether campaign and sequence entities are in scope at all (both optional)
  - question: How do you qualify a lead?
    changes: scoring_policy (BANT, MEDDIC or your own), CRM-10
    typical:
      smaller_teams: budget, authority, need, timing (BANT) — four fields on the lead
      enterprise: MEDDIC, because the economic buyer and the paper process are the risk
      self_serve: usage or firmographic score, not a questionnaire
reference:
  packages:
    - id: crm-suite-app
      kind: app
      what: all of the above in one FastAPI app + React UI
      url: https://blog.dodil.io/code/crm-suite-app/
    - id: crm-core
      kind: reference
      what: accounts, contacts, deals, activities only
      url: https://blog.dodil.io/code/crm-core/
  contracts: []
  build_guide: https://blog.dodil.io/library/crm-reference-build
  known_gaps:
    - "the test suite covers 5 of 20 stories: activities, stages, quoting, the discount cap and tasks, plus access and invariants. The rest need work in the package first — the list is in tests/README.md"
    - CRM-20/21/22 are BUILT and tested against the other three modules in one bucket (tests/test_suite.py, 10 tests) — accepting a quote promotes the party, raises a draft invoice and reaches the ledger
    - the join the party master exists to make possible needs a CAST, because invoicing's bp_id column actually holds a bp_KEY. The un-cast join FAILS outright (Could not convert string … to INT64) rather than returning wrong rows — design/ar-bp-id-change-request.md
    - POST /quote-cpq/quote_lines accepts a line for a quote that does not exist and answers 200, leaving a line with no parent
    - the hand-off to finance mints its own app claims; an app-to-app call belongs to a dodil-appid service client, so invoicing cannot yet tell a service from a person
    - "`discount_pct` holds a FRACTION (0.18 = 18%) despite its name: sending 18 silently produces a negative line total and a rejected quote"
    - tables are mostly unprefixed (contacts, quotes, products) — prefix the module-owned ones for a shared bucket
    - no scheduler for CRM-10/15/16/17
    - no notifications (CRM-11)
    - permission names predate the <module>:<object>:<verb> rule (quotes:approve)
lifecycle: https://blog.dodil.io/modules/lifecycle.md
module_pattern: https://blog.dodil.io/modules/pattern.md
design_template: https://blog.dodil.io/modules/template.md
