koprogo_api/domain/entities/
payment.rs1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
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)]
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)]
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 {
247 return Err(format!(
248 "Can only refund succeeded payments, current status: {:?}",
249 self.status
250 ));
251 }
252
253 if refund_amount_cents <= 0 {
255 return Err("Refund amount must be greater than 0".to_string());
256 }
257
258 let total_refunded = self.refunded_amount_cents + refund_amount_cents;
260 if total_refunded > self.amount_cents {
261 return Err(format!(
262 "Total refund ({} cents) would exceed original payment ({} cents)",
263 total_refunded, self.amount_cents
264 ));
265 }
266
267 self.refunded_amount_cents += refund_amount_cents;
268
269 if self.refunded_amount_cents == self.amount_cents {
271 self.status = TransactionStatus::Refunded;
272 }
273
274 self.updated_at = Utc::now();
275 Ok(())
276 }
277
278 pub fn set_stripe_payment_intent_id(&mut self, payment_intent_id: String) {
280 self.stripe_payment_intent_id = Some(payment_intent_id);
281 self.updated_at = Utc::now();
282 }
283
284 pub fn set_stripe_customer_id(&mut self, customer_id: String) {
286 self.stripe_customer_id = Some(customer_id);
287 self.updated_at = Utc::now();
288 }
289
290 pub fn set_payment_method_id(&mut self, payment_method_id: Uuid) {
292 self.payment_method_id = Some(payment_method_id);
293 self.updated_at = Utc::now();
294 }
295
296 pub fn set_metadata(&mut self, metadata: String) {
298 self.metadata = Some(metadata);
299 self.updated_at = Utc::now();
300 }
301
302 pub fn get_net_amount_cents(&self) -> i64 {
304 self.amount_cents - self.refunded_amount_cents
305 }
306
307 pub fn is_final(&self) -> bool {
309 matches!(
310 self.status,
311 TransactionStatus::Succeeded
312 | TransactionStatus::Failed
313 | TransactionStatus::Cancelled
314 | TransactionStatus::Refunded
315 )
316 }
317
318 pub fn can_refund(&self) -> bool {
320 self.status == TransactionStatus::Succeeded
321 && self.refunded_amount_cents < self.amount_cents
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 fn create_test_payment() -> Payment {
330 Payment::new(
331 Uuid::new_v4(),
332 Uuid::new_v4(),
333 Uuid::new_v4(),
334 Some(Uuid::new_v4()),
335 10000, PaymentMethodType::Card,
337 "test_idempotency_key_123456789".to_string(),
338 Some("Test payment".to_string()),
339 )
340 .unwrap()
341 }
342
343 #[test]
344 fn test_create_payment_success() {
345 let payment = create_test_payment();
346 assert_eq!(payment.amount_cents, 10000);
347 assert_eq!(payment.currency, "EUR");
348 assert_eq!(payment.status, TransactionStatus::Pending);
349 assert_eq!(payment.refunded_amount_cents, 0);
350 }
351
352 #[test]
353 fn test_create_payment_invalid_amount() {
354 let result = Payment::new(
355 Uuid::new_v4(),
356 Uuid::new_v4(),
357 Uuid::new_v4(),
358 None,
359 0, PaymentMethodType::Card,
361 "test_idempotency_key_123456789".to_string(),
362 None,
363 );
364 assert!(result.is_err());
365 assert_eq!(result.unwrap_err(), "Amount must be greater than 0");
366 }
367
368 #[test]
369 fn test_create_payment_invalid_idempotency_key() {
370 let result = Payment::new(
371 Uuid::new_v4(),
372 Uuid::new_v4(),
373 Uuid::new_v4(),
374 None,
375 10000,
376 PaymentMethodType::Card,
377 "short".to_string(), None,
379 );
380 assert!(result.is_err());
381 assert!(result.unwrap_err().contains("Idempotency key"));
382 }
383
384 #[test]
385 fn test_payment_lifecycle_success() {
386 let mut payment = create_test_payment();
387
388 assert!(payment.mark_processing().is_ok());
390 assert_eq!(payment.status, TransactionStatus::Processing);
391
392 assert!(payment.mark_succeeded().is_ok());
394 assert_eq!(payment.status, TransactionStatus::Succeeded);
395 assert!(payment.succeeded_at.is_some());
396 assert!(payment.is_final());
397 }
398
399 #[test]
400 fn test_payment_lifecycle_failure() {
401 let mut payment = create_test_payment();
402
403 payment.mark_processing().unwrap();
404
405 assert!(payment.mark_failed("Card declined".to_string()).is_ok());
407 assert_eq!(payment.status, TransactionStatus::Failed);
408 assert_eq!(payment.failure_reason, Some("Card declined".to_string()));
409 assert!(payment.failed_at.is_some());
410 assert!(payment.is_final());
411 }
412
413 #[test]
414 fn test_payment_lifecycle_cancelled() {
415 let mut payment = create_test_payment();
416
417 assert!(payment.mark_cancelled().is_ok());
419 assert_eq!(payment.status, TransactionStatus::Cancelled);
420 assert!(payment.cancelled_at.is_some());
421 assert!(payment.is_final());
422 }
423
424 #[test]
425 fn test_payment_requires_action() {
426 let mut payment = create_test_payment();
427
428 payment.mark_processing().unwrap();
429
430 assert!(payment.mark_requires_action().is_ok());
432 assert_eq!(payment.status, TransactionStatus::RequiresAction);
433
434 assert!(payment.mark_succeeded().is_ok());
436 assert_eq!(payment.status, TransactionStatus::Succeeded);
437 }
438
439 #[test]
440 fn test_payment_invalid_status_transition() {
441 let mut payment = create_test_payment();
442 payment.mark_succeeded().unwrap();
443
444 assert!(payment.mark_processing().is_err());
446 }
447
448 #[test]
449 fn test_refund_full() {
450 let mut payment = create_test_payment();
451 payment.mark_succeeded().unwrap();
452
453 assert!(payment.can_refund());
454
455 assert!(payment.refund(10000).is_ok());
457 assert_eq!(payment.refunded_amount_cents, 10000);
458 assert_eq!(payment.status, TransactionStatus::Refunded);
459 assert_eq!(payment.get_net_amount_cents(), 0);
460 assert!(!payment.can_refund());
461 }
462
463 #[test]
464 fn test_refund_partial() {
465 let mut payment = create_test_payment();
466 payment.mark_succeeded().unwrap();
467
468 assert!(payment.refund(5000).is_ok());
470 assert_eq!(payment.refunded_amount_cents, 5000);
471 assert_eq!(payment.status, TransactionStatus::Succeeded); assert_eq!(payment.get_net_amount_cents(), 5000);
473 assert!(payment.can_refund());
474
475 assert!(payment.refund(5000).is_ok());
477 assert_eq!(payment.refunded_amount_cents, 10000);
478 assert_eq!(payment.status, TransactionStatus::Refunded);
479 }
480
481 #[test]
482 fn test_refund_exceeds_amount() {
483 let mut payment = create_test_payment();
484 payment.mark_succeeded().unwrap();
485
486 let result = payment.refund(15000);
488 assert!(result.is_err());
489 assert!(result.unwrap_err().contains("exceed original payment"));
490 }
491
492 #[test]
493 fn test_refund_before_success() {
494 let mut payment = create_test_payment();
495
496 let result = payment.refund(5000);
498 assert!(result.is_err());
499 assert!(result.unwrap_err().contains("succeeded payments"));
500 }
501
502 #[test]
503 fn test_set_stripe_data() {
504 let mut payment = create_test_payment();
505
506 payment.set_stripe_payment_intent_id("pi_123456789".to_string());
507 assert_eq!(
508 payment.stripe_payment_intent_id,
509 Some("pi_123456789".to_string())
510 );
511
512 payment.set_stripe_customer_id("cus_123456789".to_string());
513 assert_eq!(
514 payment.stripe_customer_id,
515 Some("cus_123456789".to_string())
516 );
517
518 let method_id = Uuid::new_v4();
519 payment.set_payment_method_id(method_id);
520 assert_eq!(payment.payment_method_id, Some(method_id));
521 }
522}