koprogo_api/domain/entities/
owner_contribution.rs

1// Domain Entity: Owner Contribution
2//
3// Represents payments made BY owners TO the ACP (incoming money = revenue)
4// Complements Expense entity which represents payments made BY ACP TO suppliers (outgoing money = charges)
5//
6// Maps to PCMN classe 7 (Produits/Revenue)
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12/// Type of owner contribution
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "lowercase")]
15pub enum ContributionType {
16    /// Regular quarterly fees (appels de fonds ordinaires)
17    Regular,
18    /// Extraordinary fees for special works (appels de fonds extraordinaires)
19    Extraordinary,
20    /// Advance payment
21    Advance,
22    /// Adjustment (regularisation)
23    Adjustment,
24}
25
26/// Payment status for contributions
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28#[serde(rename_all = "lowercase")]
29pub enum ContributionPaymentStatus {
30    /// Not yet paid
31    Pending,
32    /// Fully paid
33    Paid,
34    /// Partially paid
35    Partial,
36    /// Cancelled
37    Cancelled,
38}
39
40/// Payment method for contributions
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42#[serde(rename_all = "snake_case")]
43pub enum ContributionPaymentMethod {
44    /// Bank transfer (virement)
45    BankTransfer,
46    /// Cash (espèces)
47    Cash,
48    /// Check (chèque)
49    Check,
50    /// Direct debit (domiciliation)
51    Domiciliation,
52}
53
54/// Owner contribution (appel de fonds / cotisation)
55///
56/// Represents money paid BY owners TO the ACP (REVENUE - classe 7 PCMN)
57/// This is the opposite of Expense which represents money paid BY ACP TO suppliers
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
59pub struct OwnerContribution {
60    pub id: Uuid,
61    pub organization_id: Uuid,
62    pub owner_id: Uuid,
63    pub unit_id: Option<Uuid>,
64
65    // Financial details
66    pub description: String,
67    pub amount: f64,
68
69    // Accounting
70    /// PCMN code (classe 7 - Produits)
71    /// Examples: "7000" = regular fees, "7100" = extraordinary fees
72    pub account_code: Option<String>,
73
74    // Contribution details
75    pub contribution_type: ContributionType,
76
77    // Dates
78    pub contribution_date: DateTime<Utc>, // When due/requested
79    pub payment_date: Option<DateTime<Utc>>, // When actually paid
80
81    // Payment details
82    pub payment_method: Option<ContributionPaymentMethod>,
83    pub payment_reference: Option<String>,
84
85    // Status
86    pub payment_status: ContributionPaymentStatus,
87
88    // Link to collective call for funds (if generated from CallForFunds)
89    pub call_for_funds_id: Option<Uuid>,
90
91    // Metadata
92    pub notes: Option<String>,
93    pub created_at: DateTime<Utc>,
94    pub updated_at: DateTime<Utc>,
95    pub created_by: Option<Uuid>,
96}
97
98impl OwnerContribution {
99    #[allow(clippy::too_many_arguments)]
100    pub fn new(
101        organization_id: Uuid,
102        owner_id: Uuid,
103        unit_id: Option<Uuid>,
104        description: String,
105        amount: f64,
106        contribution_type: ContributionType,
107        contribution_date: DateTime<Utc>,
108        account_code: Option<String>,
109    ) -> Result<Self, String> {
110        // Validate amount is positive (revenue = money coming IN)
111        if amount < 0.0 {
112            return Err(
113                "Contribution amount must be positive (revenue = money coming IN)".to_string(),
114            );
115        }
116
117        // Validate description
118        if description.trim().is_empty() {
119            return Err("Description cannot be empty".to_string());
120        }
121
122        Ok(Self {
123            id: Uuid::new_v4(),
124            organization_id,
125            owner_id,
126            unit_id,
127            description,
128            amount,
129            account_code,
130            contribution_type,
131            contribution_date,
132            payment_date: None,
133            payment_method: None,
134            payment_reference: None,
135            payment_status: ContributionPaymentStatus::Pending,
136            call_for_funds_id: None,
137            notes: None,
138            created_at: Utc::now(),
139            updated_at: Utc::now(),
140            created_by: None,
141        })
142    }
143
144    /// Mark contribution as paid
145    pub fn mark_as_paid(
146        &mut self,
147        payment_date: DateTime<Utc>,
148        payment_method: ContributionPaymentMethod,
149        payment_reference: Option<String>,
150    ) {
151        self.payment_date = Some(payment_date);
152        self.payment_method = Some(payment_method);
153        self.payment_reference = payment_reference;
154        self.payment_status = ContributionPaymentStatus::Paid;
155        self.updated_at = Utc::now();
156    }
157
158    /// Check if contribution is paid
159    pub fn is_paid(&self) -> bool {
160        self.payment_status == ContributionPaymentStatus::Paid
161    }
162
163    /// Check if contribution is overdue (not paid and past contribution_date)
164    pub fn is_overdue(&self) -> bool {
165        !self.is_paid() && Utc::now() > self.contribution_date
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn test_create_contribution_success() {
175        let contrib = OwnerContribution::new(
176            Uuid::new_v4(),
177            Uuid::new_v4(),
178            Some(Uuid::new_v4()),
179            "Appel de fonds Q1 2025".to_string(),
180            500.0,
181            ContributionType::Regular,
182            Utc::now(),
183            Some("7000".to_string()),
184        );
185
186        assert!(contrib.is_ok());
187        let contrib = contrib.unwrap();
188        assert_eq!(contrib.amount, 500.0);
189        assert_eq!(contrib.payment_status, ContributionPaymentStatus::Pending);
190        assert!(!contrib.is_paid());
191    }
192
193    #[test]
194    fn test_create_contribution_negative_amount() {
195        let contrib = OwnerContribution::new(
196            Uuid::new_v4(),
197            Uuid::new_v4(),
198            None,
199            "Test".to_string(),
200            -100.0, // Negative amount
201            ContributionType::Regular,
202            Utc::now(),
203            None,
204        );
205
206        assert!(contrib.is_err());
207        assert!(contrib.unwrap_err().contains("must be positive"));
208    }
209
210    #[test]
211    fn test_create_contribution_empty_description() {
212        let contrib = OwnerContribution::new(
213            Uuid::new_v4(),
214            Uuid::new_v4(),
215            None,
216            "   ".to_string(), // Empty description
217            100.0,
218            ContributionType::Regular,
219            Utc::now(),
220            None,
221        );
222
223        assert!(contrib.is_err());
224        assert!(contrib.unwrap_err().contains("Description cannot be empty"));
225    }
226
227    #[test]
228    fn test_mark_as_paid() {
229        let mut contrib = OwnerContribution::new(
230            Uuid::new_v4(),
231            Uuid::new_v4(),
232            None,
233            "Test payment".to_string(),
234            100.0,
235            ContributionType::Regular,
236            Utc::now(),
237            None,
238        )
239        .unwrap();
240
241        assert!(!contrib.is_paid());
242
243        contrib.mark_as_paid(
244            Utc::now(),
245            ContributionPaymentMethod::BankTransfer,
246            Some("REF-123".to_string()),
247        );
248
249        assert!(contrib.is_paid());
250        assert!(contrib.payment_date.is_some());
251        assert_eq!(
252            contrib.payment_method,
253            Some(ContributionPaymentMethod::BankTransfer)
254        );
255        assert_eq!(contrib.payment_reference, Some("REF-123".to_string()));
256    }
257
258    #[test]
259    fn test_is_overdue() {
260        let past_date = Utc::now() - chrono::Duration::days(30);
261
262        let contrib = OwnerContribution::new(
263            Uuid::new_v4(),
264            Uuid::new_v4(),
265            None,
266            "Overdue contribution".to_string(),
267            100.0,
268            ContributionType::Regular,
269            past_date,
270            None,
271        )
272        .unwrap();
273
274        assert!(contrib.is_overdue());
275    }
276}