Implement separate reporting of Other Comprehensive Income (e.g. unrealised gains)

This commit is contained in:
RunasSudo 2020-04-01 13:48:14 +11:00
parent 0d7e68592b
commit 04860845b6
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
9 changed files with 119 additions and 28 deletions

View File

@ -15,6 +15,7 @@ ledger-pyreport is a lightweight Flask webapp for generating interactive and pri
* Correctly values assets/liabilities at market value, and income/expenses at cost (pursuant to [AASB 121](https://www.aasb.gov.au/admin/file/content105/c9/AASB121_08-15_COMPfeb16_01-19.pdf)/[IAS 21](https://www.ifrs.org/issued-standards/list-of-standards/ias-21-the-effects-of-changes-in-foreign-exchange-rates/) para 39)
* Correctly computes unrealised gains ([even when Ledger does not](https://yingtongli.me/blog/2020/03/31/ledger-gains.html))
* Accounts for both profit and loss, and other comprehensive income
* Simulates annual closing of books, with presentation of income/expenses on the balance sheet as retained earnings and current year earnings
* Can simulate cash basis accounting, using FIFO methodology to recode transactions involving liabilities and non-cash assets

View File

@ -9,9 +9,13 @@ liabilities_account: Liabilities
equity_account: Equity
income_account: Income
expenses_account: Expenses
oci_account: OCI # Other Comprehensive Income
# These accounts will automatically be populated on reports
unrealized_gains: 'Equity:Unrealized Gains'
unrealized_gains: 'OCI:Unrealized Gains'
accumulated_oci: 'Equity:Accumulated Other Comprehensive Income'
current_year_oci: 'Equity:Current Year Other Comprehensive Income'
retained_earnings: 'Equity:Retained Earnings'
current_year_earnings: 'Equity:Current Year Earnings'

View File

@ -9,9 +9,13 @@ liabilities_account: Liabilities
equity_account: Equity
income_account: Income
expenses_account: Expenses
oci_account: OCI # Other Comprehensive Income
# These accounts will automatically be populated on reports
unrealized_gains: 'Equity:Unrealized Gains'
unrealized_gains: 'OCI:Unrealized Gains'
accumulated_oci: 'Equity:Accumulated Other Comprehensive Income'
current_year_oci: 'Equity:Current Year Other Comprehensive Income'
retained_earnings: 'Equity:Retained Earnings'
current_year_earnings: 'Equity:Current Year Earnings'

View File

@ -47,9 +47,7 @@ def trial():
l = ledger.raw_transactions_at_date(date)
if cash:
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)
trial_balance = accounting.trial_balance(l, date, pstart, report_currency)
total_dr = Amount(0, report_currency)
total_cr = Amount(0, report_currency)
@ -71,7 +69,7 @@ def trial():
l = ledger.raw_transactions_at_date(date)
if cash:
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)]
trial_balances = [accounting.trial_balance(l, d, p, report_currency) for d, p in zip(dates, pstarts)]
# Delete accounts with always zero balances
accounts = sorted(l.accounts.values(), key=lambda a: a.name)
@ -95,7 +93,7 @@ def balance():
l = ledger.raw_transactions_at_date(date)
if cash:
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)]
balance_sheets = [accounting.balance_sheet(accounting.trial_balance(l, d, p, report_currency)) for d, p in zip(dates, pstarts)]
# Delete accounts with always zero balances
accounts = list(l.accounts.values())
@ -119,6 +117,7 @@ def pandl():
date_end = datetime.strptime(flask.request.args['date_end'], '%Y-%m-%d')
compare = int(flask.request.args['compare'])
cash = flask.request.args.get('cash', False)
scope = flask.request.args['scope']
dates_beg = [date_beg.replace(year=date_beg.year - i) for i in range(0, compare + 1)]
dates_end = [date_end.replace(year=date_end.year - i) for i in range(0, compare + 1)]
@ -127,7 +126,7 @@ def pandl():
l = ledger.raw_transactions_at_date(date_end)
if cash:
l = accounting.ledger_to_cash(l, report_currency)
pandls = [accounting.trial_balance(l, de, db) for de, db in zip(dates_end, dates_beg)]
pandls = [accounting.trial_balance(l, de, db, report_currency) for de, db in zip(dates_end, dates_beg)]
# Delete accounts with always zero balances
accounts = list(l.accounts.values())
@ -135,7 +134,7 @@ def pandl():
if all(p.get_balance(account) == 0 and p.get_total(account) == 0 for p in pandls):
accounts.remove(account)
return flask.render_template('pandl.html', period=describe_period(date_end, date_beg), ledger=l, pandls=pandls, accounts=accounts, config=config, report_currency=report_currency, cash=cash)
return flask.render_template('pandl.html', period=describe_period(date_end, date_beg), ledger=l, pandls=pandls, accounts=accounts, config=config, report_currency=report_currency, cash=cash, scope=scope)
@app.route('/transactions')
def transactions():
@ -153,7 +152,7 @@ def transactions():
l = accounting.ledger_to_cash(l, report_currency)
# Unrealized gains
l = accounting.add_unrealized_gains(accounting.trial_balance(l, date_end, date_beg), report_currency).ledger
l = accounting.trial_balance(l, date_end, date_beg, report_currency).ledger
if not account:
# General Ledger
@ -168,8 +167,8 @@ def transactions():
account = l.get_account(account)
transactions = [t for t in l.transactions if t.date <= date_end and t.date >= date_beg and any(p.account == account for p in t.postings)]
opening_balance = accounting.trial_balance(l, date_beg - timedelta(days=1), date_beg).get_balance(account).clean()
closing_balance = accounting.trial_balance(l, date_end, date_beg).get_balance(account).clean()
opening_balance = accounting.trial_balance(l, date_beg - timedelta(days=1), date_beg, report_currency).get_balance(account).clean()
closing_balance = accounting.trial_balance(l, date_end, date_beg, report_currency).get_balance(account).clean()
def matching_posting(transaction, amount):
return next((p for p in transaction.postings if p.account == account and p.amount.currency == amount.currency), None)
@ -180,8 +179,8 @@ def transactions():
account = l.get_account(account)
transactions = [t for t in l.transactions if t.date <= date_end and t.date >= date_beg and any(p.account == account for p in t.postings)]
opening_balance = accounting.trial_balance(l, date_beg - timedelta(days=1), date_beg).get_balance(account).exchange(report_currency, True)
closing_balance = accounting.trial_balance(l, date_end, date_beg).get_balance(account).exchange(report_currency, True)
opening_balance = accounting.trial_balance(l, date_beg - timedelta(days=1), date_beg, report_currency).get_balance(account).exchange(report_currency, True)
closing_balance = accounting.trial_balance(l, date_end, date_beg, report_currency).get_balance(account).exchange(report_currency, True)
return flask.render_template('transactions.html', date_beg=date_beg, date_end=date_end, period=describe_period(date_end, date_beg), account=account, ledger=l, transactions=transactions, opening_balance=opening_balance, closing_balance=closing_balance, report_currency=report_currency, cash=cash, timedelta=timedelta)

