Skip to content

feat(bones): add round-parameter to NumericBone - #1784

Open
skoegl wants to merge 1 commit into
viur-framework:developfrom
skoegl:feat/numeric-bone-rounding
Open

skoegl wants to merge 1 commit into
viur-framework:developfrom
skoegl:feat/numeric-bone-rounding

Conversation

@skoegl

@skoegl skoegl commented Sep 18, 2026

Copy link
Copy Markdown
Member

Motivation

decimal=True exists to make money arithmetic exact — but the rounding mode is not reachable. _convert_to_decimal calls quantize(self._quantize_exp) without a rounding argument, so the active decimal context decides, and that is ROUND_HALF_EVEN out of the box.

For invoicing that is the wrong default and there is no way to change it short of subclassing the bone or mutating the decimal context process-wide. A concrete case: a line of 2.50 at 5 % VAT produces 2.50 × 5 / 100 = 0.125.

result
NumericBone(precision=2, decimal=True) today 0.12
commercial rounding (ROUND_HALF_UP) 0.13

Half-to-even is a perfectly good default for statistics. It is the wrong one when the number has to match an invoice that a bookkeeping system produced with commercial rounding — there the cent is simply wrong, and it is wrong in a way that only shows up on half-cent boundaries, i.e. rarely and unpredictably.

What

NumericBone(
    precision=2,
    decimal=True,
    rounding=decimal.ROUND_HALF_UP,
)

rounding accepts any of the decimal module's modes, exposed as ROUNDING_MODES.

Backwards compatible by construction. The default is None, and None is the decimal module's own "use the context" — it is passed straight through to quantize(). Every existing bone keeps exactly the behaviour it has, including one in an application that sets the context itself:

>>> Decimal("2.345").quantize(Decimal("0.01"), rounding=None)
Decimal('2.34')

How

Four quantize() calls gain rounding=self.rounding — the three branches of _convert_to_decimal and getEmptyValue. No branching, because None already means "context".

Two guards, both firing at definition time rather than at the first write:

  • The value is validated against ROUNDING_MODES in __setattr__, next to the existing min/max guard, so a typo also fires on later assignment.
  • rounding without decimal=True raises instead of being quietly ignored. The float mode rounds through round(float(value), precision), which is half-to-even and subject to binary representation — round(2.675, 2) is 2.67 regardless of any mode. A knob there would promise an exactness that mode cannot deliver, and silently dropping the argument would be the worse answer. I'd rather draw the line explicitly than half-support it.

Tests

8 cases in TestNumericBone_Rounding:

  • the default still follows the context (regression guard on the current behaviour)
  • ROUND_HALF_UP across all four accepted input types (Decimal, str, comma-str, float)
  • the mode actually reaches singleValueFromClient and singleValueSerialize, not just the private helper
  • ROUND_CEILING / ROUND_FLOOR work too
  • the three rejection paths: unknown mode, later assignment of an unknown mode, rounding without decimal

python tests/main.py goes 681 → 689, all green.

docs/adr/bones/numeric.md is updated: the new rule under Rules, and the float-mode round() behaviour added under Traps, since it is the reason for the scope line above.

`decimal=True` exists to make money arithmetic exact, but the rounding mode
was not reachable: `_convert_to_decimal` quantizes without passing `rounding`,
so the decimal context decides — `ROUND_HALF_EVEN` out of the box. An invoice
of 2.50 at 5% VAT yields 0.125, which becomes 0.12 instead of the 0.13 that
commercial rounding (and the invoice in the accounting system next door)
produces. There was no way to ask for a different mode short of subclassing
the bone or mutating the decimal context process-wide.

`NumericBone(precision=2, decimal=True, rounding=decimal.ROUND_HALF_UP)` now
does it.

Backwards compatible by construction: the default is `None`, and `None` is the
decimal module's own "use the context" — it is passed through to `quantize()`
unchanged, so every existing bone keeps the behaviour it has, including one
that relies on an application-set context.

Two guards, both loud at definition time rather than at the first write:

- The value is checked against `ROUNDING_MODES` in `__setattr__`, next to the
  existing min/max guard, so a typo also fires on later assignment.
- `rounding` without `decimal=True` raises instead of being ignored. The float
  mode rounds through `round(float(value), precision)`, which is half-to-even
  *and* subject to binary representation (`round(2.675, 2) == 2.67`); a knob
  there would promise an exactness that mode cannot deliver. Silently dropping
  the argument would be the worse answer.

Tests: 8 new cases in TestNumericBone_Rounding — the default still following
the context, HALF_UP across all four accepted input types, the mode reaching
`singleValueFromClient` and `singleValueSerialize`, CEILING/FLOOR, and the
three rejection paths. Suite goes 681 -> 689, all green.

The ADR for the bone is updated accordingly.
@phorward phorward added bug(fix) Something isn't working or address a specific issue or vulnerability Priority: High After critical issues are fixed, these should be dealt with before any further issues. labels Sep 21, 2026
@phorward phorward added this to the ViUR-core v3.9 milestone Sep 21, 2026

@phorward phorward left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rename the parameter and enum to just round.

Which are around 2 ** (8 * 8 - 1) negative and 2 ** (8 * 8 - 1) positive values.
"""

ROUNDING_MODES = frozenset({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we call this just ROUND?

max: int | float = MAX,
precision: int = 0,
decimal: bool = False,
rounding: str | None = None,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rename it to round.

@phorward phorward changed the title feat(bones): add rounding to NumericBone for commercial rounding feat(bones): add round-parameter to NumericBone Sep 22, 2026

This branch has not been deployed

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

Labels

bug(fix) Something isn't working or address a specific issue or vulnerability Priority: High After critical issues are fixed, these should be dealt with before any further issues.

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

2 participants