“Money”: Part 3 - Handling Money in software development

· Arash Tabrizian· 16 min read
“Money”: Part 3 - Handling Money in software development

Handling money in software applications might seem straightforward at first glance. After all, it's just numbers, right? Not really. Financial calculations demand absolute precision, immutability, and careful consideration of various edge cases that can lead to significant errors if overlooked.

In this post, I’ll explain some of the most common and critical complications of handling Money, along with best practices and examples in Go. (These problems are valid in any programming language)

1. The Peril of Floating-Point Numbers: Embrace Money Objects

The first, and perhaps most fundamental, rule of financial programming is: never use floating-point numbers (like float32, float64 in Go, or double, float in other languages) for currency.

Why? Floating-point numbers are designed for scientific calculations where approximations are acceptable. They (IEEE 754) store values as binary fractions, and many decimal fractions that look simple, such as 0.1, have no finite binary representation, just like 1/3 (0.333…) has no finite decimal one. As a result, the stored value is only the closest approximation. These tiny approximations lead to tiny, cumulative errors that are unacceptable in financial contexts. For example, in Go 0.1 + 0.2 == 0.3 is false, because the result is 0.30000000000000004.

The solution is to represent monetary values as integers, typically in the smallest currency unit (e.g., cents for USD, yen for JPY). Keep in mind that not every currency has 2 decimal places: JPY has 0, while BHD and KWD have 3, so the number of decimals should come from currency metadata (ISO 4217) instead of a hardcoded 100. A dedicated Money object or struct is the best way to encapsulate this integer value along with its currency. For example, in Go use go-money. Exact decimal types (e.g., shopspring/decimal in Go, BigDecimal in Java, Decimal in Python and C#, NUMERIC in SQL) are an equally valid alternative.

Here's a Go example:

package main

import (
	"fmt"

	"github.com/Rhymond/go-money"
)

func main() {
	oneEuro := money.New(100, money.EUR) // 1 euro = 1 * 100 cents = 100 cents

	parts, err := oneEuro.Split(3)
	if err != nil {
		fmt.Println(err)
		return
	}

	fmt.Println("Total:", oneEuro.Display())
	fmt.Println("Part 1:", parts[0].Display())
	fmt.Println("Part 2:", parts[1].Display())
	fmt.Println("Part 3:", parts[2].Display())
}

Result:

Total: €1.00
Part 1: €0.34
Part 2: €0.33
Part 3: €0.33

2. Big numbers and Extra Accuracy for Portions

The best examples for big numbers are cryptocurrencies like Bitcoin and Ethereum which are often divisible into many decimal places (e.g., Bitcoin to 8 decimal places, Ether to 18). Integers never lose precision, but int64 has a limited range. Bitcoin in satoshis fits easily, since the total supply is about 2.1×10¹⁵ satoshi. Ether in wei does not: int64 overflows at about 9.22 ETH, which is why Ethereum tooling uses 256-bit integers.

For such scenarios, Go's math/big package is invaluable. Specifically, big.Int for arbitrary-precision integers or big.Rat for arbitrary-precision rational numbers (fractions) are suitable. big.Rat is particularly powerful as it stores numbers as numerator/denominator pairs, preserving exact precision for fractions. In practice, it's best to store amounts as big.Int in the smallest unit (satoshi, wei) and use big.Rat for intermediate calculations such as rates and interest, because a big.Rat can hold values like 1/3 BTC that are silently rounded when displayed. The example below uses big.Rat to keep it short.

package main

import (
	"fmt"
	"math/big"
)

type Currency struct {
	Symbol    string
	Precision int
}

var (
	BTC = Currency{Symbol: "BTC", Precision: 8}
	ETH = Currency{Symbol: "ETH", Precision: 18}
)

// CryptoAmount represents a cryptocurrency amount using big.Rat for exact precision.
type CryptoAmount struct {
	Value    *big.Rat
	Currency Currency
}

// NewCryptoAmount creates a new CryptoAmount from a string to avoid float precision issues.
func NewCryptoAmount(s string, currency Currency) (CryptoAmount, error) {
	r := new(big.Rat)
	if _, ok := r.SetString(s); !ok {
		return CryptoAmount{}, fmt.Errorf("invalid number string: %s", s)
	}

	return CryptoAmount{Value: r, Currency: currency}, nil
}

// Display returns a human-readable representation of the CryptoAmount.
func (ca CryptoAmount) Display() string {
	return fmt.Sprintf("%s %s", ca.Value.FloatString(ca.Currency.Precision), ca.Currency.Symbol) // Display with high precision
}

func main() {
	// Representing a very small fraction of Bitcoin
	btcAmount, err := NewCryptoAmount("0.00000001", BTC) // 1 Satoshi
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("Bitcoin Amount:", btcAmount.Display())

	// Representing a very large amount of Ether with high precision
	ethAmount, err := NewCryptoAmount("1234567890.123456789012345678", ETH)
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("Ethereum Amount:", ethAmount.Display())
}

Result:

Bitcoin Amount: 0.00000001 BTC
Ethereum Amount: 1234567890.123456789012345678 ETH

3. Arithmetic of Records in the Same Currency

Performing arithmetic operations (addition, subtraction, multiplication, division) on Money objects requires careful implementation to ensure accuracy and prevent common pitfalls.

Key considerations:

  • Currency Matching: Always verify that operations are performed on Money objects of the same currency. Mixing currencies without explicit conversion is a recipe for disaster.
  • Rounding Rules: For division or multiplication that results in fractional cents, explicit rounding rules (e.g., round half up, round down) must be applied consistently. This is crucial for calculating taxes, discounts, or splitting bills. Remember the first example: when we split €1 into 3 parts, one part got an extra cent. Leftover cents must be assigned deliberately, never lost.
  • Overflow: While int64 is large, ensure your calculations don't exceed its limits, especially when dealing with very large sums or intermediate results. In Go, integer overflow wraps around silently.
  • Immutability: Always make sure that your operations return a new instance.
// same code as before

func (ca CryptoAmount) Add(other CryptoAmount) (CryptoAmount, error) {
	if ca.Currency.Symbol != other.Currency.Symbol {
		return CryptoAmount{}, fmt.Errorf("currency mismatch: %s vs %s", ca.Currency.Symbol, other.Currency.Symbol)
	}

	return CryptoAmount{
		Value:    new(big.Rat).Add(ca.Value, other.Value),
		Currency: ca.Currency,
	}, nil
}

func main() {
	// same code as before

	// Example of addition with big.Rat

	anotherEth, _ := NewCryptoAmount("0.000000000000000001", ETH) // 1 Wei
	sumEth, err := ethAmount.Add(anotherEth)
	if err != nil {
		fmt.Println(err)
		return
	}

	fmt.Println("Sum of ETH:", sumEth.Display())
}

// Result: Sum of ETH: 1234567890.123456789012345679 ETH (last decimal fraction is now "9")

4. Conversion Rates

Currency conversion is another area of potential issues:

  • Volatility: Exchange rates fluctuate constantly. The rate you use must be the rate at the time of the transaction, not the current rate. So, store them with your transaction values.
  • Source of Truth: Where do your rates come from? A reliable, often paid, API is necessary. Avoid hardcoding or using unreliable free sources for production systems.
  • Precision: Conversion rates themselves can have many decimal places. Use big.Rat or a high-precision decimal type for rates to minimize rounding errors during conversion.
  • Slippage/Fees: Real-world conversions often involve fees, spreads (the difference between buy and sell rates), or slippage, which must be accounted for and recorded as part of the transaction.

5. Arithmetic of Records When There Are Multiple Currencies

When you need to perform arithmetic on records involving different currencies, you generally have two main strategies:

  1. Convert to a Common Base Currency: Before any arithmetic, convert all amounts to a single, predetermined base currency (e.g., User’s default currency, or a global default like EUR). Perform the arithmetic, then display the result in that currency. This is the most common and recommended approach for reporting and aggregation. Avoid converting the result back to the original currencies, since every conversion adds rounding and rate error.
  2. Maintain Separate Currency Buckets: Keep track of amounts in their original currencies. This is necessary whenever the exact original currency breakdown is critical. Aggregation would then involve converting and summing. Most banks and payment providers use this approach because they keep everything in a ledger. For example, imagine you want to buy some US stocks from your account and your account is in Euros. First bank will convert a portion of money from your account to US Dollars. (Now you have 2 buckets, an EUR bucket and a USD bucket). Then will record a transaction for buying a share from stock market. In the end, you might end up with extra dollars in your USD bucket.)

