From bb66931b0e3b4e63a3cee0fe614b39012672259e Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Sat, 15 Feb 2020 11:47:10 +1100 Subject: [PATCH] Major rewrite - implement comparative periods for all reports --- ledger_pyreport/__init__.py | 86 ++++-- ledger_pyreport/accounting.py | 31 +++ ledger_pyreport/jinja2/balance.html | 107 ++++---- ledger_pyreport/jinja2/index.html | 37 ++- ledger_pyreport/jinja2/pandl.html | 53 ++-- ledger_pyreport/jinja2/trial.html | 17 +- ledger_pyreport/jinja2/trial_multiple.html | 47 ++++ ledger_pyreport/ledger.py | 290 ++++++++++++--------- ledger_pyreport/static/main.css | 3 +- 9 files changed, 433 insertions(+), 238 deletions(-) create mode 100644 ledger_pyreport/accounting.py create mode 100644 ledger_pyreport/jinja2/trial_multiple.html diff --git a/ledger_pyreport/__init__.py b/ledger_pyreport/__init__.py index e4a5b8d..7ac5ebf 100644 --- a/ledger_pyreport/__init__.py +++ b/ledger_pyreport/__init__.py @@ -14,9 +14,11 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from . import accounting from . import ledger from datetime import datetime, timedelta +from decimal import Decimal import flask app = flask.Flask(__name__, template_folder='jinja2') @@ -32,57 +34,91 @@ def index(): def trial(): 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) + compare = int(flask.request.args['compare']) + #cash = flask.request.args.get('cash', False) - # Get trial balance - accounts = ledger.trial_balance(date, pstart, cash) - - total_dr = sum(a.balance for a in accounts if a.balance > 0) - total_cr = -sum(a.balance for a in accounts if a.balance < 0) - - return flask.render_template('trial.html', date=date, accounts=accounts, total_dr=total_dr, total_cr=total_cr) + if compare == 0: + # Get trial balance + trial_balance = ledger.trial_balance(date, pstart) + + total_dr = Decimal(0) + total_cr = Decimal(0) + + for account in trial_balance.accounts.values(): + balance = trial_balance.get_balance(account.name) + if balance > 0: + total_dr += balance + else: + total_cr -= balance + + return flask.render_template('trial.html', date=date, trial_balance=trial_balance, total_dr=total_dr, total_cr=total_cr) + else: + # Get multiple trial balances for comparison + dates = [date.replace(year=date.year - i) for i in range(0, compare + 1)] + pstarts = [pstart.replace(year=pstart.year - i) for i in range(0, compare + 1)] + + trial_balances = [ledger.trial_balance(d, p) for d, p in zip(dates, pstarts)] + + # Delete accounts with always zero balances + accounts = list(trial_balances[0].accounts.values()) + for account in accounts[:]: + if all(t.get_balance(account.name) == 0 for t in trial_balances): + accounts.remove(account) + + return flask.render_template('trial_multiple.html', dates=dates, trial_balances=trial_balances, accounts=accounts) @app.route('/balance') def balance(): 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) + compare = int(flask.request.args['compare']) + #cash = flask.request.args.get('cash', False) - # Get trial balance - accounts = ledger.trial_balance(date, pstart, cash) - accounts_map = ledger.make_account_tree(accounts) + dates = [date.replace(year=date.year - i) for i in range(0, compare + 1)] + pstarts = [pstart.replace(year=pstart.year - i) for i in range(0, compare + 1)] - # Calculate Profit/Loss - total_pandl = accounts_map[ledger.config['income_account']].total() + accounts_map[ledger.config['expenses_account']].total() + balance_sheets = [accounting.balance_sheet(d, p) for d, p in zip(dates, pstarts)] - # Add Current Year Earnings account - accounts.append(ledger.Account(ledger.config['current_year_earnings'], total_pandl)) - accounts_map = ledger.make_account_tree(accounts) + # Delete accounts with always zero balances + accounts = list(balance_sheets[0].accounts.values()) + for account in accounts[:]: + if all(b.get_balance(account.name) == 0 and b.get_total(account.name) == 0 for b in balance_sheets): + accounts.remove(account) - return flask.render_template('balance.html', date=date, cash=cash, assets=accounts_map.get(ledger.config['assets_account'], ledger.Account('Assets')), liabilities=accounts_map.get(ledger.config['liabilities_account'], ledger.Account('Liabilities')), equity=accounts_map.get(ledger.config['equity_account'], ledger.Account('Equity'))) + return flask.render_template('balance.html', dates=dates, balance_sheets=balance_sheets, accounts=accounts, config=ledger.config) @app.route('/pandl') def pandl(): date_beg = datetime.strptime(flask.request.args['date_beg'], '%Y-%m-%d') date_end = datetime.strptime(flask.request.args['date_end'], '%Y-%m-%d') - cash = flask.request.args.get('cash', False) + compare = int(flask.request.args['compare']) + #cash = flask.request.args.get('cash', False) - # Get P&L - accounts = ledger.pandl(date_beg, date_end, cash) - accounts_map = ledger.make_account_tree(accounts) + 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)] + + pandls = [ledger.trial_balance(de, db) for de, db in zip(dates_end, dates_beg)] + + # Delete accounts with always zero balances + accounts = list(pandls[0].accounts.values()) + for account in accounts[:]: + if all(p.get_balance(account.name) == 0 and p.get_total(account.name) == 0 for p in pandls): + accounts.remove(account) if date_end == (date_beg.replace(year=date_beg.year + 1) - timedelta(days=1)): period = 'year ended {}'.format(date_end.strftime('%d %B %Y')) elif date_beg == ledger.financial_year(date_end): period = 'financial year to {}'.format(date_end.strftime('%d %B %Y')) else: - period = 'period from {} to {}'.format(date_begin.strftime('%d %B %Y'), date_end.strftime('%d %B %Y')) + period = 'period from {} to {}'.format(date_beg.strftime('%d %B %Y'), date_end.strftime('%d %B %Y')) - return flask.render_template('pandl.html', period=period, income=accounts_map[ledger.config['income_account']], expenses=accounts_map[ledger.config['expenses_account']]) + return flask.render_template('pandl.html', period=period, dates_end=dates_end, pandls=pandls, accounts=accounts, config=ledger.config) @app.template_filter('a') def filter_amount(amt): - if amt > 0: + if amt < 0.005 and amt >= -0.005: + return flask.Markup('0.00 ') + elif amt > 0: return flask.Markup('{:,.2f} '.format(amt).replace(',', ' ')) # Narrow no-break space else: return flask.Markup('({:,.2f})'.format(-amt).replace(',', ' ')) diff --git a/ledger_pyreport/accounting.py b/ledger_pyreport/accounting.py new file mode 100644 index 0000000..a0dfd3c --- /dev/null +++ b/ledger_pyreport/accounting.py @@ -0,0 +1,31 @@ +# 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 . + +from . import ledger + +# Generate balance sheet + +def balance_sheet(date, pstart): + # Get trial balance + trial_balance = ledger.trial_balance(date, pstart) + + # Calculate Profit/Loss + total_pandl = trial_balance.get_total(ledger.config['income_account']) + trial_balance.get_total(ledger.config['expenses_account']) + + # Add Current Year Earnings account + trial_balance.set_balance(ledger.config['current_year_earnings'], trial_balance.get_balance(ledger.config['current_year_earnings']) + total_pandl) + + return trial_balance diff --git a/ledger_pyreport/jinja2/balance.html b/ledger_pyreport/jinja2/balance.html index e6e6e2f..dbac127 100644 --- a/ledger_pyreport/jinja2/balance.html +++ b/ledger_pyreport/jinja2/balance.html @@ -16,81 +16,82 @@ along with this program. If not, see . #} -{% macro do_account(account, acc_level=0, disp_level=0, invert=False) %} +{% macro print_rows(account, invert=False, level=0) %} - {{ ':'.join(account.name_parts[acc_level+1:]) }} - {% if account.balance != 0 %}{{ (account.balance * (-1 if invert else 1))|a }}{% endif %} + {{ account.bits[-1] }} + {% for balance_sheet in balance_sheets %} + {% set amount = balance_sheet.get_balance(account.name) * (-1 if invert else 1) %} + {% if amount != 0 %}{{ amount|a }}{% endif %} + {% endfor %} - {{ walk_children(account.children, acc_level + 1, disp_level + 1, invert) }} + + {% for child in account.children if child in accounts %} + {{ print_rows(child, invert, level + 1) }} + {% endfor %} {% endmacro %} -{% macro walk_children(children, acc_level=0, disp_level=0, invert=False) %} - {% for account in children %} - {{ do_account(account, acc_level, disp_level, invert) }} +{% macro do_accounts(root, label, invert, year_headers) %} + {% for account_class in balance_sheets[0].get_account(root).children if account_class in accounts %} + {% set isfirst = (loop.index0 == 0) %} + + {% if isfirst and year_headers %} + {{ account_class.bits[-1] }} {{ label }} + {% for date in dates %}{{ date.strftime('%Y') }} {% endfor %} + {% else %} + {{ account_class.bits[-1] }} {{ label }} + {% endif %} + + + {% for account in account_class.children if account in accounts %} + {{ print_rows(account, invert=invert) }} + {% endfor %} + + + Total {{ account_class.bits[-1] }} {{ label }} + {% for balance_sheet in balance_sheets %}{{ (balance_sheet.get_total(account_class.name) * (-1 if invert else 1))|a }}{% endfor %} + +   {% endfor %} + + + Total {{ label }} + {% for balance_sheet in balance_sheets %}{{ (balance_sheet.get_total(root) * (-1 if invert else 1))|a }}{% endfor %} + {% endmacro %} - Balance Sheet as at {{ date.strftime('%d %B %Y') }} + Balance Sheet as at {{ dates[0].strftime('%d %B %Y') }}

