Skip to content

Conversation

@rfloriano
Copy link

@rfloriano rfloriano commented Aug 2, 2025

Add NewFromFloatWithRound function to fix precision loss in float-to-money conversion

Problem

The existing NewFromFloat function has precision loss issues due to its behavior of always truncating trailing decimals down (using int64() conversion). This leads to unexpected results when converting precise decimal values:

m := NewFromFloat(147.23, USD)
fmt.Println(m.Display()) // --> $147.22 (lost penny) ❌

m := NewFromFloat(-0.125, EUR) 
// Results in amount: -12 (truncated down)

This behavior is particularly problematic when:

  • Working with exact decimal amounts that match the currency's precision (e.g. 147.23 - two decimal places with currency USD, Fraction = 2)
  • Converting financial data where precision is critical
  • Dealing with negative values where truncation becomes more counterintuitive

Solution

This PR introduces NewFromFloatWithRound, a new function that uses proper mathematical rounding (math.Round) instead of truncation:

// NewFromFloatWithRound creates and returns new instance of Money from a float64.
// It rounds trailing decimals to the nearest integer as math.Round does.
func NewFromFloatWithRound(amount float64, code string) *Money {
    currency := newCurrency(code).get()
    currencyDecimals := math.Pow10(currency.Fraction)
    return &Money{
        amount:   int64(math.Round(amount * currencyDecimals)),
        currency: currency,
    }
}

Key Improvements

  1. Precision preservation: Exact decimal values are preserved correctly

    m := NewFromFloatWithRound(147.23, USD)
    fmt.Println(m.Display()) // --> $147.23 ✅
  2. Proper rounding behavior: Uses banker's rounding (round half to even) via math.Round

    NewFromFloatWithRound(-147.23, EUR)  // amount: -14723 (rounded)
    vs
    NewFromFloat(-147.23, EUR)           // amount: -14722 (truncated)
  3. Consistent with mathematical expectations: Follows standard rounding rules instead of always rounding down

Backward Compatibility

  • The existing NewFromFloat function remains unchanged to maintain backward compatibility
  • Applications that specifically need truncation behavior can continue using the original function
  • The new function provides an opt-in solution for applications requiring proper rounding

Usage Examples

// Old behavior (truncation)
m1 := NewFromFloat(147.23, USD)      // $147.22
m2 := NewFromFloat(-0.125, EUR)      // -€0.12

// New behavior (proper rounding) 
m3 := NewFromFloatWithRound(147.23, USD)  // $147.23
m4 := NewFromFloatWithRound(-0.125, EUR)  // -€0.13

This enhancement addresses precision issues while maintaining full backward compatibility, giving developers the choice between truncation and proper rounding based on their specific use cases.

Summary by Sourcery

Add NewFromFloatWithRound to handle float-to-money conversion using math.Round instead of truncation, maintaining backward compatibility, and update tests and documentation accordingly

New Features:

  • Add NewFromFloatWithRound function for float-to-money conversion with proper mathematical rounding

Enhancements:

  • Preserve existing NewFromFloat behavior for backward compatibility

Documentation:

  • Update README to document the new rounding-based initialization method

Tests:

  • Add unit tests for NewFromFloatWithRound covering positive, negative, and unregistered currency cases

@sourcery-ai
Copy link

sourcery-ai bot commented Aug 2, 2025

Reviewer's Guide

Adds a NewFromFloatWithRound method that uses math.Round to avoid precision loss in float-to-money conversion, accompanied by unit tests for positive, negative, and unregistered currency scenarios, and updates to documentation to illustrate its usage.

Class diagram for Money creation methods

classDiagram
    class Money {
        - int64 amount
        - *Currency currency
        + Currency() *Currency
    }
    class Currency {
        - int Fraction
        + get() *Currency
    }
    Money <.. Currency : uses
    class money {
        + New(amount int64, code string) *Money
        + NewFromFloat(amount float64, code string) *Money
        + NewFromFloatWithRound(amount float64, code string) *Money
    }
    money ..> Money : creates
    money ..> Currency : uses

    %% Highlight new method
    class money {
        + NewFromFloatWithRound(amount float64, code string) *Money
    }
Loading

File-Level Changes

