diff --git a/.changeset/currency-fields-drop-scale.md b/.changeset/currency-fields-drop-scale.md new file mode 100644 index 000000000..94d011c85 --- /dev/null +++ b/.changeset/currency-fields-drop-scale.md @@ -0,0 +1,24 @@ +--- +'hotcrm': patch +--- + +**Currency fields no longer declare `scale`.** ObjectStack's next release refuses `scale` on a +`currency` field: a currency amount's decimal places come from its currency's ISO 4217 minor unit +(2 for USD), not from a field setting. The refusal has no automatic conversion, so an app that +keeps the key cannot load on that release. `scale` is removed from all 23 money fields: account +revenue and purchasing budget, campaign costs and revenue, contract value, forecast amounts, lead +amounts, opportunity amount and expected revenue, product price and cost, and the quote totals. +`scale` on number, percent, formula and roll-up fields is unchanged. + +What changes at the current ObjectStack 17.4.0: + +- **Totals show whole units for a while.** List-view summary footers on money columns (for example + the **Amount** total under **Open Deals**) and the **Revenue (Won)** and **Pipeline + Value** tiles on **Sales Home** read the field's `scale` for their decimals. They show + `2,017,500` instead of `2,017,500.00` until the console takes decimals from the currency. That + console fix ships with ObjectStack's next release. Record cells are unaffected: they + already use the currency's own decimals. +- **More-precise money writes are accepted.** A money value with more than 2 decimals used to be + rejected with "must have at most 2 decimal places". It is now accepted as written, which is the + platform's rule for currency fields from the next release on. The **Generate Quote** flow still + rounds its discount and total to whole cents, so quote amounts are unchanged. diff --git a/src/marketing/objects/campaign.object.ts b/src/marketing/objects/campaign.object.ts index 5c1b630a3..1ec6469c8 100644 --- a/src/marketing/objects/campaign.object.ts +++ b/src/marketing/objects/campaign.object.ts @@ -144,21 +144,18 @@ export const Campaign = ObjectSchema.create({ budgeted_cost: Field.currency({ group: 'budget', label: 'Budgeted Cost', - scale: 2, min: 0, }), actual_cost: Field.currency({ group: 'budget', label: 'Actual Cost', - scale: 2, min: 0, }), expected_revenue: Field.currency({ group: 'budget', label: 'Expected Revenue', - scale: 2, min: 0, }), @@ -181,7 +178,6 @@ export const Campaign = ObjectSchema.create({ actual_revenue: Field.currency({ group: 'budget', label: 'Actual Revenue', - scale: 2, min: 0, }), diff --git a/src/revenue/flows/quote-generation.flow.ts b/src/revenue/flows/quote-generation.flow.ts index f2102ca19..a233a7e68 100644 --- a/src/revenue/flows/quote-generation.flow.ts +++ b/src/revenue/flows/quote-generation.flow.ts @@ -75,22 +75,21 @@ export const QuoteGenerationFlow: Flow = { crm_account: '{oppRecord.crm_account}', crm_contact: '{oppRecord.primary_contact}', owner_id: '{$User.Id}', status: 'draft', quote_date: '{TODAY()}', expiration_date: '{TODAY() + expirationDays}', - // `subtotal` is a bare path pass-through and needs no rounding: - // `crm_opportunity.amount` is itself `Field.currency({ scale: 2 })`, - // so it cannot arrive here unrounded. + // `subtotal` is a bare path pass-through and needs no rounding: it + // copies `crm_opportunity.amount` as stored and does no arithmetic, + // so it adds no floating-point tail of its own. subtotal: '{oppRecord.amount}', discount: '{discount}', - // ⛔ A currency × percentage MUST be rounded to the field's declared - // scale inside the expression — the quote's own money fields are the - // contract, and the flow meets it rather than handing the engine an - // unrounded double. `discount_amount` / `total_price` are both - // `Field.currency({ scale: 2 })`, while `discount / 100` is inexact - // for every percentage whose hundredth is not a dyadic rational, so a - // BARE product carries a tail the field refuses: 180,000 at 30% is - // 125999.99999999999 and the insert is rejected with `Total Price must - // have at most 2 decimal places (got 11)`. A bare product therefore - // makes quote generation depend on an arithmetic accident of - // amount × discount — 20% of 180K works, 30% of the same 180K does - // not — and the 400 never reaches the seller. + // ⛔ A currency × percentage MUST be rounded to whole cents inside the + // expression — the flow hands the engine a money amount, never an + // unrounded double. `discount / 100` is inexact for every percentage + // whose hundredth is not a dyadic rational, so a BARE product carries + // a tail: 180,000 at 30% is 125999.99999999999. While these fields + // declared `scale: 2` the insert was rejected for it (`Total Price + // must have at most 2 decimal places (got 11)`, #1206). Currency + // fields no longer declare `scale` — the platform refuses it, a + // currency's decimals are its ISO 4217 minor unit (#1965) — so the + // write is now ACCEPTED and the tail would be stored silently. The + // rounding keeps `discount_amount` / `total_price` whole-cent amounts. // // `round()` is the CEL stdlib's, mirrored 1:1 into flow value // expressions from service-automation 17.3.0. It is INTEGER-ONLY and diff --git a/src/revenue/objects/contract.object.ts b/src/revenue/objects/contract.object.ts index 33edc7672..c30dbe076 100644 --- a/src/revenue/objects/contract.object.ts +++ b/src/revenue/objects/contract.object.ts @@ -122,7 +122,6 @@ export const Contract = ObjectSchema.create({ contract_value: Field.currency({ label: 'Contract Value', group: 'value', - scale: 2, min: 0, required: true, storage: { notNull: true }, diff --git a/src/revenue/objects/product.object.ts b/src/revenue/objects/product.object.ts index 697262348..b3cdfbdc2 100644 --- a/src/revenue/objects/product.object.ts +++ b/src/revenue/objects/product.object.ts @@ -91,7 +91,6 @@ export const Product = ObjectSchema.create({ list_price: Field.currency({ label: 'List Price', group: 'pricing', - scale: 2, min: 0, required: true, storage: { notNull: true }, @@ -100,7 +99,6 @@ export const Product = ObjectSchema.create({ cost: Field.currency({ label: 'Cost', group: 'pricing', - scale: 2, min: 0, }), diff --git a/src/revenue/objects/quote.object.ts b/src/revenue/objects/quote.object.ts index 33b77d628..fdf3e931c 100644 --- a/src/revenue/objects/quote.object.ts +++ b/src/revenue/objects/quote.object.ts @@ -191,7 +191,6 @@ export const Quote = ObjectSchema.create({ subtotal: Field.currency({ label: 'Subtotal', group: 'pricing', - scale: 2, }), discount: Field.percent({ @@ -205,25 +204,21 @@ export const Quote = ObjectSchema.create({ discount_amount: Field.currency({ label: 'Discount Amount', group: 'pricing', - scale: 2, }), tax: Field.currency({ label: 'Tax', group: 'pricing', - scale: 2, }), shipping_handling: Field.currency({ label: 'Shipping & Handling', group: 'pricing', - scale: 2, }), total_price: Field.currency({ label: 'Total Price', group: 'pricing', - scale: 2, }), // Terms diff --git a/src/sales/objects/account.object.ts b/src/sales/objects/account.object.ts index 26a11ca55..45e43d8da 100644 --- a/src/sales/objects/account.object.ts +++ b/src/sales/objects/account.object.ts @@ -318,7 +318,6 @@ export const Account = ObjectSchema.create({ // Number fields annual_revenue: Field.currency({ label: 'Annual Revenue', - scale: 2, min: 0, group: 'financials', trackHistory: true, @@ -390,7 +389,6 @@ export const Account = ObjectSchema.create({ annual_purchasing_budget: Field.currency({ label: 'Annual Purchasing Budget', description: "What this account expects to SPEND with vendors this year — not its own revenue.", - scale: 2, min: 0, group: 'business_profile', }), diff --git a/src/sales/objects/forecast.object.ts b/src/sales/objects/forecast.object.ts index 518837a03..4b256bb41 100644 --- a/src/sales/objects/forecast.object.ts +++ b/src/sales/objects/forecast.object.ts @@ -141,7 +141,6 @@ export const Forecast = ObjectSchema.create({ quota: Field.currency({ label: 'Quota', - scale: 2, min: 0, group: 'amounts', trackHistory: true, @@ -150,7 +149,6 @@ export const Forecast = ObjectSchema.create({ pipeline_amount: Field.currency({ label: 'Pipeline', description: 'Sum of all open opportunities closing in this period (any stage).', - scale: 2, min: 0, group: 'amounts', }), @@ -158,7 +156,6 @@ export const Forecast = ObjectSchema.create({ best_case_amount: Field.currency({ label: 'Best Case', description: 'Open opportunities in the Best Case or Commit forecast category.', - scale: 2, min: 0, group: 'amounts', }), @@ -166,7 +163,6 @@ export const Forecast = ObjectSchema.create({ commit_amount: Field.currency({ label: 'Commit', description: 'Open opportunities in the Commit forecast category (owner-committed).', - scale: 2, min: 0, group: 'amounts', }), @@ -174,7 +170,6 @@ export const Forecast = ObjectSchema.create({ closed_amount: Field.currency({ label: 'Closed Won', description: 'Already-closed-won amount in this period.', - scale: 2, min: 0, group: 'amounts', }), diff --git a/src/sales/objects/lead.object.ts b/src/sales/objects/lead.object.ts index 969a423cf..04c31799e 100644 --- a/src/sales/objects/lead.object.ts +++ b/src/sales/objects/lead.object.ts @@ -244,7 +244,6 @@ export const Lead = ObjectSchema.create({ // forecast). estimated_amount: Field.currency({ label: 'Estimated Amount', - scale: 2, group: 'qualification', description: 'Estimated value of this demand, before qualification. Prefills the deal amount at conversion.', }), @@ -358,7 +357,6 @@ export const Lead = ObjectSchema.create({ // Additional Info annual_revenue: Field.currency({ label: 'Annual Revenue', - scale: 2, group: 'additional', }), diff --git a/src/sales/objects/opportunity.object.ts b/src/sales/objects/opportunity.object.ts index 696b19c66..e9ce83b66 100644 --- a/src/sales/objects/opportunity.object.ts +++ b/src/sales/objects/opportunity.object.ts @@ -81,7 +81,6 @@ export const Opportunity = ObjectSchema.create({ label: 'Amount', required: true, storage: { notNull: true }, - scale: 2, min: 0, group: 'financials', trackHistory: true, @@ -89,7 +88,6 @@ export const Opportunity = ObjectSchema.create({ expected_revenue: Field.currency({ label: 'Expected Revenue', - scale: 2, readonly: true, // Calculated field group: 'financials', }), diff --git a/test/flow-quote.test.ts b/test/flow-quote.test.ts index b4c4fb504..38033786f 100644 --- a/test/flow-quote.test.ts +++ b/test/flow-quote.test.ts @@ -55,16 +55,18 @@ describe('quote_generation flow — runtime', () => { }); /** - * Regression pin for #1206 — the money fields carry the FIELD'S SCALE, not a - * raw IEEE-754 product. + * Regression pin for #1206 — the money fields carry whole cents, not a raw + * IEEE-754 product. * * ⛔ This pin asserts the VALUE, and it has to. The harness's in-memory data - * engine does not enforce field scale, so a pin that merely asserted "the run + * engine enforces no decimal places, so a pin that merely asserted "the run * did not fail" would be green both before and after the fix and would pin - * nothing at all. Against the real driver the pre-fix value is what the - * insert was REJECTED for — `Total Price must have at most 2 decimal places - * (got 11)` — and the rejection never reached the seller, so the value here - * is the only evidence the fix works. + * nothing at all. When #1206 was filed the real driver REJECTED the pre-fix + * value — `Total Price must have at most 2 decimal places (got 11)`, from the + * fields' since-retired `scale: 2` — and the rejection never reached the + * seller. Currency fields declare no `scale` now (#1965), so the real driver + * accepts that value and stores its tail; either way the value here is the + * only evidence the rounding works. * * Both discounts are the issue's own measurements on a 180,000 opportunity, * and between them they cover both edited expressions: at 30% only @@ -74,7 +76,7 @@ describe('quote_generation flow — runtime', () => { * 10% and 0% the two cases above use — is exact either way and cannot catch * this. */ - it('rounds discount_amount and total_price to the fields\' 2-decimal scale (#1206)', async () => { + it('rounds discount_amount and total_price to whole cents (#1206)', async () => { const at = async (discount: number) => { const h = makeQuote({ crm_opportunity: [{