Skip to content

Add explicit decimal precision to Money - #534

Open
derikthiessen-shopify wants to merge 25 commits into
Shopify:mainfrom
derikthiessen-shopify:explicit-decimal-precision
Open

derikthiessen-shopify wants to merge 25 commits into
Shopify:mainfrom
derikthiessen-shopify:explicit-decimal-precision

Conversation

@derikthiessen-shopify

@derikthiessen-shopify derikthiessen-shopify commented Sep 3, 2026 •

Copy link
Copy Markdown

Part of #306

TL;DR

Added opt-in decimal_precision: to Money, with construction and arithmetic rounded to the greater of the declared precision and the currency's minor units. Kept public strings, JSON, hashes, and subunits in currency units while preserving calculation precision through Active Job and YAML.

Why Change?

Fractional unit prices can require more digits than their currency supports before quantities are applied. Giving each value an explicit calculation precision retains those digits without making split or allocation totals differ from the stored amount. Public output continues to use the existing currency format.

What Changed?

  • Rounded construction and arithmetic to the effective precision; mixed-precision arithmetic uses the highest operand and currency precision.
  • Preserved existing behavior when precision is omitted, including two-argument subclass initialization.
  • Kept subunit conversion unchanged for all values: ISO, Stripe, and legacy converters truncate fractional subunits toward zero, including when calculation precision is explicit.
  • Kept as_json, to_json, and to_h currency-formatted without precision metadata; readers still accept optional precision metadata.
  • Preserved stored calculation values and precision through Active Job and YAML.
  • Made splitting and allocation conserve the stored value; maximum allocations share precision and floor numeric/string caps before constructing values.
  • Rounded configured money-column assignments before saving, including simultaneous amount/currency changes, without adding a precision database column.
  • Consolidated precision validation and accessors; kept equality value-based and Money.rational exact.

Choose enough precision for the calculation. Precision 3 stores 0.006; precision 4 preserves 0.0057:

Money.new("0.0057", "USD", decimal_precision: 3).value.to_s("F") # => "0.006"

unit_price = Money.new("0.0057", "USD", decimal_precision: 4)
unit_price.value.to_s("F") # => "0.0057"
unit_price.to_s            # => "0.01"
(unit_price * 100).to_s    # => "0.57"
unit_price.subunits        # => 0
unit_price.as_json         # => { value: "0.01", currency: "USD" }

Arithmetic retains the selected calculation precision and renders at currency precision:

total = Money.new(1, "USD") + Money.new("0.057", "USD", decimal_precision: 3)
total.decimal_precision # => 3
total.value.to_s("F")   # => "1.057"
total.to_s              # => "1.06"

(Money.new(1, "USD", decimal_precision: 3) * Rational(1, 3)).value.to_s("F")
# => "0.333"

Allocation conserves calculation values; each displayed share is rounded separately:

amount = Money.new("0.057", "USD", decimal_precision: 3)
shares = amount.allocate_max_amounts(["0.029", "0.028"])
shares.map { |share| share.value.to_s("F") } # => ["0.029", "0.028"]
shares.sum(&:value) == amount.value        # => true
shares.map(&:to_s)                         # => ["0.03", "0.03"]

A money column accepts differently declared input precision and rounds to its own effective configuration before saving:

class Product < ActiveRecord::Base
  money_column :price, currency_column: :price_currency, decimal_precision: 3
end

product = Product.create!(price: Money.new("0.0574", "USD", decimal_precision: 4))
product.reload.price.value.to_s("F") # => "0.057"
product.price.decimal_precision     # => 3
product.price.to_s                  # => "0.06"

Tests

  • Reviewer regressions reproduced 21 failures before the contract fixes; the revised implementation passes them.
  • shadowenv exec -- bundle exec rake: 851 examples, 0 failures, 100% line coverage; RBS validation and Steep passed.
  • RuboCop passed on all six production Ruby files changed by the review fixes.
  • Covered positive/negative allocation conservation, cap handling, JSON format, legacy subunits, subclass initialization, job transport, and model-write rounding/assignment order.
  • Executed 33 checks for the refreshed PR and inline examples, including the restored subunit truncation results.
  • Converter regressions reproduced four failures before restoring truncation; the complete 851-example suite passes after the revert, including allocation conservation coverage.
  • Binks review of the converter revert reported no findings.
  • GitHub CI passed tests and type checks on Ruby 3.2, 3.3, 3.4, and 4.0 for fca196a.
  • World integration CI.

Risks

  • Subunits preserve the existing truncation rule, so fractional-unit values can produce subunits that differ from rounded string/JSON presentment.
  • Explicit precision rounds after each arithmetic operation. Callers must choose enough digits for intermediate results.
  • JSON/hash round trips retain currency presentment, not calculation precision; use Active Job or YAML where calculation values must survive transport.
  • Database amount columns must have sufficient decimal scale for the configured precision.

