koprogo_api/domain/entities/
owner_contribution.rs1use chrono::{DateTime, Utc};
9use rust_decimal::Decimal;
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
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)]
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)]
43#[serde(rename_all = "snake_case")]
44pub enum ContributionPaymentMethod {
45 BankTransfer,
47 Cash,
49 Check,
51 Domiciliation,
53}
54
55#[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 pub description: String,
68 pub amount: Decimal,
69
70 pub account_code: Option<String>,
74
75 pub contribution_type: ContributionType,
77
78 pub contribution_date: DateTime<Utc>, pub payment_date: Option<DateTime<Utc>>, pub payment_method: Option<ContributionPaymentMethod>,
84 pub payment_reference: Option<String>,
85
86 pub payment_status: ContributionPaymentStatus,
88
89 pub call_for_funds_id: Option<Uuid>,
91
92 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 if amount < Decimal::ZERO {
113 return Err(
114 "Contribution amount must be positive (revenue = money coming IN)".to_string(),
115 );
116 }
117
118 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 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 pub fn is_paid(&self) -> bool {
161 self.payment_status == ContributionPaymentStatus::Paid
162 }
163
164 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), 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(), 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}