koprogo_api/application/use_cases/
fund_use_cases.rs1use crate::application::error::AppError;
20use crate::application::ports::FundRepository;
21use crate::domain::copropriete::resolution::{MajorityType, ResolutionStatus};
22use crate::domain::entities::{Fund, FundKind, FundReassignment};
23use rust_decimal::Decimal;
24use std::sync::Arc;
25use uuid::Uuid;
26
27pub struct FundUseCases {
28 repo: Arc<dyn FundRepository>,
29}
30
31impl FundUseCases {
32 pub fn new(repo: Arc<dyn FundRepository>) -> Self {
33 Self { repo }
34 }
35
36 pub async fn create_working_capital_or_reserve(
40 &self,
41 acp_id: Uuid,
42 kind: FundKind,
43 name: String,
44 ) -> Result<Fund, AppError> {
45 let fund = Fund::new(acp_id, kind, name, None, None)?;
46 self.repo.create(&fund).await
47 }
48
49 pub async fn create_earmarked_fund(
52 &self,
53 acp_id: Uuid,
54 name: String,
55 purpose: String,
56 target_amount: Decimal,
57 majority_used: MajorityType,
58 ) -> Result<Fund, AppError> {
59 let fund = Fund::new_earmarked(acp_id, name, purpose, target_amount, majority_used)?;
60 self.repo.create(&fund).await
61 }
62
63 pub async fn contribute(&self, fund_id: Uuid, amount: Decimal) -> Result<Fund, AppError> {
65 let mut fund = self
66 .repo
67 .find_by_id(fund_id)
68 .await?
69 .ok_or_else(|| AppError::NotFound(format!("fund {fund_id}")))?;
70 fund.contribute(amount)?;
71 self.repo.update(&fund).await
72 }
73
74 pub async fn record_expense(
78 &self,
79 fund_id: Uuid,
80 expense_work_ref: &str,
81 ag_override_status: Option<ResolutionStatus>,
82 ) -> Result<(), AppError> {
83 let fund = self
84 .repo
85 .find_by_id(fund_id)
86 .await?
87 .ok_or_else(|| AppError::NotFound(format!("fund {fund_id}")))?;
88 fund.assert_expense_matches_purpose(expense_work_ref, ag_override_status)?;
89 Ok(())
90 }
91
92 pub async fn reassign(
96 &self,
97 fund_id: Uuid,
98 new_purpose: String,
99 amount: Option<Decimal>,
100 resolution_id: Uuid,
101 resolution_status: ResolutionStatus,
102 ) -> Result<FundReassignment, AppError> {
103 let mut fund = self
104 .repo
105 .find_by_id(fund_id)
106 .await?
107 .ok_or_else(|| AppError::NotFound(format!("fund {fund_id}")))?;
108 let record = fund.reassign(new_purpose, amount, resolution_id, resolution_status)?;
109 self.repo.update(&fund).await?;
110 self.repo.record_reassignment(&record).await?;
111 Ok(record)
112 }
113
114 pub async fn get(&self, fund_id: Uuid) -> Result<Fund, AppError> {
115 self.repo
116 .find_by_id(fund_id)
117 .await?
118 .ok_or_else(|| AppError::NotFound(format!("fund {fund_id}")))
119 }
120
121 pub async fn list_for_acp(&self, acp_id: Uuid) -> Result<Vec<Fund>, AppError> {
122 self.repo.find_by_acp_id(acp_id).await
123 }
124
125 pub async fn list_reassignments(
126 &self,
127 fund_id: Uuid,
128 ) -> Result<Vec<FundReassignment>, AppError> {
129 self.repo.list_reassignments(fund_id).await
130 }
131}
132
133#[cfg(test)]
138mod tests {
139 use super::*;
140 use async_trait::async_trait;
141 use rust_decimal_macros::dec;
142 use std::sync::Mutex;
143
144 #[derive(Default)]
145 struct InMemoryRepo {
146 funds: Mutex<Vec<Fund>>,
147 reassignments: Mutex<Vec<FundReassignment>>,
148 }
149
150 #[async_trait]
151 impl FundRepository for InMemoryRepo {
152 async fn create(&self, fund: &Fund) -> Result<Fund, AppError> {
153 self.funds.lock().unwrap().push(fund.clone());
154 Ok(fund.clone())
155 }
156
157 async fn find_by_id(&self, id: Uuid) -> Result<Option<Fund>, AppError> {
158 Ok(self
159 .funds
160 .lock()
161 .unwrap()
162 .iter()
163 .find(|f| f.id == id)
164 .cloned())
165 }
166
167 async fn find_by_acp_id(&self, acp_id: Uuid) -> Result<Vec<Fund>, AppError> {
168 Ok(self
169 .funds
170 .lock()
171 .unwrap()
172 .iter()
173 .filter(|f| f.acp_id == acp_id)
174 .cloned()
175 .collect())
176 }
177
178 async fn update(&self, fund: &Fund) -> Result<Fund, AppError> {
179 let mut funds = self.funds.lock().unwrap();
180 if let Some(existing) = funds.iter_mut().find(|f| f.id == fund.id) {
181 *existing = fund.clone();
182 }
183 Ok(fund.clone())
184 }
185
186 async fn record_reassignment(
187 &self,
188 reassignment: &FundReassignment,
189 ) -> Result<(), AppError> {
190 self.reassignments
191 .lock()
192 .unwrap()
193 .push(reassignment.clone());
194 Ok(())
195 }
196
197 async fn list_reassignments(
198 &self,
199 fund_id: Uuid,
200 ) -> Result<Vec<FundReassignment>, AppError> {
201 Ok(self
202 .reassignments
203 .lock()
204 .unwrap()
205 .iter()
206 .filter(|r| r.fund_id == fund_id)
207 .cloned()
208 .collect())
209 }
210 }
211
212 fn factory() -> (Arc<InMemoryRepo>, FundUseCases) {
213 let repo: Arc<InMemoryRepo> = Arc::new(InMemoryRepo::default());
214 let uc = FundUseCases::new(repo.clone() as Arc<dyn FundRepository>);
215 (repo, uc)
216 }
217
218 #[tokio::test]
221 async fn happy_earmarked_fund_created_and_fed_towards_its_target() {
222 let (_repo, uc) = factory();
223 let acp_id = Uuid::new_v4();
224
225 let fund = uc
226 .create_earmarked_fund(
227 acp_id,
228 "Toiture".to_string(),
229 "Réfection toiture".to_string(),
230 dec!(50000),
231 MajorityType::TwoThirds,
232 )
233 .await
234 .expect("vote suffisant");
235
236 let fed = uc.contribute(fund.id, dec!(12500)).await.unwrap();
237 assert_eq!(fed.balance, dec!(12500));
238 assert_eq!(fed.progress(), Some(dec!(0.25)));
239 }
240
241 #[tokio::test]
242 async fn happy_reassignment_persists_fund_and_audit_trail() {
243 let (_repo, uc) = factory();
244 let acp_id = Uuid::new_v4();
245 let fund = uc
246 .create_earmarked_fund(
247 acp_id,
248 "Ascenseur".to_string(),
249 "Remplacement ascenseur".to_string(),
250 dec!(20000),
251 MajorityType::TwoThirds,
252 )
253 .await
254 .unwrap();
255 uc.contribute(fund.id, dec!(20000)).await.unwrap();
256 let resolution_id = Uuid::new_v4();
257
258 let record = uc
259 .reassign(
260 fund.id,
261 "Ravalement façade".to_string(),
262 None,
263 resolution_id,
264 ResolutionStatus::Adopted,
265 )
266 .await
267 .expect("réaffectation autorisée par l'AG");
268
269 assert_eq!(record.resolution_id, resolution_id);
270
271 let updated = uc.get(fund.id).await.unwrap();
272 assert_eq!(updated.purpose, Some("Ravalement façade".to_string()));
273
274 let history = uc.list_reassignments(fund.id).await.unwrap();
275 assert_eq!(history.len(), 1);
276 assert_eq!(history[0].previous_purpose, "Remplacement ascenseur");
277 }
278
279 #[tokio::test]
282 async fn edge_working_capital_and_reserve_keep_separate_balances() {
283 let (_repo, uc) = factory();
284 let acp_id = Uuid::new_v4();
285
286 let roulement = uc
287 .create_working_capital_or_reserve(
288 acp_id,
289 FundKind::WorkingCapital,
290 "Roulement".to_string(),
291 )
292 .await
293 .unwrap();
294 let reserve = uc
295 .create_working_capital_or_reserve(
296 acp_id,
297 FundKind::Reserve,
298 "Réserve légale".to_string(),
299 )
300 .await
301 .unwrap();
302
303 uc.contribute(roulement.id, dec!(3000)).await.unwrap();
304 uc.contribute(reserve.id, dec!(7000)).await.unwrap();
305
306 let funds = uc.list_for_acp(acp_id).await.unwrap();
307 assert_eq!(funds.len(), 2);
308 let roulement_after = funds.iter().find(|f| f.id == roulement.id).unwrap();
309 let reserve_after = funds.iter().find(|f| f.id == reserve.id).unwrap();
310 assert_eq!(roulement_after.balance, dec!(3000));
311 assert_eq!(reserve_after.balance, dec!(7000));
312 }
313
314 #[tokio::test]
315 async fn edge_get_unknown_fund_returns_not_found() {
316 let (_repo, uc) = factory();
317 let err = uc.get(Uuid::new_v4()).await.unwrap_err();
318 assert!(matches!(err, AppError::NotFound(_)));
319 }
320
321 #[tokio::test]
324 async fn security_creating_earmarked_fund_with_simple_majority_is_rejected() {
325 let (_repo, uc) = factory();
326 let err = uc
327 .create_earmarked_fund(
328 Uuid::new_v4(),
329 "Toiture".to_string(),
330 "Réfection toiture".to_string(),
331 dec!(50000),
332 MajorityType::Absolute,
333 )
334 .await
335 .unwrap_err();
336 assert!(matches!(err, AppError::Validation(_)));
337 }
338
339 #[tokio::test]
340 async fn security_expense_outside_purpose_is_rejected_unless_ag_overrides() {
341 let (_repo, uc) = factory();
342 let fund = uc
343 .create_earmarked_fund(
344 Uuid::new_v4(),
345 "Toiture".to_string(),
346 "Réfection toiture".to_string(),
347 dec!(50000),
348 MajorityType::TwoThirds,
349 )
350 .await
351 .unwrap();
352
353 let refused = uc
354 .record_expense(fund.id, "Ravalement façade", None)
355 .await
356 .unwrap_err();
357 assert!(matches!(refused, AppError::Validation(_)));
358
359 uc.record_expense(
361 fund.id,
362 "Ravalement façade",
363 Some(ResolutionStatus::Adopted),
364 )
365 .await
366 .expect("l'AG autorise la dépense hors objet");
367 }
368
369 #[tokio::test]
372 async fn negative_reassign_unknown_fund_returns_not_found() {
373 let (_repo, uc) = factory();
374 let err = uc
375 .reassign(
376 Uuid::new_v4(),
377 "Autre objet".to_string(),
378 None,
379 Uuid::new_v4(),
380 ResolutionStatus::Adopted,
381 )
382 .await
383 .unwrap_err();
384 assert!(matches!(err, AppError::NotFound(_)));
385 }
386
387 #[tokio::test]
388 async fn negative_reassign_without_adopted_resolution_is_rejected() {
389 let (_repo, uc) = factory();
390 let fund = uc
391 .create_earmarked_fund(
392 Uuid::new_v4(),
393 "Toiture".to_string(),
394 "Réfection toiture".to_string(),
395 dec!(50000),
396 MajorityType::TwoThirds,
397 )
398 .await
399 .unwrap();
400 uc.contribute(fund.id, dec!(1000)).await.unwrap();
401
402 let err = uc
403 .reassign(
404 fund.id,
405 "Autre objet".to_string(),
406 None,
407 Uuid::new_v4(),
408 ResolutionStatus::Pending,
409 )
410 .await
411 .unwrap_err();
412 assert!(matches!(err, AppError::Validation(_)));
413 assert!(uc.list_reassignments(fund.id).await.unwrap().is_empty());
415 }
416
417 #[tokio::test]
418 async fn negative_contribute_to_unknown_fund_returns_not_found() {
419 let (_repo, uc) = factory();
420 let err = uc.contribute(Uuid::new_v4(), dec!(10)).await.unwrap_err();
421 assert!(matches!(err, AppError::NotFound(_)));
422 }
423}