From 9b4516d768651a221816b9b833ca83d812f4e341 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 10:03:15 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(revenue)!:=20retire=20the=20quote=20lin?= =?UTF-8?q?e's=20tax=20rate=20=E2=80=94=20a=20line's=20total=20is=20its=20?= =?UTF-8?q?subtotal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A quote line carried a Tax Rate % that reached only that line's Total, while the quote rollup sums pre-tax subtotals and adds the quote's own typed Tax. On a quote whose line carried a rate, the lines summed to more than the header beside them (lines 10,945 against a 9,810 total on QTE-0007's shape). The quote's typed Tax is the one tax model: it is seeded on every demo quote, on the default form and grid, and is what the docs tell users to fill. So the line rate is removed, and the line's total_price is the same expression as its subtotal. The quote rollup never read the rate and is unchanged: the header is still subtotal - discount + tax + shipping. - crm_quote_line_item.tax_rate and its four locale rows are removed; the comments that described it (object, twin, seed) are corrected. - The quotes and products pages say a line carries no tax, in all three locales; whats-new's 3.1.0 entry notes the later retirement. - docs/STATUS.md's validator transcript goes from 358 to 357 fields. - hooks-runtime pins QTE-0007's shape: the line totals add up to the header's subtotal and a rate on a line is refused (INVALID_FIELD / 400). - line-item-conventions pins total_price == subtotal; the undeclared-key probe's declared control moves from the line's tax_rate to crm_quote.tax. Claude-Session: https://claude.ai/code/session_01BCDqr1DqnQjEh5jA9gGkUG Co-authored-by: Claude --- content/docs/revenue/products.mdx | 2 +- content/docs/revenue/products.zh-Hans.mdx | 2 +- content/docs/revenue/products.zh-Hant.mdx | 2 +- content/docs/sales/quotes.mdx | 2 +- content/docs/sales/quotes.zh-Hans.mdx | 2 +- content/docs/sales/quotes.zh-Hant.mdx | 2 +- content/docs/whats-new.mdx | 3 +- content/docs/whats-new.zh-Hans.mdx | 2 +- content/docs/whats-new.zh-Hant.mdx | 2 +- docs/STATUS.md | 2 +- src/revenue/data/revenue.seed.ts | 5 -- .../objects/opportunity_line_item.object.ts | 2 +- src/revenue/objects/quote_line_item.object.ts | 30 +++-------- src/sales/translations/en/objects.commerce.ts | 1 - .../translations/es-ES/objects.commerce.ts | 1 - .../translations/ja-JP/objects.commerce.ts | 1 - .../translations/zh-CN/objects.commerce.ts | 1 - test/hooks-runtime.test.ts | 27 ++++++++++ test/line-item-conventions.test.ts | 18 ++++--- test/undeclared-key-probe.test.ts | 54 +++++++++---------- 20 files changed, 82 insertions(+), 79 deletions(-) diff --git a/content/docs/revenue/products.mdx b/content/docs/revenue/products.mdx index e747277d9..05dbd2f97 100644 --- a/content/docs/revenue/products.mdx +++ b/content/docs/revenue/products.mdx @@ -47,7 +47,7 @@ Categories and families let you slice reports by product mix — *"how much SMB- The system warns you if Cost is greater than List Price (which would be a losing sale). -**Tax is a quote-level figure, and the catalog carries no rate.** A quote's total is *subtotal − discount + Tax + Shipping*, where **Tax** is an amount you type on the quote itself, and a quote line item has its own **Tax Rate %**, defaulted to 0 and set per line. A product record stores nothing about tax. +**Tax is a quote-level figure, and the catalog carries no rate.** A quote's total is *subtotal − discount + Tax + Shipping*, where **Tax** is an amount you type on the quote itself; a quote line item carries no tax of its own. A product record stores nothing about tax. ## No inventory, no billing schedule diff --git a/content/docs/revenue/products.zh-Hans.mdx b/content/docs/revenue/products.zh-Hans.mdx index 504e66c21..6283d77ce 100644 --- a/content/docs/revenue/products.zh-Hans.mdx +++ b/content/docs/revenue/products.zh-Hans.mdx @@ -47,7 +47,7 @@ description: 你所销售内容的目录——编码、标价和 SKU。 如果成本大于标价(这会是一笔亏本的销售),系统会警告你。 -**税是报价单层面的数字,产品目录不保存任何税率。** 报价单的总额是 *小计 − 折扣 + 税额 + 运费*,其中**税额**是你在报价单上自己填的一个金额;报价明细行有它自己的 **Tax Rate %**,默认为 0、按行设置。产品记录不保存任何与税相关的内容。 +**税是报价单层面的数字,产品目录不保存任何税率。** 报价单的总额是 *小计 − 折扣 + 税额 + 运费*,其中**税额**是你在报价单上自己填的一个金额;报价明细行不单独计税。产品记录不保存任何与税相关的内容。 ## 没有库存,也没有计费周期 diff --git a/content/docs/revenue/products.zh-Hant.mdx b/content/docs/revenue/products.zh-Hant.mdx index e9d3713ae..8961f5295 100644 --- a/content/docs/revenue/products.zh-Hant.mdx +++ b/content/docs/revenue/products.zh-Hant.mdx @@ -49,7 +49,7 @@ description: 你所銷售內容的目錄——編碼、標價和 SKU。 如果成本大於標價(這會是一筆虧本的銷售),系統會警告你。 -**稅是報價單層級的數字,產品目錄不保存任何稅率。** 報價單的總額是 *小計 − 折扣 + 稅額 + 運費*,其中**稅額**是你在報價單上自己填的一個金額;報價明細行有它自己的 **Tax Rate %**,預設為 0、逐行設定。產品記錄不保存任何與稅相關的內容。 +**稅是報價單層級的數字,產品目錄不保存任何稅率。** 報價單的總額是 *小計 − 折扣 + 稅額 + 運費*,其中**稅額**是你在報價單上自己填的一個金額;報價明細行不單獨計稅。產品記錄不保存任何與稅相關的內容。 ## 沒有庫存,也沒有計費週期 diff --git a/content/docs/sales/quotes.mdx b/content/docs/sales/quotes.mdx index 41a27308c..86c6074b2 100644 --- a/content/docs/sales/quotes.mdx +++ b/content/docs/sales/quotes.mdx @@ -60,7 +60,7 @@ A quote can have any number of line items — one per product being sold. Each l The sum of line items rolls up into the quote subtotal. -On the quote's *Related* tab, the line items panel shows each line's product, quantity, sales price, subtotal and total. The subtotal is the line after its own discount and before tax; the total adds the line's tax rate on top. +On the quote's *Related* tab, the line items panel shows each line's product, quantity, sales price, subtotal and total. Both are the line after its own discount, so the line totals add up to the quote's **Subtotal**: a line carries no tax of its own, and tax is the **Tax** amount on the quote. ## Built-in rules diff --git a/content/docs/sales/quotes.zh-Hans.mdx b/content/docs/sales/quotes.zh-Hans.mdx index 771440e2e..da9012dfd 100644 --- a/content/docs/sales/quotes.zh-Hans.mdx +++ b/content/docs/sales/quotes.zh-Hans.mdx @@ -60,7 +60,7 @@ Total Price = Subtotal − Discount + Tax + Shipping & Handling 行项目之和汇总到报价小计中。 -在报价的 *Related* 标签页上,行项目面板列出每一行的产品、数量、单价、小计和合计。小计是扣除该行自身折扣之后、计税之前的金额;合计在此基础上再加上该行的税率。 +在报价的 *Related* 标签页上,行项目面板列出每一行的产品、数量、单价、小计和合计。两者都是扣除该行自身折扣之后的金额,所以各行合计加起来就是报价的**小计**:行项目不单独计税,税是报价单上的**税额**。 ## 内置规则 diff --git a/content/docs/sales/quotes.zh-Hant.mdx b/content/docs/sales/quotes.zh-Hant.mdx index afb4e97a7..91605b0ad 100644 --- a/content/docs/sales/quotes.zh-Hant.mdx +++ b/content/docs/sales/quotes.zh-Hant.mdx @@ -62,7 +62,7 @@ Total Price = Subtotal − Discount + Tax + Shipping & Handling 行項目之和彙總到報價小計中。 -在報價的 *Related* 標籤頁上,行項目面板列出每一行的產品、數量、單價、小計和合計。小計是扣除該行自身折扣之後、計稅之前的金額;合計在此基礎上再加上該行的稅率。 +在報價的 *Related* 標籤頁上,行項目面板列出每一行的產品、數量、單價、小計和合計。兩者都是扣除該行自身折扣之後的金額,所以各行合計加起來就是報價的**小計**:行項目不單獨計稅,稅是報價單上的**稅額**。 ## 內建規則 diff --git a/content/docs/whats-new.mdx b/content/docs/whats-new.mdx index 5faf95e1e..138a638c2 100644 --- a/content/docs/whats-new.mdx +++ b/content/docs/whats-new.mdx @@ -151,7 +151,8 @@ upgrade from 3.0.0 needs no migration step. - **The product catalog's *Default Tax Rate %* is gone.** `crm_product.tax_rate` could be set only through the REST API and no total anywhere read it. Tax is unchanged where it is actually computed: the quote line item's own rate, and - the Tax amount on the quote. + the Tax amount on the quote. (A later release retires the line's rate as well, + leaving the quote's Tax amount as the only tax.) Plus a long tail of fixes, each with its own entry in `CHANGELOG.md`. diff --git a/content/docs/whats-new.zh-Hans.mdx b/content/docs/whats-new.zh-Hans.mdx index f4c163486..6b9301c8b 100644 --- a/content/docs/whats-new.zh-Hans.mdx +++ b/content/docs/whats-new.zh-Hans.mdx @@ -108,7 +108,7 @@ HOTCRM_TOKEN=... ./scripts/wow1-live-schema.sh 只是按它排序的那个页签。 - **产品目录的「默认税率(%)」被移除。** `crm_product.tax_rate` 只能通过 REST API 设置,而且 没有任何一处合计读它。税额在真正参与计算的地方保持不变:报价单明细自己的税率,以及报价单 - 上的税额。 + 上的税额。(后来的版本又退役了明细行自己的税率,报价单上的税额成为唯一的税。) 此外还有一长串修复,每一条都在 `CHANGELOG.md` 里有自己的条目。 diff --git a/content/docs/whats-new.zh-Hant.mdx b/content/docs/whats-new.zh-Hant.mdx index 4ed3b2199..b990e19e2 100644 --- a/content/docs/whats-new.zh-Hant.mdx +++ b/content/docs/whats-new.zh-Hant.mdx @@ -110,7 +110,7 @@ HOTCRM_TOKEN=... ./scripts/wow1-live-schema.sh 只是按它排序的那個頁籤。 - **產品目錄的「預設稅率(%)」被移除。** `crm_product.tax_rate` 只能透過 REST API 設定,而且 沒有任何一處合計讀它。稅額在真正參與計算的地方保持不變:報價單明細自己的稅率,以及報價單 - 上的稅額。 + 上的稅額。(後來的版本又退役了明細行自己的稅率,報價單上的稅額成為唯一的稅。) 此外還有一長串修復,每一條都在 `CHANGELOG.md` 裡有自己的條目。 diff --git a/docs/STATUS.md b/docs/STATUS.md index b2228de85..e721f0ca6 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -19,7 +19,7 @@ loader registers: ```text HotCRM v4.0.1 -Data: 18 Objects 358 Fields +Data: 18 Objects 357 Fields UI: 1 Apps 14 Views 8 Pages 5 Dashboards 10 Reports 31 Actions Logic: 32 Flows Security: 12 Positions 7 Permissions diff --git a/src/revenue/data/revenue.seed.ts b/src/revenue/data/revenue.seed.ts index d233f6c50..80243cf8b 100644 --- a/src/revenue/data/revenue.seed.ts +++ b/src/revenue/data/revenue.seed.ts @@ -276,10 +276,5 @@ export const quotes = defineSeed(Quote, { export const quoteLineItems = defineSeed(QuoteLineItem, { mode: 'upsert', externalId: ['crm_quote', 'crm_product'], - // `tax_rate` is deliberately left at its 0 default: in this app tax is a - // QUOTE-level figure (`crm_quote.tax`, applied after the quote-level - // discount) and `quote_total_rollup` ignores the per-line rate entirely. - // Seeding a rate here would put a second, contradictory tax number on the - // record. records: lineItemRecords('crm_quote', QUOTE_LINES), }); diff --git a/src/revenue/objects/opportunity_line_item.object.ts b/src/revenue/objects/opportunity_line_item.object.ts index 940c718fd..271c4a54a 100644 --- a/src/revenue/objects/opportunity_line_item.object.ts +++ b/src/revenue/objects/opportunity_line_item.object.ts @@ -59,7 +59,7 @@ export const OpportunityLineItem = ObjectSchema.create({ // `quantity` is a pricing field here, not an identity one — it is a factor in // `total_price`, and separating it from the price it multiplies would put the // two halves of one product on two sections. `crm_quote_line_item` takes the - // same split over its eleven fields, and the parents agree: `crm_quote` and + // same split over its ten fields, and the parents agree: `crm_quote` and // `crm_product` both keep every money field in `pricing` — `crm_quote`'s // `tax` included — and both keep `description` in `basic`. // diff --git a/src/revenue/objects/quote_line_item.object.ts b/src/revenue/objects/quote_line_item.object.ts index 31f888105..d848cd23f 100644 --- a/src/revenue/objects/quote_line_item.object.ts +++ b/src/revenue/objects/quote_line_item.object.ts @@ -51,14 +51,9 @@ export const QuoteLineItem = ObjectSchema.create({ highlightFields: ['crm_product', 'quantity', 'unit_price', 'total_price'], // The same two-group split as `crm_opportunity_line_item` (#1453), derived - // from the eleven fields declared here: every factor `total_price` multiplies + // from the ten fields declared here: every factor `total_price` multiplies // is `pricing`, everything that says WHICH line this is is `basic`. // - // `tax_rate` and `subtotal` are pricing, not a section of their own — the - // parent `crm_quote` keeps subtotal / discount / discount_amount / tax / - // shipping_handling / total_price in one `pricing` group. A separate tax - // section would be this repo's only one. - // // ⚠️ Neither group may be a subset of the highlight strip — see the twin // object for the hoisting rule this satisfies. fieldGroups: [ @@ -86,9 +81,7 @@ export const QuoteLineItem = ObjectSchema.create({ // `crm_campaign_member.crm_campaign`). A SUPERSET of the fallback it // replaces (`highlightFields` minus this lookup: Product / Quantity / // Sales Price / Total) plus `subtotal`, the line's price after its own - // discount and before tax — the formula was evaluated on every read and - // shown in no list. It sits before `total_price` because Total is - // Subtotal with the tax multiplier applied on top. + // discount — the formula was evaluated on every read and shown in no list. // // ⛔ Not added to `highlightFields`: the detail page hoists the first // four of that strip, so a fifth entry never reaches the strip, and @@ -152,24 +145,13 @@ export const QuoteLineItem = ObjectSchema.create({ expression: F`record.quantity * record.unit_price * (1 - record.discount / 100)`, }), - tax_rate: Field.percent({ - label: 'Tax Rate %', - group: 'pricing', - scale: 2, - min: 0, - max: 100, - defaultValue: 0, - }), - - // Composed from the same STORED fields `subtotal` reads, not from - // `subtotal` itself — a formula reading another formula depends on the - // platform hydrating that field first, which is the hazard warned against - // at `lead.object.ts:61-64`. The arithmetic is unchanged: this is - // `subtotal`'s expression with the tax multiplier applied on top. + // A line carries no tax: tax is the quote's own amount (#2069), so Total is + // `subtotal`'s expression, not a read of `subtotal` — a formula reading a + // formula depends on hydration order (`lead.object.ts:61-64`). total_price: Field.formula({ label: 'Total', group: 'pricing', - expression: F`record.quantity * record.unit_price * (1 - record.discount / 100) * (1 + record.tax_rate / 100)`, + expression: F`record.quantity * record.unit_price * (1 - record.discount / 100)`, }), line_number: Field.number({ diff --git a/src/sales/translations/en/objects.commerce.ts b/src/sales/translations/en/objects.commerce.ts index ceadfebcd..17240120c 100644 --- a/src/sales/translations/en/objects.commerce.ts +++ b/src/sales/translations/en/objects.commerce.ts @@ -91,7 +91,6 @@ export const commerce: Record = { unit_price: { label: 'Sales Price' }, discount: { label: 'Discount %' }, subtotal: { label: 'Subtotal' }, - tax_rate: { label: 'Tax Rate %' }, total_price: { label: 'Total' }, line_number: { label: 'Line #' }, }, diff --git a/src/sales/translations/es-ES/objects.commerce.ts b/src/sales/translations/es-ES/objects.commerce.ts index 6fc1a4af6..3bd20b9ac 100644 --- a/src/sales/translations/es-ES/objects.commerce.ts +++ b/src/sales/translations/es-ES/objects.commerce.ts @@ -111,7 +111,6 @@ export const commerce: Record = { unit_price: { label: 'Precio de Venta' }, discount: { label: 'Descuento (%)' }, subtotal: { label: 'Subtotal' }, - tax_rate: { label: 'Tasa de Impuesto (%)' }, total_price: { label: 'Total' }, line_number: { label: 'Nº de Línea' }, }, diff --git a/src/sales/translations/ja-JP/objects.commerce.ts b/src/sales/translations/ja-JP/objects.commerce.ts index 3c5952cd5..6e2de36f1 100644 --- a/src/sales/translations/ja-JP/objects.commerce.ts +++ b/src/sales/translations/ja-JP/objects.commerce.ts @@ -109,7 +109,6 @@ export const commerce: Record = { unit_price: { label: '販売価格' }, discount: { label: '割引率(%)' }, subtotal: { label: '小計' }, - tax_rate: { label: '税率(%)' }, total_price: { label: '合計' }, line_number: { label: '行番号' }, }, diff --git a/src/sales/translations/zh-CN/objects.commerce.ts b/src/sales/translations/zh-CN/objects.commerce.ts index 192f5b1c3..6b6ba33bd 100644 --- a/src/sales/translations/zh-CN/objects.commerce.ts +++ b/src/sales/translations/zh-CN/objects.commerce.ts @@ -110,7 +110,6 @@ export const commerce: Record = { unit_price: { label: '销售单价' }, discount: { label: '折扣(%)' }, subtotal: { label: '小计' }, - tax_rate: { label: '税率(%)' }, total_price: { label: '总计' }, line_number: { label: '行号' }, }, diff --git a/test/hooks-runtime.test.ts b/test/hooks-runtime.test.ts index a2f200c73..f96f74415 100644 --- a/test/hooks-runtime.test.ts +++ b/test/hooks-runtime.test.ts @@ -209,6 +209,33 @@ describe('quote_total_rollup', () => { }); }); + it("adds up on QTE-0007's shape: the header's subtotal is the sum of the line totals beside it (#2069)", async () => { + // A 10% quote with an untaxed line and a discounted one, then the rep tries + // a 5% rate on the second line, as hosted QTE-0007 carried. On 4.0.1 the + // rate reached only that line's Total: lines 10,945 beside a header of + // 9,810. Tax is the quote's own amount, so the rate is refused. + const quote = await quoteOf({ status: 'draft', discount: 10 }); + const lines: Rec[] = []; + for (const line of [ + { quantity: 2, unit_price: 5000, discount: 0 }, // 10,000 + { quantity: 1, unit_price: 1000, discount: 10 }, // 900 + ]) { + lines.push(await repWrites('crm_quote_line_item', 'insert', { + crm_quote: quote.id, crm_product: (await productAt(line.unit_price)).id, ...line, + })); + } + const rated = await repWrites('crm_quote_line_item', 'update', { id: lines[1]!.id, tax_rate: 5 }) + .then(() => null, (e: Rec) => [e.code, e.status]); + const q = await settles('crm_quote', quote.id, (row) => expect(row.subtotal).toBe(10_900)); + const shown = await verify.rows('crm_quote_line_item', { crm_quote: quote.id }); + expect({ + lines: shown.reduce((sum, l) => sum + Number(l.total_price), 0), + subtotal: q.subtotal, + total: q.total_price, + }).toEqual({ lines: 10_900, subtotal: 10_900, total: 9_810 }); // 10,900 − 1,090 + expect(rated, 'a line took a tax rate of its own').toEqual(['INVALID_FIELD', 400]); + }); + it('skips accepted quotes', async () => { const quote = await quoteOf({ status: 'accepted', discount: 0, tax: 0, shipping_handling: 0, total_price: 111 }); // A line landing on an accepted quote — written by the system, the writer diff --git a/test/line-item-conventions.test.ts b/test/line-item-conventions.test.ts index 0db2b439c..759212bc4 100644 --- a/test/line-item-conventions.test.ts +++ b/test/line-item-conventions.test.ts @@ -129,15 +129,17 @@ describe('formulas resolve from stored values, not from other formulas', () => { expect(violations).toEqual([]); }); - it('quote line total still applies tax on top of the discounted line amount', () => { + it('quote line total is its subtotal: a line carries no tax of its own (#2069)', () => { // Inlining `subtotal` must not change the arithmetic. 4 × 100 with a 10% - // line discount is 360; an 8% tax rate takes it to 388.80. - const source: string = (QuoteLineItem as AnyRec).fields.total_price.expression.source; - // The expression is pure arithmetic over `record`, so plain JS evaluates it - // identically to CEL — enough to prove the inlining preserved the math. - const evaluate = new Function('record', `return ${source};`) as (r: AnyRec) => number; - const total = evaluate({ quantity: 4, unit_price: 100, discount: 10, tax_rate: 8 }); - expect(total).toBeCloseTo(388.8, 10); + // line discount is 360 in both, so the lines add up to the quote subtotal. + const fields: AnyRec = (QuoteLineItem as AnyRec).fields; + // The expressions are pure arithmetic over `record`, so plain JS evaluates + // them identically to CEL — enough to prove the inlining preserved the math. + const evaluate = (field: string) => + (new Function('record', `return ${fields[field].expression.source};`) as (r: AnyRec) => number)( + { quantity: 4, unit_price: 100, discount: 10 }, + ); + expect([evaluate('subtotal'), evaluate('total_price')]).toEqual([360, 360]); }); }); diff --git a/test/undeclared-key-probe.test.ts b/test/undeclared-key-probe.test.ts index 00fd6b7b6..ffce4a0c8 100644 --- a/test/undeclared-key-probe.test.ts +++ b/test/undeclared-key-probe.test.ts @@ -7,7 +7,7 @@ import { ObjectQLPlugin } from '@objectstack/objectql'; import { MetadataPlugin } from '@objectstack/metadata'; import stack from './helpers/composed-stack'; import { OpportunityLineItem } from '../src/revenue/objects/opportunity_line_item.object'; -import { QuoteLineItem } from '../src/revenue/objects/quote_line_item.object'; +import { Quote } from '../src/revenue/objects/quote.object'; /** * What a typo costs — measured, per driver (#1200). @@ -42,9 +42,9 @@ import { QuoteLineItem } from '../src/revenue/objects/quote_line_item.object'; * That guarantee is now load-bearing for this whole repo, so it is pinned * rather than assumed, WITH its controls: `A` inserts the same row without the * key (it must succeed, or a red here would only mean the fixture broke), and - * `D` sends the SAME key to the twin `crm_quote_line_item`, which DOES declare - * `tax_rate` (it must succeed, or the probe would be measuring a banned name - * rather than an undeclared one). Without both, this file could pass while + * `D` sends the SAME key to `crm_quote`, which DOES declare `tax` (it must + * succeed, or the probe would be measuring a banned name rather than an + * undeclared one). Without both, this file could pass while * measuring nothing. `E` shows the refusal is about declaration, not about one * borrowed name: a plain misspelling of a real field is refused the same way. * @@ -84,8 +84,11 @@ type AnyRec = Record; process.env.OS_REGISTRY_LOG ??= 'silent'; const SYS = { isSystem: true } as AnyRec; -/** The undeclared key. Declared on `crm_quote_line_item`, not on its twin. */ -const KEY = 'tax_rate'; +/** + * The undeclared key. Declared on `crm_quote` (its typed Tax), not on a line + * item: until #2069 this was the quote line's `tax_rate`, now retired. + */ +const KEY = 'tax'; /** A plain misspelling of a field the probe object really does declare. */ const TYPO = 'unit_pric'; /** Written to a DECLARED field, so the injector hook stays inert otherwise. */ @@ -132,11 +135,11 @@ interface Reading { insert: Outcome; /** Caller supplies the undeclared key on UPDATE of a clean row. */ update: Outcome; - /** The same key on the twin that DECLARES it — the second control. */ - declaredTwin: Outcome; - /** Stored value on that twin, proving the control really wrote. */ - declaredTwinValue: unknown; - /** A misspelling of a real field, rather than a borrowed twin's name. */ + /** The same key on `crm_quote`, which DECLARES it — the second control. */ + declared: Outcome; + /** Stored value on that quote, proving the control really wrote. */ + declaredValue: unknown; + /** A misspelling of a real field, rather than a name borrowed from another object. */ typo: Outcome; /** A hook assigns the undeclared key after the caller-path door. */ viaHook: Outcome; @@ -188,17 +191,14 @@ async function measure(driver: string, config: AnyRec): Promise<{ kernels: AnyRe () => ql.update('crm_opportunity_line_item', { id: cleanId, [KEY]: 42 }, { context: SYS }), ); - const quoteId = await insert('crm_quote', { - name: 'Probe Quote', crm_account: accountId, status: 'draft', - quote_date: '2026-01-01', expiration_date: '2026-02-01', - }); - let quoteLineId = ''; - const declaredTwin = await attempt(async () => { - quoteLineId = await insert('crm_quote_line_item', { - crm_quote: quoteId, crm_product: productId, quantity: 1, unit_price: 100, [KEY]: 10, + let quoteId = ''; + const declared = await attempt(async () => { + quoteId = await insert('crm_quote', { + name: 'Probe Quote', crm_account: accountId, status: 'draft', + quote_date: '2026-01-01', expiration_date: '2026-02-01', [KEY]: 10, }); }); - const twinRow = quoteLineId ? await read(ql, 'crm_quote_line_item', quoteLineId) : undefined; + const quoteRow = quoteId ? await read(ql, 'crm_quote', quoteId) : undefined; const typo = await attempt(() => ql.insert('crm_opportunity_line_item', line({ [TYPO]: 7 }), { context: SYS })); // ── the hook seam, on its own kernel so the injector cannot touch the above ── @@ -225,7 +225,7 @@ async function measure(driver: string, config: AnyRec): Promise<{ kernels: AnyRe kernels: [kernel, k2], reading: { control, insert: insertLeg, update: updateLeg, - declaredTwin, declaredTwinValue: twinRow?.[KEY], typo, viaHook, viaHookRow, + declared, declaredValue: quoteRow?.[KEY], typo, viaHook, viaHookRow, }, }; } @@ -258,12 +258,12 @@ afterAll(async () => { }); describe('the probe rests on a real asymmetry (#1200)', () => { - it('crm_opportunity_line_item does not declare tax_rate', () => { + it(`crm_opportunity_line_item does not declare ${KEY}`, () => { expect(Object.keys((OpportunityLineItem as AnyRec).fields)).not.toContain(KEY); }); - it('its twin crm_quote_line_item does', () => { - expect(Object.keys((QuoteLineItem as AnyRec).fields)).toContain(KEY); + it('crm_quote does', () => { + expect(Object.keys((Quote as AnyRec).fields)).toContain(KEY); }); }); @@ -276,9 +276,9 @@ describe.each(DRIVERS.map((d) => d.driver))( expect(R().control).toMatchObject({ ok: true }); }); - it('control: the same key on the twin that DECLARES it is accepted and stored', () => { - expect(R().declaredTwin).toMatchObject({ ok: true }); - expect(R().declaredTwinValue).toBe(10); + it('control: the same key on an object that DECLARES it is accepted and stored', () => { + expect(R().declared).toMatchObject({ ok: true }); + expect(R().declaredValue).toBe(10); }); it('insert carrying the undeclared key is refused as INVALID_FIELD / 400', () => { From fe3f05eea1f1cf78f361e3258d2faf4d95b6a0c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 10:15:02 +0000 Subject: [PATCH 2/3] =?UTF-8?q?chore(changeset):=20minor=20=E2=80=94=20the?= =?UTF-8?q?=20quote=20line's=20tax=20rate=20is=20removed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit States what an install that filled a line rate sees: that line's Total changes (945 to 900 on a 1 x 1,000 line at 10% off and 5%), the quote's Total Price does not, and the stored rates stay in an orphaned column that os migrate plan lists as a destructive drop and os migrate unmapped-columns reads by record id. Measured by upgrading a database written by the base build. Claude-Session: https://claude.ai/code/session_01BCDqr1DqnQjEh5jA9gGkUG Co-authored-by: Claude --- .changeset/2069-retire-line-tax-rate.md | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .changeset/2069-retire-line-tax-rate.md diff --git a/.changeset/2069-retire-line-tax-rate.md b/.changeset/2069-retire-line-tax-rate.md new file mode 100644 index 000000000..afafaa549 --- /dev/null +++ b/.changeset/2069-retire-line-tax-rate.md @@ -0,0 +1,37 @@ +--- +'hotcrm': minor +--- + +Remove **Tax Rate %** from quote line items: a line's **Total** is now its **Subtotal**. + +A quote carried two tax models that never read each other. A quote's own **Tax** is an amount +typed on the quote, and its **Total Price** adds it: *Subtotal − Discount + Tax + Shipping & +Handling*. A quote line item also carried a **Tax Rate %**, but the rate reached only that +line's own **Total**, and the quote total never read it. So on a quote whose line carried a +rate, the lines on the quote's *Related* tab added up to more than the quote beside them: lines +10,945 against a quote total of 9,810, on a quote with a 10% discount and one line taxed at 5%. + +The quote's typed **Tax** is the model this app ships. It is on every demo quote, on the quote +form and the All Quotes list, and in the documentation's price formula, so the line rate is +removed. A line's **Total** is its price after its own discount, the same figure as its +**Subtotal**, and a quote's line totals add up to its **Subtotal**. + +**What this means for you.** `crm_quote_line_item.tax_rate` no longer exists on the object, in +any of the four language packs, or in the documentation. A write naming it is refused with 400 +`INVALID_FIELD`. No demo line ever carried a rate, so the demo data is unaffected. + +**If you filled a line's Tax Rate % on an existing install**, that line's **Total** changes when +you upgrade, and its rate no longer shows. A line of 1 × 1,000 at a 10% line discount and a 5% +rate showed 945 and now shows 900. The quote's **Total Price** does not change, because the +quote total never included a line's rate. If that tax belongs on the quote, add it to the +quote's **Tax**. + +The upgrade does not delete the stored rates. The column stays in the database, unread. On boot +the server logs it as orphaned (`[schema-drift] crm_quote_line_item.tax_rate`), `os migrate plan` +lists dropping it as the one destructive change, and +`os migrate unmapped-columns --object crm_quote_line_item --json` reads each line's stored rate +by record id. `os migrate apply --allow-destructive` drops the column, so read the rates first if +you need them. + +A tax model where line rates roll up into the quote remains a possible future design; this +removal does not build it. From 31e3829c78bf449a6fb594a98a646c8c7b0a697e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 10:35:53 +0000 Subject: [PATCH 3/3] docs(inventory): QUO-007 no longer names a line-level tax-rate formula The quote line's tax rate is retired on this branch, so the row states what the line total now is: the line after its own discount, with no tax of its own. Claude-Session: https://claude.ai/code/session_01BCDqr1DqnQjEh5jA9gGkUG Co-authored-by: Claude --- docs/feature-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/feature-inventory.md b/docs/feature-inventory.md index dcab71be4..d9bd623c0 100644 --- a/docs/feature-inventory.md +++ b/docs/feature-inventory.md @@ -116,7 +116,7 @@ | QUO-004 | 从商机生成报价 | 屏幕流收集名称/有效期/折扣,按商机金额播种报价定价,并把早期阶段商机推进到 proposal | `src/revenue/flows/quote-generation.flow.ts` + `src/sales/actions/opportunity.actions.ts`(`generate_quote`) | | QUO-005 | 接受报价→起草合同 | status 变 accepted 时自动创建 draft 合同(12 个日历月,合同额=报价总价);无联系人时给出具名报错;以系统身份起草,销售代表的接受也能拿到合同 | `src/revenue/objects/quote.hook.ts`(`quote_accepted_contract_draft`) | | QUO-006 | 接受报价→商机赢单 | 与起草合同相互独立的另一个 hook,以接受者本人身份:关联商机置 closed_won、补关闭日与 `win_reason='quote_accepted'`;商机被审批锁住时接受本身即被拒绝(`quote_workflow`) | `src/revenue/objects/quote.hook.ts`(`quote_on_accepted`) | -| QUO-007 | 报价行项目与总额汇总 | 明细对象(`crm_quote_line_item`,受父控、删报价级联,行级税率公式):重算 subtotal/discount_amount/total_price 回写报价,已结报价跳过 | `src/revenue/objects/quote_line_item.object.ts` + `.hook.ts` | +| QUO-007 | 报价行项目与总额汇总 | 明细对象(`crm_quote_line_item`,受父控、删报价级联,行合计 = 扣除本行折扣后的金额,行项目不单独计税):重算 subtotal/discount_amount/total_price 回写报价,已结报价跳过 | `src/revenue/objects/quote_line_item.object.ts` + `.hook.ts` | | QUO-008 | 报价自动过期 | 每日 01:00 把过了 `expiration_date` 的未结报价置 expired | `src/revenue/flows/quote-expiration.flow.ts` | | QUO-009 | 报价视图 | 状态看板(按 total_price 汇总)、报价日→失效日日历、总额汇总列表 | `src/revenue/views/quote.view.ts` | | QUO-010 | 产品目录对象 | `crm_product`:PRD-{0000} 编号、SKU 租户唯一、类别/产品族、定价(目录价/成本)、图片与 PDF datasheet,全组织可读 | `src/revenue/objects/product.object.ts` |