3 # © Copyright 2021-2022, Scott Gasch
5 """A class to represent money. This class represents monetary amounts as Python Decimals
6 (see https://docs.python.org/3/library/decimal.html) internally.
8 The type guards against inadvertent aggregation of instances with
9 non-matching currencies, the division of one :class:`Money` by
10 another, and has a strict mode which disallows comparison or
11 aggregation with non-:class:`Money` operands (i.e. no comparison or
12 aggregation with literal numbers).
14 See also :class:`pyutils.types.centcount.CentCount` which represents
15 monetary amounts as an integral number of cents.
19 from decimal import ROUND_FLOOR, ROUND_HALF_DOWN, Decimal
20 from typing import Optional, Tuple, Union
24 """A class for representing monetary amounts potentially with
30 amount: Union[Decimal, str, float, int, "Money"] = Decimal("0"),
31 currency: str = "USD",
37 amount: the initial monetary amount to be represented; can be a
38 Money, int, float, Decimal, str, etc...
39 currency: if provided, indicates what currency this amount is
40 units of and guards against operations such as attempting
41 to aggregate Money instances with non-matching currencies
42 directly. If not provided defaults to "USD".
43 strict_mode: if True, disallows comparison or arithmetic operations
44 between Money instances and any non-Money types (e.g. literal
47 self.strict_mode = strict_mode
48 if isinstance(amount, str):
49 ret = Money._parse(amount)
51 raise Exception(f'Unable to parse money string "{amount}"')
54 if not isinstance(amount, Decimal):
55 amount = Decimal(float(amount))
58 self.currency: Optional[str] = None
60 self.currency = currency
64 sign, digits, exp = self.amount.quantize(q).as_tuple()
66 digits = list(map(str, digits))
67 build, next = result.append, digits.pop
69 build(next() if digits else "0")
82 return "".join(reversed(result)) + " " + self.currency
84 return "$" + "".join(reversed(result))
87 return Money(amount=self.amount, currency=self.currency)
90 return Money(amount=-self.amount, currency=self.currency)
92 def __add__(self, other):
93 if isinstance(other, Money):
94 if self.currency == other.currency:
95 return Money(amount=self.amount + other.amount, currency=self.currency)
97 raise TypeError("Incompatible currencies in add expression")
100 raise TypeError("In strict_mode only two moneys can be added")
103 amount=self.amount + Decimal(float(other)),
104 currency=self.currency,
107 def __sub__(self, other):
108 if isinstance(other, Money):
109 if self.currency == other.currency:
110 return Money(amount=self.amount - other.amount, currency=self.currency)
112 raise TypeError("Incompatible currencies in add expression")
115 raise TypeError("In strict_mode only two moneys can be added")
118 amount=self.amount - Decimal(float(other)),
119 currency=self.currency,
122 def __mul__(self, other):
123 if isinstance(other, Money):
124 raise TypeError("can not multiply monetary quantities")
127 amount=self.amount * Decimal(float(other)),
128 currency=self.currency,
131 def __truediv__(self, other):
132 if isinstance(other, Money):
133 raise TypeError("can not divide monetary quantities")
136 amount=self.amount / Decimal(float(other)),
137 currency=self.currency,
141 return self.amount.__float__()
143 def truncate_fractional_cents(self):
145 Truncates fractional cents being represented. e.g.
147 >>> m = Money(100.00)
151 At this point the internal representation of `m` is a long
155 Decimal('66.66666666666666666666666667')
157 It will be rendered by `__repr__` reasonably:
162 If you want to truncate this long decimal representation, this
163 method will do that for you:
165 >>> m.truncate_fractional_cents()
172 See also :meth:`round_fractional_cents`
174 self.amount = self.amount.quantize(Decimal(".01"), rounding=ROUND_FLOOR)
177 def round_fractional_cents(self):
179 Rounds fractional cents being represented. e.g.
181 >>> m = Money(100.00)
185 At this point the internal representation of `m` is a long
189 Decimal('66.66666666666666666666666667')
191 It will be rendered by `__repr__` reasonably:
196 If you want to round this long decimal representation, this
197 method will do that for you:
199 >>> m.round_fractional_cents()
206 See also :meth:`truncate_fractional_cents`
208 self.amount = self.amount.quantize(Decimal(".01"), rounding=ROUND_HALF_DOWN)
213 def __rsub__(self, other):
214 if isinstance(other, Money):
215 if self.currency == other.currency:
216 return Money(amount=other.amount - self.amount, currency=self.currency)
218 raise TypeError("Incompatible currencies in sub expression")
221 raise TypeError("In strict_mode only two moneys can be added")
224 amount=Decimal(float(other)) - self.amount,
225 currency=self.currency,
231 # Override comparison operators to also compare currency.
233 def __eq__(self, other):
236 if isinstance(other, Money):
237 return self.amount == other.amount and self.currency == other.currency
239 raise TypeError("In strict mode only two Moneys can be compared")
241 return self.amount == Decimal(float(other))
243 def __ne__(self, other):
244 result = self.__eq__(other)
245 if result is NotImplemented:
249 def __lt__(self, other):
250 if isinstance(other, Money):
251 if self.currency == other.currency:
252 return self.amount < other.amount
254 raise TypeError("can not directly compare different currencies")
257 raise TypeError("In strict mode, only two Moneys can be compated")
259 return self.amount < Decimal(float(other))
261 def __gt__(self, other):
262 if isinstance(other, Money):
263 if self.currency == other.currency:
264 return self.amount > other.amount
266 raise TypeError("can not directly compare different currencies")
269 raise TypeError("In strict mode, only two Moneys can be compated")
271 return self.amount > Decimal(float(other))
273 def __le__(self, other):
274 return self < other or self == other
276 def __ge__(self, other):
277 return self > other or self == other
279 def __hash__(self) -> int:
280 return hash(self.__repr__)
282 AMOUNT_RE = re.compile(r"^([+|-]?)(\d+)(\.\d+)$")
283 CURRENCY_RE = re.compile(r"^[A-Z][A-Z][A-Z]$")
286 def _parse(cls, s: str) -> Optional[Tuple[Decimal, str]]:
290 chunks = s.split(" ")
293 if Money.AMOUNT_RE.match(chunk) is not None:
294 amount = Decimal(chunk)
295 elif Money.CURRENCY_RE.match(chunk) is not None:
299 if amount is not None and currency is not None:
300 return (amount, currency)
301 elif amount is not None:
302 return (amount, "USD")
306 def parse(cls, s: str) -> "Money":
307 """Parses a string an attempts to create a :class:`Money`
311 s: the string to parse
313 chunks = Money._parse(s)
314 if chunks is not None:
315 return Money(chunks[0], chunks[1])
316 raise Exception(f'Unable to parse money string "{s}"')
319 if __name__ == "__main__":