Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/state-machines-status-vocabulary-to-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"hotcrm": patch
---

State-machine docs: write the status vocabulary, the two remaining behavioural claims and the Copilot section to source.

`content/docs/administration/state-machines` used a set of lead statuses and opportunity
stages that do not exist. *Working* and *Disqualified* are not statuses at all — they are
entries in `src/mappings/lead_import.mapping.ts`, the alias table that maps a legacy
export's wording onto `contacted` and `unqualified` on import, and the product never shows
either word. The real vocabulary is *New / Contacted / Qualified / Unqualified /
Converted*, and *Contacted* and *Unqualified* had never once appeared on the page that is
supposed to be authoritative about them. On the opportunity side, *Closed Without Decision*
exists nowhere in this app and the real *Needs Analysis* stage was missing, so the page
listed seven stages of which one was invented and one was absent. Both diagrams, both
transition tables and the lede now come from the `state_machine` rules themselves.

Two claims about the shape of the route were backwards. The page gave *New → Converted* as
its example of a **disallowed** move; that edge is in the table **deliberately**, because
Convert Lead is offered on any open lead and the `lead_conversion` flow stamps
`status: 'converted'` at the end — forbidding it would make every conversion from an
unworked lead log a spurious warning. And *Unqualified* was drawn as a terminal state when
it is the one status with a way back (a re-open to *New*), while the opportunity section
advertised free backwards movement and a reopen path that the table declares nowhere.

Also corrected on the same page: **Close Date** and **Amount** are `required: true`
unconditionally rather than from *Proposal* and *Negotiation* onwards; approval starts at
**$100K** (`src/flows/opportunity-approval.flow.ts`), with **$500K** being the second,
Sales-Director tier rather than the entry threshold — so a $200K deal does go through
approval, which the old wording denied. The won/lost-reason requirement was already
correct and is unchanged. The **AI Copilot** section, which described the Copilot
withholding suggestions until a status allowed the move, is replaced by what can actually
be shown: no skill under `src/skills/` and no action under `src/actions/` references a
transition table, and whether the platform's own agent reads one is left explicitly
unclaimed rather than asserted in either direction.

`content/docs/administration/setup`'s stage/probability table carried the same phantom
stage, and dropping it exposed that the missing *Needs Analysis* had shifted every
probability below it by one row — *Proposal* and *Negotiation* were showing 40% and 60%
against a source that says 60% and 80%. The table now matches `STAGE_PROBABILITY` in
`src/objects/opportunity.hook.ts`.

All changes are English, Simplified Chinese and Traditional Chinese. Documentation only —
no metadata changed.
6 changes: 3 additions & 3 deletions content/docs/administration/setup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,11 @@ Confirm the 7-stage default pipeline matches your sales process. Stages and prob
| --- | --- |
| Prospecting | 10% |
| Qualification | 25% |
| Proposal | 40% |
| Negotiation | 60% |
| Needs Analysis | 40% |
| Proposal | 60% |
| Negotiation | 80% |
| Closed Won | 100% |
| Closed Lost | 0% |
| Closed Without Decision | 0% |

If your process differs, adjust in **Setup → Opportunity → Stages**.

Expand Down
6 changes: 3 additions & 3 deletions content/docs/administration/setup.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,11 @@ description: 让 HotCRM 为你的团队做好生产准备的第一周任务。
| --- | --- |
| Prospecting | 10% |
| Qualification | 25% |
| Proposal | 40% |
| Negotiation | 60% |
| Needs Analysis | 40% |
| Proposal | 60% |
| Negotiation | 80% |
| Closed Won | 100% |
| Closed Lost | 0% |
| Closed Without Decision | 0% |

如果你的流程不同,请在 **设置 → 商机 → 阶段** 中调整。

Expand Down
6 changes: 3 additions & 3 deletions content/docs/administration/setup.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,11 @@ description: 讓 HotCRM 為你的團隊做好生產準備的第一週任務。
| --- | --- |
| Prospecting | 10% |
| Qualification | 25% |
| Proposal | 40% |
| Negotiation | 60% |
| Needs Analysis | 40% |
| Proposal | 60% |
| Negotiation | 80% |
| Closed Won | 100% |
| Closed Lost | 0% |
| Closed Without Decision | 0% |

如果你的流程不同,請在 **設定 → 商機 → 階段** 中調整。

Expand Down
108 changes: 59 additions & 49 deletions content/docs/administration/state-machines.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ description: How HotCRM declares valid status transitions on leads and opportuni

# State Machines

Some objects have a strict **lifecycle** — a lead progresses from *New* to *Working* to *Qualified*, never jumping backwards into *New*. A state machine **declares** that route; it does not enforce it. All five machines in this app carry **warning** severity, so a user can move a record outside the route and **the save still goes through** — see *A declared route is advice, not a gate* below.
Some objects have a strict **lifecycle** — a lead progresses from *New* to *Contacted* to *Qualified* and on to *Converted*. A state machine **declares** that route; it does not enforce it. All five machines in this app carry **warning** severity, so a user can move a record outside the route and **the save still goes through** — see *A declared route is advice, not a gate* below.

