austax: Basic implementation in Lua

This commit is contained in:
RunasSudo 2025-06-01 22:58:31 +10:00
parent 1d3aa269b7
commit bbb91ad1e7
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
7 changed files with 468 additions and 531 deletions

View File

@ -0,0 +1,68 @@
--!strict
-- DrCr: Web-based double-entry bookkeeping framework
-- Copyright (C) 2022-2025 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/>.
local income_types = {
{'income1', 'Salary or wages', '1'},
{'income2', 'Allowances, earnings, tips, director\'s fees etc.', '2'},
{'income3', 'Employer lump sum payments', '3'},
{'income4', 'Employment termination payments', '4'},
{'income5', 'Australian Government allowances and payments', '5'},
{'income6', 'Australian Government pensions and allowances', '6'},
{'income7', 'Australian annuities and superannuation income streams', '7'},
{'income8', 'Australian superannuation lump sum payments', '8'},
{'income9', 'Attributed personal services income', '9'},
{'income10', 'Gross interest', '10'},
{'income11', 'Dividends', '11'},
{'income12', 'Employee share schemes', '12'},
{'income13', 'Partnerships and trusts', '13'},
{'income14', 'Personal services income', '14'},
{'income15', 'Net income or loss from business', '15'},
{'income16', 'Deferred non-commercial business losses', '16'},
{'income17', 'Net farm management deposits or repayments', '17'},
{'income18', 'Capital gains', '18'},
{'income19', 'Foreign entities', '19'},
{'income20', 'Foreign source income and foreign assets or property', '20'},
{'income21', 'Rent', '21'},
{'income22', 'Bonuses from life insurance companies and friendly societies', '22'},
{'income23', 'Forestry managed investment scheme income', '23'},
{'income24', 'Other income', '24'},
}
local deduction_types = {
{'d1', 'Work-related car expenses', 'D1'},
{'d2', 'Work-related travel expenses', 'D2'},
{'d3', 'Work-related clothing, laundry and dry cleaning expenses', 'D3'},
{'d4', 'Work-related self-education expenses', 'D4'},
{'d5', 'Other work-related expenses', 'D5'},
{'d6', 'Low value pool deduction', 'D6'},
{'d7', 'Interest deductions', 'D7'},
{'d8', 'Dividend deductions', 'D8'},
{'d9', 'Gifts or donations', 'D9'},
{'d10', 'Cost of managing tax affairs', 'D10'},
{'d11', 'Deductible amount of undeducted purchase price of a foreign pension or annuity', 'D11'},
{'d12', 'Personal superannuation contributions', 'D12'},
{'d13', 'Deduction for project pool', 'D13'},
{'d14', 'Forestry managed investment scheme deduction', 'D14'},
{'d15', 'Other deductions', 'D15'},
}
local account_kinds = {
income_types = income_types,
deduction_types = deduction_types,
}
return account_kinds

View File

