koprogo_api/domain/entities/
owner_contribution.rs1use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "lowercase")]
15pub enum ContributionType {
16 Regular,
18 Extraordinary,
20 Advance,
22 Adjustment,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28#[serde(rename_all = "lowercase")]
29pub enum ContributionPaymentStatus {
30 Pending,
32 Paid,
34 Partial,
36 Cancelled,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42#[serde(rename_all = "snake_case")]
43pub enum ContributionPaymentMethod {
44 BankTransfer,
46 Cash,
48 Check,
50 Domiciliation,
52}
53
54#[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 pub description: String,
67 pub amount: f64,
68
69 pub account_code: Option<String>,
73
74 pub contribution_type: ContributionType,
76
77 pub contribution_date: DateTime<Utc>, pub payment_date: Option<DateTime<Utc>>, pub payment_method: Option<ContributionPaymentMethod>,
83 pub payment_reference: Option<String>,
84
85 pub payment_status: ContributionPaymentStatus,
87
88 pub call_for_funds_id: Option<Uuid>,
90
91 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 if amount < 0.0 {
112 return Err(
113 "Contribution amount must be positive (revenue = money coming IN)".to_string(),
114 );
115 }
116
117 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 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 pub fn is_paid(&self) -> bool {
160 self.payment_status == ContributionPaymentStatus::Paid
161 }
162
163 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, 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(), 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}