View File

@ -15,6 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import csv
from datetime import timedelta
from decimal import Decimal
import math
@ -22,7 +23,7 @@ from .model import *
# Generate a trial balance
# Perform closing of books based on specified dates
def trial_balance(ledger, date, pstart):
def trial_balance_raw(ledger, date, pstart):
tb = TrialBalance(ledger, date, pstart)
for transaction in ledger.transactions:
@ -32,13 +33,43 @@ def trial_balance(ledger, date, pstart):
for posting in transaction.postings:
if (posting.account.is_income or posting.account.is_expense) and transaction.date < pstart:
tb.balances[config['retained_earnings']] = tb.get_balance(ledger.get_account(config['retained_earnings'])) + posting.amount
elif posting.account.is_oci and transaction.date < pstart:
tb.balances[config['accumulated_oci']] = tb.get_balance(ledger.get_account(config['retained_earnings'])) + posting.amount
else:
tb.balances[posting.account.name] = tb.get_balance(posting.account) + posting.amount
return tb
# Adjust (in place) a trial balance for unrealized gains
def add_unrealized_gains(tb, currency):
# Trial balance with unrealized gains and OCI
def trial_balance(ledger, date, pstart, currency):
tb_date, r_date = _add_unrealized_gains(trial_balance_raw(ledger, date, pstart), currency)
tb_pstart, r_pstart = _add_unrealized_gains(trial_balance_raw(ledger, pstart - timedelta(days=1), pstart), currency)
for account in set(list(r_date.keys()) + list(r_pstart.keys())):
if account in r_pstart:
# Charge previous unrealized gains to Accumulated OCI
#r_pstart[account].postings[1].account = ledger.get_account(config['accumulated_oci'])
accumulated = r_pstart[account].postings[0].amount
tb_date.balances[account.name] = tb_date.get_balance(account) + accumulated
tb_date.balances[config['accumulated_oci']] = tb_date.get_balance(ledger.get_account(config['accumulated_oci'])) - accumulated
if account in r_date:
if account in r_pstart:
# Adjust for this year's unrealized gains only
r_date[account].postings[0].amount -= accumulated
r_date[account].postings[1].amount += accumulated
tb_date.balances[account.name] = tb_date.get_balance(account) + r_date[account].postings[0].amount
tb_date.balances[config['unrealized_gains']] = tb_date.get_balance(ledger.get_account(config['unrealized_gains'])) - r_date[account].postings[0].amount
return tb_date
# Adjust (in place) a trial balance for unrealized gains without accumulating OCI
def _add_unrealized_gains(tb, currency):
results = {}
unrealized_gain_account = tb.ledger.get_account(config['unrealized_gains'])
for account in list(tb.ledger.accounts.values()):
if not account.is_market:
continue
@ -50,10 +81,12 @@ def add_unrealized_gains(tb, currency):
if unrealized_gain != 0:
transaction = Transaction(tb.ledger, None, tb.date, '<Unrealized Gains>')
transaction.postings.append(Posting(transaction, account, unrealized_gain))
transaction.postings.append(Posting(transaction, tb.ledger.get_account(config['unrealized_gains']), -unrealized_gain))
transaction.postings.append(Posting(transaction, unrealized_gain_account, -unrealized_gain))
tb.ledger.transactions.append(transaction)
return trial_balance(tb.ledger, tb.date, tb.pstart)
results[account] = transaction
return tb, results
# Adjust (in place) a trial balance to include a Current Year Earnings account
# Suitable for display on a balance sheet
@ -64,6 +97,12 @@ def balance_sheet(tb):
# Add Current Year Earnings account
tb.balances[config['current_year_earnings']] = tb.get_balance(tb.ledger.get_account(config['current_year_earnings'])) + total_pandl
# Calculate OCI
total_oci = tb.get_total(tb.ledger.get_account(config['oci_account']))
# Add Current Year OCI account
tb.balances[config['current_year_oci']] = tb.get_balance(tb.ledger.get_account(config['current_year_oci'])) + total_oci
return tb
def account_to_cash(account, currency):