For transactional processing, keep amounts in their original currency and treat every conversion as its own recorded FX transaction (rate, both amounts, fees). Converting to a common base currency is the right tool for reporting and aggregation, as long as you record which rate and timestamp you used.

6. Ledgers and Reverted Transactions

Financial systems rely on the concept of an immutable ledger. Every financial event, whether a credit or a debit, should be recorded as a distinct, unchangeable transaction.

When a transaction needs to be "undone" (e.g., a refund, a cancellation, or a correction), you never delete the original transaction. Instead, you create a new, offsetting transaction. This maintains a complete audit trail and ensures that the historical state of accounts can always be reconstructed.

For example, if a payment of $100 was made in error, you would record a new REFUND transaction of $100 that offsets the original, rather than modifying or deleting the original $100 payment. In a real system, the offsetting transaction should also reference the ID of the transaction it reverses, so the audit trail links them together.

Production ledgers usually go one step further and use double-entry bookkeeping: every transaction debits one account and credits another, so the entries always sum to zero. The example below keeps things simple with a single-entry ledger for one customer account.

package main

import (
	"fmt"
	"time"

	"github.com/Rhymond/go-money"
)

// TransactionType defines the nature of the transaction.
type TransactionType string

const (
	Credit TransactionType = "CREDIT"
	Debit  TransactionType = "DEBIT"
	Refund TransactionType = "REFUND"
)

