#!/usr/bin/env python3
# © Copyright 2021-2023, Scott Gasch
"""A class to represent money. This class represents monetary amounts as Python Decimals
(see https://docs.python.org/3/library/decimal.html) internally.
The type guards against inadvertent aggregation of instances with
non-matching currencies, the division of one :class:`Money` by
another, and has a strict mode which disallows comparison or
aggregation with non-:class:`Money` operands (i.e. no comparison or
aggregation with literal numbers).
See also :class:`pyutils.typez.centcount.CentCount` which represents
monetary amounts as an integral number of cents.
"""
import logging
import re
from decimal import ROUND_FLOOR, ROUND_HALF_DOWN, Decimal
from typing import Optional, Tuple, Union
logger = logging.getLogger(__name__)
[docs]class Money(object):
"""A class for representing monetary amounts potentially with
different currencies.
"""
def __init__(
self,
amount: Union[Decimal, str, float, int, "Money"] = Decimal("0"),
currency: str = "USD",
*,
strict_mode=False,
):
"""
Args:
amount: the initial monetary amount to be represented; can be a
Money, int, float, Decimal, str, etc...
currency: if provided, indicates what currency this amount is
units of and guards against operations such as attempting
to aggregate Money instances with non-matching currencies
directly. If not provided defaults to "USD".
strict_mode: if True, disallows comparison or arithmetic operations
between Money instances and any non-Money types (e.g. literal
numbers).
Raises:
ValueError: unable to parse a money string
"""
self.strict_mode = strict_mode
if isinstance(amount, str):
ret = Money._parse(amount)
if ret is None:
raise ValueError(f'Unable to parse money string "{amount}"')
amount = ret[0]
currency = ret[1]
if not isinstance(amount, Decimal):
amount = Decimal(float(amount))
self.amount = amount
if not currency:
self.currency: Optional[str] = None
else:
self.currency = currency
def __repr__(self):
q = Decimal(10) ** -2
sign, digits, _ = self.amount.quantize(q).as_tuple()
result = []
digits = list(map(str, digits))
build, nxt = result.append, digits.pop
for i in range(2):
build(nxt() if digits else "0")
build(".")
if not digits:
build("0")
i = 0
while digits:
build(nxt())
i += 1
if i == 3 and digits:
i = 0
if sign:
build("-")
if self.currency:
return "".join(reversed(result)) + " " + self.currency
else:
return "$" + "".join(reversed(result))
def __pos__(self):
return Money(amount=self.amount, currency=self.currency)
def __neg__(self):
if not self.amount:
return Money(amount=self.amount, currency=self.currency)
else:
return Money(amount=-self.amount, currency=self.currency)
def __add__(self, other):
"""
Raises:
TypeError: attempt to add incompatible amounts or, if in strict
mode, attempt to add a Money with a literal.
"""
if isinstance(other, Money):
if self.currency == other.currency:
return Money(amount=self.amount + other.amount, currency=self.currency)
else:
raise TypeError("Incompatible currencies in add expression")
else:
if self.strict_mode:
raise TypeError("In strict_mode only two moneys can be added")
else:
return Money(
amount=self.amount + Decimal(float(other)),
currency=self.currency,
)
def __sub__(self, other):
"""
Raises:
TypeError: attempt to subtract incompatible amounts or, if in strict
mode, attempt to add a Money with a literal.
"""
if isinstance(other, Money):
if self.currency == other.currency:
return Money(amount=self.amount - other.amount, currency=self.currency)
else:
raise TypeError("Incompatible currencies in sibtraction expression")
else:
if self.strict_mode:
raise TypeError("In strict_mode only two moneys can be subtracted")
else:
return Money(
amount=self.amount - Decimal(float(other)),
currency=self.currency,
)
def __mul__(self, other):
"""
Raises:
TypeError: attempt to multiply two Money objects.
"""
if isinstance(other, Money):
raise TypeError("can not multiply monetary quantities")
else:
return Money(
amount=self.amount * Decimal(float(other)),
currency=self.currency,
)
def __truediv__(self, other):
"""
Raises:
TypeError: attempt to divide two Money objects.
"""
if isinstance(other, Money):
raise TypeError("can not divide monetary quantities")
else:
return Money(
amount=self.amount / Decimal(float(other)),
currency=self.currency,
)
def __float__(self):
return self.amount.__float__()
[docs] def truncate_fractional_cents(self):
"""
Truncates fractional cents being represented. e.g.
>>> m = Money(100.00)
>>> m *= 2
>>> m /= 3
At this point the internal representation of `m` is a long
`Decimal`:
>>> m.amount
Decimal('66.66666666666666666666666667')
It will be rendered by `__repr__` reasonably:
>>> m
66.67 USD
If you want to truncate this long decimal representation, this
method will do that for you:
>>> m.truncate_fractional_cents()
Decimal('66.66')
>>> m.amount
Decimal('66.66')
>>> m
66.66 USD
See also :meth:`round_fractional_cents`
"""
self.amount = self.amount.quantize(Decimal(".01"), rounding=ROUND_FLOOR)
return self.amount
[docs] def round_fractional_cents(self):
"""
Rounds fractional cents being represented. e.g.
>>> m = Money(100.00)
>>> m *= 2
>>> m /= 3
At this point the internal representation of `m` is a long
`Decimal`:
>>> m.amount
Decimal('66.66666666666666666666666667')
It will be rendered by `__repr__` reasonably:
>>> m
66.67 USD
If you want to round this long decimal representation, this
method will do that for you:
>>> m.round_fractional_cents()
Decimal('66.67')
>>> m.amount
Decimal('66.67')
>>> m
66.67 USD
See also :meth:`truncate_fractional_cents`
"""
self.amount = self.amount.quantize(Decimal(".01"), rounding=ROUND_HALF_DOWN)
return self.amount
__radd__ = __add__
def __rsub__(self, other):
"""
Raises:
TypeError: attempt to subtract incompatible amounts or, if in strict
mode, attempt to add a Money with a literal.
"""
if isinstance(other, Money):
if self.currency == other.currency:
return Money(amount=other.amount - self.amount, currency=self.currency)
else:
raise TypeError("Incompatible currencies in sub expression")
else:
if self.strict_mode:
raise TypeError("In strict_mode only two moneys can be added")
else:
return Money(
amount=Decimal(float(other)) - self.amount,
currency=self.currency,
)
__rmul__ = __mul__
#
# Override comparison operators to also compare currency.
#
def __eq__(self, other):
"""
Raises:
TypeError: in strict mode, an attempt to compare a Money with a
non-Money object.
"""
if other is None:
return False
if isinstance(other, Money):
return self.amount == other.amount and self.currency == other.currency
if self.strict_mode:
raise TypeError("In strict mode only two Moneys can be compared")
else:
return self.amount == Decimal(float(other))
def __ne__(self, other):
result = self.__eq__(other)
if result is NotImplemented:
return result
return not result
def __lt__(self, other):
"""
TypeError: attempt to compare incompatible amounts or, if in strict
mode, attempt to compare a Money with a literal.
"""
if isinstance(other, Money):
if self.currency == other.currency:
return self.amount < other.amount
else:
raise TypeError("can not directly compare different currencies")
else:
if self.strict_mode:
raise TypeError("In strict mode, only two Moneys can be compated")
else:
return self.amount < Decimal(float(other))
def __gt__(self, other):
"""
TypeError: attempt to compare incompatible amounts or, if in strict
mode, attempt to compare a Money with a literal.
"""
if isinstance(other, Money):
if self.currency == other.currency:
return self.amount > other.amount
else:
raise TypeError("can not directly compare different currencies")
else:
if self.strict_mode:
raise TypeError("In strict mode, only two Moneys can be compated")
else:
return self.amount > Decimal(float(other))
def __le__(self, other):
return self < other or self == other
def __ge__(self, other):
return self > other or self == other
def __hash__(self) -> int:
return hash(self.__repr__)
AMOUNT_RE = re.compile(r"^([+|-]?)(\d+)(\.\d+)$")
CURRENCY_RE = re.compile(r"^[A-Z][A-Z][A-Z]$")
@classmethod
def _parse(cls, s: str) -> Optional[Tuple[Decimal, str]]:
amount = None
currency = None
s = s.strip()
chunks = s.split(" ")
try:
for chunk in chunks:
if Money.AMOUNT_RE.match(chunk) is not None:
amount = Decimal(chunk)
elif Money.CURRENCY_RE.match(chunk) is not None:
currency = chunk
except Exception:
logger.exception("Ignoring exception in money")
if amount is not None and currency is not None:
return (amount, currency)
elif amount is not None:
return (amount, "USD")
return None
[docs] @classmethod
def parse(cls, s: str) -> "Money":
"""Parses a string an attempts to create a :class:`Money`
instance.
Args:
s: the string to parse
Raises:
ValueError: unable to parse a string
"""
chunks = Money._parse(s)
if chunks is not None:
return Money(chunks[0], chunks[1])
raise ValueError(f'Unable to parse money string "{s}"')
if __name__ == "__main__":
import doctest
doctest.testmod()