use crate::client::{Account, AccountSubtype};
use serde::Serialize;
use std::collections::HashMap;
#[derive(Debug, Serialize, PartialEq)]
pub struct AccountInventory {
pub buckets: HashMap<String, Bucket>,
pub rollup: Rollup,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct Bucket {
pub total: f64,
pub accounts: Vec<AccountEntry>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct AccountEntry {
pub display_name: String,
pub type_name: String,
pub subtype_name: Option<String>,
pub subtype_display: Option<String>,
pub balance: f64,
pub balance_unknown: bool,
pub is_hidden: bool,
pub unknown_subtype: bool,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct Rollup {
pub total_assets: f64,
pub total_liabilities: f64,
pub net_worth: f64,
}
const BUCKET_TAX_ADVANTAGED: &str = "tax_advantaged";
const BUCKET_TAXABLE_BROKERAGE: &str = "taxable_brokerage";
const BUCKET_CASH: &str = "cash";
const BUCKET_OTHER_ASSETS: &str = "other_assets";
const BUCKET_LIABILITIES: &str = "liabilities";
const TAX_ADVANTAGED_SUBTYPES: &[&str] = &["st_401k", "roth", "health_savings_account"];
const TAXABLE_BROKERAGE_SUBTYPES: &[&str] = &["brokerage", "stock_plan"];
pub fn compute_account_inventory(accounts: &[Account]) -> AccountInventory {
let mut buckets: HashMap<String, Bucket> = HashMap::new();
for account in accounts {
let (bucket_name, unknown_subtype) = assign_bucket(account);
let entry = build_entry(account, unknown_subtype);
let bucket = buckets.entry(bucket_name).or_insert(Bucket {
total: 0.0,
accounts: vec![],
});
bucket.total += account.current_balance;
bucket.accounts.push(entry);
}
let rollup = compute_rollup(accounts);
AccountInventory { buckets, rollup }
}
fn assign_bucket(account: &Account) -> (String, bool) {
match account.account_type.name.as_str() {
"depository" => (BUCKET_CASH.to_string(), false),
"credit" | "loan" => (BUCKET_LIABILITIES.to_string(), false),
"vehicle" => (BUCKET_OTHER_ASSETS.to_string(), false),
"brokerage" => assign_brokerage_bucket(account.subtype.as_ref()),
_ => {
let bucket = if account.current_balance < 0.0 {
BUCKET_LIABILITIES
} else {
BUCKET_OTHER_ASSETS
};
(bucket.to_string(), true)
}
}
}
fn assign_brokerage_bucket(subtype: Option<&AccountSubtype>) -> (String, bool) {
match subtype {
Some(st) if TAX_ADVANTAGED_SUBTYPES.contains(&st.name.as_str()) => {
(BUCKET_TAX_ADVANTAGED.to_string(), false)
}
Some(st) if TAXABLE_BROKERAGE_SUBTYPES.contains(&st.name.as_str()) => {
(BUCKET_TAXABLE_BROKERAGE.to_string(), false)
}
Some(_) => {
(BUCKET_TAXABLE_BROKERAGE.to_string(), true)
}
None => {
(BUCKET_TAXABLE_BROKERAGE.to_string(), false)
}
}
}
fn build_entry(account: &Account, unknown_subtype: bool) -> AccountEntry {
AccountEntry {
display_name: account.display_name.clone(),
type_name: account.account_type.name.clone(),
subtype_name: account.subtype.as_ref().map(|s| s.name.clone()),
subtype_display: account.subtype.as_ref().map(|s| s.display.clone()),
balance: account.current_balance,
balance_unknown: account.balance_was_null,
is_hidden: account.is_hidden,
unknown_subtype,
}
}
fn compute_rollup(accounts: &[Account]) -> Rollup {
let total_assets: f64 = accounts.iter().map(|a| a.current_balance.max(0.0)).sum();
let total_liabilities: f64 = accounts.iter().map(|a| (-a.current_balance).max(0.0)).sum();
let net_worth = total_assets - total_liabilities;
Rollup {
total_assets,
total_liabilities,
net_worth,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::{Account, AccountSubtype, AccountType};
fn account(
type_name: &str,
subtype_name: Option<&str>,
balance: f64,
is_hidden: bool,
) -> Account {
Account {
id: format!("{type_name}-{balance}"),
display_name: format!("{type_name} account"),
current_balance: balance,
balance_was_null: false,
account_type: AccountType {
name: type_name.to_string(),
},
subtype: subtype_name.map(|n| AccountSubtype {
name: n.to_string(),
display: n.to_string(),
}),
is_hidden,
}
}
fn account_with_null_balance(type_name: &str, subtype_name: Option<&str>) -> Account {
Account {
id: format!("{type_name}-null"),
display_name: format!("{type_name} account"),
current_balance: 0.0,
balance_was_null: true,
account_type: AccountType {
name: type_name.to_string(),
},
subtype: subtype_name.map(|n| AccountSubtype {
name: n.to_string(),
display: n.to_string(),
}),
is_hidden: false,
}
}
#[test]
fn st_401k_subtype_maps_to_tax_advantaged() {
let accounts = vec![account("brokerage", Some("st_401k"), 50_000.0, false)];
let inv = compute_account_inventory(&accounts);
assert!(
inv.buckets.contains_key(BUCKET_TAX_ADVANTAGED),
"tax_advantaged bucket must exist"
);
assert_eq!(inv.buckets[BUCKET_TAX_ADVANTAGED].accounts.len(), 1);
}
#[test]
fn roth_subtype_maps_to_tax_advantaged() {
let accounts = vec![account("brokerage", Some("roth"), 200_000.0, false)];
let inv = compute_account_inventory(&accounts);
assert!(inv.buckets.contains_key(BUCKET_TAX_ADVANTAGED));
assert_eq!(inv.buckets[BUCKET_TAX_ADVANTAGED].accounts.len(), 1);
}
#[test]
fn hsa_subtype_maps_to_tax_advantaged() {
let accounts = vec![account(
"brokerage",
Some("health_savings_account"),
5_000.0,
false,
)];
let inv = compute_account_inventory(&accounts);
assert!(inv.buckets.contains_key(BUCKET_TAX_ADVANTAGED));
}
#[test]
fn brokerage_subtype_maps_to_taxable_brokerage() {
let accounts = vec![account("brokerage", Some("brokerage"), 10_000.0, false)];
let inv = compute_account_inventory(&accounts);
assert!(inv.buckets.contains_key(BUCKET_TAXABLE_BROKERAGE));
assert!(!inv.buckets.contains_key(BUCKET_TAX_ADVANTAGED));
}
#[test]
fn stock_plan_subtype_maps_to_taxable_brokerage() {
let accounts = vec![account("brokerage", Some("stock_plan"), 0.0, false)];
let inv = compute_account_inventory(&accounts);
assert!(inv.buckets.contains_key(BUCKET_TAXABLE_BROKERAGE));
}
#[test]
fn null_subtype_on_brokerage_falls_back_to_taxable() {
let accounts = vec![account("brokerage", None, 5_000.0, false)];
let inv = compute_account_inventory(&accounts);
assert!(inv.buckets.contains_key(BUCKET_TAXABLE_BROKERAGE));
assert!(!inv.buckets[BUCKET_TAXABLE_BROKERAGE].accounts[0].unknown_subtype);
}
#[test]
fn unknown_subtype_on_brokerage_is_flagged() {
let accounts = vec![account("brokerage", Some("future_product"), 8_000.0, false)];
let inv = compute_account_inventory(&accounts);
assert!(inv.buckets.contains_key(BUCKET_TAXABLE_BROKERAGE));
assert!(
inv.buckets[BUCKET_TAXABLE_BROKERAGE].accounts[0].unknown_subtype,
"unknown subtype must be flagged"
);
assert_eq!(
inv.buckets[BUCKET_TAXABLE_BROKERAGE].accounts[0]
.subtype_name
.as_deref(),
Some("future_product"),
"raw subtype must be preserved in output"
);
}
#[test]
fn depository_maps_to_cash() {
let accounts = vec![account("depository", Some("checking"), 5_000.0, false)];
let inv = compute_account_inventory(&accounts);
assert!(inv.buckets.contains_key(BUCKET_CASH));
assert_eq!(inv.buckets[BUCKET_CASH].accounts.len(), 1);
}
#[test]
fn credit_maps_to_liabilities() {
let accounts = vec![account("credit", Some("credit_card"), -3_000.0, false)];
let inv = compute_account_inventory(&accounts);
assert!(inv.buckets.contains_key(BUCKET_LIABILITIES));
assert!((inv.buckets[BUCKET_LIABILITIES].total - (-3_000.0)).abs() < 0.01);
}
#[test]
fn loan_maps_to_liabilities() {
let accounts = vec![account("loan", Some("other"), -200_000.0, false)];
let inv = compute_account_inventory(&accounts);
assert!(inv.buckets.contains_key(BUCKET_LIABILITIES));
}
#[test]
fn vehicle_maps_to_other_assets() {
let accounts = vec![account("vehicle", Some("car"), 15_000.0, false)];
let inv = compute_account_inventory(&accounts);
assert!(inv.buckets.contains_key(BUCKET_OTHER_ASSETS));
assert!((inv.buckets[BUCKET_OTHER_ASSETS].total - 15_000.0).abs() < 0.01);
}
#[test]
fn hidden_account_is_included_and_flagged() {
let accounts = vec![account("depository", Some("savings"), 100.0, true)];
let inv = compute_account_inventory(&accounts);
assert!(inv.buckets.contains_key(BUCKET_CASH));
assert!(
inv.buckets[BUCKET_CASH].accounts[0].is_hidden,
"hidden flag must be propagated"
);
}
#[test]
fn empty_accounts_returns_zero_rollup() {
let inv = compute_account_inventory(&[]);
assert!(inv.buckets.is_empty());
assert!((inv.rollup.net_worth - 0.0).abs() < 0.01);
assert!((inv.rollup.total_assets - 0.0).abs() < 0.01);
assert!((inv.rollup.total_liabilities - 0.0).abs() < 0.01);
}
#[test]
fn rollup_net_worth_reconciles_assets_minus_liabilities() {
let accounts = vec![
account("depository", Some("checking"), 10_000.0, false),
account("brokerage", Some("roth"), 200_000.0, false),
account("credit", Some("credit_card"), -5_000.0, false),
account("loan", Some("other"), -100_000.0, false),
account("vehicle", Some("car"), 15_000.0, false),
];
let inv = compute_account_inventory(&accounts);
assert!((inv.rollup.total_assets - 225_000.0).abs() < 0.01);
assert!((inv.rollup.total_liabilities - 105_000.0).abs() < 0.01);
assert!((inv.rollup.net_worth - 120_000.0).abs() < 0.01);
}
#[test]
fn bucket_total_is_signed_sum_of_balances() {
let accounts = vec![
account("credit", Some("credit_card"), -3_000.0, false),
account("credit", Some("credit_card"), -2_000.0, false),
];
let inv = compute_account_inventory(&accounts);
assert!((inv.buckets[BUCKET_LIABILITIES].total - (-5_000.0)).abs() < 0.01);
}
#[test]
fn unknown_type_positive_balance_falls_back_to_other_assets_and_is_flagged() {
let accounts = vec![account("collectible", Some("art"), 10_000.0, false)];
let inv = compute_account_inventory(&accounts);
assert!(
inv.buckets.contains_key(BUCKET_OTHER_ASSETS),
"unknown type with positive balance must land in other_assets"
);
assert!(
inv.buckets[BUCKET_OTHER_ASSETS].accounts[0].unknown_subtype,
"unknown type must be flagged"
);
}
#[test]
fn unknown_type_negative_balance_falls_back_to_liabilities_and_is_flagged() {
let accounts = vec![account("mystery", None, -500.0, false)];
let inv = compute_account_inventory(&accounts);
assert!(
inv.buckets.contains_key(BUCKET_LIABILITIES),
"unknown type with negative balance must land in liabilities"
);
assert!(
inv.buckets[BUCKET_LIABILITIES].accounts[0].unknown_subtype,
"unknown type must be flagged"
);
}
#[test]
fn null_balance_sets_balance_unknown_true() {
let accounts = vec![account_with_null_balance("depository", Some("savings"))];
let inv = compute_account_inventory(&accounts);
let entry = &inv.buckets["cash"].accounts[0];
assert!(
entry.balance_unknown,
"null currentBalance must set balance_unknown=true"
);
assert!(
(entry.balance - 0.0).abs() < f64::EPSILON,
"null balance must coerce to 0.0, got {}",
entry.balance
);
}
#[test]
fn real_zero_balance_does_not_set_balance_unknown() {
let accounts = vec![account("depository", Some("savings"), 0.0, false)];
let inv = compute_account_inventory(&accounts);
let entry = &inv.buckets["cash"].accounts[0];
assert!(
!entry.balance_unknown,
"a real 0.0 balance must not set balance_unknown"
);
}
#[test]
fn overpaid_credit_card_counts_as_asset_in_rollup() {
let accounts = vec![
account("depository", Some("checking"), 10_000.0, false),
account("credit", Some("credit_card"), 500.0, false), ];
let inv = compute_account_inventory(&accounts);
let true_net_worth: f64 = accounts.iter().map(|a| a.current_balance).sum();
assert!(
(inv.rollup.net_worth - true_net_worth).abs() < 0.01,
"net_worth ({}) must equal signed sum of all balances ({})",
inv.rollup.net_worth,
true_net_worth
);
assert!(
(inv.rollup.total_assets - 10_500.0).abs() < 0.01,
"overpaid credit card must count toward total_assets, got {}",
inv.rollup.total_assets
);
assert!(
(inv.rollup.total_liabilities - 0.0).abs() < 0.01,
"no negative balances means zero total_liabilities, got {}",
inv.rollup.total_liabilities
);
}
#[test]
fn overdrawn_checking_counts_as_liability_in_rollup() {
let accounts = vec![
account("depository", Some("checking"), -200.0, false), account("brokerage", Some("roth"), 100_000.0, false),
];
let inv = compute_account_inventory(&accounts);
assert!(
(inv.rollup.total_assets - 100_000.0).abs() < 0.01,
"total_assets must be sum of positive balances only (100000), got {}",
inv.rollup.total_assets
);
assert!(
(inv.rollup.total_liabilities - 200.0).abs() < 0.01,
"overdrawn checking must contribute to total_liabilities, got {}",
inv.rollup.total_liabilities
);
assert!(
(inv.rollup.net_worth - 99_800.0).abs() < 0.01,
"net_worth must be 99800, got {}",
inv.rollup.net_worth
);
}
#[test]
fn tax_advantaged_total_is_sum_of_accounts() {
let accounts = vec![
account("brokerage", Some("st_401k"), 50_000.0, false),
account("brokerage", Some("roth"), 200_000.0, false),
account("brokerage", Some("health_savings_account"), 5_000.0, false),
];
let inv = compute_account_inventory(&accounts);
assert!((inv.buckets[BUCKET_TAX_ADVANTAGED].total - 255_000.0).abs() < 0.01);
}
}