ef1688959570020e9ff6d76f66a1de5389a56197
[utils.git] / src / budget_sync / money.py
1 LOG10_CENTS_PER_EURO = 2
2 CENTS_PER_EURO = 10 ** LOG10_CENTS_PER_EURO
3
4 __all__ = ["Money", "LOG10_CENTS_PER_EURO", "CENTS_PER_EURO"]
5
6
7 def _is_ascii_digits(s: str):
8 try:
9 s.encode("ascii")
10 except UnicodeEncodeError:
11 return False
12 return s.isdigit()
13
14
15 class Money:
16 """class for handling money, stored as an integer number of cents.
17
18 Note: float is not appropriate for dealing with monetary values due to
19 loss of precision and round-off error. Decimal has similar issues, but to
20 a smaller extent."""
21
22 __slots__ = ["cents"]
23
24 def __init__(self, value=None, *, cents=None):
25 if value is None:
26 if cents is None:
27 cents = 0
28 assert isinstance(cents, int)
29 elif isinstance(value, Money):
30 cents = value.cents
31 elif isinstance(value, int):
32 cents = value * CENTS_PER_EURO
33 elif isinstance(value, float):
34 raise TypeError("float is not an appropriate type"
35 " for dealing with money")
36 else:
37 cents = self.from_str(value).cents
38 self.cents = cents
39
40 @staticmethod
41 def from_str(text: str):
42 if not isinstance(text, str):
43 raise TypeError("Can't use Money.from_str to "
44 "convert from non-str value")
45 parts = text.strip().split(".", maxsplit=2)
46 first_part = parts[0]
47 negative = first_part.startswith("-")
48 if first_part.startswith(("-", "+")):
49 first_part = first_part[1:]
50 if first_part == "":
51 euros = 0
52 elif _is_ascii_digits(first_part):
53 euros = int(first_part)
54 else:
55 raise ValueError("invalid Money string: characters after sign and"
56 " before first `.` must be ascii digits")
57 if len(parts) > 2:
58 raise ValueError("invalid Money string: too many `.` characters")
59 elif len(parts) == 2:
60 if parts[1] == "":
61 if first_part == "":
62 raise ValueError("invalid Money string: missing digits")
63 cents = 0
64 elif _is_ascii_digits(parts[1]):
65 shift_amount = LOG10_CENTS_PER_EURO - len(parts[1])
66 if shift_amount < 0:
67 raise ValueError("invalid Money string: too many digits"
68 " after `.`")
69 cents = int(parts[1]) * (10 ** shift_amount)
70 else:
71 raise ValueError("invalid Money string: characters"
72 " after `.` must be ascii digits")
73 elif first_part == "":
74 raise ValueError("invalid Money string: missing digits")
75 else:
76 cents = 0
77 cents += CENTS_PER_EURO * euros
78 if negative:
79 cents = -cents
80 return Money(cents=cents)
81
82 def __str__(self):
83 retval = "-" if self.cents < 0 else ""
84 retval += str(abs(self.cents) // CENTS_PER_EURO)
85 cents = abs(self.cents) % CENTS_PER_EURO
86 if cents != 0:
87 retval += "."
88 retval += str(cents).zfill(LOG10_CENTS_PER_EURO)
89 return retval
90
91 def __lt__(self, other):
92 return self.cents < Money(other).cents
93
94 def __le__(self, other):
95 return self.cents <= Money(other).cents
96
97 def __eq__(self, other):
98 return self.cents == Money(other).cents
99
100 def __ne__(self, other):
101 return self.cents != Money(other).cents
102
103 def __gt__(self, other):
104 return self.cents > Money(other).cents
105
106 def __ge__(self, other):
107 return self.cents >= Money(other).cents
108
109 def __repr__(self):
110 return f"Money({repr(str(self))})"
111
112 def __bool__(self):
113 return bool(self.cents)
114
115 def __add__(self, other):
116 cents = self.cents + Money(other).cents
117 return Money(cents=cents)
118
119 def __radd__(self, other):
120 cents = Money(other).cents + self.cents
121 return Money(cents=cents)
122
123 def __sub__(self, other):
124 cents = self.cents - Money(other).cents
125 return Money(cents=cents)
126
127 def __rsub__(self, other):
128 cents = Money(other).cents - self.cents
129 return Money(cents=cents)
130
131 def __mul__(self, other):
132 if not isinstance(other, int):
133 raise TypeError("can't multiply by non-int")
134 cents = self.cents * other
135 return Money(cents=cents)
136
137 def __rmul__(self, other):
138 if not isinstance(other, int):
139 raise TypeError("can't multiply by non-int")
140 cents = other * self.cents
141 return Money(cents=cents)