# Bounded CRM Core API Contracts

## Summary
This document defines the public module contracts for the bounded CRM core. These are TypeScript application-service contracts, not HTTP endpoints. The implementation target is an in-memory service layer in `packages/api` backed by pure domain logic from `packages/domain`.

## Contract goals

- deterministic behavior
- transport-free service surface
- explicit validation and lookup failures
- minimal operations that satisfy `docs/prd.md`
- no persistence or framework assumptions

## Shared contract assumptions

- All successful create operations return the stored entity.
- All `get*` operations fail for unknown IDs instead of returning `undefined`.
- All list operations return deterministic snapshots.
- IDs are generated by the application layer.
- Domain objects are plain serializable TypeScript values.
- Money is represented as integer cents.
- Date comparisons use caller-supplied `YYYY-MM-DD` strings.

## Recommended exported interfaces

```ts
export type OpportunityStage =
  | "Prospecting"
  | "Qualified"
  | "Proposal"
  | "Negotiation"
  | "ClosedWon"
  | "ClosedLost";

export type TaskStatus = "Open" | "Done";

export interface Company {
  companyId: string;
  name: string;
  domain?: string;
}

export interface Contact {
  contactId: string;
  fullName: string;
  email?: string;
  phone?: string;
  companyId?: string;
}

export interface Opportunity {
  opportunityId: string;
  title: string;
  stage: OpportunityStage;
  amountCents: number;
  companyId?: string;
  primaryContactId?: string;
}

export interface Task {
  taskId: string;
  subject: string;
  status: TaskStatus;
  dueDate?: string;
  contactId?: string;
  opportunityId?: string;
}

export interface Note {
  noteId: string;
  body: string;
  contactId?: string;
  opportunityId?: string;
}

export interface DashboardSummary {
  totals: {
    contacts: number;
    companies: number;
    opportunities: number;
    tasks: number;
    notes: number;
  };
  opportunitiesByStage: Record<OpportunityStage, number>;
  openPipelineAmountCents: number;
  openTasksDueCount: number;
}
```

## Error contract
The exact class hierarchy is implementation-defined, but tests need a stable error shape. The safest contract is a typed error with a code and context.

Recommended shape:

```ts
export type CrmErrorCode =
  | "VALIDATION_ERROR"
  | "NOT_FOUND"
  | "INVALID_STAGE_TRANSITION";

export interface CrmError extends Error {
  code: CrmErrorCode;
  message: string;
  details?: Record<string, string | number | boolean>;
}
```

Recommended usage:

- `VALIDATION_ERROR` for empty strings, invalid parent targeting, malformed amounts, or invalid date shape.
- `NOT_FOUND` for unknown IDs and unknown linked records.
- `INVALID_STAGE_TRANSITION` for disallowed opportunity stage movement.

## Input contracts

### Company commands

```ts
export interface CreateCompanyInput {
  name: string;
  domain?: string;
}
```

Rules:

- `name` is required and trimmed for validation.
- `domain` is optional and stored as provided.

### Contact commands

```ts
export interface CreateContactInput {
  fullName: string;
  email?: string;
  phone?: string;
  companyId?: string;
}
```

Rules:

- `fullName` is required and trimmed for validation.
- `companyId`, when present, must resolve to an existing company.

### Opportunity commands

```ts
export interface CreateOpportunityInput {
  title: string;
  amountCents: number;
  companyId?: string;
  primaryContactId?: string;
}

export interface MoveOpportunityStageInput {
  opportunityId: string;
  nextStage: OpportunityStage;
}
```

Rules:

- `title` is required and trimmed for validation.
- `amountCents` must be an integer `>= 0`.
- New opportunities start in `Prospecting`.
- Linked IDs must resolve when present.
- `nextStage` must satisfy the fixed transition graph.

### Task commands

```ts
export interface AddTaskInput {
  subject: string;
  dueDate?: string;
  contactId?: string;
  opportunityId?: string;
}

export interface TaskListFilter {
  contactId?: string;
  opportunityId?: string;
  status?: TaskStatus;
}
```

Rules:

- `subject` is required and trimmed for validation.
- Exactly one of `contactId` or `opportunityId` must be present.
- `dueDate`, when present, uses `YYYY-MM-DD`.
- New tasks always start with `status = Open`.
- `markTaskDone` is idempotent in outcome if called on a completed task, but it must still fail for unknown IDs.

### Note commands

```ts
export interface AddNoteInput {
  body: string;
  contactId?: string;
  opportunityId?: string;
}

export interface NoteListFilter {
  contactId?: string;
  opportunityId?: string;
}
```

Rules:

- `body` is required and trimmed for validation.
- Exactly one of `contactId` or `opportunityId` must be present.

### Dashboard query

```ts
export interface GetDashboardSummaryInput {
  asOfDate: string;
}
```

Rules:

- `asOfDate` is required.
- The service must not substitute the current system date.

## Service facade contract