// Transaction represents a single immutable financial event.
type Transaction struct {
	ID        string
	AccountID string
	Amount    money.Money
	Type      TransactionType
	Timestamp time.Time
	Notes     string
}

// NewTransaction creates and returns a new Transaction.
// This function can be expanded to include validation logic (e.g., ensuring amount is positive).
func NewTransaction(id string, accountID string, amount money.Money, txType TransactionType, timestamp time.Time, notes string) Transaction {
	return Transaction{
		ID:        id,
		AccountID: accountID,
		Amount:    amount,
		Type:      txType,
		Timestamp: timestamp,
		Notes:     notes,
	}
}

func (t Transaction) String() string {
	return fmt.Sprintf("ID: %s, AccountID: %s, Amount: %s, Type: %s, Timestamp: %s, Notes: %s", t.ID, t.AccountID, t.Amount.Display(), t.Type, t.Timestamp.Format("2006-01-02 15:04:05"), t.Notes)
}

// Ledger is a slice of immutable transactions.
type Ledger []Transaction

// RecordTransaction adds a new transaction to the ledger.
func (l *Ledger) RecordTransaction(tx Transaction) {
	*l = append(*l, tx)
}

// GetBalance calculates the current balance for an account from the ledger.
// It returns the calculated balance and an error if any issues occur during calculation.
func (l Ledger) GetBalance(accountID string) (money.Money, error) {
	balance := money.New(0, money.EUR) // Assuming EUR for simplicity

	for _, tx := range l {
		if tx.AccountID == accountID {
			if tx.Amount.Currency().Code != balance.Currency().Code {
				// Never skip a transaction silently: that would return a wrong balance.
				// In a real system, you'd keep a separate balance per currency.
				return money.Money{}, fmt.Errorf("transaction %s in account %s has currency %s, expected %s",
					tx.ID, accountID, tx.Amount.Currency().Code, balance.Currency().Code)
			}

			var newBalance *money.Money
			var err error

			switch tx.Type {
			case Credit, Refund: // Refunds and Credits increase balance
				newBalance, err = balance.Add(&tx.Amount)
				if err != nil {
					return money.Money{}, fmt.Errorf("error adding credit/refund transaction %s to balance for account %s: %w", tx.ID, accountID, err)
				}
			case Debit: // Debit transactions decrease balance
				newBalance, err = balance.Subtract(&tx.Amount)
				if err != nil {
					return money.Money{}, fmt.Errorf("error subtracting debit transaction %s from balance for account %s: %w", tx.ID, accountID, err)
				}
			default:
				return money.Money{}, fmt.Errorf("unsupported transaction type '%s' for transaction ID %s in account %s", tx.Type, tx.ID, accountID)
			}
			balance = newBalance
		}
	}
	return *balance, nil
}

