From a0071cf120af38c0ee7007fb4b91dd842793ce75 Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Mon, 2 Jun 2025 19:05:57 +1000 Subject: [PATCH] austax: Port full income tax implementation from Python --- libdrcr/plugins/austax/calc.luau | 85 ++++++++- libdrcr/plugins/austax/reporting.luau | 231 ++++++++++++++++++++++++- libdrcr/plugins/austax/tax_tables.luau | 116 ++++++++++++- libdrcr/src/lib.rs | 2 - 4 files changed, 419 insertions(+), 15 deletions(-) diff --git a/libdrcr/plugins/austax/calc.luau b/libdrcr/plugins/austax/calc.luau index 672d8a6..635eee2 100644 --- a/libdrcr/plugins/austax/calc.luau +++ b/libdrcr/plugins/austax/calc.luau @@ -20,6 +20,7 @@ local tax_tables = require('../austax/tax_tables') local calc = {} +-- Get the amount of base income tax function calc.base_income_tax(net_taxable: number, context: libdrcr.ReportingContext): number local year, _, _ = libdrcr.parse_date(context.eofy_date) local base_tax_table = tax_tables.base_tax[year] @@ -36,11 +37,93 @@ function calc.base_income_tax(net_taxable: number, context: libdrcr.ReportingCon end if net_taxable <= upper_limit then - return flat_amount + marginal_rate * (net_taxable - lower_limit) + return flat_amount + math.floor(marginal_rate * (net_taxable - lower_limit)) end end error('Taxable income not within any tax bracket') end +-- Get the amount of low income tax offset +-- https://www.ato.gov.au/forms-and-instructions/low-and-middle-income-earner-tax-offsets +-- https://www.austlii.edu.au/cgi-bin/viewdoc/au/legis/cth/consol_act/itaa1997240/s61.115.html +function calc.lito(net_taxable: number, tax_total: number, context: libdrcr.ReportingContext): number + if net_taxable <= 37500 * (10 ^ context.dps) then + -- LITO is non-refundable + -- FIXME: This will not work if we implement multiple non-refundable tax offsets + if tax_total <= 700 * (10 ^ context.dps) then + return tax_total + else + return 700 * (10 ^ context.dps) + end + elseif net_taxable <= 45000 * (10 ^ context.dps) then + return 700 * (10 ^ context.dps) - math.floor(0.05 * (net_taxable - 37500 * (10 ^ context.dps))) + elseif net_taxable <= 66667 * (10 ^ context.dps) then + return 325 * (10 ^ context.dps) - math.floor(0.015 * (net_taxable - 45000 * (10 ^ context.dps))) + else + return 0 + end +end + +-- Get the amount of Medicare levy +function calc.medicare_levy(net_taxable: number, context: libdrcr.ReportingContext): number + local year, _, _ = libdrcr.parse_date(context.eofy_date) + local threshold_table = tax_tables.medicare_levy_threshold[year] + local lower_threshold = threshold_table[1] * (10 ^ context.dps) + local upper_threshold = threshold_table[2] * (10 ^ context.dps) + + if net_taxable < lower_threshold then + return 0 + elseif net_taxable < upper_threshold then + -- Medicare levy is 10% of the amount above the lower threshold + return math.floor((net_taxable - lower_threshold) * 0.1) + else + -- Normal Medicare levy + return math.floor(net_taxable * 0.02) + end +end + +-- Get the amount of Medicare levy surcharge +function calc.medicare_levy_surcharge(net_taxable: number, rfb_grossedup: number, context: libdrcr.ReportingContext): number + local mls_income = net_taxable + rfb_grossedup + + local year, _, _ = libdrcr.parse_date(context.eofy_date) + local mls_table = tax_tables.medicare_levy_surcharge_single[year] + + for _, row in ipairs(mls_table) do + local upper_limit = row[1] * (10 ^ context.dps) + local rate = row[2] + + if mls_income <= upper_limit then + return math.floor(rate * mls_income) + end + end + + error('MLS income not within any MLS bracket') +end + +-- Calculate the grossed-up reportable fringe benefit +function calc.rfb_grossup(rfb_taxable: number, context: libdrcr.ReportingContext): number + return math.floor(rfb_taxable * tax_tables.fbt_grossup) +end + +-- Get the amount of mandatory study loan repayment +function calc.study_loan_repayment(net_taxable: number, rfb_grossedup: number, context: libdrcr.ReportingContext): number + local repayment_income = net_taxable + rfb_grossedup + + local year, _, _ = libdrcr.parse_date(context.eofy_date) + local repayment_table = tax_tables.study_loan_repayment_rates[year] + + for _, row in ipairs(repayment_table) do + local upper_limit = row[1] * (10 ^ context.dps) + local rate = row[2] + + if repayment_income < upper_limit then + return math.floor(rate * repayment_income) + end + end + + error('HELP repayment income not within any repayment bracket') +end + return calc diff --git a/libdrcr/plugins/austax/reporting.luau b/libdrcr/plugins/austax/reporting.luau index baf06ba..84e96fc 100644 --- a/libdrcr/plugins/austax/reporting.luau +++ b/libdrcr/plugins/austax/reporting.luau @@ -111,7 +111,7 @@ function reporting.CalculateIncomeTax.execute(args, context, kinds_for_account, visible = true, link = nil, heading = true, - bordered = true, + bordered = false, }}) table.insert(report.entries, { Section = section }) table.insert(report.entries, 'Spacer') @@ -159,7 +159,7 @@ function reporting.CalculateIncomeTax.execute(args, context, kinds_for_account, visible = true, link = nil, heading = true, - bordered = true, + bordered = false, }}) table.insert(report.entries, { Section = section }) table.insert(report.entries, 'Spacer') @@ -202,8 +202,45 @@ function reporting.CalculateIncomeTax.execute(args, context, kinds_for_account, bordered = false, }}) + -- Medicare levy row + local tax_ml = calc.medicare_levy(net_taxable, context) + if tax_ml ~= 0 then + table.insert(report.entries, { Row = { + text = 'Medicare levy', + quantity = {tax_ml}, + id = 'tax_ml', + visible = true, + link = nil, + heading = false, + bordered = false, + }}) + end + + -- Precompute RFB amount as this is required for MLS + local rfb_taxable = 0 + for account, kinds in pairs(kinds_for_account) do + if libdrcr.arr_contains(kinds, 'austax.rfb') then + rfb_taxable -= balances[account] or 0 -- Invert as income = credit balances + end + end + local rfb_grossedup = calc.rfb_grossup(rfb_taxable, context) + + -- Medicare levy surcharge row + local tax_mls = calc.medicare_levy_surcharge(net_taxable, rfb_grossedup, context) + if tax_mls ~= 0 then + table.insert(report.entries, { Row = { + text = 'Medicare levy surcharge', + quantity = {tax_mls}, + id = 'tax_mls', + visible = true, + link = nil, + heading = false, + bordered = false, + }}) + end + -- Total income tax row - local tax_total = tax_base + local tax_total = tax_base + tax_ml + tax_mls table.insert(report.entries, { Row = { text = 'Total income tax', quantity = {tax_total}, @@ -213,10 +250,126 @@ function reporting.CalculateIncomeTax.execute(args, context, kinds_for_account, heading = true, bordered = true, }}) + table.insert(report.entries, 'Spacer') - -- Generate income tax transaction + -- Low income tax offset row + local offset_lito = calc.lito(net_taxable, tax_total, context) + if offset_lito ~= 0 then + table.insert(report.entries, { Row = { + text = 'Low income tax offset', + quantity = {offset_lito}, + id = nil, + visible = true, + link = nil, + heading = false, + bordered = false, + }}) + end + + -- Total tax offsets row + local offset_total = offset_lito + if offset_total ~= 0 then + table.insert(report.entries, { Row = { + text = 'Total tax offsets', + quantity = {offset_total}, + id = nil, + visible = true, + link = nil, + heading = true, + bordered = false, + }}) + table.insert(report.entries, 'Spacer') + end + + -- Calculate mandatory study loan repayment + local study_loan_repayment = calc.study_loan_repayment(net_taxable, rfb_grossedup, context) + + -- Mandatory study loan repayment section + if study_loan_repayment ~= 0 then + -- Taxable value of reportable fringe benefits row + if rfb_taxable ~= 0 then + table.insert(report.entries, { Row = { + text = 'Taxable value of reportable fringe benefits', + quantity = {rfb_taxable}, + id = 'rfb_taxable', + visible = true, + link = nil, + heading = false, + bordered = false, + }}) + end + + -- Grossed-up value row + if rfb_grossedup ~= 0 then + table.insert(report.entries, { Row = { + text = 'Grossed-up value', + quantity = {rfb_grossedup}, + id = 'rfb_grossedup', + visible = true, + link = nil, + heading = false, + bordered = false, + }}) + end + + -- Mandatory study loan repayment row + table.insert(report.entries, { Row = { + text = 'Mandatory study loan repayment', + quantity = {study_loan_repayment}, + id = 'study_loan_repayment', + visible = true, + link = nil, + heading = true, + bordered = false, + }}) + table.insert(report.entries, 'Spacer') + end + + -- Add PAYGW entries + local total_paygw = 0 + local entries = entries_for_kind('austax.paygw', false, balances, kinds_for_account) + + if #entries ~= 0 then + local section: libdrcr.Section = { + text = 'PAYG withheld amounts', + id = nil, + visible = true, + entries = entries, + } + table.insert(report.entries, { Section = section }) + total_paygw = math.floor(entries_subtotal(entries) / 100) * 100 + end + + -- Total PAYGW row + if total_paygw ~= 0 then + table.insert(report.entries, { Row = { + text = 'Total withheld amounts', + quantity = {total_paygw}, + id = 'total_paygw', + visible = true, + link = nil, + heading = true, + bordered = false, + }}) + table.insert(report.entries, 'Spacer') + end + + -- ATO liability row + local ato_payable = tax_total - offset_total - total_paygw + study_loan_repayment + table.insert(report.entries, { Row = { + text = 'ATO liability payable (refundable)', + quantity = {ato_payable}, + id = 'ato_payable', + visible = true, + link = nil, + heading = true, + bordered = true, + }}) + + -- Generate income tax transactions local transactions: {libdrcr.Transaction} = { { + -- # Estimated tax payable id = nil, dt = libdrcr.date_to_dt(context.eofy_date), description = 'Estimated income tax', @@ -226,23 +379,83 @@ function reporting.CalculateIncomeTax.execute(args, context, kinds_for_account, transaction_id = nil, description = nil, account = 'Income Tax', - quantity = tax_total, + quantity = (tax_total - offset_total), commodity = context.reporting_commodity, - quantity_ascost = tax_total, + quantity_ascost = (tax_total - offset_total), }, { id = nil, transaction_id = nil, description = nil, account = 'Income Tax Control', - quantity = -tax_total, + quantity = -(tax_total - offset_total), commodity = context.reporting_commodity, - quantity_ascost = -tax_total, - } + quantity_ascost = -(tax_total - offset_total), + }, + }, + }, + { + -- Mandatory study loan repayment + id = nil, + dt = libdrcr.date_to_dt(context.eofy_date), + description = 'Mandatory study loan repayment payable', + postings = { + { + id = nil, + transaction_id = nil, + description = nil, + account = 'HELP', + quantity = study_loan_repayment, + commodity = context.reporting_commodity, + quantity_ascost = study_loan_repayment, + }, + { + id = nil, + transaction_id = nil, + description = nil, + account = 'Income Tax Control', + quantity = -study_loan_repayment, + commodity = context.reporting_commodity, + quantity_ascost = -study_loan_repayment, + }, }, } } + -- Transfer PAYGW balances to Income Tax Control + for account, kinds in pairs(kinds_for_account) do + if libdrcr.arr_contains(kinds, 'austax.paygw') then + local balance = balances[account] or 0 + if balance ~= 0 then + table.insert(transactions, { + id = nil, + dt = libdrcr.date_to_dt(context.eofy_date), + description = 'PAYG withheld amounts', + postings = { + { + id = nil, + transaction_id = nil, + description = nil, + account = 'Income Tax Control', + quantity = balance, + commodity = context.reporting_commodity, + quantity_ascost = balance, + }, + { + id = nil, + transaction_id = nil, + description = nil, + account = account, + quantity = -balance, + commodity = context.reporting_commodity, + quantity_ascost = -balance, + }, + }, + }) + end + end + end + return { [{ name = 'CalculateIncomeTax', kind = 'Transactions', args = 'VoidArgs' }] = { Transactions = { diff --git a/libdrcr/plugins/austax/tax_tables.luau b/libdrcr/plugins/austax/tax_tables.luau index 434b0a3..36e38ae 100644 --- a/libdrcr/plugins/austax/tax_tables.luau +++ b/libdrcr/plugins/austax/tax_tables.luau @@ -15,10 +15,14 @@ -- You should have received a copy of the GNU Affero General Public License -- along with this program. If not, see . +local tax_tables = {} + -- Base income tax -- https://www.ato.gov.au/rates/individual-income-tax-rates/ +-- https://www.austlii.edu.au/cgi-bin/viewdoc/au/legis/cth/consol_act/itra1986174/sch7.html +-- https://www.austlii.edu.au/cgi-bin/viewdoc/au/legis/cth/consol_act/itra1986174/s3.html (tax-free threshold) -- Maps each financial year to list of (upper limit (INclusive), flat amount, marginal rate) -local base_tax = { +tax_tables.base_tax = { [2025] = { {18200, 0, 0}, {45000, 0, 0.16}, @@ -42,8 +46,114 @@ local base_tax = { } } -local tax_tables = { - base_tax = base_tax, +-- FBT type 1 gross-up factor +-- https://www.ato.gov.au/rates/fbt/#GrossupratesforFBT +-- https://www.austlii.edu.au/cgi-bin/viewdoc/au/legis/cth/consol_act/fbtaa1986312/s5b.html +tax_tables.fbt_grossup = 2.0802 + +-- Medicare levy thresholds +-- https://www.ato.gov.au/Individuals/Medicare-and-private-health-insurance/Medicare-levy/Medicare-levy-reduction/Medicare-levy-reduction-for-low-income-earners/ +-- https://www.austlii.edu.au/cgi-bin/viewdoc/au/legis/cth/consol_act/mla1986131/s3.html +-- Maps each financial year to list of (lower threshold, upper threshold) +tax_tables.medicare_levy_threshold = { + [2025] = {27222, 34027}, + [2024] = {26000, 32500}, + [2023] = {24276, 30345}, + [2022] = {23365, 29207} +} + +-- Medicare levy surcharge rates (singles) +-- https://www.ato.gov.au/individuals-and-families/medicare-and-private-health-insurance/medicare-levy-surcharge/medicare-levy-surcharge-income-thresholds-and-rates +-- https://www.austlii.edu.au/cgi-bin/viewdb/au/legis/cth/consol_act/mla1986131/s8b.html +-- https://www.austlii.edu.au/cgi-bin/viewdoc/au/legis/cth/consol_act/phia2007248/s22.35.html +-- https://www.austlii.edu.au/cgi-bin/viewdoc/au/legis/cth/consol_act/phia2007248/s22.45.html +-- Maps each financial year to list of (upper limit (INclusive), MLS rate) +-- FIXME: Only supports singles +tax_tables.medicare_levy_surcharge_single = { + [2025] = { + {97000, 0}, + {113000, 0.01}, + {151000, 0.0125}, + {math.huge, 0.015} + }, + [2024] = { + {93000, 0}, + {108000, 0.01}, + {144000, 0.0125}, + {math.huge, 0.015} + } +} + +-- Study and training loan (HELP, etc.) repayment thresholds and rates +-- https://www.ato.gov.au/Rates/HELP,-TSL-and-SFSS-repayment-thresholds-and-rates/ +-- https://www.austlii.edu.au/cgi-bin/viewdoc/au/legis/cth/consol_act/hesa2003271/s154.20.html +-- https://www.austlii.edu.au/cgi-bin/viewdoc/au/legis/cth/consol_act/hesa2003271/s154.25.html +-- https://www.austlii.edu.au/cgi-bin/viewdoc/au/legis/cth/consol_act/hesa2003271/s154.30.html +-- Maps each financial year to list of (upper limit (EXclusive), repayment rate) +tax_tables.study_loan_repayment_rates = { + [2025] = { + {54435, 0}, + {62851, 0.01}, + {66621, 0.02}, + {70619, 0.025}, + {74856, 0.03}, + {79347, 0.035}, + {84108, 0.04}, + {89155, 0.045}, + {94504, 0.05}, + {100175, 0.055}, + {106186, 0.06}, + {112557, 0.065}, + {119310, 0.07}, + {126468, 0.075}, + {134057, 0.08}, + {142101, 0.085}, + {150627, 0.09}, + {159664, 0.095}, + {math.huge, 0.1} + }, + [2024] = { + {51550, 0}, + {59519, 0.01}, + {63090, 0.02}, + {66876, 0.025}, + {70889, 0.03}, + {75141, 0.035}, + {79650, 0.04}, + {84430, 0.045}, + {89495, 0.05}, + {94866, 0.055}, + {100558, 0.06}, + {106591, 0.065}, + {112986, 0.07}, + {119765, 0.075}, + {126951, 0.08}, + {134569, 0.085}, + {142643, 0.09}, + {151201, 0.095}, + {math.huge, 0.1} + }, + [2023] = { + {48361, 0}, + {55837, 0.01}, + {59187, 0.02}, + {62739, 0.025}, + {66503, 0.03}, + {70493, 0.035}, + {74723, 0.04}, + {79207, 0.045}, + {83959, 0.05}, + {88997, 0.055}, + {94337, 0.06}, + {99997, 0.065}, + {105997, 0.07}, + {112356, 0.075}, + {119098, 0.08}, + {126244, 0.085}, + {133819, 0.09}, + {141848, 0.095}, + {math.huge, 0.1} + } } return tax_tables diff --git a/libdrcr/src/lib.rs b/libdrcr/src/lib.rs index 8a198e8..beae032 100644 --- a/libdrcr/src/lib.rs +++ b/libdrcr/src/lib.rs @@ -13,6 +13,4 @@ pub type QuantityInt = i64; // Magic strings // TODO: Make this configurable pub const CURRENT_YEAR_EARNINGS: &'static str = "Current Year Earnings"; -pub const INCOME_TAX: &'static str = "Income Tax"; -pub const INCOME_TAX_CONTROL: &'static str = "Income Tax Control"; pub const RETAINED_EARNINGS: &'static str = "Retained Earnings";