#!/usr/bin/env python3 from beancount.core import data from beancount.core import account from beancount.core import getters from beancount.core import amount from beancount.core import account_types from beancount.core import realization from beancount.core.number import D from beancount.parser import options __plugins__ = ('joint_account_plugin',) # DEBT_ACCOUNT = "Deute" SHARE_ACCOUNT = "Part" def debt_account_str(acc: str, debtor: str, accs: account_types.AccountTypes) -> str: return account.join(accs.liabilities, account.sans_root(acc), debtor) def share_account_str(acc: str, debtor: str) -> str: # return ":".join((account, SHARE_ACCOUNT + "-" + debtor)) return acc # reimbursment in same account def debt_account(posting: data.Posting, debtor: str, joint_accounts: dict, accs: account_types.AccountTypes) -> str: if debtor in joint_accounts.get(posting.account, {}): return debt_account_str(posting.account, debtor, accs) if account_types.is_income_statement_account(posting.account, accs): return share_account_str(posting.account, debtor) return "" def is_splitable(posting: data.Posting, accs: account_types.AccountTypes): if posting.meta.get("joint", "") == "nosplit": return False return account_types.is_income_statement_account(posting.account, accs) def divide_and_flip(posting: data.Posting, n: int = 2) -> data.Posting: """Return a new posting with n-th the amount and opposite sign.""" assert(n>0) old_amount = posting.units new_amount = amount.div(old_amount, D(-n)) return posting._replace(units=new_amount) def add_tag(txn: data.Transaction, tag: str) -> data.Transaction: return txn._replace(tags=txn.tags | set([tag])) def prepare_posting(posting, user, joint_accounts, accs, n): return divide_and_flip(posting._replace(account=debt_account(posting, user, joint_accounts, accs), flag=None, meta=None), n) def split_among_users(postings, users, joint_accounts, accs): new_postings = [] for user in users: for posting in postings: new_postings.append(prepare_posting(posting, user, joint_accounts, accs, 1 + len(users))) return new_postings def debt_transaction(txn: data.Transaction, joint_accounts: dict, new_accounts: set, accs: account_types.AccountTypes) -> None | data.Transaction: # Only two cases are processed: # a) all accounts are joint with the same people # b) a single joint account with splitable/unsplitable accounts # get how many joint accounts there are user_sets = [(idx, set(joint_accounts[posting.account])) for idx, posting in enumerate(txn.postings) if posting.account in joint_accounts] new_postings = [] # no joint account -> nothing to do if len(user_sets) == 0: return None # single joint account, assume all expenses/income are split equally between users of the joint account elif len(user_sets) == 1: splitable_postings = [posting for posting in txn.postings if is_splitable(posting, accs)] if len(splitable_postings) == 0: return None # as not every posting may be splittable, debt of joint account might need to be rebalanced new_balance = realization.compute_postings_balance(splitable_postings) joint_posting = txn.postings[user_sets[0][0]] joint_posting = joint_posting._replace(units=-new_balance.get_currency_units(joint_posting.units.currency)) new_postings = splitable_postings + [joint_posting] # all postings are from joint accounts, check they share same users and transfer debt equally elif len(user_sets) == len(txn.postings): # check all joint accounts share same users total_set = set().union(*map(lambda s: s[1], user_sets)) if any((total_set != s[1] for s in user_sets)): return None new_postings = txn.postings # just transfer debt equally new_postings = split_among_users(new_postings, user_sets[0][1], joint_accounts, accs) new_accounts |= set((p.account for p in new_postings)) return add_tag(txn._replace(postings=new_postings), "retorn") def joint_account_plugin(entries, options_map): errors = [] new_entries = [] new_accounts = set() accs = options.get_account_types(options_map) joint_accounts = {} for entry in entries: if isinstance(entry, data.Open): if "joint" in entry.meta: joint_accounts[entry.account] = entry.meta["joint"].strip().split(" ") for entry in entries: if isinstance(entry, data.Transaction): if any((acc in joint_accounts for acc in getters.get_accounts([entry]))): new_entry = debt_transaction(entry, joint_accounts, new_accounts, accs) if new_entry is not None: new_entries.append(new_entry) new_entries.append(entry) # add new_accounts at earliest date possible earliest_date = getters.get_min_max_dates(new_entries)[0] new_accounts -= set(getters.get_accounts((e for e in entries if isinstance(e, data.Open)))) for idx, acc in enumerate(new_accounts): entry = data.Open(data.new_metadata("", idx), earliest_date, acc, None, None) new_entries.append(entry) # close associated accounts for entry in entries: if isinstance(entry, data.Close): for user in joint_accounts.get(entry.account, []): e = entry._replace(account=debt_account_str(entry.account, user, accs)) new_entries.append(e) return new_entries, [] # def change_to_total_account(txn: data.Transaction, joint_accounts) -> data.Transaction: # return txn._replace(postings=list(map(lambda p: change_to_total_account_posting(p, joint_accounts), txn.postings))) # def change_to_total_account_posting(posting: data.Posting, joint_accounts) -> data.Posting: # return posting._replace(account=total_account_str(posting.account) if posting.account in joint_accounts else posting.account) # def transform_accounts(entry, joint_accounts): # if hasattr(entry, "account"): # if entry.account in joint_accounts: # return entry._replace(account=total_account_str(entry.account)) # elif isinstance(entry, data.Transaction): # return change_to_total_account(entry, joint_accounts) # return entry