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