func main() {
	var customerLedger Ledger
	customerAccountID := "CUST-001"

	// Initial payment
	payment1 := NewTransaction(
		"TXN-001",
		customerAccountID,
		*money.New(10000, money.EUR),
		Credit,
		time.Now().Add(-24*time.Hour),
		"Initial payment for service",
	)
	customerLedger.RecordTransaction(payment1)
	fmt.Println("Recorded:", payment1.String())

	balance, err := customerLedger.GetBalance(customerAccountID)
	if err != nil {
		fmt.Printf("Error getting balance after TXN-001: %v\n", err)
		// In a real application, you might want to return or panic here
	} else {
		fmt.Println("Current Balance:", balance.Display())
	}

	// Purchase
	purchase1 := NewTransaction(
		"TXN-002",
		customerAccountID,
		*money.New(3000, money.EUR),
		Debit,
		time.Now().Add(-12*time.Hour),
		"Purchase of item A",
	)
	customerLedger.RecordTransaction(purchase1)
	fmt.Println("Recorded:", purchase1.String())

	balance, err = customerLedger.GetBalance(customerAccountID)
	if err != nil {
		fmt.Printf("Error getting balance after TXN-002: %v\n", err)
	} else {
		fmt.Println("Current Balance:", balance.Display())
	}

	// Refund for an item
	refund1 := NewTransaction(
		"TXN-003",
		customerAccountID,
		*money.New(1000, money.EUR),
		Refund,
		time.Now(),
		"Partial refund for item A",
	)

	customerLedger.RecordTransaction(refund1)
	fmt.Println("Recorded:", refund1.String())

	balance, err = customerLedger.GetBalance(customerAccountID)
	if err != nil {
		fmt.Printf("Error getting balance after TXN-003: %v\n", err)
	} else {
		fmt.Println("Current Balance:", balance.Display())
	}

	fmt.Println("\n--- Full Ledger ---")
	for _, tx := range customerLedger {
		fmt.Printf("ID: %s, Type: %s, Amount: %s, Date: %s\n", tx.ID, tx.Type, tx.Amount.Display(), tx.Timestamp.Format("2006-01-02 15:04:05"))
	}
}

Result:

Recorded: ID: TXN-001, AccountID: CUST-001, Amount: €100.00, Type: CREDIT, Timestamp: 2025-06-02 22:48:51, Notes: Initial payment for service
Current Balance: €100.00
Recorded: ID: TXN-002, AccountID: CUST-001, Amount: €30.00, Type: DEBIT, Timestamp: 2025-06-03 10:48:51, Notes: Purchase of item A
Current Balance: €70.00
Recorded: ID: TXN-003, AccountID: CUST-001, Amount: €10.00, Type: REFUND, Timestamp: 2025-06-03 22:48:51, Notes: Partial refund for item A
Current Balance: €80.00

--- Full Ledger ---
ID: TXN-001, Type: CREDIT, Amount: €100.00, Date: 2025-06-02 22:48:51
ID: TXN-002, Type: DEBIT, Amount: €30.00, Date: 2025-06-03 10:48:51
ID: TXN-003, Type: REFUND, Amount: €10.00, Date: 2025-06-03 22:48:51

7. Calculating Installments and Interests

Financial mathematics for loans, mortgages, and investments is notoriously complex. It involves:

  • Compound Interest: Interest calculated on the initial principal and also on the accumulated interest of previous periods.
  • Amortization Schedules: Breaking down loan payments into principal and interest components over time.
  • Rounding Rules: Decide exactly where rounding happens (usually once per payment, not at every intermediate step) and which mode to use (round half up, round half to even, etc.), which is often set by contract or regulation. Keep full precision in between and let the last installment absorb any remaining difference. Even slight variations can lead to significant differences in the final payment or total interest.
  • Day Count Conventions: Different financial instruments use different methods to count the number of days between two dates for interest calculation (e.g., actual/actual, 30/360).

