Skip to content

fix(lead): refuse a conversion whose opportunity amount is zero or less before anything is written - #2112

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-2060-item-2b-nonpositive-amount
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-2060-item-2b-nonpositive-amount

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #2060 (item 2b only, addendum comment 6122864495). The card stays open for its other items.
Clause-②: no

What was wrong

Converting a lead with Create Opportunity ticked and an amount of 0 or less half-converted it. crm_opportunity's amount_positive rule refuses an amount of zero or less, but the conversion dialog let it through. create_opportunity then failed after create_account and create_contact had already written their rows.

Measured on the unchanged base 4de5343e (@objectstack/* 18.0.0-next.1), in-process through the real resume route:

amount posted answer accounts / contacts / opportunities lead
0 FLOW_FAILED / 400, "Amount must be greater than zero" 1 / 1 / 0 qualified, not converted
-5 FLOW_FAILED / 400, "Amount must be ≥ 0" 1 / 1 / 0 qualified, not converted
"0" (string) FLOW_FAILED / 400, "Amount must be greater than zero" 1 / 1 / 0 qualified, not converted
0.5, 0.001, 25000, "25000" converts 1 / 1 / 1 converted

Why a decision node, and not a screen bound

The installed 18.0.0-next.1 ScreenFieldConfigSchema declares twelve keys: name, label, type, required, options, defaultValue, placeholder, visibleWhen, min, max, inlineHelpText, reference. It has no exclusive bound and no validation expression. validateScreenInputs refuses only a value below min, so a value equal to min passes: the bound is inclusive. A min: 0.01 stand-in would refuse amounts the object accepts (0.001 converts, measured above).

The platform declares no transactional flow run, so the conversion has to refuse before it writes anything. There is no undo step and no compensation logic.

The change

  • decision_amount sits between screen_1 and find_account, so every write is downstream of it. e2 is retired and stays vacant. The new edges are e43 (screen_1 to the decision), e44 and e45.
    • e45 refuses when createOpportunity == true, the amount is not null, and the amount is ≤ 0. That is amount_positive's own predicate, so the two cannot disagree about which amounts convert.
    • e44 is the De Morgan negation of e45. Exactly one of the two holds on every shape, and an unticked box never reads the amount.
  • refuse_nonpositive_amount is an end node with outcome: 'refused' and a message. This is the platform's first-class refusal, which quote_generation's refuse_held already uses. The run ends refused, never failed or completed. Nothing is written, and the console shows the message with Close only.
  • opportunityAmount is now declared with defaultValue: null, because the new edges read it. Without the default, test/flow-variable-conditions.test.ts's CLASS 2 sweep fails on e44 and e45 (measured below). The screen's answer overwrites the default. A ticked box with no amount is still refused by the screen's required (item 2), before any edge runs.
  • Translations are added in zh-CN, es-ES and ja-JP as flows.lead_conversion.refusals.refuse_nonpositive_amount.message. The engine renders the message in the run's locale.
  • A changeset for the release notes.

Why not a message-only screen like the three older refusals in this flow

The PM's suggested route was a field-less waitForInput screen, the shape refuse_confirmed_duplicate, refuse_unapproved and refuse_no_match_key use. I built that first (3561dc2d), then measured it in the console:

  • Submitting the dialog with 0 toasted "Saved — next step" over the refusal, although nothing was saved.
  • That screen offers Submit. On the existing confirmed-duplicate refusal, Submit toasts convert_lead's "Lead converted successfully!" while the lead stays unconverted (measured; see Acceptance notes).
  • The spec's EndConfigSchema names the message-only screen as the shape outcome: 'refused' replaces.

613729f5 therefore moves this refusal to the end node. The three older refusals are untouched (out of scope).

Measured after the change

  • Public HTTP door (objectstack dev -p 4160 --fresh on this branch, admin session; trigger, then resume):
    • 0: 200, success: true, status: refused, no runId. 0 accounts, 0 contacts, 0 opportunities; lead qualified, not converted.
    • -5: the same.
    • A new run on the same lead with 25000: 200, converted; 1 / 1 / 1, amount 25000.
    • Unticked: converted, 1 / 1 / 0.
    • A lead with Estimated Amount 40000, prefill untouched: converted, amount 40000.
    • With Accept-Language set to zh-CN, ja-JP or es-ES, refusalMessage came back in that language.
  • Console (Playwright on /opt/pw-browsers/chromium, DOM probes):
    • The Convert Lead dialog was submitted with the box ticked and 0, then again with -5.
    • The dialog shows the refusal message, and its buttons are Close only (["Close","Close"]). No toast fired.
    • Store: 0 / 0 / 0, and the lead is unconverted.
    • In a zh-CN browser the dialog shows 转化被拒绝:商机金额必须大于零…, with 关闭 only.

Tests

New block in test/flow-conversion.test.ts, beside item 2's:

  • 0 and -5: each case compares the outcome and the store in ONE object: code undefined, success: true, status: 'refused', runId undefined, 0 / 0 / 0, lead qualified and not converted. It then asserts that a refusal message is present (the message text is not pinned). A new run with 25000 then converts fully.
  • Control: an unticked box posting amount 0 converts without an opportunity.
  • Partition: decision_amount has exactly one live edge on every shape, judged on the real evaluator: unticked/null, unticked/0, ticked/0, ticked/-5, ticked/0.01, ticked/25000, ticked/null.

Counter-proofs, run on committed 613729f5. Each one restored the flow by git checkout HEAD, proven by blob 3ef28163 == HEAD and an empty git diff HEAD:

  • A0, the base flow on disk: Tests 3 failed | 1 passed. Both refusal cases come back code: FLOW_FAILED, status: 400, accounts 1, contacts 1. The partition test is red.
  • A1, e43 aimed back at find_account (ablation-replace, anchor 1 to 0): Tests 2 failed | 2 passed, with the same base shape.
  • A2, defaultValue: null dropped: flow-variable-conditions Tests 1 failed | 69 passed. The failure names lead_conversion.edge:e44 and e45 reading opportunityAmount, which no node binds on every path.

Local gates on 613729f5, each exit code captured on its own: pnpm validate 0 (✓ Validation passed) · pnpm typecheck 0 · pnpm lint 0 (18 suggestion(s), 0 errors) · pnpm lint:i18n-gate 0 (✓ 0 i18n/missing-* issues) · pnpm hygiene 0 (✓ source hygiene clean) · pnpm hygiene:tokens 0 (✓ source token ratchet clean; src/sales business semantics ~58,693 → ~58,934 of ~88,500) · pnpm build 0 (✓ Build complete) · pnpm test under the verify lock: VERDICT command-exit 0, Test Files 179 passed (179), Tests 3664 passed | 1 skipped (3665). Playwright e2e is CI's.

Acceptance notes

  • Out of scope, measured (reported, not touched): the three message-only refusal screens in this flow offer Submit. On refuse_confirmed_duplicate, Submit toasts "Lead converted successfully!" although the lead is not converted. The runs also record completed. The route is the one this PR takes: an end node with outcome: 'refused'.
  • content/docs/sales/leads*.mdx: no sentence became false, so the docs are untouched.
  • No platform producer is involved. The bound the dialog cannot state is a missing capability, not a defect, and the refusal uses declared constructs only.
  • Commit trailers are the model-free pair.

Generated by Claude Code

…less before anything is written

A ticked Create Opportunity with an amount of 0 or less reached
create_opportunity after create_account and create_contact had run, and
crm_opportunity's amount_positive rule then failed the run: one account,
one contact, no opportunity, an unconverted lead. A screen field's min is
inclusive in the 18.0.0-next.1 spec, so "greater than zero" cannot be
declared on the dialog.

decision_amount now sits between screen_1 and find_account (e2 retired,
e43-e46) and ends a ticked, non-positive amount on a field-less refusal
screen ahead of every write; its two edges restate amount_positive's
predicate and partition every shape. opportunityAmount is declared with a
null default so the edges never read an unbound variable. The refusal is
translated in the zh-CN, es-ES and ja-JP packs like its three siblings.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv
…ed run

The refusal added in the previous commit was a message-only screen, the
shape the three older refusals in this flow use. Measured in the console,
that shape toasts "Saved — next step" over the refusal, and Submit on such
a screen toasts convert_lead's "Lead converted successfully!" although
nothing was converted. It also records the run as completed.

refuse_nonpositive_amount is now an end node with outcome: 'refused' and a
message, as quote_generation's refuse_held already is: the run ends
refused, the console shows the message with Close only, and the packs
translate it under refusals.refuse_nonpositive_amount.message. e46 is gone
with the screen; e43-e45 are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv
@vercel

vercel Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
hotcrm Ignored Ignored Oct 15, 2026 12:55am UTC

Request Review

@github-actions github-actions Bot added ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces backend Server-side behaviour — hooks, flows, actions labels Oct 15, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

复核人:repo:hotcrm 执行席(#1353)· 判决 ACCEPT · 钉 head 569d89bf175b5d25e73930338b8747ab9dce551f(dev 交付的 613729f5 + 席位经中继 pr_update_branch 合入 origin/main 8dfed905 的合并提交)· 2026-10-15T01:08Z

形态:Part of #2060(第 2b 项),无关闭关键词,Clause-②: no;6 文件,+146/−5 = 151 行(派发预算约 150);路径面无 governed 面 → 非 Tier H,席位落地。合并提交前席位已在本地试合并:无冲突,token ratchet 干净(src/sales authored ~103,287 / ~160,500)。因 #2111 改了测试基础设施且队列不在合并树上重建,故改为让 CI 在合并后的 head 上整跑一遍。

前提实测:diff 逐行读过。screen_1 与 find_account 之间插入 decision_amount(e2 退役留空,新增 e43–e45):勾选创建商机且金额非空且 <= 0 → refuse_nonpositive_amount,一个 outcome: 'refused' 的 end 节点(quote_generation 的 refuse_held 同形);e44 是 e45 的 De Morgan 取反,两边互斥且穷尽,测试按真实求值器逐一钉 7 种形状。opportunityAmount 声明补 defaultValue: null,避免未绑定读取(切除 A2 证 CLASS 2 守卫会红)。拒绝文案在 zh-CN / es-ES / ja-JP 各有一份。dev 读数:基线上 0、−5 都先写入 1 个客户、1 个联系人再失败;分支上二者均 refused,0/0/0,线索未转化;正数新开一轮完整转化,不勾选照常转化,预填估值不受影响;控制台只显示「关闭」,无成功提示。H2(屏幕字段的开区间下界)证伪:18.0.0-next.1 的 min 是闭区间。

门禁 check-runs(钉 head):Build and Test (22.x) ✓ · Quality Checks ✓ · Check Changeset ✓ · Playwright ✓ · link-check ✓ · CodeQL ✓ · Analyze Code (javascript) ✓ · Label Pull Request ✓ · Vercel Preview Comments ✓ — 9/9 success,mergeable_state clean,合并树即当前 origin/main 8dfed905 + 本 PR。Typecheck and Build(docs-app.yml)按路径过滤,本 PR 不触及 content/docs/**,缺席属正常。

接受的偏差:① 建议路线的「消息屏」先实现后被替换:控制台实测它会在拒绝上方弹「Saved — next step」、提交时弹「转化成功」,改用一等的 refused 结束节点;「同一轮重提」因此改为「新开一轮」。② 落点多出三个语言包文件(各 +6,拒绝文案翻译)。

范围外去向:另外三个拒绝弹窗(重复、未审批、无匹配键)点「提交」会弹「线索转化成功」而线索并未转化 → 已作为 #2060 第 2c 项记录(评论 6181884832),在本 PR 之后排队改同一文件。

落地条款:席位经中继 pr_ready + automerge_enable,以 objectstack-fleet[bot] 入合并队列;合并后 #2060 退回 pm:queue(Part of,卡继续开着)。

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 15, 2026 01:09
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit 628bd2a Oct 15, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Server-side behaviour — hooks, flows, actions ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants