diff --git a/ledger_pyreport/__init__.py b/ledger_pyreport/__init__.py index dd47fce..3f572d5 100644 --- a/ledger_pyreport/__init__.py +++ b/ledger_pyreport/__init__.py @@ -45,7 +45,7 @@ def trial(): # Get trial balance l = ledger.raw_transactions_at_date(date) if cash: - l = accounting.cash_basis(l, report_currency) + l = accounting.ledger_to_cash(l, report_currency) trial_balance = accounting.trial_balance(l, date, pstart) trial_balance = accounting.add_unrealized_gains(trial_balance, report_currency) @@ -69,7 +69,7 @@ def trial(): l = ledger.raw_transactions_at_date(date) if cash: - l = accounting.cash_basis(l, report_currency) + l = accounting.ledger_to_cash(l, report_currency) trial_balances = [accounting.add_unrealized_gains(accounting.trial_balance(l, d, p), report_currency) for d, p in zip(dates, pstarts)] # Delete accounts with always zero balances @@ -93,7 +93,7 @@ def balance(): report_currency = Currency(*config['report_currency']) l = ledger.raw_transactions_at_date(date) if cash: - l = accounting.cash_basis(l, report_currency) + l = accounting.ledger_to_cash(l, report_currency) balance_sheets = [accounting.balance_sheet(accounting.add_unrealized_gains(accounting.trial_balance(l, d, p), report_currency)) for d, p in zip(dates, pstarts)] # Delete accounts with always zero balances @@ -125,7 +125,7 @@ def pandl(): report_currency = Currency(*config['report_currency']) l = ledger.raw_transactions_at_date(date_end) if cash: - l = accounting.cash_basis(l, report_currency) + l = accounting.ledger_to_cash(l, report_currency) pandls = [accounting.trial_balance(l, de, db) for de, db in zip(dates_end, dates_beg)] # Delete accounts with always zero balances @@ -148,7 +148,7 @@ def transactions(): # General ledger l = ledger.raw_transactions_at_date(date) if cash: - l = accounting.cash_basis(l, report_currency) + l = accounting.ledger_to_cash(l, report_currency) # Unrealized gains l = accounting.add_unrealized_gains(accounting.trial_balance(l, date, pstart), report_currency).ledger @@ -159,7 +159,7 @@ def transactions(): total_dr = sum((p.amount for t in transactions for p in t.postings if p.amount > 0), Balance()).exchange(report_currency, True) total_cr = sum((p.amount for t in transactions for p in t.postings if p.amount < 0), Balance()).exchange(report_currency, True) - return flask.render_template('transactions.html', date=date, pstart=pstart, account=None, ledger=l, transactions=transactions, total_dr=total_dr, total_cr=total_cr, report_currency=report_currency, cash=cash) + return flask.render_template('transactions.html', date=date, pstart=pstart, period=describe_period(date, pstart), account=None, ledger=l, transactions=transactions, total_dr=total_dr, total_cr=total_cr, report_currency=report_currency, cash=cash) else: account = l.get_account(account) transactions = [t for t in l.transactions if t.date <= date and t.date >= pstart and any(p.account == account for p in t.postings)] @@ -169,6 +169,8 @@ def transactions(): return flask.render_template('transactions.html', date=date, pstart=pstart, period=describe_period(date, pstart), account=account, ledger=l, transactions=transactions, opening_balance=opening_balance, closing_balance=closing_balance, report_currency=report_currency, cash=cash, timedelta=timedelta) +# Template filters + @app.template_filter('a') def filter_amount(amt): if amt.amount < 0.005 and amt.amount >= -0.005: @@ -186,3 +188,41 @@ def filter_amount_positive(amt): @app.template_filter('bb') def filter_balance_positive(balance): return flask.Markup('
'.join(filter_amount_positive(a) for a in balance.amounts)) + +# Debug views + +@app.route('/debug/noncash_transactions') +def debug_noncash_transactions(): + date = datetime.strptime(flask.request.args['date'], '%Y-%m-%d') + pstart = datetime.strptime(flask.request.args['pstart'], '%Y-%m-%d') + account = flask.request.args.get('account') + + report_currency = Currency(*config['report_currency']) + + l = ledger.raw_transactions_at_date(date) + account = l.get_account(account) + + transactions = [t for t in l.transactions if any(p.account == account for p in t.postings)] + + accounting.account_to_cash(account, report_currency) + + return flask.render_template('debug_noncash_transactions.html', date=date, pstart=pstart, period=describe_period(date, pstart), account=account, ledger=l, transactions=transactions, report_currency=report_currency) + +@app.route('/debug/imbalances') +def debug_imbalances(): + date = datetime.strptime(flask.request.args['date'], '%Y-%m-%d') + pstart = datetime.strptime(flask.request.args['pstart'], '%Y-%m-%d') + cash = flask.request.args.get('cash', False) + + report_currency = Currency(*config['report_currency']) + + l = ledger.raw_transactions_at_date(date) + if cash: + l = accounting.ledger_to_cash(l, report_currency) + + transactions = [t for t in l.transactions if t.date <= date and t.date >= pstart and abs(sum((p.amount for p in t.postings), Balance()).exchange(report_currency, True).amount) > 0.005] + + total_dr = sum((p.amount for t in transactions for p in t.postings if p.amount > 0), Balance()).exchange(report_currency, True) + total_cr = sum((p.amount for t in transactions for p in t.postings if p.amount < 0), Balance()).exchange(report_currency, True) + + return flask.render_template('transactions.html', date=date, pstart=pstart, period=describe_period(date, pstart), account=None, ledger=l, transactions=transactions, total_dr=total_dr, total_cr=total_cr, report_currency=report_currency, cash=cash) diff --git a/ledger_pyreport/accounting.py b/ledger_pyreport/accounting.py index 3a51169..4aa335a 100644 --- a/ledger_pyreport/accounting.py +++ b/ledger_pyreport/accounting.py @@ -16,6 +16,7 @@ import csv from decimal import Decimal +import math from .model import * @@ -65,27 +66,64 @@ def balance_sheet(tb): return tb -# Adjust (in place) a ledger to convert accounting to a cash basis -def cash_basis(ledger, currency): - for transaction in ledger.transactions: - non_cash_postings = [p for p in transaction.postings if not (p.account.is_cash or p.account.is_income or p.account.is_expense or p.account.is_equity)] - - if non_cash_postings: - # We have liabilities or non-cash assets which need to be excluded - - cash_postings = [p for p in transaction.postings if p.account.is_income or p.account.is_expense or p.account.is_equity] - cash_total = sum((p.amount for p in cash_postings), Balance()).exchange(currency, True).amount - - if cash_postings: - for posting in non_cash_postings: - posting_amount = posting.amount.exchange(currency, True).amount - for posting_xfer in cash_postings: - posting_xfer_amount = posting_xfer.amount.exchange(currency, True).amount - transaction.postings.append(Posting(transaction, posting_xfer.account, Amount(posting_amount * posting_xfer_amount / cash_total, currency))) +def account_to_cash(account, currency): + # Apply FIFO methodology to match postings + balance = [] # list of [posting, amount to balance, amount remaining, balancing list of [posting, amount balanced]] + + for transaction in account.ledger.transactions[:]: + if any(p.account == account for p in transaction.postings): + for posting in transaction.postings[:]: + if posting.account == account: + #transaction.postings.remove(posting) + pass + else: + # Try to balance postings + amount_to_balance = posting.amount.exchange(currency, True).amount - transaction.postings.remove(posting) + while amount_to_balance != 0: + balancing_posting = next((b for b in balance if b[2] != 0 and math.copysign(1, b[2]) != math.copysign(1, amount_to_balance)), None) + if balancing_posting is None: + break + + if abs(balancing_posting[2]) >= abs(amount_to_balance): + balancing_posting[3].append([posting, amount_to_balance]) + balancing_posting[2] += amount_to_balance + amount_to_balance = Decimal(0) + break + else: + balancing_posting[3].append([posting, -balancing_posting[2]]) + amount_to_balance += balancing_posting[2] + balancing_posting[2] = Decimal(0) + + if amount_to_balance != 0: + # New unbalanced remainder + balance.append([posting, amount_to_balance, amount_to_balance, []]) + + transaction.postings = [] + + # Finalise balanced postings + for orig_posting, amount_to_balance, amount_remaining, balancing_postings in balance: + posting = Posting(orig_posting.transaction, orig_posting.account, Amount(amount_to_balance, currency)) + posting.transaction.postings.append(posting) + + for balancing_posting, amount_balanced in balancing_postings: + posting.transaction.postings.append(Posting(posting.transaction, balancing_posting.account, Amount(amount_balanced, currency))) + + if balancing_posting in balancing_posting.transaction.postings: + balancing_posting.transaction.postings.remove(balancing_posting) + + if amount_remaining != 0: + if account.is_asset: + # Cash - charge any unbalanced remainder to Other Income + posting.transaction.postings.append(Posting(posting.transaction, account.ledger.get_account(config['cash_other_income']), Amount(-amount_remaining, currency))) else: - for posting in non_cash_postings: - posting.account = ledger.get_account(config['cash_other_income']) + # Liabilities, etc. - discard any unbalanced remainder + posting.amount.amount -= amount_remaining + +# Adjust (in place) a ledger to convert accounting to a cash basis +def ledger_to_cash(ledger, currency): + for account in list(ledger.accounts.values()): + if not (account.is_cash or account.is_income or account.is_expense or account.is_equity): + account_to_cash(account, currency) return ledger diff --git a/ledger_pyreport/jinja2/debug_noncash_transactions.html b/ledger_pyreport/jinja2/debug_noncash_transactions.html new file mode 100644 index 0000000..a5202d0 --- /dev/null +++ b/ledger_pyreport/jinja2/debug_noncash_transactions.html @@ -0,0 +1,50 @@ +{# + ledger-pyreport + Copyright © 2020 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +#} + +{% extends 'base_report.html' %} + +{% block title %}Account Transactions as at {{ date.strftime('%d %B %Y') }}{% endblock %} + +{% block report %} +

Account Transactions

+

For {{ account.name }}

+

For the {{ period }}

+ + + + + + + + + + + {% for transaction in transactions %} + {% for posting in transaction.postings %} + {% set amount = posting.exchange(report_currency, transaction.date) %} + + + + + + + + {% endfor %} + {% endfor %} +
DateDescriptionAccountDrCr
{% if loop.first %}{{ transaction.date.strftime('%Y-%m-%d') }}{% endif %}{% if loop.first %}{{ transaction.description }}{% endif %}{{ (posting.account.name|e).__str__().replace(':', ':')|safe }}{% if amount > 0 %}{{ amount|b }}{% endif %}{% if amount < 0 %}{{ -amount|b }}{% endif %}
+{% endblock %}