Skip to content

domain.value_objects.money

src.domain.value_objects.money

Immutable Money value object with Decimal precision.

Financial calculations require exact precision - floats introduce rounding errors that accumulate in financial systems. This module provides a Money value object using Python's Decimal type.

Error Handling

Arithmetic operations between different currencies raise CurrencyMismatchError (a ValueError subclass). This follows Python's convention for type-incompatible operations (e.g., str + int raises TypeError). This is compliant with our error handling architecture - see "Value Object Arithmetic Exceptions" section in docs/architecture/error-handling-architecture.md for rationale.

Reference
  • docs/architecture/account-domain-model.md
  • docs/architecture/error-handling-architecture.md
Usage

from decimal import Decimal from src.domain.value_objects import Money

balance = Money(Decimal("1000.00"), "USD") fee = Money(Decimal("9.99"), "USD") result = balance - fee # Money(990.01, USD)

Classes

CurrencyMismatchError

Bases: ValueError

Raised when attempting operations on different currencies.

Source code in src/domain/value_objects/money.py
class CurrencyMismatchError(ValueError):
    """Raised when attempting operations on different currencies."""

    def __init__(self, currency1: str, currency2: str) -> None:
        """Initialize currency mismatch error.

        Args:
            currency1: First currency code.
            currency2: Second currency code.
        """
        super().__init__(
            f"Cannot perform operation between {currency1} and {currency2}"
        )
        self.currency1 = currency1
        self.currency2 = currency2
Functions
__init__
__init__(currency1: str, currency2: str) -> None

Parameters:

Name Type Description Default
currency1 str

First currency code.

required
currency2 str

Second currency code.

required
Source code in src/domain/value_objects/money.py
def __init__(self, currency1: str, currency2: str) -> None:
    """Initialize currency mismatch error.

    Args:
        currency1: First currency code.
        currency2: Second currency code.
    """
    super().__init__(
        f"Cannot perform operation between {currency1} and {currency2}"
    )
    self.currency1 = currency1
    self.currency2 = currency2

Money dataclass

Immutable monetary value with currency.

Financial calculations require exact precision - floats introduce rounding errors that accumulate in financial systems. This class uses Python's Decimal for exact decimal arithmetic.

Attributes:

Name Type Description
amount Decimal

Decimal value (positive, negative, or zero).

currency str

ISO 4217 currency code (e.g., "USD", "EUR").

Immutability

Frozen dataclass ensures Money cannot be modified after creation. All arithmetic operations return new Money instances.

Currency Safety

Operations between different currencies raise CurrencyMismatchError. This prevents accidental mixing of currencies without conversion.

Example

from decimal import Decimal balance = Money(Decimal("1000.00"), "USD") fee = Money(Decimal("9.99"), "USD") balance - fee Money(amount=Decimal('990.01'), currency='USD')

Warning

Always use string initialization for Decimal to avoid float precision:

Money(Decimal("0.1"), "USD") # Correct Money(Decimal(0.1), "USD") # Wrong - already imprecise!

