Allow charging statement line to multiple accounts

This commit is contained in:
RunasSudo 2022-12-24 13:22:18 +11:00
parent 511914fd1b
commit 3debfb1122
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
6 changed files with 85 additions and 18 deletions

View File

@ -22,6 +22,16 @@ class Transaction:
self.description = description self.description = description
self.postings = postings or [] self.postings = postings or []
def assert_valid(self):
"""Assert that debits equal credits, and commodities are compatible"""
if any(p.commodity != '$' for p in self.postings):
# FIXME: Allow non-$ commodities
raise AssertionError('Transaction contains multiple commodities')
if sum(p.quantity for p in self.postings) != 0:
raise AssertionError('Transaction debits and credits do not balance')
class Posting: class Posting:
def __init__(self, description=None, account=None, quantity=None, commodity=None): def __init__(self, description=None, account=None, quantity=None, commodity=None):
self.description = description self.description = description
@ -44,3 +54,6 @@ class Amount:
def format(self): def format(self):
return '{0}{1:,.{dps}f}'.format(self.commodity, self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS) return '{0}{1:,.{dps}f}'.format(self.commodity, self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS)
def quantity_string(self):
return '{:.{dps}f}'.format(self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS)

View File

@ -55,6 +55,15 @@ class StatementLine(Base):
] ]
) )
def is_complex(self):
if len(self.postings) > 1:
return True
if any(len(p.transaction.postings) > 2 for p in self.postings):
return True
return False
class StatementLineTransaction(Base, Transaction): class StatementLineTransaction(Base, Transaction):
__tablename__ = 'statement_line_transactions' __tablename__ = 'statement_line_transactions'

View File

@ -16,6 +16,7 @@
from flask import abort, redirect, render_template, request from flask import abort, redirect, render_template, request
from .. import AMOUNT_DPS
from ..database import db_session from ..database import db_session
from ..webapp import app from ..webapp import app
from .models import StatementLine, StatementLinePosting, StatementLineTransaction from .models import StatementLine, StatementLinePosting, StatementLineTransaction
@ -74,14 +75,31 @@ def statement_line_edit_transaction():
# Queue for deletion # Queue for deletion
db_session.delete(posting.transaction) db_session.delete(posting.transaction)
transaction = StatementLineTransaction( if len(request.form.getlist('charge-account')) == 1:
dt=statement_line.dt, # Simple transaction
description=request.form['description'], postings = [
postings=[
StatementLinePosting(statement_line=statement_line, account=statement_line.source_account, quantity=statement_line.quantity, commodity=statement_line.commodity), StatementLinePosting(statement_line=statement_line, account=statement_line.source_account, quantity=statement_line.quantity, commodity=statement_line.commodity),
StatementLinePosting(account=request.form['charge-account'], quantity=-statement_line.quantity, commodity=statement_line.commodity) StatementLinePosting(account=request.form['charge-account'], quantity=-statement_line.quantity, commodity=statement_line.commodity)
] ]
else:
# Complex transaction, multiple postings
postings = [
StatementLinePosting(statement_line=statement_line, account=statement_line.source_account, quantity=statement_line.quantity, commodity=statement_line.commodity)
]
for charge_account, charge_amount_str in zip(request.form.getlist('charge-account'), request.form.getlist('charge-amount')):
charge_quantity = round(float(charge_amount_str) * (10**AMOUNT_DPS))
if statement_line.quantity >= 0:
# If source is debit, charge is credit
charge_quantity = -charge_quantity
postings.append(StatementLinePosting(account=charge_account, quantity=charge_quantity, commodity=statement_line.commodity))
transaction = StatementLineTransaction(
dt=statement_line.dt,
description=request.form['description'],
postings=postings
) )
transaction.assert_valid()
db_session.add(transaction) db_session.add(transaction)
db_session.commit() db_session.commit()

View File

