Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/currency-fields-drop-scale.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 0 additions & 4 deletions src/marketing/objects/campaign.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),

Expand All @@ -181,7 +178,6 @@ export const Campaign = ObjectSchema.create({
actual_revenue: Field.currency({
group: 'budget',
label: 'Actual Revenue',
scale: 2,
min: 0,
}),

Expand Down
29 changes: 14 additions & 15 deletions src/revenue/flows/quote-generation.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion src/revenue/objects/contract.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
2 changes: 0 additions & 2 deletions src/revenue/objects/product.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -100,7 +99,6 @@ export const Product = ObjectSchema.create({
cost: Field.currency({
label: 'Cost',
group: 'pricing',
scale: 2,
min: 0,
}),

Expand Down
5 changes: 0 additions & 5 deletions src/revenue/objects/quote.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,6 @@ export const Quote = ObjectSchema.create({
subtotal: Field.currency({
label: 'Subtotal',
group: 'pricing',
scale: 2,
}),

discount: Field.percent({
Expand All @@ -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
Expand Down
2 changes: 0 additions & 2 deletions src/sales/objects/account.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
}),
Expand Down
5 changes: 0 additions & 5 deletions src/sales/objects/forecast.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,6 @@ export const Forecast = ObjectSchema.create({

quota: Field.currency({
label: 'Quota',
scale: 2,
min: 0,
group: 'amounts',
trackHistory: true,
Expand All @@ -150,31 +149,27 @@ 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',
}),

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',
}),

commit_amount: Field.currency({
label: 'Commit',
description: 'Open opportunities in the Commit forecast category (owner-committed).',
scale: 2,
min: 0,
group: 'amounts',
}),

closed_amount: Field.currency({
label: 'Closed Won',
description: 'Already-closed-won amount in this period.',
scale: 2,
min: 0,
group: 'amounts',
}),
Expand Down
2 changes: 0 additions & 2 deletions src/sales/objects/lead.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
}),
Expand Down Expand Up @@ -358,7 +357,6 @@ export const Lead = ObjectSchema.create({
// Additional Info
annual_revenue: Field.currency({
label: 'Annual Revenue',
scale: 2,
group: 'additional',
}),

Expand Down
2 changes: 0 additions & 2 deletions src/sales/objects/opportunity.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,13 @@ export const Opportunity = ObjectSchema.create({
label: 'Amount',
required: true,
storage: { notNull: true },
scale: 2,
min: 0,
group: 'financials',
trackHistory: true,
}),

expected_revenue: Field.currency({
label: 'Expected Revenue',
scale: 2,
readonly: true, // Calculated field
group: 'financials',
}),
Expand Down
18 changes: 10 additions & 8 deletions test/flow-quote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: [{
Expand Down
Loading