use crate::conventions::Usd;
use crate::tax::return_inputs::ReturnInputs;
use crate::tax::return_refuse::RefuseReason;
use crate::tax::types::FilingStatus;
use time::Date;
pub struct FormQuestion {
pub id: QuestionId,
pub prompt: &'static str,
pub unanswered: RefuseReason,
pub unanswered_detail: &'static str,
pub live: fn(&ReturnInputs) -> bool,
pub get: fn(&ReturnInputs) -> Option<bool>,
pub set: fn(&mut ReturnInputs, bool),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuestionId {
DependentTaxpayer,
DependentSpouse,
MfsSpouseItemizes,
ForeignAccounts,
ForeignTrust,
HsaActivity,
DualStatusAlien,
MortgageAllUsedToBuyBuildImprove,
}
impl QuestionId {
pub const ALL: &'static [QuestionId] = &[
QuestionId::DependentTaxpayer,
QuestionId::DependentSpouse,
QuestionId::MfsSpouseItemizes,
QuestionId::ForeignAccounts,
QuestionId::ForeignTrust,
QuestionId::HsaActivity,
QuestionId::DualStatusAlien,
QuestionId::MortgageAllUsedToBuyBuildImprove,
];
}
fn mortgage_question_live(ri: &ReturnInputs) -> bool {
ri.schedule_a
.as_ref()
.is_some_and(|a| a.mortgage_interest_1098 > Usd::ZERO)
}
pub const FORM_QUESTIONS: &[FormQuestion] = &[
FormQuestion {
id: QuestionId::DependentTaxpayer,
prompt: "Can someone claim YOU as a dependent on their return?",
unanswered: RefuseReason::DependentStatusUnanswered,
unanswered_detail:
"every return must state whether someone can claim YOU as a dependent (it selects the \
§63(c)(5) standard-deduction floor and is a checkbox on the 1040) — run `btctax income answer`",
live: |_ri| true,
get: |ri| ri.header.can_be_claimed_as_dependent_taxpayer,
set: |ri, v| ri.header.can_be_claimed_as_dependent_taxpayer = Some(v),
},
FormQuestion {
id: QuestionId::DependentSpouse,
prompt: "Can someone claim YOUR SPOUSE as a dependent on their return?",
unanswered: RefuseReason::DependentSpouseStatusUnanswered,
unanswered_detail:
"this return has (or is) a joint filing, so it must state whether someone can claim YOUR \
SPOUSE as a dependent (it is a checkbox on the 1040) — run `btctax income answer`",
live: |ri| ri.filing_status == FilingStatus::Mfj || ri.header.spouse.is_some(),
get: |ri| ri.header.can_be_claimed_as_dependent_spouse,
set: |ri, v| ri.header.can_be_claimed_as_dependent_spouse = Some(v),
},
FormQuestion {
id: QuestionId::MfsSpouseItemizes,
prompt: "Does your spouse ITEMIZE deductions on their separate return? (§63(c)(6) forces your \
choice to match theirs)",
unanswered: RefuseReason::MfsSpouseItemizeUnknown,
unanswered_detail:
"a married-filing-separately return must state whether the spouse itemizes (§63(c)(6)) — \
run `btctax income answer`",
live: |ri| ri.filing_status == FilingStatus::Mfs,
get: |ri| ri.mfs_spouse_itemizes,
set: |ri, v| ri.mfs_spouse_itemizes = Some(v),
},
FormQuestion {
id: QuestionId::ForeignAccounts,
prompt: "Schedule B line 7a: did you have a financial interest in, or signature authority over, \
a FOREIGN financial account?",
unanswered: RefuseReason::ScheduleBPart3Unanswered,
unanswered_detail:
"Schedule B Part III line 7a (a foreign financial account) must be answered on every return — \
it is the FBAR/FinCEN disclosure, and its own answer is what decides whether Schedule B files — \
run `btctax income answer`",
live: |_ri| true,
get: |ri| ri.foreign_accounts,
set: |ri, v| ri.foreign_accounts = Some(v),
},
FormQuestion {
id: QuestionId::ForeignTrust,
prompt: "Schedule B line 8: did you receive a distribution from — or were you the grantor of, or \
transferor to — a FOREIGN TRUST?",
unanswered: RefuseReason::ScheduleBPart3Unanswered,
unanswered_detail:
"Schedule B Part III line 8 (a foreign trust) must be answered on every return — a foreign \
trust independently requires Part III, so it cannot be scoped by whether Schedule B otherwise \
files — run `btctax income answer`",
live: |_ri| true,
get: |ri| ri.foreign_trust,
set: |ri, v| ri.foreign_trust = Some(v),
},
FormQuestion {
id: QuestionId::HsaActivity,
prompt: "In this tax year, did ANY of these happen with a health savings account? — (a) anyone \
(you, your employer, or anyone else on your behalf) put money into one for you; (b) you \
took money out of one; (c) you inherited one; or (d) you stopped being HSA-eligible after \
using the last-month rule or an IRA-to-HSA funding distribution in a prior year.",
unanswered: RefuseReason::HsaActivityUnanswered,
unanswered_detail:
"a return must state whether a Form 8889 trigger fired for a health savings account (a \
contribution by anyone, a distribution, a testing-period inclusion, or an inheritance) — an \
unasked distribution omits gross income and a 20% additional tax (§223(f)) — run `btctax \
income answer`",
live: |_ri| true,
get: |ri| ri.sch1.hsa_activity,
set: |ri, v| ri.sch1.hsa_activity = Some(v),
},
FormQuestion {
id: QuestionId::DualStatusAlien,
prompt: "Were you a DUAL-STATUS ALIEN this year (a nonresident alien for part of the year and a \
resident for the rest)?",
unanswered: RefuseReason::DualStatusAlienUnanswered,
unanswered_detail:
"a return must state whether you were a dual-status alien — the 1040 header prints that box, \
and §63(c)(6)(B) zeroes a nonresident alien's standard deduction — run `btctax income answer`",
live: |_ri| true,
get: |ri| ri.dual_status_alien,
set: |ri, v| ri.dual_status_alien = Some(v),
},
FormQuestion {
id: QuestionId::MortgageAllUsedToBuyBuildImprove,
prompt: "Did you use ALL of your home-mortgage loan(s) to buy, build, or improve that home? \
(Schedule A line 8: if not, the box is checked.)",
unanswered: RefuseReason::MixedUseMortgageUnanswered,
unanswered_detail:
"this Schedule A reports mortgage interest, so it must state whether the loan(s) were all used \
to buy, build, or improve the home (§163(h)(3)(F) — Schedule A line 8) — run `btctax income \
answer`",
live: mortgage_question_live,
get: |ri| {
ri.schedule_a
.as_ref()
.and_then(|a| a.mortgage_all_used_to_buy_build_improve)
},
set: |ri, v| {
if let Some(a) = ri.schedule_a.as_mut() {
a.mortgage_all_used_to_buy_build_improve = Some(v);
}
},
},
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkippableId {
BlindTaxpayer,
BlindSpouse,
SalesTaxElection,
DobTaxpayer,
DobSpouse,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkippableKind {
YesNo,
Date,
}
pub struct SkippableQuestion {
pub id: SkippableId,
pub prompt: &'static str,
pub help: &'static str,
pub kind: SkippableKind,
pub live: fn(&ReturnInputs) -> bool,
pub get_bool: fn(&ReturnInputs) -> Option<bool>,
pub set_bool: fn(&mut ReturnInputs, bool),
pub get_date: fn(&ReturnInputs) -> Option<Date>,
pub set_date: fn(&mut ReturnInputs, Date),
}
pub const SKIPPABLE_QUESTIONS: &[SkippableQuestion] = &[
SkippableQuestion {
id: SkippableId::BlindTaxpayer,
prompt: "Are YOU legally blind? (§63(f) additional deduction)",
help: "§63(f): legal blindness adds an extra standard-deduction amount. Skipping leaves it \
unclaimed — lawful, since the burden to claim is yours — and the forgone-benefit advisory fires.",
kind: SkippableKind::YesNo,
live: |_ri| true,
get_bool: |ri| ri.header.taxpayer.blind,
set_bool: |ri, v| ri.header.taxpayer.blind = Some(v),
get_date: |_ri| None,
set_date: |_ri, _v| {},
},
SkippableQuestion {
id: SkippableId::BlindSpouse,
prompt: "Is YOUR SPOUSE legally blind? (§63(f) additional deduction)",
help: "§63(f): the spouse's legal blindness adds an extra standard-deduction amount. Skipping \
leaves it unclaimed and the forgone-benefit advisory fires.",
kind: SkippableKind::YesNo,
live: |ri| ri.header.spouse.is_some(),
get_bool: |ri| ri.header.spouse.as_ref().and_then(|s| s.blind),
set_bool: |ri, v| {
if let Some(sp) = ri.header.spouse.as_mut() {
sp.blind = Some(v);
}
},
get_date: |_ri| None,
set_date: |_ri, _v| {},
},
SkippableQuestion {
id: SkippableId::SalesTaxElection,
prompt: "Deduct general SALES taxes instead of state/local income taxes? (§164(b)(5))",
help: "§164(b)(5): elect to deduct general sales taxes instead of state and local income taxes. \
Skipping keeps income taxes on the return; the election is advised when a Schedule A exists.",
kind: SkippableKind::YesNo,
live: |ri| ri.schedule_a.is_some(),
get_bool: |ri| ri.schedule_a.as_ref().and_then(|a| a.salt_use_sales_tax),
set_bool: |ri, v| {
if let Some(a) = ri.schedule_a.as_mut() {
a.salt_use_sales_tax = Some(v);
}
},
get_date: |_ri| None,
set_date: |_ri, _v| {},
},
SkippableQuestion {
id: SkippableId::DobTaxpayer,
prompt: "YOUR date of birth",
help: "§63(f): your date of birth establishes the age-65 additional standard deduction. Skipping \
leaves it unclaimed — a mandatory prompt would force you to invent a birthday, so silence stays reachable.",
kind: SkippableKind::Date,
live: |_ri| true,
get_bool: |_ri| None,
set_bool: |_ri, _v| {},
get_date: |ri| ri.header.taxpayer.date_of_birth,
set_date: |ri, v| ri.header.taxpayer.date_of_birth = Some(v),
},
SkippableQuestion {
id: SkippableId::DobSpouse,
prompt: "YOUR SPOUSE's date of birth",
help: "§63(f): the spouse's date of birth establishes the age-65 additional standard deduction. \
Skipping leaves it unclaimed.",
kind: SkippableKind::Date,
live: |ri| ri.header.spouse.is_some(),
get_bool: |_ri| None,
set_bool: |_ri, _v| {},
get_date: |ri| ri.header.spouse.as_ref().and_then(|s| s.date_of_birth),
set_date: |ri, v| {
if let Some(sp) = ri.header.spouse.as_mut() {
sp.date_of_birth = Some(v);
}
},
},
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_question_id_is_in_all_in_order_and_has_exactly_one_entry() {
for (i, id) in QuestionId::ALL.iter().enumerate() {
let idx = match id {
QuestionId::DependentTaxpayer => 0,
QuestionId::DependentSpouse => 1,
QuestionId::MfsSpouseItemizes => 2,
QuestionId::ForeignAccounts => 3,
QuestionId::ForeignTrust => 4,
QuestionId::HsaActivity => 5,
QuestionId::DualStatusAlien => 6,
QuestionId::MortgageAllUsedToBuyBuildImprove => 7,
};
assert_eq!(idx, i, "QuestionId::ALL is out of order / missing {id:?}");
assert_eq!(
FORM_QUESTIONS.iter().filter(|q| q.id == *id).count(),
1,
"exactly one FORM_QUESTIONS entry for {id:?}"
);
}
assert_eq!(QuestionId::ALL.len(), 8, "there are 8 declarations");
assert_eq!(FORM_QUESTIONS.len(), 8, "one entry per declaration");
}
#[test]
fn skippable_registry_is_separate_and_has_five_entries_with_correct_liveness() {
use crate::tax::types::FilingStatus;
assert_eq!(SKIPPABLE_QUESTIONS.len(), 5, "blind ×2, SALT, DOB ×2");
let salt = SKIPPABLE_QUESTIONS
.iter()
.find(|s| s.id == SkippableId::SalesTaxElection)
.unwrap();
let mut ri = ReturnInputs {
filing_status: FilingStatus::Single,
..Default::default()
};
assert!(!(salt.live)(&ri));
ri.schedule_a = Some(Default::default());
assert!((salt.live)(&ri));
for s in SKIPPABLE_QUESTIONS {
assert!(
!FORM_QUESTIONS
.iter()
.any(|q| format!("{:?}", q.id) == format!("{:?}", s.id)),
"a skippable must not also be a mandatory FORM_QUESTIONS declaration"
);
}
}
}