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),
pub neutral: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuestionId {
DependentTaxpayer,
DependentSpouse,
MfsSpouseItemizes,
ForeignAccounts,
ForeignTrust,
HsaActivity,
DualStatusAlien,
MortgageAllUsedToBuyBuildImprove,
AmtQualifiedDwelling,
AmtCarryoverSameAsRegular,
AmtDepreciationSameAsRegular,
}
impl QuestionId {
pub const ALL: &'static [QuestionId] = &[
QuestionId::DependentTaxpayer,
QuestionId::DependentSpouse,
QuestionId::MfsSpouseItemizes,
QuestionId::ForeignAccounts,
QuestionId::ForeignTrust,
QuestionId::HsaActivity,
QuestionId::DualStatusAlien,
QuestionId::MortgageAllUsedToBuyBuildImprove,
QuestionId::AmtQualifiedDwelling,
QuestionId::AmtCarryoverSameAsRegular,
QuestionId::AmtDepreciationSameAsRegular,
];
}
pub fn question_is_live(id: QuestionId, ri: &ReturnInputs) -> bool {
FORM_QUESTIONS
.iter()
.find(|q| q.id == id)
.is_some_and(|q| (q.live)(ri))
}
fn amt_carryover_question_live(ri: &ReturnInputs) -> bool {
let cf = ri.capital_loss_carryforward_in;
cf.short > Usd::ZERO || cf.long > Usd::ZERO
}
fn amt_depreciation_question_live(ri: &ReturnInputs) -> bool {
ri.schedule_c
.as_ref()
.is_some_and(|c| c.expenses > Usd::ZERO)
}
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),
neutral: false,
},
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),
neutral: false,
},
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),
neutral: false,
},
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),
neutral: false,
},
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),
neutral: false,
},
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),
neutral: false,
},
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),
neutral: false,
},
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);
}
},
neutral: true, },
FormQuestion {
id: QuestionId::AmtQualifiedDwelling,
prompt: "Is the home your Form 1098 mortgage interest relates to a principal residence, or a \
house, apartment, condominium or mobile home NOT used on a transient basis? (Form 6251 \
line 3 — a houseboat or recreational vehicle is NOT an AMT-qualified dwelling.)",
unanswered: RefuseReason::AmtQualifiedDwellingUnanswered,
unanswered_detail:
"this Schedule A reports mortgage interest, so Form 6251 line 3 must know whether the dwelling \
is AMT-qualified — interest on a dwelling that is not a principal residence or an \
AMT-qualified dwelling is ADDED BACK for the alternative minimum tax (i6251, Line 3). \
Guessing would understate the tax — run `btctax income answer`",
live: mortgage_question_live,
get: |ri| {
ri.schedule_a
.as_ref()
.and_then(|a| a.mortgage_dwelling_is_amt_qualified)
},
set: |ri, v| {
if let Some(a) = ri.schedule_a.as_mut() {
a.mortgage_dwelling_is_amt_qualified = Some(v);
}
},
neutral: true, },
FormQuestion {
id: QuestionId::AmtCarryoverSameAsRegular,
prompt: "Is your capital-loss carryover for the alternative minimum tax the SAME as your \
regular-tax carryover? (Form 6251 line 2k — answer no if you have ever tracked a \
separate AMT basis or AMT capital-loss carryforward.)",
unanswered: RefuseReason::AmtCarryoverDeclarationUnanswered,
unanswered_detail:
"this return carries a capital-loss carryforward, so Form 6251 line 2k must know whether the \
AMT carryover differs from the regular-tax one — btctax tracks only the regular figure, and \
a divergent AMT twin is an ADD-BACK. Guessing would understate the tax — run \
`btctax income answer`",
live: amt_carryover_question_live,
get: |ri| ri.amt_carryover_same_as_regular,
set: |ri, v| ri.amt_carryover_same_as_regular = Some(v),
neutral: true, },
FormQuestion {
id: QuestionId::AmtDepreciationSameAsRegular,
prompt: "Is the depreciation included in your Schedule C expenses the SAME for the alternative \
minimum tax as for the regular tax? (Form 6251 line 2l.) Answer YES only if one of \
these is true of EVERY depreciable asset in that total: you claimed no depreciation at \
all, and none was capitalized into inventory; or you deducted its whole cost under \
section 179; or it was placed in service after 2015 AND qualified for bonus \
depreciation; or it is depreciated STRAIGHT-LINE FOR THE REGULAR TAX and was placed in \
service after 1998; or you elected \
ADS for it. Answer NO if any asset falls outside that list — in particular anything \
placed in service before 1999, 200% declining-balance property from 1999-2015, and \
land improvements or other section 1250 property depreciated 150% declining balance. \
If you are unsure, answer NO: that refuses the return rather than risking an \
understated tax.",
unanswered: RefuseReason::AmtDepreciationDeclarationUnanswered,
unanswered_detail:
"this return carries Schedule C expenses, and btctax accepts that as a FLAT TOTAL — it never \
sees Part II line 13 ('Depreciation and section 179 expense deduction'), so it cannot tell \
whether a Form 6251 line 2l adjustment is hiding inside it. A divergent AMT amount is an \
ADD-BACK, so it is never guessed away: the prompt lists the conditions that permit a yes, \
and if none clearly applies the answer is NO. Guessing yes would understate the tax — run \
`btctax income answer`",
live: amt_depreciation_question_live,
get: |ri| ri.amt_depreciation_same_as_regular,
set: |ri, v| ri.amt_depreciation_same_as_regular = Some(v),
neutral: true, },
];
#[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,
QuestionId::AmtQualifiedDwelling => 8,
QuestionId::AmtCarryoverSameAsRegular => 9,
QuestionId::AmtDepreciationSameAsRegular => 10,
};
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(), 11, "there are 11 declarations");
assert_eq!(FORM_QUESTIONS.len(), 11, "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"
);
}
}
}