austax: Basic CGT asset register

This commit is contained in:
RunasSudo 2023-01-07 02:05:21 +11:00
parent 66b3ddff54
commit 0ebbebc43b
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
8 changed files with 165 additions and 12 deletions

View File

@ -16,14 +16,17 @@
from flask import render_template from flask import render_template
from drcr.models import AccountConfiguration, Posting, Transaction, TrialBalancer from drcr.models import AccountConfiguration, Amount, Posting, Transaction, TrialBalancer
from drcr.database import db from drcr.database import db
import drcr.plugins import drcr.plugins
from drcr.plugins import render_plugin_template
from drcr.webapp import app from drcr.webapp import app
from .models import CGTAsset
from .reports import eofy_date, tax_summary_report from .reports import eofy_date, tax_summary_report
def plugin_init(): def plugin_init():
drcr.plugins.advanced_reports.append(('/tax/cgt-assets', 'CGT assets'))
drcr.plugins.advanced_reports.append(('/tax/summary', 'Tax summary')) drcr.plugins.advanced_reports.append(('/tax/summary', 'Tax summary'))
drcr.plugins.account_kinds.append(('austax.income1', 'Salary or wages (1)')) drcr.plugins.account_kinds.append(('austax.income1', 'Salary or wages (1)'))
@ -32,9 +35,54 @@ def plugin_init():
drcr.plugins.account_kinds.append(('austax.d4', 'Work-related self-education expenses (D4)')) drcr.plugins.account_kinds.append(('austax.d4', 'Work-related self-education expenses (D4)'))
drcr.plugins.account_kinds.append(('austax.d5', 'Other work-related expenses (D5)')) drcr.plugins.account_kinds.append(('austax.d5', 'Other work-related expenses (D5)'))
drcr.plugins.account_kinds.append(('austax.paygw', 'PAYG withheld amounts')) drcr.plugins.account_kinds.append(('austax.paygw', 'PAYG withheld amounts'))
drcr.plugins.account_kinds.append(('austax.cgtasset', 'CGT asset'))
drcr.plugins.transaction_providers.append(make_tax_transactions) drcr.plugins.transaction_providers.append(make_tax_transactions)
@app.route('/tax/cgt-assets')
def cgt_assets():
# Find all CGT asset accounts
cgt_accounts = []
account_configurations = AccountConfiguration.get_all_kinds()
for account_name, kinds in account_configurations.items():
if 'austax.cgtasset' in kinds:
cgt_accounts.append(account_name)
# Get all postings to CGT asset accounts
cgt_postings = db.session.scalars(
db.select(Posting)
.where(Posting.account.in_(cgt_accounts))
.join(Posting.transaction)
.order_by(Transaction.dt)
).all()
# Process postings to determine final balances
assets = []
for posting in cgt_postings:
if posting.quantity >= 0:
assets.append(CGTAsset(posting.quantity, posting.commodity, posting.account, posting.transaction.dt))
elif posting.quantity < 0:
asset = next((a for a in assets if a.commodity == posting.commodity and a.account == posting.account), None)
if asset is None:
raise Exception('Attempted credit {} without preceding debit balance'.quantity(posting.amount()))
if asset.quantity + posting.quantity < 0:
raise Exception('Attempted credit {} with insufficient debit balance {}'.quantity(posting.amount(), asset.amount()))
if asset.quantity + posting.quantity != 0:
raise NotImplementedError('Partial disposal of CGT asset not implemented')
asset.disposal_date = posting.transaction.dt
# Calculate disposal value by searching for matching asset postings
asset.disposal_value = Amount(0, '$')
for other_posting in posting.transaction.postings:
if posting != other_posting and 'drcr.asset' in account_configurations.get(other_posting.account, []):
asset.disposal_value.quantity += other_posting.amount().as_cost().quantity
return render_plugin_template('austax', 'cgt_assets.html', assets=assets)
@app.route('/tax/summary') @app.route('/tax/summary')
def tax_summary(): def tax_summary():
report = tax_summary_report() report = tax_summary_report()

33
austax/models.py Normal file
View File

