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