Money, Rate, CentCount and a bunch of bugfixes.
[python_utils.git] / type / centcount.py
1 #!/usr/bin/env python3
2
3 import re
4 from typing import Optional, TypeVar, Tuple
5
6 import math_utils
7
8
9 T = TypeVar('T', bound='CentCount')
10
11
12 class CentCount(object):
13     """A class for representing monetary amounts potentially with
14     different currencies.
15     """
16
17     def __init__ (
18             self,
19             centcount,
20             currency: str = 'USD',
21             *,
22             strict_mode = False
23     ):
24         self.strict_mode = strict_mode
25         if isinstance(centcount, str):
26             ret = CentCount._parse(centcount)
27             if ret is None:
28                 raise Exception(f'Unable to parse money string "{centcount}"')
29             centcount = ret[0]
30             currency = ret[1]
31         if isinstance(centcount, float):
32             centcount = int(centcount * 100.0)
33         if not isinstance(centcount, int):
34             centcount = int(centcount)
35         self.centcount = centcount
36         if not currency:
37             self.currency: Optional[str] = None
38         else:
39             self.currency: Optional[str] = currency
40
41     def __repr__(self):
42         a = float(self.centcount)
43         a /= 100
44         a = round(a, 2)
45         s = f'{a:,.2f}'
46         if self.currency is not None:
47             return '%s %s' % (s, self.currency)
48         else:
49             return '$%s' % s
50
51     def __pos__(self):
52         return CentCount(centcount=self.centcount, currency=self.currency)
53
54     def __neg__(self):
55         return CentCount(centcount=-self.centcount, currency=self.currency)
56
57     def __add__(self, other):
58         if isinstance(other, CentCount):
59             if self.currency == other.currency:
60                 return CentCount(
61                     centcount = self.centcount + other.centcount,
62                     currency = self.currency
63                 )
64             else:
65                 raise TypeError('Incompatible currencies in add expression')
66         else:
67             if self.strict_mode:
68                 raise TypeError('In strict_mode only two moneys can be added')
69             else:
70                 return self.__add__(CentCount(other, self.currency))
71
72     def __sub__(self, other):
73         if isinstance(other, CentCount):
74             if self.currency == other.currency:
75                 return CentCount(
76                     centcount = self.centcount - other.centcount,
77                     currency = self.currency
78                 )
79             else:
80                 raise TypeError('Incompatible currencies in add expression')
81         else:
82             if self.strict_mode:
83                 raise TypeError('In strict_mode only two moneys can be added')
84             else:
85                 return self.__sub__(CentCount(other, self.currency))
86
87     def __mul__(self, other):
88         if isinstance(other, CentCount):
89             raise TypeError('can not multiply monetary quantities')
90         else:
91             return CentCount(
92                 centcount = int(self.centcount * float(other)),
93                 currency = self.currency
94             )
95
96     def __truediv__(self, other):
97         if isinstance(other, CentCount):
98             raise TypeError('can not divide monetary quantities')
99         else:
100             return CentCount(
101                 centcount = int(float(self.centcount) / float(other)),
102                 currency = self.currency
103             )
104
105     def __int__(self):
106         return self.centcount.__int__()
107
108     def __float__(self):
109         return self.centcount.__float__() / 100.0
110
111     def truncate_fractional_cents(self):
112         x = int(self)
113         self.centcount = int(math_utils.truncate_float(x))
114         return self.centcount
115
116     def round_fractional_cents(self):
117         x = int(self)
118         self.centcount = int(round(x, 2))
119         return self.centcount
120
121     __radd__ = __add__
122
123     def __rsub__(self, other):
124         if isinstance(other, CentCount):
125             if self.currency == other.currency:
126                 return CentCount(
127                     centcount = other.centcount - self.centcount,
128                     currency = self.currency
129                 )
130             else:
131                 raise TypeError('Incompatible currencies in sub expression')
132         else:
133             if self.strict_mode:
134                 raise TypeError('In strict_mode only two moneys can be added')
135             else:
136                 return CentCount(
137                     centcount = int(other) - self.centcount,
138                     currency = self.currency
139                 )
140
141     __rmul__ = __mul__
142
143     #
144     # Override comparison operators to also compare currency.
145     #
146     def __eq__(self, other):
147         if other is None:
148             return False
149         if isinstance(other, CentCount):
150             return (
151                 self.centcount == other.centcount and
152                 self.currency == other.currency
153             )
154         if self.strict_mode:
155             raise TypeError("In strict mode only two CentCounts can be compared")
156         else:
157             return self.centcount == int(other)
158
159     def __ne__(self, other):
160         result = self.__eq__(other)
161         if result is NotImplemented:
162             return result
163         return not result
164
165     def __lt__(self, other):
166         if isinstance(other, CentCount):
167             if self.currency == other.currency:
168                 return self.centcount < other.centcount
169             else:
170                 raise TypeError('can not directly compare different currencies')
171         else:
172             if self.strict_mode:
173                 raise TypeError('In strict mode, only two CentCounts can be compated')
174             else:
175                 return self.centcount < int(other)
176
177     def __gt__(self, other):
178         if isinstance(other, CentCount):
179             if self.currency == other.currency:
180                 return self.centcount > other.centcount
181             else:
182                 raise TypeError('can not directly compare different currencies')
183         else:
184             if self.strict_mode:
185                 raise TypeError('In strict mode, only two CentCounts can be compated')
186             else:
187                 return self.centcount > int(other)
188
189     def __le__(self, other):
190         return self < other or self == other
191
192     def __ge__(self, other):
193         return self > other or self == other
194
195     def __hash__(self):
196         return self.__repr__
197
198     CENTCOUNT_RE = re.compile("^([+|-]?)(\d+)(\.\d+)$")
199     CURRENCY_RE = re.compile("^[A-Z][A-Z][A-Z]$")
200
201     @classmethod
202     def _parse(cls, s: str) -> Optional[Tuple[int, str]]:
203         centcount = None
204         currency = None
205         s = s.strip()
206         chunks = s.split(' ')
207         try:
208             for chunk in chunks:
209                 if CentCount.CENTCOUNT_RE.match(chunk) is not None:
210                     centcount = int(float(chunk) * 100.0)
211                 elif CentCount.CURRENCY_RE.match(chunk) is not None:
212                     currency = chunk
213         except:
214             pass
215         if centcount is not None and currency is not None:
216             return (centcount, currency)
217         elif centcount is not None:
218             return (centcount, 'USD')
219         return None
220
221     @classmethod
222     def parse(cls, s: str) -> T:
223         chunks = CentCount._parse(s)
224         if chunks is not None:
225             return CentCount(chunks[0], chunks[1])
226         raise Exception(f'Unable to parse money string "{s}"')