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