ef7707ad188bd614c3fd79cede3b8208e4c5fe07
[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_nonocents(self) -> str:
84         """Same as with_currency_symbols but never ends in '.00'."""
85
86         cents = self.just_cents
87         if cents:
88             return self.with_currency_symbol()
89         return f'{self.currency_symbol}\u00A0{self.just_whole}'
90
91     def __pos__(self) -> 'Money':
92         return Money(self._currency, self._cents)
93
94     def __neg__(self) -> 'Money':
95         return Money(self._currency, -self._cents)
96
97     def _assert_same_currency(self, other) -> None:
98         if not isinstance(other, Money):
99             raise TypeError(f'{other!r} is not {Money!r}')
100         if self._currency == other._currency:
101             return
102         raise CurrencyMismatch(f'Currency mismatch between {self} and {other}')
103
104     def __add__(self, other: 'Money') -> 'Money':
105         self._assert_same_currency(other)
106         return Money(self._currency, self._cents + other._cents)
107
108     def __sub__(self, other: 'Money') -> 'Money':
109         self._assert_same_currency(other)
110         return Money(self._currency, self._cents - other._cents)
111
112     def __mul__(self, other) -> 'Money':
113         if isinstance(other, Money):
114             raise TypeError('cannot multiply monetary quantities')
115         if not isinstance(other, int):
116             raise TypeError(f'unsupported type {type(other)}')
117
118         return Money(self._currency, self._cents * other)
119
120     __radd__ = __add__
121     __rmul__ = __mul__
122
123     def __truediv__(self, divisor: typing.Union[int, 'Money']) -> typing.Union[list, float]:
124         """Split up the amount in (almost-)equal parts or compute ratio.
125
126         When the divisor is an integer, return a list of `divisor` Money
127         objects that sum to `self`.
128
129         When the divisor is a Money object, return the ratio between 'self'
130         and 'divisor'. This requires both Money objects to have the same
131         currency.
132
133         To prevent money loss and integer cent precision, something like
134         `Money('EUR', 10) / 3` should return three Money objects with resp.
135         4, 3, and 3 cents.
136         """
137         if isinstance(divisor, Money):
138             return self._ratio(divisor)
139         if not isinstance(divisor, int):
140             raise TypeError(f'Money can only be divided by Money or integer, not {divisor!r}')
141         if divisor < 0:
142             raise ValueError('Unable to divide by negative amount')
143         if divisor == 0:
144             raise ZeroDivisionError()
145
146         remainder = abs(self._cents)
147         sign = 1 if self._cents >= 0 else -1
148         results = []
149         for_all_parts = remainder // divisor
150         for _ in range(divisor):
151             results.append(Money(self._currency, sign * for_all_parts))
152             remainder -= for_all_parts
153
154         # Spread the remainder more or less equally
155         assert remainder < divisor
156         for i in range(remainder):
157             results[i]._cents += sign
158
159         return results
160
161     def __floordiv__(self, divisor: typing.Union[int, float]) -> 'Money':
162         """Lossy division of the money.
163
164         Returns the highest amount in integer cents for which
165         `result * divisor <= self` holds.
166
167         Note that this should NOT BE USED to divide a monetary amount over
168         multiple recipients, as it WILL INCUR LOSSES.
169         """
170         if not isinstance(divisor, (int, float)):
171             raise TypeError(f'Money can only be divided by an integer or float, not {divisor!r}')
172         if divisor < 0:
173             raise ValueError('Unable to divide by negative amount')
174         if divisor == 0:
175             raise ZeroDivisionError()
176
177         divided_cents = int(self._cents // divisor)
178         return self.__class__(self._currency, divided_cents)
179
180     def _ratio(self, other: 'Money') -> float:
181         self._assert_same_currency(other)
182         return self.cents / other.cents
183
184     def __eq__(self, other) -> bool:
185         if not isinstance(other, Money):
186             return False
187         return self._cents == other._cents and self._currency == other._currency
188
189     def __ne__(self, other) -> bool:
190         return not self.__eq__(other)
191
192     def __hash__(self) -> int:
193         return hash((self._currency, self._cents))
194
195     def __lt__(self, other):
196         if not isinstance(other, Money):
197             return NotImplemented
198         self._assert_same_currency(other)
199         return self._cents < other._cents
200
201     def __bool__(self) -> bool:
202         return self._cents != 0
203
204
205 def sum_per_currency(amounts: typing.Iterable[Money]) -> typing.Mapping[str, Money]:
206     """Compute the sum of all given amounts, separated by currency.
207
208     :return: Mapping of currency to the sum for that currency.
209     """
210     import collections
211
212     total_per_currency: typing.Dict[str, int] = collections.defaultdict(int)
213
214     for amount in amounts:
215         total_per_currency[amount.currency] += amount.cents
216
217     return {
218         currency: Money(currency, sum_cents)
219         for currency, sum_cents in total_per_currency.items()
220     }
221
222
223 def sum_to_euros(amounts: typing.Iterable[Money]) -> Money:
224     """Sums all amounts while converting to €.
225
226     Note that all converted amounts are rounded to entire cents, so
227     this is a lossy operation.
228     """
229
230     from django.conf import settings
231
232     rates = settings.LOOPER_CONVERTION_RATES_FROM_EURO
233
234     sum_cents_eur = 0
235     for amount in amounts:
236         conversion_rate = rates[amount.currency]
237         converted_cents = int(round(amount.cents / conversion_rate))
238         sum_cents_eur += converted_cents
239
240     return Money('EUR', sum_cents_eur)
241
242
243 def convert_currency(amount: Money, *, to_currency: str) -> Money:
244     """Convert the amount to another currency.
245
246     Note that the converted amount is rounded to entire cents, so
247     this is a lossy operation.
248     """
249     from django.conf import settings
250
251     if amount.currency == to_currency:
252         return amount
253
254     rates = settings.LOOPER_CONVERTION_RATES_FROM_EURO
255
256     current_to_euro = 1 / rates[amount.currency]
257     euro_to_target = rates[to_currency]
258
259     cents_in_eur = amount.cents * current_to_euro
260     cents_in_target = cents_in_eur * euro_to_target
261     converted_cents = int(round(cents_in_target))
262
263     return Money(to_currency, converted_cents)