Adds a __repr__ to graph.
[pyutils.git] / src / pyutils / typez / money.py
1 #!/usr/bin/env python3
2
3 # © Copyright 2021-2023, Scott Gasch
4
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.
7
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).
13
14 See also :class:`pyutils.typez.centcount.CentCount` which represents
15 monetary amounts as an integral number of cents.
16 """
17
18 import re
19 from decimal import ROUND_FLOOR, ROUND_HALF_DOWN, Decimal
20 from typing import Optional, Tuple, Union
21
22
23 class Money(object):
24     """A class for representing monetary amounts potentially with
25     different currencies.
26     """
27
28     def __init__(
29         self,
30         amount: Union[Decimal, str, float, int, "Money"] = Decimal("0"),
31         currency: str = "USD",
32         *,
33         strict_mode=False,
34     ):
35         """
36         Args:
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
45                 numbers).
46
47         Raises:
48             ValueError: unable to parse a money string
49         """
50         self.strict_mode = strict_mode
51         if isinstance(amount, str):
52             ret = Money._parse(amount)
53             if ret is None:
54                 raise ValueError(f'Unable to parse money string "{amount}"')
55             amount = ret[0]
56             currency = ret[1]
57         if not isinstance(amount, Decimal):
58             amount = Decimal(float(amount))
59         self.amount = amount
60         if not currency:
61             self.currency: Optional[str] = None
62         else:
63             self.currency = currency
64
65     def __repr__(self):
66         q = Decimal(10) ** -2
67         sign, digits, _ = self.amount.quantize(q).as_tuple()
68         result = []
69         digits = list(map(str, digits))
70         build, nxt = result.append, digits.pop
71         for i in range(2):
72             build(nxt() if digits else "0")
73         build(".")
74         if not digits:
75             build("0")
76         i = 0
77         while digits:
78             build(nxt())
79             i += 1
80             if i == 3 and digits:
81                 i = 0
82         if sign:
83             build("-")
84         if self.currency:
85             return "".join(reversed(result)) + " " + self.currency
86         else:
87             return "$" + "".join(reversed(result))
88
89     def __pos__(self):
90         return Money(amount=self.amount, currency=self.currency)
91
92     def __neg__(self):
93         return Money(amount=-self.amount, currency=self.currency)
94
95     def __add__(self, other):
96         """
97         Raises:
98             TypeError: attempt to add incompatible amounts or, if in strict
99                 mode, attempt to add a Money with a literal.
100         """
101         if isinstance(other, Money):
102             if self.currency == other.currency:
103                 return Money(amount=self.amount + other.amount, currency=self.currency)
104             else:
105                 raise TypeError("Incompatible currencies in add expression")
106         else:
107             if self.strict_mode:
108                 raise TypeError("In strict_mode only two moneys can be added")
109             else:
110                 return Money(
111                     amount=self.amount + Decimal(float(other)),
112                     currency=self.currency,
113                 )
114
115     def __sub__(self, other):
116         """
117         Raises:
118             TypeError: attempt to subtract incompatible amounts or, if in strict
119                 mode, attempt to add a Money with a literal.
120         """
121         if isinstance(other, Money):
122             if self.currency == other.currency:
123                 return Money(amount=self.amount - other.amount, currency=self.currency)
124             else:
125                 raise TypeError("Incompatible currencies in sibtraction expression")
126         else:
127             if self.strict_mode:
128                 raise TypeError("In strict_mode only two moneys can be subtracted")
129             else:
130                 return Money(
131                     amount=self.amount - Decimal(float(other)),
132                     currency=self.currency,
133                 )
134
135     def __mul__(self, other):
136         """
137         Raises:
138             TypeError: attempt to multiply two Money objects.
139         """
140         if isinstance(other, Money):
141             raise TypeError("can not multiply monetary quantities")
142         else:
143             return Money(
144                 amount=self.amount * Decimal(float(other)),
145                 currency=self.currency,
146             )
147
148     def __truediv__(self, other):
149         """
150         Raises:
151             TypeError: attempt to divide two Money objects.
152         """
153         if isinstance(other, Money):
154             raise TypeError("can not divide monetary quantities")
155         else:
156             return Money(
157                 amount=self.amount / Decimal(float(other)),
158                 currency=self.currency,
159             )
160
161     def __float__(self):
162         return self.amount.__float__()
163
164     def truncate_fractional_cents(self):
165         """
166         Truncates fractional cents being represented.  e.g.
167
168         >>> m = Money(100.00)
169         >>> m *= 2
170         >>> m /= 3
171
172         At this point the internal representation of `m` is a long
173         `Decimal`:
174
175         >>> m.amount
176         Decimal('66.66666666666666666666666667')
177
178         It will be rendered by `__repr__` reasonably:
179
180         >>> m
181         66.67 USD
182
183         If you want to truncate this long decimal representation, this
184         method will do that for you:
185
186         >>> m.truncate_fractional_cents()
187         Decimal('66.66')
188         >>> m.amount
189         Decimal('66.66')
190         >>> m
191         66.66 USD
192
193         See also :meth:`round_fractional_cents`
194         """
195         self.amount = self.amount.quantize(Decimal(".01"), rounding=ROUND_FLOOR)
196         return self.amount
197
198     def round_fractional_cents(self):
199         """
200         Rounds fractional cents being represented.  e.g.
201
202         >>> m = Money(100.00)
203         >>> m *= 2
204         >>> m /= 3
205
206         At this point the internal representation of `m` is a long
207         `Decimal`:
208
209         >>> m.amount
210         Decimal('66.66666666666666666666666667')
211
212         It will be rendered by `__repr__` reasonably:
213
214         >>> m
215         66.67 USD
216
217         If you want to round this long decimal representation, this
218         method will do that for you:
219
220         >>> m.round_fractional_cents()
221         Decimal('66.67')
222         >>> m.amount
223         Decimal('66.67')
224         >>> m
225         66.67 USD
226
227         See also :meth:`truncate_fractional_cents`
228         """
229         self.amount = self.amount.quantize(Decimal(".01"), rounding=ROUND_HALF_DOWN)
230         return self.amount
231
232     __radd__ = __add__
233
234     def __rsub__(self, other):
235         """
236         Raises:
237             TypeError: attempt to subtract incompatible amounts or, if in strict
238                 mode, attempt to add a Money with a literal.
239         """
240         if isinstance(other, Money):
241             if self.currency == other.currency:
242                 return Money(amount=other.amount - self.amount, currency=self.currency)
243             else:
244                 raise TypeError("Incompatible currencies in sub expression")
245         else:
246             if self.strict_mode:
247                 raise TypeError("In strict_mode only two moneys can be added")
248             else:
249                 return Money(
250                     amount=Decimal(float(other)) - self.amount,
251                     currency=self.currency,
252                 )
253
254     __rmul__ = __mul__
255
256     #
257     # Override comparison operators to also compare currency.
258     #
259     def __eq__(self, other):
260         """
261         Raises:
262             TypeError: in strict mode, an attempt to compare a Money with a
263                 non-Money object.
264         """
265         if other is None:
266             return False
267         if isinstance(other, Money):
268             return self.amount == other.amount and self.currency == other.currency
269         if self.strict_mode:
270             raise TypeError("In strict mode only two Moneys can be compared")
271         else:
272             return self.amount == Decimal(float(other))
273
274     def __ne__(self, other):
275         result = self.__eq__(other)
276         if result is NotImplemented:
277             return result
278         return not result
279
280     def __lt__(self, other):
281         """
282         TypeError: attempt to compare incompatible amounts or, if in strict
283             mode, attempt to compare a Money with a literal.
284         """
285         if isinstance(other, Money):
286             if self.currency == other.currency:
287                 return self.amount < other.amount
288             else:
289                 raise TypeError("can not directly compare different currencies")
290         else:
291             if self.strict_mode:
292                 raise TypeError("In strict mode, only two Moneys can be compated")
293             else:
294                 return self.amount < Decimal(float(other))
295
296     def __gt__(self, other):
297         """
298         TypeError: attempt to compare incompatible amounts or, if in strict
299             mode, attempt to compare a Money with a literal.
300         """
301         if isinstance(other, Money):
302             if self.currency == other.currency:
303                 return self.amount > other.amount
304             else:
305                 raise TypeError("can not directly compare different currencies")
306         else:
307             if self.strict_mode:
308                 raise TypeError("In strict mode, only two Moneys can be compated")
309             else:
310                 return self.amount > Decimal(float(other))
311
312     def __le__(self, other):
313         return self < other or self == other
314
315     def __ge__(self, other):
316         return self > other or self == other
317
318     def __hash__(self) -> int:
319         return hash(self.__repr__)
320
321     AMOUNT_RE = re.compile(r"^([+|-]?)(\d+)(\.\d+)$")
322     CURRENCY_RE = re.compile(r"^[A-Z][A-Z][A-Z]$")
323
324     @classmethod
325     def _parse(cls, s: str) -> Optional[Tuple[Decimal, str]]:
326         amount = None
327         currency = None
328         s = s.strip()
329         chunks = s.split(" ")
330         try:
331             for chunk in chunks:
332                 if Money.AMOUNT_RE.match(chunk) is not None:
333                     amount = Decimal(chunk)
334                 elif Money.CURRENCY_RE.match(chunk) is not None:
335                     currency = chunk
336         except Exception:
337             pass
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")
342         return None
343
344     @classmethod
345     def parse(cls, s: str) -> "Money":
346         """Parses a string an attempts to create a :class:`Money`
347         instance.
348
349         Args:
350             s: the string to parse
351
352         Raises:
353             ValueError: unable to parse a string
354         """
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}"')
359
360
361 if __name__ == "__main__":
362     import doctest
363
364     doctest.testmod()