Skip to content

Commit 4ebe22a

Browse files
committed
✨ feat: add expected income feature with budget warnings
Implement comprehensive expected income system allowing users to: - Set expected income per budget period - Receive warnings when budgeted amounts exceed expected income - View income vs budget comparisons in CLI and TUI - Manage income expectations through new CLI commands Includes models, storage, services, CLI integration, and TUI updates
1 parent 1fc1a7c commit 4ebe22a

17 files changed

Lines changed: 1836 additions & 5 deletions

File tree

INCOME_FEATURE_IMPLEMENTATION.md

Lines changed: 900 additions & 0 deletions
Large diffs are not rendered by default.

src/audit/entry.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ pub enum EntityType {
3939
BudgetAllocation,
4040
BudgetTarget,
4141
Payee,
42+
IncomeExpectation,
4243
}
4344

4445
impl std::fmt::Display for EntityType {
@@ -51,6 +52,7 @@ impl std::fmt::Display for EntityType {
5152
EntityType::BudgetAllocation => write!(f, "BudgetAllocation"),
5253
EntityType::BudgetTarget => write!(f, "BudgetTarget"),
5354
EntityType::Payee => write!(f, "Payee"),
55+
EntityType::IncomeExpectation => write!(f, "IncomeExpectation"),
5456
}
5557
}
5658
}

