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