Source code in src/domain/value_objects/money.py
@dataclass(frozen=True)
class Money:
    """Immutable monetary value with currency.

    Financial calculations require exact precision - floats introduce
    rounding errors that accumulate in financial systems. This class
    uses Python's Decimal for exact decimal arithmetic.

    Attributes:
        amount: Decimal value (positive, negative, or zero).
        currency: ISO 4217 currency code (e.g., "USD", "EUR").

    Immutability:
        Frozen dataclass ensures Money cannot be modified after creation.
        All arithmetic operations return new Money instances.

    Currency Safety:
        Operations between different currencies raise CurrencyMismatchError.
        This prevents accidental mixing of currencies without conversion.

    Example:
        >>> from decimal import Decimal
        >>> balance = Money(Decimal("1000.00"), "USD")
        >>> fee = Money(Decimal("9.99"), "USD")
        >>> balance - fee
        Money(amount=Decimal('990.01'), currency='USD')

    Warning:
        Always use string initialization for Decimal to avoid float precision:
        >>> Money(Decimal("0.1"), "USD")  # Correct
        >>> Money(Decimal(0.1), "USD")    # Wrong - already imprecise!
    """

    amount: Decimal
    currency: str

    def __post_init__(self) -> None:
        """Validate money after initialization.

        Raises:
            ValueError: If amount is not a valid Decimal or currency is invalid.
        """
        # Validate amount is Decimal
        if not isinstance(self.amount, Decimal):
            try:
                # Allow int/float but convert to Decimal
                object.__setattr__(self, "amount", Decimal(str(self.amount)))
            except (InvalidOperation, TypeError) as e:
                raise ValueError(f"Amount must be a valid number: {e}") from e

        # Check for special values (NaN, Infinity)
        if self.amount.is_nan() or self.amount.is_infinite():
            raise ValueError("Amount cannot be NaN or Infinite")

        # Validate and normalize currency
        validated_currency = validate_currency(self.currency)
        object.__setattr__(self, "currency", validated_currency)

    # -------------------------------------------------------------------------
    # Arithmetic Operations (Same Currency Only)
    # -------------------------------------------------------------------------

    def __add__(self, other: "Money") -> "Money":
        """Add two Money values.

        Args:
            other: Money to add.

        Returns:
            New Money with sum of amounts.

        Raises:
            CurrencyMismatchError: If currencies differ.
            TypeError: If other is not Money.
        """
        if not isinstance(other, Money):
            return NotImplemented
        self._check_same_currency(other)
        return Money(self.amount + other.amount, self.currency)

    def __sub__(self, other: "Money") -> "Money":
        """Subtract two Money values.

        Args:
            other: Money to subtract.

        Returns:
            New Money with difference of amounts.

        Raises:
            CurrencyMismatchError: If currencies differ.
            TypeError: If other is not Money.
        """
        if not isinstance(other, Money):
            return NotImplemented
        self._check_same_currency(other)
        return Money(self.amount - other.amount, self.currency)

    def __mul__(self, scalar: Decimal | int | float) -> "Money":
        """Multiply Money by a scalar.

        Args:
            scalar: Number to multiply by.

        Returns:
            New Money with scaled amount.

        Example:
            >>> balance = Money(Decimal("100.00"), "USD")
            >>> balance * 2
            Money(amount=Decimal('200.00'), currency='USD')
        """
        if isinstance(scalar, (Decimal, int, float)):
            return Money(self.amount * Decimal(str(scalar)), self.currency)
        return NotImplemented

    def __rmul__(self, scalar: Decimal | int | float) -> "Money":
        """Right multiply (scalar * Money).

        Args:
            scalar: Number to multiply by.

        Returns:
            New Money with scaled amount.
        """
        return self.__mul__(scalar)

    def __neg__(self) -> "Money":
        """Negate the amount.

        Returns:
            New Money with negated amount.

        Example:
            >>> debt = Money(Decimal("100.00"), "USD")
            >>> -debt
            Money(amount=Decimal('-100.00'), currency='USD')
        """
        return Money(-self.amount, self.currency)

    def __abs__(self) -> "Money":
        """Get absolute value.

        Returns:
            New Money with absolute amount.
        """
        return Money(abs(self.amount), self.currency)

    # -------------------------------------------------------------------------
    # Comparison Operations (Same Currency Only)
    # -------------------------------------------------------------------------

    def __lt__(self, other: "Money") -> bool:
        """Less than comparison.

        Args:
            other: Money to compare.

        Returns:
            True if this amount is less than other.

        Raises:
            CurrencyMismatchError: If currencies differ.
        """
        if not isinstance(other, Money):
            return NotImplemented
        self._check_same_currency(other)
        return self.amount < other.amount

    def __le__(self, other: "Money") -> bool:
        """Less than or equal comparison.

        Args:
            other: Money to compare.

        Returns:
            True if this amount is less than or equal to other.

        Raises:
            CurrencyMismatchError: If currencies differ.
        """
        if not isinstance(other, Money):
            return NotImplemented
        self._check_same_currency(other)
        return self.amount <= other.amount

    def __gt__(self, other: "Money") -> bool:
        """Greater than comparison.

        Args:
            other: Money to compare.

        Returns:
            True if this amount is greater than other.

        Raises:
            CurrencyMismatchError: If currencies differ.
        """
        if not isinstance(other, Money):
            return NotImplemented
        self._check_same_currency(other)
        return self.amount > other.amount

    def __ge__(self, other: "Money") -> bool:
        """Greater than or equal comparison.

        Args:
            other: Money to compare.

        Returns:
            True if this amount is greater than or equal to other.

        Raises:
            CurrencyMismatchError: If currencies differ.
        """
        if not isinstance(other, Money):
            return NotImplemented
        self._check_same_currency(other)
        return self.amount >= other.amount

    # -------------------------------------------------------------------------
    # Query Methods
    # -------------------------------------------------------------------------

    def is_positive(self) -> bool:
        """Check if amount is positive (> 0).

        Returns:
            True if amount is greater than zero.
        """
        return self.amount > 0

    def is_negative(self) -> bool:
        """Check if amount is negative (< 0).

        Returns:
            True if amount is less than zero.
        """
        return self.amount < 0

    def is_zero(self) -> bool:
        """Check if amount is zero.

        Returns:
            True if amount equals zero.
        """
        return self.amount == 0

    # -------------------------------------------------------------------------
    # Factory Methods
    # -------------------------------------------------------------------------

    @classmethod
    def zero(cls, currency: str = "USD") -> Self:
        """Create Money with zero amount.

        Args:
            currency: Currency code (default: USD).

        Returns:
            Money with zero amount in specified currency.

        Example:
            >>> Money.zero("EUR")
            Money(amount=Decimal('0'), currency='EUR')
        """
        return cls(Decimal("0"), currency)

    @classmethod
    def from_cents(cls, cents: int, currency: str = "USD") -> Self:
        """Create Money from cents (or smallest unit).

        Useful for APIs that return amounts in cents.

        Args:
            cents: Amount in cents/smallest currency unit.
            currency: Currency code (default: USD).

        Returns:
            Money with converted amount.

        Example:
            >>> Money.from_cents(12345, "USD")
            Money(amount=Decimal('123.45'), currency='USD')

        Note:
            Assumes 100 cents per unit. For currencies with different
            subdivisions (e.g., JPY has no cents), use direct construction.
        """
        return cls(Decimal(cents) / Decimal("100"), currency)

    # -------------------------------------------------------------------------
    # String Representations
    # -------------------------------------------------------------------------

    def __repr__(self) -> str:
        """Return repr for debugging.

        Returns:
            Detailed string representation.
        """
        return f"Money(amount={self.amount!r}, currency={self.currency!r})"

    def __str__(self) -> str:
        """Return human-readable string.

        Returns:
            Formatted string like "1,234.56 USD".
        """
        # Format with 2 decimal places and thousands separator
        formatted = f"{self.amount:,.2f}"
        return f"{formatted} {self.currency}"

    # -------------------------------------------------------------------------
    # Private Methods
    # -------------------------------------------------------------------------

    def _check_same_currency(self, other: "Money") -> None:
        """Verify currencies match for operations.

        Args:
            other: Other Money to check.

        Raises:
            CurrencyMismatchError: If currencies differ.
        """
        if self.currency != other.currency:
            raise CurrencyMismatchError(self.currency, other.currency)
