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