@derikthiessen-shopify
derikthiessen-shopify marked this pull request as ready for review September 3, 2026 22:12
@derikthiessen-shopify
derikthiessen-shopify marked this pull request as draft September 4, 2026 00:53
Comment thread lib/money_column/active_record_hooks.rb Outdated
return if money.decimal_precision == decimal_precision

raise MoneyColumn::PrecisionMismatchError,
"Invalid #{column}: Money decimal precision #{money.decimal_precision} does not match money column decimal precision #{decimal_precision.inspect}."

@derikthiessen-shopify derikthiessen-shopify Sep 18, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Assignments with a different declared precision are accepted. A configured money column rounds the incoming stored value to its effective model precision before saving and reconstructs it with that precision. The currency's minor units remain the minimum; no precision database column is added.

@derikthiessen-shopify
derikthiessen-shopify marked this pull request as ready for review September 18, 2026 03:10

@derikthiessen-shopify derikthiessen-shopify left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Inline usage examples for the public precision contracts introduced by this PR.

Comment thread lib/money/money.rb

def new(value = 0, currency = nil)
return new_from_money(value, currency) if value.is_a?(Money)
def new(value = 0, currency = nil, decimal_precision: nil)

@derikthiessen-shopify derikthiessen-shopify Sep 21, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Construction rounds to the greater of the declared precision and the currency's minor units. Preserving four calculation digits requires precision 4; strings use currency precision:

Money.new("0.0057", "USD", decimal_precision: 3).value.to_s("F")
# => "0.006"

unit_price = Money.new("0.0057", "USD", decimal_precision: 4)
unit_price.value.to_s("F") # => "0.0057"
unit_price.to_s            # => "0.01"
(unit_price * 100).to_s    # => "0.57"

Comment thread lib/money/money.rb
Money.new(value + money.value, calculated_currency(money.currency))
result_decimal_precision = calculated_decimal_precision(money)
return self if money.value.zero? && !no_currency? && result_decimal_precision == precision_argument
Money.new(value + money.value, calculated_currency(money.currency), decimal_precision: result_decimal_precision)

@derikthiessen-shopify derikthiessen-shopify Sep 21, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Arithmetic preserves the effective calculation precision, while to_s uses the currency's precision:

price = Money.new("1.000", "USD", decimal_precision: 3)
tax = Money.new("0.057", "USD", decimal_precision: 3)

(price + tax).value.to_s("F")   # => "1.057"
(price + tax).decimal_precision # => 3
(price + tax).to_s              # => "1.06"
(price * 2).to_s                # => "2.00"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

to_s will round to 1.06 and 2.00 no?

Comment thread lib/money/money.rb Outdated
to_s
else
{ value: to_s(:amount), currency: currency.to_s }
hash = { value: to_s(:amount), currency: currency.to_s }

@derikthiessen-shopify derikthiessen-shopify Sep 21, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

JSON and hashes use the existing currency-presentment shape, with no precision key. Reading that output back restores the presented amount; Active Job and YAML preserve the stored calculation value and precision:

money = Money.new("0.0057", "USD", decimal_precision: 4)
money.as_json
# => { value: "0.01", currency: "USD" }

restored = Money.from_json(money.to_json)
restored.value.to_s("F")           # => "0.01"
restored.explicit_decimal_precision # => nil
money.value.to_s("F")              # => "0.0057"

Comment thread lib/money/converters/converter.rb Outdated
def to_subunits(money)
raise ArgumentError, "money cannot be nil" if money.nil?
(money.value * subunit_to_unit(money.currency)).to_i
value = money.value.round(money.decimal_precision)

@derikthiessen-shopify derikthiessen-shopify Sep 21, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Subunits preserve each converter's existing currency units and truncation toward zero, with or without explicit calculation precision:

Money.new("0.0099", "USD", decimal_precision: 4).subunits
# => 0

Money.new("1.005", "USD", decimal_precision: 3).subunits
# => 100

Money.new("1.235", "BHD").subunits(format: :legacy_dollar)
# => 123

Money.new("1.235", "BHD", decimal_precision: 3).subunits(format: :legacy_dollar)
# => 123

Splitting and allocation use separate calculation-unit helpers to conserve the stored value.

Comment thread lib/money/allocator.rb Outdated
allocation_currency = extract_currency(maximums + [__getobj__])
maximums = maximums.map { |max| max.to_money(allocation_currency) }
maximums_total = maximums.reduce(Money.new(0, allocation_currency), :+)
maximums = maximums.map { |max| coerce_maximum(max, allocation_currency) }

