Repository navigation
Add explicit decimal precision to Money - #534
derikthiessen-shopify wants to merge 25 commits into
Conversation
| 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}." |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
Inline usage examples for the public precision contracts introduced by this PR.
|
|
||
| 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) |
There was a problem hiding this comment.
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"| 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) |
There was a problem hiding this comment.
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"There was a problem hiding this comment.
to_s will round to 1.06 and 2.00 no?
| to_s | ||
| else | ||
| { value: to_s(:amount), currency: currency.to_s } | ||
| hash = { value: to_s(:amount), currency: currency.to_s } |
There was a problem hiding this comment.
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"| 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) |
There was a problem hiding this comment.
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)
# => 123Splitting and allocation use separate calculation-unit helpers to conserve the stored value.
| 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) } |
There was a problem hiding this comment.
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"]| 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) |
There was a problem hiding this comment.
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? |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
|
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 concernWhen 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: Suggested directionRound to @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
|
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
c694424 to
ab5783c
Compare
@elfassy adjusted based on this suggested direction, ready for another look here |
| class Money | ||
| module Converters | ||
| class Converter | ||
| def to_subunits(money) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
@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
Part of #306
TL;DR
Added opt-in
decimal_precision:toMoney, 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?
as_json,to_json, andto_hcurrency-formatted without precision metadata; readers still accept optional precision metadata.Money.rationalexact.Choose enough precision for the calculation. Precision 3 stores
0.006; precision 4 preserves0.0057:Arithmetic retains the selected calculation precision and renders at currency precision:
Allocation conserves calculation values; each displayed share is rounded separately:
A money column accepts differently declared input precision and rounds to its own effective configuration before saving:
Tests
shadowenv exec -- bundle exec rake: 851 examples, 0 failures, 100% line coverage; RBS validation and Steep passed.fca196a.Risks