Balance Sheet

-

As at {{ date.strftime('%d %B %Y') }}

+

As at {{ dates[0].strftime('%d %B %Y') }}

- - +
Assets
+ {# Assets #} + + {{ do_accounts(config['assets_account'], 'Assets', False, True) }} + - {% for asset_class in assets.children %} - - {{ walk_children(asset_class.children, acc_level=1) }} - - - - - + {# Liabilities #} + + {{ do_accounts(config['liabilities_account'], 'Liabilities', True, False) }} + + + {# Equity #} + + + {% for account in balance_sheets[0].get_account(config['equity_account']).children if account in accounts %} + {{ print_rows(account, invert=True) }} {% endfor %} - - - - - - - {% if not cash %} - - - {% for liability_class in liabilities.children %} - - {{ walk_children(liability_class.children, acc_level=1, invert=True) }} - - - - - - {% endfor %} - - - - - - - - - - - {% endif %} - - - - {{ walk_children(equity.children, invert=True) }} - + {% for balance_sheet in balance_sheets %}{% endfor %}
Assets
 
{{ asset_class.name_parts[-1] }} Assets
Total {{ asset_class.name_parts[-1] }} Assets{{ asset_class.total()|a }}
 
Liabilities
 
Equity
Total Assets{{ assets.total()|a }}
 
Liabilities
{{ liability_class.name_parts[-1] }} Liabilities
Total {{ liability_class.name_parts[-1] }} Liabilities{{ -liability_class.total()|a }}
 
Total Liabilities{{ -liabilities.total()|a }}
Net Assets{{ (assets.total() + liabilities.total())|a }}
 
Equity
Total Equity{{ -equity.total()|a }}{{ -balance_sheet.get_total(config['equity_account'])|a }}
diff --git a/ledger_pyreport/jinja2/index.html b/ledger_pyreport/jinja2/index.html index ea9cd63..228d6f3 100644 --- a/ledger_pyreport/jinja2/index.html +++ b/ledger_pyreport/jinja2/index.html @@ -26,24 +26,43 @@
  • - - - + + + +
  • - - - + + + +
  • - - - + + + +
+ + diff --git a/ledger_pyreport/jinja2/pandl.html b/ledger_pyreport/jinja2/pandl.html index 9822540..55bb499 100644 --- a/ledger_pyreport/jinja2/pandl.html +++ b/ledger_pyreport/jinja2/pandl.html @@ -16,18 +16,39 @@ along with this program. If not, see . #} -{% macro do_account(account, acc_level=0, disp_level=0, invert=False) %} +{% macro print_rows(account, invert=False, level=0) %} - {{ ':'.join(account.name_parts[acc_level+1:]) }} - {% if account.balance != 0 %}{{ (account.balance * (-1 if invert else 1))|a }}{% endif %} + {{ account.bits[-1] }} + {% for pandl in pandls %} + {% set amount = pandl.get_balance(account.name) * (-1 if invert else 1) %} + {% if amount != 0 %}{{ amount|a }}{% endif %} + {% endfor %} - {{ walk_children(account.children, acc_level + 1, disp_level + 1, invert) }} + + {% for child in account.children if child in accounts %} + {{ print_rows(child, invert, level + 1) }} + {% endfor %} {% endmacro %} -{% macro walk_children(children, acc_level=0, disp_level=0, invert=False) %} - {% for account in children %} - {{ do_account(account, acc_level, disp_level, invert) }} +{% macro do_accounts(root, label, invert, year_headers) %} + + {% if year_headers %} + {# CSS hackery to centre-align the heading #} + {{ label }} + {% for date in dates_end %}{{ date.strftime('%Y') }} {% endfor %} + {% else %} + {{ label }} + {% endif %} + + + {% for account in pandls[0].get_account(root).children if account in accounts %} + {{ print_rows(account, invert=invert) }} {% endfor %} + + + Total {{ label }} + {% for pandl in pandls %}{{ (pandl.get_total(root) * (-1 if invert else 1))|a }}{% endfor %} + {% endmacro %} @@ -42,26 +63,16 @@

Income Statement

For the {{ period }}

- - - {{ walk_children(income.children, invert=True) }} - - - - +
Income
Total Income{{ -income.total()|a }}
+ {{ do_accounts(config['income_account'], 'Income', True, True) }} - - {{ walk_children(expenses.children) }} - - - - + {{ do_accounts(config['expenses_account'], 'Expenses', False, False) }} - + {% for pandl in pandls %}{% endfor %}
 
Expenses
Total Expenses{{ expenses.total()|a }}
 
Net Surplus (Loss){{ -(income.total() + expenses.total())|a }}{{ -(pandl.get_total(config['income_account']) + pandl.get_total(config['expenses_account']))|a }}
diff --git a/ledger_pyreport/jinja2/trial.html b/ledger_pyreport/jinja2/trial.html index f0b342e..7bd6c26 100644 --- a/ledger_pyreport/jinja2/trial.html +++ b/ledger_pyreport/jinja2/trial.html @@ -28,18 +28,21 @@

Trial Balance

As at {{ date.strftime('%d %B %Y') }}

- +
- {% for account in accounts %} - - - - - + {% for account in trial_balance.accounts.values() %} + {% set balance = trial_balance.get_balance(account.name) %} + {% if balance != 0 %} + + + + + + {% endif %} {% endfor %} diff --git a/ledger_pyreport/jinja2/trial_multiple.html b/ledger_pyreport/jinja2/trial_multiple.html new file mode 100644 index 0000000..4ee5892 --- /dev/null +++ b/ledger_pyreport/jinja2/trial_multiple.html @@ -0,0 +1,47 @@ +{# + 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 . +#} + + + + + + Trial Balance as at {{ dates[0].strftime('%d %B %Y') }} + + + + +

Trial Balance

+

As at {{ dates[0].strftime('%d %B %Y') }}

+ +
Dr Cr
{{ account.name }}{% if account.balance > 0 %}{{ account.balance|b }}{% endif %}{% if account.balance < 0 %}{{ -account.balance|b }}{% endif %}
{{ account.name }}{% if balance > 0 %}{{ balance|b }}{% endif %}{% if balance < 0 %}{{ -balance|b }}{% endif %}
+ + + {% for date in dates %}{% endfor %} + + {% for account in accounts %} + + + {% for trial_balance in trial_balances %} + {% set balance = trial_balance.get_balance(account.name) %} + + {% endfor %} + + {% endfor %} +
{{ date.strftime('%Y') }} 
{{ account.name }}{% if balance != 0 %}{{ balance|a }}{% endif %}
+ + diff --git a/ledger_pyreport/ledger.py b/ledger_pyreport/ledger.py index 39cda7a..e660d39 100644 --- a/ledger_pyreport/ledger.py +++ b/ledger_pyreport/ledger.py @@ -17,6 +17,7 @@ import csv from datetime import timedelta from decimal import Decimal +import re import subprocess import yaml @@ -24,26 +25,7 @@ import yaml with open('config.yml', 'r') as f: config = yaml.safe_load(f) -class Account: - def __init__(self, name, balance=None): - if balance is None: - balance = Decimal(0) - - self.name = name - self.balance = balance - - self.parent = None - self.children = [] - - @property - def name_parts(self): - return self.name.split(':') - - def total(self): - result = self.balance - for child in self.children: - result += child.total() - return result +# Helper commands to run Ledger def run_ledger(*args): proc = subprocess.Popen(['ledger', '--args-only', '--file', config['ledger_file'], '-X', config['report_currency'], '--unround'] + config['ledger_args'] + list(args), encoding='utf-8', stdout=subprocess.PIPE, stderr=subprocess.PIPE) @@ -57,48 +39,7 @@ def run_ledger(*args): def run_ledger_date(date, *args): return run_ledger('--end', (date + timedelta(days=1)).strftime('%Y-%m-%d'), *args) -BALANCE_FORMAT = '%(quoted(display_total)),%(quoted(account))\n' - -def parse_balance(output): - reader = csv.reader(output.splitlines()) - - accounts = [] - - # Parse balance lines - for row in reader: - balance = row[0] - if balance.startswith(config['report_currency']): - balance = balance[1:] - accounts.append(Account(row[1], Decimal(balance))) - - return accounts - -def make_account_tree(accounts): - accounts_map = {} - - for account in accounts: - accounts_map[account.name] = account - - for i in range(1, len(account.name_parts)): - parent_name = ':'.join(account.name_parts[:i]) - if parent_name not in accounts_map: - accounts_map[parent_name] = Account(parent_name, Decimal(0)) - - for account in accounts_map.values(): - if len(account.name_parts) > 1: - account.parent = accounts_map[':'.join(account.name_parts[:-1])] - account.parent.children.append(account) - - return accounts_map - -# Return a regex for an account and its children -def aregex(account): - return '^{0}:|^{0}$'.format(account) - -def amatch(needle, haystack): - if haystack == needle or haystack.startswith(needle + ':'): - return True - return False +# General financial logic def financial_year(date): pstart = date.replace(day=1, month=7) @@ -106,77 +47,182 @@ def financial_year(date): pstart = pstart.replace(year=pstart.year - 1) return pstart -# Calculate Unrealized Gains -def unrealized_gains(date): - accounts_cost = parse_balance(run_ledger_date(date, 'balance', '--balance-format', BALANCE_FORMAT, '--no-total', '--flat', '--cost', aregex(config['assets_account']), aregex(config['liabilities_account']))) - accounts_current = parse_balance(run_ledger_date(date, 'balance', '--balance-format', BALANCE_FORMAT, '--no-total', '--flat', '--market', aregex(config['assets_account']), aregex(config['liabilities_account']))) - total_cost = sum(a.balance for a in accounts_cost) - total_current = sum(a.balance for a in accounts_current) - unrealized_gains = total_current - total_cost - - return unrealized_gains +# Ledger logic -# Get account balances at date -def get_accounts(date, cash=False): - # Calculate Unrealized Gains - unrealized_gains_amt = unrealized_gains(date) - - # Get account balances - accounts = parse_balance(run_ledger_date(date, 'balance', '--balance-format', BALANCE_FORMAT, '--no-total', '--flat', '--cost', aregex(config['income_account']), aregex(config['expenses_account']))) - accounts += parse_balance(run_ledger_date(date, 'balance', '--balance-format', BALANCE_FORMAT, '--no-total', '--flat', '--market', aregex(config['assets_account']), aregex(config['liabilities_account']), aregex(config['equity_account']))) - - # Add Unrealized Gains - accounts.append(Account(config['unrealized_gains'], -unrealized_gains_amt)) - accounts.sort(key=lambda a: a.name) - - # Convert to cash basis - if cash: - accounts_map = make_account_tree(accounts) +class Account: + def __init__(self, name): + if not isinstance(name, str): + raise Exception('Account name must be a string') - for account in accounts[:]: - if amatch(config['liabilities_account'], account.name) or (amatch(config['assets_account'], account.name) and not any(amatch(x, account.name) for x in config['cash_asset_accounts'])): - drcr = parse_balance(run_ledger_date(date, 'balance', '--related', '--balance-format', BALANCE_FORMAT, '--no-total', '--flat', '--cost' if amatch(config['income_account'], account.name) or amatch(config['expenses_account'], account.name) else '--market', aregex(account.name))) - - for drcr_account in drcr: - accounts_map[drcr_account.name].balance -= drcr_account.balance - - accounts.remove(account) - del accounts_map[account.name] + self.name = name + + self.parent = None + self.children = [] + + def __repr__(self): + return ''.format(self.name) + + @property + def bits(self): + return self.name.split(':') + + def matches(self, needle): + if self.name == needle or self.name.startswith(needle + ':'): + return True + return False + + def insert_into_tree(self, accounts): + if ':' in self.name: + parent_name = self.name[:self.name.rindex(':')] + if parent_name not in accounts: + parent = Account(parent_name) + accounts[parent_name] = parent + parent.insert_into_tree(accounts) + + self.parent = accounts[parent_name] + if self not in self.parent.children: + self.parent.children.append(self) + + @property + def is_asset(self): + return self.matches(config['assets_account']) + @property + def is_liability(self): + return self.matches(config['liabilities_account']) + @property + def is_equity(self): + return self.matches(config['equity_account']) + @property + def is_income(self): + return self.matches(config['income_account']) + @property + def is_expense(self): + return self.matches(config['expenses_account']) + + #def get_balance(self, date, is_market=None): + # if is_market is None: + # if self.is_income or self.is_expense: + # basis = '--cost' + # else: + # basis = '--market' + # elif is_market == True: + # basis = '--market' + # else: + # basis = '--cost' + # + # output = run_ledger_date(date, 'balance', '--balance-format', '%(display_total)', '--no-total', '--flat', '--empty', basis, '--limit', 'account=~/^{}$/'.format(re.escape(self.name).replace('/', '\\/'))) + # + # return parse_amount(output) + +class Snapshot: + def __init__(self): + self.accounts = {} + self.balances = {} + + def get_account(self, account_name): + if account_name not in self.accounts: + account = Account(account_name) + self.accounts[account_name] = account + account.insert_into_tree(self.accounts) + + return self.accounts[account_name] + + def set_balance(self, account_name, balance): + if account_name not in self.accounts: + account = Account(account_name) + self.accounts[account_name] = account + account.insert_into_tree(self.accounts) + + if account_name not in self.balances: + self.balances[account_name] = Decimal(0) + + self.balances[account_name] = balance + + def get_balance(self, account_name): + if account_name not in self.accounts: + self.set_balance(account_name, Decimal(0)) + + if account_name not in self.balances: + self.balances[account_name] = Decimal(0) + + return self.balances[account_name] + + def get_total(self, account_name): + return self.get_balance(account_name) + sum(self.get_total(c.name) for c in self.accounts[account_name].children) + +def parse_amount(amount): + if amount == '' or amount == '0': + return Decimal(0) + if not amount.startswith(config['report_currency']): + raise Exception('Unexpected currency returned by ledger: {}'.format(amount)) + return Decimal(amount[len(config['report_currency']):]) + +def get_accounts(): + output = run_ledger('balance', '--balance-format', '%(account)\n', '--no-total', '--flat', '--empty') + account_names = output.rstrip('\n').split('\n') + + accounts = {n: Account(n) for n in account_names} + + for account in list(accounts.values()): + account.insert_into_tree(accounts) return accounts -# Calculate trial balance -def trial_balance(date, pstart, cash=False): +# Raw Ledger output, unlikely to balance +def get_raw_snapshot(date, basis=None): + snapshot = Snapshot() + + # Get balances from Ledger + output = ( + run_ledger_date(date, 'balance', '--balance-format', '%(quoted(account)),%(quoted(display_total))\n', '--no-total', '--flat', '--empty', basis if basis is not None else '--market', config['assets_account'], config['liabilities_account'], config['equity_account']) + + run_ledger_date(date, 'balance', '--balance-format', '%(quoted(account)),%(quoted(display_total))\n', '--no-total', '--flat', '--empty', basis if basis is not None else '--cost', config['income_account'], config['expenses_account']) + ) + reader = csv.reader(output.splitlines()) + for row in reader: + snapshot.set_balance(row[0], parse_amount(row[1])) + + return snapshot + +# Ledger output, adjusted for Unrealized Gains +def get_snapshot(date): + snapshot_cost = get_raw_snapshot(date, '--cost') + snapshot = get_raw_snapshot(date) + + market_total = Decimal(0) + cost_total = Decimal(0) + + # Calculate unrealized gains + for account in snapshot.accounts.values(): + if account.is_asset or account.is_liability: + market_total += snapshot.get_balance(account.name) + cost_total += snapshot_cost.get_balance(account.name) + + # Add Unrealized Gains account + unrealized_gains_amt = market_total - cost_total + snapshot.set_balance(config['unrealized_gains'], snapshot.get_balance(config['unrealized_gains']) - unrealized_gains_amt) + + return snapshot + +# Ledger output, simulating closing of books +def trial_balance(date, pstart): # Get balances at period start - accounts_pstart = get_accounts(pstart - timedelta(days=1), cash) - accounts_map_pstart = make_account_tree(accounts_pstart) + snapshot_pstart = get_snapshot(pstart) # Get balances at date - accounts = get_accounts(date, cash) + snapshot = get_snapshot(date) - # Adjust Retained Earnings + # Calculate Retained Earnings, and adjust income/expense accounts total_pandl = Decimal(0) - if config['income_account'] in accounts_map_pstart: - total_pandl = accounts_map_pstart[config['income_account']].total() - if config['expenses_account'] in accounts_map_pstart: - total_pandl += accounts_map_pstart[config['expenses_account']].total() + for account in snapshot_pstart.accounts.values(): + if account.is_income or account.is_expense: + total_pandl += snapshot_pstart.get_balance(account.name) - next(a for a in accounts if a.name == config['retained_earnings']).balance += total_pandl + # Add Retained Earnings account + snapshot.set_balance(config['retained_earnings'], snapshot.get_balance(config['retained_earnings']) + total_pandl) # Adjust income/expense accounts - for account in accounts: - if amatch(config['income_account'], account.name) or amatch(config['expenses_account'], account.name): - if account.name in accounts_map_pstart: - account.balance -= accounts_map_pstart[account.name].balance + for account in snapshot.accounts.values(): + if account.is_income or account.is_expense: + snapshot.set_balance(account.name, snapshot.get_balance(account.name) - snapshot_pstart.get_balance(account.name)) - return accounts - -# Calculate profit and loss -def pandl(date_beg, date_end, cash=False): - accounts = trial_balance(date_end, date_beg, cash) - - for account in accounts[:]: - if not (amatch(config['income_account'], account.name) or amatch(config['expenses_account'], account.name)): - accounts.remove(account) - - return accounts + return snapshot diff --git a/ledger_pyreport/static/main.css b/ledger_pyreport/static/main.css index 1787173..6a9fddb 100644 --- a/ledger_pyreport/static/main.css +++ b/ledger_pyreport/static/main.css @@ -57,8 +57,9 @@ table.ledger tr.total td { border-bottom: 1pt solid black; } -table.two-amounts tr td:nth-child(2), table.two-amounts tr td:nth-child(3) { +table.ledger tr td:not(:first-child), table.ledger tr th:not(:first-child) { text-align: right; + width: 6em; } @media screen {