X-Git-Url: https://plomlompom.com/repos/day?a=blobdiff_plain;f=ledger.py;h=9ff0d3eaabc723170071ffbb0e3a4726a6a747d0;hb=93756ded23b6f80333ef26e434f611a94bd04764;hp=334318ff301eb186efa6684809c9ebac1807d177;hpb=c3b5860ae1ca76c2a1dc3b61d3c1242d581424f0;p=misc diff --git a/ledger.py b/ledger.py index 334318f..9ff0d3e 100755 --- a/ledger.py +++ b/ledger.py @@ -1,48 +1,299 @@ -from http.server import BaseHTTPRequestHandler, HTTPServer -import sys -hostName = "localhost" -serverPort = 8082 +import os +import jinja2 +import decimal +from datetime import datetime, timedelta +from urllib.parse import parse_qs, urlparse +from plomlib import PlomDB, PlomException, run_server, PlomHandler +server_port = 8082 +db_path = '/home/plom/org/ledger2023.dat' -class HandledException(Exception): - pass - - -def handled_error_exit(msg): - print(f"ERROR: {msg}") - sys.exit(1) +html_head = """ + +
+ledger +balance +add free +add structured +{{date}} {{desc}} {{head_comment|e}}
+
+
{{l.acc|e}} | {{l.money|e}} | {{l.comment|e}} |
{{l.comment|e}} |
{content}" - - def balance_as_html(self): - account_sums = {} - for booking in self.bookings: - for account, changes in booking.account_changes.items(): - for currency, amount in changes.items(): - apply_booking_to_account_balances(account_sums, account, currency, amount) - account_tree = {} - def collect_branches(account_name, path): - node = account_tree - path_copy = path[:] - while len(path_copy) > 0: - step = path_copy.pop(0) - node = node[step] - toks = account_name.split(":", maxsplit=1) - parent = toks[0] - if parent in node.keys(): - child = node[parent] + def read_db_file(self, f): + self.real_lines += [l.rstrip() for l in f.readlines()] + + def get_lines(self, start, end): + return self.real_lines[start:end] + + def write_db(self, text, mode='w'): + if text[-1] != '\n': + text += '\n' + self.write_text_to_db(text) + + def insert_at_date(self, lines, date): + print("DEBUG insert_at_date") + start_at = 0 + if len(self.bookings) > 0: + if date >= self.bookings[-1].date_string: + start_at = len(self.real_lines) + lines = [''] + lines else: - child = {} - node[parent] = child - if len(toks) == 2: - k, v = collect_branches(toks[1], path + [parent]) - if k not in child.keys(): - child[k] = v - else: - child[k].update(v) - return parent, child - for account_name in sorted(account_sums.keys()): - k, v = collect_branches(account_name, []) - if k not in account_tree.keys(): - account_tree[k] = v + for b in self.bookings: + if b.date_string == date: + start_at = b.start_line + elif b.date_string > date: + start_at = b.start_line + break + return self.write_lines_in_total_lines_at(self.real_lines, start_at, lines) + + def update(self, start, end, lines, date): + print("DEBUG update") + total_lines = self.real_lines[:start] + self.real_lines[end:] + n_original_lines = end - start + start_at = len(total_lines) + for b in self.bookings: + if b.date_string == date: + if start_at == len(total_lines) or b.start_line == start: + start_at = b.start_line + if b.start_line > start: + start_at -= n_original_lines + elif b.date_string > date: + break + if start_at == len(total_lines): + lines = [''] + lines + return self.write_lines_in_total_lines_at(total_lines, start_at, lines) + + def write_lines_in_total_lines_at(self, total_lines, start_at, lines): + total_lines = total_lines[:start_at] + lines + [''] + total_lines[start_at:] + _, _ = parse_lines(lines) + text = '\n'.join(total_lines) + self.write_db(text) + return start_at + + def get_nth_for_booking_of_start_line(self, start_line): + nth = 0 + for b in self.bookings: + if b.start_line >= start_line: + break + nth += 1 + return nth + + def add_taxes(self, lines, finish=False): + ret = [] + bookings, _ = parse_lines(lines) + date = bookings[0].date_string + acc_kk_add = 'Reserves:KrankenkassenBeitragsWachstum' + acc_kk_minimum = 'Reserves:Month:KrankenkassenDefaultBeitrag' + acc_kk = 'Expenses:KrankenKasse' + acc_est = 'Reserves:Einkommenssteuer' + acc_assets = 'Assets' + acc_buffer = 'Reserves:NeuAnfangsPuffer:Ausgaben' + last_monthbreak_assets = 0 + last_monthbreak_est = 0 + last_monthbreak_kk_minimum = 0 + last_monthbreak_kk_add = 0 + buffer_expenses = 0 + kk_expenses = 0 + est_expenses = 0 + months_passed = -int(finish) + for b in self.bookings: + if date == b.date_string: + break + acc_keys = b.account_changes.keys() + if acc_buffer in acc_keys: + buffer_expenses -= b.account_changes[acc_buffer]['â¬'] + if acc_kk_add in acc_keys: + kk_expenses += b.account_changes[acc_kk_add]['â¬'] + if acc_kk in acc_keys: + kk_expenses += b.account_changes[acc_kk]['â¬'] + if acc_est in acc_keys: + est_expenses += b.account_changes[acc_est]['â¬'] + if acc_kk_add in acc_keys and acc_kk_minimum in acc_keys: + months_passed += 1 + if finish: + last_monthbreak_kk_add = b.account_changes[acc_kk_add]['â¬'] + last_monthbreak_est = b.account_changes[acc_est]['â¬'] + last_monthbreak_kk_minimum = b.account_changes[acc_kk_minimum]['â¬'] + last_monthbreak_assets = b.account_changes[acc_buffer]['â¬'] + old_needed_income_before_anything = - last_monthbreak_assets - last_monthbreak_kk_add - last_monthbreak_kk_minimum - last_monthbreak_est + if finish: + ret += [f' {acc_est} {-last_monthbreak_est}⬠; for old assumption of needed income: {old_needed_income_before_anything}â¬'] + _, account_sums = bookings_to_account_tree(bookings) + expenses_so_far = -1 * account_sums[acc_assets]['â¬'] + old_needed_income_before_anything + needed_income_before_kk = expenses_so_far + ESt_this_month = 0 + left_over = needed_income_before_kk - ESt_this_month + too_low = 0 + too_high = 2 * needed_income_before_kk + E0 = decimal.Decimal(10908) + E1 = decimal.Decimal(15999) + E2 = decimal.Decimal(62809) + E3 = decimal.Decimal(277825) + while True: + zvE = buffer_expenses - kk_expenses + (12 - months_passed) * needed_income_before_kk + if finish: + zvE += last_monthbreak_assets + last_monthbreak_kk_add + last_monthbreak_kk_minimum + if zvE < E0: + ESt = decimal.Decimal(0) + elif zvE < E1: + y = (zvE - E0)/10000 + ESt = (decimal.Decimal(979.18) * y + 1400) * y + elif zvE < E2: + y = (zvE - E1)/10000 + ESt = (decimal.Decimal(192.59) * y + 2397) * y + decimal.Decimal(966.53) + elif zvE < E3: + ESt = decimal.Decimal(0.42) * (zvE - decimal.Decimal(62809)) + decimal.Decimal(16405.54) else: - account_tree[k].update(v) - def collect_totals(parent_path, tree_node): - for k, v in tree_node.items(): - child_path = parent_path + ":" + k - for currency, amount in collect_totals(child_path, v).items(): - apply_booking_to_account_balances(account_sums, parent_path, currency, amount) - return account_sums[parent_path] - for account_name in account_tree.keys(): - account_sums[account_name] = collect_totals(account_name, account_tree[account_name]) + ESt = decimal.Decimal(0.45) * (zvE - decimal.Decimal(277825)) + decimal.Decimal(106713.52) + ESt_this_month = (ESt + last_monthbreak_est - est_expenses) / (12 - months_passed) + left_over = needed_income_before_kk - ESt_this_month + if abs(left_over - expenses_so_far) < 0.001: + break + elif left_over < expenses_so_far: + too_low = needed_income_before_kk + elif left_over > expenses_so_far: + too_high = needed_income_before_kk + needed_income_before_kk = too_low + (too_high - too_low)/2 + ESt_this_month = ESt_this_month.quantize(decimal.Decimal('0.00')) + ret += [f' {acc_est} {ESt_this_month}⬠; expenses so far: {expenses_so_far:.2f}â¬; zvE: {zvE:.2f}â¬; ESt total: {ESt:.2f}â¬; needed before Krankenkasse: {needed_income_before_kk:.2f}â¬'] + kk_minimum_income = 1131.67 + if date < '2023-02-01': + kk_minimum_income = decimal.Decimal(1096.67) + kk_factor = decimal.Decimal(0.189) + kk_minimum_tax = decimal.Decimal(207.27).quantize(decimal.Decimal('0.00')) + elif date < '2023-08-01': + kk_factor = decimal.Decimal(0.191) + kk_minimum_tax = decimal.Decimal(216.15).quantize(decimal.Decimal('0.00')) + else: + kk_factor = decimal.Decimal(0.197) + kk_minimum_tax = decimal.Decimal(222.94).quantize(decimal.Decimal('0.00')) + kk_add_so_far = account_sums[acc_kk_add]['â¬'] if acc_kk_add in account_sums.keys() else 0 + kk_add = needed_income_before_kk / (1 - kk_factor) - needed_income_before_kk - kk_minimum_tax + hit_kk_minimum_income_limit = False + if kk_add_so_far + kk_add < 0: + hit_kk_minimum_income_limit = True + kk_add_uncorrect = kk_add + kk_add = -(kk_add + kk_add_so_far) + kk_add = decimal.Decimal(kk_add).quantize(decimal.Decimal('0.00')) + if finish: + ret += [f' {acc_kk_add} {-last_monthbreak_kk_add}⬠; for old assumption of needed income'] + else: + ret += [f' {acc_kk_minimum} {kk_minimum_tax}⬠; assumed minimum income {kk_minimum_income:.2f}⬠* {kk_factor:.3f}'] + if hit_kk_minimum_income_limit: + ret += [f' {acc_kk_add} {kk_add}⬠; {needed_income_before_kk:.2f}⬠/ (1 - {kk_factor:.3f}) - {needed_income_before_kk:.2f}⬠- {kk_minimum_tax}⬠= {kk_add_uncorrect:.2f}⬠would reduce current {acc_kk_dd} ({kk_add_so_far:.2f}â¬) below 0'] + else: + ret += [f' {acc_kk_add} {kk_add}⬠; {needed_income_before_kk:.2f}⬠/ (1 - {kk_factor:.3f}) - {needed_income_before_kk:.2f}⬠- {kk_minimum_tax}â¬'] + diff = - last_monthbreak_est + ESt_this_month - last_monthbreak_kk_add + kk_add + if not finish: + diff += kk_minimum_tax + final_minus = expenses_so_far - old_needed_income_before_anything + diff + ret += [f' {acc_assets} {-diff} â¬'] + ret += [f' {acc_assets} {final_minus} â¬'] + year_needed = buffer_expenses + final_minus + (12 - months_passed - 1) * final_minus + if finish: + ret += [f' {acc_buffer} {-final_minus} â¬'] + else: + ret += [f' {acc_buffer} {-final_minus} ⬠; assume as to earn in year: {acc_buffer} + {12 - months_passed - 1} * this = {year_needed}'] + return ret + + def add_mirror(self, lines): + ret = [] + bookings, _ = parse_lines(lines) + booking = bookings[0] + for line in booking.lines[1:]: + ret += [f'? {-line[1]} {line[2]}'] + return ret + + def ledger_as_html(self): + booking_tmpl = jinja2.Template(booking_html) + single_c_tmpl = jinja2.Template('{{c|e}}
{content}" + return f"
{content}" + def add_free(self, start=0, end=0, copy=False): + tmpl = jinja2.Template(add_form_header + add_free_html + add_form_footer) + lines = self.get_lines(start, end) + if copy: + start = end = 0 + return tmpl.render(action=self.prefix + '/add_free', start=start, end=end, lines=lines) -class MyServer(BaseHTTPRequestHandler): + def add_structured(self, start=0, end=0, copy=False, temp_lines=[], add_empty_line=None): + tmpl = jinja2.Template(add_form_header + add_structured_html + add_form_footer) + lines = temp_lines if len(''.join(temp_lines)) > 0 else self.get_lines(start, end) + bookings, comments = parse_lines(lines, validate_bookings=False) + if len(bookings) > 1: + raise PlomException('can only structurally edit single Booking') + if add_empty_line is not None: + comments = comments[:add_empty_line+1] + [''] + comments[add_empty_line+1:] + booking = bookings[0] + booking.lines = booking.lines[:add_empty_line+1] + [''] + booking.lines[add_empty_line+1:] + action = self.prefix + '/add_structured' + datalist_sets = {'descriptions': set(), 'accounts': set(), 'currencies': set()} + for b in self.bookings: + datalist_sets['descriptions'].add(b.description) + for account, moneys in b.account_changes.items(): + datalist_sets['accounts'].add(account) + for currency in moneys.keys(): + datalist_sets['currencies'].add(currency) + content = '' + today = str(datetime.now())[:10] + booking_lines = [] + if copy: + start = end = 0 + desc = head_comment = '' + if len(bookings) == 0: + date=today + else: + booking = bookings[0] + desc = booking.description + date = today if copy else booking.date_string + head_comment=comments[0] + for i in range(1, len(comments)): + account = amount = currency = '' + if i < len(booking.lines) and booking.lines[i] != '': + account = booking.lines[i][0] + amount = booking.lines[i][1] + currency = booking.lines[i][2] + booking_lines += [{ + 'i': i, + 'acc': account, + 'amt': amount, + 'curr': currency if currency else 'â¬', + 'comment': comments[i], + 'comm_cols': len(comments[i])}] + for i in range(len(comments), len(comments) + 8): + booking_lines += [{'i': i, 'acc': '', 'amt': '', 'curr': 'â¬', 'comment': ''}] + content += tmpl.render( + action=action, + date=date, + desc=desc, + head_comment=head_comment, + booking_lines=booking_lines, + datalist_sets=datalist_sets, + start=start, + end=end) + return content + + def move_up(self, start, end): + prev_booking = None + for redir_nth, b in enumerate(self.bookings): + if b.start_line >= start: + break + prev_booking = b + start_at = prev_booking.start_line + self.make_move(start, end, start_at) + return redir_nth - 1 + + def move_down(self, start, end): + next_booking = None + for redir_nth, b in enumerate(self.bookings): + if b.start_line > start: + next_booking = b + break + start_at = next_booking.start_line + len(next_booking.lines) - (end - start) + 1 + self.make_move(start, end, start_at-1) + return redir_nth + + def make_move(self, start, end, start_at): + lines = self.get_lines(start, end) + total_lines = self.real_lines[:start-1] + self.real_lines[end:] # +1 because we reduce the original position's two empty border lines to in-between line + self.write_lines_in_total_lines_at(total_lines, start_at, lines) + + def booking_lines_from_postvars(self, postvars): + add_empty_line = None + date = postvars['date'][0] + description = postvars['description'][0] + start_comment = postvars['line_0_comment'][0] + start_line = f'{date} {description}' + if start_comment.rstrip() != '': + start_line += f' ; {start_comment}' + lines = [start_line] + if 'line_0_add' in postvars.keys(): + add_empty_line = 0 + i = j = 1 + while f'line_{i}_comment' in postvars.keys(): + if f'line_{i}_delete' in postvars.keys(): + i += 1 + continue + elif f'line_{i}_delete_after' in postvars.keys(): + break + elif f'line_{i}_add' in postvars.keys(): + add_empty_line = j + account = postvars[f'line_{i}_account'][0] + amount = postvars[f'line_{i}_amount'][0] + currency = postvars[f'line_{i}_currency'][0] + comment = postvars[f'line_{i}_comment'][0] + i += 1 + new_main = f' {account} {amount}' + if '' == new_main.rstrip() == comment.rstrip(): # don't write empty lines, ignore currency if nothing else set + continue + if len(amount.rstrip()) > 0: + new_main += f' {currency}' + j += 1 + new_line = new_main + if comment.rstrip() != '': + new_line += f' ; {comment}' + lines += [new_line] + if 'add_sink' in postvars.keys(): + temp_lines = lines.copy() + ['_'] + try: + temp_bookings, _ = parse_lines(temp_lines) + for currency in temp_bookings[0].sink: + amount = temp_bookings[0].sink[currency] + lines += [f'Assets {amount:.2f} {currency}'] + except PlomException: + pass + elif 'add_taxes' in postvars.keys(): + lines += self.add_taxes(lines, finish=False) + elif 'add_taxes2' in postvars.keys(): + lines += self.add_taxes(lines, finish=True) + elif 'replace' in postvars.keys(): + for i, line in enumerate(lines): + lines[i] = line.replace(postvars['replace_from'][0], postvars['replace_to'][0]) + elif 'add_mirror' in postvars.keys(): + lines += self.add_mirror(lines) + return lines, add_empty_line + + + +class LedgerHandler(PlomHandler): + + def app_init(self, handler): + default_path = '/ledger' + handler.add_route('GET', default_path, self.forward_gets) + handler.add_route('POST', default_path, self.forward_posts) + return 'ledger', default_path + + def do_POST(self): + self.try_do(self.forward_posts) + + def forward_posts(self): + prefix = self.apps['ledger'] if hasattr(self, 'apps') else '' + parsed_url = urlparse(self.path) + length = int(self.headers['content-length']) + postvars = parse_qs(self.rfile.read(length).decode(), keep_blank_values=1) + start = int(postvars['start'][0]) + end = int(postvars['end'][0]) + db = LedgerDB(prefix) + add_empty_line = None + lines = [] + # get inputs + if prefix + '/add_structured' == parsed_url.path and not 'revert' in postvars.keys(): + lines, add_empty_line = db.booking_lines_from_postvars(postvars) + elif prefix + '/add_free' == parsed_url.path and not 'revert' in postvars.keys(): + lines = postvars['booking'][0].splitlines() + # validate where appropriate + if ('save' in postvars.keys()) or ('check' in postvars.keys()): + _, _ = parse_lines(lines) + # if saving, process where to and where to redirect after + if 'save' in postvars.keys(): + last_date = str(datetime.now())[:10] + if len(db.bookings) > 0: + last_date = db.bookings[-1].date_string + target_date = last_date[:] + first_line_tokens = lines[0].split() if len(lines) > 0 else '' + first_token = first_line_tokens[0] if len(first_line_tokens) > 0 else '' + try: + datetime.strptime(first_token, '%Y-%m-%d') + target_date = first_token + except ValueError: + pass + if start == end == 0: + start = db.insert_at_date(lines, target_date) + nth = db.get_nth_for_booking_of_start_line(start) + else: + new_start = db.update(start, end, lines, target_date) + nth = db.get_nth_for_booking_of_start_line(new_start) + if new_start > start: + nth -= 1 + self.redirect(prefix + f'/#{nth}') + # otherwise just re-build editing form + else: + if prefix + '/add_structured' == parsed_url.path: + edit_content = db.add_structured(start, end, temp_lines=lines, add_empty_line=add_empty_line) + else: + edit_content = db.add_free(start, end) + header = jinja2.Template(html_head).render(prefix=prefix) + self.send_HTML(header + edit_content) def do_GET(self): - self.send_response(200) - self.send_header("Content-type", "text/html") - self.end_headers() - db = Database() - # page = db.ledger_as_html() - page = db.balance_as_html() - self.wfile.write(bytes(page, "utf-8")) - - -db = Database() -if __name__ == "__main__": - webServer = HTTPServer((hostName, serverPort), MyServer) - print(f"Server started http://{hostName}:{serverPort}") - try: - webServer.serve_forever() - except KeyboardInterrupt: - pass - webServer.server_close() - print("Server stopped.") + self.try_do(self.forward_gets) + + def forward_gets(self): + prefix = self.apps['ledger'] if hasattr(self, 'apps') else '' + parsed_url = urlparse(self.path) + params = parse_qs(parsed_url.query) + start = int(params.get('start', ['0'])[0]) + end = int(params.get('end', ['0'])[0]) + db = LedgerDB(prefix=prefix) + if parsed_url.path == prefix + '/balance': + stop = params.get('stop', [None])[0] + page = db.balance_as_html(stop) + elif parsed_url.path == prefix + '/add_free': + page = db.add_free(start, end) + elif parsed_url.path == prefix + '/add_structured': + page = db.add_structured(start, end) + elif parsed_url.path == prefix + '/copy_free': + page = db.add_free(start, end, copy=True) + elif parsed_url.path == prefix + '/copy_structured': + page = db.add_structured(start, end, copy=True) + elif parsed_url.path == prefix + '/move_up': + nth = db.move_up(start, end) + self.redirect(prefix + f'/#{nth}') + return + elif parsed_url.path == prefix + '/move_down': + nth = db.move_down(start, end) + self.redirect(prefix + f'/#{nth}') + return + else: + page = db.ledger_as_html() + header = jinja2.Template(html_head).render(prefix=prefix) + self.send_HTML(header + page) + + + +if __name__ == "__main__": + run_server(server_port, LedgerHandler)