Basic income tax estimation

This commit is contained in:
RunasSudo 2025-05-30 23:59:53 +10:00
parent 315ff158c3
commit 4f845eaaea
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
4 changed files with 236 additions and 15 deletions

View File

@ -27,6 +27,7 @@ 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, CalculatableDynamicReport, CalculatableDynamicReportEntry,
@ -40,7 +41,39 @@ use crate::reporting::types::{
Transactions, VoidArgs,
};
use crate::util::sofy_from_eofy;
use crate::QuantityInt;
use crate::{QuantityInt, INCOME_TAX, INCOME_TAX_CONTROL};
// Constants and tax calculations
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) {
@ -158,6 +191,18 @@ impl ReportingStep for CalculateIncomeTax {
let kinds_for_account =
kinds_for_account(context.db_connection.get_account_configurations().await);
// Pre-compute taxable value of reportable fringe benefits (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();
// Generate tax summary report
let report = CalculatableDynamicReport::new(
"Tax summary".to_string(),
@ -603,15 +648,132 @@ impl ReportingStep for CalculateIncomeTax {
bordered: true,
},
}),
// Precompute RFB amount as this is required for MLS
CalculatableDynamicReportEntry::LiteralRow(LiteralRow {
text: "Taxable value of reportable fringe benefits".to_string(),
quantity: vec![rfb_taxable],
id: Some("rfb_taxable".to_string()),
visible: false,
auto_hide: false,
link: None,
heading: false,
bordered: false,
}),
CalculatableDynamicReportEntry::CalculatedRow(CalculatedRow {
calculate_fn: |report| LiteralRow {
text: "Grossed-up value".to_string(),
quantity: vec![get_grossedup_rfb(
report.quantity_for_id("rfb_taxable").unwrap()[0],
)],
id: Some("rfb_grossedup".to_string()),
visible: false,
auto_hide: false,
link: None,
heading: false,
bordered: false,
},
}),
CalculatableDynamicReportEntry::Spacer,
CalculatableDynamicReportEntry::CalculatedRow(CalculatedRow {
calculate_fn: |report| LiteralRow {
text: "Base income tax".to_string(),
quantity: vec![get_base_income_tax(
report.quantity_for_id("net_taxable").unwrap()[0],
)],
id: Some("tax_base".to_string()),
visible: true,
auto_hide: false,
link: None,
heading: false,
bordered: false,
},
}),
// CalculatableDynamicReportEntry::CalculatedRow(CalculatedRow {
// calculate_fn: |report| LiteralRow {
// text: "Medicare levy".to_string(),
// quantity: vec![get_medicare_levy(
// report.quantity_for_id("net_taxable").unwrap()[0],
// )],
// id: Some("tax_ml".to_string()),
// visible: true,
// auto_hide: true,
// link: None,
// heading: false,
// bordered: false,
// },
// }),
// CalculatableDynamicReportEntry::CalculatedRow(CalculatedRow {
// calculate_fn: |report| LiteralRow {
// text: "Medicare levy".to_string(),
// quantity: vec![get_medicare_levy_surcharge(
// report.quantity_for_id("net_taxable").unwrap()[0],
// report.quantity_for_id("rfb_grossedup").unwrap()[0],
// )],
// id: Some("tax_mls".to_string()),
// visible: true,
// auto_hide: true,
// link: None,
// heading: false,
// bordered: false,
// },
// }),
CalculatableDynamicReportEntry::CalculatedRow(CalculatedRow {
calculate_fn: |report| LiteralRow {
text: "Total income tax".to_string(),
quantity: vec![
report.quantity_for_id("tax_base").unwrap()[0], // + report.quantity_for_id("tax_ml").map(|v| v[0]).unwrap_or(0)
// + report.quantity_for_id("tax_mls").map(|v| v[0]).unwrap_or(0),
],
id: Some("total_tax".to_string()),
visible: true,
auto_hide: false,
link: None,
heading: true,
bordered: true,
},
}),
],
);
let mut report: DynamicReport = report.calculate();
report.auto_hide();
let total_tax = report.quantity_for_id("total_tax").unwrap()[0];
// Generate income tax transaction
let transactions = Transactions {
transactions: Vec::new(), // FIXME
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: total_tax,
commodity: context.db_connection.metadata().reporting_commodity.clone(),
quantity_ascost: Some(total_tax),
},
Posting {
id: None,
transaction_id: None,
description: None,
account: INCOME_TAX_CONTROL.to_string(),
quantity: -total_tax,
commodity: context.db_connection.metadata().reporting_commodity.clone(),
quantity_ascost: Some(total_tax),
},
],
}],
};
// Store products

