diff --git a/austax/models.py b/austax/models.py index 987cee8..d2f0566 100644 --- a/austax/models.py +++ b/austax/models.py @@ -30,7 +30,7 @@ class CGTAsset(Amount): self.cost_adjustments = [] def __repr__(self): - return '<{}: {} [{:%Y-%m-%d}]>'.format(self.__class__.__name__, self.format(True), self.acquisition_date) + return '<{}: {} [{:%Y-%m-%d}]>'.format(self.__class__.__name__, self.format('force'), self.acquisition_date) def commodity_name(self): return self.commodity[:self.commodity.index('{')].strip() diff --git a/austax/templates/cgt_adjustments.html b/austax/templates/cgt_adjustments.html index 5880592..5c6f5c5 100644 --- a/austax/templates/cgt_adjustments.html +++ b/austax/templates/cgt_adjustments.html @@ -23,6 +23,7 @@
New CGT adjustment + Multiple CGT adjustments
diff --git a/austax/templates/cgt_adjustments_multinew.html b/austax/templates/cgt_adjustments_multinew.html new file mode 100644 index 0000000..8fb6820 --- /dev/null +++ b/austax/templates/cgt_adjustments_multinew.html @@ -0,0 +1,69 @@ +{# 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 . +#} + +{% extends 'base.html' %} +{% block title %}Multiple CGT adjustments{% endblock %} + +{% block content %} +

Multiple CGT adjustments

+ +
+

CGT assets

+ +
+ +
+ +
+
+
+ +
+ +
+
+ + + +

CGT adjustment

+ +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+ +{% endblock %} diff --git a/austax/views.py b/austax/views.py index 3f947c0..fe0454e 100644 --- a/austax/views.py +++ b/austax/views.py @@ -25,10 +25,11 @@ from .models import CGTAsset, CGTCostAdjustment from .reports import eofy_date, tax_summary_report from datetime import datetime +from math import copysign @app.route('/tax/cgt-adjustments') def cgt_adjustments(): - adjustments = db.session.scalars(db.select(CGTCostAdjustment)).all() + adjustments = db.session.scalars(db.select(CGTCostAdjustment).order_by(CGTCostAdjustment.dt, CGTCostAdjustment.account)).all() return render_plugin_template('austax', 'cgt_adjustments.html', cgt_adjustments=adjustments) @app.route('/tax/cgt-adjustments/new', methods=['GET', 'POST']) @@ -71,6 +72,92 @@ def cgt_adjustment_edit(): return redirect('/tax/cgt-adjustments') +@app.route('/tax/cgt-adjustments/multi-new', methods=['GET', 'POST']) +def cgt_adjustment_multinew(): + if request.method == 'GET': + return render_plugin_template( + 'austax', 'cgt_adjustments_multinew.html', + account=None, + commodity=None, + dt=None, + description=None, + cost_adjustment=None + ) + + # TODO: Preview mode? + + total_adjustment = Amount.parse(request.form['cost_adjustment']).quantity + + # Get all postings to the CGT asset account + cgt_postings = db.session.scalars( + db.select(Posting) + .where(Posting.account == request.form['account']) + .join(Posting.transaction) + .order_by(Transaction.dt) + ).all() + + # Process postings to determine final balances + assets = [] + + for posting in cgt_postings: + if posting.commodity[:posting.commodity.index('{')].strip() != request.form['commodity']: + continue + + 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') + + assets.remove(asset) + + # Distribute total adjustment across matching assets + total_quantity = sum(a.quantity for a in assets) + cgt_adjustments = {} + + for asset in assets: + cgt_adjustments[asset] = total_adjustment * asset.quantity / total_quantity + + # Round up as many as required to equal the total adjustment + rounding_shortfall = abs(total_adjustment) - sum(int(abs(v)) for v in cgt_adjustments.values()) + largest_remainders = [(k, abs(v) - int(abs(v))) for k, v in cgt_adjustments.items()] + largest_remainders.sort(key=lambda x: x[1], reverse=True) + for asset, _ in largest_remainders[:rounding_shortfall]: + adjustment = cgt_adjustments[asset] + adjustment = copysign(int(abs(adjustment)) + 1, adjustment) + cgt_adjustments[asset] = adjustment + + # Round others down + for asset, adjustment in cgt_adjustments.items(): + cgt_adjustments[asset] = copysign(int(abs(adjustment)), adjustment) + + # Sanity check + assert sum(v for v in cgt_adjustments.values()) == total_adjustment + + # Add adjustments + for asset, adjustment in cgt_adjustments.items(): + adjustment = CGTCostAdjustment( + quantity=asset.quantity, + commodity=asset.commodity, + account=asset.account, + acquisition_date=asset.acquisition_date, + dt=datetime.strptime(request.form['dt'], '%Y-%m-%d'), + description=request.form['description'], + cost_adjustment=adjustment + ) + db.session.add(adjustment) + + db.session.commit() + + return redirect('/tax/cgt-adjustments') + @app.route('/tax/cgt-assets') def cgt_assets(): # Find all CGT asset accounts