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
11 changes: 11 additions & 0 deletions .changeset/2016-rep-conversion-new-account.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'hotcrm': patch
---

A sales rep can now convert a lead into a new account. Before this fix the conversion stopped with "Field write denied … [annual_revenue]" whenever the lead's company had no account yet, and nothing was created; a sales manager had to convert it instead. The new account still carries the lead's **Annual Revenue**, and it is owned and created by the rep who converted it.

**Annual Revenue** stays read-only for sales reps. The rep's conversion creates the account as before, and a new sub-flow, **Copy Lead Revenue to New Account**, copies the lead's revenue onto that account with system privilege. Nobody can start the sub-flow on its own, administrators included; it runs only as a step of **Lead Conversion Process**.

A user who may not create accounts, such as a marketing user or a service agent, still cannot convert a lead into a new account, and nothing is written when they try.

On every conversion into a new account, a sales manager's included, the revenue now arrives just after the account is created. The account's approval request records the account as it was submitted, so its snapshot in the approval inbox shows no annual revenue. The account itself carries it.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# HotCRM

> **The reference app for AI-written enterprise software.** A complete CRM —
> 18 objects, 32 flows, 5 dashboards, 6 AI skills, 4 languages — built as four
> 18 objects, 33 flows, 5 dashboards, 6 AI skills, 4 languages — built as four
> packages (sales, service, revenue, marketing) that compile to one artifact.
> The **sales package**, the one a customer installs, carries its whole
> business semantics (objects, flows, actions, hooks) in **~57k tokens**
Expand Down Expand Up @@ -69,7 +69,7 @@ HotCRM is a complete, opinionated CRM built as the **first official application*
| `crm_event` | | | |
| `crm_event_attendee` | | | |

Plus **6 AI skills** (a skills-only surface — HotCRM defines no agents of its own; the skills attach to the platform `ask` assistant), **5 dashboards**, **32 flows**, **31 actions**, **9 datasets**, **4 language bundles** (en, zh-CN, es-ES, ja-JP), **6 permission profiles**, **12 positions**, and **9 sharing rules**.
Plus **6 AI skills** (a skills-only surface — HotCRM defines no agents of its own; the skills attach to the platform `ask` assistant), **5 dashboards**, **33 flows**, **31 actions**, **9 datasets**, **4 language bundles** (en, zh-CN, es-ES, ja-JP), **6 permission profiles**, **12 positions**, and **9 sharing rules**.

