3 # © Copyright 2021-2023, 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.typez.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
48 ValueError: unable to parse a money string
50 self.strict_mode = strict_mode
51 if isinstance(amount, str):
52 ret = Money._parse(amount)
54 raise ValueError(f'Unable to parse money string "{amount}"')
57 if not isinstance(amount, Decimal):
58 amount = Decimal(float(amount))
61 self.currency: Optional[str] = None
63 self.currency = currency
67 sign, digits, _ = self.amount.quantize(q).as_tuple()
69 digits = list(map(str, digits))
70 build, nxt = result.append, digits.pop
72 build(nxt() if digits else "0")
85 return "".join(reversed(result)) + " " + self.currency
87 return "$" + "".join(reversed(result))
90 return Money(amount=self.amount, currency=self.currency)
93 return Money(amount=-self.amount, currency=self.currency)
95 def __add__(self, other):
98 TypeError: attempt to add incompatible amounts or, if in strict
99 mode, attempt to add a Money with a literal.
101 if isinstance(other, Money):
102 if self.currency == other.currency:
103 return Money(amount=self.amount + other.amount, currency=self.currency)
105 raise TypeError("Incompatible currencies in add expression")
108 raise TypeError("In strict_mode only two moneys can be added")
111 amount=self.amount + Decimal(float(other)),
112 currency=self.currency,
115 def __sub__(self, other):
118 TypeError: attempt to subtract incompatible amounts or, if in strict
119 mode, attempt to add a Money with a literal.
121 if isinstance(other, Money):
122 if self.currency == other.currency:
123 return Money(amount=self.amount - other.amount, currency=self.currency)
125 raise TypeError("Incompatible currencies in sibtraction expression")
128 raise TypeError("In strict_mode only two moneys can be subtracted")
131 amount=self.amount - Decimal(float(other)),
132 currency=self.currency,
135 def __mul__(self, other):
138 TypeError: attempt to multiply two Money objects.
140 if isinstance(other, Money):
141 raise TypeError("can not multiply monetary quantities")
144 amount=self.amount * Decimal(float(other)),
145 currency=self.currency,
148 def __truediv__(self, other):
151 TypeError: attempt to divide two Money objects.
153 if isinstance(other, Money):
154 raise TypeError("can not divide monetary quantities")
157 amount=self.amount / Decimal(float(other)),
158 currency=self.currency,
162 return self.amount.__float__()
164 def truncate_fractional_cents(self):
166 Truncates fractional cents being represented. e.g.
168 >>> m = Money(100.00)
172 At this point the internal representation of `m` is a long
176 Decimal('66.66666666666666666666666667')
178 It will be rendered by `__repr__` reasonably:
183 If you want to truncate this long decimal representation, this
184 method will do that for you:
186 >>> m.truncate_fractional_cents()
193 See also :meth:`round_fractional_cents`
195 self.amount = self.amount.quantize(Decimal(".01"), rounding=ROUND_FLOOR)
198 def round_fractional_cents(self):
200 Rounds fractional cents being represented. e.g.
202 >>> m = Money(100.00)
206 At this point the internal representation of `m` is a long
210 Decimal('66.66666666666666666666666667')
212 It will be rendered by `__repr__` reasonably:
217 If you want to round this long decimal representation, this
218 method will do that for you:
220 >>> m.round_fractional_cents()
227 See also :meth:`truncate_fractional_cents`
229 self.amount = self.amount.quantize(Decimal(".01"), rounding=ROUND_HALF_DOWN)
234 def __rsub__(self, other):
237 TypeError: attempt to subtract incompatible amounts or, if in strict
238 mode, attempt to add a Money with a literal.
240 if isinstance(other, Money):
241 if self.currency == other.currency:
242 return Money(amount=other.amount - self.amount, currency=self.currency)
244 raise TypeError("Incompatible currencies in sub expression")
247 raise TypeError("In strict_mode only two moneys can be added")
250 amount=Decimal(float(other)) - self.amount,
251 currency=self.currency,
257 # Override comparison operators to also compare currency.
259 def __eq__(self, other):
262 TypeError: in strict mode, an attempt to compare a Money with a
267 if isinstance(other, Money):
268 return self.amount == other.amount and self.currency == other.currency
270 raise TypeError("In strict mode only two Moneys can be compared")
272 return self.amount == Decimal(float(other))
274 def __ne__(self, other):
275 result = self.__eq__(other)
276 if result is NotImplemented:
280 def __lt__(self, other):
282 TypeError: attempt to compare incompatible amounts or, if in strict
283 mode, attempt to compare a Money with a literal.
285 if isinstance(other, Money):
286 if self.currency == other.currency:
287 return self.amount < other.amount
289 raise TypeError("can not directly compare different currencies")
292 raise TypeError("In strict mode, only two Moneys can be compated")
294 return self.amount < Decimal(float(other))
296 def __gt__(self, other):
298 TypeError: attempt to compare incompatible amounts or, if in strict
299 mode, attempt to compare a Money with a literal.
301 if isinstance(other, Money):
302 if self.currency == other.currency:
303 return self.amount > other.amount
305 raise TypeError("can not directly compare different currencies")
308 raise TypeError("In strict mode, only two Moneys can be compated")
310 return self.amount > Decimal(float(other))
312 def __le__(self, other):
313 return self < other or self == other
315 def __ge__(self, other):
316 return self > other or self == other
318 def __hash__(self) -> int:
319 return hash(self.__repr__)
321 AMOUNT_RE = re.compile(r"^([+|-]?)(\d+)(\.\d+)$")
322 CURRENCY_RE = re.compile(r"^[A-Z][A-Z][A-Z]$")
325 def _parse(cls, s: str) -> Optional[Tuple[Decimal, str]]:
329 chunks = s.split(" ")
332 if Money.AMOUNT_RE.match(chunk) is not None:
333 amount = Decimal(chunk)
334 elif Money.CURRENCY_RE.match(chunk) is not None:
338 if amount is not None and currency is not None:
339 return (amount, currency)
340 elif amount is not None:
341 return (amount, "USD")
345 def parse(cls, s: str) -> "Money":
346 """Parses a string an attempts to create a :class:`Money`
350 s: the string to parse
353 ValueError: unable to parse a string
355 chunks = Money._parse(s)
356 if chunks is not None:
357 return Money(chunks[0], chunks[1])
358 raise ValueError(f'Unable to parse money string "{s}"')
361 if __name__ == "__main__":