1use chrono::{DateTime, Utc};
9use rust_decimal::Decimal;
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
15#[serde(rename_all = "lowercase")]
16pub enum ContributionType {
17 Regular,
19 Extraordinary,
21 Advance,
23 Adjustment,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
29#[serde(rename_all = "lowercase")]
30pub enum ContributionPaymentStatus {
31 Pending,
33 Paid,
35 Partial,
37 Cancelled,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
43#[serde(rename_all = "snake_case")]
44pub enum ContributionPaymentMethod {
45 BankTransfer,
47 Cash,
49 Check,
51 Domiciliation,
53}
54
55impl From<crate::domain::entities::PaymentMethodType> for ContributionPaymentMethod {
77 fn from(value: crate::domain::entities::PaymentMethodType) -> Self {
78 use crate::domain::entities::PaymentMethodType;
79 match value {
80 PaymentMethodType::SepaDebit => ContributionPaymentMethod::Domiciliation,
81 PaymentMethodType::BankTransfer => ContributionPaymentMethod::BankTransfer,
82 PaymentMethodType::Cash => ContributionPaymentMethod::Cash,
83 PaymentMethodType::Card => ContributionPaymentMethod::BankTransfer,
84 }
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
93pub struct OwnerContribution {
94 pub id: Uuid,
95
96 pub acp_id: Uuid,
103
104 pub organization_id: Uuid,
106 pub owner_id: Uuid,
107 pub unit_id: Option<Uuid>,
108
109 pub description: String,
111 pub amount: Decimal,
112
113 pub account_code: Option<String>,
117
118 pub contribution_type: ContributionType,
120
121 pub contribution_date: DateTime<Utc>, pub payment_date: Option<DateTime<Utc>>, pub payment_method: Option<ContributionPaymentMethod>,
127 pub payment_reference: Option<String>,
128
129 pub payment_status: ContributionPaymentStatus,
131
132 pub call_for_funds_id: Option<Uuid>,
134
135 pub notes: Option<String>,
137 pub created_at: DateTime<Utc>,
138 pub updated_at: DateTime<Utc>,
139 pub created_by: Option<Uuid>,
140}
141
142#[derive(Debug, Clone, PartialEq)]
149pub enum OwnerContributionError {
150 NonPositiveAmount,
152 EmptyDescription,
154}
155
156impl std::fmt::Display for OwnerContributionError {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 match self {
159 Self::NonPositiveAmount => write!(
160 f,
161 "Contribution amount must be positive (revenue = money coming IN)"
162 ),
163 Self::EmptyDescription => write!(f, "Description cannot be empty"),
164 }
165 }
166}
167
168impl std::error::Error for OwnerContributionError {}
169
170impl From<OwnerContributionError> for String {
174 fn from(e: OwnerContributionError) -> String {
175 e.to_string()
176 }
177}
178
179impl OwnerContribution {
180 #[allow(clippy::too_many_arguments)]
181 pub fn new(
182 acp_id: Uuid,
183 organization_id: Uuid,
184 owner_id: Uuid,
185 unit_id: Option<Uuid>,
186 description: String,
187 amount: Decimal,
188 contribution_type: ContributionType,
189 contribution_date: DateTime<Utc>,
190 account_code: Option<String>,
191 ) -> Result<Self, OwnerContributionError> {
192 if amount < Decimal::ZERO {
194 return Err(OwnerContributionError::NonPositiveAmount);
195 }
196
197 if description.trim().is_empty() {
199 return Err(OwnerContributionError::EmptyDescription);
200 }
201
202 Ok(Self {
203 id: Uuid::new_v4(),
204 acp_id,
205 organization_id,
206 owner_id,
207 unit_id,
208 description,
209 amount,
210 account_code,
211 contribution_type,
212 contribution_date,
213 payment_date: None,
214 payment_method: None,
215 payment_reference: None,
216 payment_status: ContributionPaymentStatus::Pending,
217 call_for_funds_id: None,
218 notes: None,
219 created_at: Utc::now(),
220 updated_at: Utc::now(),
221 created_by: None,
222 })
223 }
224
225 pub fn mark_as_paid(
227 &mut self,
228 payment_date: DateTime<Utc>,
229 payment_method: ContributionPaymentMethod,
230 payment_reference: Option<String>,
231 ) {
232 self.payment_date = Some(payment_date);
233 self.payment_method = Some(payment_method);
234 self.payment_reference = payment_reference;
235 self.payment_status = ContributionPaymentStatus::Paid;
236 self.updated_at = Utc::now();
237 }
238
239 pub fn is_paid(&self) -> bool {
241 self.payment_status == ContributionPaymentStatus::Paid
242 }
243
244 pub fn is_overdue(&self) -> bool {
246 !self.is_paid() && Utc::now() > self.contribution_date
247 }
248}
249
250impl crate::domain::services::PieceDeGestion for OwnerContribution {
251 fn acp_id(&self) -> Uuid {
252 self.acp_id
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn test_create_contribution_success() {
262 let contrib = OwnerContribution::new(
263 Uuid::new_v4(), Uuid::new_v4(),
265 Uuid::new_v4(),
266 Some(Uuid::new_v4()),
267 "Appel de fonds Q1 2025".to_string(),
268 rust_decimal_macros::dec!(500),
269 ContributionType::Regular,
270 Utc::now(),
271 Some("7000".to_string()),
272 );
273
274 assert!(contrib.is_ok());
275 let contrib = contrib.unwrap();
276 assert_eq!(contrib.amount, rust_decimal_macros::dec!(500));
277 assert_eq!(contrib.payment_status, ContributionPaymentStatus::Pending);
278 assert!(!contrib.is_paid());
279 }
280
281 #[test]
282 fn test_create_contribution_negative_amount() {
283 let contrib = OwnerContribution::new(
284 Uuid::new_v4(), Uuid::new_v4(),
286 Uuid::new_v4(),
287 None,
288 "Test".to_string(),
289 rust_decimal_macros::dec!(-100), ContributionType::Regular,
291 Utc::now(),
292 None,
293 );
294
295 assert!(matches!(
296 contrib.unwrap_err(),
297 OwnerContributionError::NonPositiveAmount
298 ));
299 }
300
301 #[test]
302 fn test_create_contribution_empty_description() {
303 let contrib = OwnerContribution::new(
304 Uuid::new_v4(), Uuid::new_v4(),
306 Uuid::new_v4(),
307 None,
308 " ".to_string(), rust_decimal_macros::dec!(100),
310 ContributionType::Regular,
311 Utc::now(),
312 None,
313 );
314
315 assert!(matches!(
316 contrib.unwrap_err(),
317 OwnerContributionError::EmptyDescription
318 ));
319 }
320
321 #[test]
322 fn test_mark_as_paid() {
323 let mut contrib = OwnerContribution::new(
324 Uuid::new_v4(), Uuid::new_v4(),
326 Uuid::new_v4(),
327 None,
328 "Test payment".to_string(),
329 rust_decimal_macros::dec!(100),
330 ContributionType::Regular,
331 Utc::now(),
332 None,
333 )
334 .unwrap();
335
336 assert!(!contrib.is_paid());
337
338 contrib.mark_as_paid(
339 Utc::now(),
340 ContributionPaymentMethod::BankTransfer,
341 Some("REF-123".to_string()),
342 );
343
344 assert!(contrib.is_paid());
345 assert!(contrib.payment_date.is_some());
346 assert_eq!(
347 contrib.payment_method,
348 Some(ContributionPaymentMethod::BankTransfer)
349 );
350 assert_eq!(contrib.payment_reference, Some("REF-123".to_string()));
351 }
352
353 #[test]
354 fn test_is_overdue() {
355 let past_date = Utc::now() - chrono::Duration::days(30);
356
357 let contrib = OwnerContribution::new(
358 Uuid::new_v4(), Uuid::new_v4(),
360 Uuid::new_v4(),
361 None,
362 "Overdue contribution".to_string(),
363 rust_decimal_macros::dec!(100),
364 ContributionType::Regular,
365 past_date,
366 None,
367 )
368 .unwrap();
369
370 assert!(contrib.is_overdue());
371 }
372
373 #[test]
380 fn happy_contribution_amount_decimal_exact() {
381 let c = OwnerContribution::new(
382 Uuid::new_v4(), Uuid::new_v4(),
384 Uuid::new_v4(),
385 None,
386 "Provision Q1".to_string(),
387 rust_decimal_macros::dec!(1234.56),
388 ContributionType::Regular,
389 Utc::now(),
390 None,
391 )
392 .unwrap();
393 assert_eq!(c.amount, rust_decimal_macros::dec!(1234.56));
394 }
395
396 #[test]
399 fn edge_zero_amount_and_decimal_exactness() {
400 let zero = OwnerContribution::new(
401 Uuid::new_v4(), Uuid::new_v4(),
403 Uuid::new_v4(),
404 None,
405 "Régularisation nulle".to_string(),
406 Decimal::ZERO,
407 ContributionType::Regular,
408 Utc::now(),
409 None,
410 );
411 assert!(zero.is_ok());
412
413 let c = OwnerContribution::new(
414 Uuid::new_v4(), Uuid::new_v4(),
416 Uuid::new_v4(),
417 None,
418 "x".to_string(),
419 rust_decimal_macros::dec!(0.1) + rust_decimal_macros::dec!(0.2),
420 ContributionType::Regular,
421 Utc::now(),
422 None,
423 )
424 .unwrap();
425 assert_eq!(c.amount, rust_decimal_macros::dec!(0.3));
426 }
427
428 #[test]
430 fn negative_amount_and_empty_description_rejected() {
431 assert!(matches!(
432 OwnerContribution::new(
433 Uuid::new_v4(), Uuid::new_v4(),
435 Uuid::new_v4(),
436 None,
437 "ok".to_string(),
438 rust_decimal_macros::dec!(-0.01),
439 ContributionType::Regular,
440 Utc::now(),
441 None,
442 )
443 .unwrap_err(),
444 OwnerContributionError::NonPositiveAmount
445 ));
446 assert!(matches!(
447 OwnerContribution::new(
448 Uuid::new_v4(), Uuid::new_v4(),
450 Uuid::new_v4(),
451 None,
452 " ".to_string(),
453 rust_decimal_macros::dec!(10),
454 ContributionType::Regular,
455 Utc::now(),
456 None,
457 )
458 .unwrap_err(),
459 OwnerContributionError::EmptyDescription
460 ));
461 }
462
463 #[test]
466 fn security_tampered_negative_revenue_rejected() {
467 let result = OwnerContribution::new(
468 Uuid::new_v4(), Uuid::new_v4(),
470 Uuid::new_v4(),
471 None,
472 "Faux avoir".to_string(),
473 rust_decimal_macros::dec!(-99999.99),
474 ContributionType::Regular,
475 Utc::now(),
476 None,
477 );
478 assert!(matches!(
479 result.unwrap_err(),
480 OwnerContributionError::NonPositiveAmount
481 ));
482 }
483}