diff --git a/austax/__init__.py b/austax/__init__.py new file mode 100644 index 0000000..b85fa16 --- /dev/null +++ b/austax/__init__.py @@ -0,0 +1,58 @@ +# 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 . + +from flask import render_template + +from drcr.models import Posting, Transaction +import drcr.plugins +from drcr.webapp import app + +from .reports import tax_summary_report + +from datetime import datetime + +def plugin_init(): + 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.income5', 'Australian Government allowances and payments (5)')) + 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.transaction_providers.append(make_tax_transactions) + +@app.route('/tax/summary') +def tax_summary(): + report = tax_summary_report() + return render_template('report.html', report=report) + +def make_tax_transactions(): + report = tax_summary_report() + tax_amount = report.by_id('total_tax').amount + + # Get EOFY date + dt = datetime.now().replace(month=6, day=30) + if dt < datetime.now(): + dt = dt.replace(year=dt.year + 1) + + return [Transaction( + dt=dt, + description='Estimated tax payable', + postings=[ + Posting(account='Income Tax', quantity=tax_amount.quantity, commodity='$'), + Posting(account='Income Tax Control', quantity=-tax_amount.quantity, commodity='$') + ] + )] diff --git a/austax/reports.py b/austax/reports.py new file mode 100644 index 0000000..1a6c28d --- /dev/null +++ b/austax/reports.py @@ -0,0 +1,116 @@ +# 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 . + +from drcr.database import db +from drcr.models import AccountConfiguration, Amount, Transaction, TrialBalancer +from drcr.reports import Calculated, Report, Section, Spacer, Subtotal, entries_for_kind + +def base_income_tax(taxable_income): + income = taxable_income.quantity + + if income <= 1820000: + return Amount(0, '$') + if income <= 4500000: + return Amount(int((income - 1820000) * 0.19), '$') + if income <= 12000000: + return Amount(int(509200 + (income - 4500000) * 0.325), '$') + if income <= 18000000: + return Amount(int(2946700 + (income - 12000000) * 0.37), '$') + return Amount(int(5166700 + (income - 18000000) * 0.45), '$') + +def medicare_levy(taxable_income): + if taxable_income.quantity < 2920700: + raise NotImplementedError('Medicare levy reduction is not implemented') + + return Amount(int(taxable_income.quantity * 0.02), '$') + +def tax_summary_report(): + # Get trial balance + balancer = TrialBalancer() + #balancer.apply_transactions(all_transactions()) + balancer.apply_transactions(db.session.scalars(db.select(Transaction)).all()) + + accounts = dict(sorted(balancer.accounts.items())) + + # Get account configurations + account_configurations = AccountConfiguration.get_all_kinds() + + report = Report(title='Tax summary') + report.entries = [ + Section( + entries=[ + Section( + title='Salary or wages (1)', + entries=entries_for_kind(account_configurations, accounts, 'austax.income1', neg=True, floor=100) + [Subtotal('Total item 1', id='income1')] + ), + Spacer(), + Section( + title='Australian Government allowances and payments (5)', + entries=entries_for_kind(account_configurations, accounts, 'austax.income5', neg=True) + [Subtotal('Total item 5', id='income5', floor=100)] + ), + Spacer(), + Calculated( + 'Total assessable income', + lambda r: r.by_id('income1').amount + r.by_id('income5').amount, + id='assessable', + heading=True, + bordered=True + ), + Spacer(), + Section( + title='Work-related self-education expenses (D4)', + entries=entries_for_kind(account_configurations, accounts, 'austax.d4') + [Subtotal('Total item D4', id='d4', floor=100)] + ), + Spacer(), + Section( + title='Other work-related expenses (D5)', + entries=entries_for_kind(account_configurations, accounts, 'austax.d5') + [Subtotal('Total item D5', id='d5', floor=100)] + ), + Spacer(), + Calculated( + 'Total deductions', + lambda r: r.by_id('d4').amount + r.by_id('d5').amount, + id='deductions', + heading=True, + bordered=True + ), + Spacer(), + Calculated( + 'Taxable income', + lambda r: r.by_id('assessable').amount - r.by_id('deductions').amount, + id='taxable', + heading=True, + bordered=True + ), + Section( + entries=[ + Calculated( + 'Income tax', + lambda _: base_income_tax(report.by_id('taxable').amount) + ), + Calculated( + 'Medicare levy', + lambda _: medicare_levy(report.by_id('taxable').amount) + ), + Subtotal(id='total_tax', visible=False) + ] + ) + ] + ) + ] + report.calculate() + + return report diff --git a/drcr/config.py.example b/drcr/config.py.example index fe7b711..1bc714f 100644 --- a/drcr/config.py.example +++ b/drcr/config.py.example @@ -1,6 +1 @@ -TAX_MAPPING = { - 'Government allowances': [], - 'Other work-related expenses': [], - 'Salary and wages': [], - 'Work-related self-education expenses': [] -} +PLUGINS = ['austax'] diff --git a/drcr/reports.py b/drcr/reports.py index cb79160..caa1602 100644 --- a/drcr/reports.py +++ b/drcr/reports.py @@ -77,18 +77,21 @@ class Entry: pass class Subtotal: - def __init__(self, text=None, *, id=None, bordered=False): + def __init__(self, text=None, *, id=None, visible=True, bordered=False, floor=0): self.text = text self.id = id + self.visible = visible self.bordered = bordered + self.floor = floor self.amount = None def calculate(self, parent): - self.amount = Amount( - sum(e.amount.quantity for e in parent.entries if isinstance(e, Entry)), - '$' - ) + amount = sum(e.amount.quantity for e in parent.entries if isinstance(e, Entry)) + if self.floor: + amount = (amount // self.floor) * self.floor + + self.amount = Amount(amount, '$') class Calculated(Entry): def __init__(self, text=None, calc=None, **kwargs): @@ -112,12 +115,16 @@ def validate_accounts(accounts, account_configurations): if n != 1: raise Exception('Account "{}" mapped to {} account types (expected 1)'.format(account, n)) -def entries_for_kind(account_configurations, accounts, kind, neg=False): - return [ - Entry(text=account_name, amount=-amount if neg else amount, link='/account-transactions?account=' + account_name) - for account_name, amount in accounts.items() - if kind in account_configurations.get(account_name, []) and amount.quantity != 0 - ] +def entries_for_kind(account_configurations, accounts, kind, neg=False, floor=0): + entries = [] + for account_name, amount in accounts.items(): + if kind in account_configurations.get(account_name, []) and amount.quantity != 0: + if neg: + amount = -amount + if floor: + amount.quantity = (amount.quantity // floor) * floor + entries.append(Entry(text=account_name, amount=amount, link='/account-transactions?account=' + account_name)) + return entries def balance_sheet_report(): # Get trial balance diff --git a/drcr/tax/__init__.py b/drcr/tax/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/drcr/tax/aus_tax.py b/drcr/tax/aus_tax.py deleted file mode 100644 index ec73b7f..0000000 --- a/drcr/tax/aus_tax.py +++ /dev/null @@ -1,73 +0,0 @@ -# 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 . - -from ..config import TAX_MAPPING -from ..models import Amount, Posting, Transaction, TrialBalancer - -from datetime import datetime - -def taxable_income(): - balancer = TrialBalancer() - balancer.apply_transactions(Transaction.query.all()) - - result = Amount(0, '$') - - for account in TAX_MAPPING['Salary and wages']: - result.quantity += int(balancer.accounts[account].quantity / 100) * 100 - for account in TAX_MAPPING['Government allowances']: - result.quantity += int(balancer.accounts[account].quantity / 100) * 100 - for account in TAX_MAPPING['Work-related self-education expenses']: - result.quantity += int(balancer.accounts[account].quantity / 100) * 100 - for account in TAX_MAPPING['Other work-related expenses']: - result.quantity += int(balancer.accounts[account].quantity / 100) * 100 - - return result - -def base_income_tax(taxable_income): - income = -taxable_income.as_cost().quantity - - if income <= 1820000: - return Amount(0, '$') - if income <= 4500000: - return Amount(int((income - 1820000) * 0.19), '$') - if income <= 12000000: - return Amount(int(509200 + (income - 4500000) * 0.325), '$') - if income <= 18000000: - return Amount(int(2946700 + (income - 12000000) * 0.37), '$') - return Amount(int(5166700 + (income - 18000000) * 0.45), '$') - -def calculate_tax(taxable_income): - income = -taxable_income.as_cost().quantity - medicare_levy = int(income * 0.02) - - return Amount(base_income_tax(taxable_income).quantity + medicare_levy, '$') - -def tax_transaction(taxable_income): - tax = calculate_tax(taxable_income) - - # Get EOFY date - dt = datetime.now().replace(month=6, day=30) - if dt < datetime.now(): - dt = dt.replace(year=dt.year + 1) - - return Transaction( - dt=dt, - description='Estimated tax payable', - postings=[ - Posting(account='Income Tax', quantity=tax.quantity, commodity='$'), - Posting(account='Income Tax Control', quantity=-tax.quantity, commodity='$') - ] - ) diff --git a/drcr/tax/views.py b/drcr/tax/views.py deleted file mode 100644 index 90ad061..0000000 --- a/drcr/tax/views.py +++ /dev/null @@ -1,36 +0,0 @@ -# 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 . - -from flask import render_template - -from ..config import TAX_MAPPING -from ..models import Amount, TrialBalancer -from ..webapp import all_transactions, app -from .aus_tax import base_income_tax, calculate_tax - -@app.route('/tax/summary') -def tax_summary(): - # Get trial balance and validate COA - balancer = TrialBalancer() - balancer.apply_transactions(all_transactions()) - - return render_template( - 'tax/summary.html', - accounts=balancer.accounts, - base_income_tax=base_income_tax, calculate_tax=calculate_tax, - running_total=Amount(0, '$'), - TAX_MAPPING=TAX_MAPPING - ) diff --git a/drcr/templates/report.html b/drcr/templates/report.html index 020cfd5..d7fa897 100644 --- a/drcr/templates/report.html +++ b/drcr/templates/report.html @@ -19,10 +19,12 @@ {% block title %}{{ report.title }}{% endblock %} {% macro render_section(section) %} - - {{ section.title }} - - + {% if section.title %} + + {{ section.title }} + + + {% endif %} {% for entry in section.entries %} {{ render_entry(entry) }} {% endfor %} @@ -32,10 +34,12 @@ {% if entry.__class__.__name__ == 'Section' %} {{ render_section(entry) }} {% elif entry.__class__.__name__ == 'Subtotal' %} - - {{ entry.text }} - {{ entry.amount.format_accounting() }} - + {% if entry.visible %} + + {{ entry.text }} + {{ entry.amount.format_accounting() }} + + {% endif %} {% elif entry.__class__.__name__ == 'Spacer' %}   {% else %} diff --git a/drcr/templates/tax/summary.html b/drcr/templates/tax/summary.html deleted file mode 100644 index 4f6186e..0000000 --- a/drcr/templates/tax/summary.html +++ /dev/null @@ -1,120 +0,0 @@ -{# 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 %}Tax summary{% endblock %} - -{% macro fmtbal(amount, rnd=1, mul=1) %} - {# FIXME: Honour AMOUNT_DPS #} - {% if amount.quantity * mul >= 0 %} - {{ '{:,.2f}'.format((amount.quantity|abs / rnd)|round(0, 'floor') * rnd / 100) }}  - {% else %} - ({{ '{:,.2f}'.format((amount.quantity|abs / rnd)|round(0, 'floor') * rnd / 100) }}) - {% endif %} -{% endmacro %} - -{% macro acctrow(name, rnd=1, mul=1) %} - - {{ name }} - {{ fmtbal(accounts[name], rnd, mul) }} - - {% set rnd_qty = (accounts[name].quantity|abs / rnd)|round(0, 'floor') * rnd * accounts[name].quantity/(accounts[name].quantity|abs) %} - {% set _ = running_total.__setattr__('quantity', running_total.quantity + rnd_qty) %} -{% endmacro %} - -{% block content %} -

