Thermometer: show current monthly income in entire euros
[blender-dev-fund.git] / looper / money.py
1 import functools
2 import typing
3
4 import babel.numbers
5 from django.conf import settings
6
7
8 class CurrencyMismatch(ValueError):
9     """Raised when mixing currencies in one mathematical expression."""
10
11
12 @functools.total_ordering
13 class Money:
14     """Immutable class for monetary amounts."""
15     __slots__ = ('_currency', '_cents')
16
17     def __init__(self, currency: str, cents: int) -> None:
18         if not isinstance(currency, str):
19             raise TypeError(f'currency must be string, not {type(currency)}')
20         if not isinstance(cents, int):
21             raise TypeError(f'cents must be integer, not {type(cents)}')
22
23         self._currency = currency
24         self._cents = cents
25
26     @property
27     def currency(self) -> str:
28         return self._currency
29
30     @property
31     def cents(self) -> int:
32         return self._cents
33
34     @property
35     def just_cents(self) -> int:
36         """Return just the cents, so € 1.23 → 23"""
37         return self._cents % 100
38
39     @property
40     def just_whole(self) -> int:
41         """Return the whole currency, so € 1.23 → 1"""
42         return self._cents // 100
43
44     @property
45     def currency_symbol(self) -> str:
46         return babel.numbers.get_currency_symbol(self._currency,
47                                                  locale=settings.LOOPER_MONEY_LOCALE)
48
49     @property
50     def decimals_string(self) -> str:
51         """Return the amount as a string, without currency, and with a decimal point.
52
53         >>> Money('EUR', 1033).decimals_string
54         '10.33'
55         """
56         return f'{self.just_whole}.{self.just_cents:02}'
57
58     def __str__(self) -> str:
59         return f'{self._currency}\u00A0{self.decimals_string}'
60
61     @classmethod
62     def from_str(cls, as_str: str) -> 'Money':
63         """Reverse what the __str__() method produces into a Money instance.
64
65         str(money) is called by the Django serialiser, for example when dumping
66         data into fixtures. This function is used to undo that, and parse the
67         string back into a Money instance.
68         """
69         try:
70             currency, decimals_string = as_str.split('\u00A0', 1)
71         except ValueError:
72             raise ValueError('from_str() expects a non-breaking space as separator') from None
73         cents = int(round(float(decimals_string) * 100))
74         return Money(currency, cents)
75
76     def __repr__(self) -> str:
77         return f'Money(currency={self._currency!r}, cents={self._cents})'
78
79     def with_currency_symbol(self) -> str:
80         """Return the amount as string with currency symbol, so '€' instead of 'EUR'."""
81         return f'{self.currency_symbol}\u00A0{self.decimals_string}'
82
83     def with_currency_symbol_rounded(self) -> str:
84         """Same as with_currency_symbol() rounding to entire units."""
85
86         whole_rounded = int(round(self._cents / 100))
87         return f'{self.currency_symbol}\u00A0{whole_rounded}'
88
89     def with_currency_symbol_nonocents(self) -> str:
90         """Same as with_currency_symbols but never ends in '.00'."""
91
92         whole, cents = divmod(self._cents, 100)
93         if cents:
94             return f'{self.currency_symbol}\u00A0{whole}.{cents:02}'
95         return f'{self.currency_symbol}\u00A0{whole}'
96
97     def __pos__(self) -> 'Money':
98         return Money(self._currency, self._cents)
99
100     def __neg__(self) -> 'Money':
101         return Money(self._currency, -self._cents)
102
103     def _assert_same_currency(self, other) -> None:
104         if not isinstance(other, Money):
105             raise TypeError(f'{other!r} is not {Money!r}')
106         if self._currency == other._currency:
107             return
108         raise CurrencyMismatch(f'Currency mismatch between {self} and {other}')
109
110     def __add__(self, other: 'Money') -> 'Money':
111         self._assert_same_currency(other)
112         return Money(self._currency, self._cents + other._cents)
113
114     def __sub__(self, other: 'Money') -> 'Money':
115         self._assert_same_currency(other)
116         return Money(self._currency, self._cents - other._cents)
117
118     def __mul__(self, other) -> 'Money':
119         if isinstance(other, Money):
120             raise TypeError('cannot multiply monetary quantities')
121         if not isinstance(other, int):
122             raise TypeError(f'unsupported type {type(other)}')
123
124         return Money(self._currency, self._cents * other)
125
126     __radd__ = __add__
127     __rmul__ = __mul__
128
129     def __truediv__(self, divisor: typing.Union[int, 'Money']) -> typing.Union[list, float]:
130         """Split up the amount in (almost-)equal parts or compute ratio.
131
132         When the divisor is an integer, return a list of `divisor` Money
133         objects that sum to `self`.
134
135         When the divisor is a Money object, return the ratio between 'self'
136         and 'divisor'. This requires both Money objects to have the same
137         currency.
138
139         To prevent money loss and integer cent precision, something like
140         `Money('EUR', 10) / 3` should return three Money objects with resp.
141         4, 3, and 3 cents.
142         """
143         if isinstance(divisor, Money):
144             return self._ratio(divisor)
145         if not isinstance(divisor, int):
146             raise TypeError(f'Money can only be divided by Money or integer, not {divisor!r}')
147         if divisor < 0:
148             raise ValueError('Unable to divide by negative amount')
149         if divisor == 0:
150             raise ZeroDivisionError()
151
152         remainder = abs(self._cents)
153         sign = 1 if self._cents >= 0 else -1
154         results = []
155         for_all_parts = remainder // divisor
156         for _ in range(divisor):
157             results.append(Money(self._currency, sign * for_all_parts))
158             remainder -= for_all_parts
159
160         # Spread the remainder more or less equally
161         assert remainder < divisor
162         for i in range(remainder):
163             results[i]._cents += sign
164
165         return results
166
167     def __floordiv__(self, divisor: typing.Union[int, float]) -> 'Money':
168         """Lossy division of the money.
169
170         Returns the highest amount in integer cents for which
171         `result * divisor <= self` holds.
172
173         Note that this should NOT BE USED to divide a monetary amount over
174         multiple recipients, as it WILL INCUR LOSSES.
175         """
176         if not isinstance(divisor, (int, float)):
177             raise TypeError(f'Money can only be divided by an integer or float, not {divisor!r}')
178         if divisor < 0:
179             raise ValueError('Unable to divide by negative amount')
180         if divisor == 0:
181             raise ZeroDivisionError()
182
183         divided_cents = int(self._cents // divisor)
184         return self.__class__(self._currency, divided_cents)
185
186     def _ratio(self, other: 'Money') -> float:
187         self._assert_same_currency(other)
188         return self.cents / other.cents
189
190     def __eq__(self, other) -> bool:
191         if not isinstance(other, Money):
192             return False
193         return self._cents == other._cents and self._currency == other._currency
194
195     def __ne__(self, other) -> bool:
196         return not self.__eq__(other)
197
198     def __hash__(self) -> int:
199         return hash((self._currency, self._cents))
200
201     def __lt__(self, other):
202         if not isinstance(other, Money):
203             return NotImplemented
204         self._assert_same_currency(other)
205         return self._cents < other._cents
206
207     def __bool__(self) -> bool:
208         return self._cents != 0
209
210
211 def sum_per_currency(amounts: typing.Iterable[Money]) -> typing.Mapping[str, Money]:
212     """Compute the sum of all given amounts, separated by currency.
213
214     :return: Mapping of currency to the sum for that currency.
215     """
216     import collections
217
218     total_per_currency: typing.Dict[str, int] = collections.defaultdict(int)
219
220     for amount in amounts:
221         total_per_currency[amount.currency] += amount.cents
222
223     return {
224         currency: Money(currency, sum_cents)
225         for currency, sum_cents in total_per_currency.items()
226     }
227
228
229 def sum_to_euros(amounts: typing.Iterable[Money]) -> Money:
230     """Sums all amounts while converting to €.
231
232     Note that all converted amounts are rounded to entire cents, so
233     this is a lossy operation.
234     """
235
236     from django.conf import settings
237
238     rates = settings.LOOPER_CONVERTION_RATES_FROM_EURO
239
240     sum_cents_eur = 0
241     for amount in amounts:
242         conversion_rate = rates[amount.currency]
243         converted_cents = int(round(amount.cents / conversion_rate))
244         sum_cents_eur += converted_cents
245
246     return Money('EUR', sum_cents_eur)
247
248
249 def convert_currency(amount: Money, *, to_currency: str) -> Money:
250     """Convert the amount to another currency.
251
252     Note that the converted amount is rounded to entire cents, so
253     this is a lossy operation.
254     """
255     from django.conf import settings
256
257     if amount.currency == to_currency:
258         return amount
259
260     rates = settings.LOOPER_CONVERTION_RATES_FROM_EURO
261
262     current_to_euro = 1 / rates[amount.currency]
263     euro_to_target = rates[to_currency]
264
265     cents_in_eur = amount.cents * current_to_euro
266     cents_in_target = cents_in_eur * euro_to_target
267     converted_cents = int(round(cents_in_target))
268
269     return Money(to_currency, converted_cents)