3 # © Copyright 2021-2023, Scott Gasch
5 """An amount of money represented as an integral count of cents so as
6 to avoid floating point artifacts. Multiplication and division are
7 performed using floating point arithmetic but the quotient is cast
8 back to an integer number thus truncating the result and
9 avoiding floating point arithmetic artifacts. See details below.
11 The type guards against inadvertent aggregation of instances with
12 non-matching currencies, the division of one CentCount by another, and
13 has a strict mode which disallows comparison or aggregation with
14 non-CentCount operands (i.e. no comparison or aggregation with literal
19 Multiplication and division are performed by converting the
20 `CentCount` into a float and operating on two floating point
21 numbers. The result is then cast back to an int which loses
22 precision beyond the 1-cent granularity in order to avoid floating
23 point representation artifacts.
25 This can cause "problems" such as the one illustrated
28 >>> c = CentCount(100.00)
38 Two-thirds of $100.00 is $66.66666... which might be
39 expected to round upwards to $66.67 but it does not
40 because the `int` cast truncates the result. Be aware
41 of this and decide whether it's suitable for your
44 See also the :class:`pyutils.typez.Money` class which uses Python
45 Decimals (see: https://docs.python.org/3/library/decimal.html) to
46 represent monetary amounts.
50 from typing import Optional, Tuple, Union
53 class CentCount(object):
54 """A class for representing monetary amounts potentially with
55 different currencies meant to avoid floating point rounding
56 issues by treating amount as a simple integral count of cents.
61 centcount: Union[int, float, str, "CentCount"] = 0,
62 currency: str = "USD",
68 centcount: the amount of money being represented; this can be
69 a float, int, CentCount or str.
70 currency: optionally declare the currency being represented by
71 this instance. If provided it will guard against operations
72 such as attempting to add it to non-matching currencies.
73 strict_mode: if True, the instance created will object (raise) if
74 compared or aggregated with non-CentCount objects; that is,
75 strict_mode disallows comparison with literal numbers or
76 aggregation with literal numbers.
79 ValueError: invalid money string passed in
81 self.strict_mode = strict_mode
82 if isinstance(centcount, str):
83 ret = CentCount._parse(centcount)
85 raise ValueError(f'Unable to parse money string "{centcount}"')
88 if isinstance(centcount, float):
89 centcount = int(centcount * 100.0)
90 if not isinstance(centcount, int):
91 centcount = int(centcount)
92 self.centcount = centcount
94 self.currency: Optional[str] = None
96 self.currency = currency
99 w = self.centcount // 100
100 p = self.centcount % 100
102 if self.currency is not None:
103 return f"{s} {self.currency}"
108 return CentCount(centcount=self.centcount, currency=self.currency)
111 return CentCount(centcount=-self.centcount, currency=self.currency)
113 def __add__(self, other):
116 TypeError: if addend is not compatible or the object is in strict
117 mode and the addend is not another CentCount.
119 if isinstance(other, CentCount):
120 if self.currency == other.currency:
122 centcount=self.centcount + other.centcount,
123 currency=self.currency,
126 raise TypeError("Incompatible currencies in add expression")
129 raise TypeError("In strict_mode only two moneys can be added")
131 return self.__add__(CentCount(other, self.currency))
133 def __sub__(self, other):
136 TypeError: if amount is not compatible or the object is in strict
137 mode and the amount is not another CentCount.
139 if isinstance(other, CentCount):
140 if self.currency == other.currency:
142 centcount=self.centcount - other.centcount,
143 currency=self.currency,
146 raise TypeError("Incompatible currencies in add expression")
149 raise TypeError("In strict_mode only two moneys can be added")
151 return self.__sub__(CentCount(other, self.currency))
153 def __mul__(self, other):
156 TypeError: if factor is not compatible.
160 Multiplication and division are performed by converting the
161 CentCount into a float and operating on two floating point
162 numbers. But the result is then cast back to an int which
163 loses precision beyond the 1-cent granularity in order to
164 avoid floating point representation artifacts.
166 This can cause "problems" such as the one illustrated
169 >>> c = CentCount(100.00)
175 Two-thirds of $100.00 is $66.66666... which might be
176 expected to round upwards to $66.67 but it does not
177 because the int cast truncates the result. Be aware
178 of this and decide whether it's suitable for your
181 if isinstance(other, CentCount):
182 raise TypeError("can not multiply monetary quantities")
185 centcount=int(self.centcount * float(other)),
186 currency=self.currency,
189 def __truediv__(self, other):
192 TypeError: the divisor is not compatible
196 Multiplication and division are performed by converting the
197 CentCount into a float and operating on two floating point
198 numbers. But the result is then cast back to an int which
199 loses precision beyond the 1-cent granularity in order to
200 avoid floating point representation artifacts.
202 This can cause "problems" such as the one illustrated
205 >>> c = CentCount(100.00)
211 Two-thirds of $100.00 is $66.66666... which might be
212 expected to round upwards to $66.67 but it does not
213 because the int cast truncates the result. Be aware
214 of this and decide whether it's suitable for your
217 if isinstance(other, CentCount):
218 raise TypeError("can not divide monetary quantities")
221 centcount=int(float(self.centcount) / float(other)),
222 currency=self.currency,
226 return self.centcount.__int__()
229 return self.centcount.__float__() / 100.0
233 def __rsub__(self, other):
236 TypeError: amount is not compatible or, if the object is in
237 strict mode, the amount is not a CentCount.
239 if isinstance(other, CentCount):
240 if self.currency == other.currency:
242 centcount=other.centcount - self.centcount,
243 currency=self.currency,
246 raise TypeError("Incompatible currencies in sub expression")
249 raise TypeError("In strict_mode only two moneys can be added")
252 centcount=int(other) - self.centcount,
253 currency=self.currency,
259 # Override comparison operators to also compare currency.
261 def __eq__(self, other):
264 TypeError: In strict mode and the other object isn't a CentCount.
268 if isinstance(other, CentCount):
269 return self.centcount == other.centcount and self.currency == other.currency
271 raise TypeError("In strict mode only two CentCounts can be compared")
273 return self.centcount == int(other)
275 def __ne__(self, other):
276 result = self.__eq__(other)
277 if result is NotImplemented:
281 def __lt__(self, other):
284 TypeError: amounts have different currencies or, if this object
285 is in strict mode, the amount must be a CentCount.
287 if isinstance(other, CentCount):
288 if self.currency == other.currency:
289 return self.centcount < other.centcount
291 raise TypeError("can not directly compare different currencies")
294 raise TypeError("In strict mode, only two CentCounts can be compated")
296 return self.centcount < int(other)
298 def __gt__(self, other):
301 TypeError: amounts have different currencies or, if this object
302 is in strict mode, the amount must be a CentCount.
304 if isinstance(other, CentCount):
305 if self.currency == other.currency:
306 return self.centcount > other.centcount
308 raise TypeError("can not directly compare different currencies")
311 raise TypeError("In strict mode, only two CentCounts can be compated")
313 return self.centcount > int(other)
315 def __le__(self, other):
316 return self < other or self == other
318 def __ge__(self, other):
319 return self > other or self == other
321 def __hash__(self) -> int:
322 return hash(self.__repr__)
324 CENTCOUNT_RE = re.compile(r"^([+|-]?)(\d+)(\.\d+)$")
325 CURRENCY_RE = re.compile(r"^[A-Z][A-Z][A-Z]$")
328 def _parse(cls, s: str) -> Optional[Tuple[int, str]]:
332 chunks = s.split(" ")
335 if CentCount.CENTCOUNT_RE.match(chunk) is not None:
336 centcount = int(float(chunk) * 100.0)
337 elif CentCount.CURRENCY_RE.match(chunk) is not None:
341 if centcount is not None and currency is not None:
342 return (centcount, currency)
343 elif centcount is not None:
344 return (centcount, "USD")
348 def parse(cls, s: str) -> "CentCount":
349 """Parses a string format monetary amount and returns a CentCount
353 s: the string to be parsed
356 ValueError: input string cannot be parsed.
358 chunks = CentCount._parse(s)
359 if chunks is not None:
360 return CentCount(chunks[0], chunks[1])
361 raise ValueError(f'Unable to parse money string "{s}"')
364 if __name__ == "__main__":