From 706ac8ae8777cd456284c3c30f83128594f70307 Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Sat, 15 Feb 2020 15:50:33 +1100 Subject: [PATCH] Transaction listing report --- ledger_pyreport/__init__.py | 33 +++++++- ledger_pyreport/accounting.py | 3 + ledger_pyreport/jinja2/balance.html | 26 ++++--- ledger_pyreport/jinja2/index.html | 6 +- ledger_pyreport/jinja2/pandl.html | 18 +++-- ledger_pyreport/jinja2/transactions.html | 89 ++++++++++++++++++++++ ledger_pyreport/jinja2/trial.html | 13 ++-- ledger_pyreport/jinja2/trial_multiple.html | 8 +- ledger_pyreport/ledger.py | 81 +++++++++++++++----- ledger_pyreport/static/main.css | 12 ++- 10 files changed, 236 insertions(+), 53 deletions(-) create mode 100644 ledger_pyreport/jinja2/transactions.html diff --git a/ledger_pyreport/__init__.py b/ledger_pyreport/__init__.py index 7ac5ebf..692eb4b 100644 --- a/ledger_pyreport/__init__.py +++ b/ledger_pyreport/__init__.py @@ -51,7 +51,7 @@ def trial(): else: total_cr -= balance - return flask.render_template('trial.html', date=date, trial_balance=trial_balance, total_dr=total_dr, total_cr=total_cr) + return flask.render_template('trial.html', 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)] @@ -65,7 +65,7 @@ def trial(): 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) + return flask.render_template('trial_multiple.html', trial_balances=trial_balances, accounts=accounts) @app.route('/balance') def balance(): @@ -85,7 +85,7 @@ def balance(): 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', dates=dates, balance_sheets=balance_sheets, accounts=accounts, config=ledger.config) + return flask.render_template('balance.html', balance_sheets=balance_sheets, accounts=accounts, config=ledger.config) @app.route('/pandl') def pandl(): @@ -112,7 +112,32 @@ def pandl(): else: 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, dates_end=dates_end, pandls=pandls, accounts=accounts, config=ledger.config) + return flask.render_template('pandl.html', period=period, pandls=pandls, accounts=accounts, config=ledger.config) + +@app.route('/transactions') +def transactions(): + date = datetime.strptime(flask.request.args['date'], '%Y-%m-%d') + pstart = datetime.strptime(flask.request.args['pstart'], '%Y-%m-%d') + + trial_balance_pstart = ledger.trial_balance(pstart, pstart) + account = trial_balance_pstart.get_account(flask.request.args['account']) + opening_balance = trial_balance_pstart.get_balance(account.name) + + balance = opening_balance + transactions = account.get_transactions(date, pstart) + for transaction in transactions: + for posting in transaction.postings[:]: + if posting.account == account.name: + transaction.postings.remove(posting) + else: + posting.amount = -posting.amount # In terms of effect on this account + balance += posting.amount + posting.balance = balance + + trial_balance = ledger.trial_balance(date, pstart) + closing_balance = trial_balance.get_balance(account.name) + + return flask.render_template('transactions.html', date=date, pstart=pstart, account=account, transactions=transactions, opening_balance=opening_balance, closing_balance=closing_balance) @app.template_filter('a') def filter_amount(amt): diff --git a/ledger_pyreport/accounting.py b/ledger_pyreport/accounting.py index a0dfd3c..3eac578 100644 --- a/ledger_pyreport/accounting.py +++ b/ledger_pyreport/accounting.py @@ -14,6 +14,9 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import csv +from decimal import Decimal + from . import ledger # Generate balance sheet diff --git a/ledger_pyreport/jinja2/balance.html b/ledger_pyreport/jinja2/balance.html index dbac127..0549a50 100644 --- a/ledger_pyreport/jinja2/balance.html +++ b/ledger_pyreport/jinja2/balance.html @@ -18,10 +18,16 @@ {% macro print_rows(account, invert=False, level=0) %} - {{ account.bits[-1] }} + + {% if balance_sheets|length == 1 %} + {{ account.bits[-1] }} + {% else %} + {{ account.bits[-1] }} + {% endif %} + {% 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 %} + {% if amount != 0 %}{{ amount|a }}{% endif %} {% endfor %} @@ -36,9 +42,9 @@ {% if isfirst and year_headers %} {{ account_class.bits[-1] }} {{ label }} - {% for date in dates %}{{ date.strftime('%Y') }} {% endfor %} + {% for balance_sheet in balance_sheets %}{{ balance_sheet.date.strftime('%Y') }} {% endfor %} {% else %} - {{ account_class.bits[-1] }} {{ label }} + {{ account_class.bits[-1] }} {{ label }} {% endif %} @@ -63,27 +69,27 @@ - Balance Sheet as at {{ dates[0].strftime('%d %B %Y') }} + Balance Sheet as at {{ balance_sheets[0].date.strftime('%d %B %Y') }}

