+ if not transfer_line.account in self.account_changes.keys():
+ self.account_changes[transfer_line.account] = Wealth()
+ money = {transfer_line.currency: transfer_line.amount}
+ self.account_changes[transfer_line.account] += money
+ money_changes += money
+ if sink_account is None and (not money_changes.sink_empty) and self.validate:
+ raise PlomException(f'{intro}: does not balance (undeclared non-empty sink)')
+ if sink_account is not None:
+ if not sink_account in self.account_changes.keys():
+ self.account_changes[sink_account] = Wealth()
+ self.account_changes[sink_account] += money_changes.as_sink
+
+ @property
+ def for_writing(self):
+ lines = [f'{self.date} {self.description}']
+ if self.top_comment is not None and self.top_comment.rstrip() != '':
+ lines[0] += f' ; {self.top_comment}'
+ for line in self.transfer_lines:
+ lines += [line.for_writing]
+ return lines
+
+ @property
+ def comment_cols(self):
+ return max(20, len(self.top_comment))
+
+ def validate_head(self):
+ if not self.validate:
+ return
+ if len(self.date) == 0:
+ raise PlomException(f'{self.intro}: missing date')
+ if len(self.description) == 0:
+ raise PlomException(f'{self.intro}: missing description')
+ try:
+ datetime.strptime(self.date, '%Y-%m-%d')
+ except ValueError:
+ raise PlomException(f'{self.intro}: bad headline date format: {self.date}')
+
+ def fill_sink(self):
+ replacement_lines = []
+ for i, line in enumerate(self.transfer_lines):
+ if line.amount is None:
+ for currency, amount in self.account_changes[line.account].money_dict.items():
+ replacement_lines += [TransferLine(None, f'{line.account}', amount, currency).for_writing]
+ break
+ lines = self.lines[:i+1] + [LedgerTextLine(l) for l in replacement_lines] + self.lines[i+2:]
+ self.clean()
+ self.lines = lines
+ self.parse_lines()
+
+ def mirror(self):
+ new_transfer_lines = []
+ for transfer_line in self.transfer_lines:
+ uncommented_source = LedgerTextLine(transfer_line.for_writing).non_comment
+ comment = f're: {uncommented_source.lstrip()}'
+ new_account = '?'
+ new_transfer_lines += [TransferLine(None, new_account, -transfer_line.amount, transfer_line.currency, comment)]
+ for transfer_line in new_transfer_lines:
+ self.lines += [LedgerTextLine(transfer_line.for_writing)]
+ self.clean()
+ self.parse_lines()
+
+ def replace(self, replace_from, replace_to):
+ lines = []
+ for l in self.for_writing:
+ lines += [l.replace(replace_from, replace_to)]
+ self.lines = [LedgerTextLine(l) for l in lines]
+ self.clean()
+ self.parse_lines()
+
+ def add_taxes(self, db):
+ acc_kk_add = 'Reserves:KrankenkassenBeitragsWachstum'
+ acc_kk_minimum = 'Reserves:Monthly:KrankenkassenDefaultBeitrag'
+ acc_kk = 'Expenses:KrankenKasse'
+ acc_ESt = 'Reserves:EinkommensSteuer'
+ acc_assets = 'Assets'
+ acc_neuanfangspuffer_expenses = 'Reserves:NeuAnfangsPuffer:Ausgaben'
+ months_passed = datetime.strptime(self.date, '%Y-%m-%d').month - 1
+ past_kk_expenses = 0
+ past_kk_add = 0
+ past_neuanfangspuffer_expenses = 0
+ for b in db.bookings:
+ if b.date == self.date:
+ break
+ if acc_neuanfangspuffer_expenses in b.account_changes.keys():
+ past_neuanfangspuffer_expenses -= b.account_changes[acc_neuanfangspuffer_expenses].money_dict['€']
+ if acc_kk_add in b.account_changes.keys():
+ past_kk_add += b.account_changes[acc_kk_add].money_dict['€']
+ if acc_kk_minimum in b.account_changes.keys():
+ past_kk_expenses += b.account_changes[acc_kk_minimum].money_dict['€']
+
+ needed_netto = -self.account_changes['Assets'].money_dict['€']
+ past_taxed_needs_before_kk = past_neuanfangspuffer_expenses - past_kk_expenses
+ ESt_this_month = 0
+ E0 = decimal.Decimal(11604)
+ E1 = decimal.Decimal(17006)
+ E2 = decimal.Decimal(66761)
+ E3 = decimal.Decimal(277826)
+ taxed_income_before_kk = needed_netto
+ too_low = 0
+ too_high = 2 * needed_netto
+ while True:
+ estimate_for_remaining_year = (12 - months_passed) * taxed_income_before_kk
+ zvE = past_taxed_needs_before_kk + estimate_for_remaining_year
+ if zvE < E0:
+ ESt_year = decimal.Decimal(0)
+ elif zvE < E1:
+ y = (zvE - E0)/10000
+ ESt_year = (decimal.Decimal(922.98) * y + 1400) * y
+ elif zvE < E2:
+ y = (zvE - E1)/10000
+ ESt_year = (decimal.Decimal(181.19) * y + 2397) * y + decimal.Decimal(1025.38)
+ elif zvE < E3:
+ ESt_year = decimal.Decimal(0.42) * zvE - 10602.13
+ else:
+ ESt_year = decimal.Decimal(0.45) * zvE - 18936.88
+ ESt_this_month = ESt_year / 12
+ taxed_income_minus_ESt = taxed_income_before_kk - ESt_this_month
+ if abs(taxed_income_minus_ESt - needed_netto) < 0.001:
+ break
+ elif taxed_income_minus_ESt < needed_netto:
+ too_low = taxed_income_before_kk
+ elif taxed_income_minus_ESt > needed_netto:
+ too_high = taxed_income_before_kk
+ taxed_income_before_kk = too_low + (too_high - too_low)/2
+ ESt_this_month = ESt_this_month.quantize(decimal.Decimal('0.00'))
+ comment = f'estimated zvE: {past_taxed_needs_before_kk}€ + {estimate_for_remaining_year:.2f}€ = {zvE:.2f}€ → year ESt: {ESt_year:.2f} → needed taxed income before Krankenkasse: {taxed_income_before_kk:.2f}€'
+ self.transfer_lines += [TransferLine(None, acc_ESt, ESt_this_month, '€', comment)]
+
+ kk_factor = decimal.Decimal(1.197)
+ kk_minimum_income = 1178.33
+ kk_minimum_tax = decimal.Decimal(232.13).quantize(decimal.Decimal('0.00'))
+ if self.date < '2024-02-01':
+ kk_minimum_income = 1131.67
+ kk_minimum_tax = decimal.Decimal(222.94).quantize(decimal.Decimal('0.00'))
+ comment = f'assumed minimum income of {kk_minimum_income:.2f}€ * {kk_factor:.3f}'
+ self.transfer_lines += [TransferLine(None, acc_kk_minimum, kk_minimum_tax, '€', comment)]
+ kk_add = taxed_income_before_kk * kk_factor - taxed_income_before_kk - kk_minimum_tax
+ kk_add = decimal.Decimal(kk_add).quantize(decimal.Decimal('0.00'))
+ if past_kk_add + kk_add < 0: # *if* kk_add would actually kill all earlier kk_add …
+ kk_add = - past_kk_add # … shrink it so it won't push the kk_add total below 0
+ comment = f'local negative as large as possible without moving {acc_kk_add} below zero'
+ else:
+ comment = f'({taxed_income_before_kk:.2f}€ * {kk_factor:.3f}) - {taxed_income_before_kk:.2f} - {kk_minimum_tax}'
+ self.transfer_lines += [TransferLine(None, acc_kk_add, kk_add, '€', comment)]
+
+ diff_through_taxes_and_kk = ESt_this_month + kk_minimum_tax + kk_add
+ comment = f'{ESt_this_month} + {kk_minimum_tax} + {kk_add}'
+ self.transfer_lines += [TransferLine(None, acc_assets, -diff_through_taxes_and_kk, '€', comment)]
+ final_loss = diff_through_taxes_and_kk + needed_netto
+ comment = f'{needed_netto} + {diff_through_taxes_and_kk}'
+ self.transfer_lines += [TransferLine(None, acc_assets, final_loss, '€', comment)]
+ self.transfer_lines += [TransferLine(None, acc_neuanfangspuffer_expenses, -final_loss, '€')]
+
+
+class LedgerDB(PlomDB):
+
+ def __init__(self, prefix, ignore_editable_exceptions=False):
+ self.prefix = prefix
+ self.bookings = []
+ self.comments = []
+ self.text_lines = []
+ super().__init__(db_path)
+ self.bookings = parse_lines_to_bookings(self.text_lines, ignore_editable_exceptions)
+
+ def read_db_file(self, f):
+ self.text_lines += f.readlines()
+
+ def insert_booking_at_date(self, booking):
+ place_at = 0
+ if len(self.bookings) > 0:
+ for i, iterated_booking in enumerate(self.bookings):
+ if booking.date < iterated_booking.date:
+ break
+ elif booking.date == iterated_booking.date:
+ place_at = i
+ break
+ else:
+ place_at = i + 1
+ self.bookings.insert(place_at, booking)
+
+ def ledger_as_html(self):
+ for index, booking in enumerate(self.bookings):
+ booking.can_up = index > 0 and self.bookings[index - 1].date == booking.date
+ booking.can_down = index < len(self.bookings) - 1 and self.bookings[index + 1].date == booking.date
+ return j2env.get_template('ledger.html').render(bookings=self.bookings)
+
+ def balance_as_html(self, until_after=None):
+ bookings = self.bookings[:(until_after if until_after is None else int(until_after)+1)]
+ account_trunk = Account('', None)
+ accounts = {account_trunk.full_name: account_trunk}
+ for booking in bookings:
+ for full_account_name, moneys in booking.account_changes.items():
+ toks = full_account_name.split(':')
+ path = []
+ for tok in toks:
+ parent_name = ':'.join(path)
+ path += [tok]
+ account_name = ':'.join(path)
+ if not account_name in accounts.keys():
+ accounts[account_name] = Account(own_name=tok, parent=accounts[parent_name])
+ accounts[full_account_name].add_wealth(moneys)
+ class Node:
+ def __init__(self, indent, name, moneys):
+ self.indent = indent
+ self.name = name
+ self.moneys = moneys.money_dict
+ nodes = []
+ def walk_tree(nodes, indent, account):
+ nodes += [Node(indent, account.own_name, account.full_moneys)]
+ for child in account.children:
+ walk_tree(nodes, indent+1, child)
+ for acc in account_trunk.children:
+ walk_tree(nodes, 0, acc)
+ return j2env.get_template('balance.html').render(nodes=nodes)
+
+ def edit(self, index, sent=None, error_msg=None, edit_mode='table', copy=False):
+ accounts = set()
+ if sent or -1 == index:
+ content = sent if sent else ([] if 'textarea'==edit_mode else None)
+ else:
+ content = self.bookings[index]
+ date_today = str(datetime.now())[:10]
+ if copy:
+ content.date = date_today
+ elif -1 == index and (content is None or [] == content):
+ content = Booking(date=date_today, validate=False)
+ if 'textarea' == edit_mode and content:
+ content = content.for_writing
+ else:
+ for booking in self.bookings:
+ for transfer_line in booking.transfer_lines:
+ accounts.add(transfer_line.account)
+ return j2env.get_template('edit.html').render(content=content, index=index, error_msg=error_msg, edit_mode=edit_mode, accounts=accounts, adding=(copy or -1 == index))
+
+ def move_up(self, index):
+ return self.move(index, -1)
+
+ def move_down(self, index):
+ return self.move(index, +1)
+
+ def move(self, index, direction):
+ to_move = self.bookings[index]
+ swap_index = index + 1*(direction)
+ to_swap = self.bookings[swap_index]
+ self.bookings[index] = to_swap
+ self.bookings[index + 1*(direction)] = to_move
+ return swap_index
+
+ def write_db(self):