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 Money by another, and has
10 a strict mode which disallows comparison or aggregation with
11 non-CentCount operands (i.e. no comparison or aggregation with literal
14 See also :class:`pyutils.typez.CentCount` which represents monetary
15 amounts as an integral number of cents.
20 from decimal import ROUND_FLOOR, ROUND_HALF_DOWN, Decimal
21 from typing import Optional, Tuple, Union
25 """A class for representing monetary amounts potentially with
31 amount: Union[Decimal, str, float, int, 'Money'] = Decimal("0"),
32 currency: str = 'USD',
38 amount: the initial monetary amount to be represented; can be a
39 Money, int, float, Decimal, str, etc...
40 currency: if provided, indicates what currency this amount is
41 units of and guards against operations such as attempting
42 to aggregate Money instances with non-matching currencies
44 strict_mode: if True, disallows comparison or arithmetic operations
45 between Money instances and any non-Money types (e.g. literal
48 self.strict_mode = strict_mode
49 if isinstance(amount, str):
50 ret = Money._parse(amount)
52 raise Exception(f'Unable to parse money string "{amount}"')
55 if not isinstance(amount, Decimal):
56 amount = Decimal(float(amount))
59 self.currency: Optional[str] = None
61 self.currency = currency
65 sign, digits, exp = self.amount.quantize(q).as_tuple()
67 digits = list(map(str, digits))
68 build, next = result.append, digits.pop
70 build(next() if digits else '0')
83 return ''.join(reversed(result)) + ' ' + self.currency
85 return '$' + ''.join(reversed(result))
88 return Money(amount=self.amount, currency=self.currency)
91 return Money(amount=-self.amount, currency=self.currency)
93 def __add__(self, other):
94 if isinstance(other, Money):
95 if self.currency == other.currency:
96 return Money(amount=self.amount + other.amount, currency=self.currency)
98 raise TypeError('Incompatible currencies in add expression')
101 raise TypeError('In strict_mode only two moneys can be added')
104 amount=self.amount + Decimal(float(other)),
105 currency=self.currency,
108 def __sub__(self, other):
109 if isinstance(other, Money):
110 if self.currency == other.currency:
111 return Money(amount=self.amount - other.amount, currency=self.currency)
113 raise TypeError('Incompatible currencies in add expression')
116 raise TypeError('In strict_mode only two moneys can be added')
119 amount=self.amount - Decimal(float(other)),
120 currency=self.currency,
123 def __mul__(self, other):
124 if isinstance(other, Money):
125 raise TypeError('can not multiply monetary quantities')
128 amount=self.amount * Decimal(float(other)),
129 currency=self.currency,
132 def __truediv__(self, other):
133 if isinstance(other, Money):
134 raise TypeError('can not divide monetary quantities')
137 amount=self.amount / Decimal(float(other)),
138 currency=self.currency,
142 return self.amount.__float__()
144 def truncate_fractional_cents(self):
146 Truncates fractional cents being represented. e.g.
148 >>> m = Money(100.00)
152 At this point the internal representation of `m` is a long
156 Decimal('66.66666666666666666666666667')
158 It will be rendered by `__repr__` reasonably:
163 If you want to truncate this long decimal representation, this
164 method will do that for you:
166 >>> m.truncate_fractional_cents()
173 See also :meth:`round_fractional_cents`
175 self.amount = self.amount.quantize(Decimal('.01'), rounding=ROUND_FLOOR)
178 def round_fractional_cents(self):
180 Rounds fractional cents being represented. e.g.
182 >>> m = Money(100.00)
186 At this point the internal representation of `m` is a long
190 Decimal('66.66666666666666666666666667')
192 It will be rendered by `__repr__` reasonably:
197 If you want to round this long decimal representation, this
198 method will do that for you:
200 >>> m.round_fractional_cents()
207 See also :meth:`truncate_fractional_cents`
209 self.amount = self.amount.quantize(Decimal('.01'), rounding=ROUND_HALF_DOWN)
214 def __rsub__(self, other):
215 if isinstance(other, Money):
216 if self.currency == other.currency:
217 return Money(amount=other.amount - self.amount, currency=self.currency)
219 raise TypeError('Incompatible currencies in sub expression')
222 raise TypeError('In strict_mode only two moneys can be added')
225 amount=Decimal(float(other)) - self.amount,
226 currency=self.currency,
232 # Override comparison operators to also compare currency.
234 def __eq__(self, other):
237 if isinstance(other, Money):
238 return self.amount == other.amount and self.currency == other.currency
240 raise TypeError("In strict mode only two Moneys can be compared")
242 return self.amount == Decimal(float(other))
244 def __ne__(self, other):
245 result = self.__eq__(other)
246 if result is NotImplemented:
250 def __lt__(self, other):
251 if isinstance(other, Money):
252 if self.currency == other.currency:
253 return self.amount < other.amount
255 raise TypeError('can not directly compare different currencies')
258 raise TypeError('In strict mode, only two Moneys can be compated')
260 return self.amount < Decimal(float(other))
262 def __gt__(self, other):
263 if isinstance(other, Money):
264 if self.currency == other.currency:
265 return self.amount > other.amount
267 raise TypeError('can not directly compare different currencies')
270 raise TypeError('In strict mode, only two Moneys can be compated')
272 return self.amount > Decimal(float(other))
274 def __le__(self, other):
275 return self < other or self == other
277 def __ge__(self, other):
278 return self > other or self == other
280 def __hash__(self) -> int:
281 return hash(self.__repr__)
283 AMOUNT_RE = re.compile(r"^([+|-]?)(\d+)(\.\d+)$")
284 CURRENCY_RE = re.compile(r"^[A-Z][A-Z][A-Z]$")
287 def _parse(cls, s: str) -> Optional[Tuple[Decimal, str]]:
291 chunks = s.split(' ')
294 if Money.AMOUNT_RE.match(chunk) is not None:
295 amount = Decimal(chunk)
296 elif Money.CURRENCY_RE.match(chunk) is not None:
300 if amount is not None and currency is not None:
301 return (amount, currency)
302 elif amount is not None:
303 return (amount, 'USD')
307 def parse(cls, s: str) -> 'Money':
308 """Parses a string an attempts to create a Money instance.
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__':