Rewrite backend again to process transactions fully ourselves
Reimplemented up to trial balance/account transactions
This commit is contained in:
parent
be82615598
commit
2bc6bb0e22
@ -1,7 +1,7 @@
|
|||||||
# Set up how we will call Ledger
|
# Set up how we will call Ledger
|
||||||
ledger_file: /path/to/ledger.journal
|
ledger_file: /path/to/ledger.journal
|
||||||
ledger_args: ['--pedantic', '--recursive-aliases']
|
ledger_args: ['--pedantic', '--recursive-aliases']
|
||||||
report_currency: $
|
report_currency: ['$', True] # True if prefix, False if suffix
|
||||||
|
|
||||||
# Tell ledger-pyreport about the top-level account categories
|
# Tell ledger-pyreport about the top-level account categories
|
||||||
assets_account: Assets
|
assets_account: Assets
|
||||||
|
@ -16,6 +16,8 @@
|
|||||||
|
|
||||||
from . import accounting
|
from . import accounting
|
||||||
from . import ledger
|
from . import ledger
|
||||||
|
from .config import config
|
||||||
|
from .model import *
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@ -39,36 +41,45 @@ def trial():
|
|||||||
|
|
||||||
if compare == 0:
|
if compare == 0:
|
||||||
# Get trial balance
|
# Get trial balance
|
||||||
trial_balance = ledger.trial_balance(date, pstart)
|
l = ledger.raw_transactions_at_date(date)
|
||||||
|
trial_balance = accounting.trial_balance(l, date, pstart)
|
||||||
|
|
||||||
total_dr = Decimal(0)
|
report_currency = Currency(*config['report_currency'])
|
||||||
total_cr = Decimal(0)
|
trial_balance = accounting.add_unrealized_gains(trial_balance, report_currency)
|
||||||
|
|
||||||
for account in trial_balance.accounts.values():
|
total_dr = Amount(0, report_currency)
|
||||||
balance = trial_balance.get_balance(account.name)
|
total_cr = Amount(0, report_currency)
|
||||||
|
|
||||||
|
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_currency, True)
|
||||||
if balance > 0:
|
if balance > 0:
|
||||||
total_dr += balance
|
total_dr += balance
|
||||||
else:
|
else:
|
||||||
total_cr -= balance
|
total_cr -= balance
|
||||||
|
|
||||||
return flask.render_template('trial.html', trial_balance=trial_balance, total_dr=total_dr, total_cr=total_cr)
|
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_currency=report_currency)
|
||||||
else:
|
else:
|
||||||
# Get multiple trial balances for comparison
|
# Get multiple trial balances for comparison
|
||||||
dates = [date.replace(year=date.year - i) for i in range(0, compare + 1)]
|
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)]
|
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)]
|
report_currency = Currency(*config['report_currency'])
|
||||||
|
l = ledger.raw_transactions_at_date(date)
|
||||||
|
trial_balances = [accounting.add_unrealized_gains(accounting.trial_balance(l, d, p), report_currency) for d, p in zip(dates, pstarts)]
|
||||||
|
|
||||||
# Delete accounts with always zero balances
|
# Delete accounts with always zero balances
|
||||||
accounts = list(trial_balances[0].accounts.values())
|
accounts = list(trial_balances[0].ledger.accounts.values())
|
||||||
for account in accounts[:]:
|
for account in accounts[:]:
|
||||||
if all(t.get_balance(account.name) == 0 for t in trial_balances):
|
if all(t.get_balance(account) == 0 for t in trial_balances):
|
||||||
accounts.remove(account)
|
accounts.remove(account)
|
||||||
|
|
||||||
return flask.render_template('trial_multiple.html', trial_balances=trial_balances, accounts=accounts)
|
return flask.render_template('trial_multiple.html', trial_balances=trial_balances, accounts=sorted(accounts, key=lambda a: a.name), report_currency=report_currency)
|
||||||
|
|
||||||
@app.route('/balance')
|
@app.route('/balance')
|
||||||
def balance():
|
def balance():
|
||||||
|
raise Exception('NYI')
|
||||||
|
|
||||||
date = datetime.strptime(flask.request.args['date'], '%Y-%m-%d')
|
date = datetime.strptime(flask.request.args['date'], '%Y-%m-%d')
|
||||||
pstart = datetime.strptime(flask.request.args['pstart'], '%Y-%m-%d')
|
pstart = datetime.strptime(flask.request.args['pstart'], '%Y-%m-%d')
|
||||||
compare = int(flask.request.args['compare'])
|
compare = int(flask.request.args['compare'])
|
||||||
@ -89,6 +100,8 @@ def balance():
|
|||||||
|
|
||||||
@app.route('/pandl')
|
@app.route('/pandl')
|
||||||
def pandl():
|
def pandl():
|
||||||
|
raise Exception('NYI')
|
||||||
|
|
||||||
date_beg = datetime.strptime(flask.request.args['date_beg'], '%Y-%m-%d')
|
date_beg = datetime.strptime(flask.request.args['date_beg'], '%Y-%m-%d')
|
||||||
date_end = datetime.strptime(flask.request.args['date_end'], '%Y-%m-%d')
|
date_end = datetime.strptime(flask.request.args['date_end'], '%Y-%m-%d')
|
||||||
compare = int(flask.request.args['compare'])
|
compare = int(flask.request.args['compare'])
|
||||||
@ -118,36 +131,45 @@ def pandl():
|
|||||||
def transactions():
|
def transactions():
|
||||||
date = datetime.strptime(flask.request.args['date'], '%Y-%m-%d')
|
date = datetime.strptime(flask.request.args['date'], '%Y-%m-%d')
|
||||||
pstart = datetime.strptime(flask.request.args['pstart'], '%Y-%m-%d')
|
pstart = datetime.strptime(flask.request.args['pstart'], '%Y-%m-%d')
|
||||||
|
account = flask.request.args.get('account', None)
|
||||||
|
|
||||||
trial_balance_pstart = ledger.trial_balance(pstart, pstart)
|
# General ledger
|
||||||
account = trial_balance_pstart.get_account(flask.request.args['account'])
|
l = ledger.raw_transactions_at_date(date)
|
||||||
opening_balance = trial_balance_pstart.get_balance(account.name)
|
|
||||||
|
|
||||||
balance = opening_balance
|
# Unrealized gains
|
||||||
transactions = account.get_transactions(date, pstart)
|
report_currency = Currency(*config['report_currency'])
|
||||||
for transaction in transactions:
|
l = accounting.add_unrealized_gains(accounting.trial_balance(l, date, pstart), report_currency).ledger
|
||||||
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)
|
if not account:
|
||||||
closing_balance = trial_balance.get_balance(account.name)
|
transactions = [t for t in l.transactions if t.date <= date and t.date >= pstart]
|
||||||
|
|
||||||
return flask.render_template('transactions.html', date=date, pstart=pstart, account=account, transactions=transactions, opening_balance=opening_balance, closing_balance=closing_balance)
|
total_dr = sum((p.amount for t in transactions for p in t.postings if p.amount > 0), Balance()).exchange(report_currency, True)
|
||||||
|
total_cr = sum((p.amount for t in transactions for p in t.postings if p.amount < 0), Balance()).exchange(report_currency, True)
|
||||||
|
|
||||||
|
return flask.render_template('transactions.html', date=date, pstart=pstart, account=None, ledger=l, transactions=transactions, total_dr=total_dr, total_cr=total_cr, report_currency=report_currency)
|
||||||
|
else:
|
||||||
|
account = l.get_account(account)
|
||||||
|
transactions = [t for t in l.transactions if t.date <= date and t.date >= pstart and any(p.account == account for p in t.postings)]
|
||||||
|
|
||||||
|
opening_balance = accounting.trial_balance(l, pstart, pstart).get_balance(account).exchange(report_currency, True)
|
||||||
|
closing_balance = accounting.trial_balance(l, date, pstart).get_balance(account).exchange(report_currency, True)
|
||||||
|
|
||||||
|
return flask.render_template('transactions.html', date=date, pstart=pstart, account=account, ledger=l, transactions=transactions, opening_balance=opening_balance, closing_balance=closing_balance, report_currency=report_currency)
|
||||||
|
|
||||||
@app.template_filter('a')
|
@app.template_filter('a')
|
||||||
def filter_amount(amt):
|
def filter_amount(amt):
|
||||||
if amt < 0.005 and amt >= -0.005:
|
if amt.amount < 0.005 and amt.amount >= -0.005:
|
||||||
return flask.Markup('0.00 ')
|
return flask.Markup('0.00 ')
|
||||||
elif amt > 0:
|
elif amt > 0:
|
||||||
return flask.Markup('{:,.2f} '.format(amt).replace(',', ' ')) # Narrow no-break space
|
return flask.Markup('{:,.2f} '.format(amt.amount).replace(',', ' ')) # Narrow no-break space
|
||||||
else:
|
else:
|
||||||
return flask.Markup('({:,.2f})'.format(-amt).replace(',', ' '))
|
return flask.Markup('({:,.2f})'.format(-amt.amount).replace(',', ' '))
|
||||||
|
|
||||||
@app.template_filter('b')
|
@app.template_filter('b')
|
||||||
def filter_amount_positive(amt):
|
def filter_amount_positive(amt):
|
||||||
return flask.Markup('{:,.2f}'.format(amt).replace(',', ' '))
|
return flask.Markup('{:,.2f}'.format(amt.amount).replace(',', ' '))
|
||||||
|
#return flask.Markup('{:,.2f} {}'.format(amt.amount, amt.currency.name).replace(',', ' '))
|
||||||
|
|
||||||
|
@app.template_filter('bb')
|
||||||
|
def filter_balance_positive(balance):
|
||||||
|
return flask.Markup('<br>'.join(filter_amount_positive(a) for a in balance.amounts))
|
||||||
|
@ -17,18 +17,36 @@
|
|||||||
import csv
|
import csv
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from . import ledger
|
from .model import *
|
||||||
|
|
||||||
# Generate balance sheet
|
def add_unrealized_gains(tb, currency):
|
||||||
|
for account in list(tb.ledger.accounts.values()):
|
||||||
|
if not account.is_market:
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_cost = tb.get_balance(account).exchange(currency, True)
|
||||||
|
total_market = tb.get_balance(account).exchange(currency, False, tb.date, tb.ledger)
|
||||||
|
unrealized_gain = total_market - total_cost
|
||||||
|
|
||||||
|
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))
|
||||||
|
tb.ledger.transactions.append(transaction)
|
||||||
|
|
||||||
|
return trial_balance(tb.ledger, tb.date, tb.pstart)
|
||||||
|
|
||||||
def balance_sheet(date, pstart):
|
def trial_balance(ledger, date, pstart):
|
||||||
# Get trial balance
|
tb = TrialBalance(ledger, date, pstart)
|
||||||
trial_balance = ledger.trial_balance(date, pstart)
|
|
||||||
|
|
||||||
# Calculate Profit/Loss
|
for transaction in ledger.transactions:
|
||||||
total_pandl = trial_balance.get_total(ledger.config['income_account']) + trial_balance.get_total(ledger.config['expenses_account'])
|
if transaction.date > date:
|
||||||
|
continue
|
||||||
|
|
||||||
|
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
|
||||||
|
else:
|
||||||
|
tb.balances[posting.account.name] = tb.get_balance(posting.account) + posting.amount
|
||||||
|
|
||||||
# Add Current Year Earnings account
|
return tb
|
||||||
trial_balance.set_balance(ledger.config['current_year_earnings'], trial_balance.get_balance(ledger.config['current_year_earnings']) + total_pandl)
|
|
||||||
|
|
||||||
return trial_balance
|
|
||||||
|
20
ledger_pyreport/config.py
Normal file
20
ledger_pyreport/config.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# 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/>.
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
with open('config.yml', 'r') as f:
|
||||||
|
config = yaml.safe_load(f)
|
@ -47,6 +47,13 @@
|
|||||||
<label>Compare <input name="compare" value="0" style="width: 2em;" oninput="txtc(this)"> periods</label>
|
<label>Compare <input name="compare" value="0" style="width: 2em;" oninput="txtc(this)"> periods</label>
|
||||||
{#<label><input name="cash" type="checkbox" oninput="chbc(this)"> Cash basis</label>#}
|
{#<label><input name="cash" type="checkbox" oninput="chbc(this)"> Cash basis</label>#}
|
||||||
</form></li>
|
</form></li>
|
||||||
|
|
||||||
|
<li><form action="{{ url_for('transactions') }}">
|
||||||
|
<button type="submit">General ledger</button>
|
||||||
|
<label>Date: <input name="date" value="{{ date.strftime('%Y-%m-%d') }}" style="width: 6em;" oninput="txtc(this)"></label>
|
||||||
|
<label>Period start: <input name="pstart" value="{{ pstart.strftime('%Y-%m-%d') }}" style="width: 6em;" oninput="txtc(this)"></label>
|
||||||
|
{#<label><input name="cash" type="checkbox" oninput="chbc(this)"> Cash basis</label>#}
|
||||||
|
</form></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
@ -20,13 +20,17 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Account Transactions for {{ account.name }} as at {{ date.strftime('%d %B %Y') }}</title>
|
<title>{% if account %}Account Transactions{% else %}General Ledger{% endif %} as at {{ date.strftime('%d %B %Y') }}</title>
|
||||||
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css" integrity="sha256-l85OmPOjvil/SOvVt3HnSSjzF1TUMyT9eV0c2BzEGzU=" crossorigin="anonymous">
|
<link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css" integrity="sha256-l85OmPOjvil/SOvVt3HnSSjzF1TUMyT9eV0c2BzEGzU=" crossorigin="anonymous">
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='main.css') }}">
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='main.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Account Transactions</h1>
|
{% if account %}
|
||||||
<h2 style="margin-bottom: 0;">For {{ account.name }}</h2>
|
<h1>Account Transactions</h1>
|
||||||
|
<h2 style="margin-bottom: 0;">For {{ account.name }}</h2>
|
||||||
|
{% else %}
|
||||||
|
<h1>General Ledger</h1>
|
||||||
|
{% endif %}
|
||||||
<h2>As at {{ date.strftime('%d %B %Y') }}</h2>
|
<h2>As at {{ date.strftime('%d %B %Y') }}</h2>
|
||||||
|
|
||||||
<table class="ledger">
|
<table class="ledger">
|
||||||
@ -36,56 +40,81 @@
|
|||||||
<th style="max-width: 8em;">Account</th>
|
<th style="max-width: 8em;">Account</th>
|
||||||
<th class="h1" style="text-align: right; width: 5em;">Dr</th>
|
<th class="h1" style="text-align: right; width: 5em;">Dr</th>
|
||||||
<th class="h1" style="text-align: right; width: 5em;">Cr</th>
|
<th class="h1" style="text-align: right; width: 5em;">Cr</th>
|
||||||
<th class="h1" style="text-align: right; width: 6em;">Balance</th>
|
{% if account %}<th class="h1" style="text-align: right; width: 6em;">Balance</th>{% endif %}
|
||||||
</tr>
|
|
||||||
<tr class="total">
|
|
||||||
<td>{{ pstart.strftime('%Y-%m-%d') }}</td>
|
|
||||||
<td>Opening Balance</td>
|
|
||||||
<td></td>
|
|
||||||
<td style="text-align: right;"></td>
|
|
||||||
<td style="text-align: right;"></td>
|
|
||||||
<td style="text-align: right;">
|
|
||||||
{% if opening_balance >= 0 %}
|
|
||||||
{{ opening_balance|b }} Dr
|
|
||||||
{% else %}
|
|
||||||
{{ -opening_balance|b }} Cr
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
{% set ns = namespace(balance=None) %}
|
||||||
|
{% if account %}
|
||||||
|
<tr class="total">
|
||||||
|
<td>{{ pstart.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td>Opening Balance</td>
|
||||||
|
<td></td>
|
||||||
|
<td style="text-align: right;"></td>
|
||||||
|
<td style="text-align: right;"></td>
|
||||||
|
<td style="text-align: right;">
|
||||||
|
{% if opening_balance >= 0 %}
|
||||||
|
{{ opening_balance|b }} Dr
|
||||||
|
{% else %}
|
||||||
|
{{ -opening_balance|b }} Cr
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% set ns.balance = opening_balance %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% for transaction in transactions %}
|
{% for transaction in transactions %}
|
||||||
{% for posting in transaction.postings if posting.amount >= 0.05 or posting.amount < -0.05 %}
|
{% for posting in transaction.postings if (posting.amount.amount >= 0.05 or posting.amount.amount < -0.05) and posting.account != account %}
|
||||||
|
{% set amount = posting.exchange(report_currency, transaction.date) %}
|
||||||
|
{% set ns.balance = ns.balance - amount %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{% if loop.first %}{{ transaction.date.strftime('%Y-%m-%d') }}{% endif %}</td>
|
<td>{% if loop.first %}{{ transaction.date.strftime('%Y-%m-%d') }}{% endif %}</td>
|
||||||
<td>{% if loop.first %}{{ transaction.payee }}{% endif %}</td>
|
<td>{% if loop.first %}{{ transaction.description }}{% endif %}</td>
|
||||||
<td><a href="/transactions?{{ {'date': date.strftime('%Y-%m-%d'), 'pstart': pstart.strftime('%Y-%m-%d'), 'account': posting.account}|urlencode }}">{{ (posting.account|e).__str__().replace(':', ':<wbr>')|safe }}</a></td>
|
<td><a href="/transactions?{{ {'date': date.strftime('%Y-%m-%d'), 'pstart': pstart.strftime('%Y-%m-%d'), 'account': posting.account.name}|urlencode }}">{{ (posting.account.name|e).__str__().replace(':', ':<wbr>')|safe }}</a></td>
|
||||||
<td style="text-align: right;">{% if posting.amount > 0 %}{{ posting.amount|b }}{% endif %}</td>
|
{% if account %}
|
||||||
<td style="text-align: right;">{% if posting.amount < 0 %}{{ -posting.amount|b }}{% endif %}</td>
|
{# Reverse Dr/Cr so it's from the "perspective" of this account #}
|
||||||
<td style="text-align: right;">
|
<td style="text-align: right;">{% if amount < 0 %}<span title="{{ (-posting.amount).tostr(False) }}">{{ -amount|b }}</span>{% endif %}</td>
|
||||||
{% if loop.last %}
|
<td style="text-align: right;">{% if amount > 0 %}<span title="{{ posting.amount.tostr(False) }}">{{ amount|b }}</span>{% endif %}</td>
|
||||||
{% if posting.balance >= 0 %}
|
<td style="text-align: right;">
|
||||||
{{ posting.balance|b }} Dr
|
{% if loop.last %}
|
||||||
{% else %}
|
{% if ns.balance >= 0 %}
|
||||||
{{ -posting.balance|b }} Cr
|
{{ ns.balance|b }} Dr
|
||||||
|
{% else %}
|
||||||
|
{{ -ns.balance|b }} Cr
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
</td>
|
||||||
</td>
|
{% else %}
|
||||||
|
<td style="text-align: right;">{% if amount > 0 %}<span title="{{ posting.amount.tostr(False) }}">{{ amount|b }}</span>{% endif %}</td>
|
||||||
|
<td style="text-align: right;">{% if amount < 0 %}<span title="{{ (-posting.amount).tostr(False) }}">{{ -amount|b }}</span>{% endif %}</td>
|
||||||
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<tr class="total">
|
|
||||||
<td>{{ date.strftime('%Y-%m-%d') }}</td>
|
{% if account %}
|
||||||
<td>Closing Balance</td>
|
<tr class="total">
|
||||||
<td></td>
|
<td>{{ date.strftime('%Y-%m-%d') }}</td>
|
||||||
<td style="text-align: right;"></td>
|
<td>Closing Balance</td>
|
||||||
<td style="text-align: right;"></td>
|
<td></td>
|
||||||
<td style="text-align: right;">
|
<td style="text-align: right;"></td>
|
||||||
{% if closing_balance >= 0 %}
|
<td style="text-align: right;"></td>
|
||||||
{{ closing_balance|b }} Dr
|
<td style="text-align: right;">
|
||||||
{% else %}
|
{% if closing_balance >= 0 %}
|
||||||
{{ -closing_balance|b }} Cr
|
{{ closing_balance|b }} Dr
|
||||||
{% endif %}
|
{% else %}
|
||||||
</td>
|
{{ -closing_balance|b }} Cr
|
||||||
</tr>
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr class="total">
|
||||||
|
<td>{{ date.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td>Total</td>
|
||||||
|
<td></td>
|
||||||
|
<td style="text-align: right;">{{ total_dr|b }}</td>
|
||||||
|
<td style="text-align: right;">{{ -total_cr|b }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
</table>
|
</table>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -34,8 +34,9 @@
|
|||||||
<th class="h1">Dr</th>
|
<th class="h1">Dr</th>
|
||||||
<th class="h1">Cr</th>
|
<th class="h1">Cr</th>
|
||||||
</tr>
|
</tr>
|
||||||
{% for account in trial_balance.accounts.values() %}
|
{% for account in accounts %}
|
||||||
{% set balance = trial_balance.get_balance(account.name) %}
|
{# Display in "cost basis" as we have already accounted for unrealised gains #}
|
||||||
|
{% set balance = trial_balance.get_balance(account).exchange(report_currency, True) %}
|
||||||
{% set trn_url = "/transactions?" + {'date': trial_balance.date.strftime('%Y-%m-%d'), 'pstart': trial_balance.pstart.strftime('%Y-%m-%d'), 'account': account.name}|urlencode %}
|
{% 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 %}
|
{% if balance != 0 %}
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -37,7 +37,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{ account.name }}</td>
|
<td>{{ account.name }}</td>
|
||||||
{% for trial_balance in trial_balances %}
|
{% for trial_balance in trial_balances %}
|
||||||
{% set balance = trial_balance.get_balance(account.name) %}
|
{% set balance = trial_balance.get_balance(account).exchange(report_currency, True) %}
|
||||||
<td>{% if balance != 0 %}{{ balance|a }}{% endif %}</td>
|
<td>{% if balance != 0 %}{{ balance|a }}{% endif %}</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -14,22 +14,19 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from .config import config
|
||||||
|
from .model import *
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import yaml
|
|
||||||
|
|
||||||
# Load config
|
|
||||||
with open('config.yml', 'r') as f:
|
|
||||||
config = yaml.safe_load(f)
|
|
||||||
|
|
||||||
# Helper commands to run Ledger
|
# Helper commands to run Ledger
|
||||||
|
|
||||||
def run_ledger(*args):
|
def run_ledger(*args):
|
||||||
ledger_args = ['ledger', '--args-only', '--file', config['ledger_file'], '-X', config['report_currency'], '--date-format', '%Y-%m-%d', '--unround'] + config['ledger_args'] + list(args)
|
ledger_args = ['ledger', '--args-only', '--file', config['ledger_file'], '--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)
|
proc = subprocess.Popen(ledger_args, encoding='utf-8', stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
stdout, stderr = proc.communicate()
|
stdout, stderr = proc.communicate()
|
||||||
|
|
||||||
@ -51,221 +48,56 @@ def financial_year(date):
|
|||||||
|
|
||||||
# Ledger logic
|
# Ledger logic
|
||||||
|
|
||||||
class Account:
|
|
||||||
def __init__(self, name):
|
|
||||||
if not isinstance(name, str):
|
|
||||||
raise Exception('Account name must be a string')
|
|
||||||
|
|
||||||
self.name = name
|
|
||||||
|
|
||||||
self.parent = None
|
|
||||||
self.children = []
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return '<Account "{}">'.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'])
|
|
||||||
@property
|
|
||||||
def is_cash(self):
|
|
||||||
return any(self.matches(a) for a in config['cash_asset_accounts'])
|
|
||||||
|
|
||||||
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 == '<Revalued>':
|
|
||||||
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, date):
|
|
||||||
self.date = date
|
|
||||||
self.pstart = None
|
|
||||||
|
|
||||||
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):
|
def parse_amount(amount):
|
||||||
if amount == '' or amount == '0':
|
if '{' in amount:
|
||||||
return Decimal(0)
|
amount_str = amount[:amount.index('{')].strip()
|
||||||
if not amount.startswith(config['report_currency']):
|
price_str = amount[amount.index('{')+1:amount.index('}')].strip()
|
||||||
raise Exception('Unexpected currency returned by ledger: {}'.format(amount))
|
else:
|
||||||
return Decimal(amount[len(config['report_currency']):])
|
amount_str = amount
|
||||||
|
price_str = None
|
||||||
|
|
||||||
|
if amount_str[0] in list('0123456789-'):
|
||||||
|
# Currency follows number
|
||||||
|
bits = amount_str.split()
|
||||||
|
amount_num = Decimal(bits[0])
|
||||||
|
currency = Currency(bits[1], False)
|
||||||
|
else:
|
||||||
|
# Currency precedes number
|
||||||
|
currency = Currency(amount_str[0], True)
|
||||||
|
amount_num = Decimal(amount_str[1:])
|
||||||
|
|
||||||
|
if price_str:
|
||||||
|
currency.price = parse_amount(price_str)
|
||||||
|
|
||||||
|
return Amount(amount_num, currency)
|
||||||
|
|
||||||
def get_accounts():
|
def get_pricedb():
|
||||||
output = run_ledger('balance', '--balance-format', '%(account)\n', '--no-total', '--flat', '--empty')
|
output = run_ledger('prices', '--prices-format', '%(quoted(format_date(date))),%(quoted(display_account)),%(quoted(display_amount))\n')
|
||||||
account_names = output.rstrip('\n').split('\n')
|
|
||||||
|
|
||||||
accounts = {n: Account(n) for n in account_names}
|
prices = []
|
||||||
|
|
||||||
for account in list(accounts.values()):
|
|
||||||
account.insert_into_tree(accounts)
|
|
||||||
|
|
||||||
return accounts
|
|
||||||
|
|
||||||
# Raw Ledger output, unlikely to balance
|
|
||||||
def get_raw_snapshot(date, basis=None):
|
|
||||||
snapshot = Snapshot(date)
|
|
||||||
|
|
||||||
# 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())
|
reader = csv.reader(output.splitlines())
|
||||||
for row in reader:
|
for date_str, currency, price_str in reader:
|
||||||
snapshot.set_balance(row[0], parse_amount(row[1]))
|
prices.append((datetime.strptime(date_str, '%Y-%m-%d'), currency, parse_amount(price_str)))
|
||||||
|
|
||||||
return snapshot
|
return prices
|
||||||
|
|
||||||
# Ledger output, adjusted for Unrealized Gains
|
def raw_transactions_at_date(date):
|
||||||
def get_snapshot(date):
|
ledger = Ledger(date)
|
||||||
snapshot_cost = get_raw_snapshot(date, '--cost')
|
ledger.prices = get_pricedb()
|
||||||
snapshot = get_raw_snapshot(date)
|
|
||||||
|
|
||||||
market_total = Decimal(0)
|
output = run_ledger_date(date, 'csv', '--csv-format', '%(quoted(parent.id)),%(quoted(format_date(date))),%(quoted(payee)),%(quoted(account)),%(quoted(display_amount))\n')
|
||||||
cost_total = Decimal(0)
|
|
||||||
|
|
||||||
# Calculate unrealized gains
|
reader = csv.reader(output.splitlines())
|
||||||
for account in snapshot.accounts.values():
|
for trn_id, date_str, payee, account_str, amount_str in reader:
|
||||||
if account.is_asset or account.is_liability:
|
if not ledger.transactions or trn_id != ledger.transactions[-1].id:
|
||||||
market_total += snapshot.get_balance(account.name)
|
transaction = Transaction(ledger, trn_id, datetime.strptime(date_str, '%Y-%m-%d'), payee)
|
||||||
cost_total += snapshot_cost.get_balance(account.name)
|
ledger.transactions.append(transaction)
|
||||||
|
else:
|
||||||
|
# Transaction ID matches: continuation of previous transaction
|
||||||
|
transaction = ledger.transactions[-1]
|
||||||
|
|
||||||
|
posting = Posting(transaction, ledger.get_account(account_str), parse_amount(amount_str))
|
||||||
|
transaction.postings.append(posting)
|
||||||
|
|
||||||
# Add Unrealized Gains account
|
return ledger
|
||||||
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
|
|
||||||
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)
|
|
||||||
for account in snapshot_pstart.accounts.values():
|
|
||||||
if account.is_income or account.is_expense:
|
|
||||||
total_pandl += snapshot_pstart.get_balance(account.name)
|
|
||||||
|
|
||||||
# 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 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 snapshot
|
|
||||||
|
299
ledger_pyreport/model.py
Normal file
299
ledger_pyreport/model.py
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
# 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 .config import config
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
import functools
|
||||||
|
|
||||||
|
class Ledger:
|
||||||
|
def __init__(self, date):
|
||||||
|
self.date = date
|
||||||
|
|
||||||
|
self.root_account = Account(self, '')
|
||||||
|
self.accounts = {}
|
||||||
|
self.transactions = []
|
||||||
|
|
||||||
|
self.prices = []
|
||||||
|
|
||||||
|
def get_account(self, name):
|
||||||
|
if name == '':
|
||||||
|
return self.root_account
|
||||||
|
|
||||||
|
if name in self.accounts:
|
||||||
|
return self.accounts[name]
|
||||||
|
|
||||||
|
account = Account(self, name)
|
||||||
|
if account.parent:
|
||||||
|
account.parent.children.append(account)
|
||||||
|
self.accounts[name] = account
|
||||||
|
|
||||||
|
return account
|
||||||
|
|
||||||
|
def get_price(self, currency_from, currency_to, date):
|
||||||
|
prices = [p for p in self.prices if p[1] == currency_from.name and p[2].currency == currency_to and p[0].date() <= date.date()]
|
||||||
|
|
||||||
|
if not prices:
|
||||||
|
raise Exception('No price information for {} to {} at {:%Y-%m-%d}'.format(currency_from, currency_to, date))
|
||||||
|
|
||||||
|
return max(prices, key=lambda p: p[0])[2]
|
||||||
|
|
||||||
|
class Transaction:
|
||||||
|
def __init__(self, ledger, id, date, description):
|
||||||
|
self.ledger = ledger
|
||||||
|
self.id = id
|
||||||
|
self.date = date
|
||||||
|
self.description = description
|
||||||
|
self.postings = []
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<Transaction {} "{}">'.format(self.id, self.description)
|
||||||
|
|
||||||
|
def describe(self):
|
||||||
|
result = ['{:%Y-%m-%d} {}'.format(self.date, self.description)]
|
||||||
|
for posting in self.postings:
|
||||||
|
result.append(' {} {}'.format(posting.account.name, posting.amount.tostr(False)))
|
||||||
|
return '\n'.join(result)
|
||||||
|
|
||||||
|
class Posting:
|
||||||
|
def __init__(self, transaction, account, amount):
|
||||||
|
self.transaction = transaction
|
||||||
|
self.account = account
|
||||||
|
self.amount = Amount(amount)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<Posting "{}" {}>'.format(self.account.name, self.amount.tostr(False))
|
||||||
|
|
||||||
|
def exchange(self, currency, date):
|
||||||
|
if self.amount.currency.name == currency.name and self.amount.currency.is_prefix == currency.is_prefix:
|
||||||
|
return Amount(self.amount)
|
||||||
|
|
||||||
|
return self.amount.exchange(currency, True) # Cost basis
|
||||||
|
|
||||||
|
class Account:
|
||||||
|
def __init__(self, ledger, name):
|
||||||
|
self.ledger = ledger
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
self.children = []
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<Account {}>'.format(self.name)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bits(self):
|
||||||
|
return self.name.split(':')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parent(self):
|
||||||
|
if self.name == '':
|
||||||
|
return None
|
||||||
|
return self.ledger.get_account(':'.join(self.bits[:-1]))
|
||||||
|
|
||||||
|
def matches(self, part):
|
||||||
|
if self.name == part or self.name.startswith(part + ':'):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_income(self):
|
||||||
|
return self.matches(config['income_account'])
|
||||||
|
@property
|
||||||
|
def is_expense(self):
|
||||||
|
return self.matches(config['expenses_account'])
|
||||||
|
@property
|
||||||
|
def is_equity(self):
|
||||||
|
return self.matches(config['equity_account'])
|
||||||
|
@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_cost(self):
|
||||||
|
return self.is_income or self.is_expense or self.is_equity
|
||||||
|
@property
|
||||||
|
def is_market(self):
|
||||||
|
return self.is_asset or self.is_liability
|
||||||
|
|
||||||
|
class Amount:
|
||||||
|
def __init__(self, amount, currency=None):
|
||||||
|
if isinstance(amount, Amount):
|
||||||
|
self.amount = amount.amount
|
||||||
|
self.currency = amount.currency
|
||||||
|
elif currency is None:
|
||||||
|
raise TypeError('currency is required')
|
||||||
|
else:
|
||||||
|
self.amount = Decimal(amount)
|
||||||
|
self.currency = currency
|
||||||
|
|
||||||
|
def tostr(self, round=True):
|
||||||
|
if self.currency.is_prefix:
|
||||||
|
amount_str = ('{}{:.2f}' if round else '{}{}').format(self.currency.name, self.amount)
|
||||||
|
else:
|
||||||
|
amount_str = ('{:.2f} {}' if round else '{:.2f} {}').format(self.amount, self.currency.name)
|
||||||
|
|
||||||
|
if self.currency.price:
|
||||||
|
return '{} {{{}}}'.format(amount_str, self.currency.price)
|
||||||
|
return amount_str
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<Amount {}>'.format(self.tostr(False))
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.tostr()
|
||||||
|
|
||||||
|
def compatible_currency(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapped(self, other):
|
||||||
|
if isinstance(other, Amount):
|
||||||
|
if other.currency != self.currency:
|
||||||
|
raise TypeError('Cannot combine Amounts of currency {} and {}'.format(self.currency.name, other.currency.name))
|
||||||
|
other = other.amount
|
||||||
|
elif other != 0:
|
||||||
|
raise TypeError('Cannot combine Amount with non-zero number')
|
||||||
|
return func(self, other)
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
def __neg__(self):
|
||||||
|
return Amount(-self.amount, self.currency)
|
||||||
|
|
||||||
|
@compatible_currency
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.amount == other
|
||||||
|
@compatible_currency
|
||||||
|
def __ne__(self, other):
|
||||||
|
return self.amount != other
|
||||||
|
@compatible_currency
|
||||||
|
def __gt__(self, other):
|
||||||
|
return self.amount > other
|
||||||
|
@compatible_currency
|
||||||
|
def __ge__(self, other):
|
||||||
|
return self.amount >= other
|
||||||
|
@compatible_currency
|
||||||
|
def __lt__(self, other):
|
||||||
|
return self.amount < other
|
||||||
|
@compatible_currency
|
||||||
|
def __le__(self, other):
|
||||||
|
return self.amount <= other
|
||||||
|
|
||||||
|
@compatible_currency
|
||||||
|
def __add__(self, other):
|
||||||
|
return Amount(self.amount + other, self.currency)
|
||||||
|
@compatible_currency
|
||||||
|
def __radd__(self, other):
|
||||||
|
return Amount(other + self.amount, self.currency)
|
||||||
|
@compatible_currency
|
||||||
|
def __sub__(self, other):
|
||||||
|
return Amount(self.amount - other, self.currency)
|
||||||
|
@compatible_currency
|
||||||
|
def __rsub__(self, other):
|
||||||
|
return Amount(other - self.amount, self.currency)
|
||||||
|
|
||||||
|
def exchange(self, currency, is_cost, price=None):
|
||||||
|
if self.currency.name == currency.name and self.currency.is_prefix == currency.is_prefix:
|
||||||
|
return Amount(self)
|
||||||
|
|
||||||
|
if is_cost and self.currency.price and self.currency.price.currency.name == currency.name and self.currency.price.currency.is_prefix == currency.is_prefix:
|
||||||
|
return Amount(self.amount * self.currency.price.amount, currency)
|
||||||
|
|
||||||
|
if price:
|
||||||
|
return Amount(self.amount * price.amount, currency)
|
||||||
|
|
||||||
|
raise TypeError('Cannot exchange {} to {}'.format(self.currency, currency))
|
||||||
|
|
||||||
|
class Balance:
|
||||||
|
def __init__(self, amounts=None):
|
||||||
|
self.amounts = amounts or []
|
||||||
|
|
||||||
|
def tidy(self):
|
||||||
|
new_amounts = []
|
||||||
|
for amount in self.amounts:
|
||||||
|
new_amount = next((a for a in new_amounts if a.currency == amount.currency), None)
|
||||||
|
return Balance(new_amounts)
|
||||||
|
|
||||||
|
def exchange(self, currency, is_cost, date=None, ledger=None):
|
||||||
|
result = Amount(0, currency)
|
||||||
|
for amount in self.amounts:
|
||||||
|
if is_cost or amount.currency.name == currency.name and amount.currency.is_prefix == amount.currency.is_prefix:
|
||||||
|
result += amount.exchange(currency, is_cost)
|
||||||
|
else:
|
||||||
|
result += amount.exchange(currency, is_cost, ledger.get_price(amount.currency, currency, date))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def __neg__(self):
|
||||||
|
return Balance([-a for a in self.amounts])
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if isinstance(other, Balance):
|
||||||
|
raise Exception('NYI')
|
||||||
|
elif isinstance(other, Amount):
|
||||||
|
raise Exception('NYI')
|
||||||
|
elif other == 0:
|
||||||
|
return len(self.amounts) == 0
|
||||||
|
else:
|
||||||
|
raise TypeError('Cannot compare Balance with non-zero number')
|
||||||
|
|
||||||
|
def __add__(self, other):
|
||||||
|
new_amounts = [Amount(a) for a in self.amounts]
|
||||||
|
|
||||||
|
if isinstance(other, Balance):
|
||||||
|
for amount in other.amounts:
|
||||||
|
new_amount = next((a for a in new_amounts if a.currency == amount.currency), None)
|
||||||
|
if new_amount is None:
|
||||||
|
new_amount = Amount(0, amount.currency)
|
||||||
|
new_amounts.append(new_amount)
|
||||||
|
new_amount.amount += amount.amount
|
||||||
|
elif isinstance(other, Amount):
|
||||||
|
new_amount = next((a for a in new_amounts if a.currency == other.currency), None)
|
||||||
|
if new_amount is None:
|
||||||
|
new_amount = Amount(0, other.currency)
|
||||||
|
new_amounts.append(new_amount)
|
||||||
|
new_amount.amount += other.amount
|
||||||
|
else:
|
||||||
|
raise Exception('NYI')
|
||||||
|
|
||||||
|
return Balance(new_amounts)
|
||||||
|
|
||||||
|
class Currency:
|
||||||
|
def __init__(self, name, is_prefix, price=None):
|
||||||
|
self.name = name
|
||||||
|
self.is_prefix = is_prefix
|
||||||
|
self.price = price
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<Currency {} ({})>'.format(self.name, 'prefix' if self.is_prefix else 'suffix')
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if not isinstance(other, Currency):
|
||||||
|
return False
|
||||||
|
return self.name == other.name and self.is_prefix == other.is_prefix and self.price == other.price
|
||||||
|
|
||||||
|
class TrialBalance:
|
||||||
|
def __init__(self, ledger, date, pstart):
|
||||||
|
self.ledger = ledger
|
||||||
|
self.date = date
|
||||||
|
self.pstart = pstart
|
||||||
|
|
||||||
|
self.balances = {}
|
||||||
|
|
||||||
|
def get_balance(self, account):
|
||||||
|
return self.balances.get(account.name, Balance())
|
||||||
|
|
||||||
|
def get_total(self, account):
|
||||||
|
return self.get_balance(account) + sum(self.get_total(a) for a in account.children)
|
Reference in New Issue
Block a user