Functions
__post_init__
__post_init__() -> None

Validate money after initialization.

Raises:

Type Description
ValueError

If amount is not a valid Decimal or currency is invalid.

Source code in src/domain/value_objects/money.py
def __post_init__(self) -> None:
    """Validate money after initialization.

    Raises:
        ValueError: If amount is not a valid Decimal or currency is invalid.
    """
    # Validate amount is Decimal
    if not isinstance(self.amount, Decimal):
        try:
            # Allow int/float but convert to Decimal
            object.__setattr__(self, "amount", Decimal(str(self.amount)))
        except (InvalidOperation, TypeError) as e:
            raise ValueError(f"Amount must be a valid number: {e}") from e

    # Check for special values (NaN, Infinity)
    if self.amount.is_nan() or self.amount.is_infinite():
        raise ValueError("Amount cannot be NaN or Infinite")

    # Validate and normalize currency
    validated_currency = validate_currency(self.currency)
    object.__setattr__(self, "currency", validated_currency)
__add__
__add__(other: Money) -> Money

Add two Money values.

Parameters:

Name Type Description Default
other Money

Money to add.

required

Returns:

Type Description
Money

New Money with sum of amounts.

Raises:

Type Description
CurrencyMismatchError

If currencies differ.

TypeError

If other is not Money.

