RunasSudo
3fa8f8a829
Per Ledger behaviour, commodities considered equivalent if same name regardless of whether prefix or suffix Commodity display drawn from Ledger data, no longer explicitly specified in config
354 lines
17 KiB
Python
354 lines
17 KiB
Python
# 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 <https://www.gnu.org/licenses/>.
|
|
|
|
from . import accounting
|
|
from . import ledger
|
|
from .config import config
|
|
from .model import *
|
|
|
|
from datetime import datetime, timedelta
|
|
from decimal import Decimal
|
|
import flask
|
|
import itertools
|
|
|
|
app = flask.Flask(__name__, template_folder='jinja2')
|
|
|
|
@app.route('/')
|
|
def index():
|
|
date = datetime.now()
|
|
pstart = ledger.financial_year(date)
|
|
|
|
return flask.render_template('index.html', date=date, pstart=pstart)
|
|
|
|
@app.route('/trial')
|
|
def trial():
|
|
date = datetime.strptime(flask.request.args['date'], '%Y-%m-%d')
|
|
pstart = datetime.strptime(flask.request.args['pstart'], '%Y-%m-%d')
|
|
compare = int(flask.request.args['compare'])
|
|
cash = flask.request.args.get('cash', False)
|
|
|
|
if compare == 0:
|
|
# Get trial balance
|
|
l = ledger.raw_transactions_at_date(date)
|
|
report_commodity = l.get_commodity(config['report_commodity'])
|
|
if cash:
|
|
l = accounting.ledger_to_cash(l, report_commodity)
|
|
trial_balance = accounting.trial_balance(l, date, pstart, report_commodity)
|
|
|
|
total_dr = Amount(0, report_commodity)
|
|
total_cr = Amount(0, report_commodity)
|
|
|
|
for account in l.accounts.values():
|
|
# Display in "cost basis" as we have already accounted for unrealised gains
|
|
balance = trial_balance.get_balance(account).exchange(report_commodity, True)
|
|
if balance > 0:
|
|
total_dr += balance
|
|
else:
|
|
total_cr -= balance
|
|
|
|
return flask.render_template('trial.html', date=date, pstart=pstart, trial_balance=trial_balance, accounts=sorted(l.accounts.values(), key=lambda a: a.name), total_dr=total_dr, total_cr=total_cr, report_commodity=report_commodity)
|
|
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)]
|
|
|
|
l = ledger.raw_transactions_at_date(date)
|
|
report_commodity = l.get_commodity(config['report_commodity'])
|
|
if cash:
|
|
l = accounting.ledger_to_cash(l, report_commodity)
|
|
trial_balances = [accounting.trial_balance(l.clone(), d, p, report_commodity) for d, p in zip(dates, pstarts)]
|
|
|
|
# Delete accounts with always zero balances
|
|
accounts = sorted(l.accounts.values(), key=lambda a: a.name)
|
|
for account in accounts[:]:
|
|
if all(t.get_balance(account).exchange(report_commodity, True).near_zero for t in trial_balances):
|
|
accounts.remove(account)
|
|
|
|
return flask.render_template('trial_multiple.html', trial_balances=trial_balances, accounts=accounts, report_commodity=report_commodity, cash=cash)
|
|
|
|
@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')
|
|
compare = int(flask.request.args['compare'])
|
|
cash = flask.request.args.get('cash', False)
|
|
|
|
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)]
|
|
|
|
l = ledger.raw_transactions_at_date(date)
|
|
report_commodity = l.get_commodity(config['report_commodity'])
|
|
if cash:
|
|
l = accounting.ledger_to_cash(l, report_commodity)
|
|
balance_sheets = [accounting.balance_sheet(accounting.trial_balance(l.clone(), d, p, report_commodity)) for d, p in zip(dates, pstarts)]
|
|
|
|
# Delete accounts with always zero balances
|
|
accounts = list(l.accounts.values())
|
|
for account in accounts[:]:
|
|
if all(b.get_balance(account).exchange(report_commodity, True).near_zero and b.get_total(account).exchange(report_commodity, True).near_zero for b in balance_sheets):
|
|
accounts.remove(account)
|
|
|
|
return flask.render_template('balance.html', ledger=l, balance_sheets=balance_sheets, accounts=accounts, config=config, report_commodity=report_commodity, cash=cash)
|
|
|
|
def describe_period(date_end, date_beg):
|
|
if date_end == (date_beg.replace(year=date_beg.year + 1) - timedelta(days=1)):
|
|
return 'year ended {}'.format(date_end.strftime('%d %B %Y'))
|
|
elif date_beg == ledger.financial_year(date_end):
|
|
return 'financial year to {}'.format(date_end.strftime('%d %B %Y'))
|
|
else:
|
|
return 'period from {} to {}'.format(date_beg.strftime('%d %B %Y'), date_end.strftime('%d %B %Y'))
|
|
|
|
@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')
|
|
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)]
|
|
|
|
l = ledger.raw_transactions_at_date(date_end)
|
|
report_commodity = l.get_commodity(config['report_commodity'])
|
|
if cash:
|
|
l = accounting.ledger_to_cash(l, report_commodity)
|
|
pandls = [accounting.trial_balance(l.clone(), de, db, report_commodity) for de, db in zip(dates_end, dates_beg)]
|
|
|
|
# Delete accounts with always zero balances
|
|
accounts = list(l.accounts.values())
|
|
for account in accounts[:]:
|
|
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_commodity=report_commodity, cash=cash, scope=scope)
|
|
|
|
@app.route('/cashflow')
|
|
def cashflow():
|
|
date_beg = datetime.strptime(flask.request.args['date_beg'], '%Y-%m-%d')
|
|
date_end = datetime.strptime(flask.request.args['date_end'], '%Y-%m-%d')
|
|
compare = int(flask.request.args['compare'])
|
|
method = flask.request.args['method']
|
|
|
|
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)]
|
|
|
|
l = ledger.raw_transactions_at_date(date_end)
|
|
report_commodity = l.get_commodity(config['report_commodity'])
|
|
|
|
cash_accounts = [a for a in l.accounts.values() if a.is_cash]
|
|
|
|
# Calculate opening and closing cash
|
|
opening_balances = []
|
|
closing_balances = []
|
|
cashflows = []
|
|
profits = []
|
|
for de, db in zip(dates_end, dates_beg):
|
|
tb = accounting.trial_balance(l.clone(), db - timedelta(days=1), db, report_commodity)
|
|
opening_balances.append(sum((tb.get_balance(a) for a in cash_accounts), Balance()).exchange(report_commodity, True))
|
|
|
|
tb = accounting.trial_balance(l.clone(), de, db, report_commodity)
|
|
closing_balances.append(sum((tb.get_balance(a) for a in cash_accounts), Balance()).exchange(report_commodity, True))
|
|
|
|
if method == 'direct':
|
|
# Determine transactions affecting cash assets
|
|
cashflows.append(accounting.account_flows(tb.ledger, de, db, cash_accounts, True))
|
|
else:
|
|
# Determine net profit (loss)
|
|
profits.append(-(tb.get_total(tb.ledger.get_account(config['income_account'])) + tb.get_total(tb.ledger.get_account(config['expenses_account'])) + tb.get_total(tb.ledger.get_account(config['oci_account']))).exchange(report_commodity, True))
|
|
|
|
# Determine transactions affecting equity, liabilities and non-cash assets
|
|
noncash_accounts = [a for a in l.accounts.values() if a.is_equity or a.is_liability or (a.is_asset and not a.is_cash)]
|
|
cashflows.append(accounting.account_flows(tb.ledger, de, db, noncash_accounts, False))
|
|
|
|
# Delete accounts with always zero balances
|
|
accounts = list(l.accounts.values())
|
|
for account in accounts[:]:
|
|
if all(p.get_balance(account) == 0 and p.get_total(account) == 0 for p in cashflows):
|
|
accounts.remove(account)
|
|
|
|
if method == 'direct':
|
|
return flask.render_template('cashflow_direct.html', period=describe_period(date_end, date_beg), ledger=l, cashflows=cashflows, opening_balances=opening_balances, closing_balances=closing_balances, accounts=accounts, config=config, report_commodity=report_commodity)
|
|
else:
|
|
return flask.render_template('cashflow_indirect.html', period=describe_period(date_end, date_beg), ledger=l, cashflows=cashflows, profits=profits, opening_balances=opening_balances, closing_balances=closing_balances, accounts=accounts, config=config, report_commodity=report_commodity)
|
|
|
|
@app.route('/transactions')
|
|
def transactions():
|
|
date_beg = datetime.strptime(flask.request.args['date_beg'], '%Y-%m-%d')
|
|
date_end = datetime.strptime(flask.request.args['date_end'], '%Y-%m-%d')
|
|
account = flask.request.args.get('account', None)
|
|
cash = flask.request.args.get('cash', False)
|
|
commodity = flask.request.args.get('commodity', False)
|
|
|
|
# General ledger
|
|
l = ledger.raw_transactions_at_date(date_end)
|
|
report_commodity = l.get_commodity(config['report_commodity'])
|
|
if cash:
|
|
l = accounting.ledger_to_cash(l, report_commodity)
|
|
|
|
# Unrealized gains
|
|
l = accounting.trial_balance(l, date_end, date_beg, report_commodity).ledger
|
|
|
|
if not account:
|
|
# General Ledger
|
|
transactions = [t for t in l.transactions if t.date <= date_end and t.date >= date_beg]
|
|
|
|
total_dr = sum((p.amount for t in transactions for p in t.postings if p.amount > 0), Balance()).exchange(report_commodity, True)
|
|
total_cr = sum((p.amount for t in transactions for p in t.postings if p.amount < 0), Balance()).exchange(report_commodity, True)
|
|
|
|
return flask.render_template('transactions.html', date_beg=date_beg, date_end=date_end, period=describe_period(date_end, date_beg), account=None, ledger=l, transactions=transactions, total_dr=total_dr, total_cr=total_cr, report_commodity=report_commodity, cash=cash)
|
|
elif commodity:
|
|
# Account Transactions with commodity detail
|
|
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)]
|
|
|
|
# Use trial_balance_raw because ledger is already adjusted for unrealised gains, etc.
|
|
opening_balance = accounting.trial_balance_raw(l, date_beg - timedelta(days=1), date_beg).get_balance(account).clean()
|
|
closing_balance = accounting.trial_balance_raw(l, date_end, date_beg).get_balance(account).clean()
|
|
|
|
def matching_posting(transaction, amount):
|
|
return next((p for p in transaction.postings if p.account == account and p.amount.commodity == amount.commodity), None)
|
|
|
|
return flask.render_template('transactions_commodity.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_commodity=report_commodity, cash=cash, timedelta=timedelta, matching_posting=matching_posting)
|
|
else:
|
|
# Account 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_raw(l, date_beg - timedelta(days=1), date_beg).get_balance(account).exchange(report_commodity, True)
|
|
closing_balance = accounting.trial_balance_raw(l, date_end, date_beg).get_balance(account).exchange(report_commodity, 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_commodity=report_commodity, cash=cash, timedelta=timedelta)
|
|
|
|
@app.route('/transaction')
|
|
def transaction():
|
|
tid = flask.request.args['tid']
|
|
cash = flask.request.args.get('cash', False)
|
|
commodity = flask.request.args.get('commodity', False)
|
|
|
|
# General ledger
|
|
l = ledger.raw_transactions_at_date(None)
|
|
report_commodity = l.get_commodity(config['report_commodity'])
|
|
if cash:
|
|
l = accounting.ledger_to_cash(l, report_commodity)
|
|
|
|
transaction = next((t for t in l.transactions if str(t.id) == tid))
|
|
|
|
if commodity:
|
|
total_dr = sum((p.amount for p in transaction.postings if p.amount > 0), Balance()).clean()
|
|
total_cr = sum((p.amount for p in transaction.postings if p.amount < 0), Balance()).clean()
|
|
totals = itertools.zip_longest(total_dr.amounts, total_cr.amounts)
|
|
return flask.render_template('transaction_commodity.html', ledger=l, transaction=transaction, totals=totals, total_dr=total_dr.exchange(report_commodity, True), total_cr=total_cr.exchange(report_commodity, True), report_commodity=report_commodity, cash=cash)
|
|
else:
|
|
total_dr = sum((p.amount for p in transaction.postings if p.amount > 0), Balance()).exchange(report_commodity, True)
|
|
total_cr = sum((p.amount for p in transaction.postings if p.amount < 0), Balance()).exchange(report_commodity, True)
|
|
return flask.render_template('transaction.html', ledger=l, transaction=transaction, total_dr=total_dr, total_cr=total_cr, report_commodity=report_commodity, cash=cash)
|
|
|
|
# Template filters
|
|
|
|
@app.template_filter('a')
|
|
def filter_amount(amt, link=None):
|
|
if amt.near_zero:
|
|
amt_str = '0.00'
|
|
is_pos = True
|
|
elif amt >= 0:
|
|
amt_str = '{:,.2f}'.format(amt.amount).replace(',', ' ') # Narrow no-break space
|
|
is_pos = True
|
|
else:
|
|
amt_str = '{:,.2f}'.format(-amt.amount).replace(',', ' ')
|
|
is_pos = False
|
|
|
|
if link:
|
|
if is_pos:
|
|
return flask.Markup('<a href="{}"><span title="{}">{}</span></a> '.format(link, amt.tostr(False), amt_str))
|
|
else:
|
|
return flask.Markup('<a href="{}"><span title="{}">({})</span></a>'.format(link, amt.tostr(False), amt_str))
|
|
else:
|
|
if is_pos:
|
|
return flask.Markup('<span title="{}">{}</span> '.format(amt.tostr(False), amt_str))
|
|
else:
|
|
return flask.Markup('<span title="{}">({})</span>'.format(amt.tostr(False), amt_str))
|
|
|
|
@app.template_filter('b')
|
|
def filter_amount_positive(amt):
|
|
return flask.Markup('<span title="{}">{:,.2f}</span>'.format(amt.tostr(False), amt.amount).replace(',', ' '))
|
|
|
|
@app.template_filter('bc')
|
|
def filter_commodity_positive(amt):
|
|
if amt.commodity.is_prefix:
|
|
return flask.Markup('<span title="{}">{}{:,.2f}</span>'.format(amt.tostr(False), amt.commodity.name, amt.amount).replace(',', ' '))
|
|
else:
|
|
return flask.Markup('<span title="{}">{:,.2f} {}</span>'.format(amt.tostr(False), amt.amount, amt.commodity.name).replace(',', ' '))
|
|
|
|
@app.template_filter('bt')
|
|
def filter_commodity_table_positive(amt, show_price, link=None):
|
|
result = []
|
|
if amt.commodity.is_prefix:
|
|
amt_str = filter_commodity_positive(amt)
|
|
cur_str = ''
|
|
else:
|
|
amt_str = '{:,.2f}'.format(amt.amount).replace(',', ' ')
|
|
cur_str = amt.commodity.name
|
|
|
|
amt_full = amt.tostr(False)
|
|
|
|
result.append('<td style="text-align: right;"><a href="{}"><span title="{}">{}</span></a></td>'.format(link, amt_full, amt_str) if link else '<td style="text-align: right;"><span title="{}">{}</span></td>'.format(amt_full, amt_str))
|
|
result.append('<td><span title="{}">{}</span></td>'.format(amt_full, cur_str))
|
|
|
|
if show_price:
|
|
if amt.commodity.price:
|
|
result.append('<td><span title="{}">{{{}}}</span></td>'.format(amt_full, filter_commodity_positive(amt.commodity.price)))
|
|
else:
|
|
result.append('<td></td>')
|
|
|
|
return flask.Markup(''.join(result))
|
|
|
|
# Debug views
|
|
|
|
@app.route('/debug/noncash_transactions')
|
|
def debug_noncash_transactions():
|
|
date = datetime.strptime(flask.request.args['date'], '%Y-%m-%d')
|
|
pstart = datetime.strptime(flask.request.args['pstart'], '%Y-%m-%d')
|
|
account = flask.request.args.get('account')
|
|
|
|
l = ledger.raw_transactions_at_date(date)
|
|
report_commodity = l.get_commodity(config['report_commodity'])
|
|
account = l.get_account(account)
|
|
|
|
transactions = [t for t in l.transactions if any(p.account == account for p in t.postings)]
|
|
|
|
accounting.account_to_cash(account, report_commodity)
|
|
|
|
return flask.render_template('debug_noncash_transactions.html', date=date, pstart=pstart, period=describe_period(date, pstart), account=account, ledger=l, transactions=transactions, report_commodity=report_commodity)
|
|
|
|
@app.route('/debug/imbalances')
|
|
def debug_imbalances():
|
|
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)
|
|
|
|
l = ledger.raw_transactions_at_date(date)
|
|
report_commodity = l.get_commodity(config['report_commodity'])
|
|
if cash:
|
|
l = accounting.ledger_to_cash(l, report_commodity)
|
|
|
|
transactions = [t for t in l.transactions if t.date <= date and t.date >= pstart and not sum((p.amount for p in t.postings), Balance()).exchange(report_commodity, True).near_zero]
|
|
|
|
total_dr = sum((p.amount for t in transactions for p in t.postings if p.amount > 0), Balance()).exchange(report_commodity, True)
|
|
total_cr = sum((p.amount for t in transactions for p in t.postings if p.amount < 0), Balance()).exchange(report_commodity, True)
|
|
|
|
return flask.render_template('transactions.html', date=date, pstart=pstart, period=describe_period(date, pstart), account=None, ledger=l, transactions=transactions, total_dr=total_dr, total_cr=total_cr, report_commodity=report_commodity, cash=cash)
|