src/cli/income.rs

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
//! Income CLI commands
2+
//!
3+
//! Implements CLI commands for managing expected income per budget period.
4+
5+
use clap::Subcommand;
6+
7+
use crate::config::settings::Settings;
8+
use crate::error::{EnvelopeError, EnvelopeResult};
9+
use crate::models::Money;
10+
use crate::services::{BudgetService, IncomeService, PeriodService};
11+
use crate::storage::Storage;
12+
13+
/// Income subcommands
14+
#[derive(Subcommand)]
15+
pub enum IncomeCommands {
16+
/// Set expected income for a period
17+
Set {
18+
/// Expected income amount (e.g., "5000" or "5000.00")
19+
amount: String,
20+
21+
/// Budget period (e.g., "2025-01" for January 2025)
22+
#[arg(short, long)]
23+
period: Option<String>,
24+
25+
/// Notes about this income expectation
26+
#[arg(short, long)]
27+
notes: Option<String>,
28+
},
29+
30+
/// Show expected income for a period
31+
Show {
32+
/// Budget period (defaults to current month)
33+
#[arg(short, long)]
34+
period: Option<String>,
35+
},
36+
37+
/// Remove expected income for a period
38+
Remove {
39+
/// Budget period
40+
#[arg(short, long)]
41+
period: Option<String>,
42+
},
43+
44+
/// Compare expected income vs budgeted amounts
45+
Compare {
46+
/// Budget period (defaults to current month)
47+
#[arg(short, long)]
48+
period: Option<String>,
49+
},
50+
}
51+
52+
/// Handle an income command
53+
pub fn handle_income_command(
54+
storage: &Storage,
55+
settings: &Settings,
56+
cmd: IncomeCommands,
57+
) -> EnvelopeResult<()> {
58+
let period_service = PeriodService::new(settings);
59+
let income_service = IncomeService::new(storage);
60+
let budget_service = BudgetService::new(storage);
61+
62+
match cmd {
63+
IncomeCommands::Set {
64+
amount,
65+
period,
66+
notes,
67+
} => {
68+
let period = period_service.parse_or_current(period.as_deref())?;
69+
let amount = Money::parse(&amount)
70+
.map_err(|e| EnvelopeError::Validation(format!("Invalid amount: {}", e)))?;
71+
let friendly = period_service.format_period_friendly(&period);
72+
73+
let expectation = income_service.set_expected_income(&period, amount, notes)?;
74+
75+
println!(
76+
"Set expected income for {} to {}",
77+
friendly, expectation.expected_amount
78+
);
79+
80+
// Show comparison if budget exists
81+
if let Some(overage) = budget_service.is_over_expected_income(&period)? {
82+
println!(
83+
"Warning: You're budgeting {} more than expected income!",
84+
overage
85+
);
86+
} else if let Some(remaining) =
87+
budget_service.get_remaining_to_budget_from_income(&period)?
88+
{
89+
if remaining.is_positive() {
90+
println!("Remaining to budget from income: {}", remaining);
91+
} else {
92+
println!("Budget matches expected income.");
93+
}
94+
}
95+
}
96+
97+
IncomeCommands::Show { period } => {
98+
let period = period_service.parse_or_current(period.as_deref())?;
99+
let friendly = period_service.format_period_friendly(&period);
100+
101+
if let Some(expectation) = income_service.get_income_expectation(&period) {
102+
println!("Expected Income for {}", friendly);
103+
println!("{}", "-".repeat(40));
104+
println!("Amount: {}", expectation.expected_amount);
105+
if !expectation.notes.is_empty() {
106+
println!("Notes: {}", expectation.notes);
107+
}
108+
109+
// Show budget comparison
110+
let overview = budget_service.get_budget_overview(&period)?;
111+
println!();
112+
println!("Budget Comparison:");
113+
println!(" Expected Income: {}", expectation.expected_amount);
114+
println!(" Total Budgeted: {}", overview.total_budgeted);
115+
116+
let diff = expectation.expected_amount - overview.total_budgeted;
117+
if diff.is_negative() {
118+
println!(" Over Budget: {} ⚠", diff.abs());
119+
} else {
120+
println!(" Remaining: {} ✓", diff);
121+
}
122+
} else {
123+
println!("No expected income set for {}", friendly);
124+
println!("Use 'envelope income set <amount>' to set expected income.");
125+
}
126+
}
127+
128+
IncomeCommands::Remove { period } => {
129+
let period = period_service.parse_or_current(period.as_deref())?;
130+
let friendly = period_service.format_period_friendly(&period);
131+
132+
if income_service.delete_expected_income(&period)? {
133+
println!("Removed expected income for {}", friendly);
134+
} else {
135+
println!("No expected income was set for {}", friendly);
136+
}
137+
}
138+
139+
IncomeCommands::Compare { period } => {
140+
let period = period_service.parse_or_current(period.as_deref())?;
141+
let friendly = period_service.format_period_friendly(&period);
142+
let overview = budget_service.get_budget_overview(&period)?;
143+
144+
println!("Income vs Budget Comparison for {}", friendly);
145+
println!("{}", "=".repeat(50));
146+
147+
if let Some(expectation) = income_service.get_income_expectation(&period) {
148+
println!("Expected Income: {:>12}", expectation.expected_amount);
149+
println!("Total Budgeted: {:>12}", overview.total_budgeted);
150+
println!("{}", "-".repeat(50));
151+
152+
let diff = expectation.expected_amount - overview.total_budgeted;
153+
if diff.is_negative() {
154+
println!("OVER BUDGET: {:>12} ⚠", diff.abs());
155+
println!();
156+
println!("Warning: You're budgeting more than you expect to earn!");
157+
println!("Consider reducing budget allocations or increasing expected income.");
158+
} else if diff.is_zero() {
159+
println!("Remaining to Budget: {:>12} ✓", diff);
160+
println!();
161+
println!("Your budget exactly matches expected income.");
162+
} else {
163+
println!("Remaining to Budget: {:>12} ✓", diff);
164+
println!();
165+
println!("You have {} available to budget.", diff);
166+
}
167+
168+
// Show additional info
169+
if !expectation.notes.is_empty() {
170+
println!();
171+
println!("Notes: {}", expectation.notes);
172+
}
173+
} else {
174+
println!("Expected Income: Not set");
175+
println!("Total Budgeted: {:>12}", overview.total_budgeted);
176+
println!();
177+
println!("Tip: Set expected income with 'envelope income set <amount>'");
178+
}
179+
180+
// Show available to budget (from account balances)
181+
println!();
182+
println!("{}", "-".repeat(50));
183+
println!(
184+
"Available to Budget (from accounts): {:>12}",
185+
overview.available_to_budget
186+
);
187+
}
188+
}
189+
190+
Ok(())
191+
}

