Compare commits
3 Commits
e5cce0fd4b
...
9373212482
Author | SHA1 | Date | |
---|---|---|---|
9373212482 | |||
18392d6917 | |||
fe8d47c676 |
@ -16,13 +16,14 @@
|
|||||||
|
|
||||||
from flask import render_template, url_for
|
from flask import render_template, url_for
|
||||||
|
|
||||||
from drcr.models import AccountConfiguration, Posting, Transaction, TrialBalancer
|
from drcr.models import AccountConfiguration, Posting, Transaction, TrialBalancer, reporting_commodity
|
||||||
from drcr.database import db
|
from drcr.database import db
|
||||||
from drcr.webapp import eofy_date
|
from drcr.webapp import eofy_date
|
||||||
import drcr.plugins
|
import drcr.plugins
|
||||||
|
|
||||||
|
from . import views # Load routes
|
||||||
from .reports import tax_summary_report
|
from .reports import tax_summary_report
|
||||||
from . import views
|
from .util import assert_aud
|
||||||
|
|
||||||
def plugin_init():
|
def plugin_init():
|
||||||
drcr.plugins.data_sources.append(('cgt_adjustments', 'CGT adjustments'))
|
drcr.plugins.data_sources.append(('cgt_adjustments', 'CGT adjustments'))
|
||||||
@ -47,6 +48,7 @@ def plugin_init():
|
|||||||
|
|
||||||
drcr.plugins.transaction_providers.append(make_tax_transactions)
|
drcr.plugins.transaction_providers.append(make_tax_transactions)
|
||||||
|
|
||||||
|
@assert_aud
|
||||||
def make_tax_transactions():
|
def make_tax_transactions():
|
||||||
report = tax_summary_report()
|
report = tax_summary_report()
|
||||||
tax_amount = report.by_id('total_tax').amount
|
tax_amount = report.by_id('total_tax').amount
|
||||||
@ -59,8 +61,8 @@ def make_tax_transactions():
|
|||||||
dt=dt,
|
dt=dt,
|
||||||
description='Estimated income tax',
|
description='Estimated income tax',
|
||||||
postings=[
|
postings=[
|
||||||
Posting(account='Income Tax', quantity=tax_amount.quantity, commodity='$'),
|
Posting(account='Income Tax', quantity=tax_amount.quantity, commodity=reporting_commodity()),
|
||||||
Posting(account='Income Tax Control', quantity=-tax_amount.quantity, commodity='$')
|
Posting(account='Income Tax Control', quantity=-tax_amount.quantity, commodity=reporting_commodity())
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
|
|
||||||
@ -71,14 +73,13 @@ def make_tax_transactions():
|
|||||||
dt=dt,
|
dt=dt,
|
||||||
description='Mandatory study loan repayment payable',
|
description='Mandatory study loan repayment payable',
|
||||||
postings=[
|
postings=[
|
||||||
Posting(account='HELP', quantity=loan_repayment.quantity, commodity='$'), # FIXME: Correct account
|
Posting(account='HELP', quantity=loan_repayment.quantity, commodity=reporting_commodity()), # FIXME: Correct account
|
||||||
Posting(account='Income Tax Control', quantity=-loan_repayment.quantity, commodity='$')
|
Posting(account='Income Tax Control', quantity=-loan_repayment.quantity, commodity=reporting_commodity())
|
||||||
]
|
]
|
||||||
))
|
))
|
||||||
|
|
||||||
# Get trial balance
|
# Get trial balance
|
||||||
balancer = TrialBalancer()
|
balancer = TrialBalancer.from_cached()
|
||||||
balancer.apply_transactions(db.session.scalars(db.select(Transaction).options(db.selectinload(Transaction.postings))).all())
|
|
||||||
|
|
||||||
accounts = dict(sorted(balancer.accounts.items()))
|
accounts = dict(sorted(balancer.accounts.items()))
|
||||||
|
|
||||||
@ -94,8 +95,8 @@ def make_tax_transactions():
|
|||||||
dt=dt,
|
dt=dt,
|
||||||
description='PAYG withheld amounts',
|
description='PAYG withheld amounts',
|
||||||
postings=[
|
postings=[
|
||||||
Posting(account='Income Tax Control', quantity=accounts[account_name].quantity, commodity='$'),
|
Posting(account='Income Tax Control', quantity=accounts[account_name].quantity, commodity=reporting_commodity()),
|
||||||
Posting(account=account_name, quantity=-accounts[account_name].quantity, commodity='$')
|
Posting(account=account_name, quantity=-accounts[account_name].quantity, commodity=reporting_commodity())
|
||||||
]
|
]
|
||||||
))
|
))
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# DrCr: Web-based double-entry bookkeeping framework
|
# DrCr: Web-based double-entry bookkeeping framework
|
||||||
# Copyright (C) 2022–2023 Lee Yingtong Li (RunasSudo)
|
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@ -37,20 +37,20 @@ class CGTAsset(Amount):
|
|||||||
return self.commodity[:self.commodity.index('{')].strip()
|
return self.commodity[:self.commodity.index('{')].strip()
|
||||||
|
|
||||||
def cost_adjustment(self):
|
def cost_adjustment(self):
|
||||||
return Amount(sum(a.cost_adjustment for a in self.cost_adjustments), '$')
|
return Amount(sum(a.cost_adjustment for a in self.cost_adjustments), reporting_commodity())
|
||||||
|
|
||||||
def cost_adjustment_brought_forward(self):
|
def cost_adjustment_brought_forward(self):
|
||||||
date1 = eofy_date()
|
date1 = eofy_date()
|
||||||
date1 = date1.replace(year=date1.year - 1)
|
date1 = date1.replace(year=date1.year - 1)
|
||||||
|
|
||||||
return Amount(sum(a.cost_adjustment for a in self.cost_adjustments if a.dt <= date1), '$')
|
return Amount(sum(a.cost_adjustment for a in self.cost_adjustments if a.dt <= date1), reporting_commodity())
|
||||||
|
|
||||||
def cost_adjustment_current_period(self):
|
def cost_adjustment_current_period(self):
|
||||||
date1 = eofy_date()
|
date1 = eofy_date()
|
||||||
date1 = date1.replace(year=date1.year - 1)
|
date1 = date1.replace(year=date1.year - 1)
|
||||||
date2 = eofy_date()
|
date2 = eofy_date()
|
||||||
|
|
||||||
return Amount(sum(a.cost_adjustment for a in self.cost_adjustments if a.dt > date1 and a.dt <= date2), '$')
|
return Amount(sum(a.cost_adjustment for a in self.cost_adjustments if a.dt > date1 and a.dt <= date2), reporting_commodity())
|
||||||
|
|
||||||
def gain(self):
|
def gain(self):
|
||||||
return self.disposal_value - (self.as_cost() + self.cost_adjustment())
|
return self.disposal_value - (self.as_cost() + self.cost_adjustment())
|
||||||
@ -73,4 +73,4 @@ class CGTCostAdjustment(db.Model):
|
|||||||
return CGTAsset(self.quantity, self.commodity, self.account, self.acquisition_date)
|
return CGTAsset(self.quantity, self.commodity, self.account, self.acquisition_date)
|
||||||
|
|
||||||
def cost_adjustment_amount(self):
|
def cost_adjustment_amount(self):
|
||||||
return Amount(self.cost_adjustment, '$')
|
return Amount(self.cost_adjustment, reporting_commodity())
|
||||||
|
@ -16,20 +16,23 @@
|
|||||||
|
|
||||||
from drcr import AMOUNT_DPS
|
from drcr import AMOUNT_DPS
|
||||||
from drcr.database import db
|
from drcr.database import db
|
||||||
from drcr.models import AccountConfiguration, Amount, Metadata, Transaction, TrialBalancer
|
from drcr.models import AccountConfiguration, Amount, Metadata, Transaction, TrialBalancer, reporting_commodity
|
||||||
from drcr.reports import Calculated, Report, Section, Spacer, Subtotal, entries_for_kind
|
from drcr.reports import Calculated, Report, Section, Spacer, Subtotal, entries_for_kind
|
||||||
from drcr.webapp import eofy_date, sofy_date
|
from drcr.webapp import eofy_date, sofy_date
|
||||||
|
|
||||||
from .tax_tables import base_tax, medicare_levy_threshold, medicare_levy_surcharge_single, repayment_rates, fbt_grossup
|
from .tax_tables import base_tax, medicare_levy_threshold, medicare_levy_surcharge_single, repayment_rates, fbt_grossup
|
||||||
|
from .util import assert_aud
|
||||||
|
|
||||||
|
@assert_aud
|
||||||
def base_income_tax(year, taxable_income):
|
def base_income_tax(year, taxable_income):
|
||||||
"""Get the amount of base income tax"""
|
"""Get the amount of base income tax"""
|
||||||
|
|
||||||
for i, (upper_limit, flat_amount, marginal_rate) in enumerate(base_tax[year]):
|
for i, (upper_limit, flat_amount, marginal_rate) in enumerate(base_tax[year]):
|
||||||
if upper_limit is None or taxable_income.quantity <= upper_limit * (10**AMOUNT_DPS):
|
if upper_limit is None or taxable_income.quantity <= upper_limit * (10**AMOUNT_DPS):
|
||||||
lower_limit = base_tax[year][i - 1][0] or 0
|
lower_limit = base_tax[year][i - 1][0] or 0
|
||||||
return Amount(flat_amount * (10**AMOUNT_DPS) + marginal_rate * (taxable_income.quantity - lower_limit * (10**AMOUNT_DPS)), '$')
|
return Amount(flat_amount * (10**AMOUNT_DPS) + marginal_rate * (taxable_income.quantity - lower_limit * (10**AMOUNT_DPS)), reporting_commodity())
|
||||||
|
|
||||||
|
@assert_aud
|
||||||
def lito(taxable_income, total_tax):
|
def lito(taxable_income, total_tax):
|
||||||
"""Get the amount of low income tax offset"""
|
"""Get the amount of low income tax offset"""
|
||||||
|
|
||||||
@ -38,34 +41,37 @@ def lito(taxable_income, total_tax):
|
|||||||
# FIXME: This will not work if we implement multiple non-refundable tax offsets
|
# FIXME: This will not work if we implement multiple non-refundable tax offsets
|
||||||
if total_tax.quantity <= 70000:
|
if total_tax.quantity <= 70000:
|
||||||
return total_tax
|
return total_tax
|
||||||
return Amount(70000, '$')
|
return Amount(70000, reporting_commodity())
|
||||||
if taxable_income.quantity <= 4500000:
|
if taxable_income.quantity <= 4500000:
|
||||||
return Amount(70000 - 0.05 * (taxable_income.quantity - 3750000), '$')
|
return Amount(70000 - 0.05 * (taxable_income.quantity - 3750000), reporting_commodity())
|
||||||
if taxable_income.quantity <= 6666700:
|
if taxable_income.quantity <= 6666700:
|
||||||
return Amount(32500 - int(0.015 * (taxable_income.quantity - 4500000)), '$')
|
return Amount(32500 - int(0.015 * (taxable_income.quantity - 4500000)), reporting_commodity())
|
||||||
|
|
||||||
return Amount(0, '$')
|
return Amount(0, reporting_commodity())
|
||||||
|
|
||||||
|
@assert_aud
|
||||||
def medicare_levy(year, taxable_income):
|
def medicare_levy(year, taxable_income):
|
||||||
lower_threshold, upper_threshold = medicare_levy_threshold[year]
|
lower_threshold, upper_threshold = medicare_levy_threshold[year]
|
||||||
|
|
||||||
if taxable_income.quantity < lower_threshold * 100:
|
if taxable_income.quantity < lower_threshold * 100:
|
||||||
return Amount(0, '$')
|
return Amount(0, reporting_commodity())
|
||||||
|
|
||||||
if taxable_income.quantity < upper_threshold * 100:
|
if taxable_income.quantity < upper_threshold * 100:
|
||||||
# Medicare levy is 10% of the amount above the lower threshold
|
# Medicare levy is 10% of the amount above the lower threshold
|
||||||
return Amount((taxable_income - lower_threshold * 100) * 0.1, '$')
|
return Amount((taxable_income - lower_threshold * 100) * 0.1, reporting_commodity())
|
||||||
|
|
||||||
# Normal Medicare levy
|
# Normal Medicare levy
|
||||||
return Amount(int(taxable_income.quantity * 0.02), '$')
|
return Amount(int(taxable_income.quantity * 0.02), reporting_commodity())
|
||||||
|
|
||||||
|
@assert_aud
|
||||||
def medicare_levy_surcharge(year, taxable_income, rfb_grossedup):
|
def medicare_levy_surcharge(year, taxable_income, rfb_grossedup):
|
||||||
mls_income = taxable_income + rfb_grossedup
|
mls_income = taxable_income + rfb_grossedup
|
||||||
|
|
||||||
for i, (upper_limit, rate) in enumerate(medicare_levy_surcharge_single[year]):
|
for i, (upper_limit, rate) in enumerate(medicare_levy_surcharge_single[year]):
|
||||||
if upper_limit is None or mls_income.quantity <= upper_limit * (10**AMOUNT_DPS):
|
if upper_limit is None or mls_income.quantity <= upper_limit * (10**AMOUNT_DPS):
|
||||||
return Amount(rate * mls_income.quantity, '$')
|
return Amount(rate * mls_income.quantity, reporting_commodity())
|
||||||
|
|
||||||
|
@assert_aud
|
||||||
def study_loan_repayment(year, taxable_income, rfb_grossedup):
|
def study_loan_repayment(year, taxable_income, rfb_grossedup):
|
||||||
"""Get the amount of mandatory study loan repayment"""
|
"""Get the amount of mandatory study loan repayment"""
|
||||||
|
|
||||||
@ -73,12 +79,12 @@ def study_loan_repayment(year, taxable_income, rfb_grossedup):
|
|||||||
|
|
||||||
for upper_limit, rate in repayment_rates[year]:
|
for upper_limit, rate in repayment_rates[year]:
|
||||||
if upper_limit is None or repayment_income.quantity <= upper_limit * (10**AMOUNT_DPS):
|
if upper_limit is None or repayment_income.quantity <= upper_limit * (10**AMOUNT_DPS):
|
||||||
return Amount(rate * repayment_income.quantity, '$')
|
return Amount(rate * repayment_income.quantity, reporting_commodity())
|
||||||
|
|
||||||
|
@assert_aud
|
||||||
def tax_summary_report():
|
def tax_summary_report():
|
||||||
# Get trial balance
|
# Get trial balance
|
||||||
balancer = TrialBalancer()
|
balancer = TrialBalancer.from_cached(start_date=sofy_date(), end_date=eofy_date())
|
||||||
balancer.apply_transactions(db.session.scalars(db.select(Transaction).where((Transaction.dt >= sofy_date()) & (Transaction.dt <= eofy_date())).options(db.selectinload(Transaction.postings))).all())
|
|
||||||
|
|
||||||
accounts = dict(sorted(balancer.accounts.items()))
|
accounts = dict(sorted(balancer.accounts.items()))
|
||||||
|
|
||||||
@ -182,12 +188,12 @@ def tax_summary_report():
|
|||||||
entries=[
|
entries=[
|
||||||
Calculated(
|
Calculated(
|
||||||
'Taxable value of reportable fringe benefits',
|
'Taxable value of reportable fringe benefits',
|
||||||
lambda _: -sum((e.amount for e in entries_for_kind(account_configurations, accounts, 'austax.rfb')), Amount(0, '$')),
|
lambda _: -sum((e.amount for e in entries_for_kind(account_configurations, accounts, 'austax.rfb')), Amount(0, reporting_commodity())),
|
||||||
id='rfb_taxable'
|
id='rfb_taxable'
|
||||||
),
|
),
|
||||||
Calculated(
|
Calculated(
|
||||||
'Grossed-up value',
|
'Grossed-up value',
|
||||||
lambda _: Amount(report.by_id('rfb_taxable').amount.quantity * fbt_grossup[eofy_date().year], '$'),
|
lambda _: Amount(report.by_id('rfb_taxable').amount.quantity * fbt_grossup[eofy_date().year], reporting_commodity()),
|
||||||
id='rfb_grossedup'
|
id='rfb_grossedup'
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
@ -55,7 +55,7 @@
|
|||||||
<label for="cost_adjustment" class="block text-gray-900 pr-4">Cost adjustment</label>
|
<label for="cost_adjustment" class="block text-gray-900 pr-4">Cost adjustment</label>
|
||||||
<div class="relative shadow-sm">
|
<div class="relative shadow-sm">
|
||||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
<span class="text-gray-500">$</span>
|
<span class="text-gray-500">{{ reporting_commodity }}</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="number" class="bordered-field pl-7" name="cost_adjustment" id="cost_adjustment" step="0.01" value="{{ adjustment.cost_adjustment_amount().quantity_string() if adjustment else '' }}" placeholder="0.00">
|
<input type="number" class="bordered-field pl-7" name="cost_adjustment" id="cost_adjustment" step="0.01" value="{{ adjustment.cost_adjustment_amount().quantity_string() if adjustment else '' }}" placeholder="0.00">
|
||||||
</div>
|
</div>
|
||||||
|
@ -64,7 +64,7 @@
|
|||||||
<label for="cost_adjustment" class="block text-gray-900 pr-4">Total cost adjustment</label>
|
<label for="cost_adjustment" class="block text-gray-900 pr-4">Total cost adjustment</label>
|
||||||
<div class="relative shadow-sm">
|
<div class="relative shadow-sm">
|
||||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
<span class="text-gray-500">$</span>
|
<span class="text-gray-500">{{ reporting_commodity }}</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="number" class="bordered-field pl-7" name="cost_adjustment" id="cost_adjustment" step="0.01" value="{{ cost_adjustment or '' }}" placeholder="0.00">
|
<input type="number" class="bordered-field pl-7" name="cost_adjustment" id="cost_adjustment" step="0.01" value="{{ cost_adjustment or '' }}" placeholder="0.00">
|
||||||
</div>
|
</div>
|
||||||
|
31
austax/util.py
Normal file
31
austax/util.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# DrCr: Web-based double-entry bookkeeping framework
|
||||||
|
# Copyright (C) 2022–2024 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 drcr.models import reporting_commodity
|
||||||
|
|
||||||
|
import functools
|
||||||
|
|
||||||
|
def assert_aud(f):
|
||||||
|
"""Wrap a function to assert that the reporting_commodity is $"""
|
||||||
|
|
||||||
|
@functools.wraps(f)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if reporting_commodity() != '$':
|
||||||
|
raise Exception('austax requires reporting_commodity to be $')
|
||||||
|
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
@ -23,11 +23,13 @@ from drcr.webapp import all_accounts, app, eofy_date
|
|||||||
|
|
||||||
from .models import CGTAsset, CGTCostAdjustment
|
from .models import CGTAsset, CGTCostAdjustment
|
||||||
from .reports import tax_summary_report
|
from .reports import tax_summary_report
|
||||||
|
from .util import assert_aud
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from math import copysign
|
from math import copysign
|
||||||
|
|
||||||
@app.route('/tax/cgt-adjustments')
|
@app.route('/tax/cgt-adjustments')
|
||||||
|
@assert_aud
|
||||||
def cgt_adjustments():
|
def cgt_adjustments():
|
||||||
adjustments = db.select(CGTCostAdjustment).order_by(CGTCostAdjustment.dt.desc(), CGTCostAdjustment.account, CGTCostAdjustment.id.desc())
|
adjustments = db.select(CGTCostAdjustment).order_by(CGTCostAdjustment.dt.desc(), CGTCostAdjustment.account, CGTCostAdjustment.id.desc())
|
||||||
if 'account' in request.args:
|
if 'account' in request.args:
|
||||||
@ -43,6 +45,7 @@ def cgt_adjustments():
|
|||||||
return render_plugin_template('austax', 'cgt_adjustments.html', cgt_adjustments=adjustments)
|
return render_plugin_template('austax', 'cgt_adjustments.html', cgt_adjustments=adjustments)
|
||||||
|
|
||||||
@app.route('/tax/cgt-adjustments/new', methods=['GET', 'POST'])
|
@app.route('/tax/cgt-adjustments/new', methods=['GET', 'POST'])
|
||||||
|
@assert_aud
|
||||||
def cgt_adjustment_new():
|
def cgt_adjustment_new():
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
return render_plugin_template('austax', 'cgt_adjustments_edit.html', adjustment=None, all_accounts=all_accounts())
|
return render_plugin_template('austax', 'cgt_adjustments_edit.html', adjustment=None, all_accounts=all_accounts())
|
||||||
@ -63,6 +66,7 @@ def cgt_adjustment_new():
|
|||||||
return redirect(url_for('cgt_adjustments'))
|
return redirect(url_for('cgt_adjustments'))
|
||||||
|
|
||||||
@app.route('/tax/cgt-adjustments/edit', methods=['GET', 'POST'])
|
@app.route('/tax/cgt-adjustments/edit', methods=['GET', 'POST'])
|
||||||
|
@assert_aud
|
||||||
def cgt_adjustment_edit():
|
def cgt_adjustment_edit():
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
return render_plugin_template('austax', 'cgt_adjustments_edit.html', adjustment=db.session.get(CGTCostAdjustment, request.args['id']), all_accounts=all_accounts())
|
return render_plugin_template('austax', 'cgt_adjustments_edit.html', adjustment=db.session.get(CGTCostAdjustment, request.args['id']), all_accounts=all_accounts())
|
||||||
@ -83,6 +87,7 @@ def cgt_adjustment_edit():
|
|||||||
return redirect(url_for('cgt_adjustments'))
|
return redirect(url_for('cgt_adjustments'))
|
||||||
|
|
||||||
@app.route('/tax/cgt-adjustments/multi-new', methods=['GET', 'POST'])
|
@app.route('/tax/cgt-adjustments/multi-new', methods=['GET', 'POST'])
|
||||||
|
@assert_aud
|
||||||
def cgt_adjustment_multinew():
|
def cgt_adjustment_multinew():
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
return render_plugin_template(
|
return render_plugin_template(
|
||||||
@ -172,6 +177,7 @@ def cgt_adjustment_multinew():
|
|||||||
return redirect(url_for('cgt_adjustments'))
|
return redirect(url_for('cgt_adjustments'))
|
||||||
|
|
||||||
@app.route('/tax/cgt-assets')
|
@app.route('/tax/cgt-assets')
|
||||||
|
@assert_aud
|
||||||
def cgt_assets():
|
def cgt_assets():
|
||||||
# Find all CGT asset accounts
|
# Find all CGT asset accounts
|
||||||
cgt_accounts = []
|
cgt_accounts = []
|
||||||
@ -192,7 +198,7 @@ def cgt_assets():
|
|||||||
assets = []
|
assets = []
|
||||||
|
|
||||||
for posting in cgt_postings:
|
for posting in cgt_postings:
|
||||||
if posting.commodity == '$':
|
if posting.commodity == reporting_commodity():
|
||||||
# FIXME: Detect this better
|
# FIXME: Detect this better
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -212,7 +218,7 @@ def cgt_assets():
|
|||||||
asset.disposal_date = posting.transaction.dt
|
asset.disposal_date = posting.transaction.dt
|
||||||
|
|
||||||
# Calculate disposal value by searching for matching asset postings
|
# Calculate disposal value by searching for matching asset postings
|
||||||
asset.disposal_value = Amount(0, '$')
|
asset.disposal_value = Amount(0, reporting_commodity())
|
||||||
for other_posting in posting.transaction.postings:
|
for other_posting in posting.transaction.postings:
|
||||||
if posting != other_posting and 'drcr.asset' in account_configurations.get(other_posting.account, []):
|
if posting != other_posting and 'drcr.asset' in account_configurations.get(other_posting.account, []):
|
||||||
asset.disposal_value.quantity += other_posting.amount().as_cost().quantity
|
asset.disposal_value.quantity += other_posting.amount().as_cost().quantity
|
||||||
@ -229,6 +235,7 @@ def cgt_assets():
|
|||||||
return render_plugin_template('austax', 'cgt_assets.html', assets=assets, eofy_date=eofy_date())
|
return render_plugin_template('austax', 'cgt_assets.html', assets=assets, eofy_date=eofy_date())
|
||||||
|
|
||||||
@app.route('/tax/summary')
|
@app.route('/tax/summary')
|
||||||
|
@assert_aud
|
||||||
def tax_summary():
|
def tax_summary():
|
||||||
report = tax_summary_report()
|
report = tax_summary_report()
|
||||||
return render_template('report.html', report=report)
|
return render_template('report.html', report=report)
|
||||||
|
@ -18,7 +18,7 @@ from flask import abort, redirect, render_template, request, url_for
|
|||||||
|
|
||||||
from .. import AMOUNT_DPS
|
from .. import AMOUNT_DPS
|
||||||
from ..database import db
|
from ..database import db
|
||||||
from ..models import Amount, Posting, Transaction, TrialBalancer
|
from ..models import Amount, Posting, Transaction, TrialBalancer, queue_invalidate_running_balances, reporting_commodity
|
||||||
from ..webapp import all_accounts, all_transactions, app
|
from ..webapp import all_accounts, all_transactions, app
|
||||||
from .models import BalanceAssertion
|
from .models import BalanceAssertion
|
||||||
from ..statements.models import StatementLineReconciliation
|
from ..statements.models import StatementLineReconciliation
|
||||||
@ -58,10 +58,13 @@ def journal_new_transaction():
|
|||||||
commodity=amount.commodity
|
commodity=amount.commodity
|
||||||
)
|
)
|
||||||
transaction.postings.append(posting)
|
transaction.postings.append(posting)
|
||||||
|
|
||||||
|
# Invalidate future running balances
|
||||||
|
queue_invalidate_running_balances(account, transaction.dt)
|
||||||
|
|
||||||
transaction.assert_valid()
|
transaction.assert_valid()
|
||||||
|
|
||||||
db.session.add(transaction)
|
db.session.add(transaction)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return redirect(request.form.get('referrer', '') or url_for('journal'))
|
return redirect(request.form.get('referrer', '') or url_for('journal'))
|
||||||
@ -103,6 +106,9 @@ def journal_edit_transaction():
|
|||||||
commodity=amount.commodity
|
commodity=amount.commodity
|
||||||
)
|
)
|
||||||
new_postings.append(posting)
|
new_postings.append(posting)
|
||||||
|
|
||||||
|
# Invalidate future running balances
|
||||||
|
queue_invalidate_running_balances(account, transaction.dt)
|
||||||
|
|
||||||
# Fix up reconciliations
|
# Fix up reconciliations
|
||||||
for old_posting in transaction.postings:
|
for old_posting in transaction.postings:
|
||||||
@ -158,7 +164,7 @@ def balance_assertions_new():
|
|||||||
description=request.form['description'],
|
description=request.form['description'],
|
||||||
account=request.form['account'],
|
account=request.form['account'],
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
commodity='$'
|
commodity=reporting_commodity()
|
||||||
)
|
)
|
||||||
db.session.add(assertion)
|
db.session.add(assertion)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
137
drcr/models.py
137
drcr/models.py
@ -1,5 +1,5 @@
|
|||||||
# DrCr: Web-based double-entry bookkeeping framework
|
# DrCr: Web-based double-entry bookkeeping framework
|
||||||
# Copyright (C) 2022–2023 Lee Yingtong Li (RunasSudo)
|
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@ -19,6 +19,7 @@ from markupsafe import Markup
|
|||||||
from . import AMOUNT_DPS
|
from . import AMOUNT_DPS
|
||||||
from .database import db
|
from .database import db
|
||||||
|
|
||||||
|
import functools
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
|
|
||||||
class Transaction(db.Model):
|
class Transaction(db.Model):
|
||||||
@ -63,17 +64,32 @@ class Posting(db.Model):
|
|||||||
quantity = db.Column(db.Integer)
|
quantity = db.Column(db.Integer)
|
||||||
commodity = db.Column(db.String)
|
commodity = db.Column(db.String)
|
||||||
|
|
||||||
|
# Running balance of the account in units of reporting_commodity
|
||||||
|
# Only takes into consideration Transactions stored in database, not API-generated ones
|
||||||
|
running_balance = db.Column(db.Integer)
|
||||||
|
|
||||||
transaction = db.relationship('Transaction', back_populates='postings')
|
transaction = db.relationship('Transaction', back_populates='postings')
|
||||||
|
|
||||||
def __init__(self, description=None, account=None, quantity=None, commodity=None):
|
def __init__(self, description=None, account=None, quantity=None, commodity=None, running_balance=None):
|
||||||
self.description = description
|
self.description = description
|
||||||
self.account = account
|
self.account = account
|
||||||
self.quantity = quantity
|
self.quantity = quantity
|
||||||
self.commodity = commodity
|
self.commodity = commodity
|
||||||
|
self.running_balance = running_balance
|
||||||
|
|
||||||
def amount(self):
|
def amount(self):
|
||||||
return Amount(self.quantity, self.commodity)
|
return Amount(self.quantity, self.commodity)
|
||||||
|
|
||||||
|
def queue_invalidate_running_balances(account, dt_from):
|
||||||
|
"""
|
||||||
|
Invalidate running_balances for Postings in the specified account, from the given date onwards
|
||||||
|
|
||||||
|
NOTE: Does not call db.session.commit()
|
||||||
|
"""
|
||||||
|
|
||||||
|
for posting in db.session.scalars(db.select(Posting).join(Posting.transaction).where((Transaction.dt >= dt_from) & (Posting.account == account))).all():
|
||||||
|
posting.running_balance = None
|
||||||
|
|
||||||
class Amount:
|
class Amount:
|
||||||
__slots__ = ['quantity', 'commodity']
|
__slots__ = ['quantity', 'commodity']
|
||||||
|
|
||||||
@ -86,7 +102,7 @@ class Amount:
|
|||||||
if ' ' not in amount_str:
|
if ' ' not in amount_str:
|
||||||
# Default commodity
|
# Default commodity
|
||||||
quantity = round(float(amount_str) * (10**AMOUNT_DPS))
|
quantity = round(float(amount_str) * (10**AMOUNT_DPS))
|
||||||
return Amount(quantity, '$') # TODO: Customisable default commodity
|
return Amount(quantity, reporting_commodity())
|
||||||
|
|
||||||
quantity_str = amount_str[:amount_str.index(' ')]
|
quantity_str = amount_str[:amount_str.index(' ')]
|
||||||
quantity = round(float(quantity_str) * (10**AMOUNT_DPS))
|
quantity = round(float(quantity_str) * (10**AMOUNT_DPS))
|
||||||
@ -118,7 +134,7 @@ class Amount:
|
|||||||
if commodity not in ('non_reporting', 'force', 'hide'):
|
if commodity not in ('non_reporting', 'force', 'hide'):
|
||||||
raise ValueError('Invalid commodity reporting option')
|
raise ValueError('Invalid commodity reporting option')
|
||||||
|
|
||||||
if (self.commodity == '$' and commodity in ('non_reporting', 'force')) or commodity == 'hide':
|
if (self.commodity == reporting_commodity() and commodity in ('non_reporting', 'force')) or commodity == 'hide':
|
||||||
return Markup('{:,.{dps}f}'.format(self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', ' '))
|
return Markup('{:,.{dps}f}'.format(self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', ' '))
|
||||||
elif len(self.commodity) == 1:
|
elif len(self.commodity) == 1:
|
||||||
return Markup('{0}{1:,.{dps}f}'.format(self.commodity, self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', ' '))
|
return Markup('{0}{1:,.{dps}f}'.format(self.commodity, self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', ' '))
|
||||||
@ -139,7 +155,7 @@ class Amount:
|
|||||||
return Markup('<a href="{}" class="hover:text-blue-700 hover:underline">{}</a>{}'.format(link, text, space))
|
return Markup('<a href="{}" class="hover:text-blue-700 hover:underline">{}</a>{}'.format(link, text, space))
|
||||||
|
|
||||||
def quantity_string(self):
|
def quantity_string(self):
|
||||||
if self.commodity == '$':
|
if self.commodity == reporting_commodity():
|
||||||
return '{:.{dps}f}'.format(self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS)
|
return '{:.{dps}f}'.format(self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS)
|
||||||
elif len(self.commodity) == 1:
|
elif len(self.commodity) == 1:
|
||||||
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)
|
||||||
@ -149,7 +165,7 @@ class Amount:
|
|||||||
def as_cost(self):
|
def as_cost(self):
|
||||||
"""Convert commodity to reporting currency in cost basis"""
|
"""Convert commodity to reporting currency in cost basis"""
|
||||||
|
|
||||||
if self.commodity == '$':
|
if self.commodity == reporting_commodity():
|
||||||
return self
|
return self
|
||||||
|
|
||||||
# TODO: Refactor this
|
# TODO: Refactor this
|
||||||
@ -158,10 +174,10 @@ class Amount:
|
|||||||
cost = float(self.commodity[self.commodity.index('{{')+2:self.commodity.index('}}')])
|
cost = float(self.commodity[self.commodity.index('{{')+2:self.commodity.index('}}')])
|
||||||
if self.quantity < 0:
|
if self.quantity < 0:
|
||||||
cost = -cost
|
cost = -cost
|
||||||
return Amount(round(cost * (10**AMOUNT_DPS)), '$')
|
return Amount(round(cost * (10**AMOUNT_DPS)), reporting_commodity())
|
||||||
elif '{' in self.commodity:
|
elif '{' in self.commodity:
|
||||||
cost = float(self.commodity[self.commodity.index('{')+1:self.commodity.index('}')])
|
cost = float(self.commodity[self.commodity.index('{')+1:self.commodity.index('}')])
|
||||||
return Amount(round(cost * self.quantity), '$') # FIXME: Custom reporting currency
|
return Amount(round(cost * self.quantity), reporting_commodity()) # FIXME: Custom reporting currency
|
||||||
else:
|
else:
|
||||||
raise Exception('No cost base for commodity {}'.format(self.commodity))
|
raise Exception('No cost base for commodity {}'.format(self.commodity))
|
||||||
|
|
||||||
@ -195,13 +211,103 @@ class TrialBalancer:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.accounts = {}
|
self.accounts = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_cached(cls, start_date=None, end_date=None):
|
||||||
|
"""Obtain a TrialBalancer based on the cached running_balance"""
|
||||||
|
|
||||||
|
# First, recompute any running_balance if required
|
||||||
|
stale_accounts = db.session.scalars('SELECT DISTINCT account FROM postings WHERE running_balance IS NULL').all()
|
||||||
|
if stale_accounts:
|
||||||
|
# Get all relevant Postings in database in correct order
|
||||||
|
# FIXME: Recompute balances only from the last non-stale balance to be more efficient
|
||||||
|
postings = db.session.scalars(db.select(Posting).join(Posting.transaction).where(Posting.account.in_(stale_accounts)).order_by(Transaction.dt, Transaction.id)).all()
|
||||||
|
|
||||||
|
accounts = {}
|
||||||
|
|
||||||
|
for posting in postings:
|
||||||
|
if posting.account not in accounts:
|
||||||
|
accounts[posting.account] = Amount(0, reporting_commodity())
|
||||||
|
|
||||||
|
# FIXME: Handle commodities better (ensure compatible commodities)
|
||||||
|
accounts[posting.account].quantity += posting.amount().as_cost().quantity
|
||||||
|
|
||||||
|
posting.running_balance = accounts[posting.account].quantity
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if start_date is not None:
|
||||||
|
result_start_date = cls()
|
||||||
|
|
||||||
|
# First SELECT the last applicable dt by account
|
||||||
|
# Then, among the transactions with that dt, SELECT the last applicable transaction_id
|
||||||
|
# Then extract the running_balance for each account at that transaction_id
|
||||||
|
running_balances = db.session.execute('''
|
||||||
|
SELECT p3.account, running_balance FROM
|
||||||
|
(
|
||||||
|
SELECT p1.account, max(p2.transaction_id) AS max_tid FROM
|
||||||
|
(
|
||||||
|
SELECT account, max(dt) AS max_dt FROM postings JOIN transactions ON postings.transaction_id = transactions.id WHERE dt < :start_date GROUP BY account
|
||||||
|
) p1
|
||||||
|
JOIN postings p2 ON p1.account = p2.account AND p1.max_dt = transactions.dt JOIN transactions ON p2.transaction_id = transactions.id GROUP BY p2.account
|
||||||
|
) p3
|
||||||
|
JOIN postings p4 ON p3.account = p4.account AND p3.max_tid = p4.transaction_id
|
||||||
|
''', {'start_date': start_date})
|
||||||
|
|
||||||
|
for running_balance in running_balances.all():
|
||||||
|
result_start_date.accounts[running_balance.account] = Amount(running_balance.running_balance, reporting_commodity())
|
||||||
|
|
||||||
|
if end_date is None:
|
||||||
|
result = cls()
|
||||||
|
|
||||||
|
running_balances = db.session.execute('''
|
||||||
|
SELECT p3.account, running_balance FROM
|
||||||
|
(
|
||||||
|
SELECT p1.account, max(p2.transaction_id) AS max_tid FROM
|
||||||
|
(
|
||||||
|
SELECT account, max(dt) AS max_dt FROM postings JOIN transactions ON postings.transaction_id = transactions.id GROUP BY account
|
||||||
|
) p1
|
||||||
|
JOIN postings p2 ON p1.account = p2.account AND p1.max_dt = transactions.dt JOIN transactions ON p2.transaction_id = transactions.id GROUP BY p2.account
|
||||||
|
) p3
|
||||||
|
JOIN postings p4 ON p3.account = p4.account AND p3.max_tid = p4.transaction_id
|
||||||
|
''')
|
||||||
|
|
||||||
|
for running_balance in running_balances.all():
|
||||||
|
result.accounts[running_balance.account] = Amount(running_balance.running_balance, reporting_commodity())
|
||||||
|
|
||||||
|
if end_date is not None:
|
||||||
|
result = cls()
|
||||||
|
|
||||||
|
running_balances = db.session.execute('''
|
||||||
|
SELECT p3.account, running_balance FROM
|
||||||
|
(
|
||||||
|
SELECT p1.account, max(p2.transaction_id) AS max_tid FROM
|
||||||
|
(
|
||||||
|
SELECT account, max(dt) AS max_dt FROM postings JOIN transactions ON postings.transaction_id = transactions.id WHERE dt <= :end_date GROUP BY account
|
||||||
|
) p1
|
||||||
|
JOIN postings p2 ON p1.account = p2.account AND p1.max_dt = transactions.dt JOIN transactions ON p2.transaction_id = transactions.id GROUP BY p2.account
|
||||||
|
) p3
|
||||||
|
JOIN postings p4 ON p3.account = p4.account AND p3.max_tid = p4.transaction_id
|
||||||
|
''', {'end_date': end_date})
|
||||||
|
|
||||||
|
for running_balance in running_balances.all():
|
||||||
|
result.accounts[running_balance.account] = Amount(running_balance.running_balance, reporting_commodity())
|
||||||
|
|
||||||
|
# Subtract balances at start_date from balances at end_date if required
|
||||||
|
if start_date is not None:
|
||||||
|
for k in result.accounts.keys():
|
||||||
|
# If k not in result_start_date, then the balance at start_date was necessarily 0 and subtraction is not required
|
||||||
|
if k in result_start_date.accounts:
|
||||||
|
result.accounts[k].quantity -= result_start_date.accounts[k].quantity
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def apply_transactions(self, transactions):
|
def apply_transactions(self, transactions):
|
||||||
for transaction in transactions:
|
for transaction in transactions:
|
||||||
for posting in transaction.postings:
|
for posting in transaction.postings:
|
||||||
if posting.account not in self.accounts:
|
if posting.account not in self.accounts:
|
||||||
self.accounts[posting.account] = Amount(0, '$')
|
self.accounts[posting.account] = Amount(0, reporting_commodity())
|
||||||
|
|
||||||
# FIXME: Handle commodities better
|
# FIXME: Handle commodities better (ensure compatible commodities)
|
||||||
self.accounts[posting.account].quantity += posting.amount().as_cost().quantity
|
self.accounts[posting.account].quantity += posting.amount().as_cost().quantity
|
||||||
|
|
||||||
def transfer_balance(self, source_account, destination_account, description=None):
|
def transfer_balance(self, source_account, destination_account, description=None):
|
||||||
@ -217,7 +323,7 @@ class TrialBalancer:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if destination_account not in self.accounts:
|
if destination_account not in self.accounts:
|
||||||
self.accounts[destination_account] = Amount(0, '$') # FIXME: Other commodities
|
self.accounts[destination_account] = Amount(0, reporting_commodity())
|
||||||
|
|
||||||
# FIXME: Handle commodities
|
# FIXME: Handle commodities
|
||||||
self.accounts[destination_account].quantity += self.accounts[source_account].quantity
|
self.accounts[destination_account].quantity += self.accounts[source_account].quantity
|
||||||
@ -246,6 +352,9 @@ class AccountConfiguration(db.Model):
|
|||||||
|
|
||||||
return kinds
|
return kinds
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
# Metadata helpers
|
||||||
|
|
||||||
class Metadata(db.Model):
|
class Metadata(db.Model):
|
||||||
__tablename__ = 'metadata'
|
__tablename__ = 'metadata'
|
||||||
|
|
||||||
@ -257,3 +366,9 @@ class Metadata(db.Model):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def get(key):
|
def get(key):
|
||||||
return Metadata.query.filter_by(key=key).one().value
|
return Metadata.query.filter_by(key=key).one().value
|
||||||
|
|
||||||
|
@functools.cache # Very poor performance if result is not cached!
|
||||||
|
def reporting_commodity():
|
||||||
|
"""Get the native reporting commodity"""
|
||||||
|
|
||||||
|
return Metadata.get('reporting_commodity')
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# DrCr: Web-based double-entry bookkeeping framework
|
# DrCr: Web-based double-entry bookkeeping framework
|
||||||
# Copyright (C) 2022–2023 Lee Yingtong Li (RunasSudo)
|
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@ -16,8 +16,8 @@
|
|||||||
|
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
|
|
||||||
from .models import AccountConfiguration, Amount, TrialBalancer
|
from .models import AccountConfiguration, Amount, TrialBalancer, reporting_commodity
|
||||||
from .webapp import all_transactions, eofy_date, sofy_date
|
from .webapp import all_transactions, api_transactions, eofy_date, sofy_date
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
@ -110,7 +110,7 @@ class Subtotal:
|
|||||||
if self.floor:
|
if self.floor:
|
||||||
amount = (amount // self.floor) * self.floor
|
amount = (amount // self.floor) * self.floor
|
||||||
|
|
||||||
self.amount = Amount(amount, '$')
|
self.amount = Amount(amount, reporting_commodity())
|
||||||
|
|
||||||
class Calculated(Entry):
|
class Calculated(Entry):
|
||||||
def __init__(self, text=None, calc=None, **kwargs):
|
def __init__(self, text=None, calc=None, **kwargs):
|
||||||
@ -154,8 +154,8 @@ def entries_for_kind(account_configurations, accounts, kind, neg=False, floor=0)
|
|||||||
|
|
||||||
def balance_sheet_report():
|
def balance_sheet_report():
|
||||||
# Get trial balance
|
# Get trial balance
|
||||||
balancer = TrialBalancer()
|
balancer = TrialBalancer.from_cached()
|
||||||
balancer.apply_transactions(all_transactions())
|
balancer.apply_transactions(api_transactions())
|
||||||
|
|
||||||
accounts = dict(sorted(balancer.accounts.items()))
|
accounts = dict(sorted(balancer.accounts.items()))
|
||||||
|
|
||||||
@ -207,8 +207,8 @@ def income_statement_report(start_date=None, end_date=None):
|
|||||||
end_date = eofy_date()
|
end_date = eofy_date()
|
||||||
|
|
||||||
# Get trial balance
|
# Get trial balance
|
||||||
balancer = TrialBalancer()
|
balancer = TrialBalancer.from_cached(start_date=start_date, end_date=end_date)
|
||||||
balancer.apply_transactions(all_transactions(start_date=start_date, end_date=end_date))
|
balancer.apply_transactions(api_transactions(start_date=start_date, end_date=end_date))
|
||||||
|
|
||||||
accounts = dict(sorted(balancer.accounts.items()))
|
accounts = dict(sorted(balancer.accounts.items()))
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# DrCr: Web-based double-entry bookkeeping framework
|
# DrCr: Web-based double-entry bookkeeping framework
|
||||||
# Copyright (C) 2022–2023 Lee Yingtong Li (RunasSudo)
|
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import lxml.etree as ET
|
import lxml.etree as ET
|
||||||
|
|
||||||
from ..models import StatementLine
|
from ..models import StatementLine, reporting_commodity
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
@ -51,7 +51,7 @@ def import_ofx1(file):
|
|||||||
dt=datetime.strptime(date, '%Y-%m-%d'),
|
dt=datetime.strptime(date, '%Y-%m-%d'),
|
||||||
description=description,
|
description=description,
|
||||||
quantity=round(float(amount)*100),
|
quantity=round(float(amount)*100),
|
||||||
commodity='$'
|
commodity=reporting_commodity()
|
||||||
))
|
))
|
||||||
|
|
||||||
return imported_statement_lines
|
return imported_statement_lines
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# DrCr: Web-based double-entry bookkeeping framework
|
# DrCr: Web-based double-entry bookkeeping framework
|
||||||
# Copyright (C) 2022–2023 Lee Yingtong Li (RunasSudo)
|
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@ -14,7 +14,7 @@
|
|||||||
# 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 ..models import StatementLine
|
from ..models import StatementLine, reporting_commodity
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
@ -55,7 +55,7 @@ def import_ofx2(file):
|
|||||||
dt=datetime.strptime(date, '%Y-%m-%d'),
|
dt=datetime.strptime(date, '%Y-%m-%d'),
|
||||||
description=description,
|
description=description,
|
||||||
quantity=round(float(amount)*100),
|
quantity=round(float(amount)*100),
|
||||||
commodity='$'
|
commodity=reporting_commodity()
|
||||||
))
|
))
|
||||||
|
|
||||||
return imported_statement_lines
|
return imported_statement_lines
|
||||||
|
@ -42,7 +42,7 @@
|
|||||||
<label for="amount" class="block text-gray-900 pr-4">Balance</label>
|
<label for="amount" class="block text-gray-900 pr-4">Balance</label>
|
||||||
<div class="relative shadow-sm">
|
<div class="relative shadow-sm">
|
||||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
<span class="text-gray-500">$</span>
|
<span class="text-gray-500">{{ reporting_commodity }}</span>
|
||||||
</div>
|
</div>
|
||||||
{# TODO: Display existing credit assertion as credit, not as negative debit #}
|
{# TODO: Display existing credit assertion as credit, not as negative debit #}
|
||||||
<input type="number" class="bordered-field pl-7 pr-16" name="amount" step="0.01" value="{{ assertion.balance().quantity_string() if assertion else '' }}" placeholder="0.00">
|
<input type="number" class="bordered-field pl-7 pr-16" name="amount" step="0.01" value="{{ assertion.balance().quantity_string() if assertion else '' }}" placeholder="0.00">
|
||||||
|
@ -47,7 +47,8 @@
|
|||||||
<td class="amount-dr has-amount py-1 px-1">
|
<td class="amount-dr has-amount py-1 px-1">
|
||||||
<div class="relative shadow-sm">
|
<div class="relative shadow-sm">
|
||||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
<span class="text-gray-500">$</span>
|
{# FIXME: Gracefully handle when the reporting commodity is not a single character #}
|
||||||
|
<span class="text-gray-500">{{ reporting_commodity }}</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" class="bordered-field pl-7" name="amount" oninput="changeAmount(this)">
|
<input type="text" class="bordered-field pl-7" name="amount" oninput="changeAmount(this)">
|
||||||
</div>
|
</div>
|
||||||
@ -84,7 +85,7 @@
|
|||||||
<td class="amount-cr has-amount py-1 pl-1">
|
<td class="amount-cr has-amount py-1 pl-1">
|
||||||
<div class="relative shadow-sm">
|
<div class="relative shadow-sm">
|
||||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
<span class="text-gray-500">$</span>
|
<span class="text-gray-500">{{ reporting_commodity }}</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" class="bordered-field pl-7" name="amount" oninput="changeAmount(this)">
|
<input type="text" class="bordered-field pl-7" name="amount" oninput="changeAmount(this)">
|
||||||
</div>
|
</div>
|
||||||
@ -148,7 +149,7 @@
|
|||||||
<td class="amount-dr has-amount py-1 px-1">
|
<td class="amount-dr has-amount py-1 px-1">
|
||||||
<div class="relative shadow-sm">
|
<div class="relative shadow-sm">
|
||||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
<span class="text-gray-500">$</span>
|
<span class="text-gray-500">{{ reporting_commodity }}</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" class="bordered-field pl-7" name="amount" value="{{ posting.amount().quantity_string() }}" oninput="changeAmount(this)">
|
<input type="text" class="bordered-field pl-7" name="amount" value="{{ posting.amount().quantity_string() }}" oninput="changeAmount(this)">
|
||||||
</div>
|
</div>
|
||||||
@ -159,7 +160,7 @@
|
|||||||
<td class="amount-cr has-amount py-1 pl-1">
|
<td class="amount-cr has-amount py-1 pl-1">
|
||||||
<div class="relative shadow-sm">
|
<div class="relative shadow-sm">
|
||||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
<span class="text-gray-500">$</span>
|
<span class="text-gray-500">{{ reporting_commodity }}</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" class="bordered-field pl-7" name="amount" value="{{ (posting.amount()|abs).quantity_string() }}" oninput="changeAmount(this)">
|
<input type="text" class="bordered-field pl-7" name="amount" value="{{ (posting.amount()|abs).quantity_string() }}" oninput="changeAmount(this)">
|
||||||
</div>
|
</div>
|
||||||
|
@ -67,7 +67,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-gray-300">
|
<tr class="border-b border-gray-300">
|
||||||
<th></th>
|
<th></th>
|
||||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">$ </th>
|
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">{{ reporting_commodity }} </th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -17,10 +17,10 @@
|
|||||||
from flask import redirect, render_template, request, url_for
|
from flask import redirect, render_template, request, url_for
|
||||||
|
|
||||||
from .database import db
|
from .database import db
|
||||||
from .models import AccountConfiguration, Amount, Balance, Posting, TrialBalancer
|
from .models import AccountConfiguration, Amount, Balance, Posting, TrialBalancer, reporting_commodity
|
||||||
from .plugins import account_kinds, advanced_reports, data_sources
|
from .plugins import account_kinds, advanced_reports, data_sources
|
||||||
from .reports import balance_sheet_report, income_statement_report
|
from .reports import balance_sheet_report, income_statement_report
|
||||||
from .webapp import all_transactions, app
|
from .webapp import all_transactions, api_transactions, app
|
||||||
|
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
|
|
||||||
@ -69,11 +69,11 @@ def general_ledger():
|
|||||||
|
|
||||||
@app.route('/trial-balance')
|
@app.route('/trial-balance')
|
||||||
def trial_balance():
|
def trial_balance():
|
||||||
balancer = TrialBalancer()
|
balancer = TrialBalancer.from_cached()
|
||||||
balancer.apply_transactions(all_transactions())
|
balancer.apply_transactions(api_transactions())
|
||||||
|
|
||||||
total_dr = Amount(sum(v.quantity for v in balancer.accounts.values() if v.quantity > 0), '$')
|
total_dr = Amount(sum(v.quantity for v in balancer.accounts.values() if v.quantity > 0), reporting_commodity())
|
||||||
total_cr = Amount(sum(v.quantity for v in balancer.accounts.values() if v.quantity < 0), '$')
|
total_cr = Amount(sum(v.quantity for v in balancer.accounts.values() if v.quantity < 0), reporting_commodity())
|
||||||
|
|
||||||
return render_template('trial_balance.html', accounts=dict(sorted(balancer.accounts.items())), total_dr=total_dr, total_cr=total_cr)
|
return render_template('trial_balance.html', accounts=dict(sorted(balancer.accounts.items())), total_dr=total_dr, total_cr=total_cr)
|
||||||
|
|
||||||
@ -105,7 +105,7 @@ def account_transactions():
|
|||||||
# Pre-compute running totals
|
# Pre-compute running totals
|
||||||
# There can be more than one posting per account per transaction, so track the running total at the level of individual postings
|
# There can be more than one posting per account per transaction, so track the running total at the level of individual postings
|
||||||
running_totals = {}
|
running_totals = {}
|
||||||
running_total = Amount(0, '$')
|
running_total = Amount(0, reporting_commodity())
|
||||||
for transaction in sorted(transactions, key=lambda t: t.dt):
|
for transaction in sorted(transactions, key=lambda t: t.dt):
|
||||||
for posting in transaction.postings:
|
for posting in transaction.postings:
|
||||||
if posting.account == request.args['account']:
|
if posting.account == request.args['account']:
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# DrCr: Web-based double-entry bookkeeping framework
|
# DrCr: Web-based double-entry bookkeeping framework
|
||||||
# Copyright (C) 2022–2023 Lee Yingtong Li (RunasSudo)
|
# Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@ -22,7 +22,7 @@ app.config.from_file('config.toml', load=toml.load)
|
|||||||
from flask_sqlalchemy.record_queries import get_recorded_queries
|
from flask_sqlalchemy.record_queries import get_recorded_queries
|
||||||
|
|
||||||
from .database import db
|
from .database import db
|
||||||
from .models import Metadata, Transaction
|
from .models import Amount, Metadata, Transaction, reporting_commodity
|
||||||
from .plugins import init_plugins, transaction_providers
|
from .plugins import init_plugins, transaction_providers
|
||||||
from .statements.models import StatementLine
|
from .statements.models import StatementLine
|
||||||
|
|
||||||
@ -33,6 +33,8 @@ app.config['SQLALCHEMY_RECORD_QUERIES'] = app.debug
|
|||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
|
|
||||||
def all_transactions(start_date=None, end_date=None, join_postings=True):
|
def all_transactions(start_date=None, end_date=None, join_postings=True):
|
||||||
|
"""Return all transactions, including from DB and API"""
|
||||||
|
|
||||||
# All Transactions in database between start_date and end_date
|
# All Transactions in database between start_date and end_date
|
||||||
query = db.select(Transaction)
|
query = db.select(Transaction)
|
||||||
if start_date and end_date:
|
if start_date and end_date:
|
||||||
@ -46,10 +48,21 @@ def all_transactions(start_date=None, end_date=None, join_postings=True):
|
|||||||
|
|
||||||
transactions = db.session.scalars(query).all()
|
transactions = db.session.scalars(query).all()
|
||||||
|
|
||||||
|
transactions.extend(api_transactions(start_date, end_date))
|
||||||
|
|
||||||
|
return transactions
|
||||||
|
|
||||||
|
def api_transactions(start_date=None, end_date=None):
|
||||||
|
"""Return only transactions from API"""
|
||||||
|
|
||||||
|
transactions = []
|
||||||
|
|
||||||
# Unreconciled StatementLines
|
# Unreconciled StatementLines
|
||||||
|
# FIXME: Filter by start_date and end_date
|
||||||
transactions.extend(line.into_transaction() for line in StatementLine.query.filter(StatementLine.reconciliation == None))
|
transactions.extend(line.into_transaction() for line in StatementLine.query.filter(StatementLine.reconciliation == None))
|
||||||
|
|
||||||
# Plugins
|
# Plugins
|
||||||
|
# FIXME: Filter by start_date and end_date
|
||||||
for transaction_provider in transaction_providers:
|
for transaction_provider in transaction_providers:
|
||||||
transactions.extend(transaction_provider())
|
transactions.extend(transaction_provider())
|
||||||
|
|
||||||
@ -80,10 +93,37 @@ init_plugins()
|
|||||||
|
|
||||||
@app.cli.command('initdb')
|
@app.cli.command('initdb')
|
||||||
def initdb():
|
def initdb():
|
||||||
|
"""Initialise database tables"""
|
||||||
|
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
|
||||||
# FIXME: Need to init metadata
|
# FIXME: Need to init metadata
|
||||||
|
|
||||||
|
@app.cli.command('recache_balances')
|
||||||
|
def recache_balances():
|
||||||
|
"""Recompute running_balance for all postings"""
|
||||||
|
|
||||||
|
# Get all Transactions in database in correct order
|
||||||
|
transactions = db.session.scalars(db.select(Transaction).options(db.selectinload(Transaction.postings)).order_by(Transaction.dt, Transaction.id)).all()
|
||||||
|
|
||||||
|
accounts = {}
|
||||||
|
|
||||||
|
for transaction in transactions:
|
||||||
|
for posting in transaction.postings:
|
||||||
|
if posting.account not in accounts:
|
||||||
|
accounts[posting.account] = Amount(0, reporting_commodity())
|
||||||
|
|
||||||
|
# FIXME: Handle commodities better (ensure compatible commodities)
|
||||||
|
accounts[posting.account].quantity += posting.amount().as_cost().quantity
|
||||||
|
|
||||||
|
posting.running_balance = accounts[posting.account].quantity
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def add_reporting_commodity():
|
||||||
|
return dict(reporting_commodity=reporting_commodity())
|
||||||
|
|
||||||
if app.debug:
|
if app.debug:
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def before_request():
|
def before_request():
|
||||||
|
Loading…
Reference in New Issue
Block a user