Skip to content

fix(flows): round quote money to the fields' declared 2-decimal scale - #1700

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-1206-quote-money-scale-r2
Sep 6, 2026
Merged

fix(flows): round quote money to the fields' declared 2-decimal scale#1700
os-steve merged 2 commits into
mainfrom
claude/issue-1206-quote-money-scale-r2

Conversation

@os-steve

@os-steve os-steve commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1206

Generate Quote was broken for most non-zero discounts on the flagship exemplar, and it failed silently. This rounds both money expressions to the fields' declared scale, using the numeric function table that @objectstack/service-automation 17.3.0 shipped — the capability this card waited 13 days for.

The artefact check came first, not the code

This card was unblocked on a read of the release record. Per R48's predicate the unlock had to be evaluated against the installed artefact, so that was the first action, before any edit. Both legs pass on the installed @objectstack/service-automation 17.3.0 (node_modules/@objectstack/service-automation/dist/index.js):

leg reading
numeric function table present KNOWN_EXPRESSION_FUNCTIONS = abs, round, floor, ceil, min, max
unknown function fails loudly grep -c "unknown function" dist/index.js = 1
positive control for that grep `grep -c "NOW

Grep proves presence, not behaviour, so the table was also exercised through the real AutomationEngine on the real flow (180,000 opportunity, test/helpers/flow-harness.ts):

[A raw product (current main), 180000@30] rows=1 discount_amount=54000 total_price=125999.99999999999
[B round(x*100)/100,          180000@30] rows=1 discount_amount=54000 total_price=126000
[C round(x*100)/100,          180000@70] rows=1 discount_amount=126000 total_price=54000
[D round(x, 2)  — two-arg   ] rows=0 runError="flow value expression: round() takes exactly 1 argument, got 2. There is no
                                       precision form — the CEL stdlib's round() is integer-only; for N-decimal rounding
                                       write round(x * 100) / 100 (scale 2), matching the CEL authoring pattern."
[E ROUND(...)   — unknown fn] rows=0 runError="flow value expression: unknown function 'ROUND' … Did you mean 'round'?"
[F .toFixed(2)  — unknown fn] rows=0 runError="flow value expression: unknown function 'toFixed' …"

Leg A reproduces the issue's measured value exactly, which is what confirms this is the live evaluator. D/E/F now abort the run with zero rows written; before 17.3.0 every one of them was rewritten to the literal null and the field was written undefined.

The function table is not what the card assumed

round() is the CEL stdlib's, mirrored 1:1 — integer-only and single-argument. There is no round(x, 2). N-decimal rounding is round(x * 100) / 100, and the platform's own arity diagnostic names that pattern verbatim (leg D above), so this is the sanctioned spelling rather than an invention.

Changes Made

  • src/flows/quote-generation.flow.ts — both money expressions round to the 2-decimal field scale:
discount_amount: '{round(oppRecord.amount * (discount / 100) * 100) / 100}',
total_price:     '{round(oppRecord.amount * (1 - discount / 100) * 100) / 100}',

with a comment recording why, because the shape applies anywhere a flow multiplies a currency by a percentage.

  • test/flow-quote.test.ts — the regression pin, added to the existing runtime suite (no new gate, AGENTS.md:431).
  • One changeset (patch).

No operator trick. (x * 100 + 0.5 | 0) / 100 does evaluate, but |0 is an int32 coercion that silently overflows above ~21.5M — on a money field that is worse than the defect being fixed. round() refuses loudly past Number.MAX_SAFE_INTEGER instead.

Not made readonly or formula fields. quote.object.ts records deliberately that these are not readonly because the line-item rollup writes them; that would be a schema redesign, not this card.

subtotal is unchanged and re-verified as safe: a bare path pass-through of crm_opportunity.amount, itself Field.currency({ scale: 2 }).

The pin asserts the VALUE, and it has to

The harness's in-memory data engine does not enforce field scale, so a pin asserting "the run did not fail" would be green before and after and would pin nothing. Ablation, run from the committed state — the fix reverted to the pre-fix bare products, the mutation proved on disk by blob hash (bea2eb83 vs 2f42350e) before the suite ran:

AssertionError: expected 125999.99999999999 to be 126000 // Object.is equality
 Test Files  1 failed (1)
      Tests  1 failed | 2 passed (3)

Restored by byte identity, not by an exit code: the blob hash returned to 2f42350e and git diff HEAD is empty.

The two pre-existing cases (10% of 200,000, and 0%) stayed green through the ablation — they use discounts that are exact either way, which is precisely why they never caught this. The new pin runs 30% and 70% on a 180,000 opportunity so that both edited expressions are covered: at 30% the tail sits on total_price, at 70% it moves to discount_amount.

Sibling scan (re-run, not quoted forward)

src/flows/ re-scanned across all 25 flow files for arithmetic inside {...} tokens. Difference set vs the previous dev's scan: empty. The only currency × percentage sites are the two lines fixed here; every other hit is a cron schedule, a TODAY() + N date macro, or a wildcard path token. No sibling card to file.

Verification

pnpm verify fully green at ac4f00e4 — all eight stages ran (validatetypechecklintlint:i18n-gatehygienehygiene:tokensbuildtest):

✓ i18n lint gate: 0 `i18n/missing-*` issues (13 total lint issue(s) reported, unaffected by this gate)
✓ source token ratchet clean
 Test Files  161 passed (161)
      Tests  3415 passed | 1 skipped (3416)
os-verify-lock: VERDICT command-exit 0

The token ratchet stays clean with headroom: business semantics ~85,041 (ceiling ~100,000), interaction layer ~37,963 (ceiling ~40,000), authored total ~137,345 (ceiling ~140,000). The ratchet is comment-stripped, so the explanatory comment costs nothing.

⚠️ The console swallowing the 400 is a separately-filed upstream defect and is out of scope — which means the fix cannot be judged from the UI, and the pin is the only evidence. That is why it asserts the value.


Generated by Claude Code

`quote_generation` wrote `discount_amount` and `total_price` as bare
IEEE-754 products of a currency and a percentage. `discount / 100` is
inexact for every percentage whose hundredth is not a dyadic rational,
so the product carried a tail that `crm_quote`'s `scale: 2` money fields
refuse: 180,000 at 30% is 125999.99999999999 and the insert was rejected
with `Total Price must have at most 2 decimal places (got 11)`. Whether
a quote could be created at all was an arithmetic accident of
amount x discount — 20% of 180K worked, 30% of the same 180K did not —
and the console never surfaced the 400, so the action looked like it did
nothing at all.

Round both products to the field scale inside the expression, using the
CEL stdlib's `round()` as mirrored into flow value expressions by
service-automation 17.3.0. That function is integer-only and
single-argument, so N-decimal rounding is `round(x * 100) / 100`, the
pattern the platform's own arity diagnostic names. No operator trick:
`(x * 100 + 0.5 | 0) / 100` evaluates too, but `|0` is an int32 coercion
that silently overflows above ~21.5M, which on a money field is worse
than the defect it dodges.

The regression pin asserts the VALUE, not the absence of an error: the
harness's in-memory data engine does not enforce field scale, so a pin
asserting "the run did not fail" would be green before and after. It
covers both edited expressions — 30% leaves the tail on `total_price`,
70% moves it to `discount_amount`.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
hotcrm Ignored Ignored Sep 6, 2026 11:44am UTC

Request Review

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

Labels

backend Server-side behaviour — hooks, flows, actions ci/cd CI plumbing and the verification pipeline

Projects

None yet

2 participants