+#!/usr/bin/env python3
+
+from decimal import Decimal
+import re
+from typing import Optional, TypeVar, Tuple
+
+import math_utils
+
+
+T = TypeVar('T', bound='Money')
+
+
+class Money(object):
+ """A class for representing monetary amounts potentially with
+ different currencies.
+ """
+
+ def __init__ (
+ self,
+ amount: Decimal = Decimal("0.0"),
+ currency: str = 'USD',
+ *,
+ strict_mode = False
+ ):
+ self.strict_mode = strict_mode
+ if isinstance(amount, str):
+ ret = Money._parse(amount)
+ if ret is None:
+ raise Exception(f'Unable to parse money string "{amount}"')
+ amount = ret[0]
+ currency = ret[1]
+ if not isinstance(amount, Decimal):
+ amount = Decimal(float(amount))
+ self.amount = amount
+ if not currency:
+ self.currency: Optional[str] = None
+ else:
+ self.currency: Optional[str] = currency
+
+ def __repr__(self):
+ a = float(self.amount)
+ a = round(a, 2)
+ s = f'{a:,.2f}'
+ if self.currency is not None:
+ return '%s %s' % (s, self.currency)
+ else:
+ return '$%s' % s
+
+ def __pos__(self):
+ return Money(amount=self.amount, currency=self.currency)
+
+ def __neg__(self):
+ return Money(amount=-self.amount, currency=self.currency)
+
+ def __add__(self, other):
+ if isinstance(other, Money):
+ if self.currency == other.currency:
+ return Money(
+ amount = self.amount + other.amount,
+ currency = self.currency
+ )
+ else:
+ raise TypeError('Incompatible currencies in add expression')
+ else:
+ if self.strict_mode:
+ raise TypeError('In strict_mode only two moneys can be added')
+ else:
+ return Money(
+ amount = self.amount + Decimal(float(other)),
+ currency = self.currency
+ )
+
+ def __sub__(self, other):
+ if isinstance(other, Money):
+ if self.currency == other.currency:
+ return Money(
+ amount = self.amount - other.amount,
+ currency = self.currency
+ )
+ else:
+ raise TypeError('Incompatible currencies in add expression')
+ else:
+ if self.strict_mode:
+ raise TypeError('In strict_mode only two moneys can be added')
+ else:
+ return Money(
+ amount = self.amount - Decimal(float(other)),
+ currency = self.currency
+ )
+
+ def __mul__(self, other):
+ if isinstance(other, Money):
+ raise TypeError('can not multiply monetary quantities')
+ else:
+ return Money(
+ amount = self.amount * Decimal(float(other)),
+ currency = self.currency
+ )
+
+ def __truediv__(self, other):
+ if isinstance(other, Money):
+ raise TypeError('can not divide monetary quantities')
+ else:
+ return Money(
+ amount = self.amount / Decimal(float(other)),
+ currency = self.currency
+ )
+
+ def __float__(self):
+ return self.amount.__float__()
+
+ def truncate_fractional_cents(self):
+ x = float(self)
+ self.amount = Decimal(math_utils.truncate_float(x))
+ return self.amount
+
+ def round_fractional_cents(self):
+ x = float(self)
+ self.amount = Decimal(round(x, 2))
+ return self.amount
+
+ __radd__ = __add__
+
+ def __rsub__(self, other):
+ if isinstance(other, Money):
+ if self.currency == other.currency:
+ return Money(
+ amount = other.amount - self.amount,
+ currency = self.currency
+ )
+ else:
+ raise TypeError('Incompatible currencies in sub expression')
+ else:
+ if self.strict_mode:
+ raise TypeError('In strict_mode only two moneys can be added')
+ else:
+ return Money(
+ amount = Decimal(float(other)) - self.amount,
+ currency = self.currency
+ )
+
+ __rmul__ = __mul__
+
+ #
+ # Override comparison operators to also compare currency.
+ #
+ def __eq__(self, other):
+ if other is None:
+ return False
+ if isinstance(other, Money):
+ return (
+ self.amount == other.amount and
+ self.currency == other.currency
+ )
+ if self.strict_mode:
+ raise TypeError("In strict mode only two Moneys can be compared")
+ else:
+ return self.amount == Decimal(float(other))
+
+ def __ne__(self, other):
+ result = self.__eq__(other)
+ if result is NotImplemented:
+ return result
+ return not result
+
+ def __lt__(self, other):
+ if isinstance(other, Money):
+ if self.currency == other.currency:
+ return self.amount < other.amount
+ else:
+ raise TypeError('can not directly compare different currencies')
+ else:
+ if self.strict_mode:
+ raise TypeError('In strict mode, only two Moneys can be compated')
+ else:
+ return self.amount < Decimal(float(other))
+
+ def __gt__(self, other):
+ if isinstance(other, Money):
+ if self.currency == other.currency:
+ return self.amount > other.amount
+ else:
+ raise TypeError('can not directly compare different currencies')
+ else:
+ if self.strict_mode:
+ raise TypeError('In strict mode, only two Moneys can be compated')
+ else:
+ return self.amount > Decimal(float(other))
+
+ def __le__(self, other):
+ return self < other or self == other
+
+ def __ge__(self, other):
+ return self > other or self == other
+
+ def __hash__(self):
+ return self.__repr__
+
+ AMOUNT_RE = re.compile("^([+|-]?)(\d+)(\.\d+)$")
+ CURRENCY_RE = re.compile("^[A-Z][A-Z][A-Z]$")
+
+ @classmethod
+ def _parse(cls, s: str) -> Optional[Tuple[Decimal, str]]:
+ amount = None
+ currency = None
+ s = s.strip()
+ chunks = s.split(' ')
+ try:
+ for chunk in chunks:
+ if Money.AMOUNT_RE.match(chunk) is not None:
+ amount = Decimal(chunk)
+ elif Money.CURRENCY_RE.match(chunk) is not None:
+ currency = chunk
+ except:
+ pass
+ if amount is not None and currency is not None:
+ return (amount, currency)
+ elif amount is not None:
+ return (amount, 'USD')
+ return None
+
+ @classmethod
+ def parse(cls, s: str) -> T:
+ chunks = Money._parse(s)
+ if chunks is not None:
+ return Money(chunks[0], chunks[1])
+ raise Exception(f'Unable to parse money string "{s}"')