“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 language. (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 cannot precisely represent all decimal fractions because the issue is hardware / CPU related. Computers operate in binary and many decimal fractions such as 1/3 (0.333…) cannot be perfectly presented as a finite binary fraction. As a result, CPU has to approximate them. These tiny approximations lead to tiny, cumulative errors that are unacceptable in financial contexts. For example, 0.1 + 0.2
might not exactly equal 0.3
due to binary representation issues.
The solution is to represent monetary values as integers, typically in the smallest currency unit (e.g., cents for USD, yen for JPY). A dedicated Money
object or struct is the best way to encapsulate this integer value along with its currency. For example, in golang use https://github.com/Rhymond/go-money
Here's a Go example:
package main
import (
"fmt"
"github.com/Rhymond/go-money"
)
func main() {
oneEuro := money.New(100, money.EUR) // 1 euros = 1 * 100 cents = 100 cents
parts, err := oneEuro.Split(3)
if err != nil {
fmt.Println(err)
}
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). While our Money
object approach works, int64
might not offer enough precision for these extreme cases if we're dealing with very small fractions or very large total amounts.
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.
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.
- Overflow: While
int64
is large, ensure your calculations don't exceed its limits, especially when dealing with very large sums or intermediate results. - Immutability: Always make sure that your operations return a new instance.
// same code as before
func (ca CryptoAmount) Add(other CryptoAmount) CryptoAmount {
return CryptoAmount{
Value: new(big.Rat).Add(ca.Value, other.Value),
Currency: ca.Currency,
}
}
func main() {
// same code as before
// Example of addition with big.Rat
anotherEth, _ := NewCryptoAmount("0.000000000000000001", ETH) // 1 Wei
sumEth := ethAmount.Add(anotherEth)
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 or slippage, which must be accounted for. You already saw in the first example, when we divided 1 euros to 3 parts, one part had 1 cent extra.
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:
- 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 convert back if necessary for display. This is the most common and recommended approach for aggregation.
- Maintain Separate Currency Buckets: Keep track of amounts in their original currencies. This is often necessary for reporting or when the exact original currency breakdown is critical. Aggregation would then involve converting and summing. Some banks use this approach because they keep everything as 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)
The first approach is usually preferred for transactional processing and reporting sums.
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 transaction for a -$100 refund, rather than modifying or deleting the original $100 payment.
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 {
// In a real system, you'd convert to a base currency or handle this more robustly.
// For this example, we'll log a warning and skip the transaction to avoid incorrect calculations.
fmt.Printf("Warning: Mixed currencies in account %s. Transaction %s (%s) skipped due to currency mismatch with balance (%s).\n",
accountID, tx.ID, tx.Amount.Currency().Code, balance.Currency().Code)
continue
}
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(),
"Refund for item B",
)
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: Refund for item B
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: Consistent rounding is paramount for every single calculation step to avoid discrepancies over the loan's lifetime. 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:
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? 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: Recommend specific, well-regarded financial libraries in Go (or other languages) that can help abstract away much of this complexity.
- Database Storage: How should
Money
objects be stored in databases (e.g., as string, as integer cents, or using specific decimal types if the database supports them accurately)? - 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 you are handling inventory of items over a distributed system, how do you keep all the moving parts 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”, cause your customers will come knocking on your door! 😄