From 3561dc2d8da8a761c918eab0a189bacc96c8d7d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 00:21:41 +0000 Subject: [PATCH 1/2] fix(lead): refuse a conversion with an opportunity amount of zero or 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 Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv --- .changeset/2060-item-2b-nonpositive-amount.md | 7 ++ src/sales/flows/lead-conversion.flow.ts | 49 +++++++++++-- src/sales/translations/es-ES/app.ts | 5 ++ src/sales/translations/ja-JP/app.ts | 5 ++ src/sales/translations/zh-CN/app.ts | 5 ++ test/flow-conversion.test.ts | 73 ++++++++++++++++++- 6 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 .changeset/2060-item-2b-nonpositive-amount.md diff --git a/.changeset/2060-item-2b-nonpositive-amount.md b/.changeset/2060-item-2b-nonpositive-amount.md new file mode 100644 index 00000000..f5924852 --- /dev/null +++ b/.changeset/2060-item-2b-nonpositive-amount.md @@ -0,0 +1,7 @@ +--- +"hotcrm": patch +--- + +**Converting a lead with Create Opportunity ticked and an amount of zero or less no longer leaves it half-converted.** Every opportunity needs an amount greater than zero, but the conversion dialog accepted 0 or a negative figure. Submitting it created the account and the contact first, then failed on the opportunity with "Amount must be greater than zero". The lead was left with a new account and contact, no opportunity, and still not converted. + +Such a conversion now stops right after the dialog, before anything is created, and says why: the opportunity amount must be greater than zero. Convert the lead again with the amount the deal is worth, or leave Create Opportunity unticked to convert it without one. A positive amount converts exactly as before. diff --git a/src/sales/flows/lead-conversion.flow.ts b/src/sales/flows/lead-conversion.flow.ts index e81cbd92..21bf6985 100644 --- a/src/sales/flows/lead-conversion.flow.ts +++ b/src/sales/flows/lead-conversion.flow.ts @@ -39,7 +39,12 @@ export const LeadConversionFlow: Flow = { // pinned in `test/flow-variable-conditions.test.ts`. { name: 'createOpportunity', type: 'boolean', isInput: true, isOutput: false, defaultValue: false }, { name: 'opportunityName', type: 'text', isInput: true, isOutput: false }, - { name: 'opportunityAmount', type: 'text', isInput: true, isOutput: false }, + // Bound to `null` up front because `decision_amount`'s edges read it: a + // runner may post nothing for an unticked box, and an unbound read fails + // the run (`test/flow-variable-conditions.test.ts`, CLASS 2). The screen + // answer overwrites it, and a ticked box with no amount is still refused + // by the screen's `required` before any edge is read. + { name: 'opportunityAmount', type: 'text', isInput: true, isOutput: false, defaultValue: null }, // The conversion's close date, collected on the screen below // instead of stamped inside `create_opportunity`. Declared with NO // `defaultValue`, and that is measured rather than chosen: the engine @@ -138,7 +143,10 @@ export const LeadConversionFlow: Flow = { // `notNull`, and without this a ticked box with no amount reached // `create_opportunity` after the account and contact were written, // leaving the lead half-converted (#2060 item 2). Visible-only, so - // an unticked box still converts with no amount. + // an unticked box still converts with no amount. The column's other + // rule, `amount_positive` (greater than zero), cannot be said here: + // a screen field's `min` is inclusive. `decision_amount` refuses it + // instead, before any write (#2060 item 2b). { name: 'opportunityName', label: 'Opportunity Name', type: 'text', required: true, defaultValue: cel`leadRecord.company`, visibleWhen: 'createOpportunity == true' }, { name: 'opportunityAmount', label: 'Opportunity Amount', type: 'currency', required: true, defaultValue: cel`has(leadRecord.estimated_amount) ? leadRecord.estimated_amount : null`, visibleWhen: 'createOpportunity == true' }, // The close date, DEFAULTED AND VISIBLE. `close_date` is what files @@ -362,6 +370,29 @@ export const LeadConversionFlow: Flow = { waitForInput: true, }, }, + { + // Branching is on edges `e44` / `e45` — see `decision_account`. The + // only gate AFTER `screen_1`, because it judges an answer the rep typed + // there, and it dominates every write (`find_account` below only reads). + id: 'decision_amount', type: 'decision', label: 'Opportunity Amount Above Zero?', + }, + { + // The NON-POSITIVE AMOUNT refusal (#2060 item 2b). `crm_opportunity`'s + // `amount_positive` refuses an amount of zero or less, and the platform + // does not undo a failed run, so letting it reach `create_opportunity` + // left the account and contact written and the lead unconverted. The + // edges restate that rule's predicate exactly (`<= 0`, null excluded: + // an empty amount is the screen's `required`), so the two cannot + // disagree about which amounts convert. Same field-less shape as the + // three refusals above; translated in the packs like them. + id: 'refuse_nonpositive_amount', type: 'screen', label: 'Conversion Refused', + config: { + title: 'Conversion refused', + description: + 'An opportunity amount must be greater than zero. Nothing has been created: convert the lead again and enter what the deal is worth, or leave Create Opportunity unticked to convert it without an opportunity.', + waitForInput: true, + }, + }, { // Account dedupe: match on the NORMALIZED company name, never on the raw // one — "Acme Corp" and "ACME Corp" are the same account. @@ -650,7 +681,7 @@ 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`, `e7` and `e15` are gaps on purpose. + // it has always had — `e1`, `e2`, `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' }, @@ -658,7 +689,7 @@ export const LeadConversionFlow: Flow = { // 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`, `e7` and `e15` are + // editor picking out of the sequence. `e1`, `e2`, `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' }, @@ -735,7 +766,15 @@ export const LeadConversionFlow: Flow = { // every create/update sits behind `screen_1`, which this path never // reaches. { id: 'e26', source: 'refuse_confirmed_duplicate', target: 'end', type: 'default' }, - { id: 'e2', source: 'screen_1', target: 'find_account', type: 'default' }, + // `e2` (screen_1 → find_account) is RETIRED and stays vacant (#2060 item + // 2b): the amount gate sits between them now, on `e43` – `e46`. The pair + // is TOTAL and PARTITIONING (`e45` is `e44` negated by De Morgan), pinned + // in `test/flow-conversion.test.ts`; an unticked box never reads the amount. + { id: 'e43', source: 'screen_1', target: 'decision_amount', type: 'default' }, + { id: 'e44', source: 'decision_amount', target: 'find_account', type: 'default', condition: P`vars.createOpportunity != true || vars.opportunityAmount == null || vars.opportunityAmount > 0`, label: 'Amount accepted' }, + { id: 'e45', source: 'decision_amount', target: 'refuse_nonpositive_amount', type: 'default', condition: P`vars.createOpportunity == true && vars.opportunityAmount != null && vars.opportunityAmount <= 0`, label: 'Zero or less' }, + // Rejoins nothing, exactly like `e26` / `e30` / `e33`. + { id: 'e46', source: 'refuse_nonpositive_amount', target: 'end', type: 'default' }, { id: 'e4', source: 'find_account', target: 'decision_account', type: 'default' }, // Existing account → reuse; no account → create. Both converge on create_contact. // `find_account` is reached only with a match key (`e31`), so a match diff --git a/src/sales/translations/es-ES/app.ts b/src/sales/translations/es-ES/app.ts index f1ba35f6..7702d8e0 100644 --- a/src/sales/translations/es-ES/app.ts +++ b/src/sales/translations/es-ES/app.ts @@ -685,6 +685,11 @@ export const appSurface: Omit = { description: 'La empresa de este prospecto no tiene clave de coincidencia, así que la conversión no puede saber a qué cuenta pertenece y no lo va a adivinar: una suposición podría archivar el contacto y la oportunidad bajo otro cliente. Guardar la Empresa del prospecto reconstruye la clave, así que guárdela de nuevo y luego conviértalo. No se ha creado nada.', }, + refuse_nonpositive_amount: { + title: 'Conversión rechazada', + description: + 'El monto de una oportunidad debe ser mayor que cero. No se ha creado nada: vuelva a convertir el prospecto e introduzca lo que vale el negocio, o deje sin marcar ¿Crear oportunidad? para convertirlo sin una oportunidad.', + }, }, }, schedule_followup: { diff --git a/src/sales/translations/ja-JP/app.ts b/src/sales/translations/ja-JP/app.ts index 0782cd47..28bb7797 100644 --- a/src/sales/translations/ja-JP/app.ts +++ b/src/sales/translations/ja-JP/app.ts @@ -687,6 +687,11 @@ export const appSurface: Omit = { description: 'このリードの会社名には照合キーがないため、変換ではどの取引先に属するかを判断できず、推測もしません。推測すると、取引先責任者と商談が別の顧客の下に登録されるおそれがあります。リードの「会社名」を保存するとキーが再作成されるので、もう一度保存してから変換してください。何も作成されていません。', }, + refuse_nonpositive_amount: { + title: '変換は拒否されました', + description: + '商談金額は 0 より大きくなければなりません。何も作成されていません。もう一度リードを変換して取引の金額を入力するか、「商談を作成しますか?」のチェックを外して商談なしで変換してください。', + }, }, }, schedule_followup: { diff --git a/src/sales/translations/zh-CN/app.ts b/src/sales/translations/zh-CN/app.ts index dd55500c..9f7d6464 100644 --- a/src/sales/translations/zh-CN/app.ts +++ b/src/sales/translations/zh-CN/app.ts @@ -686,6 +686,11 @@ export const appSurface: Omit = { description: '该线索的公司没有匹配键,因此转化无法判断它属于哪个客户,也不会去猜测:猜测可能会把联系人和商机归到另一个客户名下。保存线索的「公司」会重新生成匹配键,请重新保存后再转化。尚未创建任何记录。', }, + refuse_nonpositive_amount: { + title: '转化被拒绝', + description: + '商机金额必须大于零。尚未创建任何记录:请重新转化该线索并填写这笔交易的金额,或不勾选「是否创建商机?」,在不创建商机的情况下完成转化。', + }, }, }, schedule_followup: { diff --git a/test/flow-conversion.test.ts b/test/flow-conversion.test.ts index 53a55b10..a06828e5 100644 --- a/test/flow-conversion.test.ts +++ b/test/flow-conversion.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeAll } from 'vitest'; -import type { VerifyStack } from '@objectstack/verify'; +import type { FlowRunRef, VerifyStack } from '@objectstack/verify'; import { LeadConversionFlow } from '../src/sales/flows/lead-conversion.flow'; import { hotcrmStack, signUpPerson, notificationsTo, conditionHolds, type Person, @@ -380,6 +380,77 @@ describe('lead_conversion — a ticked Create Opportunity needs an amount before }); }); +/** + * A ticked Create Opportunity with an amount of zero or less is refused + * BEFORE anything is written (#2060 item 2b). + * + * `crm_opportunity`'s `amount_positive` rule refuses an amount of zero or + * less, and a screen field's `min` is inclusive, so the dialog cannot state + * "greater than zero". On 18.0.0-next.1 such a submission reached + * `create_opportunity` after `create_account` and `create_contact` had run: + * `FLOW_FAILED`, one account, one contact, no opportunity, an unconverted + * lead. `decision_amount` now turns it away right after the dialog, so the + * resume SUCCEEDS and the run pauses on the refusal: the claim is that node + * and an empty store, compared in one object. + */ +describe('lead_conversion — a ticked Create Opportunity with an amount of zero or less is refused before anything is written (#2060 item 2b)', () => { + const state = async (who: { company: string; email: string }, leadId: string) => { + const out = await after(who, leadId); + return { + accounts: out.accounts.length, contacts: out.contacts.length, opportunities: out.opportunities.length, + isConverted: Boolean(out.lead.is_converted), leadStatus: out.lead.status, + }; + }; + const ticked = (amount: number) => ({ createOpportunity: true, opportunityName: 'Deal', opportunityAmount: amount }); + + it.each([['zero', 0], ['negative', -5]] as const)('amount %s: refused with nothing written, and a new run with an amount converts', async (_label, amount) => { + const who = identity(); + const lead = await qualifiedLead(who); + const refused = (await runConversion(lead.id, ticked(amount)).catch((e: unknown) => e)) as Rec; + expect({ code: refused.code, status: refused.status, node: refused.screen?.nodeId, ...(await state(who, lead.id)) }).toEqual({ + code: undefined, status: 'paused', node: 'refuse_nonpositive_amount', + accounts: 0, contacts: 0, opportunities: 0, isConverted: false, leadStatus: 'qualified', + }); + // Closing the refusal is the rep's next click; that path reaches `end` without a write. + expect((await verify.flows.resume(refused as FlowRunRef, {}, { as: rep.token })).success).toBe(true); + expect(await state(who, lead.id)).toMatchObject({ accounts: 0, contacts: 0, isConverted: false }); + + expect((await runConversion(lead.id, ticked(25_000))).error ?? null).toBeNull(); + expect(await state(who, lead.id)).toEqual({ + accounts: 1, contacts: 1, opportunities: 1, isConverted: true, leadStatus: 'converted', + }); + expect((await after(who, lead.id)).opportunities[0]!.amount).toBe(25_000); + }); + + it('control: an unticked box converts without an opportunity whatever amount is posted', async () => { + const who = identity(); + const lead = await qualifiedLead(who); + expect((await runConversion(lead.id, { createOpportunity: false, opportunityAmount: 0 })).error ?? null).toBeNull(); + expect(await state(who, lead.id)).toEqual({ + accounts: 1, contacts: 1, opportunities: 0, isConverted: true, leadStatus: 'converted', + }); + }); + + it('decision_amount answers with exactly one live edge on every shape', () => { + const out = (LeadConversionFlow.edges as Rec[]).filter((e) => e.source === 'decision_amount'); + expect(out, 'decision_amount must branch two ways').toHaveLength(2); + for (const [shape, vars, refuse] of [ + ['unticked, no amount', { createOpportunity: false, opportunityAmount: null }, false], + ['unticked, zero', { createOpportunity: false, opportunityAmount: 0 }, false], + ['ticked, zero', { createOpportunity: true, opportunityAmount: 0 }, true], + ['ticked, negative', { createOpportunity: true, opportunityAmount: -5 }, true], + ['ticked, a cent', { createOpportunity: true, opportunityAmount: 0.01 }, false], + ['ticked, positive', { createOpportunity: true, opportunityAmount: 25_000 }, false], + // Unreachable at runtime (the screen's `required` refuses it first); the edges stay total on it. + ['ticked, no amount', { createOpportunity: true, opportunityAmount: null }, false], + ] as Array<[string, Rec, boolean]>) { + const live = out.filter((e) => conditionHolds(verify, e.condition, vars)); + expect(live, shape).toHaveLength(1); + expect(live[0]!.target === 'refuse_nonpositive_amount', shape).toBe(refuse); + } + }); +}); + /** * A sales REP converts a lead into a NEW account (#2016). * From 613729f50cb5f015aaa011b7527af1d952d20823 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 00:38:25 +0000 Subject: [PATCH 2/2] fix(lead): end the non-positive amount refusal as a first-class refused run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01P8mZuzuyZAHSrgQKmv6ASv --- src/sales/flows/lead-conversion.flow.ts | 24 ++++++++++++++---------- src/sales/translations/es-ES/app.ts | 7 ++++--- src/sales/translations/ja-JP/app.ts | 7 ++++--- src/sales/translations/zh-CN/app.ts | 7 ++++--- test/flow-conversion.test.ts | 18 ++++++++++-------- 5 files changed, 36 insertions(+), 27 deletions(-) diff --git a/src/sales/flows/lead-conversion.flow.ts b/src/sales/flows/lead-conversion.flow.ts index 21bf6985..3ef28163 100644 --- a/src/sales/flows/lead-conversion.flow.ts +++ b/src/sales/flows/lead-conversion.flow.ts @@ -383,14 +383,19 @@ export const LeadConversionFlow: Flow = { // left the account and contact written and the lead unconverted. The // edges restate that rule's predicate exactly (`<= 0`, null excluded: // an empty amount is the screen's `required`), so the two cannot - // disagree about which amounts convert. Same field-less shape as the - // three refusals above; translated in the packs like them. - id: 'refuse_nonpositive_amount', type: 'screen', label: 'Conversion Refused', + // disagree about which amounts convert. + // + // A first-class refusal (`EndConfigSchema` `outcome: 'refused'`), as in + // `quote_generation`, and ⛔ not the message-only screen the three + // refusals above use: the run ends `refused` rather than `completed`, + // and the console shows the message with Close only. A screen here was + // measured to toast "Saved — next step" over a refusal, and its Submit + // toasts `convert_lead`'s "Lead converted successfully!". Translated as + // `refusals.refuse_nonpositive_amount.message` in the packs. + id: 'refuse_nonpositive_amount', type: 'end', label: 'Conversion Refused', config: { - title: 'Conversion refused', - description: - 'An opportunity amount must be greater than zero. Nothing has been created: convert the lead again and enter what the deal is worth, or leave Create Opportunity unticked to convert it without an opportunity.', - waitForInput: true, + outcome: 'refused', + message: 'Conversion refused: an opportunity amount must be greater than zero. Nothing was created. Convert the lead again with the amount the deal is worth, or leave Create Opportunity unticked to convert it without one.', }, }, { @@ -767,14 +772,13 @@ export const LeadConversionFlow: Flow = { // reaches. { id: 'e26', source: 'refuse_confirmed_duplicate', target: 'end', type: 'default' }, // `e2` (screen_1 → find_account) is RETIRED and stays vacant (#2060 item - // 2b): the amount gate sits between them now, on `e43` – `e46`. The pair + // 2b): the amount gate sits between them now, on `e43` – `e45`. The pair // is TOTAL and PARTITIONING (`e45` is `e44` negated by De Morgan), pinned // in `test/flow-conversion.test.ts`; an unticked box never reads the amount. + // `e45` ends the run at its target, a refusing `end`: no edge leaves it. { id: 'e43', source: 'screen_1', target: 'decision_amount', type: 'default' }, { id: 'e44', source: 'decision_amount', target: 'find_account', type: 'default', condition: P`vars.createOpportunity != true || vars.opportunityAmount == null || vars.opportunityAmount > 0`, label: 'Amount accepted' }, { id: 'e45', source: 'decision_amount', target: 'refuse_nonpositive_amount', type: 'default', condition: P`vars.createOpportunity == true && vars.opportunityAmount != null && vars.opportunityAmount <= 0`, label: 'Zero or less' }, - // Rejoins nothing, exactly like `e26` / `e30` / `e33`. - { id: 'e46', source: 'refuse_nonpositive_amount', target: 'end', type: 'default' }, { id: 'e4', source: 'find_account', target: 'decision_account', type: 'default' }, // Existing account → reuse; no account → create. Both converge on create_contact. // `find_account` is reached only with a match key (`e31`), so a match diff --git a/src/sales/translations/es-ES/app.ts b/src/sales/translations/es-ES/app.ts index 7702d8e0..dcdaaf2f 100644 --- a/src/sales/translations/es-ES/app.ts +++ b/src/sales/translations/es-ES/app.ts @@ -685,10 +685,11 @@ export const appSurface: Omit = { description: 'La empresa de este prospecto no tiene clave de coincidencia, así que la conversión no puede saber a qué cuenta pertenece y no lo va a adivinar: una suposición podría archivar el contacto y la oportunidad bajo otro cliente. Guardar la Empresa del prospecto reconstruye la clave, así que guárdela de nuevo y luego conviértalo. No se ha creado nada.', }, + }, + refusals: { refuse_nonpositive_amount: { - title: 'Conversión rechazada', - description: - 'El monto de una oportunidad debe ser mayor que cero. No se ha creado nada: vuelva a convertir el prospecto e introduzca lo que vale el negocio, o deje sin marcar ¿Crear oportunidad? para convertirlo sin una oportunidad.', + message: + 'Conversión rechazada: el monto de una oportunidad debe ser mayor que cero. No se creó nada. Vuelva a convertir el prospecto con el monto que vale el negocio, o deje sin marcar ¿Crear oportunidad? para convertirlo sin una.', }, }, }, diff --git a/src/sales/translations/ja-JP/app.ts b/src/sales/translations/ja-JP/app.ts index 28bb7797..211beb79 100644 --- a/src/sales/translations/ja-JP/app.ts +++ b/src/sales/translations/ja-JP/app.ts @@ -687,10 +687,11 @@ export const appSurface: Omit = { description: 'このリードの会社名には照合キーがないため、変換ではどの取引先に属するかを判断できず、推測もしません。推測すると、取引先責任者と商談が別の顧客の下に登録されるおそれがあります。リードの「会社名」を保存するとキーが再作成されるので、もう一度保存してから変換してください。何も作成されていません。', }, + }, + refusals: { refuse_nonpositive_amount: { - title: '変換は拒否されました', - description: - '商談金額は 0 より大きくなければなりません。何も作成されていません。もう一度リードを変換して取引の金額を入力するか、「商談を作成しますか?」のチェックを外して商談なしで変換してください。', + message: + '変換は拒否されました。商談金額は 0 より大きくなければなりません。何も作成されていません。取引の金額を入力してもう一度リードを変換するか、「商談を作成しますか?」のチェックを外して商談なしで変換してください。', }, }, }, diff --git a/src/sales/translations/zh-CN/app.ts b/src/sales/translations/zh-CN/app.ts index 9f7d6464..b90b44b9 100644 --- a/src/sales/translations/zh-CN/app.ts +++ b/src/sales/translations/zh-CN/app.ts @@ -686,10 +686,11 @@ export const appSurface: Omit = { description: '该线索的公司没有匹配键,因此转化无法判断它属于哪个客户,也不会去猜测:猜测可能会把联系人和商机归到另一个客户名下。保存线索的「公司」会重新生成匹配键,请重新保存后再转化。尚未创建任何记录。', }, + }, + refusals: { refuse_nonpositive_amount: { - title: '转化被拒绝', - description: - '商机金额必须大于零。尚未创建任何记录:请重新转化该线索并填写这笔交易的金额,或不勾选「是否创建商机?」,在不创建商机的情况下完成转化。', + message: + '转化被拒绝:商机金额必须大于零。尚未创建任何内容。请填写这笔交易的金额后重新转化该线索,或不勾选「是否创建商机?」,以不创建商机的方式转化。', }, }, }, diff --git a/test/flow-conversion.test.ts b/test/flow-conversion.test.ts index a06828e5..d476b9ab 100644 --- a/test/flow-conversion.test.ts +++ b/test/flow-conversion.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeAll } from 'vitest'; -import type { FlowRunRef, VerifyStack } from '@objectstack/verify'; +import type { VerifyStack } from '@objectstack/verify'; import { LeadConversionFlow } from '../src/sales/flows/lead-conversion.flow'; import { hotcrmStack, signUpPerson, notificationsTo, conditionHolds, type Person, @@ -389,8 +389,9 @@ describe('lead_conversion — a ticked Create Opportunity needs an amount before * "greater than zero". On 18.0.0-next.1 such a submission reached * `create_opportunity` after `create_account` and `create_contact` had run: * `FLOW_FAILED`, one account, one contact, no opportunity, an unconverted - * lead. `decision_amount` now turns it away right after the dialog, so the - * resume SUCCEEDS and the run pauses on the refusal: the claim is that node + * lead. `decision_amount` now turns it away right after the dialog, at a + * refusing `end`: the resume SUCCEEDS and the run ends `refused` (the only + * refusal in this flow that ends that way), and the claim is that outcome * and an empty store, compared in one object. */ describe('lead_conversion — a ticked Create Opportunity with an amount of zero or less is refused before anything is written (#2060 item 2b)', () => { @@ -407,13 +408,14 @@ describe('lead_conversion — a ticked Create Opportunity with an amount of zero const who = identity(); const lead = await qualifiedLead(who); const refused = (await runConversion(lead.id, ticked(amount)).catch((e: unknown) => e)) as Rec; - expect({ code: refused.code, status: refused.status, node: refused.screen?.nodeId, ...(await state(who, lead.id)) }).toEqual({ - code: undefined, status: 'paused', node: 'refuse_nonpositive_amount', + expect({ + code: refused.code, success: refused.success, status: refused.status, runId: refused.runId, + ...(await state(who, lead.id)), + }).toEqual({ + code: undefined, success: true, status: 'refused', runId: undefined, accounts: 0, contacts: 0, opportunities: 0, isConverted: false, leadStatus: 'qualified', }); - // Closing the refusal is the rep's next click; that path reaches `end` without a write. - expect((await verify.flows.resume(refused as FlowRunRef, {}, { as: rep.token })).success).toBe(true); - expect(await state(who, lead.id)).toMatchObject({ accounts: 0, contacts: 0, isConverted: false }); + expect(String(refused.refusalMessage ?? ''), 'the rep is not told why').not.toBe(''); expect((await runConversion(lead.id, ticked(25_000))).error ?? null).toBeNull(); expect(await state(who, lead.id)).toEqual({