Make subdirs type clean too.
[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)), currency=self.currency
64                 )
65
66     def __sub__(self, other):
67         if isinstance(other, Money):
68             if self.currency == other.currency:
69                 return Money(amount=self.amount - other.amount, currency=self.currency)
70             else:
71                 raise TypeError('Incompatible currencies in add expression')
72         else:
73             if self.strict_mode:
74                 raise TypeError('In strict_mode only two moneys can be added')
75             else:
76                 return Money(
77                     amount=self.amount - Decimal(float(other)), currency=self.currency
78                 )
79
80     def __mul__(self, other):
81         if isinstance(other, Money):
82             raise TypeError('can not multiply monetary quantities')
83         else:
84             return Money(
85                 amount=self.amount * Decimal(float(other)), currency=self.currency
86             )
87
88     def __truediv__(self, other):
89         if isinstance(other, Money):
90             raise TypeError('can not divide monetary quantities')
91         else:
92             return Money(
93                 amount=self.amount / Decimal(float(other)), currency=self.currency
94             )
95
96     def __float__(self):
97         return self.amount.__float__()
98
99     def truncate_fractional_cents(self):
100         x = float(self)
101         self.amount = Decimal(math_utils.truncate_float(x))
102         return self.amount
103
104     def round_fractional_cents(self):
105         x = float(self)
106         self.amount = Decimal(round(x, 2))
107         return self.amount
108
109     __radd__ = __add__
110
111     def __rsub__(self, other):
112         if isinstance(other, Money):
113             if self.currency == other.currency:
114                 return Money(amount=other.amount - self.amount, currency=self.currency)
115             else:
116                 raise TypeError('Incompatible currencies in sub expression')
117         else:
118             if self.strict_mode:
119                 raise TypeError('In strict_mode only two moneys can be added')
120             else:
121                 return Money(
122                     amount=Decimal(float(other)) - self.amount, currency=self.currency
123                 )
124
125     __rmul__ = __mul__
126
127     #
128     # Override comparison operators to also compare currency.
129     #
130     def __eq__(self, other):
131         if other is None:
132             return False
133         if isinstance(other, Money):
134             return self.amount == other.amount and self.currency == other.currency
135         if self.strict_mode:
136             raise TypeError("In strict mode only two Moneys can be compared")
137         else:
138             return self.amount == Decimal(float(other))
139
140     def __ne__(self, other):
141         result = self.__eq__(other)
142         if result is NotImplemented:
143             return result
144         return not result
145
146     def __lt__(self, other):
147         if isinstance(other, Money):
148             if self.currency == other.currency:
149                 return self.amount < other.amount
150             else:
151                 raise TypeError('can not directly compare different currencies')
152         else:
153             if self.strict_mode:
154                 raise TypeError('In strict mode, only two Moneys can be compated')
155             else:
156                 return self.amount < Decimal(float(other))
157
158     def __gt__(self, other):
159         if isinstance(other, Money):
160             if self.currency == other.currency:
161                 return self.amount > other.amount
162             else:
163                 raise TypeError('can not directly compare different currencies')
164         else:
165             if self.strict_mode:
166                 raise TypeError('In strict mode, only two Moneys can be compated')
167             else:
168                 return self.amount > Decimal(float(other))
169
170     def __le__(self, other):
171         return self < other or self == other
172
173     def __ge__(self, other):
174         return self > other or self == other
175
176     def __hash__(self):
177         return self.__repr__
178
179     AMOUNT_RE = re.compile(r"^([+|-]?)(\d+)(\.\d+)$")
180     CURRENCY_RE = re.compile(r"^[A-Z][A-Z][A-Z]$")
181
182     @classmethod
183     def _parse(cls, s: str) -> Optional[Tuple[Decimal, str]]:
184         amount = None
185         currency = None
186         s = s.strip()
187         chunks = s.split(' ')
188         try:
189             for chunk in chunks:
190                 if Money.AMOUNT_RE.match(chunk) is not None:
191                     amount = Decimal(chunk)
192                 elif Money.CURRENCY_RE.match(chunk) is not None:
193                     currency = chunk
194         except Exception:
195             pass
196         if amount is not None and currency is not None:
197             return (amount, currency)
198         elif amount is not None:
199             return (amount, 'USD')
200         return None
201
202     @classmethod
203     def parse(cls, s: str) -> 'Money':
204         chunks = Money._parse(s)
205         if chunks is not None:
206             return Money(chunks[0], chunks[1])
207         raise Exception(f'Unable to parse money string "{s}"')