Changeset View
Changeset View
Standalone View
Standalone View
looper/money.py
| import functools | import functools | ||||
| import typing | from typing import Dict, Iterable, List, Mapping, Union | ||||
| import babel.numbers | import babel.numbers | ||||
| from django.conf import settings | from django.conf import settings | ||||
| class CurrencyMismatch(ValueError): | class CurrencyMismatch(ValueError): | ||||
| """Raised when mixing currencies in one mathematical expression.""" | """Raised when mixing currencies in one mathematical expression.""" | ||||
| @functools.total_ordering | @functools.total_ordering | ||||
| class Money: | class Money: | ||||
| """Immutable class for monetary amounts.""" | """Immutable class for monetary amounts.""" | ||||
| __slots__ = ('_currency', '_cents') | __slots__ = ('_currency', '_cents') | ||||
| def __init__(self, currency: str, cents: int) -> None: | def __init__(self, currency: str, cents: int) -> None: | ||||
| if not isinstance(currency, str): | if not isinstance(currency, str): | ||||
| raise TypeError(f'currency must be string, not {type(currency)}') | raise TypeError(f'currency must be string, not {type(currency)}') | ||||
| if not isinstance(cents, int): | if not isinstance(cents, int): | ||||
| raise TypeError(f'cents must be integer, not {type(cents)}') | raise TypeError(f'cents must be integer, not {type(cents)}') | ||||
| Show All 15 Lines | class Money: | ||||
| @property | @property | ||||
| def just_whole(self) -> int: | def just_whole(self) -> int: | ||||
| """Return the whole currency, so € 1.23 → 1""" | """Return the whole currency, so € 1.23 → 1""" | ||||
| return self._cents // 100 | return self._cents // 100 | ||||
| @property | @property | ||||
| def currency_symbol(self) -> str: | def currency_symbol(self) -> str: | ||||
| return babel.numbers.get_currency_symbol(self._currency, | symbol = babel.numbers.get_currency_symbol( | ||||
| locale=settings.LOOPER_MONEY_LOCALE) | self._currency, locale=settings.LOOPER_MONEY_LOCALE | ||||
| ) | |||||
| assert isinstance(symbol, str) | |||||
| return symbol | |||||
| @property | @property | ||||
| def decimals_string(self) -> str: | def decimals_string(self) -> str: | ||||
| """Return the amount as a string, without currency, and with a decimal point. | """Return the amount as a string, without currency, and with a decimal point. | ||||
| >>> Money('EUR', 1033).decimals_string | >>> Money('EUR', 1033).decimals_string | ||||
| '10.33' | '10.33' | ||||
| """ | """ | ||||
| Show All 39 Lines | def with_currency_symbol_nonocents(self) -> str: | ||||
| return f'{self.currency_symbol}\u00A0{whole}' | return f'{self.currency_symbol}\u00A0{whole}' | ||||
| def __pos__(self) -> 'Money': | def __pos__(self) -> 'Money': | ||||
| return Money(self._currency, self._cents) | return Money(self._currency, self._cents) | ||||
| def __neg__(self) -> 'Money': | def __neg__(self) -> 'Money': | ||||
| return Money(self._currency, -self._cents) | return Money(self._currency, -self._cents) | ||||
| def _assert_same_currency(self, other) -> None: | def _assert_same_currency(self, other: 'Money') -> None: | ||||
| if not isinstance(other, Money): | if not isinstance(other, Money): | ||||
| raise TypeError(f'{other!r} is not {Money!r}') | raise TypeError(f'{other!r} is not {Money!r}') | ||||
| if self._currency == other._currency: | if self._currency == other._currency: | ||||
| return | return | ||||
| raise CurrencyMismatch(f'Currency mismatch between {self} and {other}') | raise CurrencyMismatch(f'Currency mismatch between {self} and {other}') | ||||
| def __add__(self, other: 'Money') -> 'Money': | def __add__(self, other: 'Money') -> 'Money': | ||||
| self._assert_same_currency(other) | self._assert_same_currency(other) | ||||
| return Money(self._currency, self._cents + other._cents) | return Money(self._currency, self._cents + other._cents) | ||||
| def __sub__(self, other: 'Money') -> 'Money': | def __sub__(self, other: 'Money') -> 'Money': | ||||
| self._assert_same_currency(other) | self._assert_same_currency(other) | ||||
| return Money(self._currency, self._cents - other._cents) | return Money(self._currency, self._cents - other._cents) | ||||
| def __mul__(self, other) -> 'Money': | def __mul__(self, other: int) -> 'Money': | ||||
| if isinstance(other, Money): | if isinstance(other, Money): | ||||
| raise TypeError('cannot multiply monetary quantities') | raise TypeError('cannot multiply monetary quantities') | ||||
| if not isinstance(other, int): | if not isinstance(other, int): | ||||
| raise TypeError(f'unsupported type {type(other)}') | raise TypeError(f'unsupported type {type(other)}') | ||||
| return Money(self._currency, self._cents * other) | return Money(self._currency, self._cents * other) | ||||
| __radd__ = __add__ | __radd__ = __add__ | ||||
| __rmul__ = __mul__ | __rmul__ = __mul__ | ||||
| def __truediv__(self, divisor: typing.Union[int, 'Money']) -> typing.Union[list, float]: | def __truediv__(self, divisor: Union[int, 'Money']) -> Union[List['Money'], float]: | ||||
| """Split up the amount in (almost-)equal parts or compute ratio. | """Split up the amount in (almost-)equal parts or compute ratio. | ||||
| When the divisor is an integer, return a list of `divisor` Money | When the divisor is an integer, return a list of `divisor` Money | ||||
| objects that sum to `self`. | objects that sum to `self`. | ||||
| When the divisor is a Money object, return the ratio between 'self' | When the divisor is a Money object, return the ratio between 'self' | ||||
| and 'divisor'. This requires both Money objects to have the same | and 'divisor'. This requires both Money objects to have the same | ||||
| currency. | currency. | ||||
| Show All 21 Lines | def __truediv__(self, divisor: Union[int, 'Money']) -> Union[List['Money'], float]: | ||||
| # Spread the remainder more or less equally | # Spread the remainder more or less equally | ||||
| assert remainder < divisor | assert remainder < divisor | ||||
| for i in range(remainder): | for i in range(remainder): | ||||
| results[i]._cents += sign | results[i]._cents += sign | ||||
| return results | return results | ||||
| def __floordiv__(self, divisor: typing.Union[int, float]) -> 'Money': | def __floordiv__(self, divisor: Union[int, float]) -> 'Money': | ||||
| """Lossy division of the money. | """Lossy division of the money. | ||||
| Returns the highest amount in integer cents for which | Returns the highest amount in integer cents for which | ||||
| `result * divisor <= self` holds. | `result * divisor <= self` holds. | ||||
| Note that this should NOT BE USED to divide a monetary amount over | Note that this should NOT BE USED to divide a monetary amount over | ||||
| multiple recipients, as it WILL INCUR LOSSES. | multiple recipients, as it WILL INCUR LOSSES. | ||||
| """ | """ | ||||
| if not isinstance(divisor, (int, float)): | if not isinstance(divisor, (int, float)): | ||||
| raise TypeError(f'Money can only be divided by an integer or float, not {divisor!r}') | raise TypeError(f'Money can only be divided by an integer or float, not {divisor!r}') | ||||
| if divisor < 0: | if divisor < 0: | ||||
| raise ValueError('Unable to divide by negative amount') | raise ValueError('Unable to divide by negative amount') | ||||
| if divisor == 0: | if divisor == 0: | ||||
| raise ZeroDivisionError() | raise ZeroDivisionError() | ||||
| divided_cents = int(self._cents // divisor) | divided_cents = int(self._cents // divisor) | ||||
| return self.__class__(self._currency, divided_cents) | return self.__class__(self._currency, divided_cents) | ||||
| def _ratio(self, other: 'Money') -> float: | def _ratio(self, other: 'Money') -> float: | ||||
| self._assert_same_currency(other) | self._assert_same_currency(other) | ||||
| return self.cents / other.cents | return self.cents / other.cents | ||||
| def __eq__(self, other) -> bool: | def __eq__(self, other: object) -> bool: | ||||
| if not isinstance(other, Money): | if not isinstance(other, Money): | ||||
| return False | return False | ||||
| return self._cents == other._cents and self._currency == other._currency | return self._cents == other._cents and self._currency == other._currency | ||||
| def __ne__(self, other) -> bool: | def __ne__(self, other: object) -> bool: | ||||
| return not self.__eq__(other) | return not self.__eq__(other) | ||||
| def __hash__(self) -> int: | def __hash__(self) -> int: | ||||
| return hash((self._currency, self._cents)) | return hash((self._currency, self._cents)) | ||||
| def __lt__(self, other): | def __lt__(self, other: object) -> bool: | ||||
| if not isinstance(other, Money): | if not isinstance(other, Money): | ||||
| return NotImplemented | return NotImplemented | ||||
| self._assert_same_currency(other) | self._assert_same_currency(other) | ||||
| return self._cents < other._cents | return self._cents < other._cents | ||||
| def __bool__(self) -> bool: | def __bool__(self) -> bool: | ||||
| return self._cents != 0 | return self._cents != 0 | ||||
| def sum_per_currency(amounts: typing.Iterable[Money]) -> typing.Mapping[str, Money]: | def sum_per_currency(amounts: Iterable[Money]) -> Mapping[str, Money]: | ||||
| """Compute the sum of all given amounts, separated by currency. | """Compute the sum of all given amounts, separated by currency. | ||||
| :return: Mapping of currency to the sum for that currency. | :return: Mapping of currency to the sum for that currency. | ||||
| """ | """ | ||||
| import collections | import collections | ||||
| total_per_currency: typing.Dict[str, int] = collections.defaultdict(int) | total_per_currency: Dict[str, int] = collections.defaultdict(int) | ||||
| for amount in amounts: | for amount in amounts: | ||||
| total_per_currency[amount.currency] += amount.cents | total_per_currency[amount.currency] += amount.cents | ||||
| return { | return { | ||||
| currency: Money(currency, sum_cents) | currency: Money(currency, sum_cents) for currency, sum_cents in total_per_currency.items() | ||||
| for currency, sum_cents in total_per_currency.items() | |||||
| } | } | ||||
| def sum_to_euros(amounts: typing.Iterable[Money]) -> Money: | def sum_to_euros(amounts: Iterable[Money]) -> Money: | ||||
| """Sums all amounts while converting to €. | """Sums all amounts while converting to €. | ||||
| Note that all converted amounts are rounded to entire cents, so | Note that all converted amounts are rounded to entire cents, so | ||||
| this is a lossy operation. | this is a lossy operation. | ||||
| """ | """ | ||||
| from django.conf import settings | from django.conf import settings | ||||
| Show All 32 Lines | |||||