koprogo_api/domain/comptabilite/
payment.rs1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
8#[serde(rename_all = "snake_case")]
9pub enum TransactionStatus {
10 Pending,
12 Processing,
14 RequiresAction,
16 Succeeded,
18 Failed,
20 Cancelled,
22 Refunded,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
28#[serde(rename_all = "snake_case")]
29pub enum PaymentMethodType {
30 Card,
32 SepaDebit,
34 BankTransfer,
36 Cash,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
48pub struct Payment {
49 pub id: Uuid,
50 pub organization_id: Uuid,
52 pub building_id: Uuid,
54 pub owner_id: Uuid,
56 pub expense_id: Option<Uuid>,
58 pub contribution_id: Option<Uuid>,
64 pub amount_cents: i64,
66 pub currency: String,
68 pub status: TransactionStatus,
70 pub payment_method_type: PaymentMethodType,
72 pub stripe_payment_intent_id: Option<String>,
74 pub stripe_customer_id: Option<String>,
76 pub payment_method_id: Option<Uuid>,
78 pub idempotency_key: String,
80 pub description: Option<String>,
82 pub metadata: Option<String>,
84 pub failure_reason: Option<String>,
86 pub refunded_amount_cents: i64,
88 pub succeeded_at: Option<DateTime<Utc>>,
90 pub failed_at: Option<DateTime<Utc>>,
92 pub cancelled_at: Option<DateTime<Utc>>,
94 pub created_at: DateTime<Utc>,
95 pub updated_at: DateTime<Utc>,
96}
97
98impl Payment {
99 pub fn new(
116 organization_id: Uuid,
117 building_id: Uuid,
118 owner_id: Uuid,
119 expense_id: Option<Uuid>,
120 contribution_id: Option<Uuid>,
121 amount_cents: i64,
122 payment_method_type: PaymentMethodType,
123 idempotency_key: String,
124 description: Option<String>,
125 ) -> Result<Self, String> {
126 if amount_cents <= 0 {
128 return Err("Amount must be greater than 0".to_string());
129 }
130
131 if idempotency_key.trim().is_empty() || idempotency_key.len() < 16 {
133 return Err(
134 "Idempotency key must be at least 16 characters for uniqueness".to_string(),
135 );
136 }
137
138 let now = Utc::now();
139
140 Ok(Self {
141 id: Uuid::new_v4(),
142 organization_id,
143 building_id,
144 owner_id,
145 expense_id,
146 contribution_id,
147 amount_cents,
148 currency: "EUR".to_string(), status: TransactionStatus::Pending,
150 payment_method_type,
151 stripe_payment_intent_id: None,
152 stripe_customer_id: None,
153 payment_method_id: None,
154 idempotency_key,
155 description,
156 metadata: None,
157 failure_reason: None,
158 refunded_amount_cents: 0,
159 succeeded_at: None,
160 failed_at: None,
161 cancelled_at: None,
162 created_at: now,
163 updated_at: now,
164 })
165 }
166
167 pub fn mark_processing(&mut self) -> Result<(), String> {
169 match self.status {
170 TransactionStatus::Pending => {
171 self.status = TransactionStatus::Processing;
172 self.updated_at = Utc::now();
173 Ok(())
174 }
175 _ => Err(format!(
176 "Cannot mark as processing from status: {:?}",
177 self.status
178 )),
179 }
180 }
181
182 pub fn mark_requires_action(&mut self) -> Result<(), String> {
184 match self.status {
185 TransactionStatus::Pending | TransactionStatus::Processing => {
186 self.status = TransactionStatus::RequiresAction;
187 self.updated_at = Utc::now();
188 Ok(())
189 }
190 _ => Err(format!(
191 "Cannot mark as requires_action from status: {:?}",
192 self.status
193 )),
194 }
195 }
196
197 pub fn mark_succeeded(&mut self) -> Result<(), String> {
199 match self.status {
200 TransactionStatus::Pending
201 | TransactionStatus::Processing
202 | TransactionStatus::RequiresAction => {
203 self.status = TransactionStatus::Succeeded;
204 self.succeeded_at = Some(Utc::now());
205 self.updated_at = Utc::now();
206 Ok(())
207 }
208 _ => Err(format!(
209 "Cannot mark as succeeded from status: {:?}",
210 self.status
211 )),
212 }
213 }
214
215 pub fn mark_failed(&mut self, reason: String) -> Result<(), String> {
217 match self.status {
218 TransactionStatus::Pending
219 | TransactionStatus::Processing
220 | TransactionStatus::RequiresAction => {
221 self.status = TransactionStatus::Failed;
222 self.failure_reason = Some(reason);
223 self.failed_at = Some(Utc::now());
224 self.updated_at = Utc::now();
225 Ok(())
226 }
227 _ => Err(format!(
228 "Cannot mark as failed from status: {:?}",
229 self.status
230 )),
231 }
232 }
233
234 pub fn mark_cancelled(&mut self) -> Result<(), String> {
236 match self.status {
237 TransactionStatus::Pending
238 | TransactionStatus::Processing
239 | TransactionStatus::RequiresAction => {
240 self.status = TransactionStatus::Cancelled;
241 self.cancelled_at = Some(Utc::now());
242 self.updated_at = Utc::now();
243 Ok(())
244 }
245 _ => Err(format!(
246 "Cannot mark as cancelled from status: {:?}",
247 self.status
248 )),
249 }
250 }
251
252 pub fn refund(&mut self, refund_amount_cents: i64) -> Result<(), String> {
254 if self.status != TransactionStatus::Succeeded && self.status != TransactionStatus::Refunded
256 {
257 return Err(format!(
258 "Can only refund succeeded payments, current status: {:?}",
259 self.status
260 ));
261 }
262
263 if refund_amount_cents <= 0 {
265 return Err("Refund amount must be greater than 0".to_string());
266 }
267
268 let total_refunded = self.refunded_amount_cents + refund_amount_cents;
270 if total_refunded > self.amount_cents {
271 return Err(format!(
272 "Total refund ({} cents) would exceeds original payment ({} cents)",
273 total_refunded, self.amount_cents
274 ));
275 }
276
277 self.refunded_amount_cents += refund_amount_cents;
278
279 self.status = TransactionStatus::Refunded;
281
282 self.updated_at = Utc::now();
283 Ok(())
284 }
285
286 pub fn set_stripe_payment_intent_id(&mut self, payment_intent_id: String) {
288 self.stripe_payment_intent_id = Some(payment_intent_id);
289 self.updated_at = Utc::now();
290 }
291
292 pub fn set_stripe_customer_id(&mut self, customer_id: String) {
294 self.stripe_customer_id = Some(customer_id);
295 self.updated_at = Utc::now();
296 }
297
298 pub fn set_payment_method_id(&mut self, payment_method_id: Uuid) {
300 self.payment_method_id = Some(payment_method_id);
301 self.updated_at = Utc::now();
302 }
303
304 pub fn set_metadata(&mut self, metadata: String) {
306 self.metadata = Some(metadata);
307 self.updated_at = Utc::now();
308 }
309
310 pub fn get_net_amount_cents(&self) -> i64 {
312 self.amount_cents - self.refunded_amount_cents
313 }
314
315 pub fn is_final(&self) -> bool {
317 matches!(
318 self.status,
319 TransactionStatus::Succeeded
320 | TransactionStatus::Failed
321 | TransactionStatus::Cancelled
322 | TransactionStatus::Refunded
323 )
324 }
325
326 pub fn can_refund(&self) -> bool {
328 self.status == TransactionStatus::Succeeded
329 && self.refunded_amount_cents < self.amount_cents
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 fn create_test_payment() -> Payment {
338 Payment::new(
339 Uuid::new_v4(),
340 Uuid::new_v4(),
341 Uuid::new_v4(),
342 Some(Uuid::new_v4()),
343 None,
344 10000, PaymentMethodType::Card,
346 "test_idempotency_key_123456789".to_string(),
347 Some("Test payment".to_string()),
348 )
349 .unwrap()
350 }
351
352 #[test]
353 fn test_create_payment_success() {
354 let payment = create_test_payment();
355 assert_eq!(payment.amount_cents, 10000);
356 assert_eq!(payment.currency, "EUR");
357 assert_eq!(payment.status, TransactionStatus::Pending);
358 assert_eq!(payment.refunded_amount_cents, 0);
359 }
360
361 #[test]
362 fn test_create_payment_invalid_amount() {
363 let result = Payment::new(
364 Uuid::new_v4(),
365 Uuid::new_v4(),
366 Uuid::new_v4(),
367 None,
368 None,
369 0, PaymentMethodType::Card,
371 "test_idempotency_key_123456789".to_string(),
372 None,
373 );
374 assert!(result.is_err());
375 assert_eq!(result.unwrap_err(), "Amount must be greater than 0");
376 }
377
378 #[test]
379 fn test_create_payment_invalid_idempotency_key() {
380 let result = Payment::new(
381 Uuid::new_v4(),
382 Uuid::new_v4(),
383 Uuid::new_v4(),
384 None,
385 None,
386 10000,
387 PaymentMethodType::Card,
388 "short".to_string(), None,
390 );
391 assert!(result.is_err());
392 assert!(result.unwrap_err().contains("Idempotency key"));
393 }
394
395 #[test]
396 fn test_payment_lifecycle_success() {
397 let mut payment = create_test_payment();
398
399 assert!(payment.mark_processing().is_ok());
401 assert_eq!(payment.status, TransactionStatus::Processing);
402
403 assert!(payment.mark_succeeded().is_ok());
405 assert_eq!(payment.status, TransactionStatus::Succeeded);
406 assert!(payment.succeeded_at.is_some());
407 assert!(payment.is_final());
408 }
409
410 #[test]
411 fn test_payment_lifecycle_failure() {
412 let mut payment = create_test_payment();
413
414 payment.mark_processing().unwrap();
415
416 assert!(payment.mark_failed("Card declined".to_string()).is_ok());
418 assert_eq!(payment.status, TransactionStatus::Failed);
419 assert_eq!(payment.failure_reason, Some("Card declined".to_string()));
420 assert!(payment.failed_at.is_some());
421 assert!(payment.is_final());
422 }
423
424 #[test]
425 fn test_payment_lifecycle_cancelled() {
426 let mut payment = create_test_payment();
427
428 assert!(payment.mark_cancelled().is_ok());
430 assert_eq!(payment.status, TransactionStatus::Cancelled);
431 assert!(payment.cancelled_at.is_some());
432 assert!(payment.is_final());
433 }
434
435 #[test]
436 fn test_payment_requires_action() {
437 let mut payment = create_test_payment();
438
439 payment.mark_processing().unwrap();
440
441 assert!(payment.mark_requires_action().is_ok());
443 assert_eq!(payment.status, TransactionStatus::RequiresAction);
444
445 assert!(payment.mark_succeeded().is_ok());
447 assert_eq!(payment.status, TransactionStatus::Succeeded);
448 }
449
450 #[test]
451 fn test_payment_invalid_status_transition() {
452 let mut payment = create_test_payment();
453 payment.mark_succeeded().unwrap();
454
455 assert!(payment.mark_processing().is_err());
457 }
458
459 #[test]
460 fn test_refund_full() {
461 let mut payment = create_test_payment();
462 payment.mark_succeeded().unwrap();
463
464 assert!(payment.can_refund());
465
466 assert!(payment.refund(10000).is_ok());
468 assert_eq!(payment.refunded_amount_cents, 10000);
469 assert_eq!(payment.status, TransactionStatus::Refunded);
470 assert_eq!(payment.get_net_amount_cents(), 0);
471 assert!(!payment.can_refund());
472 }
473
474 #[test]
475 fn test_refund_partial() {
476 let mut payment = create_test_payment();
477 payment.mark_succeeded().unwrap();
478
479 assert!(payment.refund(5000).is_ok());
481 assert_eq!(payment.refunded_amount_cents, 5000);
482 assert_eq!(payment.status, TransactionStatus::Refunded); assert_eq!(payment.get_net_amount_cents(), 5000);
484 assert!(!payment.can_refund()); assert!(payment.refund(5000).is_ok());
488 assert_eq!(payment.refunded_amount_cents, 10000);
489 assert_eq!(payment.status, TransactionStatus::Refunded);
490 }
491
492 #[test]
493 fn test_refund_exceeds_amount() {
494 let mut payment = create_test_payment();
495 payment.mark_succeeded().unwrap();
496
497 let result = payment.refund(15000);
499 assert!(result.is_err());
500 assert!(result.unwrap_err().contains("exceeds"));
501 }
502
503 #[test]
504 fn test_refund_before_success() {
505 let mut payment = create_test_payment();
506
507 let result = payment.refund(5000);
509 assert!(result.is_err());
510 assert!(result.unwrap_err().contains("succeeded payments"));
511 }
512
513 #[test]
514 fn test_set_stripe_data() {
515 let mut payment = create_test_payment();
516
517 payment.set_stripe_payment_intent_id("pi_123456789".to_string());
518 assert_eq!(
519 payment.stripe_payment_intent_id,
520 Some("pi_123456789".to_string())
521 );
522
523 payment.set_stripe_customer_id("cus_123456789".to_string());
524 assert_eq!(
525 payment.stripe_customer_id,
526 Some("cus_123456789".to_string())
527 );
528
529 let method_id = Uuid::new_v4();
530 payment.set_payment_method_id(method_id);
531 assert_eq!(payment.payment_method_id, Some(method_id));
532 }
533}