Add feature to split transaction into balanced pairs

Might be useful later for cash basis
This commit is contained in:
RunasSudo 2020-08-06 03:00:21 +10:00
parent 0d9f9a096e
commit 454801cd63
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
4 changed files with 62 additions and 3 deletions

View File

@ -309,6 +309,7 @@ def transaction():
uuid = flask.request.args['uuid'] uuid = flask.request.args['uuid']
cash = flask.request.args.get('cash', False) cash = flask.request.args.get('cash', False)
commodity = flask.request.args.get('commodity', False) commodity = flask.request.args.get('commodity', False)
split = flask.request.args.get('split', False)
# General ledger # General ledger
l = ledger.raw_transactions_at_date(None) 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)) 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: if commodity:
total_dr = sum((p.amount for p in transaction.postings if p.amount > 0), Balance()).clean() 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() 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) 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: else:
total_dr = sum((p.amount for p in transaction.postings if p.amount > 0), Balance()).exchange(report_commodity, True) 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) 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 # Template filters

View File

@ -22,7 +22,8 @@
{% block links %} {% block links %}
{{ super() }} {{ super() }}
<li><a href="/transaction?{{ {'date': date.strftime('%Y-%m-%d'), 'pstart': pstart.strftime('%Y-%m-%d'), 'uuid': transaction.uuid, 'cash': 'on' if cash else '', 'commodity': 'on'}|urlencode }}">Show commodity detail</a></li> <li><a href="/transaction?{{ {'date': date.strftime('%Y-%m-%d'), 'pstart': pstart.strftime('%Y-%m-%d'), 'uuid': transaction.uuid, 'cash': 'on' if cash else '', 'commodity': 'on', 'split': 'on' if split else ''}|urlencode }}">Show commodity detail</a></li>
{% if not split %}<li><a href="/transaction?{{ {'date': date.strftime('%Y-%m-%d'), 'pstart': pstart.strftime('%Y-%m-%d'), 'uuid': transaction.uuid, 'cash': 'on' if cash else '', 'split': 'on'}|urlencode }}">Balance postings</a></li>{% endif %}
{% endblock %} {% endblock %}
{% block report %} {% block report %}

View File

@ -20,6 +20,11 @@
{% block title %}{{ transaction.description }}{% endblock %} {% block title %}{{ transaction.description }}{% endblock %}
{% block links %}
{{ super() }}
{% if not split %}<li><a href="/transaction?{{ {'date': date.strftime('%Y-%m-%d'), 'pstart': pstart.strftime('%Y-%m-%d'), 'uuid': transaction.uuid, 'cash': 'on' if cash else '', 'commodity': 'on', 'split': 'on'}|urlencode }}">Balance postings</a></li>{% endif %}
{% endblock %}
{% block report %} {% block report %}
<h1 style="margin-bottom: 1em;">Transaction</h1> <h1 style="margin-bottom: 1em;">Transaction</h1>

View File

@ -20,6 +20,7 @@ from decimal import Decimal
from enum import Enum from enum import Enum
import functools import functools
import itertools import itertools
import math
class Ledger: class Ledger:
def __init__(self, date): def __init__(self, date):
@ -104,6 +105,52 @@ class Transaction:
result.postings.append(posting) result.postings.append(posting)
return result 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 Posting:
class State(Enum): class State(Enum):