@ -0,0 +1,33 @@
# DrCr: Web-based double-entry bookkeeping framework
# Copyright (C) 2022–2023 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 Amount
class CGTAsset(Amount):
def __init__(self, quantity, commodity, account, acquisition_date):
super().__init__(quantity, commodity)
self.account = account
self.acquisition_date = acquisition_date
self.disposal_date = None
self.disposal_value = None
def __repr__(self):
return '<{}: {} [{:%Y-%m-%d}]>'.format(self.__class__.__name__, self.format(True), self.acquisition_date)
def commodity_name(self):
return self.commodity[:self.commodity.index('{')].strip()

View File

@ -0,0 +1,60 @@
{# DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 2022–2023 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/>.
#}
{% extends 'base.html' %}
{% block title %}CGT assets{% endblock %}
{% block content %}
<h1 class="h2 mt-4">CGT assets</h1>
<table class="table">
<thead>
<tr>
<th style="border-bottom-width:0"></th>
<th style="border-bottom-width:0"></th>
<th style="border-bottom-width:0"></th>
<th style="border-left-width:1px" colspan="2">Acquisition</th>
<th style="border-left-width:1px" colspan="2">Disposal</th>
<th style="border-bottom-width:0;border-left-width:1px"></th>
</tr>
<tr>
<th>Account</th>
<th>Asset</th>
<th class="text-end">Units</th>
<th style="border-left-width:1px">Date</th>
<th class="text-end">Value</th>
<th style="border-left-width:1px">Date</th>
<th class="text-end">Value</th>
<th style="border-left-width:1px" class="text-end">Gain&nbsp;</th>
</tr>
</thead>
<tbody>
{% for asset in assets %}
<tr>
<td>{{ asset.account }}</td>
<td>{{ asset.commodity_name() }}</td>
<td class="text-end">{{ asset.format('hide') }}</td>
<td style="border-left-width:1px">{{ asset.acquisition_date.strftime('%Y-%m-%d') }}</td>
<td class="text-end">{{ asset.as_cost().format() }}</td>
<td style="border-left-width:1px">{{ asset.disposal_date.strftime('%Y-%m-%d') if asset.disposal_date else '' }}</td>
<td class="text-end">{{ asset.disposal_value.format() if asset.disposal_value else '' }}</td>
<td style="border-left-width:1px" class="text-end">{% if asset.disposal_date %}{{ (asset.disposal_value - asset.as_cost()).format_accounting() }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@ -94,6 +94,9 @@ class Amount:
commodity = amount_str[amount_str.index(' ')+1:] commodity = amount_str[amount_str.index(' ')+1:]
return Amount(quantity, commodity) return Amount(quantity, commodity)
def __repr__(self):
return '<{}: {}>'.format(self.__class__.__name__, self.format('force'))
def __abs__(self): def __abs__(self):
return Amount(abs(self.quantity), self.commodity) return Amount(abs(self.quantity), self.commodity)
@ -108,8 +111,11 @@ class Amount:
def __sub__(self, other): def __sub__(self, other):
return self + (-other) return self + (-other)
def format(self, force_commodity=False): def format(self, commodity='non_reporting'):
if self.commodity == '$' and not force_commodity: if commodity not in ('non_reporting', 'force', 'hide'):
raise ValueError('Invalid commodity reporting option')
if (self.commodity == '$' and commodity in ('non_reporting', 'force')) or commodity == 'hide':
return Markup('{:,.{dps}f}'.format(self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', '&#x202F;')) return Markup('{:,.{dps}f}'.format(self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', '&#x202F;'))
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(',', '&#x202F;')) return Markup('{0}{1:,.{dps}f}'.format(self.commodity, self.quantity / (10**AMOUNT_DPS), dps=AMOUNT_DPS).replace(',', '&#x202F;'))

View File

@ -14,6 +14,9 @@
# 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 flask import render_template
from jinja2 import PackageLoader
from .webapp import app from .webapp import app
import importlib import importlib
@ -33,3 +36,6 @@ def init_plugins():
for plugin in app.config['PLUGINS']: for plugin in app.config['PLUGINS']:
module = importlib.import_module(plugin) module = importlib.import_module(plugin)
module.plugin_init() module.plugin_init()
def render_plugin_template(plugin_name, template_path, **kwargs):
return render_template(PackageLoader(plugin_name).load(app.jinja_env, template_path, app.jinja_env.globals), **kwargs)

View File

@ -1,5 +1,5 @@
{# DrCr: Web-based double-entry bookkeeping framework {# DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 2022 Lee Yingtong Li (RunasSudo) Copyright (C) 2022–2023 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
@ -53,8 +53,8 @@
<td class="text-end"><i>{{ 'Dr' if posting.quantity >= 0 else 'Cr' }}</i></td> <td class="text-end"><i>{{ 'Dr' if posting.quantity >= 0 else 'Cr' }}</i></td>
<td>{{ posting.account }}</td> <td>{{ posting.account }}</td>
{% if commodity_detail %} {% if commodity_detail %}
<td class="text-end">{{ posting.amount().format(True) if posting.quantity >= 0 else '' }}</td> <td class="text-end">{{ posting.amount().format('force') if posting.quantity >= 0 else '' }}</td>
<td class="text-end">{{ (posting.amount()|abs).format(True) if posting.quantity < 0 else '' }}</td> <td class="text-end">{{ (posting.amount()|abs).format('force') if posting.quantity < 0 else '' }}</td>
{% else %} {% else %}
<td class="text-end">{{ posting.amount().as_cost().format() if posting.quantity >= 0 else '' }}</td> <td class="text-end">{{ posting.amount().as_cost().format() if posting.quantity >= 0 else '' }}</td>
<td class="text-end">{{ (posting.amount()|abs).as_cost().format() if posting.quantity < 0 else '' }}</td> <td class="text-end">{{ (posting.amount()|abs).as_cost().format() if posting.quantity < 0 else '' }}</td>

View File

@ -54,8 +54,8 @@
<td class="text-end"><i>{{ 'Dr' if posting.quantity >= 0 else 'Cr' }}</i></td> <td class="text-end"><i>{{ 'Dr' if posting.quantity >= 0 else 'Cr' }}</i></td>
<td>{{ posting.account }}</td> <td>{{ posting.account }}</td>
{% if commodity_detail %} {% if commodity_detail %}
<td class="text-end">{{ posting.amount().format(True) if posting.quantity >= 0 else '' }}</td> <td class="text-end">{{ posting.amount().format('force') if posting.quantity >= 0 else '' }}</td>
<td class="text-end">{{ (posting.amount()|abs).format(True) if posting.quantity < 0 else '' }}</td> <td class="text-end">{{ (posting.amount()|abs).format('force') if posting.quantity < 0 else '' }}</td>
{% else %} {% else %}
<td class="text-end">{{ posting.amount().as_cost().format() if posting.quantity >= 0 else '' }}</td> <td class="text-end">{{ posting.amount().as_cost().format() if posting.quantity >= 0 else '' }}</td>
<td class="text-end">{{ (posting.amount()|abs).as_cost().format() if posting.quantity < 0 else '' }}</td> <td class="text-end">{{ (posting.amount()|abs).as_cost().format() if posting.quantity < 0 else '' }}</td>

View File

@ -1,5 +1,5 @@
{# DrCr: Web-based double-entry bookkeeping framework {# DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 2022 Lee Yingtong Li (RunasSudo) Copyright (C) 2022–2023 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
@ -59,8 +59,8 @@
<td></td> <td></td>
<td></td> <td></td>
<td>{{ 'Dr' if posting.quantity >= 0 else 'Cr' }}</td> <td>{{ 'Dr' if posting.quantity >= 0 else 'Cr' }}</td>
<td class="text-end">{{ (posting.amount()|abs).format(True) }}</td> <td class="text-end">{{ (posting.amount()|abs).format('force') }}</td>
<td class="text-end">{{ (amount|abs).format(True) }}</td> <td class="text-end">{{ (amount|abs).format('force') }}</td>
<td>{{ 'Dr' if amount.quantity >= 0 else 'Cr' }}</td> <td>{{ 'Dr' if amount.quantity >= 0 else 'Cr' }}</td>
</tr> </tr>
{% else %} {% else %}
@ -69,7 +69,7 @@
<td></td> <td></td>
<td></td> <td></td>
<td></td> <td></td>
<td class="text-end">{{ (amount|abs).format(True) }}</td> <td class="text-end">{{ (amount|abs).format('force') }}</td>
<td>{{ 'Dr' if amount.quantity >= 0 else 'Cr' }}</td> <td>{{ 'Dr' if amount.quantity >= 0 else 'Cr' }}</td>
</tr> </tr>
{% endfor %} {% endfor %}