Change Details Files
Introduce NewFromFloatWithRound function for proper rounding in float-to-money conversion
  • Implement NewFromFloatWithRound in money.go using math.Round and currency.Fraction
  • Preserve backward compatibility by keeping the original truncating function
money.go
Add unit tests for NewFromFloatWithRound
  • Test positive and negative rounding behavior
  • Validate uppercase and lowercase currency code handling
  • Cover unregistered currency code scenario
money_test.go
Update README to document new rounding function
  • Add example usage of NewFromFloatWithRound
  • Clarify difference between truncation and rounding in examples
README.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey @rfloriano - I've reviewed your changes - here's some feedback:

  • Update the doc comments and README to accurately state that math.Round rounds ties away from zero (not banker's rounding).
  • Add a positive mid-point test case (e.g., 1.005 for USD) to verify the expected rounding-away-from-zero behavior.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- Update the doc comments and README to accurately state that math.Round rounds ties away from zero (not banker's rounding).
- Add a positive mid-point test case (e.g., 1.005 for USD) to verify the expected rounding-away-from-zero behavior.

## Individual Comments

### Comment 1
<location> `money.go:99` </location>
<code_context>

+// NewFromFloatWithRound creates and returns new instance of Money from a float64.
+// It rounds trailing decimals to the nearest integer as math.Round does.
+func NewFromFloatWithRound(amount float64, code string) *Money {
+	currency := newCurrency(code).get()
+	currencyDecimals := math.Pow10(currency.Fraction)
</code_context>

<issue_to_address>
Consider handling NaN and Inf values for the amount parameter.

math.Round will pass through NaN or Inf values, which can cause unexpected behavior or panics when converting to int64. Please add explicit checks for these cases and handle them appropriately.
</issue_to_address>

<suggested_fix>
<<<<<<< SEARCH
func NewFromFloatWithRound(amount float64, code string) *Money {
	currency := newCurrency(code).get()
	currencyDecimals := math.Pow10(currency.Fraction)
	return &Money{
		amount:   int64(math.Round(amount * currencyDecimals)),
		currency: currency,
	}
}
=======
func NewFromFloatWithRound(amount float64, code string) *Money {
	if math.IsNaN(amount) || math.IsInf(amount, 0) {
		return nil
	}
	currency := newCurrency(code).get()
	currencyDecimals := math.Pow10(currency.Fraction)
	return &Money{
		amount:   int64(math.Round(amount * currencyDecimals)),
		currency: currency,
	}
}
>>>>>>> REPLACE

</suggested_fix>

### Comment 2
<location> `money_test.go:844` </location>
<code_context>
 	}
 }

+func TestNewFromFloatWithRound(t *testing.T) {
+	m := NewFromFloatWithRound(12.34, EUR)
+
+	if m.amount != 1234 {
+		t.Errorf("Expected %d got %d", 1234, m.amount)
+	}
+
+	if m.currency.Code != EUR {
+		t.Errorf("Expected currency %s got %s", EUR, m.currency.Code)
+	}
+
+	m = NewFromFloatWithRound(12.34, "eur")
+
+	if m.amount != 1234 {
+		t.Errorf("Expected %d got %d", 1234, m.amount)
+	}
+
+	if m.currency.Code != EUR {
+		t.Errorf("Expected currency %s got %s", EUR, m.currency.Code)
+	}
+
+	m = NewFromFloatWithRound(-0.125, EUR)
+
+	if m.amount != -13 {
+		t.Errorf("Expected %d got %d", -13, m.amount)
+	}
+
+	m = NewFromFloatWithRound(147.23, BRL)
+
+	if m.amount != 14723 {
+		t.Errorf("Expected %d got %d", 14723, m.amount)
+	}
+}
+
+func TestNewFromFloatWithRound_WithUnregisteredCurrency(t *testing.T) {
</code_context>

<issue_to_address>
Missing edge case tests for rounding behavior at .5 boundaries and for currencies with different fractions.

Please add tests for rounding at .5 boundaries (e.g., 0.125, 0.135, 0.145) and for currencies with 0 and 3 decimal places (e.g., JPY, IQD) to ensure correct handling of these cases.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

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.

1 participant