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