@derikthiessen-shopify derikthiessen-shopify Sep 21, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Maximum allocations use the highest explicit precision across the receiver and Money caps, with currency precision as the minimum. Numeric/string caps are floored to those units so their limits are not exceeded:

amount = Money.new("0.057", "USD", decimal_precision: 3)
shares = amount.allocate_max_amounts(["0.029", "0.028"])

shares.map { |share| share.value.to_s("F") } # => ["0.029", "0.028"]
shares.sum(&:value) == amount.value        # => true
shares.map(&:to_s)                         # => ["0.03", "0.03"]

Money.new("0.1", "USD", decimal_precision: 2)
  .allocate_max_amounts(["0.055", "0.045"])
  .map { |share| share.value.to_s("F") }
# => ["0.05", "0.04"]

Comment thread lib/money/splitter.rb
subunits = @money.subunits
low = Money.from_subunits(subunits / @num, @money.currency)
high = Money.from_subunits(low.subunits + 1, @money.currency)
units = Helpers.money_to_units(@money)

@derikthiessen-shopify derikthiessen-shopify Sep 21, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Splitting uses the effective calculation precision as its indivisible unit and conserves the stored value. Each share's string uses currency precision:

amount = Money.new("0.057", "USD", decimal_precision: 3)
shares = amount.split(2)

shares.map { |share| share.value.to_s("F") } # => ["0.029", "0.028"]
shares.sum(&:value) == amount.value        # => true
shares.map(&:to_s)                         # => ["0.03", "0.03"]

def serialize(money)
super("value" => money.value.to_s("F"), "currency" => money.currency.iso_code)
attributes = { "value" => money.value.to_s("F"), "currency" => money.currency.iso_code }
attributes["decimal_precision"] = money.decimal_precision if money.explicit_decimal_precision?

@derikthiessen-shopify derikthiessen-shopify Sep 21, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Active Job transport preserves the stored calculation value and its effective explicit precision. Use precision 4 to retain 0.0574:

money = Money.new("0.0574", "USD", decimal_precision: 4)
SomeJob.perform_later(money)
# The job receives value 0.0574 with decimal_precision 4.

money.to_s # => "0.06"

attr_reader :money_column_options

def money_column(*columns, currency_column: nil, currency: nil, currency_read_only: false, coerce_null: false)
def money_column(*columns, currency_column: nil, currency: nil, currency_read_only: false, coerce_null: false, decimal_precision: nil)

@derikthiessen-shopify derikthiessen-shopify Sep 21, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The model owns persistence precision. Assignments with a different declared precision are accepted and rounded to the model's effective precision before saving:

class Product < ActiveRecord::Base
  money_column :price,
    currency_column: :price_currency,
    decimal_precision: 3
end

product = Product.create!(
  price: Money.new("0.0574", "USD", decimal_precision: 4),
)

product.reload.price.value.to_s("F") # => "0.057"
product.price.decimal_precision     # => 3
product.price.to_s                  # => "0.06"

The currency's minor units are the minimum. The amount column needs sufficient decimal scale; there is no precision database column.

Comment thread lib/money/money.rb
Comment thread lib/money/money.rb Outdated
Comment thread lib/money/money.rb Outdated
Comment thread lib/money/splitter.rb
Comment thread lib/money/allocator.rb
Comment thread lib/money/allocator.rb Outdated
Comment thread lib/money/money.rb
Comment thread lib/money/money.rb Outdated
Comment thread lib/money/converters/converter.rb Outdated
Comment thread lib/money_column/active_record_hooks.rb Outdated
@elfassy

elfassy commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

Thanks for this! The goal makes sense: keep extra digits during calculations, and round only when showing the value. I checked the branch out locally and tested edge cases. Every example in these comments comes from running the branch. I also tried the suggested fixes locally.

Main concern

When decimal_precision is passed, the value is never rounded. So one value has three different precisions:

m = Money.new("0.0057", "USD", decimal_precision: 3)
m.value.to_s("F")   # => "0.0057"  raw digits (4 places)
m.decimal_precision # => 3         used for split and allocate
m.to_s              # => "0.01"    currency precision (2 places)

Why this matters: each part of the code has to pick which of the three to use, and different parts pick differently. That causes the main bug: split, allocate and allocate_max_amounts can create money. The parts add up to 0.006 when the original is 0.0057.

Suggested direction

Round to decimal_precision when the object is built, the same way we round to minor_units today. The 0.0057 case then declares decimal_precision: 4.

@decimal_precision = [decimal_precision, currency.minor_units].max if decimal_precision
@value = BigDecimal(value.round(self.decimal_precision))