Tax summary

- - - - - - - - - - - - - {% set _ = running_total.__setattr__('quantity', 0) %} - {% for account in TAX_MAPPING['Salary and wages'] if account in accounts %} - {{ acctrow(account, 100, -1) }} - {% endfor %} - - - - - - - {% for account in TAX_MAPPING['Government allowances'] if account in accounts %} - {{ acctrow(account, 100, -1) }} - {% endfor %} - - - - - - - - {% set taxable_income = running_total.quantity %} - - - - - - - {% set _ = running_total.__setattr__('quantity', 0) %} - {% for account in TAX_MAPPING['Work-related self-education expenses'] if account in accounts %} - {{ acctrow(account, 100) }} - {% endfor %} - - - - - - - {% for account in TAX_MAPPING['Other work-related expenses'] if account in accounts %} - {{ acctrow(account, 100) }} - {% endfor %} - - - - - - - - {% set taxable_income = taxable_income + running_total.quantity %} - - - - - - {% set _ = running_total.__setattr__('quantity', taxable_income) %} - - - - - - - - - {% set _ = running_total.__setattr__('quantity', taxable_income * 0.02) %} - - - -
Salary and wages (1)
 
Government allowances (5)
 
Total assessable income{{ fmtbal(running_total, 1, -1) }}
 
Work-related self-education expenses (D4)
 
Other work-related expenses (D5)
 
Total deductions{{ fmtbal(running_total) }}
 
Taxable income{{ fmtbal(running_total, 1, -1) }}
Income tax{{ fmtbal(base_income_tax(running_total)) }}
Medicare levy{{ fmtbal(running_total, 1, -1) }}
-{% endblock %} diff --git a/drcr/templates/transactions.html b/drcr/templates/transactions.html index 6291d72..ba2a3be 100644 --- a/drcr/templates/transactions.html +++ b/drcr/templates/transactions.html @@ -22,6 +22,7 @@

Account transactions

diff --git a/drcr/webapp.py b/drcr/webapp.py index 7984c2f..68a371c 100644 --- a/drcr/webapp.py +++ b/drcr/webapp.py @@ -21,7 +21,6 @@ from .database import db from .models import Transaction from .plugins import init_plugins, transaction_providers from .statements.models import StatementLine -from .tax import aus_tax import time @@ -49,7 +48,6 @@ from .journal import views from .statements import views init_plugins() -from .tax import views @app.cli.command('initdb') def initdb():