@ -17,6 +17,32 @@
local libdrcr = require('../libdrcr')
local account_kinds = require('../austax/account_kinds')
local tax_tables = require('../austax/tax_tables')
function get_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]
for i, row in ipairs(base_tax_table) do
local upper_limit = row[1] * (10 ^ context.dps)
local flat_amount = row[2] * (10 ^ context.dps)
local marginal_rate = row[3]
-- Lower limit is the upper limit of the preceding bracket
local lower_limit = 0
if i > 1 then
lower_limit = base_tax_table[i - 1][1] * (10 ^ context.dps)
end
if net_taxable <= upper_limit then
return flat_amount + marginal_rate * (net_taxable - lower_limit)
end
end
error('Taxable income not within any tax bracket')
end
function requires(args, context)
return {
{
@ -28,8 +54,7 @@ function requires(args, context)
end
function after_init_graph(args, steps, add_dependency, context)
for i = 1, #steps do
local other = steps[i]
for _, other in ipairs(steps) do
if other.name == 'AllTransactionsExceptEarningsToEquity' then
-- AllTransactionsExceptEarningsToEquity depends on CalculateIncomeTax
-- TODO: Only in applicable years
@ -50,26 +75,269 @@ function after_init_graph(args, steps, add_dependency, context)
end
end
function execute(args, context, get_product)
print('Stub: CombineOrdinaryTransactions.execute')
function execute(args, context, kinds_for_account, get_product)
-- Get balances for current year
local product = get_product({
name = 'CombineOrdinaryTransactions',
kind = 'BalancesBetween',
args = { DateStartDateEndArgs = { date_start = context.sofy_date, date_end = context.eofy_date } }
})
assert(product.BalancesBetween ~= nil)
local balances = product.BalancesBetween.balances
print(libdrcr.repr(product))
-- Generate tax summary report
local report: libdrcr.DynamicReport = {
title = 'Tax summary',
columns = {'$'},
entries = {},
}
-- Add income entries
local total_income = 0
for _, income_type in ipairs(account_kinds.income_types) do
local code, label, number = unpack(income_type)
local entries
if code == 'income1' then
-- Special case for salary or wages - round each separately
entries = entries_for_kind_floor('austax.' .. code, true, balances, kinds_for_account, 100)
else
entries = entries_for_kind('austax.' .. code, true, balances, kinds_for_account)
end
if #entries == 0 then
continue
end
local section: libdrcr.Section = {
text = label .. ' (' .. number .. ')',
id = nil,
visible = true,
entries = entries,
}
-- Add subtotal row
local subtotal = math.floor(entries_subtotal(entries) / 100) * 100
total_income += subtotal
table.insert(section.entries, { Row = {
text = 'Total item ' .. number,
quantity = {subtotal},
id = 'total_' .. code,
visible = true,
link = nil,
heading = true,
bordered = true,
}})
table.insert(report.entries, { Section = section })
table.insert(report.entries, 'Spacer')
end
-- Total assessable income
table.insert(report.entries, { Row = {
text = 'Total assessable income',
quantity = {total_income},
id = 'total_income',
visible = true,
link = nil,
heading = true,
bordered = true,
}})
table.insert(report.entries, 'Spacer')
-- Add deduction entries
local total_deductions = 0
for _, deduction_type in ipairs(account_kinds.deduction_types) do
local code, label, number = unpack(deduction_type)
local entries = entries_for_kind('austax.' .. code, false, balances, kinds_for_account)
if #entries == 0 then
continue
end
local section: libdrcr.Section = {
text = label .. ' (' .. number .. ')',
id = nil,
visible = true,
entries = entries,
}
-- Add subtotal row
local subtotal = math.floor(entries_subtotal(entries) / 100) * 100
total_deductions += subtotal
table.insert(section.entries, { Row = {
text = 'Total item ' .. number,
quantity = {subtotal},
id = 'total_' .. code,
visible = true,
link = nil,
heading = true,
bordered = true,
}})
table.insert(report.entries, { Section = section })
table.insert(report.entries, 'Spacer')
end
-- Total deductions
table.insert(report.entries, { Row = {
text = 'Total deductions',
quantity = {total_deductions},
id = 'total_deductions',
visible = true,
link = nil,
heading = true,
bordered = true,
}})
table.insert(report.entries, 'Spacer')
-- Net taxable income
local net_taxable = total_income - total_deductions
table.insert(report.entries, { Row = {
text = 'Net taxable income',
quantity = {net_taxable},
id = 'net_taxable',
visible = true,
link = nil,
heading = true,
bordered = true,
}})
table.insert(report.entries, 'Spacer')
-- Base income tax row
local tax_base = get_base_income_tax(net_taxable, context)
table.insert(report.entries, { Row = {
text = 'Base income tax',
quantity = {tax_base},
id = 'tax_base',
visible = true,
link = nil,
heading = false,
bordered = false,
}})
-- Total income tax row
local tax_total = tax_base
table.insert(report.entries, { Row = {
text = 'Total income tax',
quantity = {tax_total},
id = 'tax_total',
visible = true,
link = nil,
heading = true,
bordered = true,
}})
-- Generate income tax transaction
local transactions: {libdrcr.Transaction} = {
{
id = nil,
dt = libdrcr.date_to_dt(context.eofy_date),
description = 'Estimated income tax',
postings = {
{
id = nil,
transaction_id = nil,
description = nil,
account = 'Income Tax',
quantity = tax_total,
commodity = context.reporting_commodity,
quantity_ascost = tax_total,
},
{
id = nil,
transaction_id = nil,
description = nil,
account = 'Income Tax Control',
quantity = -tax_total,
commodity = context.reporting_commodity,
quantity_ascost = -tax_total,
}
},
}
}
return {
[{ name = 'CalculateIncomeTax', kind = 'Transactions', args = 'VoidArgs' }] = {
Transactions = {
transactions = {}
transactions = transactions
}
}
},
[{ name = 'CalculateIncomeTax', kind = 'DynamicReport', args = 'VoidArgs' }] = {
DynamicReport = report
},
}
end
function entries_for_kind(kind: string, invert: boolean, balances:{ [string]: number }, kinds_for_account:{ [string]: {string} }): {libdrcr.DynamicReportEntry}
-- Get accounts of specified kind
local accounts = {}
for account, kinds in pairs(kinds_for_account) do
if libdrcr.arr_contains(kinds, kind) then
table.insert(accounts, account)
end
end
table.sort(accounts)
local entries = {}
for _, account in ipairs(accounts) do
local quantity = balances[account] or 0
if invert then
quantity = -quantity
end
-- Do not show if all quantities are zero
if quantity == 0 then
continue
end
-- Some exceptions for the link
local link: string | nil
if account == 'Current Year Earnings' then
link = '/income-statement'
elseif account == 'Retained Earnings' then
link = nil
else
link = '/transactions/' .. account
end
local row: libdrcr.Row = {
text = account,
quantity = {quantity},
id = nil,
visible = true,
link = link,
heading = false,
bordered = false,
}
table.insert(entries, { Row = row })
end
return entries
end
-- Call `entries_for_kind` then round results down to next multiple of `floor`
function entries_for_kind_floor(kind: string, invert: boolean, balances:{ [string]: number }, kinds_for_account:{ [string]: {string} }, floor: number): {libdrcr.DynamicReportEntry}
local entries = entries_for_kind(kind, invert, balances, kinds_for_account)
for _, entry in ipairs(entries) do
local row = (entry :: { Row: libdrcr.Row }).Row
row.quantity[1] = math.floor(row.quantity[1] / floor) * floor
end
return entries
end
function entries_subtotal(entries: {libdrcr.DynamicReportEntry}): number
local subtotal = 0
for _, entry in ipairs(entries) do
local row = (entry :: { Row: libdrcr.Row }).Row
subtotal += row.quantity[1]
end
return subtotal
end
local plugin: libdrcr.Plugin = {
name = 'austax',
reporting_steps = {

View File

@ -0,0 +1,49 @@
--!strict
-- DrCr: Web-based double-entry bookkeeping framework
-- Copyright (C) 2022-2025 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/>.
-- Base income tax
-- https://www.ato.gov.au/rates/individual-income-tax-rates/
-- Maps each financial year to list of (upper limit (INclusive), flat amount, marginal rate)
local base_tax = {
[2025] = {
{18200, 0, 0},
{45000, 0, 0.16},
{135000, 4288, 0.30},
{190000, 31288, 0.37},
{math.huge, 51638, 0.45}
},
[2024] = {
{18200, 0, 0},
{45000, 0, 0.19},
{120000, 5092, 0.325},
{180000, 29467, 0.37},
{math.huge, 51667, 0.45}
},
[2023] = {
{18200, 0, 0},
{45000, 0, 0.19},
{120000, 5092, 0.325},
{180000, 29467, 0.37},
{math.huge, 51667, 0.45}
}
}
local tax_tables = {
base_tax = base_tax,
}
return tax_tables

View File

@ -44,10 +44,39 @@ export type ReportingStep = {
execute: (
ReportingStepArgs,
ReportingContext,
{[string]: {string}}, -- kinds_for_account
(ReportingProductId) -> ReportingProduct -- get_product
) -> {[ReportingProductId]: ReportingProduct},
}
------------------
-- Dynamic reports
export type DynamicReport = {
title: string,
columns: {string},
entries: {DynamicReportEntry},
}
export type DynamicReportEntry = 'Spacer' | { Section: Section } | { Row: Row }
export type Section = {
text: string | nil,
id: string | nil,
visible: boolean,
entries: {DynamicReportEntry},
}
export type Row = {
text: string,
quantity: {number},
id: string | nil,
visible: boolean,
link: string | nil,
heading: boolean,
bordered: boolean,
}
-------------------------
-- libdrcr internal types
@ -55,6 +84,7 @@ export type ReportingContext = {
sofy_date: string,
eofy_date: string,
reporting_commodity: string,
dps: number,
}
-- Accounting types
@ -87,7 +117,6 @@ export type ReportingProduct = {
export type BalancesAt = any
export type BalancesBetween = any
export type DynamicReport = any
export type Transactions = { transactions: {Transaction} }
export type ReportingProductId = {
@ -120,6 +149,33 @@ export type MultipleDateStartDateEndArgs = { dates: {DateStartDateEndArgs} }
local libdrcr = {}
function libdrcr.arr_contains(haystack: {any}, needle: any): boolean
for _, element in ipairs(haystack) do
if element == needle then
return true
end
end
return false
end
function libdrcr.date_to_dt(date: string): string
return date .. ' 00:00:00.000000'
end
function libdrcr.parse_date(date: string): (number, number, number)
local year_str, month_str, day_str = string.match(date, '(%d%d%d%d)-(%d%d)-(%d%d)')
local year = tonumber(year_str)
local month = tonumber(month_str)
local day = tonumber(day_str)
assert(year ~= nil)
assert(month ~= nil)
assert(day ~= nil)
return year, month, day
end
function libdrcr.repr(value: any): string
local result = ''
if type(value) == 'table' then

View File

@ -1,512 +0,0 @@
/*
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 2022-2025 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/>.
*/
//! Implements Australian individual income tax calculations
// TODO: Ideally this would be separated into its own plugin
use std::collections::HashMap;
use std::fmt::Display;
use async_trait::async_trait;
use tokio::sync::RwLock;
use crate::account_config::kinds_for_account;
use crate::model::transaction::{Posting, Transaction, TransactionWithPostings};
use crate::reporting::calculator::ReportingGraphDependencies;
use crate::reporting::dynamic_report::{
entries_for_kind, DynamicReport, DynamicReportEntry, Row, Section,
};
use crate::reporting::executor::ReportingExecutionError;
use crate::reporting::steps::AllTransactionsExceptEarningsToEquityBalances;
use crate::reporting::types::{
BalancesBetween, DateStartDateEndArgs, ReportingContext, ReportingProductId,
ReportingProductKind, ReportingProducts, ReportingStep, ReportingStepArgs, ReportingStepId,
Transactions, VoidArgs,
};
use crate::util::sofy_from_eofy;
use crate::{QuantityInt, INCOME_TAX, INCOME_TAX_CONTROL};
// Constants and tax calculations
#[rustfmt::skip]
const INCOME_TYPES: &[(&str, &str, &str)] = &[
("income1", "Salary or wages", "1"),
("income2", "Allowances, earnings, tips, director's fees etc.", "2"),
("income3", "Employer lump sum payments", "3"),
("income4", "Employment termination payments", "4"),
("income5", "Australian Government allowances and payments", "5"),
("income6", "Australian Government pensions and allowances", "6"),
("income7", "Australian annuities and superannuation income streams", "7"),
("income8", "Australian superannuation lump sum payments", "8"),
("income9", "Attributed personal services income", "9"),
("income10", "Gross interest", "10"),
("income11", "Dividends", "11"),
("income12", "Employee share schemes", "12"),
("income13", "Partnerships and trusts", "13"),
("income14", "Personal services income", "14"),
("income15", "Net income or loss from business", "15"),
("income16", "Deferred non-commercial business losses", "16"),
("income17", "Net farm management deposits or repayments", "17"),
("income18", "Capital gains", "18"),
("income19", "Foreign entities", "19"),
("income20", "Foreign source income and foreign assets or property", "20"),
("income21", "Rent", "21"),
("income22", "Bonuses from life insurance companies and friendly societies", "22"),
("income23", "Forestry managed investment scheme income", "23"),
("income24", "Other income", "24"),
];
#[rustfmt::skip]
const DEDUCTION_TYPES: &[(&str, &str, &str)] = &[
("d1", "Work-related car expenses", "D1"),
("d2", "Work-related travel expenses", "D2"),
("d3", "Work-related clothing, laundry and dry cleaning expenses", "D3"),
("d4", "Work-related self-education expenses", "D4"),
("d5", "Other work-related expenses", "D5"),
("d6", "Low value pool deduction", "D6"),
("d7", "Interest deductions", "D7"),
("d8", "Dividend deductions", "D8"),
("d9", "Gifts or donations", "D9"),
("d10", "Cost of managing tax affairs", "D10"),
("d11", "Deductible amount of undeducted purchase price of a foreign pension or annuity", "D11"),
("d12", "Personal superannuation contributions", "D12"),
("d13", "Deduction for project pool", "D13"),
("d14", "Forestry managed investment scheme deduction", "D14"),
("d15", "Other deductions", "D15"),
];
fn get_grossedup_rfb(taxable_value: QuantityInt) -> QuantityInt {
// FIXME: May vary from year to year
((taxable_value as f64) * 2.0802) as QuantityInt
}
fn get_base_income_tax(net_taxable: QuantityInt) -> QuantityInt {
// FIXME: May vary from year to year
if net_taxable <= 18200_00 {
0
} else if net_taxable <= 45000_00 {
(0.16 * (net_taxable - 18200_00) as f64) as QuantityInt
} else if net_taxable <= 135000_00 {
4288_00 + (0.30 * (net_taxable - 45000_00) as f64) as QuantityInt
} else if net_taxable <= 190000_00 {
31288_00 + (0.37 * (net_taxable - 135000_00) as f64) as QuantityInt
} else {
51638_00 + (0.45 * (net_taxable - 190000_00) as f64) as QuantityInt
}
}
// fn get_medicare_levy(net_taxable: QuantityInt) -> QuantityInt {
// todo!()
// }
// fn get_medicare_levy_surcharge(
// net_taxable: QuantityInt,
// rfb_grossedup: QuantityInt,
// ) -> QuantityInt {
// todo!()
// }
/// Call [ReportingContext::register_lookup_fn] for all steps provided by this module
pub fn register_lookup_fns(context: &mut ReportingContext) {
CalculateIncomeTax::register_lookup_fn(context);
}
/// Calculates income tax
///
/// [Transactions] product represents income tax charge for the year.
/// [DynamicReport] product represents the tax summary report.
#[derive(Debug)]
pub struct CalculateIncomeTax {}
impl CalculateIncomeTax {
fn register_lookup_fn(context: &mut ReportingContext) {
context.register_lookup_fn(
"CalculateIncomeTax".to_string(),
vec![ReportingProductKind::Transactions],
Self::takes_args,
Self::from_args,
);
}
fn takes_args(args: &Box<dyn ReportingStepArgs>) -> bool {
args.is::<VoidArgs>()
}
fn from_args(_args: Box<dyn ReportingStepArgs>) -> Box<dyn ReportingStep> {
Box::new(CalculateIncomeTax {})
}
}
impl Display for CalculateIncomeTax {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("{}", self.id()))
}
}
#[async_trait]
impl ReportingStep for CalculateIncomeTax {
fn id(&self) -> ReportingStepId {
ReportingStepId {
name: "CalculateIncomeTax".to_string(),
product_kinds: vec![
ReportingProductKind::DynamicReport,
ReportingProductKind::Transactions,
],
args: Box::new(VoidArgs {}),
}
}
fn requires(&self, context: &ReportingContext) -> Vec<ReportingProductId> {
// CalculateIncomeTax depends on CombineOrdinaryTransactions
vec![ReportingProductId {
name: "CombineOrdinaryTransactions".to_string(),
kind: ReportingProductKind::BalancesBetween,
args: Box::new(DateStartDateEndArgs {
date_start: sofy_from_eofy(context.eofy_date),
date_end: context.eofy_date.clone(),
}),
}]
}
fn after_init_graph(
&self,
steps: &Vec<Box<dyn ReportingStep>>,
dependencies: &mut ReportingGraphDependencies,
_context: &ReportingContext,
) {
for other in steps {
if let Some(other) =
other.downcast_ref::<AllTransactionsExceptEarningsToEquityBalances>()
{
// AllTransactionsExceptEarningsToEquity depends on CalculateIncomeTax
dependencies.add_dependency(
other.id(),
ReportingProductId {
name: self.id().name,
kind: other.product_kind,
args: if other.product_kind == ReportingProductKind::Transactions {
Box::new(VoidArgs {})
} else {
other.id().args
},
},
);
}
}
}
async fn execute(
&self,
context: &ReportingContext,
_steps: &Vec<Box<dyn ReportingStep>>,
_dependencies: &ReportingGraphDependencies,
products: &RwLock<ReportingProducts>,
) -> Result<ReportingProducts, ReportingExecutionError> {
let products = products.read().await;
// Get balances for current year
let balances = &products
.get_or_err(&ReportingProductId {
name: "CombineOrdinaryTransactions".to_string(),
kind: ReportingProductKind::BalancesBetween,
args: Box::new(DateStartDateEndArgs {
date_start: sofy_from_eofy(context.eofy_date),
date_end: context.eofy_date.clone(),
}),
})?
.downcast_ref::<BalancesBetween>()
.unwrap()
.balances;
// Get taxable income and deduction accounts
let kinds_for_account =
kinds_for_account(context.db_connection.get_account_configurations().await);
// Generate tax summary report
let mut report = DynamicReport {
title: "Tax summary".to_string(),
columns: vec!["$".to_string()],
entries: Vec::new(),
};
// Add income entries
let mut total_income: QuantityInt = 0;
for (code, label, number) in INCOME_TYPES {
let entries;
if *code == "income1" {
// Special case for salary or wages - round each separately
entries = entries_for_kind_floor(
&format!("austax.{}", code),
true,
&vec![balances],
&kinds_for_account,
100,
);
} else {
entries = entries_for_kind(
&format!("austax.{}", code),
true,
&vec![balances],
&kinds_for_account,
);
}
if entries.is_empty() {
continue;
}
let mut section = Section {
text: Some(format!("{} ({})", label, number)),
id: None,
visible: true,
entries,
};
// Add subtotal row
let subtotal = floor_quantity(section.subtotal(&report), 100);
total_income += subtotal[0];
section.entries.push(
Row {
text: format!("Total item {}", number),
quantity: subtotal,
id: Some(format!("total_{}", code)),
visible: true,
link: None,
heading: true,
bordered: true,
}
.into(),
);
report.entries.push(section.into());
report.entries.push(DynamicReportEntry::Spacer);
}
// Total assessable income
report.entries.push(
Row {
text: "Total assessable income".to_string(),
quantity: vec![total_income],
id: Some("total_income".to_string()),
visible: true,
link: None,
heading: true,
bordered: true,
}
.into(),
);
report.entries.push(DynamicReportEntry::Spacer);
// Add deduction entries
let mut total_deductions: QuantityInt = 0;
for (code, label, number) in DEDUCTION_TYPES {
let entries = entries_for_kind(
&format!("austax.{}", code),
false,
&vec![balances],
&kinds_for_account,
);
if entries.is_empty() {
continue;
}
let mut section = Section {
text: Some(format!("{} ({})", label, number)),
id: None,
visible: true,
entries,
};
// Add subtotal row
let subtotal = floor_quantity(section.subtotal(&report), 100);
total_deductions += subtotal[0];
section.entries.push(
Row {
text: format!("Total item {}", number),
quantity: subtotal,
id: Some(format!("total_{}", code)),
visible: true,
link: None,
heading: true,
bordered: true,
}
.into(),
);
report.entries.push(section.into());
report.entries.push(DynamicReportEntry::Spacer);
}
// Total deductions
report.entries.push(
Row {
text: "Total deductions".to_string(),
quantity: vec![total_deductions],
id: Some("total_deductions".to_string()),
visible: true,
link: None,
heading: true,
bordered: true,
}
.into(),
);
report.entries.push(DynamicReportEntry::Spacer);
// Net taxable income
let net_taxable = total_income - total_deductions;
report.entries.push(
Row {
text: "Net taxable income".to_string(),
quantity: vec![net_taxable],
id: Some("net_taxable".to_string()),
visible: true,
link: None,
heading: true,
bordered: true,
}
.into(),
);
report.entries.push(DynamicReportEntry::Spacer);
// Precompute RFB amount as this is required for MLS
let rfb_taxable = balances
.iter()
.filter(|(acc, _)| {
kinds_for_account
.get(*acc)
.map(|kinds| kinds.iter().any(|k| k == "austax.rfb"))
.unwrap_or(false)
})
.map(|(_, bal)| *bal)
.sum();
let _rfb_grossedup = get_grossedup_rfb(rfb_taxable);
// Base income tax row
let tax_base = get_base_income_tax(net_taxable);
report.entries.push(
Row {
text: "Base income tax".to_string(),
quantity: vec![tax_base],
id: Some("tax_base".to_string()),
visible: true,
link: None,
heading: false,
bordered: false,
}
.into(),
);
// Total income tax row
let tax_total = tax_base;
report.entries.push(
Row {
text: "Total income tax".to_string(),
quantity: vec![tax_total],
id: Some("tax_total".to_string()),
visible: true,
link: None,
heading: true,
bordered: true,
}
.into(),
);
// Generate income tax transaction
let transactions = Transactions {
transactions: vec![TransactionWithPostings {
transaction: Transaction {
id: None,
dt: context
.db_connection
.metadata()
.eofy_date
.and_hms_opt(0, 0, 0)
.unwrap(),
description: "Estimated income tax".to_string(),
},
postings: vec![
Posting {
id: None,
transaction_id: None,
description: None,
account: INCOME_TAX.to_string(),
quantity: tax_total,
commodity: context.db_connection.metadata().reporting_commodity.clone(),
quantity_ascost: Some(tax_total),
},
Posting {
id: None,
transaction_id: None,
description: None,
account: INCOME_TAX_CONTROL.to_string(),
quantity: -tax_total,
commodity: context.db_connection.metadata().reporting_commodity.clone(),
quantity_ascost: Some(tax_total),
},
],
}],
};
// Store products
let mut result = ReportingProducts::new();
result.insert(
ReportingProductId {
name: self.id().name,
kind: ReportingProductKind::Transactions,
args: Box::new(VoidArgs {}),
},
Box::new(transactions),
);
result.insert(
ReportingProductId {
name: self.id().name,
kind: ReportingProductKind::DynamicReport,
args: Box::new(VoidArgs {}),
},
Box::new(report),
);
Ok(result)
}
}
/// Call [entries_for_kind] then round results down to next multiple of `floor`
fn entries_for_kind_floor(
kind: &str,
invert: bool,
balances: &Vec<&HashMap<String, QuantityInt>>,
kinds_for_account: &HashMap<String, Vec<String>>,
floor: QuantityInt,
) -> Vec<DynamicReportEntry> {
let mut entries_for_kind = entries_for_kind(kind, invert, balances, kinds_for_account);
entries_for_kind.iter_mut().for_each(|e| match e {
DynamicReportEntry::Row(row) => row
.quantity
.iter_mut()
.for_each(|v| *v = (*v / floor) * floor),
_ => unreachable!(),
});
entries_for_kind
}
fn floor_quantity(mut quantity: Vec<QuantityInt>, floor: QuantityInt) -> Vec<QuantityInt> {
quantity.iter_mut().for_each(|v| *v = (*v / floor) * floor);
quantity
}

View File

@ -111,17 +111,17 @@ async fn main() {
.await
.unwrap();
// let result = products
// .get_or_err(&ReportingProductId {
// name: "CalculateIncomeTax".to_string(),
// kind: ReportingProductKind::DynamicReport,
// args: ReportingStepArgs::VoidArgs,
// })
// .unwrap();
// println!("Tax summary:");
// println!("{:?}", result);
let result = products
.get_or_err(&ReportingProductId {
name: "CalculateIncomeTax".to_string(),
kind: ReportingProductKind::DynamicReport,
args: ReportingStepArgs::VoidArgs,
})
.unwrap();
println!("Tax summary:");
println!("{:?}", result);
let result = products
.get_or_err(&ReportingProductId {
name: "AllTransactionsExceptEarningsToEquity".to_string(),

View File

@ -24,6 +24,7 @@ use mlua::{FromLua, Function, Lua, LuaSerdeExt, Table, Value};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use crate::account_config::kinds_for_account;
use crate::reporting::calculator::ReportingGraphDependencies;
use crate::reporting::dynamic_report::DynamicReport;
use crate::reporting::executor::ReportingExecutionError;
@ -178,6 +179,7 @@ struct LuaReportingContext {
#[serde(with = "crate::serde::naivedate_to_js")]
pub eofy_date: NaiveDate,
pub reporting_commodity: String,
pub dps: u32,
}
impl LuaReportingContext {
@ -186,6 +188,7 @@ impl LuaReportingContext {
sofy_date: sofy_from_eofy(context.eofy_date),
eofy_date: context.eofy_date,
reporting_commodity: context.reporting_commodity.clone(),
dps: context.db_connection.metadata().dps,
}
}
}
@ -313,6 +316,10 @@ impl ReportingStep for PluginReportingStep {
_dependencies: &ReportingGraphDependencies,
products: &RwLock<ReportingProducts>,
) -> Result<ReportingProducts, ReportingExecutionError> {
// Pre-compute some context for Lua
let kinds_for_account =
kinds_for_account(context.db_connection.get_account_configurations().await);
let products = products.read().await;
// Load plugin
@ -338,6 +345,7 @@ impl ReportingStep for PluginReportingStep {
let result_table = plugin_step.execute.call::<Table>((
lua.to_value(&self.args).unwrap(),
lua.to_value(&LuaReportingContext::from(context)).unwrap(),
lua.to_value(&kinds_for_account).unwrap(),
get_product,
))?;