Unless you are a financial math expert, it's highly recommended to use a well-vetted financial library for these calculations rather than implementing them from scratch. If you must implement, ensure rigorous testing against known financial models.

While a full Go example for a complex amortization schedule is beyond the scope of a blog post, here's a simplified interest calculation demonstrating the need for precision:

package main

import (
	"fmt"
	"math/big"
)

func main() {
	principal := big.NewRat(1000000, 1) // €10,000.00 in cents
	monthlyRate := big.NewRat(5, 1200)  // 5% annual interest / 12 months
	factor := new(big.Rat).Add(big.NewRat(1, 1), monthlyRate)

	balance := new(big.Rat).Set(principal)
	for i := 0; i < 12; i++ {
		balance.Mul(balance, factor) // exact, no rounding per step
	}

	fmt.Println("Exact result: ", balance.FloatString(15))

	// The same calculation with float64
	f := 1000000.0
	for i := 0; i < 12; i++ {
		f *= 1 + 0.05/12
	}
	fmt.Printf("float64 result: %.15f\n", f)

	fmt.Println("Balance after 1 year:", balance.FloatString(0), "cents") // round only once, at the end
}

Result:

Exact result:  1051161.897881733189805
float64 result: 1051161.897881733020768
Balance after 1 year: 1051162 cents

The float64 result starts to drift after the 9th decimal place. That looks harmless for one loan over one year, but over many periods, larger amounts, and millions of accounts, these errors add up.

8. Bonus topics to think about!

Here are some other topics that you need to figure out when handling money in your software:

  • Audit Trails and Immutability: Be ready for audits. Log every change and ensure that financial data, once recorded, is never altered, only offset.
  • Monitoring and disaster recovery: Of course you need these in every system, but they are more important in financial data. Unavailability of a financial system can literally be a life or death situation for someone.
  • Time Zones and Dates: How do time zones affect financial transactions, especially for global operations or interest calculations? When is a transaction considered "done" across different time zones?
  • Compliance and Regulations: If someone (from Europe) wants to use their right to be forgotten (GDPR for personal financial data), what should happen to their financial data? (Hint: the right to erasure is limited where the law requires you to keep records, and EU anti-money laundering rules usually require keeping transaction records for at least 5 years.) How to comply with money laundering laws? How to handle a politically exposed person? If you are a small team how are you going to comply with the ever changing regulations of FinTech world?
  • Security Considerations: How to protect financial data from unauthorized access, fraud, and cyber threats (encryption, access control, secure coding practices).
  • Testing Financial Logic: Not only you need extensive unit, integration, and regression testing, especially with known good test cases from financial experts, but also you need gateway and manual testing.
  • External Financial Libraries: Well-regarded libraries can abstract away much of this complexity, e.g., Rhymond/go-money, shopspring/decimal and cockroachdb/apd in Go, JavaMoney (JSR 354) in Java, dinero.js in JavaScript, and decimal and py-moneyed in Python.
  • Database Storage: How should Money objects be stored in databases (e.g., as integer cents in a BIGINT, or as NUMERIC/DECIMAL, always next to a currency column)? Avoid FLOAT/REAL, and be careful with PostgreSQL's MONEY type, whose formatting depends on the locale and which supports only one currency precision.
  • User Interface (UI) Presentation: How to display monetary values to users, including currency symbols, decimal separators, and handling different locale formats. How to receive these values in API requests or present them in API responses.
  • Transactions in distributed systems and idempotent results: If a payment request is retried over a distributed system (e.g., after a timeout), how do you make sure the customer is not charged twice? Idempotency keys are the usual answer, but all the moving parts still need to stay in sync.

I might write a separate post to cover these topics, but for now I leave them here as food for thought!

Conclusion

Handling money in software development is a domain where "close enough" is never good enough. From choosing the right data types to designing immutable ledgers and understanding complex financial mathematics, each step requires meticulous attention to detail and adherence to best practices. By embracing Money objects, leveraging arbitrary-precision arithmetic, and understanding the nuances of currency conversion and transaction recording, you can build financial systems that are accurate, reliable, and auditable. Just make sure you do not lose even “a cent”, because your customers will come knocking on your door! 😄

“Money”: Part 3 - Handling Money in software development | Minimalist Perfectionist