Source code in src/domain/value_objects/money.py
def __add__(self, other: "Money") -> "Money":
    """Add two Money values.

    Args:
        other: Money to add.

    Returns:
        New Money with sum of amounts.

    Raises:
        CurrencyMismatchError: If currencies differ.
        TypeError: If other is not Money.
    """
    if not isinstance(other, Money):
        return NotImplemented
    self._check_same_currency(other)
    return Money(self.amount + other.amount, self.currency)
__sub__
__sub__(other: Money) -> Money

Subtract two Money values.

Parameters:

Name Type Description Default
other Money

Money to subtract.

required

Returns:

Type Description
Money

New Money with difference of amounts.

Raises:

Type Description
CurrencyMismatchError

If currencies differ.

TypeError

If other is not Money.

Source code in src/domain/value_objects/money.py
def __sub__(self, other: "Money") -> "Money":
    """Subtract two Money values.

    Args:
        other: Money to subtract.

    Returns:
        New Money with difference of amounts.

    Raises:
        CurrencyMismatchError: If currencies differ.
        TypeError: If other is not Money.
    """
    if not isinstance(other, Money):
        return NotImplemented
    self._check_same_currency(other)
    return Money(self.amount - other.amount, self.currency)
__mul__
__mul__(scalar: Decimal | int | float) -> Money

Multiply Money by a scalar.

Parameters:

Name Type Description Default
scalar Decimal | int | float

Number to multiply by.

required

Returns:

Type Description
Money

New Money with scaled amount.

Example

balance = Money(Decimal("100.00"), "USD") balance * 2 Money(amount=Decimal('200.00'), currency='USD')

Source code in src/domain/value_objects/money.py
def __mul__(self, scalar: Decimal | int | float) -> "Money":
    """Multiply Money by a scalar.

    Args:
        scalar: Number to multiply by.

    Returns:
        New Money with scaled amount.

    Example:
        >>> balance = Money(Decimal("100.00"), "USD")
        >>> balance * 2
        Money(amount=Decimal('200.00'), currency='USD')
    """
    if isinstance(scalar, (Decimal, int, float)):
        return Money(self.amount * Decimal(str(scalar)), self.currency)
    return NotImplemented
__rmul__
__rmul__(scalar: Decimal | int | float) -> Money

Right multiply (scalar * Money).

Parameters:

Name Type Description Default
scalar Decimal | int | float

Number to multiply by.

required

Returns:

Type Description
Money

New Money with scaled amount.

Source code in src/domain/value_objects/money.py
def __rmul__(self, scalar: Decimal | int | float) -> "Money":
    """Right multiply (scalar * Money).

    Args:
        scalar: Number to multiply by.

    Returns:
        New Money with scaled amount.
    """
    return self.__mul__(scalar)
__neg__
__neg__() -> Money

Negate the amount.

Returns:

Type Description
Money

New Money with negated amount.

Example

debt = Money(Decimal("100.00"), "USD") -debt Money(amount=Decimal('-100.00'), currency='USD')

Source code in src/domain/value_objects/money.py
def __neg__(self) -> "Money":
    """Negate the amount.

    Returns:
        New Money with negated amount.

    Example:
        >>> debt = Money(Decimal("100.00"), "USD")
        >>> -debt
        Money(amount=Decimal('-100.00'), currency='USD')
    """
    return Money(-self.amount, self.currency)
__abs__
__abs__() -> Money

Get absolute value.