src/cli/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub mod category;
1010
pub mod encrypt;
1111
pub mod export;
1212
pub mod import;
13+
pub mod income;
1314
pub mod payee;
1415
pub mod reconcile;
1516
pub mod report;
@@ -24,6 +25,7 @@ pub use category::{handle_category_command, CategoryCommands};
2425
pub use encrypt::{handle_encrypt_command, EncryptCommands};
2526
pub use export::{handle_export_command, ExportCommands};
2627
pub use import::handle_import_command;
28+
pub use income::{handle_income_command, IncomeCommands};
2729
pub use payee::{handle_payee_command, PayeeCommands};
2830
pub use reconcile::{handle_reconcile_command, ReconcileCommands};
2931
pub use report::{handle_report_command, ReportCommands};

src/config/paths.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ impl EnvelopePaths {
105105
self.data_dir().join("targets.json")
106106
}
107107

108+
/// Get the path to income.json (income expectations)
109+
pub fn income_file(&self) -> PathBuf {
110+
self.data_dir().join("income.json")
111+
}
112+
108113
/// Ensure all required directories exist
109114
///
110115
/// Creates:

src/error.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ pub enum EnvelopeError {
7777
/// TUI errors
7878
#[error("TUI error: {0}")]
7979
Tui(String),
80+
81+
/// Income expectation errors
82+
#[error("Income error: {0}")]
83+
Income(String),
8084
}
8185

8286
impl EnvelopeError {
@@ -174,6 +178,7 @@ impl EnvelopeError {
174178
}
175179
Self::Storage(msg) => format!("Storage error: {}", msg),
176180
Self::Tui(msg) => format!("Display error: {}", msg),
181+
Self::Income(msg) => msg.clone(),
177182
}
178183
}
179184

@@ -236,6 +241,10 @@ impl EnvelopeError {
236241
"Try with elevated permissions",
237242
],
238243
Self::Tui(_) => vec!["Try resizing your terminal", "Use CLI commands instead"],
244+
Self::Income(_) => vec![
245+
"Check the expected income amount is positive",
246+
"Run 'envelope income show' to see current income expectations",
247+
],
239248
}
240249
}
241250

@@ -257,6 +266,7 @@ impl EnvelopeError {
257266
Self::InsufficientFunds { .. } => 13,
258267
Self::Storage(_) => 14,
259268
Self::Tui(_) => 15,
269+
Self::Income(_) => 16,
260270
}
261271
}
262272
}

src/main.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ use clap::{Parser, Subcommand};
33

44
use envelope_cli::cli::{
55
handle_account_command, handle_backup_command, handle_budget_command, handle_category_command,
6-
handle_encrypt_command, handle_export_command, handle_import_command, handle_payee_command,
7-
handle_reconcile_command, handle_report_command, handle_target_command,
6+
handle_encrypt_command, handle_export_command, handle_import_command, handle_income_command,
7+
handle_payee_command, handle_reconcile_command, handle_report_command, handle_target_command,
88
handle_transaction_command, handle_transfer_command,
99
};
1010
use envelope_cli::config::{paths::EnvelopePaths, settings::Settings};
@@ -47,6 +47,10 @@ enum Commands {
4747
#[command(subcommand)]
4848
Target(envelope_cli::cli::TargetCommands),
4949

50+
/// Expected income management commands
51+
#[command(subcommand)]
52+
Income(envelope_cli::cli::IncomeCommands),
53+
5054
/// Backup management commands
5155
#[command(subcommand)]
5256
Backup(envelope_cli::cli::BackupCommands),
@@ -135,6 +139,9 @@ fn main() -> Result<()> {
135139
Some(Commands::Target(cmd)) => {
136140
handle_target_command(&storage, &settings, cmd)?;
137141
}
142+
Some(Commands::Income(cmd)) => {
143+
handle_income_command(&storage, &settings, cmd)?;
144+
}
138145
Some(Commands::Backup(cmd)) => {
139146
handle_backup_command(&paths, &settings, cmd)?;
140147
}

src/models/ids.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ define_id!(TransactionId, "txn-");
7676
define_id!(CategoryId, "cat-");
7777
define_id!(CategoryGroupId, "grp-");
7878
define_id!(PayeeId, "pay-");
79+
define_id!(IncomeId, "inc-");
7980

8081
#[cfg(test)]
8182
mod tests {

0 commit comments

Comments
 (0)