## Where state machines apply

HotCRM uses state machines on **five** objects:

- **Leads** — *New → Working → Qualified → Converted / Disqualified*
- **Opportunities** — through the 7 stages *Prospecting → Qualification → Proposal → Negotiation → Closed Won / Closed Lost / Closed Without Decision*
- **Leads** — *New → Contacted → Qualified → Converted*, with *Unqualified* reachable from every open status and the only way back out of it being a re-open to *New*
- **Opportunities** — through the 7 stages *Prospecting → Qualification → Needs Analysis → Proposal → Negotiation → Closed Won / Closed Lost*
- **Cases** — *New → In Progress → Resolved → Closed*, with *Waiting on Customer* and *Waiting on Support* to park an open case, *Escalated* reachable from any unresolved status, and both *Resolved* and *Closed* able to reopen to *In Progress*
- **Contracts** — *Draft → In Approval → Activated → Expired*, with *In Approval* able to send the paperwork back to *Draft*, *Terminated* reachable from all three live statuses, and *Expired* and *Terminated* both final
- **Quotes** — *Draft → In Review → Presented → Accepted*, with *In Review* able to return to *Draft*, *Rejected* starting over at *Draft*, *Expired* reachable from *Draft*, *In Review* and *Presented*, and *Accepted* and *Expired* both final
Expand All @@ -25,63 +25,75 @@ All five are declared the same way — a named `state_machine` validation rule o

## The Lead state machine

The main route, with the two side edges that leave it:

```
┌──────────────┐
│ New │
└──────┬───────┘
│ Begin working
▼
┌──────────────┐
│ Working │
└──┬────────┬──┘
Disqual │ │ Qualify
▼ ▼
┌────────────┐ ┌────────────┐
│Disqualified│ │ Qualified │
└────────────┘ └─────┬──────┘
│ Convert
▼
┌────────────┐
│ Converted │
└────────────┘
New ──Contact──▶ Contacted ──Qualify──▶ Qualified ──Convert──▶ Converted
▲ │ │ (terminal)
│ │ Disqualify │ Disqualify
│ └───────────┬────────────┘
│ ▼
│ ┌─────────────┐
└────────── Re-open ───────│ Unqualified │
└─────────────┘
```

**Allowed transitions**:
**Allowed transitions** — this is the whole table, copied from the `lead_status_progression` rule in `src/objects/lead.object.ts`, and it carries a few shortcuts the picture above leaves out:

| From | To | Trigger |
| From | May move to | Notes |
| --- | --- | --- |
| New | Working | Manual ("Begin Working") or auto-update on first activity |
| Working | Qualified | Manual ("Mark Qualified") |
| Working | Disqualified | Manual ("Disqualify") with required reason |
| Qualified | Converted | The Convert wizard (creates account/contact/opportunity) |
| Disqualified | (terminal) | — |
| Converted | (terminal) | — |
| New | Contacted, Qualified, Unqualified, Converted | A lead can be qualified or converted without ever passing through *Contacted* |
| Contacted | Qualified, Unqualified, Converted | |
| Qualified | Converted, Unqualified | |
| Unqualified | New | Re-open. This is the only edge out of *Unqualified* — it is not a terminal status |
| Converted | — | Terminal |

Two of those edges have machinery behind them. Moving to *Unqualified* needs a **Disqualification Reason**: a separate validation rule, `disqualification_reason_required`, fires when the status is `unqualified` and the reason is blank — and unlike the transition table it is `error` severity, so that one **does** reject the save. Moving to *Converted* is the **Convert Lead** button, which runs the `lead_conversion` flow — it creates the account, contact and opportunity and stamps `status: 'converted'` at the end.

**Allowed on purpose**: *New* directly to *Converted*. Convert Lead is visible on any lead that is neither converted nor unqualified — so *New*, *Contacted* and *Qualified* all show it — and the flow stamps the status itself, so that shortcut has to be in the table or every conversion from an unworked lead would log a spurious "invalid transition" warning. The comment above the table in `lead.object.ts` says exactly this.

**Disallowed**: jumping from *New* directly to *Converted*; reverting *Converted* back to *Working*.
**Not in the table**: anything out of *Converted*, which is terminal; stepping back from *Qualified* to *Contacted* or *New*; and re-qualifying an *Unqualified* lead in one move — the way back from there is a re-open to *New*.

## The Opportunity state machine

```
Prospecting → Qualification → Proposal → Negotiation
│
┌─────────────────┼────────────────────────┐
▼ ▼ ▼
Closed Won Closed Lost Closed Without Decision
Prospecting → Qualification → Needs Analysis → Proposal → Negotiation
│ │
└─────┬──────┘
▼
Closed Won

Prospecting / Qualification / Needs Analysis / Proposal / Negotiation ──▶ Closed Lost
```