Returns:

Type Description
Money

New Money with absolute amount.

Source code in src/domain/value_objects/money.py
def __abs__(self) -> "Money":
    """Get absolute value.

    Returns:
        New Money with absolute amount.
    """
    return Money(abs(self.amount), self.currency)
__lt__
__lt__(other: Money) -> bool

Less than comparison.

Parameters:

Name Type Description Default
other Money

Money to compare.

required

Returns:

Type Description
bool

True if this amount is less than other.

Raises:

Type Description
CurrencyMismatchError

If currencies differ.

Source code in src/domain/value_objects/money.py
def __lt__(self, other: "Money") -> bool:
    """Less than comparison.

    Args:
        other: Money to compare.

    Returns:
        True if this amount is less than other.

    Raises:
        CurrencyMismatchError: If currencies differ.
    """
    if not isinstance(other, Money):
        return NotImplemented
    self._check_same_currency(other)
    return self.amount < other.amount
__le__
__le__(other: Money) -> bool

Less than or equal comparison.

Parameters:

Name Type Description Default
other Money

Money to compare.

required

Returns:

Type Description
bool

True if this amount is less than or equal to other.

Raises:

Type Description
CurrencyMismatchError

If currencies differ.

Source code in src/domain/value_objects/money.py
def __le__(self, other: "Money") -> bool:
    """Less than or equal comparison.

    Args:
        other: Money to compare.

    Returns:
        True if this amount is less than or equal to other.

    Raises:
        CurrencyMismatchError: If currencies differ.
    """
    if not isinstance(other, Money):
        return NotImplemented
    self._check_same_currency(other)
    return self.amount <= other.amount
__gt__
__gt__(other: Money) -> bool

Greater than comparison.

Parameters:

Name Type Description Default
other Money

Money to compare.

required

Returns:

Type Description
bool

True if this amount is greater than other.

Raises:

Type Description
CurrencyMismatchError

If currencies differ.

Source code in src/domain/value_objects/money.py
def __gt__(self, other: "Money") -> bool:
    """Greater than comparison.

    Args:
        other: Money to compare.

    Returns:
        True if this amount is greater than other.

    Raises:
        CurrencyMismatchError: If currencies differ.
    """
    if not isinstance(other, Money):
        return NotImplemented
    self._check_same_currency(other)
    return self.amount > other.amount
__ge__
__ge__(other: Money) -> bool

Greater than or equal comparison.

Parameters:

Name Type Description Default
other Money

Money to compare.

required

Returns:

Type Description
bool

True if this amount is greater than or equal to other.

Raises:

Type Description
CurrencyMismatchError

If currencies differ.

Source code in src/domain/value_objects/money.py
def __ge__(self, other: "Money") -> bool:
    """Greater than or equal comparison.

    Args:
        other: Money to compare.

    Returns:
        True if this amount is greater than or equal to other.

    Raises:
        CurrencyMismatchError: If currencies differ.
    """
    if not isinstance(other, Money):
        return NotImplemented
    self._check_same_currency(other)
    return self.amount >= other.amount
is_positive
is_positive() -> bool

Check if amount is positive (> 0).

Returns:

Type Description
bool

True if amount is greater than zero.

Source code in src/domain/value_objects/money.py
def is_positive(self) -> bool:
    """Check if amount is positive (> 0).

    Returns:
        True if amount is greater than zero.
    """
    return self.amount > 0
is_negative
is_negative() -> bool

Check if amount is negative (< 0).

Returns:

Type Description
bool

True if amount is less than zero.

Source code in src/domain/value_objects/money.py
def is_negative(self) -> bool:
    """Check if amount is negative (< 0).

    Returns:
        True if amount is less than zero.
    """
    return self.amount < 0
is_zero
is_zero() -> bool

Check if amount is zero.

Returns:

Type Description
bool

True if amount equals zero.

Source code in src/domain/value_objects/money.py
def is_zero(self) -> bool:
    """Check if amount is zero.

    Returns:
        True if amount equals zero.
    """
    return self.amount == 0
