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
Date | +Description | +Account | +Dr | +Cr | +
---|---|---|---|---|
{% if loop.first %}{{ transaction.date.strftime('%Y-%m-%d') }}{% endif %} | +{% if loop.first %}{{ transaction.description }}{% endif %} | +{{ (posting.account.name|e).__str__().replace(':', ': |
+ {% if amount > 0 %}{{ amount|b }}{% endif %} | +{% if amount < 0 %}{{ -amount|b }}{% endif %} | +