envelope_cli/services/
income.rs

1//! Income service
2//!
3//! Provides business logic for managing expected income per budget period.
4
5use crate::audit::EntityType;
6use crate::error::{EnvelopeError, EnvelopeResult};
7use crate::models::{BudgetPeriod, IncomeExpectation, Money};
8use crate::storage::Storage;
9
10/// Service for income expectation management
11pub struct IncomeService<'a> {
12    storage: &'a Storage,
13}
14
15impl<'a> IncomeService<'a> {
16    /// Create a new income service
17    pub fn new(storage: &'a Storage) -> Self {
18        Self { storage }
19    }
20
21    /// Set expected income for a period
22    pub fn set_expected_income(
23        &self,
24        period: &BudgetPeriod,
25        amount: Money,
26        notes: Option<String>,
27    ) -> EnvelopeResult<IncomeExpectation> {
28        // Check if there's an existing expectation
29        if let Some(existing) = self.storage.income.get_for_period(period) {
30            let mut updated = existing.clone();
31            let before = existing.clone();
32
33            updated.set_expected_amount(amount);
34            if let Some(n) = notes {
35                updated.set_notes(n);
36            }
37
38            // Validate
39            updated
40                .validate()
41                .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
42
43            // Save
44            self.storage.income.upsert(updated.clone())?;
45            self.storage.income.save()?;
46
47            // Audit update
48            self.storage.log_update(
49                EntityType::IncomeExpectation,
50                updated.id.to_string(),
51                Some(format!("Income for {}", period)),
52                &before,
53                &updated,
54                Some(format!(
55                    "{} -> {}",
56                    before.expected_amount, updated.expected_amount
57                )),
58            )?;
59
60            Ok(updated)
61        } else {
62            let mut expectation = IncomeExpectation::new(period.clone(), amount);
63            if let Some(n) = notes {
64                expectation.set_notes(n);
65            }
66
67            // Validate
68            expectation
69                .validate()
70                .map_err(|e| EnvelopeError::Validation(e.to_string()))?;
71
72            // Save
73            self.storage.income.upsert(expectation.clone())?;
74            self.storage.income.save()?;
75
76            // Audit create
77            self.storage.log_create(
78                EntityType::IncomeExpectation,
79                expectation.id.to_string(),
80                Some(format!("Income for {}", period)),
81                &expectation,
82            )?;
83
84            Ok(expectation)
85        }
86    }
87
88    /// Get expected income amount for a period
89    pub fn get_expected_income(&self, period: &BudgetPeriod) -> Option<Money> {
90        self.storage
91            .income
92            .get_for_period(period)
93            .map(|e| e.expected_amount)
94    }
95
96    /// Get the full income expectation for a period
97    pub fn get_income_expectation(&self, period: &BudgetPeriod) -> Option<IncomeExpectation> {
98        self.storage.income.get_for_period(period)
99    }
100
101    /// Delete income expectation for a period
102    pub fn delete_expected_income(&self, period: &BudgetPeriod) -> EnvelopeResult<bool> {
103        if let Some(removed) = self.storage.income.delete_for_period(period) {
104            self.storage.income.save()?;
105
106            // Audit delete
107            self.storage.log_delete(
108                EntityType::IncomeExpectation,
109                removed.id.to_string(),
110                Some(format!("Income for {}", period)),
111                &removed,
112            )?;
113
114            Ok(true)
115        } else {
116            Ok(false)
117        }
118    }
119
120    /// Get all income expectations
121    pub fn get_all_expectations(&self) -> EnvelopeResult<Vec<IncomeExpectation>> {
122        self.storage.income.get_all()
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::config::paths::EnvelopePaths;
130    use tempfile::TempDir;
131
132    fn create_test_storage() -> (TempDir, Storage) {
133        let temp_dir = TempDir::new().unwrap();
134        let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
135        let mut storage = Storage::new(paths).unwrap();
136        storage.load_all().unwrap();
137        (temp_dir, storage)
138    }
139
140    #[test]
141    fn test_set_expected_income() {
142        let (_temp_dir, storage) = create_test_storage();
143        let service = IncomeService::new(&storage);
144        let period = BudgetPeriod::monthly(2025, 1);
145
146        let expectation = service
147            .set_expected_income(&period, Money::from_cents(500000), None)
148            .unwrap();
149
150        assert_eq!(expectation.expected_amount.cents(), 500000);
151        assert_eq!(expectation.period, period);
152    }
153
154    #[test]
155    fn test_update_expected_income() {
156        let (_temp_dir, storage) = create_test_storage();
157        let service = IncomeService::new(&storage);
158        let period = BudgetPeriod::monthly(2025, 1);
159
160        // Set initial
161        service
162            .set_expected_income(&period, Money::from_cents(500000), None)
163            .unwrap();
164
165        // Update
166        let updated = service
167            .set_expected_income(
168                &period,
169                Money::from_cents(600000),
170                Some("Updated".to_string()),
171            )
172            .unwrap();
173
174        assert_eq!(updated.expected_amount.cents(), 600000);
175        assert_eq!(updated.notes, "Updated");
176    }
177
178    #[test]
179    fn test_get_expected_income() {
180        let (_temp_dir, storage) = create_test_storage();
181        let service = IncomeService::new(&storage);
182        let period = BudgetPeriod::monthly(2025, 1);
183
184        // No income set
185        assert!(service.get_expected_income(&period).is_none());
186
187        // Set income
188        service
189            .set_expected_income(&period, Money::from_cents(500000), None)
190            .unwrap();
191
192        // Now it should be Some
193        let income = service.get_expected_income(&period).unwrap();
194        assert_eq!(income.cents(), 500000);
195    }
196
197    #[test]
198    fn test_delete_expected_income() {
199        let (_temp_dir, storage) = create_test_storage();
200        let service = IncomeService::new(&storage);
201        let period = BudgetPeriod::monthly(2025, 1);
202
203        // Set and delete
204        service
205            .set_expected_income(&period, Money::from_cents(500000), None)
206            .unwrap();
207
208        let deleted = service.delete_expected_income(&period).unwrap();
209        assert!(deleted);
210
211        // Should be gone
212        assert!(service.get_expected_income(&period).is_none());
213
214        // Deleting again should return false
215        let deleted_again = service.delete_expected_income(&period).unwrap();
216        assert!(!deleted_again);
217    }
218
219    #[test]
220    fn test_negative_amount_rejected() {
221        let (_temp_dir, storage) = create_test_storage();
222        let service = IncomeService::new(&storage);
223        let period = BudgetPeriod::monthly(2025, 1);
224
225        let result = service.set_expected_income(&period, Money::from_cents(-100), None);
226        assert!(result.is_err());
227    }
228}