Implement separate reporting of Other Comprehensive Income (e.g. unrealised gains)
This commit is contained in:
parent
0d7e68592b
commit
04860845b6
@ -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
|
||||
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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"> </td></tr>
|
||||
<tr><td colspan="{{ pandls|length + 1}}"> </td></tr>
|
||||
|
||||
{{ do_accounts(ledger.get_account(config['expenses_account']), 'Expenses', False, False) }}
|
||||
<tr><td colspan="2"> </td></tr>
|
||||
<tr><td colspan="{{ pandls|length + 1}}"> </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') }} </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}}"> </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}}"> </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 %}
|
||||
|
@ -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):
|
||||
|
@ -86,6 +86,10 @@ table.ledger a:hover {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-left: 1ex;
|
||||
}
|
||||
|
||||
@media screen {
|
||||
body {
|
||||
padding: 2em;
|
||||
|
Reference in New Issue
Block a user