View File

@ -48,6 +48,11 @@
<label>End date: <input name="date_end" data-inputgroup="date" value="{{ date.strftime('%Y-%m-%d') }}" style="width: 6em;" oninput="txtc(this)"></label>
<label>Compare <input name="compare" data-inputgroup="compare" value="0" style="width: 2em;" oninput="txtc(this)"> periods</label>
<label><input name="cash" data-inputgroup="cash" type="checkbox" oninput="chbc(this)"> Cash basis</label>
<label>Scope: <select name="scope">
<option value="pandl" selected>P&L only</option>
<option value="oci">OCI only</option>
<option value="both">P&L and OCI</option>
</select></label>
</form>
</div>

View File

@ -66,15 +66,47 @@
<h2>For the {{ period }}</h2>
<table class="ledger onedesc">
{# Profit and loss #}
{% if scope != 'oci' %}
{{ do_accounts(ledger.get_account(config['income_account']), 'Income', True, True) }}
<tr><td colspan="2">&nbsp;</td></tr>
<tr><td colspan="{{ pandls|length + 1}}">&nbsp;</td></tr>
{{ do_accounts(ledger.get_account(config['expenses_account']), 'Expenses', False, False) }}
<tr><td colspan="2">&nbsp;</td></tr>
<tr><td colspan="{{ pandls|length + 1}}">&nbsp;</td></tr>
<tr class="total">
<td>Net Surplus (Loss)</td>
<td>Net Profit (Loss)</td>
{% for pandl in pandls %}<td>{{ -(pandl.get_total(ledger.get_account(config['income_account'])) + pandl.get_total(ledger.get_account(config['expenses_account']))).exchange(report_currency, True)|a }}</td>{% endfor %}
</tr>
{% else %}
<tr>
<th></th>
{% for pandl in pandls %}<th class="h2">{{ pandl.date.strftime('%Y') }}&nbsp;</th>{% endfor %}
</tr>
<tr>
<td>Net Profit (Loss)</td>
{% for pandl in pandls %}<td>{{ -(pandl.get_total(ledger.get_account(config['income_account'])) + pandl.get_total(ledger.get_account(config['expenses_account']))).exchange(report_currency, True)|a }}</td>{% endfor %}
</tr>
{% endif %}
{# Other comprehensive income #}
{% if scope != 'pandl' %}
<tr><td colspan="{{ pandls|length + 1}}">&nbsp;</td></tr>
<tr><th class="{% if scope == 'both' %}h1{% else %}h2{% endif %}" colspan="{{ pandls|length + 1 }}">Other Comprehensive Income</th></tr>
{% for child in ledger.get_account(config['oci_account']).children|sort(attribute='name') if child in accounts %}
{{ print_rows(child, True, 0) }}
{% endfor %}
<tr class="total">
<td>Total Other Comprehensive Income</td>
{% for pandl in pandls %}<td>{{ -pandl.get_total(ledger.get_account(config['oci_account'])).exchange(report_currency, True)|a }}</td>{% endfor %}
</tr>
<tr><td colspan="{{ pandls|length + 1}}">&nbsp;</td></tr>
<tr class="total">
<td>Total Comprehensive Income</td>
{% for pandl in pandls %}<td>{{ -(pandl.get_total(ledger.get_account(config['income_account'])) + pandl.get_total(ledger.get_account(config['expenses_account'])) + pandl.get_total(ledger.get_account(config['oci_account']))).exchange(report_currency, True)|a }}</td>{% endfor %}
</tr>
{% endif %}
</table>
{% endblock %}

View File

@ -130,6 +130,9 @@ class Account:
def is_cash(self):
# Is this a cash asset?
return any(self.matches(a) for a in config['cash_asset_accounts'])
@property
def is_oci(self):
return self.matches(config['oci_account'])
@property
def is_cost(self):

View File

@ -86,6 +86,10 @@ table.ledger a:hover {
color: #666;
}
label {
margin-left: 1ex;
}
@media screen {
body {
padding: 2em;