From f60e4bb2f9c46e97aa7937a71f0899ba64a6bb48 Mon Sep 17 00:00:00 2001 From: brianh Date: Mon, 27 Jul 2026 00:31:27 +1000 Subject: [PATCH 1/2] fix(crypto): cost basis method, chain, address are no longer hardcoded (#150) --- crates/ledger-core/src/crypto.rs | 197 ++++++++++++++++++++ crates/ledger-core/src/lib.rs | 1 + crates/ledgerr-mcp/src/contract.rs | 33 ++++ crates/ledgerr-mcp/src/crypto.rs | 55 ++++-- crates/ledgerr-mcp/src/mcp_adapter.rs | 8 +- crates/ledgerr-mcp/tests/crypto_contract.rs | 116 ++++++++++++ 6 files changed, 390 insertions(+), 20 deletions(-) create mode 100644 crates/ledger-core/src/crypto.rs create mode 100644 crates/ledgerr-mcp/tests/crypto_contract.rs diff --git a/crates/ledger-core/src/crypto.rs b/crates/ledger-core/src/crypto.rs new file mode 100644 index 0000000..7929b10 --- /dev/null +++ b/crates/ledger-core/src/crypto.rs @@ -0,0 +1,197 @@ +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use ufo_types::{ + iso::{Currency, Lei}, + satisfies::{Constraint, Satisfies, SatisfiesResult}, +}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Chain { + Bitcoin, + Ethereum, + Solana, + Cardano, + Polkadot, + Avalanche, + Polygon, + Arbitrum, + Optimism, + Bsc, + Other(String), +} + +impl Chain { + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "bitcoin" | "btc" => Some(Self::Bitcoin), + "ethereum" | "eth" => Some(Self::Ethereum), + "solana" | "sol" => Some(Self::Solana), + "cardano" | "ada" => Some(Self::Cardano), + "polkadot" | "dot" => Some(Self::Polkadot), + "avalanche" | "avax" => Some(Self::Avalanche), + "polygon" | "matic" => Some(Self::Polygon), + "arbitrum" | "arb" => Some(Self::Arbitrum), + "optimism" | "op" => Some(Self::Optimism), + "bsc" | "bnb" => Some(Self::Bsc), + other => Some(Self::Other(other.to_string())), + } + } + + pub fn as_str(&self) -> &str { + match self { + Self::Bitcoin => "Bitcoin", + Self::Ethereum => "Ethereum", + Self::Solana => "Solana", + Self::Cardano => "Cardano", + Self::Polkadot => "Polkadot", + Self::Avalanche => "Avalanche", + Self::Polygon => "Polygon", + Self::Arbitrum => "Arbitrum", + Self::Optimism => "Optimism", + Self::Bsc => "BSC", + Self::Other(s) => s.as_str(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum CostBasisMethod { + Fifo, + Lifo, + Hifo, + Acb, + SpecificIdentification { lot_refs: Vec }, +} + +impl CostBasisMethod { + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "fifo" => Some(Self::Fifo), + "lifo" => Some(Self::Lifo), + "hifo" => Some(Self::Hifo), + "acb" => Some(Self::Acb), + "specific_id" | "specific_identification" => { + Some(Self::SpecificIdentification { lot_refs: vec![] }) + } + _ => None, + } + } + + pub fn as_str(&self) -> &str { + match self { + Self::Fifo => "FIFO", + Self::Lifo => "LIFO", + Self::Hifo => "HIFO", + Self::Acb => "ACB", + Self::SpecificIdentification { .. } => "SpecificIdentification", + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum TaxJurisdiction { + Us, + Au, +} + +impl TaxJurisdiction { + pub fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "us" => Self::Us, + _ => Self::Au, + } + } + + pub fn as_str(&self) -> &str { + match self { + Self::Us => "US", + Self::Au => "AU", + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum TxType { + Buy, + Sell, + Staking, + Airdrop, + Spend, + Transfer, +} + +impl TxType { + pub fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "buy" => Self::Buy, + "staking" => Self::Staking, + "airdrop" => Self::Airdrop, + "spend" => Self::Spend, + "transfer" => Self::Transfer, + _ => Self::Sell, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CryptoWallet { + pub lei: Lei, + pub address: String, + pub chain: Chain, + pub jurisdiction: TaxJurisdiction, + pub cost_basis_method: CostBasisMethod, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CryptoTx { + pub tx_hash: String, + pub wallet: CryptoWallet, + pub tx_type: TxType, + pub gross_proceeds: Decimal, + pub cost_basis: Decimal, + pub date: chrono::NaiveDate, + pub acquisition_date: Option, + pub currency: Currency, +} + +impl CryptoTx { + pub fn gain_loss(&self) -> Decimal { + self.gross_proceeds - self.cost_basis + } + + pub fn au_discount_eligible(&self) -> bool { + match self.acquisition_date { + Some(acq) => (self.date - acq).num_days() > 365, + None => false, + } + } + + pub fn au_taxable_gain(&self) -> Decimal { + if self.au_discount_eligible() { + self.gain_loss() * Decimal::new(5, 1) + } else { + self.gain_loss() + } + } +} + +pub struct CryptoCostBasisRules; + +impl Constraint for CryptoCostBasisRules {} + +impl Satisfies for CryptoTx { + fn satisfies(&self, _rules: &CryptoCostBasisRules) -> SatisfiesResult { + match &self.wallet.cost_basis_method { + CostBasisMethod::SpecificIdentification { lot_refs } => { + if lot_refs.is_empty() { + SatisfiesResult::violated( + "SpecificIdentification requires at least one lot reference", + ) + } else { + SatisfiesResult::satisfied(0.95, vec![]) + } + } + _ => SatisfiesResult::satisfied(1.0, vec![]), + } + } +} diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 7f43861..aacebfd 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod attest; pub mod calendar; pub mod classify; pub mod constraints; +pub mod crypto; pub mod document; pub mod document_shape; pub mod filename; diff --git a/crates/ledgerr-mcp/src/contract.rs b/crates/ledgerr-mcp/src/contract.rs index f6c3d22..1ec0681 100644 --- a/crates/ledgerr-mcp/src/contract.rs +++ b/crates/ledgerr-mcp/src/contract.rs @@ -122,6 +122,7 @@ pub const PUBLISHED_TOOLS: [ToolContractSpec; 12] = [ "ambiguity_review", "schedule_summary", "export_workbook", + "compute_depreciation", ], }, ToolContractSpec { @@ -681,6 +682,38 @@ pub enum TaxArgs { acquisition_date: Option, jurisdiction: String, currency: String, + cost_basis_method: String, + chain: String, + address: String, + }, + /// Compute FEIE (Form 2555) exclusion with hard guard that SE tax is not reduced. + ComputeFeie { + tax_year: u16, + foreign_earned_income: String, + days_qualified: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + housing_exclusion: Option, + test: String, + test_start: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + test_end: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + qualifying_days: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + window_start: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + window_end: Option, + }, + /// Compute Schedule E 27.5yr straight-line depreciation with mid-month convention. + ComputeDepreciation { + tax_year: u16, + placed_in_service: String, + total_basis: String, + land_value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + improvements: Option>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + prior_accumulated: Option, }, } diff --git a/crates/ledgerr-mcp/src/crypto.rs b/crates/ledgerr-mcp/src/crypto.rs index 9143cf1..7fdc8e4 100644 --- a/crates/ledgerr-mcp/src/crypto.rs +++ b/crates/ledgerr-mcp/src/crypto.rs @@ -1,11 +1,11 @@ -//! Thin MCP handlers for crypto cost basis rules (gh#516). - use chrono::NaiveDate; use rust_decimal::Decimal; use serde_json::{json, Value}; -use ledger_core::crypto::{Chain, CostBasisMethod, CryptoTx, CryptoWallet, CryptoCostBasisRules, TaxJurisdiction, TxType}; -use ufo_types::{iso::{Currency, Lei}, satisfies::Satisfies}; +use ledger_core::crypto::{ + Chain, CostBasisMethod, CryptoTx, CryptoWallet, CryptoCostBasisRules, TaxJurisdiction, TxType, +}; +use ufo_types::{iso::Currency, satisfies::Satisfies}; pub fn handle_crypto_cost_basis_check( lei: &str, @@ -17,6 +17,9 @@ pub fn handle_crypto_cost_basis_check( acquisition_date: Option<&str>, jurisdiction: &str, currency: &str, + cost_basis_method: &str, + chain: &str, + address: &str, ) -> Value { let lei = match Lei::new(lei) { Ok(l) => l, @@ -33,29 +36,42 @@ pub fn handle_crypto_cost_basis_check( }; let acq_date = acquisition_date.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()); - let jx = match jurisdiction { - "US" | "us" => TaxJurisdiction::Us, - _ => TaxJurisdiction::Au, - }; + let jx = TaxJurisdiction::from_str(jurisdiction); let ccy = match currency { "USD" => Currency::Usd, _ => Currency::Aud, }; - let tt = match tx_type { - "buy" => TxType::Buy, "staking" => TxType::Staking, - "airdrop" => TxType::Airdrop, "spend" => TxType::Spend, - "transfer" => TxType::Transfer, - _ => TxType::Sell, + let tt = TxType::from_str(tx_type); + + let ch = match Chain::from_str(chain) { + Some(c) => c, + None => return json!({ "error": format!("unrecognized chain: {chain}") }), + }; + let cbm = match CostBasisMethod::from_str(cost_basis_method) { + Some(m) => m, + None => { + return json!({ + "error": format!("unrecognized cost_basis_method: {cost_basis_method}") + }) + } }; let wallet = CryptoWallet { - lei, address: String::new(), chain: Chain::Bitcoin, - jurisdiction: jx, cost_basis_method: CostBasisMethod::Fifo, + lei, + address: address.to_string(), + chain: ch, + jurisdiction: jx, + cost_basis_method: cbm, }; let tx = CryptoTx { - tx_hash: tx_hash.to_string(), wallet, tx_type: tt, - gross_proceeds: gross, cost_basis: cost, - date: tx_date, acquisition_date: acq_date, currency: ccy, + tx_hash: tx_hash.to_string(), + wallet, + tx_type: tt, + gross_proceeds: gross, + cost_basis: cost, + date: tx_date, + acquisition_date: acq_date, + currency: ccy, }; json!({ @@ -63,5 +79,8 @@ pub fn handle_crypto_cost_basis_check( "gain_loss": tx.gain_loss(), "au_discount_eligible": tx.au_discount_eligible(), "au_taxable_gain": tx.au_taxable_gain(), + "method_used": tx.wallet.cost_basis_method.as_str(), + "chain": tx.wallet.chain.as_str(), + "address": tx.wallet.address, }) } diff --git a/crates/ledgerr-mcp/src/mcp_adapter.rs b/crates/ledgerr-mcp/src/mcp_adapter.rs index 026c468..f1cab03 100644 --- a/crates/ledgerr-mcp/src/mcp_adapter.rs +++ b/crates/ledgerr-mcp/src/mcp_adapter.rs @@ -1146,8 +1146,12 @@ pub fn handle_tax_tool(service: &TurboLedgerService, arguments: &Value) -> Value crate::au_rd::handle_au_rd_calculate_offset(&lei, &total_eligible_aud, is_refundable), TaxArgs::UsRdcFourPartTestCheck { lei, activity_id, activity_name, technical_in_nature, permits_experimentation, technological_uncertainty, systematic_process } => crate::us_rdc::handle_us_rdc_four_part_test(&lei, &activity_id, &activity_name, technical_in_nature, permits_experimentation, technological_uncertainty, systematic_process), - TaxArgs::CryptoCostBasisCheck { lei, tx_hash, tx_type, gross_proceeds, cost_basis, date, acquisition_date, jurisdiction, currency } => - crate::crypto::handle_crypto_cost_basis_check(&lei, &tx_hash, &tx_type, &gross_proceeds, &cost_basis, &date, acquisition_date.as_deref(), &jurisdiction, ¤cy), + TaxArgs::CryptoCostBasisCheck { lei, tx_hash, tx_type, gross_proceeds, cost_basis, date, acquisition_date, jurisdiction, currency, cost_basis_method, chain, address } => + crate::crypto::handle_crypto_cost_basis_check(&lei, &tx_hash, &tx_type, &gross_proceeds, &cost_basis, &date, acquisition_date.as_deref(), &jurisdiction, ¤cy, &cost_basis_method, &chain, &address), + TaxArgs::ComputeFeie { tax_year, foreign_earned_income, days_qualified, housing_exclusion, test, test_start, test_end, qualifying_days, window_start, window_end } => + crate::feie::handle_compute_feie(tax_year, &foreign_earned_income, days_qualified, housing_exclusion.as_deref(), &test, &test_start, test_end.as_deref(), qualifying_days, window_start.as_deref(), window_end.as_deref()), + TaxArgs::ComputeDepreciation { tax_year, placed_in_service, total_basis, land_value, improvements, prior_accumulated } => + crate::schedule_e::handle_compute_depreciation(tax_year, placed_in_service, total_basis, land_value, improvements, prior_accumulated), } } diff --git a/crates/ledgerr-mcp/tests/crypto_contract.rs b/crates/ledgerr-mcp/tests/crypto_contract.rs new file mode 100644 index 0000000..ad377e2 --- /dev/null +++ b/crates/ledgerr-mcp/tests/crypto_contract.rs @@ -0,0 +1,116 @@ +mod common; + +fn check( + cost_basis_method: &str, + chain: &str, + address: &str, +) -> serde_json::Value { + ledgerr_mcp::crypto::handle_crypto_cost_basis_check( + "529900T8BMF4KJ2H7K41", + "0xabc123", + "sell", + "10000.00", + "6000.00", + "2024-06-15", + Some("2023-01-10"), + "US", + "USD", + cost_basis_method, + chain, + address, + ) +} + +#[test] +fn fifo_btc_no_address() { + let v = check("fifo", "btc", ""); + assert!(v["error"].is_null(), "unexpected error: {:?}", v["error"]); + assert_eq!(v["method_used"], "FIFO"); + assert_eq!(v["chain"], "Bitcoin"); + assert_eq!(v["address"], ""); + assert_eq!(v["gain_loss"], 4000.0); +} + +#[test] +fn hifo_eth_with_address() { + let v = check("hifo", "eth", "0xdeadbeef"); + assert!(v["error"].is_null(), "unexpected error: {:?}", v["error"]); + assert_eq!(v["method_used"], "HIFO"); + assert_eq!(v["chain"], "Ethereum"); + assert_eq!(v["address"], "0xdeadbeef"); +} + +#[test] +fn lifo_solana_address() { + let v = check("lifo", "sol", "Sol111111111111111111111111111111111111111"); + assert!(v["error"].is_null()); + assert_eq!(v["method_used"], "LIFO"); + assert_eq!(v["chain"], "Solana"); +} + +#[test] +fn specific_id_without_lots_fails() { + let v = check("specific_identification", "btc", "bc1abc"); + assert!(v["error"].is_null(), "unexpected error: {:?}", v["error"]); + assert_eq!(v["method_used"], "SpecificIdentification"); + assert_eq!( + v["result"]["disposition"]["reason"], + "SpecificIdentification requires at least one lot reference" + ); +} + +#[test] +fn unrecognized_method_returns_error() { + let v = check("bogus", "btc", ""); + assert!(!v["error"].is_null()); + assert!(v["error"].as_str().unwrap().contains("bogus")); +} + +#[test] +fn unrecognized_chain_returns_error() { + let v = check("fifo", "nonexistent_chain", ""); + assert!(!v["error"].is_null()); + assert!(v["error"].as_str().unwrap().contains("nonexistent_chain")); +} + +#[test] +fn au_discount_eligible_long_hold() { + let v = ledgerr_mcp::crypto::handle_crypto_cost_basis_check( + "529900T8BMF4KJ2H7K41", + "0xdef", + "sell", + "20000.00", + "5000.00", + "2024-06-15", + Some("2020-01-01"), + "AU", + "AUD", + "fifo", + "eth", + "0xaaaa", + ); + assert!(v["error"].is_null()); + assert!(v["au_discount_eligible"].as_bool().unwrap_or(false)); + assert!(v["au_taxable_gain"].as_f64().unwrap_or(0.0) < 15000.0); +} + +#[test] +fn au_no_discount_short_hold() { + let v = ledgerr_mcp::crypto::handle_crypto_cost_basis_check( + "529900T8BMF4KJ2H7K41", + "0xdef", + "sell", + "20000.00", + "5000.00", + "2024-06-15", + Some("2024-06-01"), + "AU", + "AUD", + "fifo", + "eth", + "0xbbbb", + ); + assert!(v["error"].is_null()); + assert!(!v["au_discount_eligible"].as_bool().unwrap_or(true)); + assert_eq!(v["au_taxable_gain"].as_f64().unwrap_or(0.0), 15000.0); +} From 4fcf9c1e5eac9a714714ce2decdeccfebc5ea4b1 Mon Sep 17 00:00:00 2001 From: brianh Date: Mon, 27 Jul 2026 01:02:46 +1000 Subject: [PATCH 2/2] feat(us-tax): Form 2555 FEIE model + hard guard FEIE does not exclude SE tax (#147) --- crates/ledgerr-mcp/src/feie.rs | 214 ++++++++++++++++++++++ crates/ledgerr-mcp/src/lib.rs | 11 +- crates/ledgerr-mcp/tests/feie_contract.rs | 65 +++++++ 3 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 crates/ledgerr-mcp/src/feie.rs create mode 100644 crates/ledgerr-mcp/tests/feie_contract.rs diff --git a/crates/ledgerr-mcp/src/feie.rs b/crates/ledgerr-mcp/src/feie.rs new file mode 100644 index 0000000..43d353c --- /dev/null +++ b/crates/ledgerr-mcp/src/feie.rs @@ -0,0 +1,214 @@ +use rust_decimal::Decimal; +use serde::Serialize; +use serde_json::{json, Value}; +use std::str::FromStr; + +use crate::ToolError; + +pub enum ForeignResidenceTest { + BonaFideResidence { start: String, end: Option }, + PhysicalPresence { qualifying_days: u16, window: (String, String) }, +} + +pub struct FeieInput { + pub tax_year: u16, + pub test: ForeignResidenceTest, + pub foreign_earned_income: String, + pub days_qualified: u16, + pub housing_exclusion: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FeieOutcome { + pub excluded_amount: String, + pub income_subject_to_income_tax: String, + pub income_subject_to_se_tax: String, + pub warnings: Vec, +} + +fn exclusion_limit_for_year(year: u16) -> Decimal { + match year { + 2023 => Decimal::new(120000, 0), + 2024 => Decimal::new(126500, 0), + 2025 => Decimal::new(130000, 0), + _ => Decimal::new(120000, 0), + } +} + +pub fn compute_feie(input: &FeieInput) -> FeieOutcome { + let limit = exclusion_limit_for_year(input.tax_year); + let days_in_year = Decimal::new(365, 0); + let days_qualified = Decimal::from(input.days_qualified); + let pro_rated_limit = limit * days_qualified / days_in_year; + + let foreign_income = + Decimal::from_str(&input.foreign_earned_income).unwrap_or(Decimal::ZERO); + + let excluded_amount = foreign_income.min(pro_rated_limit); + let income_subject_to_income_tax = foreign_income - excluded_amount; + let income_subject_to_se_tax = foreign_income; + + let mut warnings = Vec::new(); + if income_subject_to_se_tax > Decimal::ZERO { + warnings.push( + "FEIE does not reduce self-employment tax. SE tax applies to foreign earned income even when FEIE excludes it from income tax.".to_string() + ); + } + + FeieOutcome { + excluded_amount: excluded_amount.to_string(), + income_subject_to_income_tax: income_subject_to_income_tax.to_string(), + income_subject_to_se_tax: income_subject_to_se_tax.to_string(), + warnings, + } +} + +pub fn compute_feie_from_json(args: &serde_json::Value) -> Result { + let tax_year = args + .get("tax_year") + .and_then(|v| v.as_u64()) + .ok_or_else(|| ToolError::InvalidInput("missing or invalid `tax_year`".to_string()))? + as u16; + + let test_str = args + .get("test") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidInput("missing or invalid `test`".to_string()))?; + + let test = match test_str { + "bona_fide" => ForeignResidenceTest::BonaFideResidence { + start: args + .get("test_start") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidInput( + "missing `test_start` for bona_fide test".to_string(), + ) + })? + .to_string(), + end: args + .get("test_end") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + }, + "physical_presence" => ForeignResidenceTest::PhysicalPresence { + qualifying_days: args + .get("qualifying_days") + .and_then(|v| v.as_u64()) + .ok_or_else(|| { + ToolError::InvalidInput( + "missing `qualifying_days` for physical_presence test".to_string(), + ) + })? as u16, + window: { + let start = args + .get("window_start") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidInput( + "missing `window_start` for physical_presence test".to_string(), + ) + })? + .to_string(); + let end = args + .get("window_end") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidInput( + "missing `window_end` for physical_presence test".to_string(), + ) + })? + .to_string(); + (start, end) + }, + }, + _ => { + return Err(ToolError::InvalidInput(format!( + "unknown test variant: {test_str}" + ))) + } + }; + + let foreign_earned_income = args + .get("foreign_earned_income") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidInput("missing `foreign_earned_income`".to_string()) + })? + .to_string(); + + let days_qualified = args + .get("days_qualified") + .and_then(|v| v.as_u64()) + .ok_or_else(|| ToolError::InvalidInput("missing `days_qualified`".to_string()))? + as u16; + + let housing_exclusion = args + .get("housing_exclusion") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + Ok(compute_feie(&FeieInput { + tax_year, + test, + foreign_earned_income, + days_qualified, + housing_exclusion, + })) +} + +pub fn handle_compute_feie( + tax_year: u16, + foreign_earned_income: &str, + days_qualified: u16, + housing_exclusion: Option<&str>, + test: &str, + test_start: &str, + test_end: Option<&str>, + qualifying_days: Option, + window_start: Option<&str>, + window_end: Option<&str>, +) -> Value { + let feie_test = match test { + "bona_fide" => ForeignResidenceTest::BonaFideResidence { + start: test_start.to_string(), + end: test_end.map(|s| s.to_string()), + }, + "physical_presence" => { + let qd = match qualifying_days { + Some(d) => d, + None => { + return json!({ "error": "qualifying_days required for physical_presence test" }) + } + }; + let ws = match window_start { + Some(s) => s.to_string(), + None => { + return json!({ "error": "window_start required for physical_presence test" }) + } + }; + let we = match window_end { + Some(s) => s.to_string(), + None => { + return json!({ "error": "window_end required for physical_presence test" }) + } + }; + ForeignResidenceTest::PhysicalPresence { + qualifying_days: qd, + window: (ws, we), + } + } + _ => return json!({ "error": format!("unknown test: {test}") }), + }; + + let input = FeieInput { + tax_year, + test: feie_test, + foreign_earned_income: foreign_earned_income.to_string(), + days_qualified, + housing_exclusion: housing_exclusion.map(|s| s.to_string()), + }; + + let outcome = compute_feie(&input); + json!(outcome) +} diff --git a/crates/ledgerr-mcp/src/lib.rs b/crates/ledgerr-mcp/src/lib.rs index 463f83d..8463103 100644 --- a/crates/ledgerr-mcp/src/lib.rs +++ b/crates/ledgerr-mcp/src/lib.rs @@ -44,6 +44,8 @@ pub mod schema; pub mod shape_tool; pub mod au_rd; pub mod crypto; +pub mod schedule_e; +pub mod feie; pub mod tax_assist; pub mod us_rdc; pub mod xero_service; @@ -78,6 +80,7 @@ pub use reconciliation::{ }; pub use schema::{CustomKind, KindInfo, SchemaKinds, SchemaStore}; pub use shape_tool::{get_document_shape, GetDocumentShapeRequest}; +pub use feie::{FeieInput, FeieOutcome, ForeignResidenceTest}; pub use tax_assist::{ TaxAmbiguityRecord, TaxAmbiguityReviewRequest, TaxAmbiguityReviewResponse, TaxAssistRequest, TaxAssistResponse, TaxAssistSummary, TaxEvidenceChainRequest, TaxEvidenceChainResponse, @@ -1140,6 +1143,10 @@ impl TurboLedgerService { )) } + pub fn compute_feie_tool(&self, input: FeieInput) -> Result { + Ok(feie::compute_feie(&input)) + } + fn append_lifecycle_event( &self, event_type: &str, @@ -3205,10 +3212,12 @@ fn emit_ingest_ontology_edges( OntologyEntityInput { kind: OntologyEntityKind::Document, attrs: doc_attrs, + custom_kind: None, }, OntologyEntityInput { kind: OntologyEntityKind::Transaction, attrs: tx_attrs, + custom_kind: None, }, ], )? @@ -3897,7 +3906,7 @@ impl TurboLedgerService { attrs.insert("xero_id".into(), xero_id.clone()); attrs.insert("display_name".into(), display_name.clone()); attrs.insert("local_id".into(), local_id.clone()); - let _ = ontology::upsert_entities(&mut store, vec![OntologyEntityInput { kind, attrs }]); + let _ = ontology::upsert_entities(&mut store, vec![OntologyEntityInput { kind, attrs, custom_kind: None }]); let _ = ontology::persist_store(&store, &ont_path); } diff --git a/crates/ledgerr-mcp/tests/feie_contract.rs b/crates/ledgerr-mcp/tests/feie_contract.rs new file mode 100644 index 0000000..70ef9ec --- /dev/null +++ b/crates/ledgerr-mcp/tests/feie_contract.rs @@ -0,0 +1,65 @@ +use ledgerr_mcp::{ + feie::{compute_feie, FeieInput, ForeignResidenceTest}, + FeieOutcome, +}; + +#[test] +fn feie_01_full_year_excludes_correctly() { + let outcome = compute_feie(&FeieInput { + tax_year: 2024, + test: ForeignResidenceTest::BonaFideResidence { + start: "2024-01-01".to_string(), + end: None, + }, + foreign_earned_income: "150000".to_string(), + days_qualified: 365, + housing_exclusion: None, + }); + + assert_eq!(outcome.excluded_amount, "126500"); + assert_eq!(outcome.income_subject_to_income_tax, "23500"); + assert_eq!(outcome.income_subject_to_se_tax, "150000"); + assert!(outcome.warnings.iter().any(|w| w.contains("SE tax"))); +} + +#[test] +fn feie_02_se_tax_not_reduced_by_exclusion() { + let outcome = compute_feie(&FeieInput { + tax_year: 2024, + test: ForeignResidenceTest::PhysicalPresence { + qualifying_days: 330, + window: ("2024-01-01".to_string(), "2024-12-31".to_string()), + }, + foreign_earned_income: "100000".to_string(), + days_qualified: 365, + housing_exclusion: None, + }); + + // Full exclusion since income < limit + assert_eq!(outcome.excluded_amount, "100000"); + assert_eq!(outcome.income_subject_to_income_tax, "0"); + + // HARD GUARD: SE tax base is NOT reduced by FEIE + assert_eq!(outcome.income_subject_to_se_tax, "100000"); + assert!(outcome.warnings.iter().any(|w| w.contains("SE tax"))); +} + +#[test] +fn feie_03_partial_year_pro_rata() { + let outcome = compute_feie(&FeieInput { + tax_year: 2024, + test: ForeignResidenceTest::BonaFideResidence { + start: "2024-07-01".to_string(), + end: None, + }, + foreign_earned_income: "80000".to_string(), + days_qualified: 184, + housing_exclusion: None, + }); + + // 184/365 * 126500 with Decimal precision + assert_eq!(outcome.excluded_amount, "63769.86301369863013698630137"); + assert_eq!(outcome.income_subject_to_income_tax, "16230.13698630136986301369863"); + // Hard guard still applies + assert_eq!(outcome.income_subject_to_se_tax, "80000"); +}