zero classmethod
zero(currency: str = 'USD') -> Self

Create Money with zero amount.

Parameters:

Name Type Description Default
currency str

Currency code (default: USD).

'USD'

Returns:

Type Description
Self

Money with zero amount in specified currency.

Example

Money.zero("EUR") Money(amount=Decimal('0'), currency='EUR')

Source code in src/domain/value_objects/money.py
@classmethod
def zero(cls, currency: str = "USD") -> Self:
    """Create Money with zero amount.

    Args:
        currency: Currency code (default: USD).

    Returns:
        Money with zero amount in specified currency.

    Example:
        >>> Money.zero("EUR")
        Money(amount=Decimal('0'), currency='EUR')
    """
    return cls(Decimal("0"), currency)
from_cents classmethod
from_cents(cents: int, currency: str = 'USD') -> Self

Create Money from cents (or smallest unit).

Useful for APIs that return amounts in cents.

Parameters:

Name Type Description Default
cents int

Amount in cents/smallest currency unit.

required
currency str

Currency code (default: USD).

'USD'

Returns:

Type Description
Self

Money with converted amount.

Example

Money.from_cents(12345, "USD") Money(amount=Decimal('123.45'), currency='USD')

Note

Assumes 100 cents per unit. For currencies with different subdivisions (e.g., JPY has no cents), use direct construction.

Source code in src/domain/value_objects/money.py
@classmethod
def from_cents(cls, cents: int, currency: str = "USD") -> Self:
    """Create Money from cents (or smallest unit).

    Useful for APIs that return amounts in cents.

    Args:
        cents: Amount in cents/smallest currency unit.
        currency: Currency code (default: USD).

    Returns:
        Money with converted amount.

    Example:
        >>> Money.from_cents(12345, "USD")
        Money(amount=Decimal('123.45'), currency='USD')

    Note:
        Assumes 100 cents per unit. For currencies with different
        subdivisions (e.g., JPY has no cents), use direct construction.
    """
    return cls(Decimal(cents) / Decimal("100"), currency)
__repr__
__repr__() -> str

Return repr for debugging.

Returns:

Type Description
str

Detailed string representation.

Source code in src/domain/value_objects/money.py
def __repr__(self) -> str:
    """Return repr for debugging.

    Returns:
        Detailed string representation.
    """
    return f"Money(amount={self.amount!r}, currency={self.currency!r})"
__str__
__str__() -> str

Return human-readable string.

Returns:

Type Description
str

Formatted string like "1,234.56 USD".

Source code in src/domain/value_objects/money.py
def __str__(self) -> str:
    """Return human-readable string.

    Returns:
        Formatted string like "1,234.56 USD".
    """
    # Format with 2 decimal places and thousands separator
    formatted = f"{self.amount:,.2f}"
    return f"{formatted} {self.currency}"

Functions

validate_currency

validate_currency(code: str) -> str

Validate and normalize currency code.

Parameters:

Name Type Description Default
code str

Currency code (case-insensitive).

required

Returns:

Type Description
str

Uppercase ISO 4217 currency code.

Raises:

Type Description
ValueError

If code is not a valid ISO 4217 currency.

Example

validate_currency("usd") 'USD' validate_currency("XYZ") ValueError: Invalid currency code: XYZ

Source code in src/domain/value_objects/money.py
def validate_currency(code: str) -> str:
    """Validate and normalize currency code.

    Args:
        code: Currency code (case-insensitive).

    Returns:
        Uppercase ISO 4217 currency code.

    Raises:
        ValueError: If code is not a valid ISO 4217 currency.

    Example:
        >>> validate_currency("usd")
        'USD'
        >>> validate_currency("XYZ")
        ValueError: Invalid currency code: XYZ
    """
    if not code or not isinstance(code, str):
        raise ValueError("Currency code cannot be empty")

    normalized = code.upper().strip()

    if len(normalized) != 3:
        raise ValueError(f"Currency code must be 3 characters: {code}")

    if normalized not in VALID_CURRENCIES:
        raise ValueError(f"Invalid currency code: {code}")

    return normalized