1use crate::errors::Result;
6use crate::validation::{Validate, ValidationBuilder};
7use chrono::{DateTime, Utc};
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use stateset_primitives::{CurrencyCode, CustomerId, OrderId, PaymentId};
11use strum::{Display, EnumString};
12use uuid::Uuid;
13
14#[derive(
16 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
17)]
18#[serde(rename_all = "snake_case")]
19#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
20#[non_exhaustive]
21pub enum PaymentTransactionStatus {
22 #[default]
24 Pending,
25 Processing,
27 RequiresAction,
29 Completed,
31 Failed,
33 #[strum(serialize = "cancelled", serialize = "canceled")]
35 Cancelled,
36 Refunded,
38 PartiallyRefunded,
40 Disputed,
42}
43
44impl PaymentTransactionStatus {
45 #[must_use]
47 pub fn can_transition_to(self, next: Self) -> bool {
48 if self == next {
49 return true;
50 }
51 match self {
52 Self::Pending => matches!(next, Self::Processing | Self::Cancelled | Self::Failed),
53 Self::Processing => matches!(
54 next,
55 Self::RequiresAction | Self::Completed | Self::Failed | Self::Cancelled
56 ),
57 Self::RequiresAction => {
58 matches!(next, Self::Processing | Self::Completed | Self::Failed | Self::Cancelled)
59 }
60 Self::Completed => {
61 matches!(next, Self::Refunded | Self::PartiallyRefunded | Self::Disputed)
62 }
63 Self::PartiallyRefunded => matches!(next, Self::Refunded | Self::Disputed),
64 Self::Disputed => matches!(next, Self::Completed | Self::Refunded | Self::Cancelled),
65 Self::Failed | Self::Cancelled | Self::Refunded => false,
66 }
67 }
68
69 #[must_use]
71 pub const fn is_terminal(self) -> bool {
72 matches!(self, Self::Failed | Self::Cancelled | Self::Refunded)
73 }
74
75 #[must_use]
77 pub const fn is_successful(self) -> bool {
78 matches!(self, Self::Completed | Self::PartiallyRefunded)
79 }
80
81 #[must_use]
83 pub const fn is_in_progress(self) -> bool {
84 matches!(self, Self::Pending | Self::Processing | Self::RequiresAction)
85 }
86
87 #[must_use]
95 pub const fn is_refundable(self) -> bool {
96 matches!(self, Self::Completed | Self::PartiallyRefunded)
97 }
98}
99
100#[derive(
102 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
103)]
104#[serde(rename_all = "snake_case")]
105#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
106#[non_exhaustive]
107pub enum PaymentMethodType {
108 #[default]
110 CreditCard,
111 DebitCard,
113 #[strum(serialize = "bank_transfer", serialize = "ach")]
115 BankTransfer,
116 #[strum(serialize = "paypal")]
118 PayPal,
119 ApplePay,
121 GooglePay,
123 #[strum(serialize = "crypto", serialize = "cryptocurrency")]
125 Crypto,
126 #[strum(serialize = "stablecoin", serialize = "usdc", serialize = "usdt", serialize = "ssusd")]
128 Stablecoin,
129 StoreCredit,
131 GiftCard,
133 #[strum(serialize = "cash_on_delivery", serialize = "cod")]
135 CashOnDelivery,
136 Invoice,
138 Other,
140}
141
142#[derive(
144 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
145)]
146#[serde(rename_all = "snake_case")]
147#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
148#[non_exhaustive]
149pub enum BlockchainNetwork {
150 #[default]
152 #[strum(serialize = "solana", serialize = "solana_mainnet", serialize = "mainnet-beta")]
153 Solana,
154 #[strum(serialize = "solana_devnet", serialize = "devnet")]
156 SolanaDevnet,
157 #[strum(serialize = "set_chain", serialize = "set", serialize = "ssc")]
159 SetChain,
160 #[strum(serialize = "set_chain_testnet", serialize = "set_testnet")]
162 SetChainTestnet,
163 #[strum(serialize = "ethereum", serialize = "eth")]
165 Ethereum,
166 Base,
168 #[strum(serialize = "arbitrum", serialize = "arb")]
170 Arbitrum,
171 Near,
173 #[strum(serialize = "cosmos", serialize = "atom")]
175 Cosmos,
176}
177
178#[derive(
180 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
181)]
182#[serde(rename_all = "snake_case")]
183#[strum(ascii_case_insensitive)]
184#[non_exhaustive]
185pub enum StablecoinType {
186 #[default]
188 #[strum(serialize = "USDC")]
189 Usdc,
190 #[strum(serialize = "USDT", serialize = "TETHER")]
192 Usdt,
193 #[strum(serialize = "ssUSD", serialize = "SSUSD", serialize = "SS_USD")]
195 SsUsd,
196 #[strum(serialize = "wssUSD", serialize = "WSSUSD", serialize = "WSS_USD")]
198 WssUsd,
199 #[strum(serialize = "DAI")]
201 Dai,
202}
203
204#[derive(
206 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
207)]
208#[serde(rename_all = "snake_case")]
209#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
210#[non_exhaustive]
211pub enum CardBrand {
212 #[default]
213 Unknown,
214 Visa,
215 Mastercard,
216 #[strum(serialize = "amex", serialize = "american_express")]
217 Amex,
218 Discover,
219 #[strum(serialize = "diners_club", serialize = "diners")]
220 DinersClub,
221 Jcb,
222 #[strum(serialize = "union_pay", serialize = "unionpay")]
223 UnionPay,
224}
225
226#[derive(
228 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
229)]
230#[serde(rename_all = "snake_case")]
231#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
232#[non_exhaustive]
233pub enum RefundStatus {
234 #[default]
236 Pending,
237 Processing,
239 Completed,
241 Failed,
243 #[strum(serialize = "cancelled", serialize = "canceled")]
245 Cancelled,
246}
247
248impl RefundStatus {
249 #[must_use]
251 pub fn can_transition_to(self, next: Self) -> bool {
252 if self == next {
253 return true;
254 }
255 match self {
256 Self::Pending => matches!(next, Self::Processing | Self::Cancelled | Self::Failed),
257 Self::Processing => matches!(next, Self::Completed | Self::Failed),
258 Self::Completed | Self::Failed | Self::Cancelled => false,
259 }
260 }
261
262 #[must_use]
264 pub const fn is_in_progress(self) -> bool {
265 matches!(self, Self::Pending | Self::Processing)
266 }
267
268 #[must_use]
270 pub const fn is_terminal(self) -> bool {
271 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
272 }
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct Payment {
278 pub id: PaymentId,
280 pub payment_number: String,
282 pub order_id: Option<OrderId>,
284 pub invoice_id: Option<Uuid>,
286 pub customer_id: Option<CustomerId>,
288 pub status: PaymentTransactionStatus,
290 pub payment_method: PaymentMethodType,
292 pub amount: Decimal,
294 pub currency: CurrencyCode,
296 pub amount_refunded: Decimal,
298 pub external_id: Option<String>,
300 pub idempotency_key: Option<String>,
302 pub processor: Option<String>,
304 pub card_brand: Option<CardBrand>,
306 pub card_last4: Option<String>,
308 pub card_exp_month: Option<i32>,
310 pub card_exp_year: Option<i32>,
312 pub blockchain_network: Option<BlockchainNetwork>,
317 pub stablecoin_type: Option<StablecoinType>,
319 pub from_wallet_address: Option<String>,
321 pub to_wallet_address: Option<String>,
323 pub tx_hash: Option<String>,
325 pub block_number: Option<i64>,
327 pub confirmations: Option<i32>,
329 pub token_address: Option<String>,
331 pub ves_intent_id: Option<String>,
333 pub billing_email: Option<String>,
336 pub billing_name: Option<String>,
338 pub billing_address: Option<String>,
340 pub description: Option<String>,
342 pub failure_reason: Option<String>,
344 pub failure_code: Option<String>,
346 pub metadata: Option<String>,
348 pub paid_at: Option<DateTime<Utc>>,
350 pub version: i32,
352 pub created_at: DateTime<Utc>,
354 pub updated_at: DateTime<Utc>,
356}
357
358impl Payment {
359 #[must_use]
361 pub fn refundable_remaining(&self) -> Decimal {
362 self.amount - self.amount_refunded
363 }
364
365 pub fn validate_refund(&self, requested: Option<Decimal>) -> crate::Result<Decimal> {
379 use crate::CommerceError;
380
381 if !self.status.is_refundable() {
382 return Err(CommerceError::ValidationError(format!(
383 "Cannot refund a payment in status '{}'; only completed or partially refunded payments are refundable",
384 self.status
385 )));
386 }
387
388 let remaining = self.refundable_remaining();
389 let amount = requested.unwrap_or(remaining);
390
391 if amount <= Decimal::ZERO {
392 return Err(CommerceError::ValidationError(
393 "Refund amount must be greater than zero".into(),
394 ));
395 }
396
397 if amount > remaining {
398 return Err(CommerceError::ValidationError(format!(
399 "Refund amount {amount} exceeds remaining refundable balance {remaining}"
400 )));
401 }
402
403 Ok(amount)
404 }
405}
406
407#[derive(Debug, Clone, Default, Serialize, Deserialize)]
409pub struct CreatePayment {
410 pub order_id: Option<OrderId>,
412 pub invoice_id: Option<Uuid>,
414 pub customer_id: Option<CustomerId>,
416 pub payment_method: PaymentMethodType,
418 pub amount: Decimal,
420 pub currency: Option<CurrencyCode>,
422 pub external_id: Option<String>,
424 pub idempotency_key: Option<String>,
426 pub processor: Option<String>,
428 pub card_brand: Option<CardBrand>,
430 pub card_last4: Option<String>,
432 pub card_exp_month: Option<i32>,
434 pub card_exp_year: Option<i32>,
436 pub blockchain_network: Option<BlockchainNetwork>,
441 pub stablecoin_type: Option<StablecoinType>,
443 pub from_wallet_address: Option<String>,
445 pub to_wallet_address: Option<String>,
447 pub token_address: Option<String>,
449 pub billing_email: Option<String>,
452 pub billing_name: Option<String>,
454 pub billing_address: Option<String>,
456 pub description: Option<String>,
458 pub metadata: Option<String>,
460}
461
462impl Validate for CreatePayment {
463 fn validate(&self) -> Result<()> {
469 ValidationBuilder::new()
470 .non_negative("amount", self.amount)
471 .email_if_present("billing_email", self.billing_email.as_deref())
472 .build()
473 }
474}
475
476#[derive(Debug, Clone, Default, Serialize, Deserialize)]
478pub struct UpdatePayment {
479 pub status: Option<PaymentTransactionStatus>,
481 pub external_id: Option<String>,
483 pub failure_reason: Option<String>,
485 pub failure_code: Option<String>,
487 pub metadata: Option<String>,
489 pub tx_hash: Option<String>,
494 pub block_number: Option<i64>,
496 pub confirmations: Option<i32>,
498 pub ves_intent_id: Option<String>,
500}
501
502#[derive(Debug, Clone, Default, Serialize, Deserialize)]
504pub struct PaymentFilter {
505 pub order_id: Option<OrderId>,
507 pub invoice_id: Option<Uuid>,
509 pub customer_id: Option<CustomerId>,
511 pub status: Option<PaymentTransactionStatus>,
513 pub payment_method: Option<PaymentMethodType>,
515 pub processor: Option<String>,
517 pub currency: Option<CurrencyCode>,
519 pub min_amount: Option<Decimal>,
521 pub max_amount: Option<Decimal>,
523 pub from_date: Option<DateTime<Utc>>,
525 pub to_date: Option<DateTime<Utc>>,
527 pub limit: Option<u32>,
529 pub offset: Option<u32>,
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize)]
535pub struct Refund {
536 pub id: Uuid,
538 pub refund_number: String,
540 pub payment_id: PaymentId,
542 pub status: RefundStatus,
544 pub amount: Decimal,
546 pub currency: CurrencyCode,
548 pub reason: Option<String>,
550 pub external_id: Option<String>,
552 pub idempotency_key: Option<String>,
554 pub failure_reason: Option<String>,
556 pub notes: Option<String>,
558 pub refunded_at: Option<DateTime<Utc>>,
560 pub created_at: DateTime<Utc>,
562 pub updated_at: DateTime<Utc>,
564}
565
566#[derive(Debug, Clone, Default, Serialize, Deserialize)]
568pub struct CreateRefund {
569 pub payment_id: PaymentId,
571 pub amount: Option<Decimal>,
573 pub reason: Option<String>,
575 pub external_id: Option<String>,
577 pub idempotency_key: Option<String>,
579 pub notes: Option<String>,
581}
582
583impl Validate for CreateRefund {
584 fn validate(&self) -> Result<()> {
592 ValidationBuilder::new().uuid_not_nil("payment_id", self.payment_id.into_uuid()).build()
593 }
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct PaymentMethod {
599 pub id: Uuid,
601 pub customer_id: CustomerId,
603 pub method_type: PaymentMethodType,
605 pub is_default: bool,
607 pub card_brand: Option<CardBrand>,
609 pub card_last4: Option<String>,
611 pub card_exp_month: Option<i32>,
613 pub card_exp_year: Option<i32>,
615 pub cardholder_name: Option<String>,
617 pub bank_name: Option<String>,
619 pub account_last4: Option<String>,
621 pub wallet_address: Option<String>,
626 pub blockchain_network: Option<BlockchainNetwork>,
628 pub stablecoin_type: Option<StablecoinType>,
630 pub external_id: Option<String>,
633 pub billing_address: Option<String>,
635 pub created_at: DateTime<Utc>,
637 pub updated_at: DateTime<Utc>,
639}
640
641#[derive(Debug, Clone, Default, Serialize, Deserialize)]
643pub struct CreatePaymentMethod {
644 pub customer_id: CustomerId,
646 pub method_type: PaymentMethodType,
648 pub is_default: Option<bool>,
650 pub card_brand: Option<CardBrand>,
652 pub card_last4: Option<String>,
654 pub card_exp_month: Option<i32>,
656 pub card_exp_year: Option<i32>,
658 pub cardholder_name: Option<String>,
660 pub bank_name: Option<String>,
662 pub account_last4: Option<String>,
664 pub wallet_address: Option<String>,
669 pub blockchain_network: Option<BlockchainNetwork>,
671 pub stablecoin_type: Option<StablecoinType>,
673 pub external_id: Option<String>,
676 pub billing_address: Option<String>,
678}
679
680#[must_use]
682pub fn generate_payment_number() -> String {
683 generate_number("PAY")
684}
685
686#[must_use]
688pub fn generate_refund_number() -> String {
689 generate_number("REF")
690}
691
692fn generate_number(prefix: &str) -> String {
693 let now = chrono::Utc::now();
694 let timestamp = now.timestamp_millis();
695 let entropy = uuid::Uuid::new_v4().simple().to_string();
696 format!("{prefix}-{timestamp}-{}", entropy[..12].to_ascii_uppercase())
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702
703 #[test]
704 fn payment_number_has_prefix_and_entropy_suffix() {
705 let value = generate_payment_number();
706 assert!(value.starts_with("PAY-"));
707 let parts: Vec<&str> = value.split('-').collect();
708 assert_eq!(parts.len(), 3);
709 assert_eq!(parts[2].len(), 12);
710 }
711
712 #[test]
713 fn refund_number_has_prefix_and_entropy_suffix() {
714 let value = generate_refund_number();
715 assert!(value.starts_with("REF-"));
716 let parts: Vec<&str> = value.split('-').collect();
717 assert_eq!(parts.len(), 3);
718 assert_eq!(parts[2].len(), 12);
719 }
720
721 #[test]
722 fn generated_numbers_are_not_equal() {
723 assert_ne!(generate_payment_number(), generate_payment_number());
724 assert_ne!(generate_refund_number(), generate_refund_number());
725 }
726
727 #[test]
728 fn payment_status_valid_transitions() {
729 use PaymentTransactionStatus::*;
730 assert!(Pending.can_transition_to(Processing));
732 assert!(Pending.can_transition_to(Cancelled));
733 assert!(Pending.can_transition_to(Failed));
734 assert!(Processing.can_transition_to(RequiresAction));
736 assert!(Processing.can_transition_to(Completed));
737 assert!(Processing.can_transition_to(Failed));
738 assert!(Processing.can_transition_to(Cancelled));
739 assert!(RequiresAction.can_transition_to(Processing));
741 assert!(RequiresAction.can_transition_to(Completed));
742 assert!(Completed.can_transition_to(Refunded));
744 assert!(Completed.can_transition_to(PartiallyRefunded));
745 assert!(Completed.can_transition_to(Disputed));
746 assert!(PartiallyRefunded.can_transition_to(Refunded));
748 assert!(PartiallyRefunded.can_transition_to(Disputed));
749 assert!(Disputed.can_transition_to(Completed));
751 assert!(Disputed.can_transition_to(Refunded));
752 assert!(Disputed.can_transition_to(Cancelled));
753 }
754
755 #[test]
756 fn payment_status_invalid_transitions() {
757 use PaymentTransactionStatus::*;
758 assert!(!Pending.can_transition_to(Completed));
759 assert!(!Pending.can_transition_to(Refunded));
760 assert!(!Completed.can_transition_to(Pending));
761 assert!(!Completed.can_transition_to(Processing));
762 assert!(!PartiallyRefunded.can_transition_to(Pending));
763 }
764
765 #[test]
766 fn payment_status_terminal_states() {
767 use PaymentTransactionStatus::*;
768 assert!(Failed.is_terminal());
769 assert!(Cancelled.is_terminal());
770 assert!(Refunded.is_terminal());
771 assert!(!Pending.is_terminal());
772 assert!(!Processing.is_terminal());
773 assert!(!Completed.is_terminal());
774 assert!(!Failed.can_transition_to(Pending));
776 assert!(!Cancelled.can_transition_to(Processing));
777 assert!(!Refunded.can_transition_to(Completed));
778 }
779
780 #[test]
781 fn payment_status_self_transitions() {
782 use PaymentTransactionStatus::*;
783 assert!(Pending.can_transition_to(Pending));
784 assert!(Processing.can_transition_to(Processing));
785 assert!(Failed.can_transition_to(Failed));
786 }
787
788 #[test]
789 fn payment_status_is_successful() {
790 use PaymentTransactionStatus::*;
791 assert!(Completed.is_successful());
792 assert!(PartiallyRefunded.is_successful());
793 assert!(!Pending.is_successful());
794 assert!(!Failed.is_successful());
795 assert!(!Refunded.is_successful());
796 }
797
798 #[test]
799 fn refund_status_valid_transitions() {
800 use RefundStatus::*;
801 assert!(Pending.can_transition_to(Processing));
802 assert!(Pending.can_transition_to(Cancelled));
803 assert!(Pending.can_transition_to(Failed));
804 assert!(Processing.can_transition_to(Completed));
805 assert!(Processing.can_transition_to(Failed));
806 }
807
808 #[test]
809 fn refund_status_invalid_transitions() {
810 use RefundStatus::*;
811 assert!(!Pending.can_transition_to(Completed));
812 assert!(!Processing.can_transition_to(Cancelled));
813 assert!(!Completed.can_transition_to(Pending));
814 assert!(!Failed.can_transition_to(Processing));
815 }
816
817 #[test]
818 fn refund_status_terminal_states() {
819 use RefundStatus::*;
820 assert!(Completed.is_terminal());
821 assert!(Failed.is_terminal());
822 assert!(Cancelled.is_terminal());
823 assert!(!Pending.is_terminal());
824 assert!(!Processing.is_terminal());
825 }
826
827 #[test]
828 fn payment_status_is_refundable() {
829 use PaymentTransactionStatus::*;
830 assert!(Completed.is_refundable());
832 assert!(PartiallyRefunded.is_refundable());
833 assert!(!Pending.is_refundable());
835 assert!(!Processing.is_refundable());
836 assert!(!RequiresAction.is_refundable());
837 assert!(!Failed.is_refundable());
838 assert!(!Cancelled.is_refundable());
839 assert!(!Refunded.is_refundable());
840 assert!(!Disputed.is_refundable());
841 }
842
843 fn payment_for_refund(
845 status: PaymentTransactionStatus,
846 amount: Decimal,
847 amount_refunded: Decimal,
848 ) -> Payment {
849 let now = Utc::now();
850 Payment {
851 id: PaymentId::new(),
852 payment_number: generate_payment_number(),
853 order_id: None,
854 invoice_id: None,
855 customer_id: None,
856 status,
857 payment_method: PaymentMethodType::CreditCard,
858 amount,
859 currency: CurrencyCode::default(),
860 amount_refunded,
861 external_id: None,
862 idempotency_key: None,
863 processor: None,
864 card_brand: None,
865 card_last4: None,
866 card_exp_month: None,
867 card_exp_year: None,
868 blockchain_network: None,
869 stablecoin_type: None,
870 from_wallet_address: None,
871 to_wallet_address: None,
872 tx_hash: None,
873 block_number: None,
874 confirmations: None,
875 token_address: None,
876 ves_intent_id: None,
877 billing_email: None,
878 billing_name: None,
879 billing_address: None,
880 description: None,
881 failure_reason: None,
882 failure_code: None,
883 metadata: None,
884 paid_at: None,
885 version: 1,
886 created_at: now,
887 updated_at: now,
888 }
889 }
890
891 #[test]
892 fn validate_refund_resolves_full_remaining_when_amount_omitted() {
893 use rust_decimal_macros::dec;
894 let payment = payment_for_refund(PaymentTransactionStatus::Completed, dec!(100), dec!(40));
895 assert_eq!(payment.validate_refund(None).expect("resolved"), dec!(60));
897 }
898
899 #[test]
900 fn validate_refund_rejects_non_refundable_status() {
901 use rust_decimal_macros::dec;
902 for status in [
903 PaymentTransactionStatus::Pending,
904 PaymentTransactionStatus::Processing,
905 PaymentTransactionStatus::RequiresAction,
906 PaymentTransactionStatus::Failed,
907 PaymentTransactionStatus::Cancelled,
908 PaymentTransactionStatus::Refunded,
909 PaymentTransactionStatus::Disputed,
910 ] {
911 let payment = payment_for_refund(status, dec!(100), dec!(0));
912 let err = payment
913 .validate_refund(Some(dec!(10)))
914 .expect_err("non-refundable status must be rejected");
915 assert!(matches!(err, crate::CommerceError::ValidationError(_)), "{status}: {err:?}");
916 }
917 }
918
919 #[test]
920 fn validate_refund_rejects_non_positive_amount() {
921 use rust_decimal_macros::dec;
922 let payment = payment_for_refund(PaymentTransactionStatus::Completed, dec!(100), dec!(0));
923 assert!(payment.validate_refund(Some(dec!(0))).is_err());
924 assert!(payment.validate_refund(Some(dec!(-1))).is_err());
925 }
926
927 #[test]
928 fn validate_refund_rejects_amount_exceeding_remaining() {
929 use rust_decimal_macros::dec;
930 let payment =
931 payment_for_refund(PaymentTransactionStatus::PartiallyRefunded, dec!(100), dec!(70));
932 assert!(payment.validate_refund(Some(dec!(31))).is_err());
934 assert_eq!(payment.validate_refund(Some(dec!(30))).expect("ok"), dec!(30));
935 }
936
937 #[test]
938 fn create_payment_rejects_negative_amount() {
939 use crate::Validate;
940 use rust_decimal_macros::dec;
941 let input = CreatePayment { amount: dec!(-1), ..Default::default() };
942 let err = input.validate().expect_err("negative amount must be rejected");
943 assert!(
944 matches!(err, crate::CommerceError::InvalidInput { ref field, .. } if field == "amount")
945 );
946 }
947
948 #[test]
949 fn create_payment_accepts_zero_and_positive_amount() {
950 use crate::Validate;
951 use rust_decimal_macros::dec;
952 assert!(CreatePayment { amount: dec!(0), ..Default::default() }.validate().is_ok());
954 assert!(CreatePayment { amount: dec!(99.99), ..Default::default() }.validate().is_ok());
955 }
956
957 #[test]
958 fn create_payment_rejects_malformed_billing_email() {
959 use crate::Validate;
960 use rust_decimal_macros::dec;
961 let input = CreatePayment {
962 amount: dec!(10),
963 billing_email: Some("not-an-email".to_string()),
964 ..Default::default()
965 };
966 let err = input.validate().expect_err("malformed billing email must be rejected");
967 assert!(
968 matches!(err, crate::CommerceError::InvalidInput { ref field, .. } if field == "billing_email")
969 );
970 }
971
972 #[test]
973 fn create_payment_accepts_valid_billing_email() {
974 use crate::Validate;
975 use rust_decimal_macros::dec;
976 let input = CreatePayment {
977 amount: dec!(10),
978 billing_email: Some("alice@example.com".to_string()),
979 ..Default::default()
980 };
981 assert!(input.validate().is_ok());
982 }
983
984 #[test]
985 fn create_refund_rejects_nil_payment_id() {
986 use crate::Validate;
987 use rust_decimal_macros::dec;
988 let input = CreateRefund {
989 payment_id: PaymentId::from_uuid(Uuid::nil()),
990 amount: Some(dec!(5)),
991 ..Default::default()
992 };
993 let err = input.validate().expect_err("a nil payment reference must be rejected");
994 assert!(
995 matches!(err, crate::CommerceError::InvalidInput { ref field, .. } if field == "payment_id")
996 );
997 }
998
999 #[test]
1000 fn create_refund_accepts_valid_payment_reference() {
1001 use crate::Validate;
1002 use rust_decimal_macros::dec;
1003 let id = PaymentId::new();
1004 assert!(
1007 CreateRefund { payment_id: id, amount: None, ..Default::default() }.validate().is_ok()
1008 );
1009 assert!(
1010 CreateRefund { payment_id: id, amount: Some(dec!(25)), ..Default::default() }
1011 .validate()
1012 .is_ok()
1013 );
1014 }
1015}