```ts
export interface CrmCoreApi {
  createCompany(input: CreateCompanyInput): Company;
  getCompany(companyId: string): Company;
  listCompanies(): Company[];

  createContact(input: CreateContactInput): Contact;
  getContact(contactId: string): Contact;
  listContacts(): Contact[];

  createOpportunity(input: CreateOpportunityInput): Opportunity;
  getOpportunity(opportunityId: string): Opportunity;
  listOpportunities(): Opportunity[];
  moveOpportunityStage(input: MoveOpportunityStageInput): Opportunity;

  addTask(input: AddTaskInput): Task;
  markTaskDone(taskId: string): Task;
  listTasks(filter?: TaskListFilter): Task[];

  addNote(input: AddNoteInput): Note;
  listNotes(filter?: NoteListFilter): Note[];

  getDashboardSummary(input: GetDashboardSummaryInput): DashboardSummary;
}
```

## Operation-by-operation behavior

### `createCompany`
Success result:

- returns a `Company` with a generated `companyId`

Failure cases:

- `VALIDATION_ERROR` when `name` is empty after trimming

### `getCompany`
Success result:

- returns the stored company

Failure cases:

- `NOT_FOUND` when `companyId` does not exist

### `listCompanies`
Success result:

- returns all companies in deterministic order

### `createContact`
Success result:

- returns a `Contact` with a generated `contactId`

Failure cases:

- `VALIDATION_ERROR` when `fullName` is empty after trimming
- `NOT_FOUND` when `companyId` does not exist

### `getContact`
Success result:

- returns the stored contact

Failure cases:

- `NOT_FOUND` when `contactId` does not exist

### `listContacts`
Success result:

- returns all contacts in deterministic order

### `createOpportunity`
Success result:

- returns an `Opportunity` with generated `opportunityId`
- persisted `stage` is `Prospecting`

Failure cases:

- `VALIDATION_ERROR` when `title` is empty after trimming
- `VALIDATION_ERROR` when `amountCents` is negative or non-integer
- `NOT_FOUND` when `companyId` or `primaryContactId` does not exist

### `getOpportunity`
Success result:

- returns the stored opportunity

Failure cases:

- `NOT_FOUND` when `opportunityId` does not exist

### `listOpportunities`
Success result:

- returns all opportunities in deterministic order

### `moveOpportunityStage`
Success result:

- returns the updated opportunity with the new `stage`

Failure cases:

- `NOT_FOUND` when `opportunityId` does not exist
- `INVALID_STAGE_TRANSITION` when the transition is not in the approved graph

### `addTask`
Success result:

- returns a `Task` with generated `taskId`
- persisted `status` is `Open`

Failure cases:

- `VALIDATION_ERROR` when `subject` is empty after trimming
- `VALIDATION_ERROR` when neither or both parent IDs are provided
- `NOT_FOUND` when the selected parent ID does not exist

### `markTaskDone`
Success result:

- returns the updated task with `status = Done`

Failure cases:

- `NOT_FOUND` when `taskId` does not exist

### `listTasks`
Success result:

- with no filter, returns all tasks in deterministic order
- with `contactId`, returns only tasks for that contact
- with `opportunityId`, returns only tasks for that opportunity
- with `status`, returns only matching tasks

Failure cases:

- none required for filter-only reads if unknown filter IDs simply produce an empty result

### `addNote`
Success result:

- returns a `Note` with generated `noteId`

Failure cases:

- `VALIDATION_ERROR` when `body` is empty after trimming
- `VALIDATION_ERROR` when neither or both parent IDs are provided
- `NOT_FOUND` when the selected parent ID does not exist

### `listNotes`
Success result:

- with no filter, returns all notes in deterministic order
- with `contactId`, returns only notes for that contact
- with `opportunityId`, returns only notes for that opportunity

Failure cases:

- none required for filter-only reads if unknown filter IDs simply produce an empty result

### `getDashboardSummary`
Success result:

- returns totals for contacts, companies, opportunities, tasks, and notes
- returns `opportunitiesByStage` with every allowed stage present
- returns `openPipelineAmountCents`
- returns `openTasksDueCount`

Failure cases:

- `VALIDATION_ERROR` when `asOfDate` is missing or malformed

Computation rules:

- `openPipelineAmountCents` includes opportunities not in `ClosedWon` or `ClosedLost`
- `openTasksDueCount` includes tasks where:
  - `status = Open`
  - `dueDate` exists
  - `dueDate <= asOfDate`

## State transition rules

```ts
const allowedTransitions: Record<OpportunityStage, OpportunityStage[]> = {
  Prospecting: ["Qualified", "ClosedLost"],
  Qualified: ["Proposal", "ClosedLost"],
  Proposal: ["Negotiation", "ClosedLost"],
  Negotiation: ["ClosedWon", "ClosedLost"],
  ClosedWon: [],
  ClosedLost: [],
};
```

Contract implications:

- terminal stages are immutable
- reopening is blocked
- skipping forward is blocked
- moving sideways is blocked

## In-memory infrastructure expectations
The service implementation should be easy to wrap later:

- store entities in per-type collections keyed by ID
- inject ID generation rather than hard-code randomness
- expose plain values, not ORM instances
- keep domain mutation rules inside pure functions where possible

## Open questions
- None for this bounded scope.

## Recommended next artifact
- `specs/invariants.md`