Balance Sheet

-

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

+

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

- +
{# Assets #} - + {{ do_accounts(config['assets_account'], 'Assets', False, True) }} {# 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) }} diff --git a/ledger_pyreport/jinja2/index.html b/ledger_pyreport/jinja2/index.html index 228d6f3..8f5d6ae 100644 --- a/ledger_pyreport/jinja2/index.html +++ b/ledger_pyreport/jinja2/index.html @@ -29,7 +29,7 @@ - + {##}
  • @@ -37,7 +37,7 @@ - + {##}
  • @@ -45,7 +45,7 @@ - + {##}
  • diff --git a/ledger_pyreport/jinja2/pandl.html b/ledger_pyreport/jinja2/pandl.html index 55bb499..d967ad6 100644 --- a/ledger_pyreport/jinja2/pandl.html +++ b/ledger_pyreport/jinja2/pandl.html @@ -18,10 +18,16 @@ {% macro print_rows(account, invert=False, level=0) %} - + {% for pandl in pandls %} {% set amount = pandl.get_balance(account.name) * (-1 if invert else 1) %} - + {% endfor %} @@ -34,10 +40,10 @@ {% if year_headers %} {# CSS hackery to centre-align the heading #} - - {% for date in dates_end %}{% endfor %} + + {% for pandl in pandls %}{% endfor %} {% else %} - + {% endif %} @@ -63,7 +69,7 @@

    Income Statement

    For the {{ period }}

    -
    Assets
    Assets
     
    Liabilities
    Liabilities
     
    Equity
    Equity
    {{ account.bits[-1] }} + {% if pandls|length == 1 %} + {{ account.bits[-1] }} + {% else %} + {{ account.bits[-1] }} + {% endif %} + {% if amount != 0 %}{{ amount|a }}{% endif %}{% if amount != 0 %}{{ amount|a }}{% endif %}
    {{ label }}{{ date.strftime('%Y') }} {{ label }}{{ pandl.date.strftime('%Y') }} {{ label }}{{ label }}
    +
    {{ do_accounts(config['income_account'], 'Income', True, True) }} diff --git a/ledger_pyreport/jinja2/transactions.html b/ledger_pyreport/jinja2/transactions.html new file mode 100644 index 0000000..e81f450 --- /dev/null +++ b/ledger_pyreport/jinja2/transactions.html @@ -0,0 +1,89 @@ +{# + 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 . +#} + + + + + + Account Transactions for {{ account.name }} as at {{ date.strftime('%d %B %Y') }} + + + + +

    Account Transactions

    +

    For {{ account.name }}

    +

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

    + +
     
    + + + + + + + + + + + + + + + + + {% for transaction in transactions %} + {% for posting in transaction.postings %} + + + + + + + + + {% endfor %} + {% endfor %} + + + + + + + + +
    DateDescriptionAccountDrCrBalance
    {{ pstart.strftime('%Y-%m-%d') }}Opening Balance + {% if opening_balance >= 0 %} + {{ opening_balance|b }} Dr + {% else %} + {{ -opening_balance|b }} Cr + {% endif %} +
    {% if loop.index0 == 0 %}{{ transaction.date.strftime('%Y-%m-%d') }}{% endif %}{% if loop.index0 == 0 %}{{ transaction.payee }}{% endif %}{{ (posting.account|e).__str__().replace(':', ':')|safe }}{% if posting.amount > 0 %}{{ posting.amount|b }}{% endif %}{% if posting.amount < 0 %}{{ -posting.amount|b }}{% endif %} + {% if posting.balance >= 0 %} + {{ posting.balance|b }} Dr + {% else %} + {{ -posting.balance|b }} Cr + {% endif %} +
    {{ date.strftime('%Y-%m-%d') }}Closing Balance + {% if closing_balance >= 0 %} + {{ closing_balance|b }} Dr + {% else %} + {{ -closing_balance|b }} Cr + {% endif %} +
    + + diff --git a/ledger_pyreport/jinja2/trial.html b/ledger_pyreport/jinja2/trial.html index 7bd6c26..709d610 100644 --- a/ledger_pyreport/jinja2/trial.html +++ b/ledger_pyreport/jinja2/trial.html @@ -20,15 +20,15 @@ - Trial Balance as at {{ date.strftime('%d %B %Y') }} + Trial Balance as at {{ trial_balance.date.strftime('%d %B %Y') }}

    Trial Balance

    -

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

    +

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

    - +
    @@ -36,11 +36,12 @@ {% for account in trial_balance.accounts.values() %} {% set balance = trial_balance.get_balance(account.name) %} + {% set trn_url = "/transactions?" + {'date': trial_balance.date.strftime('%Y-%m-%d'), 'pstart': trial_balance.pstart.strftime('%Y-%m-%d'), 'account': account.name}|urlencode %} {% if balance != 0 %} - - - + + + {% endif %} {% endfor %} diff --git a/ledger_pyreport/jinja2/trial_multiple.html b/ledger_pyreport/jinja2/trial_multiple.html index 4ee5892..775b6a2 100644 --- a/ledger_pyreport/jinja2/trial_multiple.html +++ b/ledger_pyreport/jinja2/trial_multiple.html @@ -20,18 +20,18 @@ - Trial Balance as at {{ dates[0].strftime('%d %B %Y') }} + Trial Balance as at {{ trial_balances[0].date.strftime('%d %B %Y') }}

    Trial Balance

    -

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

    +

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

    -
    Dr
    {{ account.name }}{% if balance > 0 %}{{ balance|b }}{% endif %}{% if balance < 0 %}{{ -balance|b }}{% endif %}{{ account.name }}{% if balance > 0 %}{{ balance|b }}{% endif %}{% if balance < 0 %}{{ -balance|b }}{% endif %}
    +
    - {% for date in dates %}{% endfor %} + {% for trial_balance in trial_balances %}{% endfor %} {% for account in accounts %} diff --git a/ledger_pyreport/ledger.py b/ledger_pyreport/ledger.py index e660d39..de89ddf 100644 --- a/ledger_pyreport/ledger.py +++ b/ledger_pyreport/ledger.py @@ -15,7 +15,7 @@ # along with this program. If not, see . import csv -from datetime import timedelta +from datetime import datetime, timedelta from decimal import Decimal import re import subprocess @@ -28,7 +28,9 @@ with open('config.yml', 'r') as f: # 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) + ledger_args = ['ledger', '--args-only', '--file', config['ledger_file'], '-X', config['report_currency'], '--date-format', '%Y-%m-%d', '--unround'] + config['ledger_args'] + list(args) + #print(' '.join(ledger_args)) + proc = subprocess.Popen(ledger_args, encoding='utf-8', stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = proc.communicate() if stderr: @@ -98,24 +100,64 @@ class Account: @property def is_expense(self): return self.matches(config['expenses_account']) + @property + def is_cash(self): + return any(self.matches(a) for a in config['cash_asset_accounts']) - #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) + def get_transactions(self, date, pstart): + transactions = [] + + output = run_ledger_date(date, 'register', '--no-rounding', '--register-format', '%(quoted(format_date(date))),%(quoted(payee)),%(quoted(account)),%(quoted(display_amount))\n', '--limit', 'account=~/^{}$/'.format(re.escape(self.name).replace('/', '\\/')), '--cost' if self.is_income or self.is_expense else '--market', '--related-all', '--no-revalued') + + output += run_ledger_date(date, 'register', '--no-rounding', '--register-format', '%(quoted(format_date(date))),%(quoted(payee)),%(quoted(account)),%(quoted(display_amount))\n', '--limit', 'account=~/^{}$/'.format(re.escape(self.name).replace('/', '\\/')), '--cost' if self.is_income or self.is_expense else '--market', '--revalued-only') + + reader = csv.reader(output.splitlines()) + for row in reader: + t_date = datetime.strptime(row[0], '%Y-%m-%d') + if t_date < pstart: + continue + + posting = Posting(row[2], parse_amount(row[3])) + + if posting.account == '': + posting.account = self.name + + if transactions and t_date == transactions[-1].date and row[1] == transactions[-1].payee: + # Posting for previous transaction + transactions[-1].postings.append(posting) + else: + # New transaction + transactions.append(Transaction(t_date, row[1], [posting])) + + transactions.sort(key=lambda t: t.date) + + # Balance transactions + for transaction in transactions: + t_total = sum(p.amount for p in transaction.postings) + if t_total != 0: + # Transaction requires balancing, probably due to unrealised gain/revaluation? + transaction.postings.append(Posting(config['unrealized_gains'], -t_total)) + + return transactions + +class Transaction: + def __init__(self, date, payee, postings): + self.date = date + self.payee = payee + self.postings = postings + +class Posting: + def __init__(self, account, amount): + self.account = account + self.amount = amount + + self.balance = None class Snapshot: - def __init__(self): + def __init__(self, date): + self.date = date + self.pstart = None + self.accounts = {} self.balances = {} @@ -170,7 +212,7 @@ def get_accounts(): # Raw Ledger output, unlikely to balance def get_raw_snapshot(date, basis=None): - snapshot = Snapshot() + snapshot = Snapshot(date) # Get balances from Ledger output = ( @@ -206,10 +248,11 @@ def get_snapshot(date): # Ledger output, simulating closing of books def trial_balance(date, pstart): # Get balances at period start - snapshot_pstart = get_snapshot(pstart) + snapshot_pstart = get_snapshot(pstart - timedelta(days=1)) # Get balances at date snapshot = get_snapshot(date) + snapshot.pstart = pstart # Calculate Retained Earnings, and adjust income/expense accounts total_pandl = Decimal(0) diff --git a/ledger_pyreport/static/main.css b/ledger_pyreport/static/main.css index 6a9fddb..a18162b 100644 --- a/ledger_pyreport/static/main.css +++ b/ledger_pyreport/static/main.css @@ -57,11 +57,21 @@ table.ledger tr.total td { border-bottom: 1pt solid black; } -table.ledger tr td:not(:first-child), table.ledger tr th:not(:first-child) { +table.ledger.onedesc tr td:not(:first-child), table.ledger.onedesc tr th:not(:first-child) { text-align: right; width: 6em; } +table.ledger a { + color: black; + text-decoration: none; +} + +table.ledger a:hover { + color: blue; + text-decoration: underline; +} + @media screen { body { padding: 2em;
    {{ date.strftime('%Y') }} {{ trial_balance.date.strftime('%Y') }}