# Bounded CRM Core — Domain Model

## Purpose
Define the **domain-only** model and pure behaviors for a small CRM core slice:

- Contacts
- Companies
- Opportunities (with stage transitions)
- Tasks
- Notes
- Dashboard summaries (aggregations)

This domain is designed to be **deterministic**, **dependency-free**, and implemented as **pure TypeScript** in `packages/domain`.

## Domain boundaries
- No IO: no network, filesystem, database, environment reads.
- No clock: all timestamps are explicit ISO strings provided by the caller.
- No randomness: IDs are inputs (or generated by the application layer and passed in).
- No cross-aggregate lookups: referential integrity (e.g. “companyId exists”) is enforced by the in-memory application service.

## Core types
### Identifiers
All entity IDs are strings. (Implementation may use `type CompanyId = string` etc. for readability.)

### Time
All time values are ISO-8601 strings in the format `YYYY-MM-DDTHH:mm:ss.sssZ` and are provided by the caller.

## Entities
### Company
- `id: string`
- `name: string` (required, trimmed, non-empty)
- `createdAt: string` (ISO)

### Contact
- `id: string`
- `displayName: string` (required, trimmed, non-empty)
- `email?: string` (optional; if present must contain `@` and be trimmed)
- `companyId?: string` (optional; if present, must reference an existing company — enforced by application service)
- `createdAt: string` (ISO)

### Opportunity
- `id: string`
- `companyId: string` (required; must reference an existing company — enforced by application service)
- `primaryContactId?: string` (optional; if present, must reference an existing contact — enforced by application service)
- `title: string` (required, trimmed, non-empty)
- `amountCents: number` (integer, `>= 0`)
- `currency: string` (required; uppercase 3-letter code, `^[A-Z]{3}$`)
- `stage: OpportunityStage`
- `createdAt: string` (ISO)
- `stageUpdatedAt: string` (ISO)

### Task
- `id: string`
- `subject: SubjectRef`
- `title: string` (required, trimmed, non-empty)
- `dueAt?: string` (optional ISO)
- `status: "open" | "completed"`
- `createdAt: string` (ISO)
- `completedAt?: string` (ISO; set only when completed)

### Note
- `id: string`
- `subject: SubjectRef`
- `body: string` (required, trimmed, non-empty)
- `createdAt: string` (ISO)

## Relationships (enforced by application service)
- `Contact.companyId` (if present) references an existing `Company`.
- `Opportunity.companyId` references an existing `Company`.
- `Opportunity.primaryContactId` (if present) references an existing `Contact`.
- `Task.subject` and `Note.subject` reference an existing entity of the referenced type.

## Subject model (for tasks and notes)
`Task.subject` and `Note.subject` share the same discriminated union:

- `{ type: "contact"; id: string }`
- `{ type: "company"; id: string }`
- `{ type: "opportunity"; id: string }`

## Opportunity stages
### Stage values
Stages are fixed string values:

- `prospecting`
- `qualified`
- `proposal`
- `negotiation`
- `closed-won`
- `closed-lost`

### Allowed transitions
All other transitions are rejected:
- `prospecting` → `qualified` | `closed-lost`
- `qualified` → `proposal` | `closed-lost`
- `proposal` → `negotiation` | `closed-lost`
- `negotiation` → `closed-won` | `closed-lost`
- `closed-won` → *(no transitions)*
- `closed-lost` → *(no transitions)*

## Pure domain operations (to implement in `packages/domain`)
The domain package should export pure functions that validate and return new values without mutating inputs:

- `createCompany({ id, name, createdAt })`
- `createContact({ id, displayName, email?, companyId?, createdAt })` (does not check company existence)
- `createOpportunity({ id, companyId, primaryContactId?, title, amountCents, currency, createdAt })`
  - sets `stage = "prospecting"`
  - sets `stageUpdatedAt = createdAt`
- `moveOpportunityStage(opportunity, toStage, now)`
  - validates transition and sets `stageUpdatedAt = now`
- `createTask({ id, subject, title, dueAt?, createdAt })` (does not check subject existence)
- `completeTask(task, now)` (one-way: only `"open"` → `"completed"`)
- `createNote({ id, subject, body, createdAt })` (does not check subject existence)
- `computeDashboardSummary(state, asOf)`

## Dashboard summary model
`computeDashboardSummary(state, asOf)` returns:
- totals: companies, contacts, opportunities, tasks (open/completed), notes
- opportunitiesByStage: counts for every stage value
- overdueOpenTasks: count where `status = "open"` and `dueAt` is present and `dueAt < asOf`

The summary must be computed deterministically from the supplied state and explicit `asOf`.

