From 9531c4ff1042f9d04905c0491c6735ddaca78e37 Mon Sep 17 00:00:00 2001 From: Yasir Shariff Date: Sat, 18 Nov 2023 18:16:03 +0300 Subject: [PATCH] Add validator module and implement phone number validation --- src/lib.rs | 1 + src/services/express_request.rs | 9 ++ src/services/transaction_reversal.rs | 45 ++++++++-- src/validator.rs | 90 +++++++++++++++++++ tests/mpesa-rust/stk_push_test.rs | 4 +- tests/mpesa-rust/transaction_reversal_test.rs | 44 +++++++++ 6 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 src/validator.rs diff --git a/src/lib.rs b/src/lib.rs index 253b32ab9..3a98a37ff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ mod constants; pub mod environment; mod errors; pub mod services; +pub mod validator; pub use client::Mpesa; pub use constants::{ diff --git a/src/services/express_request.rs b/src/services/express_request.rs index 7721f9744..6be361718 100644 --- a/src/services/express_request.rs +++ b/src/services/express_request.rs @@ -9,6 +9,7 @@ use crate::client::Mpesa; use crate::constants::CommandId; use crate::environment::ApiEnvironment; use crate::errors::{MpesaError, MpesaResult}; +use crate::validator::PhoneValidator; /// The default passkey for the sandbox environment /// Source: [test credentials](https://developer.safaricom.co.ke/test_credentials) @@ -188,6 +189,14 @@ impl MpesaExpressBuilder<'_, Env> { )); } + if let Some(phone_number) = self.phone_number { + phone_number.validate()?; + } + + if let Some(party_a) = self.party_a { + party_a.validate()?; + } + Ok(()) } } diff --git a/src/services/transaction_reversal.rs b/src/services/transaction_reversal.rs index 342bc08a5..85a5630bd 100644 --- a/src/services/transaction_reversal.rs +++ b/src/services/transaction_reversal.rs @@ -9,21 +9,33 @@ const TRANSACTION_REVERSAL_URL: &str = "/mpesa/reversal/v1/request"; #[derive(Debug, Serialize)] #[serde(rename_all = "PascalCase")] pub struct TransactionReversalRequest<'mpesa> { + /// The name of the initiator to initiate the request. pub initiator: &'mpesa str, + /// Encrypted Credential of user getting transaction reversed. pub security_credential: String, + /// Unique command for each transaction type. #[serde(rename = "CommandID")] pub command_id: CommandId, + /// This is the Mpesa Transaction ID of the transaction which you wish to #[serde(rename = "TransactionID")] pub transaction_id: &'mpesa str, - receiver_party: &'mpesa str, - receiver_identifier_type: IdentifierTypes, + /// The organization that receives the transaction. + pub receiver_party: &'mpesa str, + /// Type of organization that receives the transaction. + pub receiver_identifier_type: IdentifierTypes, + /// The path that stores information about the transaction. #[serde(rename = "ResultURL")] - result_url: Url, + pub result_url: Url, + /// The path that stores information about the time-out transaction. #[serde(rename = "QueueTimeOutURL")] - queue_timeout_url: Url, - remarks: &'mpesa str, - occasion: Option<&'mpesa str>, - amount: f64, + pub queue_timeout_url: Url, + /// Comments that are sent along with the transaction. + pub remarks: &'mpesa str, + /// Comments that are sent along with the transaction. + pub occasion: Option<&'mpesa str>, + /// The amount transacted in the transaction is to be reversed, down to the + /// cent. + pub amount: f64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -107,6 +119,25 @@ impl<'mpesa, Env: ApiEnvironment> TransactionReversal<'mpesa, Env> { TransactionReversalBuilder::default().client(client) } + /// Creates a new `TransactionReversal` from a `TransactionReversalRequest` + pub fn from_request( + client: &'mpesa Mpesa, + request: TransactionReversalRequest<'mpesa>, + ) -> TransactionReversal<'mpesa, Env> { + TransactionReversal { + client, + initiator: request.initiator, + transaction_id: request.transaction_id, + receiver_party: request.receiver_party, + receiver_identifier_type: request.receiver_identifier_type, + result_url: request.result_url, + timeout_url: request.queue_timeout_url, + remarks: request.remarks, + occasion: request.occasion, + amount: request.amount, + } + } + /// # Transaction Reversal API /// /// Requests for transaction reversal diff --git a/src/validator.rs b/src/validator.rs new file mode 100644 index 000000000..892eb01a4 --- /dev/null +++ b/src/validator.rs @@ -0,0 +1,90 @@ +use crate::{MpesaError, MpesaResult}; + +pub trait PhoneValidator { + fn validate(&self) -> MpesaResult<()>; +} + +impl PhoneValidator for &str { + fn validate(&self) -> MpesaResult<()> { + if self.starts_with("254") + && self.len() == 12 + && self.chars().skip(3).all(|c| c.is_ascii_digit()) + { + Ok(()) + } else { + Err(MpesaError::Message( + "Invalid phone number, must be in the format 2547XXXXXXXX", + )) + } + } +} + +impl PhoneValidator for String { + fn validate(&self) -> MpesaResult<()> { + self.as_str().validate() + } +} + +impl PhoneValidator for u64 { + fn validate(&self) -> MpesaResult<()> { + self.to_string().validate() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_phone() { + assert!("254712345678".validate().is_ok()); + assert!("254012345678".validate().is_ok()); + assert!("254712345678900".validate().is_err()); + assert!("25471234567".validate().is_err()); + assert!("2547".validate().is_err()); + assert!("2547a".validate().is_err()); + assert!("254".validate().is_err()); + assert!("254a".validate().is_err()); + assert!("25".validate().is_err()); + assert!("25a".validate().is_err()); + assert!("2".validate().is_err()); + assert!("2a".validate().is_err()); + assert!("".validate().is_err()); + assert!("a".validate().is_err()); + } + + #[test] + fn test_validate_phone_string() { + assert!("254712345678".to_string().validate().is_ok()); + assert!("254012345678".to_string().validate().is_ok()); + assert!("254712345678900".to_string().validate().is_err()); + assert!("25471234567".to_string().validate().is_err()); + assert!("2547".to_string().validate().is_err()); + assert!("2547a".to_string().validate().is_err()); + assert!("254".to_string().validate().is_err()); + assert!("254a".to_string().validate().is_err()); + assert!("25".to_string().validate().is_err()); + assert!("25a".to_string().validate().is_err()); + assert!("2".to_string().validate().is_err()); + assert!("2a".to_string().validate().is_err()); + assert!("".to_string().validate().is_err()); + assert!("a".to_string().validate().is_err()); + } + + #[test] + fn test_validate_phone_u64() { + assert!(254712345678u64.validate().is_ok()); + assert!(254012345678u64.validate().is_ok()); + assert!(254712345678900u64.validate().is_err()); + assert!(25471234567u64.validate().is_err()); + assert!(2547u64.validate().is_err()); + assert!(2547u64.validate().is_err()); + assert!(254u64.validate().is_err()); + assert!(254u64.validate().is_err()); + assert!(25u64.validate().is_err()); + assert!(25u64.validate().is_err()); + assert!(2u64.validate().is_err()); + assert!(2u64.validate().is_err()); + assert!(0u64.validate().is_err()); + } +} diff --git a/tests/mpesa-rust/stk_push_test.rs b/tests/mpesa-rust/stk_push_test.rs index e843f9f19..70b781953 100644 --- a/tests/mpesa-rust/stk_push_test.rs +++ b/tests/mpesa-rust/stk_push_test.rs @@ -73,10 +73,10 @@ async fn stk_push_only_accepts_specific_tx_type() { .express_request() .business_short_code("174379") .transaction_type(mpesa::CommandId::SalaryPayment) - .party_a("254708374149") + .party_a("254704837414") .party_b("174379") .account_ref("test") - .phone_number("254708374149") + .phone_number("254708437414") .amount(500) .pass_key("test") .try_callback_url("https://test.example.com/api") diff --git a/tests/mpesa-rust/transaction_reversal_test.rs b/tests/mpesa-rust/transaction_reversal_test.rs index 77cd990fd..2621e774d 100644 --- a/tests/mpesa-rust/transaction_reversal_test.rs +++ b/tests/mpesa-rust/transaction_reversal_test.rs @@ -1,3 +1,4 @@ +use mpesa::services::{TransactionReversal, TransactionReversalRequest}; use mpesa::IdentifierTypes; use serde_json::json; use wiremock::matchers::{method, path}; @@ -45,3 +46,46 @@ async fn transaction_reversal_success() { "Accept the service request successfully." ); } + +#[tokio::test] +async fn transaction_reversal_test_using_struct_initialization() { + let (client, server) = get_mpesa_client!(); + let sample_response_body = json!({ + "OriginatorConversationID": "29464-48063588-1", + "ConversationID": "AG_20230206_201056794190723278ff", + "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/reversal/v1/request")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(1) + .mount(&server) + .await; + + let payload = TransactionReversalRequest { + initiator: "testapi496", + security_credential: "testapi496".to_string(), + command_id: mpesa::CommandId::TransactionReversal, + transaction_id: "OEI2AK4Q16", + receiver_party: "600111", + receiver_identifier_type: IdentifierTypes::ShortCode, + result_url: "https://testdomain.com/ok".parse().unwrap(), + queue_timeout_url: "https://testdomain.com/err".parse().unwrap(), + remarks: "wrong recipient", + occasion: None, + amount: 1.0, + }; + + let response = TransactionReversal::from_request(&client, payload) + .send() + .await + .unwrap(); + + assert_eq!(response.originator_conversation_id, "29464-48063588-1"); + assert_eq!(response.conversation_id, "AG_20230206_201056794190723278ff"); + assert_eq!( + response.response_description, + "Accept the service request successfully." + ); +}