View File

@ -12,4 +12,6 @@ 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";

View File

@ -224,6 +224,59 @@ impl DynamicReport {
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap()
}
/// Look up [DynamicReportEntry] by id
///
/// Returns a cloned copy of the [DynamicReportEntry]. This is necessary because the entry may be within a [Section], and [RefCell] semantics cannot express this type of nested borrow.
pub fn by_id(&self, id: &str) -> Option<DynamicReportEntry> {
// Manually iterate over self.entries rather than self.entries()
// To catch the situation where entry is already mutably borrowed
for entry in self.entries.iter() {
match entry {
DynamicReportEntry::Section(section) => {
if let Some(i) = &section.id {
if i == id {
return Some(entry.clone());
}
}
if let Some(e) = section.by_id(id) {
return Some(match e {
DynamicReportEntry::Section(section) => {
DynamicReportEntry::Section(section.clone())
}
DynamicReportEntry::LiteralRow(row) => {
DynamicReportEntry::LiteralRow(row.clone())
}
DynamicReportEntry::Spacer => DynamicReportEntry::Spacer,
});
}
}
DynamicReportEntry::LiteralRow(row) => {
if let Some(i) = &row.id {
if i == id {
return Some(entry.clone());
}
}
}
DynamicReportEntry::Spacer => (),
}
}
None
}
// Return the quantities for the [LiteralRow] with the given id
pub fn quantity_for_id(&self, id: &str) -> Option<Vec<QuantityInt>> {
if let Some(entry) = self.by_id(id) {
if let DynamicReportEntry::LiteralRow(row) = entry {
Some(row.quantity)
} else {
panic!("Called quantity_for_id on non-LiteralRow");
}
} else {
None
}
}
}
impl ReportingProduct for DynamicReport {}

View File

@ -19,21 +19,25 @@
<template>
<template v-if="literalRow">
<tr :class="literalRow.bordered ? 'border-y border-gray-300' : null">
<component :is="literalRow.heading ? 'th' : 'td'" class="py-0.5 pr-1 text-gray-900 text-start" :class="{ 'font-semibold': literalRow.heading }">
<a :href="literalRow.link as string" class="hover:text-blue-700 hover:underline" v-if="literalRow.link !== null">{{ literalRow.text }}</a>
<template v-if="literalRow.link === null">{{ literalRow.text }}</template>
</component>
<component :is="literalRow.heading ? 'th' : 'td'" class="py-0.5 pl-1 text-gray-900 text-end" :class="{ 'font-semibold': literalRow.heading }" v-html="(cell !== 0 || literalRow.heading) ? ppBracketed(cell, literalRow.link ?? undefined) : ''" v-for="cell of literalRow.quantity">
</component>
</tr>
<template v-if="literalRow.visible">
<tr :class="literalRow.bordered ? 'border-y border-gray-300' : null">
<component :is="literalRow.heading ? 'th' : 'td'" class="py-0.5 pr-1 text-gray-900 text-start" :class="{ 'font-semibold': literalRow.heading }">
<a :href="literalRow.link as string" class="hover:text-blue-700 hover:underline" v-if="literalRow.link !== null">{{ literalRow.text }}</a>
<template v-if="literalRow.link === null">{{ literalRow.text }}</template>
</component>
<component :is="literalRow.heading ? 'th' : 'td'" class="py-0.5 pl-1 text-gray-900 text-end" :class="{ 'font-semibold': literalRow.heading }" v-html="(cell !== 0 || literalRow.heading) ? ppBracketed(cell, literalRow.link ?? undefined) : ''" v-for="cell of literalRow.quantity">
</component>
</tr>
</template>
</template>
<template v-if="section">
<tr v-if="section.text !== null">
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">{{ section.text }}</th>
<th></th><!-- FIXME: Have correct colspan -->
</tr>
<DynamicReportEntryComponent :entry="child" v-for="child of section.entries" />
<template v-if="section.visible">
<tr v-if="section.text !== null">
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">{{ section.text }}</th>
<th></th><!-- FIXME: Have correct colspan -->
</tr>
<DynamicReportEntryComponent :entry="child" v-for="child of section.entries" />
</template>
</template>
<template v-if="entry == 'Spacer'">
<tr><td colspan="2" class="py-0.5">&nbsp;</td></tr><!-- FIXME: Have correct colspan -->