**Rules**:
There are **two** closed stages, not three: *Closed Won* and *Closed Lost*. There is no *Closed Without Decision* — a deal that goes quiet is a *Closed Lost*.

**Allowed transitions**, copied from the `opportunity_stage_progression` rule in `src/objects/opportunity.object.ts`:

- You can move **forward** through *Prospecting → Qualification → Proposal → Negotiation* freely.
- You can move **backwards** (e.g., from *Negotiation* back to *Proposal*) — sometimes deals slip.
- From any open stage, you can move to one of the three **closed** stages.
- Once *Closed*, the opportunity is **locked** for amount/stage edits (except via reopen — admin only).
| From | May move to | Notes |
| --- | --- | --- |
| Prospecting | Qualification, Proposal, Closed Lost | |
| Qualification | Needs Analysis, Proposal, Closed Lost | |
| Needs Analysis | Proposal, Closed Lost | 40% default probability, *Best Case* forecast category |
| Proposal | Negotiation, Closed Won, Closed Lost | |
| Negotiation | Closed Won, Closed Lost | |
| Closed Won | — | Terminal |
| Closed Lost | — | Terminal |

Two of those edges exist for the CPQ path rather than for a rep clicking through stages: *→ Proposal* is legal from every pre-proposal stage because the `quote_generation` flow fast-forwards the deal to *Proposal* the moment a quote is generated, which can happen at any open stage; and *Proposal → Closed Won* is legal because the `quote_on_accepted` hook wins the linked deal straight from an accepted quote, with no separate negotiation step.

**Not in the table**: moving **backwards**. No open stage declares an edge to an earlier one, so *Negotiation* back to *Proposal* — a deal slipping — is outside the route and logs a warning like any other departure (it still saves, per the note above). Neither closed stage declares an edge either, so there is no reopen transition and no admin path back to an open stage.

A closed opportunity is separately **frozen**, and by more than the stage field: the `opportunity_lifecycle` hook refuses any user edit to a *Closed Won* or *Closed Lost* record except to `description`, `next_step` and `notes`, and that refusal is a thrown error naming the fields attempted — not a warning. Approval verdicts and platform-managed columns are exempted so an in-flight approval can still land, and a write with no authenticated user (a seed or a backfill) is not guarded at all.

The system also enforces:

- **Close Date is required** before moving to *Proposal*.
- **Amount is required** before moving to *Negotiation*.
- **Won/Lost reason is required** when entering *Closed Lost* or *Closed Won*.
- **Approval may be required** to enter *Closed Won* if amount > $500K and discount approval hasn't been granted.
- **Close Date is required** — and not from *Proposal* onwards, but always: `close_date` carries `required: true` with `notNull` storage, so an opportunity cannot be created without one.
- **Amount is required** — unconditional in the same way. Neither field waits for a stage.
- **Won/Lost reason is required** when entering *Closed Lost* or *Closed Won* — `requiredWhen` predicates on `win_reason` and `loss_reason`, keyed on the stage.
- **Approval starts at $100K.** The `opportunity_approval` flow fires on any update that leaves an open opportunity above **$100,000** with no approval on file, and routes it to a **Sales Manager**. **$500K** is the second tier, not the entry: above that a **Sales Director** signs off after the manager. So a $200K deal does go through approval.

The heading is fair for the first three, unlike the transition tables above them: `required` and `requiredWhen` are hard, and a save that violates one is refused. The fourth is not a save-time check at all — `opportunity_approval` is a `record_change` flow that runs *after* the update and then holds the record locked while a step is pending.

## What a state machine buys you

Expand Down Expand Up @@ -115,13 +127,11 @@ All of that is a source edit, not a Setup screen: **there is no Setup → Object

## State machines and the AI Copilot

The Copilot understands the state machine. When suggesting an action:
**Nothing in this app connects the two.** The six skills under `src/skills/` and every action under `src/actions/` name no transition table, no state machine and no legal-next-state lookup, and no skill's instructions carry a list of which statuses may follow which. This page previously described the Copilot withholding a *"Qualify this lead"* or *"Convert this lead"* suggestion until the record's status allowed the move; HotCRM declares nothing that would produce that behaviour.

- *"Qualify this lead"* → only suggested if status is *Working*.
- *"Convert this lead"* → only suggested if status is *Qualified*.
- *"Mark this opportunity Closed Won"* → only suggested if win conditions are met (amount, close date, win reason, approval if required).
Whether the platform's own agent reads a `state_machine` validation when it loads an object's metadata, and narrows its suggestions accordingly, is a question about the runtime rather than about this app — so this page makes no claim about it in either direction. If you are relying on the Copilot to stay inside a lifecycle, verify it against your deployment rather than against this page.

This prevents the Copilot from suggesting illegal transitions.
What the tables do give an agent is what they give a report author: the route, declared in one place on the object, for a skill or a flow to be written against deliberately.

## Tips for admins

Expand Down
Loading
Loading