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