# 2024-25 resident individual tax. Illustrative inputs only.
from dataclasses import dataclass
# (top of bracket, marginal rate). Stage 3 rates, from 1 July 2024.
BRACKETS = [
(18_200, 0.00),
(45_000, 0.16),
(135_000, 0.30),
(190_000, 0.37),
(float("inf"), 0.45),
]
MEDICARE_RATE = 0.02
MEDICARE_LOWER = 27_222 # single, no SAPTO; the levy shades in at 10c per dollar above this
# Study loans: ONE rate applied to the whole repayment income. (lower bound, rate).
# Only the bands up to $84,107 and the 10% top band are copied here.
STUDY_LOAN = [
(0, 0.000),
(54_435, 0.010),
(62_851, 0.020),
(66_621, 0.025),
(70_619, 0.030),
(74_856, 0.035),
(79_347, 0.040),
]
STUDY_LOAN_CHECKED_TO = 84_107
STUDY_LOAN_TOP = (159_664, 0.10)
def income_tax(taxable):
"""Tax before offsets, stepping through each bracket."""
tax, lower = 0.0, 0.0
for upper, rate in BRACKETS:
if taxable <= lower:
break
tax += (min(taxable, upper) - lower) * rate
lower = upper
return tax
def lito(taxable):
"""Low Income Tax Offset: $700 up to $37,500, then two tapers to nil at $66,667."""
if taxable <= 37_500:
return 700.0
if taxable <= 45_000:
return 700.0 - (taxable - 37_500) * 0.05
if taxable <= 66_667:
return max(0.0, 325.0 - (taxable - 45_000) * 0.015)
return 0.0
def medicare_levy(taxable):
if taxable <= MEDICARE_LOWER:
return 0.0
return min(taxable * MEDICARE_RATE, (taxable - MEDICARE_LOWER) * 0.10)
def study_loan_repayment(repayment_income):
if repayment_income >= STUDY_LOAN_TOP[0]:
return repayment_income * STUDY_LOAN_TOP[1]
if repayment_income > STUDY_LOAN_CHECKED_TO:
raise ValueError("Add the 2024-25 bands above $84,107 from the ATO table")
rate = 0.0
for lower, band_rate in STUDY_LOAN:
if repayment_income >= lower:
rate = band_rate
return repayment_income * rate
@dataclass
class Return:
"""One year's figures, in dollars."""
salary: float = 0.0
contracting: float = 0.0 # fees received in the year
interest: float = 0.0
dividends: float = 0.0 # cash amount
franking_credits: float = 0.0
trust_income: float = 0.0 # ETF distributions, excluding capital gains
net_capital_gain: float = 0.0 # after losses and the 50% discount
other_income: float = 0.0 # e.g. net CFD gains on revenue account
deductions: float = 0.0
tax_withheld: float = 0.0 # total on the income statement, incl. any study loan part
has_study_loan: bool = False
@property
def assessable(self):
return (self.salary + self.contracting + self.interest + self.dividends
+ self.franking_credits + self.trust_income + self.net_capital_gain
+ self.other_income)
@property
def taxable(self):
return max(0.0, self.assessable - self.deductions)
def summary(self):
taxable = self.taxable
before = income_tax(taxable)
offset = min(before, lito(taxable))
levy = medicare_levy(taxable)
# Repayment income also adds reportable super and fringe benefits; none here.
loan = study_loan_repayment(taxable) if self.has_study_loan else 0.0
liability = before - offset + levy + loan
credits = self.tax_withheld + self.franking_credits
return {
"assessable income": self.assessable,
"less deductions": -self.deductions,
"taxable income": taxable,
"income tax on the brackets": before,
"less LITO": 0.0 - offset,
"plus Medicare levy": levy,
"plus study loan repayment": loan,
"total liability": liability,
"less tax withheld": -self.tax_withheld,
"less franking credits": -self.franking_credits,
"owed (negative is a refund)": liability - credits,
}
def show(r):
rows = r.summary()
width = max(len(k) for k in rows)
for label, amount in rows.items():
print(f" {label:<{width}} {amount:>12,.2f}")
print(f" {'average rate on taxable income':<{width}} "
f"{(rows['total liability'] - rows['plus study loan repayment']) / rows['taxable income']:>12.1%}")
example = Return(
salary=60_000,
contracting=8_000,
interest=200,
dividends=1_000,
franking_credits=428.57,
deductions=2_000,
tax_withheld=11_200,
has_study_loan=True,
)
show(example)