Why: every value is then a whole number of units at its own precision. Split and allocate keep their "parts add up to the whole" guarantee with no extra code, the number of digits stays limited, and much of the new branching goes away. I applied this locally with the other suggestions in the inline comments. The split and allocate examples then add up correctly. 18 existing specs fail, and all of them check that raw digits are kept, so they would need updating to the new rule.

Other notes

  • Money#initialize now takes 3 positional arguments. Example: a subclass that overrides initialize(value, currency) now gets ArgumentError, because Money.new passes 3 arguments. Why: it is a small break, but easy to miss without a changelog note.
  • Several earlier inline example comments no longer match the code. Example: one says to_s # => "0.006", and the code returns "0.01". Another says mismatched writes are rejected, which is no longer true. Why: reviewers read those examples as the contract, so outdated ones lead to wrong conclusions. Could you resolve or update them?
  • The Money.rational rewrite (to_r / to_r) is a nice simplification. It is exact and removes the subunit factor math, so let's keep it.

Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92
Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92
Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92
Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92
Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92
Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92
Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92
Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92
Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92
Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92
Assisted-By: devx/345c75c7-270f-479c-894f-2febe9415c92
Assisted-By: devx/ec9e0de4-fb50-4089-9e14-c59b7c4dacce
Assisted-By: devx/ec9e0de4-fb50-4089-9e14-c59b7c4dacce
This reverts commit aa129ba.

Assisted-By: devx/ec9e0de4-fb50-4089-9e14-c59b7c4dacce
Assisted-By: devx/ec9e0de4-fb50-4089-9e14-c59b7c4dacce
Assisted-By: devx/ec9e0de4-fb50-4089-9e14-c59b7c4dacce
Assisted-By: devx/ec9e0de4-fb50-4089-9e14-c59b7c4dacce
Assisted-By: devx/ec9e0de4-fb50-4089-9e14-c59b7c4dacce
@derikthiessen-shopify

Copy link
Copy Markdown
Author

Thanks for this! The goal makes sense: keep extra digits during calculations, and round only when showing the value. I checked the branch out locally and tested edge cases. Every example in these comments comes from running the branch. I also tried the suggested fixes locally.

Main concern

When decimal_precision is passed, the value is never rounded. So one value has three different precisions:

m = Money.new("0.0057", "USD", decimal_precision: 3)
m.value.to_s("F")   # => "0.0057"  raw digits (4 places)
m.decimal_precision # => 3         used for split and allocate
m.to_s              # => "0.01"    currency precision (2 places)

Why this matters: each part of the code has to pick which of the three to use, and different parts pick differently. That causes the main bug: split, allocate and allocate_max_amounts can create money. The parts add up to 0.006 when the original is 0.0057.

Suggested direction

Round to decimal_precision when the object is built, the same way we round to minor_units today. The 0.0057 case then declares decimal_precision: 4.

@decimal_precision = [decimal_precision, currency.minor_units].max if decimal_precision
@value = BigDecimal(value.round(self.decimal_precision))

Why: every value is then a whole number of units at its own precision. Split and allocate keep their "parts add up to the whole" guarantee with no extra code, the number of digits stays limited, and much of the new branching goes away. I applied this locally with the other suggestions in the inline comments. The split and allocate examples then add up correctly. 18 existing specs fail, and all of them check that raw digits are kept, so they would need updating to the new rule.

Other notes

  • Money#initialize now takes 3 positional arguments. Example: a subclass that overrides initialize(value, currency) now gets ArgumentError, because Money.new passes 3 arguments. Why: it is a small break, but easy to miss without a changelog note.
  • Several earlier inline example comments no longer match the code. Example: one says to_s # => "0.006", and the code returns "0.01". Another says mismatched writes are rejected, which is no longer true. Why: reviewers read those examples as the contract, so outdated ones lead to wrong conclusions. Could you resolve or update them?
  • The Money.rational rewrite (to_r / to_r) is a nice simplification. It is exact and removes the subunit factor math, so let's keep it.

@elfassy adjusted based on this suggested direction, ready for another look here

class Money
module Converters
class Converter
def to_subunits(money)

@elfassy elfassy Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What's your rationale for needing this change to apply to to_subunits? Subunits are a common understanding on how we represent money with external vendors. It replaced the to_cents method prior to multi-currency. Let me know if you feel strongly, otherwise let's revert this change. It's very likely a breaking change
Money.new(1.001).to_cents #=> 100 or 100.1

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@elfassy the original thinking was to make subunits round consistently with currency display. I don't feel strongly about this decision. I've reverted the change, noting that the tradeoff with the new approach we are doing here is that the truncated subunits can differ from the rounded output from calling to_s

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants