use crate::conventions::Usd;
use crate::state::{LedgerState, RemovalKind};
use crate::tax::return_1040::{mixed_use_mortgage_forgone, AbsoluteReturn};
use crate::tax::return_inputs::ReturnInputs;
use crate::tax::tables::FullReturnParams;
use crate::tax::types::FilingStatus;
use rust_decimal_macros::dec;
const EIC_ADVISORY_AGI_CEILING: Usd = dec!(70000);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Advisory {
CtcOdcOmitted { dependents: usize },
EicOmitted,
AgedBoxForfeitedNoDob { per_box: Usd },
FbarFinCen,
CharitableDoneeAssumedPublicCharity { donations: usize },
OtherCreditsOmitted,
RefundByPaperCheck { refund: Usd },
MixedUseMortgageNotAllocated {
forgone_interest: Usd,
itemized: bool,
},
BlindBoxForfeitedNotDeclared { per_box: Usd, persons: usize },
SalesTaxElectionNotAsked { itemized: bool },
}
fn fmt_usd(v: Usd) -> String {
let cents = v.round_dp(2);
let whole = cents.trunc().abs();
let frac = (cents - cents.trunc()).abs();
let digits = whole.to_string();
let mut grouped = String::new();
for (i, ch) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
grouped.push(',');
}
grouped.push(ch);
}
let sign = if cents.is_sign_negative() { "-" } else { "" };
if frac.is_zero() {
format!("{sign}${grouped}")
} else {
format!("{sign}${grouped}.{:02}", (frac * dec!(100)).round())
}
}
impl Advisory {
pub fn message(&self) -> String {
match self {
Advisory::CtcOdcOmitted { dependents } => format!(
"CTC/ODC NOT COMPUTED — you captured {dependents} dependent(s), but v1 does not compute the \
Child Tax Credit or the Credit for Other Dependents (1040 line 19 is $0). Your tax is \
OVERSTATED by up to $2,000 per qualifying child / $500 per other dependent. File Schedule \
8812 yourself to claim it."
),
Advisory::EicOmitted =>
"EIC NOT COMPUTED — your income is low enough that you may qualify for the Earned Income \
Credit, which v1 does not compute. Your tax may be OVERSTATED. Check Pub. 596."
.to_string(),
Advisory::AgedBoxForfeitedNoDob { per_box } => format!(
"DATE OF BIRTH NOT ON FILE — the §63(f) additional standard deduction for age 65+ \
({} per box) was NOT granted, because v1 never assumes a birthdate. If you (or your \
spouse) are 65 or older, enter the date of birth and re-run: your tax is currently \
OVERSTATED.",
fmt_usd(*per_box)
),
Advisory::FbarFinCen =>
"FBAR / FinCEN — you declared a foreign financial account. Under FinCEN Notice 2020-2 an \
account holding ONLY virtual currency is (for now) outside the FBAR requirement, but that \
is under active reconsideration, and an account holding crypto PLUS fiat or securities may \
well be reportable. btctax never answers Schedule B Part III for you — decide it yourself."
.to_string(),
Advisory::CharitableDoneeAssumedPublicCharity { donations } => format!(
"CHARITABLE DONEE ASSUMED — your {donations} crypto donation(s) were valued assuming a \
PUBLIC CHARITY (50%-organization) donee: long-term gifts at fair market value under the \
30%-of-AGI ceiling. If the donee is a PRIVATE FOUNDATION, the correct treatment is the \
20% ceiling at BASIS (which v1 refuses). Verify who you gave to."
),
Advisory::OtherCreditsOmitted =>
"OTHER CREDITS NOT COMPUTED — v1 does not compute the education (Form 8863), \
dependent-care (Form 2441), retirement-savings/saver's (Form 8880), residential-energy \
(Form 5695) or adoption (Form 8839) credits: Schedule 3 Part I is $0 apart from the \
foreign tax credit. If you qualify for any of them your tax is OVERSTATED — claim them \
yourself."
.to_string(),
Advisory::RefundByPaperCheck { refund } => format!(
"REFUND BY PAPER CHECK — your return is due a refund of {}, but v1 never fills the \
direct-deposit block (1040 lines 35b–35d). As filed, the IRS will mail a check. Add your \
routing and account numbers by hand if you want it deposited.",
fmt_usd(*refund)
),
Advisory::MixedUseMortgageNotAllocated {
forgone_interest,
itemized,
} => {
if *itemized {
format!(
"MIXED-USE MORTGAGE — Your Schedule A claimed $0 on line 8a and the mixed-use box is \
checked. Because not all of the loan was used to buy, build, or improve the home, \
§163(h)(3)(F) makes the rest non-deductible and v1 cannot compute the Pub. 936 \
allocation. A Pub. 936 allocation could restore up to {} of mortgage interest — your \
tax is OVERSTATED.",
fmt_usd(*forgone_interest)
)
} else {
format!(
"MIXED-USE MORTGAGE — Your return took the standard deduction. Because you declared a \
mixed-use mortgage, line 8a was treated as $0 (§163(h)(3)(F); v1 cannot compute the \
Pub. 936 allocation); a Pub. 936 allocation of up to {} of mortgage interest might \
have made itemizing win.",
fmt_usd(*forgone_interest)
)
}
}
Advisory::BlindBoxForfeitedNotDeclared { per_box, persons } => format!(
"BLINDNESS NOT DECLARED — the §63(f) additional standard deduction for blindness ({} per \
box) was NOT granted for {persons} person(s) whose blindness was never stated (v1 never \
assumes it). It STACKS with the age-65+ box. If you (or your spouse) are legally blind, run \
`btctax income answer`: your tax is currently OVERSTATED.",
fmt_usd(*per_box)
),
Advisory::SalesTaxElectionNotAsked { itemized } => {
if *itemized {
"SALES-TAX ELECTION NOT ASKED — your Schedule A used state and local INCOME taxes, but \
you were never asked whether to deduct general SALES taxes instead (§164(b)(5)). In a \
no-income-tax state or a big-purchase year the sales-tax figure can be larger. If so, \
your SALT deduction is too small and your tax is OVERSTATED. Run `btctax income answer` \
to choose."
.to_string()
} else {
"SALES-TAX ELECTION NOT ASKED — you have Schedule A items but took the standard \
deduction, and were never asked whether to deduct general SALES taxes instead of \
income taxes (§164(b)(5)). In a no-income-tax state or a big-purchase year the \
sales-tax figure can be larger — and could even flip this return into itemizing. If \
so, your tax is OVERSTATED. Run `btctax income answer` to choose."
.to_string()
}
}
}
}
}
pub fn advisories_for(
ri: &ReturnInputs,
state: &LedgerState,
ar: &AbsoluteReturn,
params: &FullReturnParams,
year: i32,
) -> Vec<Advisory> {
let earned = ar.wages + ar.se.as_ref().map_or(Usd::ZERO, |s| s.net_se);
advisories(
ri,
state,
earned,
ar.agi,
ar.overpayment_refund,
params,
year,
ar.deduction_is_itemized,
)
}
#[allow(clippy::too_many_arguments)]
pub fn advisories(
ri: &ReturnInputs,
state: &LedgerState,
earned_income: Usd,
agi: Usd,
refund: Usd,
params: &FullReturnParams,
year: i32,
deduction_is_itemized: bool,
) -> Vec<Advisory> {
let mut out = Vec::new();
let dependents = ri.header.dependents.len();
if dependents > 0 {
out.push(Advisory::CtcOdcOmitted { dependents });
}
if earned_income > Usd::ZERO && agi < EIC_ADVISORY_AGI_CEILING {
out.push(Advisory::EicOmitted);
}
out.push(Advisory::OtherCreditsOmitted);
if refund > Usd::ZERO {
out.push(Advisory::RefundByPaperCheck { refund });
}
let married_rate = matches!(
ri.filing_status,
FilingStatus::Mfj | FilingStatus::Mfs | FilingStatus::Qss
);
let per_box = if married_rate {
params.std_aged_blind_married
} else {
params.std_aged_blind_unmarried
};
let taxpayer_no_dob = ri.header.taxpayer.date_of_birth.is_none();
let spouse_dob_on_file = ri
.header
.spouse
.as_ref()
.is_some_and(|s| s.date_of_birth.is_some());
let spouse_no_dob = ri.filing_status == FilingStatus::Mfj && !spouse_dob_on_file;
if taxpayer_no_dob || spouse_no_dob {
out.push(Advisory::AgedBoxForfeitedNoDob { per_box });
}
let taxpayer_no_blind = ri.header.taxpayer.blind.is_none();
let spouse_blind_on_file = ri.header.spouse.as_ref().is_some_and(|s| s.blind.is_some());
let spouse_no_blind = ri.filing_status == FilingStatus::Mfj && !spouse_blind_on_file;
let blind_persons = usize::from(taxpayer_no_blind) + usize::from(spouse_no_blind);
if blind_persons > 0 {
out.push(Advisory::BlindBoxForfeitedNotDeclared {
per_box,
persons: blind_persons,
});
}
if let Some(forgone_interest) = mixed_use_mortgage_forgone(ri) {
out.push(Advisory::MixedUseMortgageNotAllocated {
forgone_interest,
itemized: deduction_is_itemized,
});
}
if ri
.schedule_a
.as_ref()
.is_some_and(|a| a.salt_use_sales_tax.is_none())
{
out.push(Advisory::SalesTaxElectionNotAsked {
itemized: deduction_is_itemized,
});
}
if ri.foreign_accounts == Some(true) {
out.push(Advisory::FbarFinCen);
}
let donations = state
.removals
.iter()
.filter(|r| r.kind == RemovalKind::Donation && r.removed_at.year() == year)
.count();
if donations > 0 {
out.push(Advisory::CharitableDoneeAssumedPublicCharity { donations });
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tax::return_inputs::{Dependent, ScheduleAInputs};
#[test]
fn ctc_fires_on_dependents_and_says_overstated() {
let a = Advisory::CtcOdcOmitted { dependents: 2 };
let m = a.message();
assert!(m.contains("2 dependent(s)"));
assert!(m.contains("OVERSTATED"));
assert!(m.contains("8812"));
}
#[test]
fn dob_advisory_names_the_forfeited_amount() {
let m = Advisory::AgedBoxForfeitedNoDob {
per_box: dec!(1950),
}
.message();
assert!(m.contains("$1,950"), "thousands-separated (P5-N5): {m}");
assert!(m.contains("OVERSTATED"));
}
#[test]
fn fbar_advisory_refuses_to_answer_for_you() {
let m = Advisory::FbarFinCen.message();
assert!(m.contains("never answers Schedule B Part III"));
}
#[test]
fn donee_advisory_names_the_private_foundation_risk() {
let m = Advisory::CharitableDoneeAssumedPublicCharity { donations: 1 }.message();
assert!(m.contains("PRIVATE FOUNDATION"));
assert!(m.contains("BASIS"));
}
fn params() -> FullReturnParams {
let mut std_deduction = std::collections::BTreeMap::new();
for s in [
FilingStatus::Single,
FilingStatus::Mfj,
FilingStatus::Mfs,
FilingStatus::HoH,
] {
std_deduction.insert(s, dec!(14600));
}
FullReturnParams {
year: 2024,
std_deduction,
std_aged_blind_married: dec!(1550),
std_aged_blind_unmarried: dec!(1950),
dependent_std_floor: dec!(1300),
dependent_std_earned_addon: dec!(450),
salt_cap: dec!(10000),
kiddie_unearned_threshold: dec!(2600),
elective_deferral_limit: dec!(23000),
ftc_ceiling: dec!(300),
qbi_ti_threshold_unmarried: dec!(191950),
qbi_ti_threshold_married: dec!(383900),
student_loan_phaseout_unmarried: (dec!(80000), dec!(95000)),
student_loan_phaseout_married: (dec!(165000), dec!(195000)),
amt: crate::tax::tables::AmtParams {
exemption_single_hoh: dec!(85700),
exemption_mfj_qss: dec!(133300),
exemption_mfs: dec!(66650),
phaseout_start_single_hoh_mfs: dec!(609350),
phaseout_start_mfj_qss: dec!(1218700),
breakpoint_28pct: dec!(232600),
breakpoint_28pct_mfs: dec!(116300),
},
}
}
#[test]
fn a_clean_high_income_return_has_only_the_unconditional_omission() {
let mut ri = ReturnInputs {
filing_status: FilingStatus::Single,
..Default::default()
};
ri.header.taxpayer.date_of_birth = Some(time::macros::date!(1980 - 01 - 01));
ri.header.taxpayer.blind = Some(false); let got = advisories(
&ri,
&LedgerState::default(),
dec!(150000), dec!(150000), Usd::ZERO,
¶ms(),
2024,
false,
);
assert_eq!(got, vec![Advisory::OtherCreditsOmitted], "{got:?}");
}
#[test]
fn omissions_fire_together_in_order() {
let mut ri = ReturnInputs {
filing_status: FilingStatus::Single,
..Default::default()
};
ri.header.dependents = vec![Dependent::default(), Dependent::default()];
ri.header.taxpayer.blind = Some(false);
let got = advisories(
&ri,
&LedgerState::default(),
dec!(30000), dec!(30000), Usd::ZERO,
¶ms(),
2024,
false,
);
assert_eq!(
got,
vec![
Advisory::CtcOdcOmitted { dependents: 2 },
Advisory::EicOmitted,
Advisory::OtherCreditsOmitted,
Advisory::AgedBoxForfeitedNoDob {
per_box: dec!(1950)
},
]
);
}
#[test]
fn eic_needs_earned_income_and_low_agi() {
let mut ri = ReturnInputs {
filing_status: FilingStatus::Single,
..Default::default()
};
ri.header.taxpayer.date_of_birth = Some(time::macros::date!(1980 - 01 - 01));
let p = params();
assert!(!advisories(
&ri,
&LedgerState::default(),
Usd::ZERO,
dec!(30000),
Usd::ZERO,
&p,
2024,
false
)
.contains(&Advisory::EicOmitted));
assert!(!advisories(
&ri,
&LedgerState::default(),
dec!(70000),
dec!(70000),
Usd::ZERO,
&p,
2024,
false
)
.contains(&Advisory::EicOmitted));
assert!(advisories(
&ri,
&LedgerState::default(),
dec!(30000),
dec!(30000),
Usd::ZERO,
&p,
2024,
false
)
.contains(&Advisory::EicOmitted));
}
#[test]
fn fbar_fires_and_mfj_uses_the_married_rate() {
let ri = ReturnInputs {
filing_status: FilingStatus::Mfj,
foreign_accounts: Some(true),
..Default::default()
};
let got = advisories(
&ri,
&LedgerState::default(),
dec!(200000),
dec!(200000),
Usd::ZERO,
¶ms(),
2024,
false,
);
assert!(got.contains(&Advisory::FbarFinCen));
assert!(got.contains(&Advisory::AgedBoxForfeitedNoDob {
per_box: dec!(1550) }));
}
#[test]
fn eic_advisory_fires_for_mfj_at_63k_p5_i3() {
let mut ri = ReturnInputs {
filing_status: FilingStatus::Mfj,
..Default::default()
};
ri.header.dependents = vec![
Dependent::default(),
Dependent::default(),
Dependent::default(),
];
let got = advisories(
&ri,
&LedgerState::default(),
dec!(63000), dec!(63000), Usd::ZERO,
¶ms(),
2024,
false,
);
assert!(
got.contains(&Advisory::EicOmitted),
"MFJ/$63k/3 kids must fire the EIC omission: {got:?}"
);
assert!(dec!(63000) > dec!(60000) && dec!(63000) < EIC_ADVISORY_AGI_CEILING);
}
#[test]
fn other_credits_and_paper_check_advisories_fire_p5_i2() {
let mut ri = ReturnInputs {
filing_status: FilingStatus::Single,
..Default::default()
};
ri.header.taxpayer.date_of_birth = Some(time::macros::date!(1980 - 01 - 01));
let p = params();
let owes = advisories(
&ri,
&LedgerState::default(),
dec!(150000),
dec!(150000),
Usd::ZERO, &p,
2024,
false,
);
assert!(owes.contains(&Advisory::OtherCreditsOmitted));
assert!(!owes
.iter()
.any(|a| matches!(a, Advisory::RefundByPaperCheck { .. })));
let refunded = advisories(
&ri,
&LedgerState::default(),
dec!(150000),
dec!(150000),
dec!(1234.56),
&p,
2024,
false,
);
assert!(refunded.contains(&Advisory::OtherCreditsOmitted));
assert!(refunded.contains(&Advisory::RefundByPaperCheck {
refund: dec!(1234.56)
}));
let m = Advisory::RefundByPaperCheck {
refund: dec!(1234.56),
}
.message();
assert!(m.contains("$1,234.56"), "{m}");
assert!(m.contains("mail a check"), "{m}");
let oc = Advisory::OtherCreditsOmitted.message();
for form in ["8863", "2441", "8880", "5695", "8839"] {
assert!(
oc.contains(form),
"other-credits advisory must name Form {form}: {oc}"
);
}
assert!(oc.contains("OVERSTATED"));
}
#[test]
fn mfj_with_no_spouse_record_still_advises_the_aged_box_p5_m2() {
let mut ri = ReturnInputs {
filing_status: FilingStatus::Mfj,
..Default::default()
};
ri.header.taxpayer.date_of_birth = Some(time::macros::date!(1980 - 01 - 01));
assert!(ri.header.spouse.is_none());
let got = advisories(
&ri,
&LedgerState::default(),
dec!(200000),
dec!(200000),
Usd::ZERO,
¶ms(),
2024,
false,
);
assert!(
got.contains(&Advisory::AgedBoxForfeitedNoDob {
per_box: dec!(1550) }),
"an absent MFJ spouse record must not forfeit the aged box silently: {got:?}"
);
}
fn mixed_use_ri(answer: Option<bool>, interest: Usd) -> ReturnInputs {
let mut ri = ReturnInputs {
filing_status: FilingStatus::Single,
..Default::default()
};
ri.header.taxpayer.date_of_birth = Some(time::macros::date!(1980 - 01 - 01));
ri.schedule_a = Some(ScheduleAInputs {
mortgage_interest_1098: interest,
mortgage_all_used_to_buy_build_improve: answer,
..Default::default()
});
ri
}
#[test]
fn mixed_use_mortgage_advisory_fires_on_declared_no() {
let ri = mixed_use_ri(Some(false), dec!(20000));
let itemized = advisories(
&ri,
&LedgerState::default(),
dec!(150000),
dec!(150000),
Usd::ZERO,
¶ms(),
2024,
true,
);
assert!(itemized.contains(&Advisory::MixedUseMortgageNotAllocated {
forgone_interest: dec!(20000),
itemized: true,
}));
let standard = advisories(
&ri,
&LedgerState::default(),
dec!(150000),
dec!(150000),
Usd::ZERO,
¶ms(),
2024,
false,
);
assert!(standard.contains(&Advisory::MixedUseMortgageNotAllocated {
forgone_interest: dec!(20000),
itemized: false,
}));
}
#[test]
fn mixed_use_mortgage_advisory_quiet_unless_declared_no() {
let quiet = |ri: &ReturnInputs| {
!advisories(
ri,
&LedgerState::default(),
dec!(150000),
dec!(150000),
Usd::ZERO,
¶ms(),
2024,
true,
)
.iter()
.any(|a| matches!(a, Advisory::MixedUseMortgageNotAllocated { .. }))
};
assert!(
quiet(&mixed_use_ri(Some(true), dec!(20000))),
"acquisition-only"
);
assert!(quiet(&mixed_use_ri(None, dec!(20000))), "unanswered");
assert!(
quiet(&mixed_use_ri(Some(false), Usd::ZERO)),
"no interest ⇒ not live"
);
let no_sched_a = ReturnInputs {
filing_status: FilingStatus::Single,
..Default::default()
};
assert!(quiet(&no_sched_a), "no Schedule A");
}
#[test]
fn blind_advisory_counts_taxpayer_and_mfj_spouse_and_fires_on_none() {
let dob = time::macros::date!(1980 - 01 - 01); let go = |ri: &ReturnInputs| {
advisories(
ri,
&LedgerState::default(),
dec!(150000),
dec!(150000),
Usd::ZERO,
¶ms(),
2024,
false,
)
};
let has_blind = |ri: &ReturnInputs, per_box, persons| {
go(ri).contains(&Advisory::BlindBoxForfeitedNotDeclared { per_box, persons })
};
let mut single = ReturnInputs {
filing_status: FilingStatus::Single,
..Default::default()
};
single.header.taxpayer.date_of_birth = Some(dob);
assert!(has_blind(&single, dec!(1950), 1));
let mut declared = single.clone();
declared.header.taxpayer.blind = Some(false);
assert!(
!go(&declared)
.iter()
.any(|a| matches!(a, Advisory::BlindBoxForfeitedNotDeclared { .. })),
"declared-not-blind must be silent"
);
let mut mfj = ReturnInputs {
filing_status: FilingStatus::Mfj,
..Default::default()
};
mfj.header.taxpayer.date_of_birth = Some(dob);
assert!(
has_blind(&mfj, dec!(1550), 2),
"MFJ absent spouse forfeits too"
);
let mut mfs = ReturnInputs {
filing_status: FilingStatus::Mfs,
..Default::default()
};
mfs.header.taxpayer.date_of_birth = Some(dob);
mfs.header.spouse = Some(Default::default());
assert!(
has_blind(&mfs, dec!(1550), 1),
"MFS: spouse box is not this filer's"
);
}
#[test]
fn sales_tax_election_advisory_fires_on_none_with_a_schedule_a() {
let mut ri = mixed_use_ri(Some(true), dec!(1));
let go = |ri: &ReturnInputs| {
advisories(
ri,
&LedgerState::default(),
dec!(150000),
dec!(150000),
Usd::ZERO,
¶ms(),
2024,
false,
)
};
assert!(go(&ri).contains(&Advisory::SalesTaxElectionNotAsked { itemized: false }));
ri.schedule_a.as_mut().unwrap().salt_use_sales_tax = Some(false);
assert!(!go(&ri).contains(&Advisory::SalesTaxElectionNotAsked { itemized: false }));
let no_a = ReturnInputs {
filing_status: FilingStatus::Single,
..Default::default()
};
assert!(!go(&no_a).contains(&Advisory::SalesTaxElectionNotAsked { itemized: false }));
}
#[test]
fn mixed_use_mortgage_advisory_text_branches_on_deduction_taken() {
let itemized = Advisory::MixedUseMortgageNotAllocated {
forgone_interest: dec!(20000),
itemized: true,
}
.message();
assert!(itemized.contains("Schedule A"), "{itemized}");
assert!(itemized.contains("line 8a"), "{itemized}");
assert!(
itemized.contains("up to $20,000"),
"the ceiling, comma-grouped: {itemized}"
);
assert!(itemized.contains("OVERSTATED"), "{itemized}");
let standard = Advisory::MixedUseMortgageNotAllocated {
forgone_interest: dec!(20000),
itemized: false,
}
.message();
assert!(standard.contains("standard deduction"), "{standard}");
assert!(standard.contains("up to $20,000"), "{standard}");
assert!(
!standard.contains("Your Schedule A claimed"),
"the standard branch must not describe a form the filer did not file: {standard}"
);
}
#[test]
fn blind_and_sales_tax_advisories_name_the_stakes() {
let blind = Advisory::BlindBoxForfeitedNotDeclared {
per_box: dec!(1950),
persons: 1,
}
.message();
assert!(blind.contains("$1,950"), "thousands-separated: {blind}");
assert!(blind.contains("§63(f)"), "{blind}");
assert!(blind.contains("OVERSTATED"), "{blind}");
let salt = Advisory::SalesTaxElectionNotAsked { itemized: true }.message();
assert!(
salt.contains("§164(b)(5)") || salt.contains("sales tax"),
"{salt}"
);
assert!(salt.contains("OVERSTATED"), "{salt}");
assert!(salt.contains("income answer"), "names the exit: {salt}");
}
#[test]
fn sales_tax_advisory_does_not_describe_a_form_the_standard_filer_did_not_file() {
let itemized = Advisory::SalesTaxElectionNotAsked { itemized: true }.message();
assert!(
itemized.contains("your Schedule A used"),
"the itemized filer DID file a Schedule A: {itemized}"
);
let standard = Advisory::SalesTaxElectionNotAsked { itemized: false }.message();
assert!(
!standard.contains("your Schedule A used"),
"the standard filer filed NO Schedule A — do not say it 'used' income taxes: {standard}"
);
assert!(standard.contains("standard deduction"), "{standard}");
assert!(standard.contains("OVERSTATED"), "{standard}");
}
}