@ -34,7 +34,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr id="statement-line" data-quantity="{{ statement_line.amount().quantity_string() }}" data-commodity="{{ statement_line.commodity }}">
<td>{{ statement_line.source_account }}</td> <td>{{ statement_line.source_account }}</td>
<td>{{ statement_line.dt.strftime('%Y-%m-%d') }}</td> <td>{{ statement_line.dt.strftime('%Y-%m-%d') }}</td>
<td>{{ statement_line.description }}</td> <td>{{ statement_line.description }}</td>
@ -59,26 +59,27 @@
</thead> </thead>
<tbody> <tbody>
{# FIXME: Customise date, etc. #} {# FIXME: Customise date, etc. #}
{# FIXME: Complex transactions #}
<tr> <tr>
<td>{{ statement_line.dt.strftime('%Y-%m-%d') }}</td> <td>{{ statement_line.dt.strftime('%Y-%m-%d') }}</td>
<td colspan="2"><input type="text" name="description" value="{{ statement_line.description }}" class="w-100"></td> <td colspan="2"><input type="text" name="description" value="{{ statement_line.description }}" class="w-100"></td>
<td></td> <td></td>
<td></td> <td></td>
</tr> </tr>
<tr> <tr data-side="source">
{# Source line #}
<td></td> <td></td>
<td></td> <td></td>
<td><i>{{ 'Dr' if statement_line.quantity >= 0 else 'Cr' }}</i> {{ statement_line.source_account }}</td> <td><i>{{ 'Dr' if statement_line.quantity >= 0 else 'Cr' }}</i> {{ statement_line.source_account }} {#<a class="text-primary" href="#" onclick="addPosting(this);return false;"><i class="bi bi-plus-circle-fill"></i></a>#}</td>
<td>{{ statement_line.amount().format() if statement_line.quantity >= 0 else '' }}</td> {% if statement_line.quantity >= 0 %}<td class="has-amount">{{ statement_line.amount().format() }}</td>{% else %}<td></td>{% endif %}
<td>{{ (statement_line.amount()|abs).format() if statement_line.quantity < 0 else '' }}</td> {% if statement_line.quantity < 0 %}<td class="has-amount">{{ (statement_line.amount()|abs).format() }}</td>{% else %}<td></td>{% endif %}
</tr> </tr>
<tr> <tr data-side="charge">
{# Charge line #}
<td></td> <td></td>
<td></td> <td></td>
<td><i>{{ 'Cr' if statement_line.quantity >= 0 else 'Dr' }}</i> <input type="text" name="charge-account"></td> <td><i>{{ 'Cr' if statement_line.quantity >= 0 else 'Dr' }}</i> <input type="text" name="charge-account"> <a class="text-primary" href="#" onclick="addPosting(this);return false;"><i class="bi bi-plus-circle-fill"></i></a></td>
<td>{{ (statement_line.amount()|abs).format() if statement_line.quantity < 0 else '' }}</td> {% if statement_line.quantity < 0 %}<td class="has-amount">{{ (statement_line.amount()|abs).format() }}</td>{% else %}<td></td>{% endif %}
<td>{{ statement_line.amount().format() if statement_line.quantity >= 0 else '' }}</td> {% if statement_line.quantity >= 0 %}<td class="has-amount">{{ statement_line.amount().format() }}</td>{% else %}<td></td>{% endif %}
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -88,4 +89,30 @@
</div> </div>
</form> </form>
</div> </div>
<script>
function addPosting(el) {
let trPosting = el.parentNode.parentNode;
let trLine = document.getElementById('statement-line');
let qtyf = parseFloat(trLine.dataset.quantity);
if (trPosting.dataset['side'] === 'source') {
alert('NYI');
return;
}
if (trPosting.dataset['side'] === 'charge') {
let inputAmount = trLine.dataset.commodity + '<input type="number" name="charge-amount" step="0.01">';
// Add new posting row
let trNew = document.createElement('tr');
trNew.dataset['side'] = 'charge';
trNew.innerHTML = '<td></td><td></td><td><i>' + (qtyf >= 0 ? 'Cr' : 'Dr') + '</i> <input type="text" name="charge-account"> <a class="text-primary" href="#" onclick="addPosting(this);return false;"><i class="bi bi-plus-circle-fill"></i></a></td><td>' + (qtyf < 0 ? inputAmount : '') + '</td><td>' + (qtyf >= 0 ? inputAmount : '') + '</td>';
trPosting.after(trNew);
// Put input box in existing row
trPosting.querySelector('.has-amount').innerHTML = inputAmount;
}
}
</script>
{% endblock %} {% endblock %}

View File

@ -40,10 +40,10 @@
<td>{{ line.source_account }}</td> <td>{{ line.source_account }}</td>
<td>{{ line.dt.strftime('%Y-%m-%d') }}</td> <td>{{ line.dt.strftime('%Y-%m-%d') }}</td>
<td>{{ line.description }}</td> <td>{{ line.description }}</td>
{% if line.postings|length > 1 %} {% if line.postings|length == 0 %}
<td class="charge-account"><i class="bi bi-info-circle-fill" title="Complex transaction"></i></td>
{% elif line.postings|length == 0 %}
<td class="charge-account"><a href="#" class="text-danger" onclick="classifyLine({{ line.id }});return false;">Unclassified</a> <a href="/statement-lines/edit-transaction?line-id={{ line.id }}" class="text-muted"><i class="bi bi-pencil"></i></a></td> <td class="charge-account"><a href="#" class="text-danger" onclick="classifyLine({{ line.id }});return false;">Unclassified</a> <a href="/statement-lines/edit-transaction?line-id={{ line.id }}" class="text-muted"><i class="bi bi-pencil"></i></a></td>
{% elif line.is_complex() %}
<td class="charge-account"><i>(Complex)</i></td>
{% else %} {% else %}
<td class="charge-account"><a href="#" class="text-body" onclick="classifyLine({{ line.id }});return false;">{{ line.postings[0].transaction.charge_account(line.source_account) }}</a> <a href="/statement-lines/edit-transaction?line-id={{ line.id }}" class="text-muted"><i class="bi bi-pencil"></i></a></td> <td class="charge-account"><a href="#" class="text-body" onclick="classifyLine({{ line.id }});return false;">{{ line.postings[0].transaction.charge_account(line.source_account) }}</a> <a href="/statement-lines/edit-transaction?line-id={{ line.id }}" class="text-muted"><i class="bi bi-pencil"></i></a></td>
{% endif %} {% endif %}

View File

@ -36,7 +36,7 @@ def all_transactions():
@app.route('/general-ledger') @app.route('/general-ledger')
def general_ledger(): def general_ledger():
return render_template('general_ledger.html', transactions=all_transactions()) return render_template('general_ledger.html', transactions=sorted(all_transactions(), key=lambda t: t.dt))
@app.route('/trial-balance') @app.route('/trial-balance')
def trial_balance(): def trial_balance():