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