X-Git-Url: https://plomlompom.com/repos/do_day?a=blobdiff_plain;f=ledger.py;h=0785b1a7559869c074080b0775d16f1a81c2cb3a;hb=3edadb6c0b977d0362ca8344303b3260184bebae;hp=c3f0ab9c22d028d92b1c2741e7c411a4d9efa499;hpb=2deeead813fbdf0fec4f94d238a43fd93514f94f;p=misc diff --git a/ledger.py b/ledger.py index c3f0ab9..0785b1a 100755 --- a/ledger.py +++ b/ledger.py @@ -1,6 +1,10 @@ from http.server import BaseHTTPRequestHandler, HTTPServer import sys import os +import html +import jinja2 +import decimal +from urllib.parse import parse_qs, urlparse hostName = "localhost" serverPort = 8082 @@ -23,15 +27,114 @@ def apply_booking_to_account_balances(account_sums, account, currency, amount): account_sums[account][currency] += amount +def add_taxes(lines): + bookings, _ = parse_lines(lines) + _, account_sums = bookings_to_account_tree(bookings) + expenses_so_far = -1 * account_sums['Assets']['â¬'] + needed_income_before_krankenkasse = expenses_so_far + ESt_this_month = 0 + left_over = needed_income_before_krankenkasse - ESt_this_month + too_low = 0 + too_high = 2 * needed_income_before_krankenkasse + E0 = decimal.Decimal(10908) + E1 = decimal.Decimal(15999) + E2 = decimal.Decimal(62809) + E3 = decimal.Decimal(277825) + while True: + zvE = 12 * needed_income_before_krankenkasse + 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: + ESt = decimal.Decimal(0.45) * (zvE - decimal.Decimal(277825)) + decimal.Decimal(106713.52) + ESt_this_month = ESt / 12 + left_over = needed_income_before_krankenkasse - ESt_this_month + if abs(left_over - expenses_so_far) < 0.001: + break + elif left_over < expenses_so_far: + too_low = needed_income_before_krankenkasse + elif left_over > expenses_so_far: + too_high = needed_income_before_krankenkasse + needed_income_before_krankenkasse = too_low + (too_high - too_low)/2 + ESt_this_month = ESt_this_month.quantize(decimal.Decimal('0.00')) + line_income_tax = f' Reserves:Einkommenssteuer {ESt_this_month}⬠; expenses so far: {expenses_so_far:.2f}â¬; zvE: {zvE:.2f}â¬; ESt total: {ESt:.2f}â¬; needed before Krankenkasse: {needed_income_before_krankenkasse:.2f}â¬' + 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')) + # kk_minimum_income = 1131.67 + # kk_factor = decimal.Decimal(0.191) + # kk_minimum_tax = decimal.Decimal(216.15) + # kk_factor = decimal.Decimal(0.197) + # kk_minimum_tax = decimal.Decimal(222.94) + kk_add = max(0, kk_factor * needed_income_before_krankenkasse - kk_minimum_tax) + kk_add = decimal.Decimal(kk_add).quantize(decimal.Decimal('0.00')) + line_kk_minimum = f' Reserves:Month:Krankenkassendefaultbeitrag {kk_minimum_tax}⬠; assumed minimum income {kk_minimum_income:.2f}⬠* {kk_factor:.3f}' + line_kk_add = f' Reserves:Month:Krankenkassenbeitragswachstum {kk_add}⬠; max(0, {kk_factor:.3f} * {needed_income_before_krankenkasse:.2f}⬠- {kk_minimum_tax}â¬)' + final_minus = expenses_so_far + ESt_this_month + kk_minimum_tax + kk_add + line_finish = f' Assets -{ESt_this_month + kk_minimum_tax + kk_add} ⬠; -{final_minus}â¬' + return [line_income_tax, line_kk_minimum, line_kk_add, line_finish] + + +def bookings_to_account_tree(bookings): + account_sums = {} + for booking in 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] + 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 + 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]) + return account_tree, account_sums + + def parse_lines(lines): import datetime - import decimal inside_booking = False date_string, description = None, None booking_lines = [] start_line = 0 bookings = [] comments = [] + lines = lines.copy() + [''] # to ensure a booking-ending last line for i, line in enumerate(lines): prefix = f"line {i}" # we start with the case of an utterly empty line @@ -52,7 +155,7 @@ def parse_lines(lines): # if non-empty line, first get comment if any, and commit to DB split_by_comment = stripped_line.split(sep=";", maxsplit=1) if len(split_by_comment) == 2: - comments[i] = split_by_comment[1] + comments[i] = split_by_comment[1].lstrip() # 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 non_comment = split_by_comment[0].rstrip() if non_comment.rstrip() == '': @@ -73,6 +176,7 @@ def parse_lines(lines): except IndexError: raise HandledException(f"{prefix} bad description: {description}") inside_booking = True + booking_lines += [non_comment] continue # otherwise, read as transfer data toks = non_comment.split() # ignore specification's allowance of single spaces in names @@ -142,6 +246,7 @@ def parse_lines(lines): raise HandledException(f"{prefix} last booking unfinished") return bookings, comments + class Booking: def __init__(self, date_string, description, booking_lines, start_line): @@ -150,13 +255,14 @@ class Booking: self.lines = booking_lines self.start_line = start_line self.validate_booking_lines() + self.sink = {} self.account_changes = self.parse_booking_lines_to_account_changes() def validate_booking_lines(self): prefix = f"booking at line {self.start_line}" sums = {} empty_values = 0 - for line in self.lines: + for line in self.lines[1:]: if line == '': continue _, amount, currency = line @@ -171,7 +277,7 @@ class Booking: if empty_values == 0: for k, v in sums.items(): if v != 0: - raise HandledException(f"{prefix} does not sum up to zero") + raise HandledException(f"{prefix} does not add up to zero") else: sinkable = False for k, v in sums.items(): @@ -184,7 +290,7 @@ class Booking: account_changes = {} debt = {} sink_account = None - for line in self.lines: + for line in self.lines[1:]: if line == '': continue account, amount, currency = line @@ -199,23 +305,43 @@ class Booking: if sink_account: for currency, amount in debt.items(): apply_booking_to_account_balances(account_changes, sink_account, currency, -amount) + self.sink[currency] = -amount return account_changes class Database: - def __init__(self, load_from_file=True): + def __init__(self): db_name = "_ledger" self.db_file = db_name + ".json" self.lock_file = db_name+ ".lock" self.bookings = [] self.comments = [] - if load_from_file and os.path.exists(self.db_file): + self.real_lines = [] + if os.path.exists(self.db_file): with open(self.db_file, "r") as f: - ret = parse_lines(f.readlines()) - self.bookings += ret[0] - self.comments += ret[1] + self.real_lines += [l.rstrip() for l in f.readlines()] + ret = parse_lines(self.real_lines) + self.bookings += ret[0] + self.comments += ret[1] + + def get_lines(self, start, end): + return self.real_lines[start:end] + + def replace(self, start, end, lines): + import shutil + if os.path.exists(self.lock_file): + raise HandledException('Sorry, lock file!') + if os.path.exists(self.db_file): + shutil.copy(self.db_file, self.db_file + ".bak") + f = open(self.lock_file, 'w+') + f.close() + total_lines = self.real_lines[:start] + lines + self.real_lines[end:] + text = '\n'.join(total_lines) + with open(self.db_file, 'w') as f: + f.write(text); + os.remove(self.lock_file) def append(self, lines): import shutil @@ -226,30 +352,93 @@ class Database: f = open(self.lock_file, 'w+') f.close() with open(self.db_file, 'a') as f: - f.write('\n' + '\n'.join(lines) + '\n'); + f.write('\n\n' + '\n'.join(lines) + '\n\n'); os.remove(self.lock_file) class MyServer(BaseHTTPRequestHandler): - header = '
ledger balance add{content}" + def ledger2_as_html(self, db): + single_c_tmpl = jinja2.Template('{{c|e}}
{{date}} {{desc}} {{head_comment|e}} +[edit: structured +/ free +| copy:structured +/ free] +
{{l.acc|e}} | {{l.money|e}} | {{l.balance|e}} |
{{date}} {{desc}} {{head_comment|e}} +[edit: structured +/ free +| copy:structured +/ free] +
{{l.acc|e}} | {{l.money|e}} | {{l.comment|e}} |
{{l.comment|e}} |
{content}" + account = booking_line[0] + money = '' + if booking_line[1] is not None: + money = f'{booking_line[1]} {booking_line[2]}' + booking_lines += [{'acc': booking_line[0], 'money':money, 'comment':comment}] + elements_to_write += [booking_tmpl.render( + start=booking.start_line, + end=booking_end, + date=booking.date_string, + desc=booking.description, + head_comment=db.comments[booking.start_line], + booking_lines = booking_lines)] + elements_to_write += [single_c_tmpl.render(c=c) for c in db.comments[last_i:] if c != ''] + return '\n'.join(elements_to_write) - def add(self): - return '' + def add_free(self, db, start=0, end=0, copy=False): + tmpl = jinja2.Template(""" + +""") + lines = db.get_lines(start, end) + if copy: + start = end = 0 + return tmpl.render(start=start, end=end, lines=lines) + + def add_structured(self, db, start=0, end=0, copy=False, temp_lines=[], add_empty_line=None): + tmpl = jinja2.Template(""" + +""") + import datetime + lines = temp_lines if len(''.join(temp_lines)) > 0 else db.get_lines(start, end) + bookings, comments = parse_lines(lines) + if len(bookings) > 1: + raise HandledException('can only 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 = 'add_structured' + datalist_sets = {'descriptions': set(), 'accounts': set(), 'currencies': set()} + for b in db.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.datetime.now())[:10] + booking_lines = [] + if copy: + start = end = 0 + desc = head_comment = '' + if len(bookings) == 0: + for i in range(1, 3): + booking_lines += [{'i': i, 'acc': '', 'amt': '', 'curr': '', 'comment': ''}] + date=today + else: + booking = bookings[0] + desc = booking.description + date = today if copy else booking.date_string + head_comment=comments[0] + last_line = len(comments) + 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])}] + 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 -db = Database() -if __name__ == "__main__": +if __name__ == "__main__": webServer = HTTPServer((hostName, serverPort), MyServer) print(f"Server started http://{hostName}:{serverPort}") try: