koprogo_api/domain/entities/
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 amount_cents: i64,
60 pub currency: String,
62 pub status: TransactionStatus,
64 pub payment_method_type: PaymentMethodType,
66 pub stripe_payment_intent_id: Option<String>,
68 pub stripe_customer_id: Option<String>,
70 pub payment_method_id: Option<Uuid>,
72 pub idempotency_key: String,
74 pub description: Option<String>,
76 pub metadata: Option<String>,
78 pub failure_reason: Option<String>,
80 pub refunded_amount_cents: i64,
82 pub succeeded_at: Option<DateTime<Utc>>,
84 pub failed_at: Option<DateTime<Utc>>,
86 pub cancelled_at: Option<DateTime<Utc>>,
88 pub created_at: DateTime<Utc>,
89 pub updated_at: DateTime<Utc>,
90}
91
92impl Payment {
93 pub fn new(
109 organization_id: Uuid,
110 building_id: Uuid,
111 owner_id: Uuid,
112 expense_id: Option<Uuid>,
113 amount_cents: i64,
114 payment_method_type: PaymentMethodType,
115 idempotency_key: String,
116 description: Option<String>,
117 ) -> Result<Self, String> {
118 if amount_cents <= 0 {
120 return Err("Amount must be greater than 0".to_string());
121 }
122
123 if idempotency_key.trim().is_empty() || idempotency_key.len() < 16 {
125 return Err(
126 "Idempotency key must be at least 16 characters for uniqueness".to_string(),
127 );
128 }
129
130 let now = Utc::now();
131
132 Ok(Self {
133 id: Uuid::new_v4(),
134 organization_id,
135 building_id,
136 owner_id,
137 expense_id,
138 amount_cents,
139 currency: "EUR".to_string(), status: TransactionStatus::Pending,
141 payment_method_type,
142 stripe_payment_intent_id: None,
143 stripe_customer_id: None,
144 payment_method_id: None,
145 idempotency_key,
146 description,
147 metadata: None,
148 failure_reason: None,
149 refunded_amount_cents: 0,
150 succeeded_at: None,
151 failed_at: None,
152 cancelled_at: None,
153 created_at: now,
154 updated_at: now,
155 })
156 }
157
158 pub fn mark_processing(&mut self) -> Result<(), String> {
160 match self.status {
161 TransactionStatus::Pending => {
162 self.status = TransactionStatus::Processing;
163 self.updated_at = Utc::now();
164 Ok(())
165 }
166 _ => Err(format!(
167 "Cannot mark as processing from status: {:?}",
168 self.status
169 )),
170 }
171 }
172
173 pub fn mark_requires_action(&mut self) -> Result<(), String> {
175 match self.status {
176 TransactionStatus::Pending | TransactionStatus::Processing => {
177 self.status = TransactionStatus::RequiresAction;
178 self.updated_at = Utc::now();
179 Ok(())
180 }
181 _ => Err(format!(
182 "Cannot mark as requires_action from status: {:?}",
183 self.status
184 )),
185 }
186 }
187
188 pub fn mark_succeeded(&mut self) -> Result<(), String> {
190 match self.status {
191 TransactionStatus::Pending
192 | TransactionStatus::Processing
193 | TransactionStatus::RequiresAction => {
194 self.status = TransactionStatus::Succeeded;
195 self.succeeded_at = Some(Utc::now());
196 self.updated_at = Utc::now();
197 Ok(())
198 }
199 _ => Err(format!(
200 "Cannot mark as succeeded from status: {:?}",
201 self.status
202 )),
203 }
204 }
205
206 pub fn mark_failed(&mut self, reason: String) -> Result<(), String> {
208 match self.status {
209 TransactionStatus::Pending
210 | TransactionStatus::Processing
211 | TransactionStatus::RequiresAction => {
212 self.status = TransactionStatus::Failed;
213 self.failure_reason = Some(reason);
214 self.failed_at = Some(Utc::now());
215 self.updated_at = Utc::now();
216 Ok(())
217 }
218 _ => Err(format!(
219 "Cannot mark as failed from status: {:?}",
220 self.status
221 )),
222 }
223 }
224
225 pub fn mark_cancelled(&mut self) -> Result<(), String> {
227 match self.status {
228 TransactionStatus::Pending
229 | TransactionStatus::Processing
230 | TransactionStatus::RequiresAction => {
231 self.status = TransactionStatus::Cancelled;
232 self.cancelled_at = Some(Utc::now());
233 self.updated_at = Utc::now();
234 Ok(())
235 }
236 _ => Err(format!(
237 "Cannot mark as cancelled from status: {:?}",
238 self.status
239 )),
240 }
241 }
242
243 pub fn refund(&mut self, refund_amount_cents: i64) -> Result<(), String> {
245 if self.status != TransactionStatus::Succeeded && self.status != TransactionStatus::Refunded
247 {
248 return Err(format!(
249 "Can only refund succeeded payments, current status: {:?}",
250 self.status
251 ));
252 }
253
254 if refund_amount_cents <= 0 {
256 return Err("Refund amount must be greater than 0".to_string());
257 }
258
259 let total_refunded = self.refunded_amount_cents + refund_amount_cents;
261 if total_refunded > self.amount_cents {
262 return Err(format!(
263 "Total refund ({} cents) would exceeds original payment ({} cents)",
264 total_refunded, self.amount_cents
265 ));
266 }
267
268 self.refunded_amount_cents += refund_amount_cents;
269
270 self.status = TransactionStatus::Refunded;
272
273 self.updated_at = Utc::now();
274 Ok(())
275 }
276
277 pub fn set_stripe_payment_intent_id(&mut self, payment_intent_id: String) {
279 self.stripe_payment_intent_id = Some(payment_intent_id);
280 self.updated_at = Utc::now();
281 }
282
283 pub fn set_stripe_customer_id(&mut self, customer_id: String) {
285 self.stripe_customer_id = Some(customer_id);
286 self.updated_at = Utc::now();
287 }
288
289 pub fn set_payment_method_id(&mut self, payment_method_id: Uuid) {
291 self.payment_method_id = Some(payment_method_id);
292 self.updated_at = Utc::now();
293 }
294
295 pub fn set_metadata(&mut self, metadata: String) {
297 self.metadata = Some(metadata);
298 self.updated_at = Utc::now();
299 }
300
301 pub fn get_net_amount_cents(&self) -> i64 {
303 self.amount_cents - self.refunded_amount_cents
304 }
305
306 pub fn is_final(&self) -> bool {
308 matches!(
309 self.status,
310 TransactionStatus::Succeeded
311 | TransactionStatus::Failed
312 | TransactionStatus::Cancelled
313 | TransactionStatus::Refunded
314 )
315 }
316
317 pub fn can_refund(&self) -> bool {
319 self.status == TransactionStatus::Succeeded
320 && self.refunded_amount_cents < self.amount_cents
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 fn create_test_payment() -> Payment {
329 Payment::new(
330 Uuid::new_v4(),
331 Uuid::new_v4(),
332 Uuid::new_v4(),
333 Some(Uuid::new_v4()),
334 10000, PaymentMethodType::Card,
336 "test_idempotency_key_123456789".to_string(),
337 Some("Test payment".to_string()),
338 )
339 .unwrap()
340 }
341
342 #[test]
343 fn test_create_payment_success() {
344 let payment = create_test_payment();
345 assert_eq!(payment.amount_cents, 10000);
346 assert_eq!(payment.currency, "EUR");
347 assert_eq!(payment.status, TransactionStatus::Pending);
348 assert_eq!(payment.refunded_amount_cents, 0);
349 }
350
351 #[test]
352 fn test_create_payment_invalid_amount() {
353 let result = Payment::new(
354 Uuid::new_v4(),
355 Uuid::new_v4(),
356 Uuid::new_v4(),
357 None,
358 0, PaymentMethodType::Card,
360 "test_idempotency_key_123456789".to_string(),
361 None,
362 );
363 assert!(result.is_err());
364 assert_eq!(result.unwrap_err(), "Amount must be greater than 0");
365 }
366
367 #[test]
368 fn test_create_payment_invalid_idempotency_key() {
369 let result = Payment::new(
370 Uuid::new_v4(),
371 Uuid::new_v4(),
372 Uuid::new_v4(),
373 None,
374 10000,
375 PaymentMethodType::Card,
376 "short".to_string(), None,
378 );
379 assert!(result.is_err());
380 assert!(result.unwrap_err().contains("Idempotency key"));
381 }
382
383 #[test]
384 fn test_payment_lifecycle_success() {
385 let mut payment = create_test_payment();
386
387 assert!(payment.mark_processing().is_ok());
389 assert_eq!(payment.status, TransactionStatus::Processing);
390
391 assert!(payment.mark_succeeded().is_ok());
393 assert_eq!(payment.status, TransactionStatus::Succeeded);
394 assert!(payment.succeeded_at.is_some());
395 assert!(payment.is_final());
396 }
397
398 #[test]
399 fn test_payment_lifecycle_failure() {
400 let mut payment = create_test_payment();
401
402 payment.mark_processing().unwrap();
403
404 assert!(payment.mark_failed("Card declined".to_string()).is_ok());
406 assert_eq!(payment.status, TransactionStatus::Failed);
407 assert_eq!(payment.failure_reason, Some("Card declined".to_string()));
408 assert!(payment.failed_at.is_some());
409 assert!(payment.is_final());
410 }
411
412 #[test]
413 fn test_payment_lifecycle_cancelled() {
414 let mut payment = create_test_payment();
415
416 assert!(payment.mark_cancelled().is_ok());
418 assert_eq!(payment.status, TransactionStatus::Cancelled);
419 assert!(payment.cancelled_at.is_some());
420 assert!(payment.is_final());
421 }
422
423 #[test]
424 fn test_payment_requires_action() {
425 let mut payment = create_test_payment();
426
427 payment.mark_processing().unwrap();
428
429 assert!(payment.mark_requires_action().is_ok());
431 assert_eq!(payment.status, TransactionStatus::RequiresAction);
432
433 assert!(payment.mark_succeeded().is_ok());
435 assert_eq!(payment.status, TransactionStatus::Succeeded);
436 }
437
438 #[test]
439 fn test_payment_invalid_status_transition() {
440 let mut payment = create_test_payment();
441 payment.mark_succeeded().unwrap();
442
443 assert!(payment.mark_processing().is_err());
445 }
446
447 #[test]
448 fn test_refund_full() {
449 let mut payment = create_test_payment();
450 payment.mark_succeeded().unwrap();
451
452 assert!(payment.can_refund());
453
454 assert!(payment.refund(10000).is_ok());
456 assert_eq!(payment.refunded_amount_cents, 10000);
457 assert_eq!(payment.status, TransactionStatus::Refunded);
458 assert_eq!(payment.get_net_amount_cents(), 0);
459 assert!(!payment.can_refund());
460 }
461
462 #[test]
463 fn test_refund_partial() {
464 let mut payment = create_test_payment();
465 payment.mark_succeeded().unwrap();
466
467 assert!(payment.refund(5000).is_ok());
469 assert_eq!(payment.refunded_amount_cents, 5000);
470 assert_eq!(payment.status, TransactionStatus::Refunded); assert_eq!(payment.get_net_amount_cents(), 5000);
472 assert!(!payment.can_refund()); assert!(payment.refund(5000).is_ok());
476 assert_eq!(payment.refunded_amount_cents, 10000);
477 assert_eq!(payment.status, TransactionStatus::Refunded);
478 }
479
480 #[test]
481 fn test_refund_exceeds_amount() {
482 let mut payment = create_test_payment();
483 payment.mark_succeeded().unwrap();
484
485 let result = payment.refund(15000);
487 assert!(result.is_err());
488 assert!(result.unwrap_err().contains("exceeds"));
489 }
490
491 #[test]
492 fn test_refund_before_success() {
493 let mut payment = create_test_payment();
494
495 let result = payment.refund(5000);
497 assert!(result.is_err());
498 assert!(result.unwrap_err().contains("succeeded payments"));
499 }
500
501 #[test]
502 fn test_set_stripe_data() {
503 let mut payment = create_test_payment();
504
505 payment.set_stripe_payment_intent_id("pi_123456789".to_string());
506 assert_eq!(
507 payment.stripe_payment_intent_id,
508 Some("pi_123456789".to_string())
509 );
510
511 payment.set_stripe_customer_id("cus_123456789".to_string());
512 assert_eq!(
513 payment.stripe_customer_id,
514 Some("cus_123456789".to_string())
515 );
516
517 let method_id = Uuid::new_v4();
518 payment.set_payment_method_id(method_id);
519 assert_eq!(payment.payment_method_id, Some(method_id));
520 }
521}