Skip to content

A quote's total ignores line-item tax: lines total 10,945 while the quote shows 9,810 (the rollup sums pre-tax subtotals and never fills crm_quote.tax) #2069

Closed

Description

@hotlong

Filing gate ①: a product defect, reproduced and measured. Source: 2026-10-12 hosted-shape browser pass (rig at cloud v1.36.0's pins, HotCRM 4.0.1 from the marketplace).
Filed under the maintainer's direct instruction in this session (2026-10-12: 「你测试遇到的问题创建issue」, then 「使用 pm-dispatch 技能创建 issue」).
Reader: the repo:hotcrm lane's execution seat (the quote rollup).
Dedupe: repo:objectstack-ai/hotcrm is:issue quote tax total → 24 hits, all states. … quote line tax rollup → 10. Nearest: #1193 / #1198 (closed: crm_product.tax_rate inert, ruled for removal), which is product-level tax and not the line→quote rollup. #2060 item 1 (accepted quote takes lines) is a different mechanism.

What happens

A quote's total ignores the tax its line items carry.

Measured on QTE-0007, which has a 10% quote-level discount:

  • Line A: 2 × 5,000, tax 0%.
  • Line B: 1 × 1,000, line discount 10%, 税率 (tax rate) 5%. It is stored as subtotal 900, total_price 945.

Lines total 10,945, but the quote reads:

GET /api/v1/data/crm_quote/RvfVXy77tfiiB2HY?select=subtotal,discount,discount_amount,tax,total_price
→ {"subtotal":10900,"discount":10,"discount_amount":1090,"tax":null,"total_price":9810}

The console shows the related list's 总计 900 / 945 next to the header's 总金额 9,810.00.

After editing line B to qty 3 (line total 2,835), the quote reads subtotal 12700, total_price 11430. The 135 of tax is lost again. Reproduced 2 of 2 (add and edit).

Cause

The quote rollup sums the line subtotals (pre-tax) and never sums line tax into crm_quote.tax, so total_price = subtotal − discount with no tax. Inferred from the stored values; the rollup itself was not traced in source.

Acceptance

  1. Either crm_quote.tax = Σ line tax and total_price = subtotal − discount + tax, or the quote states that tax is excluded. In both cases the header total agrees with the lines the page shows next to it.
  2. Pin it with a test on a quote with a taxed and an untaxed line plus a quote discount.

Related

#2060 item 1 (an accepted quote still takes lines, and its totals drift) is a different mechanism and stays separate.


🤖 Generated with Claude Code

Activity

  1. objectstack-fleet commented on Oct 12, 2026

    @objectstack-fleet
    Contributor

    First touch by the repo:hotcrm seat, session_01WnMDsLSaUQUD41WaWnADcF, 2026-10-12T08:49Z. ⛔ Not a claim.


    Generated by Claude Code

  2. added
    pm:queueReady for the PM dispatch loop
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Oct 12, 2026
  3. objectstack-fleet commented on Oct 12, 2026

    @objectstack-fleet
    Contributor

    Claim: PM loop round R81
    Session: session_01WnMDsLSaUQUD41WaWnADcF
    Account: os-warren (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-2069-quote-tax-rollup
    Worktree: hotcrm-issue-2069
    Domain: repo:hotcrm (single-lane repo, no domain:* taxonomy)
    Seat: repo:hotcrm#1
    File surface: src/revenue/objects/quote_line_item.hook.ts (quote_total_rollup), src/revenue/objects/quote.object.ts (tax / total_price only if their meaning changes), and any src/revenue/** file the token payment shrinks (behaviour unchanged, named in the PR); content/docs/sales/quotes.mdx (+ zh) pricing sentences only if one becomes false; their tests; one .changeset/2069-*.md (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: default (dispatch-gates --tier --repo objectstack-ai/hotcrm hits no declared tier glob for these paths; the tier stays the PM's per-card call)
    Clause-②: no
    Responsibility: the app: quote_total_rollup sums the lines' pre-tax subtotals and adds only the quote's own tax field, so a line's tax_rate never reaches the quote total | none: the platform computes no quote totals | every rep and customer reading a quote whose lines carry tax (QTE-0007: lines 10,945, header 9,810)
    Thread-read: 6120898863
    Serial constraints cleared: src/revenue/** is free: PR #2076 (#2060 item 1) landed as b8f5421 and left ~14 tokens of authored-total headroom (~18,986 / 19,000), so this card pays for its growth with an equal shrink elsewhere in src/revenue (as #2037 did) and ⛔ never moves the ceiling. #2077 (closed-deal lines) is queued behind this card on src/revenue. In flight, disjoint files: #2070 (src/service), #2060 item 2 (lead-conversion.flow.ts). PR #2079 (#2071, pages) is landing.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 12, 2026

    @objectstack-fleet
    Contributor

    os-dev-report

    {
      "issue": 2069,
      "status": "needs_decision",
      "branch": "claude/issue-2069-quote-tax-rollup",
      "pr": null,
      "session": "session_01WnMDsLSaUQUD41WaWnADcF",
      "premise_still_valid": true,
      "summary": "The defect is real and the mechanism is exactly PM assumption 1, reproduced on b8f5421 twice: on the real engine (QTE-0007 shape: line A 10,000; line B subtotal 900, total 945; quote subtotal 10900, discount_amount 1090, tax null, total_price 9810) and at the REST door of a dev server (QTE-0005: one line at tax_rate 5 shows total 36,750, lines sum to 81,750, header stays 86,400 = 80,000 + typed tax 6,400). Assumption 2's stop condition fired: crm_quote.tax is an amount users are told to type. It is on the default form (Totals section) and the inline-editable All Quotes grid with a column total, and it is seeded on 6 of 6 quotes with every line rate deliberately 0 (revenue.seed.ts:279-283). Three shipped texts say so in plain words: products.mdx:50 \"Tax is an amount you type on the quote itself\", products.mdx:94 \"enter it on the quote\", and the 3.1.0 release note. Deriving it from line rates (acceptance branch 1) therefore changes a published field's meaning. That is the option the 2026-08-17 ruling on #1198 explicitly did not take (\"a real tax model is a future product-design card\"), so I wrote no code and return both branches priced with measured token costs. Recommendation: B, which retires the line rate so the quote's typed Tax is the only tax model. It is self-funding at -47 tokens. A second, related defect was measured at the REST door: editing the quote's own Tax or Discount does not update its total until some line is touched. It is reported as a finding and priced into the options.",
      "tests": "No implementation, so no test of a fix exists. Measurements run, all on b8f5421: (1) Temporary uncommitted vitest file on the shipped verify stack, run under the shared lock: `bash os-verify-lock.sh -c 'git rev-parse --short HEAD && pnpm exec vitest run --maxWorkers=2 test/zz-scratch-2069.test.ts'` printed b8f5421, 'Tests  2 passed (2)', 'VERDICT command-exit 0 · held the lock 17s · waited 0s'. An earlier identical run had console output suppressed and printed 'VERDICT command-exit 0 · held the lock 15s · waited 188s (3m08s)'. Readings: line A {subtotal 10000, total_price 10000, tax_rate 0}; line B {subtotal 900, total_price 945, tax_rate 5}; quote {subtotal 10900, discount 10, discount_amount 1090, tax null, total_price 9810}; after a rep's quote update tax 0 to 80: total_price 1000 (subtotal 1000); after discount 0 to 10: discount_amount 0, total_price 1000. The file was removed and the tree is clean. (2) Public door: `objectstack dev -p 4769` from the worktree, sqlite DB local to the worktree, signed in as the dev admin. PATCH crm_quote QTE-0005 tax 6400 to 7000 returned 200, then GET total_price 86400 (expected 87000). PATCH discount 0 to 10 returned 200, then GET discount_amount 0, total_price 86400 (expected 8000 / 79000). PATCH one line's description only returned 200, then GET discount_amount 8000, total_price 79000 (the line rollup catches the header up). After a seed re-upsert, PATCH line tax_rate 0 to 5 returned 200: the line reads subtotal 35000, total_price 36750, lines sum to 81750 and the header reads subtotal 80000, tax 6400, total_price 86400. Build verified as HEAD before that last reading: the prototype markers occur 0 times in dist/objectstack.json, and the HEAD markers 'num(quote.tax)' and '1 + record.tax_rate / 100' occur once each. The server was stopped by its own process group (pgid 10369) and the port no longer answers. (3) Token pricing by on-disk prototypes. Each was applied by an anchored python replace that exits non-zero on a miss and confirmed on disk by grep -c. Each was restored by `git checkout HEAD -- ABSOLUTE_PATH` under trap EXIT INT TERM, then proven by `git hash-object` equal to the HEAD blob (RESTORE-OK, never empty) and an empty `git diff HEAD`. `node scripts/check-source-token-ratchet.mjs`, src/revenue authored total: base 18,986 (exit 0, 'source token ratchet clean'); A 19,031 (exit 1, over by ~31); B 18,939 (exit 0, headroom ~61); Q 19,083 (exit 1, over by ~83); B+Q 19,036 (exit 1, over by ~36); A+Q 19,128 (exit 1, over by ~128).",
      "gates": "NOT MEASURED: pnpm verify (pre-test chain and pnpm test), reason: no diff. The branch head is b8f5421, byte-identical to origin/main, so there is nothing to verify. Run: the scratch vitest under the lock (VERDICT command-exit 0, Tests 2 passed (2), sha b8f5421) and hygiene:tokens on the base tree (exit 0, '✓ source token ratchet clean').",
      "line_budget": "additions 0 / deletions 0. hygiene:tokens src/revenue authored total before 18,986, after 18,986 (no diff). Prototype readings for pricing only: A 19,031 · B 18,939 · Q 19,083 · B+Q 19,036 · A+Q 19,128 against the ~19,000 ceiling.",
      "files_changed": [],
      "base": "b8f5421af26cd32fd9a256f64f110ff812439220",
      "deviations": [
        "No PR and no code. Stopped at the dispatch's assumption-2 stop condition: crm_quote.tax is user-authored, so overwriting it changes product meaning, which makes this needs_decision. The branch was pushed empty at b8f5421 as the claim marker. The worktree was removed after the readings.",
        "Conflict with the dispatch, stated rather than chosen silently. The dispatch says 'Prior ruling to respect: ... tax lives on the LINE (crm_quote_line_item.tax_rate), not the product'. The ruling text does not say that. #1198 comment 5315744424 (2026-08-17) rules only 'remove crm_product.tax_rate' and adds 'The third option (product-rate-driven quote tax) is explicitly NOT taken — a real tax model is a future product-design card if ever wanted'. The 3.1.0 release note says both the line rate and the quote's typed Tax 'stay exactly as they are'. Neither option below re-decides that ruling: B retires a different field by the same enforce-or-remove reasoning, and A is the deferred design.",
        "Conflict with the dispatch's changeset instruction ('patch bump'). If B is ruled, the precedent for a published-field removal is a MINOR bump: crm_product.tax_rate shipped under 3.1.0 'Minor Changes'.",
        "Started a dev server (port 4769, worktree-local sqlite) for a public-door reading, and stopped exactly its process group. Its file watcher rebuilt it and re-upserted the seed while the token prototypes briefly touched src/revenue. The two Q-probe readings were taken before any prototype existed, and the build was verified as HEAD by artifact markers before the last reading.",
        "Prototype edits to quote_line_item.hook.ts, quote_line_item.object.ts and quote.hook.ts existed for seconds each, to price tokens. Each was restored and proven by blob hash and was never committed. quote.hook.ts's freeze was not touched; the Q prototype was an additive beforeUpdate step."
      ],
      "mcp_calls": "0 — no MCP tool used; every GitHub read was `gh api` REST GET (issues 2069, 1193, 1198 and their comments).",
      "api_writes": "1 — POST /repos/objectstack-ai/hotcrm/issues/2069/comments (this os-dev-report), through scripts/pm/post-stamped.mjs and the fleet-write relay. git push of the empty branch is not a REST write.",
      "open_questions": [
        {
          "question": "Q1. Which tax model does a HotCRM quote carry? Today it carries two that never read each other (#1198 measured this, and it is re-measured here). (1) The quote's typed Tax amount, which the total includes. (2) A per-line Tax Rate %, which only the line's own Total shows. #2069 is what a user sees when they use the second one. Acceptance requires the header total to agree with the lines.",
          "options": [
            "A: line rates roll up. quote_total_rollup reads tax_rate, sets crm_quote.tax to the sum of line tax, and total = subtotal - discount_amount + tax + shipping. COST, measured: the hook grows +45 tokens (18,986 to 19,031), 31 over the ceiling, and no honest shrink has been identified. crm_quote.tax stops being an input on every quote that has lines, but it cannot be made readonly: per quote.object.ts:179-193, stripReadonlyFields deletes the rollup's own caller-supplied UPDATE. It therefore stays editable on the form and in the inline grid, and a typed value is silently overwritten at the next line write. With every line at the default rate 0 (all seeded lines), that overwrite is to 0. Example on the seed: the first line edit on QTE-0005 drops its typed 6,400 and the total goes from 86,400 to 80,000. The same happens on the 4 unsettled seeded quotes (QTE-0003/4/5/6) and on any hosted install that followed the docs. Quotes made by Generate Quote start with no lines (quotes.mdx:101), so their tax stays typed until the first line: a hybrid field. A contradicts products.mdx:50 and :94 (x3 locales) and the 3.1.0 release note. Other work: the seed moves its tax onto line rates (8.5% Acme, 8% others), which reproduces today's figures exactly only if tax is levied after the quote discount (see Q3); the hooks-runtime test pinning typed tax (705 = 700-70+50+25) and seed-consistency.test.ts:250 are rewritten. AXES: business need: no measured pull for per-line rates (0 seeded values; the one writer measured is the test pass that filed this card), while the typed quote Tax is the model users were told to use. Long-term: this is the 'real tax model' the 2026-08-17 ruling deferred to a product-design card, built piecemeal under a bug card. AI-error: it leaves an editable field whose value the runtime discards, a declared capability that is not honored. Startup: it expands capability and needs shrink found elsewhere.",
            "B: the quote's typed Tax is the only tax. Retire crm_quote_line_item.tax_rate and drop '* (1 + record.tax_rate / 100)' from the line total_price, so line Total equals line Subtotal, lines sum to the quote Subtotal, and the header reads Subtotal - Discount + Tax + Shipping. QTE-0007 becomes lines 10,000 + 900 = 10,900 = subtotal, total 9,810. COST, measured: -47 tokens (18,986 to 18,939, headroom 61), so it is self-funding. It is a published-field deletion on an apiEnabled object: a rate written via the UI or API is dropped and a write naming it is refused, as 3.1.0 did for the product rate, and a line that carried a rate loses that tax from its displayed Total (hosted QTE-0007 line B: 945 to 900; the quote total is unchanged because the rollup never read the rate). It needs a minor bump by precedent. Surfaces: the field, the formula and comments in quote_line_item.object.ts; 4 locale rows; docs quotes.mdx:63 and products.mdx:50 (x3 locales each); the revenue.seed.ts:279 comment; a whats-new entry. Tests: line-item-conventions.test.ts:132-140 is rewritten, and undeclared-key-probe.test.ts control D uses this exact field as its declared-on-the-quote-line key and must be re-pointed. Measured candidate: crm_quote, the only other stored field declared on the quote line and absent on its twin; this is the same fixture class that blocked #1198. Line Total and Subtotal become equal columns in the related list (cosmetic follow-up). AXES: business need: it keeps the one model that is seeded, on the form and grid, in the price formula, and told to users three times. Long-term: one tax model, the one the product has published. AI-error: no input remains that the money path ignores, and no field is silently overwritten. Startup: it retires an unpulled declared surface immediately and pays its own tokens.",
            "C: the card's literal branch 2, label only. Keep both models and state on the quote or the lines that line tax is excluded from the quote total. COST: about 0 src/revenue tokens (translations and docs only). AXES: it leaves a declared input whose value never reaches the amount the customer is asked to pay, and the header still disagrees with the line Totals shown next to it. It fails acceptance in substance, fails declare-equals-enforce, and invites double tax when a rep fills both. Not recommended.",
            "(Rejected inline, D: add the sum of line tax into total next to the typed tax. The header's own arithmetic then stops adding up, Subtotal - Discount + Tax + Shipping no longer equals Total, and it creates the 'second, contradictory tax number' the seed comment warns against.)"
          ],
          "recommendation": "B. Business need: measured pull exists only for the typed quote Tax. It is on 6 of 6 seeded quotes, on the default form and grid with a column total, in the docs' price formula, and in three shipped instructions. The line rate has 0 seeded values by design and one measured writer, the test pass that filed this card. Long-term: one tax model, and B does not reopen #1198. It applies #1198's enforce-or-remove reasoning to the twin field, while A is the product-design card that ruling deferred. AI-error: B leaves nothing that is declared but discarded, whereas A leaves crm_quote.tax editable but silently overwritten, because readonly would break the rollup. Startup: B contracts immediately and is self-funding at -47 tokens. A grows by 45 tokens and needs at least 31 of shrink nobody has found yet, at least 128 with Q. It is a published-field deletion, so it is the maintainer's call, as #1198 was. What would make A right: a named customer quoting mixed-rate lines (taxable goods and exempt services on one quote). That warrants a product-design card covering the tax base, line-less quotes, migration of typed quote tax, and a guard on a typed tax for itemized quotes, not this bug fix."
        },
        {
          "question": "Q2. Scope: does #2069's fix also make the quote recompute when its OWN Tax, Discount % or Shipping changes? Measured at the REST door: it does not, until some line is touched (finding below). Under B this path carries the only tax, so whether the header agrees after a typed edit depends on it.",
          "options": [
            "(i) Bundle it into #2069. Under B+Q this is +50 tokens net (19,036, over by 36), which needs at least 36 tokens of honest shrink in src/revenue. It adds an additive beforeUpdate step to quote_workflow in quote.hook.ts, outside the claim's declared file surface; the freeze is untouched. Under A+Q it is 19,128, over by 128.",
            "(ii) File it separately from the finding below and sequence it right after #2069 on src/revenue (one writer at a time). #2069 then lands B alone, self-funded and inside its surface."
          ],
          "recommendation": "(ii), unless the seat prefers one PR that makes 'the header agrees' true on every edit path. Long-term and AI-error favour landing both soon, since a typed tax that does not reach the total is the same declared-but-not-honored shape. Startup and the token ceiling favour keeping #2069 self-funding (B alone leaves headroom 61) and giving Q its own shrink budget and surface claim on quote.hook.ts."
        },
        {
          "question": "Q3. Only if A is ruled: is line tax levied before or after the quote-level discount? The docs are silent: quotes.mdx:44-46 names Tax as a term but not its base.",
          "options": [
            "After the discount: tax = sum of (line net x rate) x (1 - quote discount). QTE-0007 gives 9,850.50, which equals the sum of line Totals x 0.9. This matches the seed's documented basis ('8.5% of the post-discount subtotal') and reproduces every seeded tax exactly from line rates. It also taxes the price actually charged.",
            "Before the discount: tax = sum of (line net x rate). QTE-0007 gives 9,855, which equals the sum of line Totals minus the shown Discount Amount, so the header reconciles with the displayed figures. It taxes 4.50 the customer never pays, and the seeded taxes change (Wayne 81,600 becomes 96,000)."
          ],
          "recommendation": "After the discount, because it matches the seed's stated basis and the price actually charged. With a quote discount, neither reading makes the header equal the plain sum of line Totals; the dispatch's tie-break assumed one would."
        }
      ],
      "out_of_scope_findings": [
        "class: a (also b: it contradicts content/docs/sales/quotes.mdx:49, 'Anything you change in the line items, discount, tax or shipping fields, the totals update automatically') · reach: REST door, dev server at b8f5421. PATCH /api/v1/data/crm_quote/QTE-0005 {tax: 7000} returned 200, then GET total_price 86400 (expected 87000). PATCH {discount: 10} returned 200, then GET discount_amount 0, total_price 86400 (expected 8000 / 79000). Only an unrelated line PATCH brings the header to 8000 / 79000. · Seam: docs:quotes.mdx:49 to runtime: quote_total_rollup fires on crm_quote_line_item only (quote_line_item.hook.ts:53-54), and no crm_quote hook recomputes discount_amount or total_price (quote.hook.ts quote_workflow covers expiration, freeze and approval only). Inferred, not measured: quote_accepted_contract_draft reads total_price (quote.hook.ts:380), so a quote accepted right after a discount or tax edit drafts its contract at the stale total. The engine stack agrees (rep update tax 0 to 80 leaves total_price 1000), but it is not a public door. · dedupe words: quote total stale, discount_amount not recomputed, quote tax edit total, quote header recompute, quote discount edit"
      ]
    }

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 12, 2026

    @objectstack-fleet
    Contributor

    Release: session session_01WnMDsLSaUQUD41WaWnADcF · reason: the dev stopped at the dispatch's stop condition (the quote's Tax is an amount users type) · destination: needs-user-decision (this card is the anchor)

    Verdict on the os-dev report on this card — ACCEPT as needs_decision, pr: null; the branch was pushed empty at b8f5421 as the claim marker. repo:hotcrm seat, 2026-10-12T10:57Z.

    Checked: the stop condition fired on evidence, not judgment:

    • crm_quote.tax is on the default form's Totals section and in the inline-editable All Quotes grid;
    • it is seeded on 6 of 6 quotes, with every line rate deliberately 0 (revenue.seed.ts ~279–283);
    • products.mdx:50 says "Tax is an amount you type on the quote itself".

    The defect itself was reproduced twice: QTE-0007's shape on the engine, and QTE-0005 at the REST door of a dev server.

    ⚠️ Seat correction (the dispatch misquoted a ruling): the dispatch said the #1198 ruling put tax "on the line". The ruling (5315744424, 2026-08-17) says only "remove crm_product.tax_rate", and adds that "a real tax model is a future product-design card if ever wanted". The dev was right to flag it. Neither option below re-decides #1198.

    Split out: the quote header does not recompute when the quote's own Tax, Discount % or Shipping changes (measured at the REST door). It is needed whichever model is chosen, so it is filed as its own card, linked below.

    待裁:HotCRM 的报价单到底用哪一种税?

    一句话问题:报价单今天并存两种互不相干的税。一是报价单上手填的「税额」,计入总价;二是每个行项目的「税率 %」,只体现在该行的小计里,不进报价总价。所以客户看到行合计 10,945,报价总价却是 9,810。

    Governing text:

    选项 做什么 客户看到什么 工作量(实测)
    A 行税率汇总 汇总钩子读行税率,crm_quote.tax = Σ 行税额 报价总价含行税;但手填的税额会在下一次改行时被静默覆盖(该字段不能设只读,否则汇总写不进去),种子里 4 张未结报价的手填税会归零 src/revenue +45 tokens,超上限 31,尚无可删出处;重写 2 个测试与种子
    B 只留手填税额 退役行项目的 tax_rate,行合计 = 行小计 行合计 = 报价小计;表头 = 小计 − 折扣 + 税 + 运费,处处一致;曾填过行税率的行(托管 QTE-0007 那一行)行合计从 945 变 900 src/revenue −47 tokens(自筹余量 61);删已发布字段 → 按 3.1.0 先例 minor 版本;4 个语言行、文档 2 页 × 3 语言、2 个测试重指向
    C 只加说明 两种税都留,标注「行税不计入报价总价」 表头仍与行合计不一致;两边都填会双重征税 约 0 token(只改文档与译文)

    被排除:D(把行税再加进总价、与手填税并列)会让表头算式自相矛盾,种子注释已明确警告这种「第二个矛盾的税数」。

    业务含义直译:

    四轴(业务立场):

    推荐:B;回退:C(只在维护者不愿删已发布字段时)。A 只在出现具名客户需要「同一报价混合税率」(应税商品 + 免税服务)时才对,那应是一张覆盖税基、无行报价、手填税迁移的产品设计卡,不是本缺陷修复。

    • 置信缺口:托管环境里是否有客户填过行税率,未测。B 会让这些行的「行合计」显示变化(报价总价不变,因为汇总从未读它)。
    • 自检:只看①选 B;②③④ 是否翻转:否。dev 的推荐同为 B,席位独立按①得出。

    裁后执行:

    • 选 B:本卡转回 pm:queue,派 dev 退役行税率(minor changeset,4 个语言行,文档 2 页 × 3 语言,种子注释,whats-new,2 个测试重指向),PR 标题与 changeset 点明是已发布字段删除。
    • 选 C:派一个只改文档与译文的 PR。
    • 选 A:本卡转为产品设计卡,先定税基(折扣前/后)、无行报价与手填税迁移,再派。

    os-decision-facets
    ① 项目长远合理性:B 收缩为一个已发布的税模型,与 #1198 同理退役未进金额的税率;A 造一个被静默覆盖的字段。
    ② 实际业务拉动:手填税额 6/6 种子、表单、列表、文档都在用;行税率零种子值、零具名用户。
    ③ 防 AI 犯错:B 之后无「声明不兑现」的输入,写已退役字段被响亮拒绝;A 静默覆盖,C 静默双重征税。
    ④ 创业阶段不扩散:B 立即退役、token 自筹(−47);A 扩张且超上限 31。
    Prior rulings read: tax_rate / quote tax over #1193 / #1198 → 1 ruling (5315744424: remove the product rate; tax model deferred); ADR none found; thread: this card's dev report.


    Generated by Claude Code

  6. added
    needs-user-decisionNeeds the maintainer's call before work proceeds
    and removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Oct 12, 2026
  7. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    Contributor

    Ruling: letter B, one tax model, typed on the quote · the maintainer's word, verbatim: 「hotcrm 三个决裁都同意」 (triage seat session session_01AavokzJ5DndAwitDXvKy4U, on the batch the seat presented in chat with each card's own recommendation)

    Recorded by the triage seat (objectstack seat post objectstack-ai/objectstack#6015) · 2026-10-13T09:01Z. ⛔ Not a claim, ⛔ not a dispatch. needs-user-decision → pm:queue in this act, for the repo:hotcrm seat to execute.

    What the ruling binds (the card's own 裁后执行 for B):

    • The line item's tax_rate is retired. A line's total is its subtotal, and the quote header reads subtotal − discount + tax + shipping, everywhere.
    • It removes a published field, so the release is a minor with the removal named in the PR title and the changeset (the 3.1.0 precedent).
      • The work covers 4 locale rows, the docs (2 pages × 3 locales), the seed comment, whats-new, and 2 tests re-pointed.
      • It is self-funding at about −47 tokens in src/revenue.
    • Before the PR merges: the changeset states what a hosted customer who filled a line rate sees. That line's total changes (945 → 900 on QTE-0007's shape). The quote total does not, because the rollup never read the rate.
    • This is not a re-decision of [Decision] crm_product.tax_rate: enforcing it is measurably wrong, so the remaining option is removal — which needs a ruling #1198. A real tax model stays a future product-design card, only if a named customer needs mixed tax rates on one quote.
    • The split-out finding (the header does not recompute when the quote's own Tax, Discount % or Shipping changes) stays on its own card.
  8. added
    pm:queueReady for the PM dispatch loop
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    needs-user-decisionNeeds the maintainer's call before work proceeds
    pm:queueReady for the PM dispatch loop
    on Oct 13, 2026
  9. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    Contributor

    Claim: PM loop round R82
    Session: session_01BCDqr1DqnQjEh5jA9gGkUG
    Account: os-justin (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-2069-quote-tax-rollup
    Worktree: hotcrm-issue-2069
    Domain: repo:hotcrm (single-lane repo, no domain:* taxonomy)
    Seat: repo:hotcrm#1
    File surface: src/revenue/objects/quote_line_item.object.ts (tax_rate retired; total_price = the line subtotal), every other src/revenue/** reader of the line's tax_rate (hook, view, page, seed comment at revenue.seed.ts ~279), the four locale packs' tax_rate rows under src/sales/translations/**, content/docs/** pages that describe line tax (2 pages × 3 locales, per the ruling) and the whats-new entry, the 2 tests the ruling names (re-pointed) plus one new test for acceptance 2, one .changeset/2069-*.md (minor; the removal named) (stop on breach; explain in the report)
    Container & model: M (retiring a published field: judgment on every reader and on the hosted-customer sentence), mode:subagent, model: default (dispatch-gates --tier --repo objectstack-ai/hotcrm hits no declared tier glob for these paths; the tier stays the PM's per-card call)
    Clause-②: no
    Responsibility: the app: crm_quote_line_item.tax_rate feeds the line's total_price while the quote rollup sums pre-tax subtotals | none: the platform rolls up what the app declares | every rep who reads a quote's lines beside its header (QTE-0007: lines 10,945, header 9,810)
    Ruling-ref: 6142078386 (letter B; the maintainer's 「hotcrm 三个决裁都同意」, recorded by the triage seat; fetched in this act)
    Thread-read: 6142078386
    Serial constraints cleared: src/revenue/** hot-file queue (seat post §3): #2069 → #2083 → #2077; nothing in flight on it. #2031 (the 18.0.0-next.0 upgrade) is in the decision box; its branch be03a57 does not touch src/revenue, and this card shrinks src/revenue (~−47 tokens, per the ruling's measurement), which only helps it. In flight in this lane: #2066 (crm.app.ts, assets/icon.svg, docs/feature-inventory.md ADM-001), disjoint. The old branch head b8f5421 (the R81 empty claim marker) is an ancestor of main 0f84c9c (REST compare: ahead 5, behind 0), so this branch continues by fast-forward.


    Generated by Claude Code

  10. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    Contributor

    os-dev-report

    {
      "issue": 2069,
      "status": "done",
      "branch": "claude/issue-2069-quote-tax-rollup",
      "pr": "https://github.com/objectstack-ai/hotcrm/pull/2089",
      "session": "session_01BCDqr1DqnQjEh5jA9gGkUG (subagent; the dispatching seat's session)",
      "premise_still_valid": true,
      "summary": "Ruling B is executed. crm_quote_line_item.tax_rate is removed. The line's total_price is now subtotal's own expression (the '* (1 + record.tax_rate / 100)' multiplier is gone), so line totals add up to the quote Subtotal. The header stays subtotal - discount + tax + shipping, and quote_total_rollup, which never read the rate, is unchanged. Also done: 4 locale rows removed; the comments that described the rate corrected (object, twin, seed); the quotes and products docs x3 locales say a line carries no tax; the whats-new 3.1.0 entry x3 gains a forward note; docs/STATUS.md goes from 358 to 357 Fields; and a minor changeset carries the measured hosted-customer sentence. Acceptance 2 is a new hooks-runtime pin on QTE-0007's shape. It was red on the unfixed tree (lines 10945 vs subtotal 10900, total 9810) and is green after. The two named tests are re-pointed. #2083 is not addressed here.",
      "tests": "RED BEFORE: the new hooks-runtime test on HEAD 0f84c9c7 with only the test file modified, run under the lock as 'pnpm exec vitest run --maxWorkers=2 test/hooks-runtime.test.ts -t QTE-0007'. It printed 'AssertionError: expected { lines: 10945, subtotal: 10900, ...(1) } to deeply equal { lines: 10900, subtotal: 10900, ...(1) }' ('- lines: 10900 / + lines: 10945 / subtotal: 10900 / total: 9810'), 'Tests 1 failed | 16 skipped (17)', 'os-verify-lock: VERDICT command-exit 1'. TARGETED AFTER (uncommitted fix): hooks-runtime, line-item-conventions, undeclared-key-probe, docs-declared-versions, docs-object-term-consistency and docs-metadata-counts, 'Test Files 6 passed (6) / Tests 116 passed (116)', 'VERDICT command-exit 0'. FULL SUITE on fe3f05ee: 'pnpm test' 'Test Files 179 passed (179)', 'Tests 3691 passed | 1 skipped (3692)', 'VERDICT command-exit 0 · held the lock 685s (11m25s)'. HOSTED UPGRADE READING: I built base 0f84c9c7 and head 9b4516d7. Marker check: base dist has '1 + record.tax_rate / 100' x1 and '\"tax_rate\"' x5; head dist has 0 and 0. I booted base with 'objectstack dev --no-watch' on a scratch sqlite file and wrote QTE-0007's shape over REST with line B tax_rate 5. Base read: line B total_price 945, lines 10945, header {subtotal 10900, discount_amount 1090, tax null, total_price 9810}. I stopped it by its process group and booted head on the same file. The boot log reads '[schema-drift] crm_quote_line_item.tax_rate: column exists in the database but not in metadata (orphaned) — \"os migrate apply --allow-destructive\" to drop it.'. Line B total_price is 900 with no tax_rate key, and the header is unchanged at 10900 / 9810. PATCH line B {tax_rate:5} returned 400 INVALID_FIELD \"Unknown field 'tax_rate' on object 'crm_quote_line_item'\". 'os migrate plan' (exit 0) lists 'crm_quote_line_item.tax_rate [drop_column]' as its only destructive change. 'os migrate unmapped-columns --object crm_quote_line_item --json' (exit 0) returns columns [{tax_rate, float}] with count 22, line B 5 and the rest 0. A console DOM probe on that quote read related-list Totals 900 + 10000 = 10900, equal to header Subtotal 10,900.00, with Total Price 9,810.00. BROWSER, FRESH DB: 'pnpm dev' on port 4001 with a fresh .objectstack DB, signed in as admin@objectos.ai. The rated line insert returned 400 INVALID_FIELD. DOM probe: {rows:2, sumLineTotal:10900, sumLineSubtotal:10900, headerSubtotal:10900, headerTotal:9810, agree:true}. The line record page shows no Tax Rate (Sales Price and Total present). Chromium /opt/pw-browsers/chromium; every server I started was stopped by its own process group (31127, 32716, 4159), and each port was confirmed closed.",
      "gates": [
        "pnpm validate · '✓ Validation passed (3529ms)', 'Data: 18 Objects  357 Fields' · exit 0 · fe3f05ee",
        "pnpm typecheck · 'tsc --noEmit' (no output) · exit 0 · fe3f05ee",
        "pnpm lint · '18 suggestion(s)', pre-existing; the quote-line entries are the unchanged rollup/name/inline-edit/master-detail suggestions · exit 0 · fe3f05ee",
        "pnpm lint:i18n-gate · '✓ i18n lint gate: 0 i18n/missing-* issues (18 total lint issue(s) reported, unaffected by this gate)' · exit 0 · fe3f05ee",
        "pnpm hygiene · '✓ source hygiene clean' · exit 0 · fe3f05ee",
        "pnpm hygiene:tokens · '✓ source token ratchet clean'; src/revenue authored total ~18,939 (ceiling ~19,000, headroom ~61) · exit 0 · fe3f05ee",
        "pnpm build · 'Artifact: dist/objectstack.json (2348.6 KB)' · exit 0 · fe3f05ee",
        "pnpm test · 'Test Files 179 passed (179)', 'Tests 3691 passed | 1 skipped (3692)', 'os-verify-lock: VERDICT command-exit 0' · exit 0 · fe3f05ee",
        "The 7 pre-test links ran under one lock hold: 'VERDICT command-exit 0 · held the lock 47s · waited 163s'. pnpm test ran in a second hold.",
        "CI on PR 2089 at fe3f05ee when reported: Check Changeset, Build and Test (22.x), Quality Checks, Typecheck and Build, Playwright, Analyze Code and link-check all in_progress; Label Pull Request success. Not waited for."
      ],
      "line_budget": "21 files, +119 / -79. hygiene:tokens src/revenue: authored total ~18,986 before, ~18,939 after (-47; ceiling ~19,000; headroom 14 to 61). Business semantics ~15,846 to ~15,799. Interaction layer ~2,165 unchanged.",
      "files_changed": [
        ".changeset/2069-retire-line-tax-rate.md",
        "content/docs/revenue/products.mdx",
        "content/docs/revenue/products.zh-Hans.mdx",
        "content/docs/revenue/products.zh-Hant.mdx",
        "content/docs/sales/quotes.mdx",
        "content/docs/sales/quotes.zh-Hans.mdx",
        "content/docs/sales/quotes.zh-Hant.mdx",
        "content/docs/whats-new.mdx",
        "content/docs/whats-new.zh-Hans.mdx",
        "content/docs/whats-new.zh-Hant.mdx",
        "docs/STATUS.md",
        "src/revenue/data/revenue.seed.ts",
        "src/revenue/objects/opportunity_line_item.object.ts",
        "src/revenue/objects/quote_line_item.object.ts",
        "src/sales/translations/en/objects.commerce.ts",
        "src/sales/translations/es-ES/objects.commerce.ts",
        "src/sales/translations/ja-JP/objects.commerce.ts",
        "src/sales/translations/zh-CN/objects.commerce.ts",
        "test/hooks-runtime.test.ts",
        "test/line-item-conventions.test.ts",
        "test/undeclared-key-probe.test.ts"
      ],
      "base": "0f84c9c72dc2e2ced4990de28b737c5597a3d5b4",
      "head": "fe3f05eea1f1cf78f361e3258d2faf4d95b6a0c2",
      "deviations": [
        "File surface: docs/STATUS.md is outside the claim's list. Its validator transcript goes from 358 to 357 Fields because test/docs-declared-versions.test.ts pins it against the registered stack. The 3.1.0 precedent 1486ec0c did the same, and the build prints 357. src/revenue/objects/opportunity_line_item.object.ts ('its eleven fields' to 'ten') is a src/revenue reader of the fact, so it is inside the claim's 'every other src/revenue reader'.",
        "whats-new: I read the ruling's item as the 3.1.0 entry that names the line rate ('Tax is unchanged where it is actually computed: the quote line item's own rate'); R81's option B listed 'a whats-new entry' among the surfaces naming the field. No feature PR adds a release section. The release PR writes it from changesets, as 3.1.0's was written from 1486ec0c's changeset. The dated entry is kept and gains a forward note x3 locales. No new section was added. If the seat wants an upcoming-release section instead, it is a separate decision.",
        "Probe re-point: control D uses crm_quote.tax (KEY 'tax', value 10, stored value read back), not R81's suggested 'crm_quote' lookup. A lookup's value must be a real quote id, which would break the same-key-same-value control and make it depend on reference semantics. The reading fields were renamed from declaredTwin/declaredTwinValue to declared/declaredValue, since the declaring object is no longer the twin. All 3 drivers pass.",
        "PM assumption 1 confirmed and extended. The re-grep found no further readers in views, pages, field groups, hooks, flows, datasets, reports, mappings, assets/import-templates or e2e. Extra readers of the removal: the twin's field-count comment, docs/STATUS.md, and the whats-new 3.1.0 entry. Out of reach: docs/feature-inventory.md QUO-007 (see the findings).",
        "PM assumption 2 confirmed: -47 tokens exactly (18,986 to 18,939). The tests that needed changes were exactly the 2 named, line-item-conventions and undeclared-key-probe; the full suite found no third.",
        "PM assumption 4 measured, not inferred. The changeset states that the column stays orphaned, the boot log names it, 'os migrate plan' lists it as destructive, 'os migrate unmapped-columns' reads the stored rates by record id, and 'apply --allow-destructive' drops it.",
        "Worktree history was deepened with 'git fetch --deepen=1500' to read the 3.1.0 precedent. That is a shared .git object-store change (refs untouched besides FETCH_HEAD).",
        "I started 3 local servers (ports 4771, 4772, 4001) and stopped each by its own process group. The #2070 sibling's server on port 4172 was not touched. A temporary detached base worktree /home/user/hotcrm-issue-2069-base was created for the base build and then removed, and /home/user/hotcrm-issue-2069 was removed after the PR opened. Both had clean status.",
        "Commit trailers are model-free ('Co-authored-by: Claude' plus Claude-Session) per the agent definition, overriding the harness reminder's model-named form. Commits carry no card trailer.",
        "No label writes, per the dispatch."
      ],
      "mcp_calls": "0 — no MCP tool used. GitHub reads were 'gh api' REST GETs: issue 2069 and its comments, pulls/2089, and commits/fe3f05ee/check-runs.",
      "api_writes": "3 REST writes on objectstack-ai/hotcrm, all through the fleet-write relay as objectstack-fleet[bot]. (1) and (2) came from relay request fw-20261013T103141Z-f684f3 (run 38383975470, a workflow dispatch on objectstack-ai/objectstack, HTTP 204): POST /repos/objectstack-ai/hotcrm/pulls (draft, read back as 8428 bytes sent and 8428 stored, identical) and POST /repos/objectstack-ai/hotcrm/issues/2089/assignees (os-justin). (3) is POST /repos/objectstack-ai/hotcrm/issues/2069/comments, this os-dev-report, via scripts/pm/post-stamped.mjs. The git pushes of the branch (b8f5421 to 0f84c9c7 to 9b4516d7 to fe3f05ee, all fast-forward) are not REST writes.",
      "open_questions": [],
      "out_of_scope_findings": [
        "carrier: the hotcrm seat, or #2066's PR (its claim holds docs/feature-inventory.md, ADM-001 row) · docs/feature-inventory.md:119 QUO-007 still says '行级税率公式' (line-level tax-rate formula), which this PR makes false. It is internal maintainer docs with no guard reading it, so condition 3 of the in-place-fix exemption fails and the file is not touched · noted, not filed",
        "carrier: 承接者:无 · the quote's line-items panel now shows Subtotal and Total as equal columns (relatedListColumns unchanged). R81 named this a cosmetic follow-up inside option B, and the ruling took B with it stated · in PR Acceptance notes only",
        "carrier: 承接者:无 · the line-items panel's formula columns render unformatted ('10000' beside Sales Price '5,000.00'), seen in base and head alike; this predates the change · in PR Acceptance notes only"
      ]
    }

    Generated by Claude Code

  11. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    Contributor

    Verdict on the os-dev report on this card for PR #2089: ACCEPT (ruling B, Ruling-ref: 6142078386). repo:hotcrm seat, session_01BCDqr1DqnQjEh5jA9gGkUG, 2026-10-13T10:37Z. The seat lands it through the merge queue once every check on head 31e3829c is green.

    Checked on GitHub, head 31e3829c:

    • Shape: draft → main, assignee os-justin, title fix(revenue)!: remove crm_quote_line_item.tax_rate — … (the removal is named, per the ruling). The first line is Fixes #2069, and the body carries no other closing keyword (full-body grep). 22 files, +120 / −80; not governed (check-governed-merges --pr).

    • The ruling's items, one by one:

      • tax_rate is removed;
      • the line total_price is subtotal's expression, so the multiplier is gone;
      • 4 locale rows;
      • quotes and products docs × 3 locales;
      • the seed comment;
      • the whats-new 3.1.0 entry × 3 gains a forward note (the dev's reading of "whats-new"; no release section is written by a feature PR);
      • the 2 named tests re-pointed, plus a new hooks-runtime pin;
      • a minor changeset.

      Editing a quote's own Tax, Discount % or Shipping does not update its total until some line item is touched (the docs promise the totals update automatically) #2083 is not addressed here.

    • Changeset, read sentence by sentence:

      • the two tax models ✓;
      • 10,945 vs 9,810 (the card's measurement) ✓;
      • the typed Tax on every demo quote, the form, the list and the docs formula (R81 analysis) ✓;
      • 400 INVALID_FIELD on a write naming it (measured) ✓;
      • no demo line carried a rate (seed comment) ✓;
      • the hosted sentence 945 → 900, header unchanged (measured on a base → head boot over the same sqlite file) ✓;
      • the orphaned column, os migrate plan / unmapped-columns / apply --allow-destructive (each measured, exit 0) ✓.
    • Docs: quotes.mdx "Both are the line after its own discount … a line carries no tax of its own" ✓; products.mdx "a quote line item carries no tax of its own" ✓.

    • Tokens: src/revenue authored ~18,986 → ~18,939 (−47, exactly the ruling's figure).

    • Gates: the dev's pnpm verify links all reported exit 0 on fe3f05ee (179 / 179 test files). The new pin was red on the unfixed tree (lines: 10945 vs 10900) and green after.

    • Patch round (seat-requested, one line): docs/feature-inventory.md QUO-007 "行级税率公式" → "行合计 = 扣除本行折扣后的金额,行项目不单独计税". This PR had made the phrase false, and feat(branding): replace the app icon with the flat H mark and set primaryColor to its orange #2087 released the file. 31e3829c touches only that line; pnpm hygiene and test/docs-zh-hant-justification.test.ts (the one docs walker that scans docs/) reported exit 0 on it.

    • CI on 31e3829c at verdict: Check Changeset, Quality Checks, Typecheck and Build, CodeQL, link-check, Label all success; Build and Test (22.x) and Playwright are in_progress.

    Acceptance notes, not filed (cosmetic, no reach:): the line-items panel now shows Subtotal and Total as equal columns; its formula columns render unformatted (pre-existing).


    Generated by Claude Code

  12. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    Contributor

    Landed: PR #2089 MERGED as 77de62d4 on main through the merge queue (merged 10:50:02Z). This card was closed completed by its Fixes line. repo:hotcrm seat, 2026-10-13T10:50Z.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions