home · contact · privacy
c3f0ab9c22d028d92b1c2741e7c411a4d9efa499
[misc] / ledger.py
1 from http.server import BaseHTTPRequestHandler, HTTPServer
2 import sys
3 import os
4 hostName = "localhost"
5 serverPort = 8082
6
7
8 class HandledException(Exception):
9     pass
10
11
12 def handled_error_exit(msg):
13     print(f"ERROR: {msg}")
14     sys.exit(1)
15
16
17 def apply_booking_to_account_balances(account_sums, account, currency, amount):
18     if not account in account_sums:
19         account_sums[account] = {currency: amount}
20     elif not currency in account_sums[account].keys():
21         account_sums[account][currency] = amount
22     else:
23         account_sums[account][currency] += amount
24
25
26 def parse_lines(lines):
27     import datetime
28     import decimal
29     inside_booking = False
30     date_string, description = None, None
31     booking_lines = []
32     start_line = 0
33     bookings = []
34     comments = []
35     for i, line in enumerate(lines):
36         prefix = f"line {i}"
37         # we start with the case of an utterly empty line
38         comments += [""]
39         stripped_line = line.rstrip()
40         if stripped_line == '':
41             if inside_booking:
42                 # assume we finished a booking, finalize, and commit to DB
43                 if len(booking_lines) < 2:
44                     raise HandledException(f"{prefix} booking ends to early")
45                 booking = Booking(date_string, description, booking_lines, start_line)
46                 bookings += [booking]
47             # expect new booking to follow so re-zeroall booking data
48             inside_booking = False
49             date_string, description = None, None
50             booking_lines = []
51             continue
52         # if non-empty line, first get comment if any, and commit to DB
53         split_by_comment = stripped_line.split(sep=";", maxsplit=1)
54         if len(split_by_comment) == 2:
55             comments[i] = split_by_comment[1]
56         # if pre-comment empty: if inside_booking, this must be a comment-only line so we keep it for later ledger-output to capture those comments; otherwise, no more to process for this line
57         non_comment = split_by_comment[0].rstrip()
58         if non_comment.rstrip() == '':
59              if inside_booking:
60                  booking_lines += ['']
61              continue
62         # if we're starting a booking, parse by first-line pattern
63         if not inside_booking:
64             start_line = i
65             toks = non_comment.split(maxsplit=1)
66             date_string = toks[0]
67             try:
68                 datetime.datetime.strptime(date_string, '%Y-%m-%d')
69             except ValueError:
70                 raise HandledException(f"{prefix} bad date string: {date_string}")
71             try:
72                 description = toks[1]
73             except IndexError:
74                 raise HandledException(f"{prefix} bad description: {description}")
75             inside_booking = True
76             continue
77         # otherwise, read as transfer data
78         toks = non_comment.split()  # ignore specification's allowance of single spaces in names
79         if len(toks) > 3:
80             raise HandledException(f"{prefix} too many booking line tokens: {toks}")
81         amount, currency = None, None
82         account_name = toks[0]
83         if account_name[0] == '[' and account_name[-1] == ']':
84             # ignore specification's differentiation of "virtual" accounts
85             account_name = account_name[1:-1]
86         decimal_chars = ".-0123456789"
87         if len(toks) == 3:
88             i_currency = 1
89             try:
90                 amount = decimal.Decimal(toks[1])
91                 i_currency = 2
92             except decimal.InvalidOperation:
93                 try:
94                     amount = decimal.Decimal(toks[2])
95                 except decimal.InvalidOperation:
96                     raise HandledException(f"{prefix} no decimal number in: {toks[1:]}")
97             currency = toks[i_currency]
98             if currency[0] in decimal_chars:
99                 raise HandledException(f"{prefix} currency starts with int, dot, or minus: {currency}")
100         elif len(toks) == 2:
101             value = toks[1]
102             inside_amount = False
103             inside_currency = False
104             amount_string = ""
105             currency = ""
106             dots_counted = 0
107             for i, c in enumerate(value):
108                 if i == 0:
109                     if c in decimal_chars:
110                         inside_amount = True
111                     else:
112                         inside_currency = True
113                 if inside_currency:
114                     if c in decimal_chars and len(amount_string) == 0:
115                         inside_currency = False
116                         inside_amount = True
117                     else:
118                         currency += c
119                         continue
120                 if inside_amount:
121                     if c not in decimal_chars:
122                         if len(currency) > 0:
123                             raise HandledException(f"{prefix} amount has non-decimal chars: {value}")
124                         inside_currency = True
125                         inside_amount = False
126                         currency += c
127                         continue
128                     if c == '-' and len(amount_string) > 1:
129                         raise HandledException(f"{prefix} amount has non-start '-': {value}")
130                     if c == '.':
131                         if dots_counted > 1:
132                             raise HandledException(f"{prefix} amount has multiple dots: {value}")
133                         dots_counted += 1
134                     amount_string += c
135             if len(amount_string) == 0:
136                 raise HandledException(f"{prefix} amount missing: {value}")
137             if len(currency) == 0:
138                 raise HandledException(f"{prefix} currency missing: {value}")
139             amount = decimal.Decimal(amount_string)
140         booking_lines += [(account_name, amount, currency)]
141     if inside_booking:
142         raise HandledException(f"{prefix} last booking unfinished")
143     return bookings, comments
144
145 class Booking:
146
147     def __init__(self, date_string, description, booking_lines, start_line):
148         self.date_string = date_string
149         self.description = description
150         self.lines = booking_lines
151         self.start_line = start_line
152         self.validate_booking_lines()
153         self.account_changes = self.parse_booking_lines_to_account_changes()
154
155     def validate_booking_lines(self):
156         prefix = f"booking at line {self.start_line}"
157         sums = {}
158         empty_values = 0
159         for line in self.lines:
160             if line == '':
161                 continue
162             _, amount, currency = line
163             if amount is None:
164                 if empty_values > 0:
165                     raise HandledException(f"{prefix} relates more than one empty value of same currency {currency}")
166                 empty_values += 1
167                 continue
168             if currency not in sums:
169                 sums[currency] = 0
170             sums[currency] += amount
171         if empty_values == 0:
172             for k, v in sums.items():
173                 if v != 0:
174                     raise HandledException(f"{prefix} does not sum up to zero")
175         else:
176             sinkable = False
177             for k, v in sums.items():
178                 if v != 0:
179                     sinkable = True
180             if not sinkable:
181                 raise HandledException(f"{prefix} has empty value that cannot be filled")
182
183     def parse_booking_lines_to_account_changes(self):
184         account_changes = {}
185         debt = {}
186         sink_account = None
187         for line in self.lines:
188             if line == '':
189                 continue
190             account, amount, currency = line
191             if amount is None:
192                 sink_account = account
193                 continue
194             apply_booking_to_account_balances(account_changes, account, currency, amount)
195             if currency not in debt:
196                 debt[currency] = amount
197             else:
198                 debt[currency] += amount
199         if sink_account:
200             for currency, amount in debt.items():
201                 apply_booking_to_account_balances(account_changes, sink_account, currency, -amount)
202         return account_changes
203
204
205
206 class Database:
207
208     def __init__(self, load_from_file=True):
209         db_name = "_ledger"
210         self.db_file = db_name + ".json"
211         self.lock_file = db_name+ ".lock"
212         self.bookings = []
213         self.comments = []
214         if load_from_file and os.path.exists(self.db_file):
215             with open(self.db_file, "r") as f:
216                 ret = parse_lines(f.readlines())
217                 self.bookings += ret[0]
218                 self.comments += ret[1]
219
220     def append(self, lines):
221         import shutil
222         if os.path.exists(self.lock_file):
223             raise HandledException('Sorry, lock file!')
224         if os.path.exists(self.db_file):
225             shutil.copy(self.db_file, self.db_file + ".bak")
226         f = open(self.lock_file, 'w+')
227         f.close()
228         with open(self.db_file, 'a') as f:
229             f.write('\n' + '\n'.join(lines) + '\n');
230         os.remove(self.lock_file)
231
232
233 class MyServer(BaseHTTPRequestHandler):
234     header = '<html><meta charset="UTF-8"><body><a href="/ledger">ledger</a> <a href="/balance">balance</a> <a href="/add">add</a><hr />'
235     footer = '</body><html>'
236
237     def do_POST(self):
238         from urllib.parse import parse_qs
239         length = int(self.headers['content-length'])
240         postvars = parse_qs(self.rfile.read(length).decode(), keep_blank_values=1)
241         lines = postvars['booking'][0].splitlines()
242         try:
243             bookings, comments = parse_lines(lines)
244             db.append(lines)
245             self.send_response(200)
246             self.end_headers()
247             page = f"{self.header}Success!{self.footer}"
248             self.wfile.write(bytes(page, "utf-8"))
249         except HandledException as e:
250             self.send_response(400)
251             self.end_headers()
252             page = f"{self.header}{e}{self.footer}"
253             self.wfile.write(bytes(page, "utf-8"))
254
255     def do_GET(self):
256         self.send_response(200)
257         self.send_header("Content-type", "text/html")
258         self.end_headers()
259         db = Database()
260         page = self.header + ''
261         if self.path == '/balance':
262             page += self.balance_as_html(db)
263         elif self.path == '/add':
264             page += self.add()
265         else:
266             page += self.ledger_as_html(db)
267         page += self.footer
268         self.wfile.write(bytes(page, "utf-8"))
269
270     def balance_as_html(self, db):
271         account_sums = {}
272         for booking in db.bookings:
273             for account, changes in booking.account_changes.items():
274                 for currency, amount in changes.items():
275                     apply_booking_to_account_balances(account_sums, account, currency, amount)
276         account_tree = {}
277         def collect_branches(account_name, path):
278             node = account_tree
279             path_copy = path[:]
280             while len(path_copy) > 0:
281                 step = path_copy.pop(0)
282                 node = node[step]
283             toks = account_name.split(":", maxsplit=1)
284             parent = toks[0]
285             if parent in node.keys():
286                 child = node[parent]
287             else:
288                 child = {}
289                 node[parent] = child
290             if len(toks) == 2:
291                 k, v = collect_branches(toks[1], path + [parent])
292                 if k not in child.keys():
293                     child[k] = v
294                 else:
295                     child[k].update(v)
296             return parent, child
297         for account_name in sorted(account_sums.keys()):
298             k, v = collect_branches(account_name, [])
299             if k not in account_tree.keys():
300                 account_tree[k] = v
301             else:
302                 account_tree[k].update(v)
303         def collect_totals(parent_path, tree_node):
304             for k, v in tree_node.items():
305                 child_path = parent_path + ":" + k
306                 for currency, amount in collect_totals(child_path, v).items():
307                     apply_booking_to_account_balances(account_sums, parent_path, currency, amount)
308             return account_sums[parent_path]
309         for account_name in account_tree.keys():
310             account_sums[account_name] = collect_totals(account_name, account_tree[account_name])
311         lines = []
312         def print_subtree(lines, indent, node, subtree, path):
313             line = f"{indent}{node}"
314             n_tabs = 5 - (len(line) // 8)
315             line += n_tabs * "\t"
316             if "€" in account_sums[path + node].keys():
317                 amount = account_sums[path + node]["€"]
318                 line += f"{amount:9.2f} €\t"
319             else:
320                 line += f"\t\t"
321             for currency, amount in account_sums[path + node].items():
322                 if currency != '€' and amount > 0:
323                     line += f"{amount:5.2f} {currency}\t"
324             lines += [line]
325             indent += "  "
326             for k, v in sorted(subtree.items()):
327                 print_subtree(lines, indent, k, v, path + node + ":")
328         for k, v in sorted(account_tree.items()):
329             print_subtree(lines, "", k, v, "")
330         content = "\n".join(lines)
331         return f"<pre>{content}</pre>"
332
333     def ledger_as_html(self, db):
334         lines = []
335         for comment in db.comments:
336             lines += [f"; {comment}" if comment != '' else '']
337         for booking in db.bookings:
338             i = booking.start_line
339             suffix = f" {lines[i]}" if len(lines[i]) > 0 else ""
340             lines[i] = f"{booking.date_string} {booking.description}{suffix}"
341             for booking_line in booking.lines:
342                 i += 1
343                 if booking_line == '':
344                     continue
345                 suffix = f" {lines[i]}" if len(lines[i]) > 0 else ""
346                 value = f" {booking_line[1]} {booking_line[2]}" if booking_line[1] else ""
347                 lines[i] = f"{booking_line[0]}{value}{suffix}"
348         content = "\n".join(lines)
349         return f"<pre>{content}</pre>"
350
351     def add(self):
352         return '<form method="POST" action="/"><textarea name="booking" rows="8" cols="80"></textarea><input type="submit"></form>'
353
354
355 db = Database()
356 if __name__ == "__main__":      
357     webServer = HTTPServer((hostName, serverPort), MyServer)
358     print(f"Server started http://{hostName}:{serverPort}")
359     try:
360         webServer.serve_forever()
361     except KeyboardInterrupt:
362         pass
363     webServer.server_close()
364     print("Server stopped.")