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