diff --git a/README.md b/README.md index cd77acb..4b40cca 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.example.yml b/config.example.yml index a4df044..c610bdb 100644 --- a/config.example.yml +++ b/config.example.yml @@ -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' diff --git a/demo/config.yml b/demo/config.yml index 41ebf23..c35e1a7 100644 --- a/demo/config.yml +++ b/demo/config.yml @@ -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' diff --git a/ledger_pyreport/__init__.py b/ledger_pyreport/__init__.py index 60df5bd..21a97c6 100644 --- a/ledger_pyreport/__init__.py +++ b/ledger_pyreport/__init__.py @@ -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) diff --git a/ledger_pyreport/accounting.py b/ledger_pyreport/accounting.py index e02364c..fc2afe2 100644 --- a/ledger_pyreport/accounting.py +++ b/ledger_pyreport/accounting.py @@ -15,6 +15,7 @@ # along with this program. If not, see . 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, '') 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) + + results[account] = transaction - return trial_balance(tb.ledger, tb.date, tb.pstart) + 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): diff --git a/ledger_pyreport/jinja2/index.html b/ledger_pyreport/jinja2/index.html index 74c8941..c92543b 100644 --- a/ledger_pyreport/jinja2/index.html +++ b/ledger_pyreport/jinja2/index.html @@ -48,6 +48,11 @@ + diff --git a/ledger_pyreport/jinja2/pandl.html b/ledger_pyreport/jinja2/pandl.html index fa329d7..a0d06a0 100644 --- a/ledger_pyreport/jinja2/pandl.html +++ b/ledger_pyreport/jinja2/pandl.html @@ -66,15 +66,47 @@

For the {{ period }}

- {{ do_accounts(ledger.get_account(config['income_account']), 'Income', True, True) }} - + {# Profit and loss #} + {% if scope != 'oci' %} + {{ do_accounts(ledger.get_account(config['income_account']), 'Income', True, True) }} + + + {{ do_accounts(ledger.get_account(config['expenses_account']), 'Expenses', False, False) }} + + + + + {% for pandl in pandls %}{% endfor %} + + {% else %} + + + {% for pandl in pandls %}{% endfor %} + + + + {% for pandl in pandls %}{% endfor %} + + {% endif %} - {{ do_accounts(ledger.get_account(config['expenses_account']), 'Expenses', False, False) }} - - - - - {% for pandl in pandls %}{% endfor %} - + {# Other comprehensive income #} + {% if scope != 'pandl' %} + + + {% for child in ledger.get_account(config['oci_account']).children|sort(attribute='name') if child in accounts %} + {{ print_rows(child, True, 0) }} + {% endfor %} + + + + {% for pandl in pandls %}{% endfor %} + + + + + + {% for pandl in pandls %}{% endfor %} + + {% endif %}
 
 
 
Net Profit (Loss){{ -(pandl.get_total(ledger.get_account(config['income_account'])) + pandl.get_total(ledger.get_account(config['expenses_account']))).exchange(report_currency, True)|a }}
{{ pandl.date.strftime('%Y') }} 
Net Profit (Loss){{ -(pandl.get_total(ledger.get_account(config['income_account'])) + pandl.get_total(ledger.get_account(config['expenses_account']))).exchange(report_currency, True)|a }}
 
Net Surplus (Loss){{ -(pandl.get_total(ledger.get_account(config['income_account'])) + pandl.get_total(ledger.get_account(config['expenses_account']))).exchange(report_currency, True)|a }}
 
Other Comprehensive Income
Total Other Comprehensive Income{{ -pandl.get_total(ledger.get_account(config['oci_account'])).exchange(report_currency, True)|a }}
 
Total Comprehensive Income{{ -(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 }}
{% endblock %} diff --git a/ledger_pyreport/model.py b/ledger_pyreport/model.py index fe46063..eb8bb3e 100644 --- a/ledger_pyreport/model.py +++ b/ledger_pyreport/model.py @@ -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): diff --git a/ledger_pyreport/static/main.css b/ledger_pyreport/static/main.css index 4936354..11301d5 100644 --- a/ledger_pyreport/static/main.css +++ b/ledger_pyreport/static/main.css @@ -86,6 +86,10 @@ table.ledger a:hover { color: #666; } +label { + margin-left: 1ex; +} + @media screen { body { padding: 2em;