Skip to main content

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