From 454801cd63dc8c4ab1ce7f2f5f5c14dbcb4d1643 Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Thu, 6 Aug 2020 03:00:21 +1000 Subject: [PATCH] Add feature to split transaction into balanced pairs Might be useful later for cash basis --- ledger_pyreport/__init__.py | 10 +++- ledger_pyreport/jinja2/transaction.html | 3 +- .../jinja2/transaction_commodity.html | 5 ++ ledger_pyreport/model.py | 47 +++++++++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/ledger_pyreport/__init__.py b/ledger_pyreport/__init__.py index 9bbab30..268b235 100644 --- a/ledger_pyreport/__init__.py +++ b/ledger_pyreport/__init__.py @@ -309,6 +309,7 @@ def transaction(): uuid = flask.request.args['uuid'] cash = flask.request.args.get('cash', False) commodity = flask.request.args.get('commodity', False) + split = flask.request.args.get('split', False) # General ledger l = ledger.raw_transactions_at_date(None) @@ -320,15 +321,20 @@ def transaction(): transaction = next((t for t in l.transactions if str(t.uuid) == uuid)) + if split: + postings = transaction.split(report_commodity) + transaction = Transaction(l, transaction.id, transaction.date, transaction.description, transaction.code, transaction.uuid) + transaction.postings = [p for r in postings for p in r] + if commodity: total_dr = sum((p.amount for p in transaction.postings if p.amount > 0), Balance()).clean() total_cr = sum((p.amount for p in transaction.postings if p.amount < 0), Balance()).clean() totals = itertools.zip_longest(total_dr.amounts, total_cr.amounts) - return flask.render_template('transaction_commodity.html', ledger=l, transaction=transaction, totals=totals, total_dr=total_dr.exchange(report_commodity, True), total_cr=total_cr.exchange(report_commodity, True), report_commodity=report_commodity, cash=cash, date=date, pstart=pstart) + return flask.render_template('transaction_commodity.html', ledger=l, transaction=transaction, totals=totals, total_dr=total_dr.exchange(report_commodity, True), total_cr=total_cr.exchange(report_commodity, True), report_commodity=report_commodity, cash=cash, split=split, date=date, pstart=pstart) else: total_dr = sum((p.amount for p in transaction.postings if p.amount > 0), Balance()).exchange(report_commodity, True) total_cr = sum((p.amount for p in transaction.postings if p.amount < 0), Balance()).exchange(report_commodity, True) - return flask.render_template('transaction.html', ledger=l, transaction=transaction, total_dr=total_dr, total_cr=total_cr, report_commodity=report_commodity, cash=cash, date=date, pstart=pstart) + return flask.render_template('transaction.html', ledger=l, transaction=transaction, total_dr=total_dr, total_cr=total_cr, report_commodity=report_commodity, cash=cash, split=split, date=date, pstart=pstart) # Template filters diff --git a/ledger_pyreport/jinja2/transaction.html b/ledger_pyreport/jinja2/transaction.html index c1b6427..4ec95e1 100644 --- a/ledger_pyreport/jinja2/transaction.html +++ b/ledger_pyreport/jinja2/transaction.html @@ -22,7 +22,8 @@ {% block links %} {{ super() }} -
  • Show commodity detail
  • +
  • Show commodity detail
  • + {% if not split %}
  • Balance postings
  • {% endif %} {% endblock %} {% block report %} diff --git a/ledger_pyreport/jinja2/transaction_commodity.html b/ledger_pyreport/jinja2/transaction_commodity.html index 6cfaf39..771dc68 100644 --- a/ledger_pyreport/jinja2/transaction_commodity.html +++ b/ledger_pyreport/jinja2/transaction_commodity.html @@ -20,6 +20,11 @@ {% block title %}{{ transaction.description }}{% endblock %} +{% block links %} + {{ super() }} + {% if not split %}
  • Balance postings
  • {% endif %} +{% endblock %} + {% block report %}

    Transaction

    diff --git a/ledger_pyreport/model.py b/ledger_pyreport/model.py index 0a6e590..40f7b96 100644 --- a/ledger_pyreport/model.py +++ b/ledger_pyreport/model.py @@ -20,6 +20,7 @@ from decimal import Decimal from enum import Enum import functools import itertools +import math class Ledger: def __init__(self, date): @@ -104,6 +105,52 @@ class Transaction: result.postings.append(posting) return result + + def split(self, report_commodity): + # Split postings into debit-credit pairs (cost price) + unbalanced_postings = [] # List of [posting, base amount in report commodity, amount to balance in report commodity] + result = [] # List of balanced pairs + + for posting in self.postings: + base_amount = posting.amount.exchange(report_commodity, True).amount # Used to apportion other commodities + amount_to_balance = base_amount + + # Try to balance against previously unbalanced postings + for unbalanced_posting in unbalanced_postings[:]: + if math.copysign(1, amount_to_balance) != math.copysign(1, unbalanced_posting[2]): + if abs(unbalanced_posting[2]) == abs(amount_to_balance): + # Just enough + unbalanced_postings.remove(unbalanced_posting) + result.append(( + Posting(self, posting.account, Amount(amount_to_balance / base_amount * posting.amount.amount, posting.amount.commodity), posting.comment, posting.state), + Posting(self, unbalanced_posting[0].account, Amount(-amount_to_balance / unbalanced_posting[1] * unbalanced_posting[0].amount.amount, unbalanced_posting[0].amount.commodity), unbalanced_posting[0].comment, unbalanced_posting[0].state) + )) + amount_to_balance = 0 + elif abs(unbalanced_posting[2]) > abs(amount_to_balance): + # Excess - partial balancing of unbalanced posting + unbalanced_posting[2] += amount_to_balance + result.append(( + Posting(self, posting.account, Amount(amount_to_balance / base_amount * posting.amount.amount, posting.amount.commodity), posting.comment, posting.state), + Posting(self, unbalanced_posting[0].account, Amount(-amount_to_balance / unbalanced_posting[1] * unbalanced_posting[0].amount.amount, unbalanced_posting[0].amount.commodity), unbalanced_posting[0].comment, unbalanced_posting[0].state) + )) + amount_to_balance = 0 + elif abs(unbalanced_posting[2]) < abs(amount_to_balance): + # Not enough - partial balancing of this posting + amount_to_balance += unbalanced_posting[2] + result.append(( + Posting(self, posting.account, Amount(-unbalanced_posting[2] / base_amount * posting.amount.amount, posting.amount.commodity), posting.comment, posting.state), + Posting(self, unbalanced_posting[0].account, Amount(unbalanced_posting[2] / unbalanced_posting[1] * unbalanced_posting[0].amount.amount, unbalanced_posting[0].amount.commodity), unbalanced_posting[0].comment, unbalanced_posting[0].state) + )) + unbalanced_posting[2] = 0 + + if amount_to_balance: + # Unbalanced remainder - add it to the list + unbalanced_postings.append([posting, base_amount, amount_to_balance]) + + if unbalanced_postings: + raise Exception('Unexpectedly imbalanced transaction') + + return result class Posting: class State(Enum):