> **Business reader?** The ObjectStack docs tour every one of these capabilities in plain business language — [What Can It Do?](https://objectstack.ai/docs/capabilities) — with HotCRM as the running example on every page.

Expand Down
3 changes: 2 additions & 1 deletion content/docs/administration/automation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,12 @@ A flow fires one of three ways, set by its start node:

> **Auto-launch needs the `triggers` capability.** Record-change and scheduled flows only fire when the stack's `requires` list includes `triggers` — it installs the record-change + schedule trigger providers (schedule triggers also use the job service). Screen flows are always launched manually.

**Built-in flows in HotCRM** (32). Each row carries the flow's own label — the name listed in **Studio → Automation → Flows**, and the name you pick from in **Studio → Developer → Flow Runs**, so a run you are chasing can be looked up here verbatim:
**Built-in flows in HotCRM** (33). Each row carries the flow's own label — the name listed in **Studio → Automation → Flows**, and the name you pick from in **Studio → Developer → Flow Runs**, so a run you are chasing can be looked up here verbatim:

| Flow | Trigger | What it does |
| --- | --- | --- |
| **Lead Conversion Process** | Screen | Convert a qualified lead into an account + contact (+ optional opportunity), then notify |
| **Copy Lead Revenue to New Account** | Subflow | Called by **Lead Conversion Process** to copy the lead's annual revenue onto the account it has just created, with system privilege |
| **Generate Quote from Opportunity** | Screen | Build a quote from an opportunity and move it to *Proposal* |
| **Schedule Follow-up** | Screen | Create the next follow-up task on a lead, already linked to it and owned by the right user |
| **Enroll Members in Campaign** | Screen | Bulk-enroll eligible leads or contacts into this campaign, skipping the already-enrolled and the opted-out |
Expand Down
3 changes: 2 additions & 1 deletion content/docs/administration/automation.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,12 @@ description: 验证规则、流程、计划作业与审批 —— 无需你动

> **自动触发需要 `triggers` 能力。** 记录变更与计划类流程,只有当 stack 的 `requires` 列表包含 `triggers` 时才会触发 —— 它会安装记录变更与计划触发器提供方(计划触发器还依赖 job 服务)。屏幕类流程始终为手动启动。

**HotCRM 中的内置流程**(32 个)。每一行用的都是流程自身的标签 —— 也就是 **Studio → 自动化 → 流程** 里列出、并在 **Studio → 开发者 → 流程运行记录** 里供你挑选的那个名字,因此一次运行可以逐字回到这张表里查:
**HotCRM 中的内置流程**(33 个)。每一行用的都是流程自身的标签 —— 也就是 **Studio → 自动化 → 流程** 里列出、并在 **Studio → 开发者 → 流程运行记录** 里供你挑选的那个名字,因此一次运行可以逐字回到这张表里查:

| 流程 | 触发 | 它做什么 |
| --- | --- | --- |
| **线索转化流程** | 屏幕 | 将合格潜在客户转化为客户 + 联系人(+ 可选商机),然后通知 |
| **将线索年营收写入新客户** | 子流程 | 由**线索转化流程**调用,以系统权限把线索的年营收写入它刚创建的客户 |
| **由商机生成报价** | 屏幕 | 从商机生成报价,并将其推进到 *Proposal* |
| **安排跟进** | 屏幕 | 在线索上创建下一个跟进任务,并已挂好关联与负责人 |
| **将成员登记进营销活动** | 屏幕 | 批量把符合条件的线索或联系人登记进本营销活动,跳过已登记与已退订的 |
Expand Down
3 changes: 2 additions & 1 deletion content/docs/administration/automation.zh-Hant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,12 @@ description: 驗證規則、流程、排程作業與審批 —— 無需你動

> **自動觸發需要 `triggers` 能力。** 記錄變更與排程類流程,只有當 stack 的 `requires` 列表包含 `triggers` 時才會觸發 —— 它會安裝記錄變更與排程觸發器提供方(排程觸發器還依賴 job 服務)。螢幕類流程始終為手動啟動。

**HotCRM 中的內建流程**(32 個)。每一行用的都是流程自身的標籤 —— 也就是 **Studio → Automation → Flows** 裡列出、並在 **Studio → Developer → Flow Runs** 裡供你挑選的那個名字,因此一次執行可以逐字回到這張表裡查:
**HotCRM 中的內建流程**(33 個)。每一行用的都是流程自身的標籤 —— 也就是 **Studio → Automation → Flows** 裡列出、並在 **Studio → Developer → Flow Runs** 裡供你挑選的那個名字,因此一次執行可以逐字回到這張表裡查:

| 流程 | 觸發 | 它做什麼 |
| --- | --- | --- |
| **線索轉化流程** | 螢幕 | 將合格潛在客戶轉化為客戶 + 聯絡人(+ 可選商機),然後通知 |
| **將線索年營收寫入新客戶** | 子流程 | 由**線索轉化流程**呼叫,以系統權限把線索的年營收寫入它剛建立的客戶 |
| **由商機產生報價** | 螢幕 | 從商機生成報價,並將其推進到 *Proposal* |
| **安排跟進** | 螢幕 | 在線索上建立下一個跟進任務,並已掛好關聯與負責人 |
| **將成員登記進行銷活動** | 螢幕 | 批次把符合條件的線索或聯絡人登記進本行銷活動,跳過已登記與已退訂的 |
Expand Down
2 changes: 1 addition & 1 deletion docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ loader registers:
HotCRM v4.0.1
Data: 18 Objects 357 Fields
UI: 1 Apps 14 Views 8 Pages 5 Dashboards 10 Reports 31 Actions
Logic: 32 Flows
Logic: 33 Flows
Security: 12 Positions 7 Permissions
```

Expand Down
3 changes: 2 additions & 1 deletion objectstack.composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ import { campaignHook, campaignMemberHook } from './src/marketing/objects/hooks.
import {
ContactWelcomeFlow, ForecastSnapshotFlow, LeadAssignmentFlow,
AccountApprovalFlow,
LeadConversionFlow, LeadConversionApprovalFlow,
LeadConversionFlow, LeadConversionAccountRevenueFlow, LeadConversionApprovalFlow,
OpportunityApprovalFlow, OpportunityApprovalOnCreateFlow,
OpportunityStatusChangeApprovalFlow, OpportunityQualificationApprovalFlow,
OpportunityStagnationFlow, OpportunityWonAlertFlow, ScheduleFollowUpFlow,
Expand Down Expand Up @@ -218,6 +218,7 @@ export const appFlows = [
CampaignContactMemberEnrollFlow,
AccountApprovalFlow,
LeadConversionFlow,
LeadConversionAccountRevenueFlow,
LeadConversionApprovalFlow,
ScheduleFollowUpFlow,
OpportunityApprovalFlow,
Expand Down
1 change: 1 addition & 0 deletions src/sales/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
* sources every package reads along its edge into sales.
*/
export { LeadConversionFlow } from './lead-conversion.flow';
export { LeadConversionAccountRevenueFlow } from './lead-conversion-account-revenue.flow';
export { LeadConversionApprovalFlow } from './lead-conversion-approval.flow';
export { AccountApprovalFlow } from './account-approval.flow';
export { ScheduleFollowUpFlow } from './schedule-followup.flow';
Expand Down
107 changes: 107 additions & 0 deletions src/sales/flows/lead-conversion-account-revenue.flow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { cel } from '@objectstack/spec';
import type * as Automation from '@objectstack/spec/automation';
type Flow = Automation.Flow;

/**
* The lead's annual revenue, copied onto the account a conversion just
* created: the one elevated write in Convert Lead (#2016).
*
* ## Why this flow exists
*
* `lead_conversion` copies the lead's `annual_revenue` onto the NEW account it
* creates: the lead field documents itself as exactly that value, and the
* account's list views, roll-ups, `account_metrics` and the executive
* dashboard read it. The `sales_rep` permission set makes
* `crm_account.annual_revenue` read-only (`editable: false`), which is
* published policy for admins. A screen flow runs as its caller, so a rep's
* conversion into a new account was refused at `create_account` with
* "Field write denied … [annual_revenue]", and nothing was converted.
*
* The maintainer's ruling (D, then B) keeps the lock and moves the write into
* a dedicated `runAs: 'system'` flow called through a `subflow` node, which is
* AGENTS.md house rule 9 ("Elevate as little as possible"). It waited for the
* platform: through 17.7.0 any signed-in member could start a `runAs: 'system'`
* autolaunched flow directly. Since 18.0.0-next.0 every door that starts a
* flow by name (the trigger route, a `type: 'flow'` action, a declared
* endpoint) refuses a non-system caller this flow, admin included, with
* `PERMISSION_DENIED` / 403 (objectstack#22310, #22407). A parent's `subflow`
* node is the only way in. `test/flow-conversion.test.ts` pins the refusal.
*
* ## Why it copies one column and does not create the account
*
* The account is created by the converting user's own write in
* `lead_conversion` (`create_account`), so the object gate and every other
* field permission still apply to it. This flow writes the one column that
* user may not edit, onto the account that write just created. Same shape as
* `case_escalation_stamp`: the user does the work, a system callee writes the
* protected column afterwards.
*
* Creating the whole account here was measured first and rejected: the system
* context skips the object gate as well as the field lock. A marketing user or
* a service agent (both may read leads; neither may create accounts) then
* created an account through Convert Lead and the run failed one node later at
* `create_contact`, leaving that account behind. Their conversion into a new
* company stops at `create_account` with nothing written, as it always has;
* `test/flow-conversion.test.ts` pins that for the marketing user.
*
* ⚠️ The price of this shape, measured: `account_approval` opens the account's
* approval request on the insert, before this flow runs, so the request's
* submission snapshot carries no annual revenue. The account itself does.
*
* ## ⛔ What this flow deliberately does NOT do
*
* It reads nothing, so it scans nothing (house rule 10 governs elevated scans
* and roll-ups). It updates one row by the id its caller hands it, and the
* caller hands it the id of the account it created in the same run. A system
* run keeps the trigger user's organization, and that account was born in the
* converting user's organization by their own write.
*
* Elevation is not anonymity: the platform carries the converting user
* through a system run, so the update is attributed to them (`updated_by`).
*/
export const LeadConversionAccountRevenueFlow: Flow = {
name: 'lead_conversion_account_revenue',
label: 'Copy Lead Revenue to New Account',
description:
"Write the converted lead's annual revenue onto the account lead_conversion just created, using the system context. Called by lead_conversion through a subflow node.",
type: 'autolaunched',
status: 'active',

// ⭐ THE POINT OF THIS FILE: one `update_record`, one column.
runAs: 'system',

variables: [
// Both supplied by `lead_conversion`'s `copy_account_revenue` node.
// ⛔ `accountId` is only ever the account the caller created in the same
// run. Handing this flow any other account would let a caller overwrite
// revenue it may not edit.
{ name: 'accountId', type: 'text', isInput: true, isOutput: false },
{ name: 'annualRevenue', type: 'number', isInput: true, isOutput: false },
],

nodes: [
// `config: {}`: an autolaunched callee is handed its params by the
// `subflow` executor and loads no trigger record of its own. Same shape as
// `case_escalation_stamp` and the two campaign-member callees.
{ id: 'start', type: 'start', label: 'Start', config: {} },
{
id: 'copy_revenue', type: 'update_record', label: 'Copy Annual Revenue',
config: {
objectName: 'crm_account',
filter: { id: cel`accountId` },
// ⛔ Do not add fields here. Every column in this node is written with
// the elevated context; anything the converting user may write belongs
// in `lead_conversion`'s `create_account`.
fields: { annual_revenue: cel`annualRevenue` },
},
},
{ id: 'end', type: 'end', label: 'End' },
],

edges: [
{ id: 'e1', source: 'start', target: 'copy_revenue', type: 'default' },
{ id: 'e2', source: 'copy_revenue', target: 'end', type: 'default' },
],
};
40 changes: 34 additions & 6 deletions src/sales/flows/lead-conversion.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,11 @@ export const LeadConversionFlow: Flow = {
// hook-owned, and `account_protection` derives it from the `name` written
// here, so an account created by this node is immediately findable by the
// next conversion.
//
// ⛔ No `annual_revenue` here, ever, not even as null. The `sales_rep`
// permission set makes it read-only, and the field lock refuses the KEY
// whatever its value, so carrying it failed every rep's conversion into
// a new account (#2016). `copy_account_revenue` below writes it.
id: 'create_account', type: 'create_record', label: 'Create Account',
config: {
objectName: 'crm_account',
Expand All @@ -447,14 +452,34 @@ export const LeadConversionFlow: Flow = {
name: cel`leadRecord.company`, phone: cel`has(leadRecord.phone) ? leadRecord.phone : null`,
website: cel`has(leadRecord.website) ? leadRecord.website : null`,
industry: cel`has(leadRecord.industry) ? leadRecord.industry : null`,
annual_revenue: cel`has(leadRecord.annual_revenue) ? leadRecord.annual_revenue : null`,
number_of_employees: cel`has(leadRecord.number_of_employees) ? leadRecord.number_of_employees : null`,
billing_address: cel`has(leadRecord.address) ? leadRecord.address : null`,
owner_id: cel`current_user.id`, is_active: true,
},
outputVariable: 'createdAccount',
},
},
{
// The ELEVATED half of the new-account branch, and the only elevation
// in this flow (AGENTS.md house rule 9): the lead's annual revenue,
// written onto the account `create_account` just created by
// `lead_conversion_account_revenue` (`runAs: 'system'`). This flow stays
// `runAs: 'user'`, so the account itself is still the converting user's
// own write, held to their object and field permissions. Why the callee
// copies one column instead of creating the account is recorded in its
// header (`lead-conversion-account-revenue.flow.ts`).
//
// ⛔ `accountId` is only ever `createdAccount.id`: the callee writes
// with the system context onto whatever account it is handed.
id: 'copy_account_revenue', type: 'subflow', label: 'Copy Lead Revenue to Account',
config: {
flowName: 'lead_conversion_account_revenue',
input: {
accountId: cel`createdAccount.id`,
annualRevenue: cel`has(leadRecord.annual_revenue) ? leadRecord.annual_revenue : null`,
},
},
},
{
id: 'use_new_account', type: 'assignment', label: 'Use New Account',
config: { assignments: { accountId: cel`createdAccount.id` } },
Expand Down Expand Up @@ -625,17 +650,17 @@ export const LeadConversionFlow: Flow = {

edges: [
// ⛔ A retired edge's id stays VACANT and every surviving edge keeps the id
// it has always had — `e1`, `e3` and `e15` are gaps on purpose.
// it has always had — `e1`, `e3`, `e7` and `e15` are gaps on purpose.
// Renumbering to close a gap rewrites ids that other notes, tests and run
// logs refer to.
{ id: 'e0', source: 'start', target: 'get_lead', type: 'default' },
// ⛔ When you add an edge, take the next id AFTER THE HIGHEST IN USE — do
// not reuse a vacant one, and do not assume an unused-looking id is free.
// A duplicate id is INERT (traversal filters out-edges by `source`, never
// by `id`), so nothing fails and the collision survives to trap the next
// editor picking out of the sequence. `e1`, `e3` and `e15` are vacant
// retired ids and stay that way; `e27` is above the highest live id, not
// a gap-fill.
// editor picking out of the sequence. `e1`, `e3`, `e7` and `e15` are
// vacant retired ids and stay that way; `e27` is above the highest live
// id, not a gap-fill.
{ id: 'e27', source: 'get_lead', target: 'decision_approval', type: 'default' },
// ── The approval gate (REQ-0005 step 7) ────────────────────────────
//
Expand Down Expand Up @@ -720,7 +745,10 @@ export const LeadConversionFlow: Flow = {
// shape #650 retired from this repo's decision nodes.
{ id: 'e5', source: 'decision_account', target: 'use_existing_account', type: 'default', condition: P`vars.matchedAccount != null`, label: 'Existing' },
{ id: 'e6', source: 'decision_account', target: 'create_account', type: 'default', condition: P`vars.matchedAccount == null`, label: 'New' },
{ id: 'e7', source: 'create_account', target: 'use_new_account', type: 'default' },
// `e7` (create_account → use_new_account) is RETIRED and stays vacant
// (#2016): the revenue copy sits between them now, on `e41` / `e42`.
{ id: 'e41', source: 'create_account', target: 'copy_account_revenue', type: 'default' },
{ id: 'e42', source: 'copy_account_revenue', target: 'use_new_account', type: 'default' },
// Both account branches converge on the contact-dedupe lookup.
{ id: 'e8', source: 'use_new_account', target: 'find_contact', type: 'default' },
{ id: 'e9', source: 'use_existing_account', target: 'find_contact', type: 'default' },
Expand Down
Loading
Loading