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