From 599acf10fc7ba2e94b9ef41282314783e8fb50dd Mon Sep 17 00:00:00 2001 From: Yasir Shariff Date: Mon, 29 Jan 2024 08:40:25 +0530 Subject: [PATCH 01/10] feat(service): Add M-Pesa Express Query service --- src/services/express/express_query.rs | 170 ++++++++++++++++++ src/services/{ => express}/express_request.rs | 2 +- src/services/express/mod.rs | 7 + src/services/mod.rs | 6 +- 4 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 src/services/express/express_query.rs rename src/services/{ => express}/express_request.rs (99%) create mode 100644 src/services/express/mod.rs diff --git a/src/services/express/express_query.rs b/src/services/express/express_query.rs new file mode 100644 index 000000000..c5565be46 --- /dev/null +++ b/src/services/express/express_query.rs @@ -0,0 +1,170 @@ +// #![doc = include_str!("../../docs/client/express_request.md")] + +use chrono::prelude::Local; +use chrono::DateTime; +use derive_builder::Builder; +use openssl::base64; +use serde::{Deserialize, Serialize}; + +use crate::client::Mpesa; + +use crate::errors::{MpesaError, MpesaResult}; + +/// Source: [test credentials](https://developer.safaricom.co.ke/test_credentials) +pub static DEFAULT_PASSKEY: &str = + "bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919"; + +const EXPRESS_QUERY_URL: &str = "mpesa/stkpushquery/v1/query"; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct MpesaExpressQueryRequest<'mpesa> { + /// This is the organization's shortcode (Paybill or Buygoods - A 5 to + /// 6-digit account number) used to identify an organization and receive + /// the transaction. + pub business_short_code: &'mpesa str, + /// This is the password used for encrypting the request sent: + pub password: String, + /// This is the Timestamp of the transaction, normally in the format of + /// (YYYYMMDDHHMMSS) + #[serde(serialize_with = "serialize_utc_to_string")] + pub timestamp: DateTime, + ///This is a global unique identifier of the processed checkout transaction + /// request. + #[serde(rename = "CheckoutRequestID")] + pub checkout_request_id: &'mpesa str, +} + +fn serialize_utc_to_string(date: &DateTime, serializer: S) -> Result +where + S: serde::Serializer, +{ + let s = date.format("%Y%m%d%H%M%S").to_string(); + serializer.serialize_str(&s) +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct MpesaExpressQueryResponse { + ///This is a global unique identifier of the processed checkout transaction + /// request. + #[serde(rename = "CheckoutRequestID")] + pub checkout_request_id: String, + #[serde(rename = "MerchantRequestID")] + pub merchant_request_id: String, + /// This is a Numeric status code that indicates the status of the + /// transaction submission. 0 means successful submission and any other + /// code means an error occurred. + pub response_code: String, + ///Response description is an acknowledgment message from the API that + /// gives the status of the request submission. It usually maps to a + /// specific ResponseCode value. + /// + /// It can be a Success submission message or an error description. + pub response_description: String, + + /// This is a Numeric status code that indicates the status of the + /// transaction submission. 0 means successful submission and any other + /// code means an error occurred. + pub result_code: String, + ///Response description is an acknowledgment message from the API that + /// gives the status of the request submission. It usually maps to a + /// specific ResponseCode value. + pub result_desc: String, +} + +#[derive(Builder, Debug, Clone)] +#[builder(build_fn(error = "MpesaError"))] +pub struct MpesaExpressQuery<'mpesa> { + #[builder(pattern = "immutable")] + client: &'mpesa Mpesa, + /// This is the organization's shortcode (Paybill or Buygoods - A 5 to + /// 6-digit account number) used to identify an organization and receive + /// the transaction. + #[builder(setter(into))] + business_short_code: &'mpesa str, + + /// This is the password used for encrypting the request sent: + /// The password for encrypting the request is obtained by base64 encoding + /// BusinessShortCode, Passkey and Timestamp. + /// The timestamp format is YYYYMMDDHHmmss + #[builder(setter(into))] + pass_key: Option<&'mpesa str>, + + /// This is a global unique identifier of the processed checkout transaction + /// request. + #[builder(setter(into))] + checkout_request_id: &'mpesa str, +} + +impl<'mpesa> From> for MpesaExpressQueryRequest<'mpesa> { + fn from(express: MpesaExpressQuery<'mpesa>) -> MpesaExpressQueryRequest<'mpesa> { + let timestamp = chrono::Local::now(); + + let encoded_password = + MpesaExpressQuery::encode_password(express.business_short_code, express.pass_key); + + MpesaExpressQueryRequest { + business_short_code: express.business_short_code, + password: encoded_password, + timestamp, + checkout_request_id: express.checkout_request_id, + } + } +} + +impl<'mpesa> MpesaExpressQuery<'mpesa> { + /// Creates new `MpesaExpressQueryBuilder` + pub(crate) fn builder(client: &'mpesa Mpesa) -> MpesaExpressQueryBuilder<'mpesa> { + MpesaExpressQueryBuilder::default().client(client) + } + + /// Encodes the password for the request + /// The password for encrypting the request is obtained by base64 encoding + /// BusinessShortCode, Passkey and Timestamp. + /// The timestamp format is YYYYMMDDHHmmss + pub fn encode_password(business_short_code: &str, pass_key: Option<&'mpesa str>) -> String { + let timestamp = chrono::Local::now().format("%Y%m%d%H%M%S").to_string(); + base64::encode_block( + format!( + "{}{}{}", + business_short_code, + pass_key.unwrap_or(DEFAULT_PASSKEY), + timestamp + ) + .as_bytes(), + ) + } + + /// Creates a new `MpesaExpressQuery` from a `MpesaExpressQueryRequest` + pub fn from_request( + client: &'mpesa Mpesa, + request: MpesaExpressQueryRequest<'mpesa>, + pass_key: Option<&'mpesa str>, + ) -> MpesaExpressQuery<'mpesa> { + MpesaExpressQuery { + client, + business_short_code: request.business_short_code, + checkout_request_id: request.checkout_request_id, + pass_key, + } + } + + /// # Lipa na M-Pesa Online Payment / Mpesa Express/ Stk push + /// + /// Initiates a M-Pesa transaction on behalf of a customer using STK Push + /// + /// A successful request returns a `MpesaExpressQueryResponse` type + /// + /// # Errors + /// Returns a `MpesaError` on failure + pub async fn send(self) -> MpesaResult { + self.client + .send::(crate::client::Request { + method: reqwest::Method::POST, + path: EXPRESS_QUERY_URL, + body: self.into(), + }) + .await + } +} diff --git a/src/services/express_request.rs b/src/services/express/express_request.rs similarity index 99% rename from src/services/express_request.rs rename to src/services/express/express_request.rs index 59828b27a..a515fcce2 100644 --- a/src/services/express_request.rs +++ b/src/services/express/express_request.rs @@ -1,4 +1,4 @@ -#![doc = include_str!("../../docs/client/express_request.md")] +// #![doc = include_str!("../../docs/client/express_request.md")] use chrono::prelude::Local; use chrono::DateTime; diff --git a/src/services/express/mod.rs b/src/services/express/mod.rs new file mode 100644 index 000000000..112e2514d --- /dev/null +++ b/src/services/express/mod.rs @@ -0,0 +1,7 @@ +pub mod express_query; +pub mod express_request; + +pub use express_query::{MpesaExpressQuery, MpesaExpressQueryBuilder, MpesaExpressQueryResponse}; +pub use express_request::{ + MpesaExpress, MpesaExpressBuilder, MpesaExpressRequest, MpesaExpressResponse, +}; diff --git a/src/services/mod.rs b/src/services/mod.rs index 5f2acc123..8e2278540 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -24,7 +24,7 @@ mod bill_manager; mod c2b_register; mod c2b_simulate; mod dynamic_qr; -mod express_request; +mod express; mod transaction_reversal; mod transaction_status; @@ -43,9 +43,7 @@ pub use c2b_simulate::{C2bSimulateBuilder, C2bSimulateResponse}; #[cfg(feature = "dynamic_qr")] pub use dynamic_qr::{DynamicQR, DynamicQRBuilder, DynamicQRRequest, DynamicQRResponse}; #[cfg(feature = "express_request")] -pub use express_request::{ - MpesaExpress, MpesaExpressBuilder, MpesaExpressRequest, MpesaExpressResponse, -}; +pub use express::{MpesaExpress, MpesaExpressBuilder, MpesaExpressRequest, MpesaExpressResponse}; #[cfg(feature = "transaction_reversal")] pub use transaction_reversal::{ TransactionReversal, TransactionReversalBuilder, TransactionReversalRequest, From 86d65c9198b56b05e1607ae1b6ea846b29e24936 Mon Sep 17 00:00:00 2001 From: Yasir Shariff Date: Mon, 29 Jan 2024 09:18:54 +0530 Subject: [PATCH 02/10] feat: add docs and doc tests --- Cargo.toml | 10 +++--- docs/client/express_request.md | 41 ++++++++++++++++++++++--- src/client.rs | 12 ++++++-- src/services/express/express_query.rs | 10 ++---- src/services/express/express_request.rs | 8 ++--- src/services/express/mod.rs | 4 +++ src/services/mod.rs | 7 +++-- 7 files changed, 65 insertions(+), 27 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6093a777b..92339da89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ default = [ "bill_manager", "c2b_register", "c2b_simulate", - "express_request", + "express", "transaction_reversal", "transaction_status", "dynamic_qr", @@ -29,20 +29,20 @@ b2c = ["dep:openssl"] bill_manager = ["dep:chrono"] c2b_register = [] c2b_simulate = [] -express_request = ["dep:chrono"] +express = ["dep:chrono"] transaction_reversal = ["dep:openssl"] transaction_status = ["dep:openssl"] [dependencies] -cached = { version = "0.46", features = ["wasm", "async", "proc_macro"] } +cached = { version = "0.48", features = ["wasm", "async", "proc_macro"] } chrono = { version = "0.4", optional = true, default-features = false, features = [ "clock", "serde", ] } openssl = { version = "0.10", optional = true } reqwest = { version = "0.11", features = ["json"] } -derive_builder = "0.12" +derive_builder = "0.13" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_repr = "0.1" @@ -54,6 +54,6 @@ regex = { version = "1.10", default-features = false, features = ["std"] } [dev-dependencies] -dotenvy = "0.15.7" +dotenvy = "0.15" tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } wiremock = "0.5" diff --git a/docs/client/express_request.md b/docs/client/express_request.md index a58a95dcb..314da837b 100644 --- a/docs/client/express_request.md +++ b/docs/client/express_request.md @@ -1,4 +1,6 @@ -# Express Request +# Mpesa Express + +## Mpese Express Request Lipa na M-PESA online API also known as M-PESA express (STK Push/NI push) is a Merchant/Business initiated C2B (Customer to Business) Payment. @@ -9,9 +11,7 @@ returns a `MpesaExpressRequestBuilder` struct Safaricom API docs [reference](https://developer.safaricom.co.ke/APIs/MpesaExpressSimulate) -## Example - -TODO::Should be investigated why the test fails +### Example ```rust,ignore use mpesa::{Mpesa, Environment}; @@ -46,3 +46,36 @@ async fn main() -> Result<(), Box>{ Ok(()) } ``` + +## Mpesa Express Query + +M-PESA Express Query API checks the status of a Lipa Na M-PESA Online Payment. + +### Example + +```rust,ignore +use mpesa::{Mpesa, Environment}; + +#[tokio::main] +async fn main() -> Result<(), Box>{ + dotenvy::dotenv().ok(); + + let client = Mpesa::new( + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), + Environment::Sandbox, + ); + + let response = client + .express_query() + .business_short_code("174379") + .checkout_request_id("ws_CO_271120201234567891") + .build()? + .send() + .await; + + assert!(response.is_ok()); + + Ok(()) +} +``` diff --git a/src/client.rs b/src/client.rs index df08e43d5..1b39449e5 100644 --- a/src/client.rs +++ b/src/client.rs @@ -15,9 +15,9 @@ use crate::environment::ApiEnvironment; use crate::services::{ AccountBalanceBuilder, B2bBuilder, B2cBuilder, BulkInvoiceBuilder, C2bRegisterBuilder, C2bSimulateBuilder, CancelInvoiceBuilder, DynamicQR, DynamicQRBuilder, MpesaExpress, - MpesaExpressBuilder, OnboardBuilder, OnboardModifyBuilder, ReconciliationBuilder, - SingleInvoiceBuilder, TransactionReversal, TransactionReversalBuilder, - TransactionStatusBuilder, + MpesaExpressBuilder, MpesaExpressQuery, MpesaExpressQueryBuilder, OnboardBuilder, + OnboardModifyBuilder, ReconciliationBuilder, SingleInvoiceBuilder, TransactionReversal, + TransactionReversalBuilder, TransactionStatusBuilder, }; use crate::{auth, MpesaError, MpesaResult, ResponseError}; @@ -242,6 +242,12 @@ impl Mpesa { MpesaExpress::builder(self) } + #[cfg(feature = "express")] + #[doc = include_str!("../docs/client/express_request.md")] + pub fn express_query(&self) -> MpesaExpressQueryBuilder { + MpesaExpressQuery::builder(self) + } + #[cfg(feature = "transaction_reversal")] #[doc = include_str!("../docs/client/transaction_reversal.md")] pub fn transaction_reversal(&self) -> TransactionReversalBuilder { diff --git a/src/services/express/express_query.rs b/src/services/express/express_query.rs index c5565be46..959383cb5 100644 --- a/src/services/express/express_query.rs +++ b/src/services/express/express_query.rs @@ -1,4 +1,4 @@ -// #![doc = include_str!("../../docs/client/express_request.md")] +#![doc = include_str!("../../../docs/client/express_request.md")] use chrono::prelude::Local; use chrono::DateTime; @@ -6,14 +6,10 @@ use derive_builder::Builder; use openssl::base64; use serde::{Deserialize, Serialize}; +use super::DEFAULT_PASSKEY; use crate::client::Mpesa; - use crate::errors::{MpesaError, MpesaResult}; -/// Source: [test credentials](https://developer.safaricom.co.ke/test_credentials) -pub static DEFAULT_PASSKEY: &str = - "bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919"; - const EXPRESS_QUERY_URL: &str = "mpesa/stkpushquery/v1/query"; #[derive(Debug, Serialize)] @@ -88,7 +84,7 @@ pub struct MpesaExpressQuery<'mpesa> { /// The password for encrypting the request is obtained by base64 encoding /// BusinessShortCode, Passkey and Timestamp. /// The timestamp format is YYYYMMDDHHmmss - #[builder(setter(into))] + #[builder(setter(into, strip_option), default = "Some(DEFAULT_PASSKEY)")] pass_key: Option<&'mpesa str>, /// This is a global unique identifier of the processed checkout transaction diff --git a/src/services/express/express_request.rs b/src/services/express/express_request.rs index a515fcce2..a42984c4a 100644 --- a/src/services/express/express_request.rs +++ b/src/services/express/express_request.rs @@ -1,4 +1,4 @@ -// #![doc = include_str!("../../docs/client/express_request.md")] +#![doc = include_str!("../../../docs/client/express_request.md")] use chrono::prelude::Local; use chrono::DateTime; @@ -7,15 +7,11 @@ use openssl::base64; use serde::{Deserialize, Serialize}; use url::Url; +use super::DEFAULT_PASSKEY; use crate::client::Mpesa; use crate::constants::CommandId; use crate::errors::{MpesaError, MpesaResult}; use crate::validator::PhoneNumberValidator; - -/// Source: [test credentials](https://developer.safaricom.co.ke/test_credentials) -pub static DEFAULT_PASSKEY: &str = - "bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919"; - const EXPRESS_REQUEST_URL: &str = "mpesa/stkpush/v1/processrequest"; #[derive(Debug, Serialize)] diff --git a/src/services/express/mod.rs b/src/services/express/mod.rs index 112e2514d..47db92918 100644 --- a/src/services/express/mod.rs +++ b/src/services/express/mod.rs @@ -5,3 +5,7 @@ pub use express_query::{MpesaExpressQuery, MpesaExpressQueryBuilder, MpesaExpres pub use express_request::{ MpesaExpress, MpesaExpressBuilder, MpesaExpressRequest, MpesaExpressResponse, }; + +/// Source: [test credentials](https://developer.safaricom.co.ke/test_credentials) +pub static DEFAULT_PASSKEY: &str = + "bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919"; diff --git a/src/services/mod.rs b/src/services/mod.rs index 8e2278540..cb7d579eb 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -42,8 +42,11 @@ pub use c2b_register::{C2bRegisterBuilder, C2bRegisterResponse}; pub use c2b_simulate::{C2bSimulateBuilder, C2bSimulateResponse}; #[cfg(feature = "dynamic_qr")] pub use dynamic_qr::{DynamicQR, DynamicQRBuilder, DynamicQRRequest, DynamicQRResponse}; -#[cfg(feature = "express_request")] -pub use express::{MpesaExpress, MpesaExpressBuilder, MpesaExpressRequest, MpesaExpressResponse}; +#[cfg(feature = "express")] +pub use express::{ + MpesaExpress, MpesaExpressBuilder, MpesaExpressQuery, MpesaExpressQueryBuilder, + MpesaExpressQueryResponse, MpesaExpressRequest, MpesaExpressResponse, +}; #[cfg(feature = "transaction_reversal")] pub use transaction_reversal::{ TransactionReversal, TransactionReversalBuilder, TransactionReversalRequest, From aa028cad3ef62abaf1b13f6b63a0968731f48d90 Mon Sep 17 00:00:00 2001 From: Yasir Shariff Date: Mon, 29 Jan 2024 10:14:25 +0530 Subject: [PATCH 03/10] chore: Add mock tests --- Cargo.toml | 4 +- src/client.rs | 2 +- src/services/express/express_query.rs | 10 +- src/services/express/express_request.rs | 10 +- src/services/express/mod.rs | 10 ++ tests/mpesa-rust/main.rs | 2 +- tests/mpesa-rust/stk_push_test.rs | 141 ------------------------ 7 files changed, 16 insertions(+), 163 deletions(-) delete mode 100644 tests/mpesa-rust/stk_push_test.rs diff --git a/Cargo.toml b/Cargo.toml index 92339da89..950ee144b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,9 +40,10 @@ chrono = { version = "0.4", optional = true, default-features = false, features "clock", "serde", ] } +derive_builder = "0.13" openssl = { version = "0.10", optional = true } reqwest = { version = "0.11", features = ["json"] } -derive_builder = "0.13" +regex = { version = "1.10", default-features = false, features = ["std"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_repr = "0.1" @@ -50,7 +51,6 @@ thiserror = "1.0" secrecy = "0.8" serde-aux = "4.2" url = { version = "2", features = ["serde"] } -regex = { version = "1.10", default-features = false, features = ["std"] } [dev-dependencies] diff --git a/src/client.rs b/src/client.rs index 1b39449e5..230650dda 100644 --- a/src/client.rs +++ b/src/client.rs @@ -236,7 +236,7 @@ impl Mpesa { AccountBalanceBuilder::new(self, initiator_name) } - #[cfg(feature = "express_request")] + #[cfg(feature = "express")] #[doc = include_str!("../docs/client/express_request.md")] pub fn express_request(&self) -> MpesaExpressBuilder { MpesaExpress::builder(self) diff --git a/src/services/express/express_query.rs b/src/services/express/express_query.rs index 959383cb5..0a9a8fc1b 100644 --- a/src/services/express/express_query.rs +++ b/src/services/express/express_query.rs @@ -6,7 +6,7 @@ use derive_builder::Builder; use openssl::base64; use serde::{Deserialize, Serialize}; -use super::DEFAULT_PASSKEY; +use super::{serialize_utc_to_string, DEFAULT_PASSKEY}; use crate::client::Mpesa; use crate::errors::{MpesaError, MpesaResult}; @@ -31,14 +31,6 @@ pub struct MpesaExpressQueryRequest<'mpesa> { pub checkout_request_id: &'mpesa str, } -fn serialize_utc_to_string(date: &DateTime, serializer: S) -> Result -where - S: serde::Serializer, -{ - let s = date.format("%Y%m%d%H%M%S").to_string(); - serializer.serialize_str(&s) -} - #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct MpesaExpressQueryResponse { diff --git a/src/services/express/express_request.rs b/src/services/express/express_request.rs index a42984c4a..80ad67eb3 100644 --- a/src/services/express/express_request.rs +++ b/src/services/express/express_request.rs @@ -7,7 +7,7 @@ use openssl::base64; use serde::{Deserialize, Serialize}; use url::Url; -use super::DEFAULT_PASSKEY; +use super::{serialize_utc_to_string, DEFAULT_PASSKEY}; use crate::client::Mpesa; use crate::constants::CommandId; use crate::errors::{MpesaError, MpesaResult}; @@ -60,14 +60,6 @@ pub struct MpesaExpressRequest<'mpesa> { pub transaction_desc: Option<&'mpesa str>, } -fn serialize_utc_to_string(date: &DateTime, serializer: S) -> Result -where - S: serde::Serializer, -{ - let s = date.format("%Y%m%d%H%M%S").to_string(); - serializer.serialize_str(&s) -} - // TODO:: The success response has more fields than this #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "PascalCase")] diff --git a/src/services/express/mod.rs b/src/services/express/mod.rs index 47db92918..379add18c 100644 --- a/src/services/express/mod.rs +++ b/src/services/express/mod.rs @@ -1,6 +1,7 @@ pub mod express_query; pub mod express_request; +use chrono::{DateTime, Local}; pub use express_query::{MpesaExpressQuery, MpesaExpressQueryBuilder, MpesaExpressQueryResponse}; pub use express_request::{ MpesaExpress, MpesaExpressBuilder, MpesaExpressRequest, MpesaExpressResponse, @@ -9,3 +10,12 @@ pub use express_request::{ /// Source: [test credentials](https://developer.safaricom.co.ke/test_credentials) pub static DEFAULT_PASSKEY: &str = "bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919"; + +/// Helper function to serialize a `DateTime` to a string +fn serialize_utc_to_string(date: &DateTime, serializer: S) -> Result +where + S: serde::Serializer, +{ + let s = date.format("%Y%m%d%H%M%S").to_string(); + serializer.serialize_str(&s) +} diff --git a/tests/mpesa-rust/main.rs b/tests/mpesa-rust/main.rs index ed859273a..c627c6b39 100644 --- a/tests/mpesa-rust/main.rs +++ b/tests/mpesa-rust/main.rs @@ -14,7 +14,7 @@ mod c2b_simulate_test; mod dynamic_qr_tests; mod helpers; #[cfg(test)] -mod stk_push_test; +mod express; #[cfg(test)] mod transaction_reversal_test; #[cfg(test)] diff --git a/tests/mpesa-rust/stk_push_test.rs b/tests/mpesa-rust/stk_push_test.rs deleted file mode 100644 index 71760e7d1..000000000 --- a/tests/mpesa-rust/stk_push_test.rs +++ /dev/null @@ -1,141 +0,0 @@ -use mpesa::services::{MpesaExpress, MpesaExpressRequest}; -use mpesa::CommandId; -use serde_json::json; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, ResponseTemplate}; - -use crate::get_mpesa_client; - -#[tokio::test] -async fn stk_push_success() { - let (client, server) = get_mpesa_client!(); - let sample_response_body = json!({ - "MerchantRequestID": "16813-1590513-1", - "CheckoutRequestID": "ws_CO_DMZ_12321_23423476", - "ResponseDescription": "Accept the service request successfully.", - "ResponseCode": "0", - "CustomerMessage": "Success. Request accepted for processing" - }); - Mock::given(method("POST")) - .and(path("/mpesa/stkpush/v1/processrequest")) - .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) - .expect(1) - .mount(&server) - .await; - let response = client - .express_request() - .business_short_code("174379") - .transaction_type(mpesa::CommandId::BusinessBuyGoods) - .party_a("254708374149") - .party_b("174379") - .account_ref("test") - .phone_number("254708374149") - .amount(500) - .pass_key("test") - .try_callback_url("https://test.example.com/api") - .unwrap() - .build() - .unwrap() - .send() - .await - .unwrap(); - - assert_eq!(response.merchant_request_id, "16813-1590513-1"); - assert_eq!(response.checkout_request_id, "ws_CO_DMZ_12321_23423476"); - assert_eq!( - response.response_description, - "Accept the service request successfully." - ); - assert_eq!( - response.customer_message, - "Success. Request accepted for processing" - ); -} - -#[tokio::test] -async fn stk_push_only_accepts_specific_tx_type() { - let (client, server) = get_mpesa_client!(expected_auth_requests = 0); - let sample_response_body = json!({ - "MerchantRequestID": "16813-1590513-1", - "CheckoutRequestID": "ws_CO_DMZ_12321_23423476", - "ResponseDescription": "Accept the service request successfully.", - "ResponseCode": "0", - "CustomerMessage": "Success. Request accepted for processing" - }); - Mock::given(method("POST")) - .and(path("/mpesa/stkpush/v1/processrequest")) - .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) - .expect(0) - .mount(&server) - .await; - let err = client - .express_request() - .business_short_code("174379") - .transaction_type(mpesa::CommandId::SalaryPayment) - .party_a("254704837414") - .party_b("174379") - .account_ref("test") - .phone_number("254708437414") - .amount(500) - .pass_key("test") - .try_callback_url("https://test.example.com/api") - .unwrap() - .build() - .unwrap_err(); - - assert_eq!( - err.to_string(), - "Invalid transaction type. Expected BusinessBuyGoods or CustomerPayBillOnline" - ); -} - -#[tokio::test] -async fn express_request_test_using_struct_initialization() { - let (client, server) = get_mpesa_client!(); - - let sample_response_body = json!({ - "MerchantRequestID": "16813-1590513-1", - "CheckoutRequestID": "ws_CO_DMZ_12321_23423476", - "ResponseDescription": "Accept the service request successfully.", - "ResponseCode": "0", - "CustomerMessage": "Success. Request accepted for processing" - }); - - let password = MpesaExpress::encode_password("174379", None); - - let request = MpesaExpressRequest { - business_short_code: "174379", - transaction_type: CommandId::BusinessBuyGoods, - amount: 500, - party_a: "254708374149", - party_b: "174379", - phone_number: "254708374149", - password, - timestamp: chrono::Local::now(), - call_back_url: "https://test.example.com/api".try_into().unwrap(), - account_reference: "test", - transaction_desc: None, - }; - - Mock::given(method("POST")) - .and(path("/mpesa/stkpush/v1/processrequest")) - .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) - .expect(1) - .mount(&server) - .await; - - let request = MpesaExpress::from_request(&client, request, None); - - let response = request.send().await.unwrap(); - - assert_eq!(response.merchant_request_id, "16813-1590513-1"); - assert_eq!(response.checkout_request_id, "ws_CO_DMZ_12321_23423476"); - assert_eq!( - response.response_description, - "Accept the service request successfully." - ); - assert_eq!( - response.customer_message, - "Success. Request accepted for processing" - ); -} From d22b9e2e81e7bb5b9d261433766c92fb5fb7dae1 Mon Sep 17 00:00:00 2001 From: Yasir Shariff Date: Mon, 29 Jan 2024 10:19:08 +0530 Subject: [PATCH 04/10] fix!: express tests --- tests/mpesa-rust/express.rs | 186 ++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 tests/mpesa-rust/express.rs diff --git a/tests/mpesa-rust/express.rs b/tests/mpesa-rust/express.rs new file mode 100644 index 000000000..9ba57a257 --- /dev/null +++ b/tests/mpesa-rust/express.rs @@ -0,0 +1,186 @@ +use mpesa::services::{MpesaExpress, MpesaExpressRequest}; +use mpesa::CommandId; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::get_mpesa_client; + +#[tokio::test] +async fn stk_push_success() { + let (client, server) = get_mpesa_client!(); + let sample_response_body = json!({ + "MerchantRequestID": "16813-1590513-1", + "CheckoutRequestID": "ws_CO_DMZ_12321_23423476", + "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0", + "CustomerMessage": "Success. Request accepted for processing" + }); + Mock::given(method("POST")) + .and(path("/mpesa/stkpush/v1/processrequest")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(1) + .mount(&server) + .await; + let response = client + .express_request() + .business_short_code("174379") + .transaction_type(mpesa::CommandId::BusinessBuyGoods) + .party_a("254708374149") + .party_b("174379") + .account_ref("test") + .phone_number("254708374149") + .amount(500) + .pass_key("test") + .try_callback_url("https://test.example.com/api") + .unwrap() + .build() + .unwrap() + .send() + .await + .unwrap(); + + assert_eq!(response.merchant_request_id, "16813-1590513-1"); + assert_eq!(response.checkout_request_id, "ws_CO_DMZ_12321_23423476"); + assert_eq!( + response.response_description, + "Accept the service request successfully." + ); + assert_eq!( + response.customer_message, + "Success. Request accepted for processing" + ); +} + +#[tokio::test] +async fn stk_push_only_accepts_specific_tx_type() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + let sample_response_body = json!({ + "MerchantRequestID": "16813-1590513-1", + "CheckoutRequestID": "ws_CO_DMZ_12321_23423476", + "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0", + "CustomerMessage": "Success. Request accepted for processing" + }); + Mock::given(method("POST")) + .and(path("/mpesa/stkpush/v1/processrequest")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + let err = client + .express_request() + .business_short_code("174379") + .transaction_type(mpesa::CommandId::SalaryPayment) + .party_a("254704837414") + .party_b("174379") + .account_ref("test") + .phone_number("254708437414") + .amount(500) + .pass_key("test") + .try_callback_url("https://test.example.com/api") + .unwrap() + .build() + .unwrap_err(); + + assert_eq!( + err.to_string(), + "Invalid transaction type. Expected BusinessBuyGoods or CustomerPayBillOnline" + ); +} + +#[tokio::test] +async fn express_request_test_using_struct_initialization() { + let (client, server) = get_mpesa_client!(); + + let sample_response_body = json!({ + "MerchantRequestID": "16813-1590513-1", + "CheckoutRequestID": "ws_CO_DMZ_12321_23423476", + "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0", + "CustomerMessage": "Success. Request accepted for processing" + }); + + let password = MpesaExpress::encode_password("174379", None); + + let request = MpesaExpressRequest { + business_short_code: "174379", + transaction_type: CommandId::BusinessBuyGoods, + amount: 500, + party_a: "254708374149", + party_b: "174379", + phone_number: "254708374149", + password, + timestamp: chrono::Local::now(), + call_back_url: "https://test.example.com/api".try_into().unwrap(), + account_reference: "test", + transaction_desc: None, + }; + + Mock::given(method("POST")) + .and(path("/mpesa/stkpush/v1/processrequest")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(1) + .mount(&server) + .await; + + let request = MpesaExpress::from_request(&client, request, None); + + let response = request.send().await.unwrap(); + + assert_eq!(response.merchant_request_id, "16813-1590513-1"); + assert_eq!(response.checkout_request_id, "ws_CO_DMZ_12321_23423476"); + assert_eq!( + response.response_description, + "Accept the service request successfully." + ); + assert_eq!( + response.customer_message, + "Success. Request accepted for processing" + ); +} + +#[tokio::test] +async fn test_express_query_transaction() { + // Arrange + let (client, server) = get_mpesa_client!(); + + let sample_response_body = json!( { + "CheckoutRequestID": "ws_CO_DMZ_12321_23423476", + "MerchantRequestID": "16813-1590513-1", + "ResponseCode": "0", + "ResponseDescription": "Accept the service request successfully.", + "ResultCode": "0", + "ResultDesc": "The service request is processed successfully.", + }); + + Mock::given(method("POST")) + .and(path("/mpesa/stkpushquery/v1/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(1) + .mount(&server) + .await; + let response = client + .express_query() + .checkout_request_id("ws_CO_DMZ_12321_23423476") + .business_short_code("174379") + .pass_key("test") + .build() + .unwrap() + .send() + .await + .unwrap(); + + assert_eq!(response.merchant_request_id, "16813-1590513-1"); + assert_eq!(response.checkout_request_id, "ws_CO_DMZ_12321_23423476"); + assert_eq!( + response.response_description, + "Accept the service request successfully." + ); + assert_eq!( + response.result_desc, + "The service request is processed successfully." + ); + + assert_eq!(response.response_code, "0"); +} From 4e2065a4572059ee35c8e7458d0c7c2d84177ebc Mon Sep 17 00:00:00 2001 From: Yasir Shariff Date: Mon, 29 Jan 2024 10:21:17 +0530 Subject: [PATCH 05/10] chore: fix imports --- tests/mpesa-rust/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mpesa-rust/main.rs b/tests/mpesa-rust/main.rs index c627c6b39..d8ca7d6c4 100644 --- a/tests/mpesa-rust/main.rs +++ b/tests/mpesa-rust/main.rs @@ -12,9 +12,9 @@ mod c2b_register_test; mod c2b_simulate_test; mod dynamic_qr_tests; -mod helpers; #[cfg(test)] mod express; +mod helpers; #[cfg(test)] mod transaction_reversal_test; #[cfg(test)] From 810d115d57594cccf0b78f7e272f8448dab49e67 Mon Sep 17 00:00:00 2001 From: Yasir Shariff Date: Mon, 29 Jan 2024 10:29:25 +0530 Subject: [PATCH 06/10] chore: Add doc link --- docs/client/express_request.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/client/express_request.md b/docs/client/express_request.md index e1c366820..a1392d182 100644 --- a/docs/client/express_request.md +++ b/docs/client/express_request.md @@ -51,6 +51,8 @@ async fn main() -> Result<(), Box>{ M-PESA Express Query API checks the status of a Lipa Na M-PESA Online Payment. +Safaricom API docs [reference](https://developer.safaricom.co.ke/APIs/MpesaExpressQuery) + ### Example ```rust,ignore From f1119e0488ee36f9002e7d1df8144173b30cbd3b Mon Sep 17 00:00:00 2001 From: Yasir Date: Thu, 1 Feb 2024 09:41:09 +0300 Subject: [PATCH 07/10] Apply suggestions from code review Co-authored-by: Collins Muriuki --- src/services/express/express_query.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/express/express_query.rs b/src/services/express/express_query.rs index 0a9a8fc1b..84419da1b 100644 --- a/src/services/express/express_query.rs +++ b/src/services/express/express_query.rs @@ -25,7 +25,7 @@ pub struct MpesaExpressQueryRequest<'mpesa> { /// (YYYYMMDDHHMMSS) #[serde(serialize_with = "serialize_utc_to_string")] pub timestamp: DateTime, - ///This is a global unique identifier of the processed checkout transaction + /// This is a global unique identifier of the processed checkout transaction /// request. #[serde(rename = "CheckoutRequestID")] pub checkout_request_id: &'mpesa str, @@ -34,7 +34,7 @@ pub struct MpesaExpressQueryRequest<'mpesa> { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "PascalCase")] pub struct MpesaExpressQueryResponse { - ///This is a global unique identifier of the processed checkout transaction + /// This is a global unique identifier of the processed checkout transaction /// request. #[serde(rename = "CheckoutRequestID")] pub checkout_request_id: String, From 8c32ae38b5a3413cd390f102702b2b547260b56a Mon Sep 17 00:00:00 2001 From: Yasir Shariff Date: Mon, 5 Feb 2024 19:24:07 +0530 Subject: [PATCH 08/10] chore(doc): Update readme --- README.md | 6 +- docs/client/express_request.md | 83 ------------------------- src/client.rs | 4 +- src/services/express/express_query.rs | 2 +- src/services/express/express_request.rs | 2 +- 5 files changed, 7 insertions(+), 90 deletions(-) delete mode 100644 docs/client/express_request.md diff --git a/README.md b/README.md index 5d079bdd4..4c5094a13 100644 --- a/README.md +++ b/README.md @@ -167,11 +167,11 @@ The table below shows all the MPESA APIs from Safaricom and those supported by t | [Customer To Business (Register URL)](https://developer.safaricom.co.ke/APIs/CustomerToBusinessRegisterURL) | `c2b_register` | Stable ✅️ | [c2b register example](/docs/client/c2b_register.md) | | [Customer To Business (Simulate)](#) | `c2b_simulate` | Stable ✅️ | [c2b simulate example](/docs/client/c2b_simulate.md) | | [Dynamic QR](https://developer.safaricom.co.ke/APIs/DynamicQRCode) | `dynamic_qr` | Stable ✅️ | [dynamic qr example](/docs/client/dynamic_qr.md) | -| [M-PESA Express (Query)](https://developer.safaricom.co.ke/APIs/MpesaExpressQuery) | N/A | Unimplemented ️ | N/A | -| [M-PESA Express (Simulate)/ STK push](https://developer.safaricom.co.ke/APIs/MpesaExpressSimulate) | `express_request` | Stable ✅️ | [express request example](/docs/client/express_request.md) | +| [M-PESA Express (Query)](https://developer.safaricom.co.ke/APIs/MpesaExpressQuery) | `express` | Stable ✅️ ️ | [express query example](/docs/client/express.md) | +| [M-PESA Express (Simulate)/ STK push](https://developer.safaricom.co.ke/APIs/MpesaExpressSimulate) | `express` | Stable ✅️ | [express request example](/docs/client/express.md) | | [Transaction Status](https://developer.safaricom.co.ke/APIs/TransactionStatus) | `transaction_status` | Stable ✅️ | [transaction status example](/docs/client/transaction_status.md) | | [Transaction Reversal](https://developer.safaricom.co.ke/APIs/Reversal) | `transaction_reversal` | Stable ✅️ | [transaction reversal example](/docs/client/transaction_reversal.md) | -| [Tax Remittance](https://developer.safaricom.co.ke/APIs/TaxRemittance) | N/A | Unimplemented | N/A | +| [Tax Remittance](https://developer.safaricom.co.ke/APIs/TaxRemittance) | N/A | Unimplemented | N/A | ## Author diff --git a/docs/client/express_request.md b/docs/client/express_request.md deleted file mode 100644 index a1392d182..000000000 --- a/docs/client/express_request.md +++ /dev/null @@ -1,83 +0,0 @@ -# Mpesa Express - -## Mpese Express Request - -Lipa na M-PESA online API also known as M-PESA express (STK Push/NI push) is a Merchant/Business initiated C2B (Customer to Business) Payment. - -Once you, our merchant integrate with the API, you will be able to send a payment prompt on the customer's phone (Popularly known as STK Push Prompt) to your customer's M-PESA registered phone number requesting them to enter their M-PESA pin to authorize and complete payment. - -Requires a `business_short_code` - The organization shortcode used to receive the transaction and -returns a `MpesaExpressRequestBuilder` struct - -Safaricom API docs [reference](https://developer.safaricom.co.ke/APIs/MpesaExpressSimulate) - -### Example - -```rust,ignore -use mpesa::{Mpesa, Environment}; - -#[tokio::main] -async fn main() -> Result<(), Box>{ - dotenvy::dotenv().ok(); - - let client = Mpesa::new( - dotenvy::var("CONSUMER_KEY").unwrap(), - dotenvy::var("CONSUMER_SECRET").unwrap(), - Environment::Sandbox, - ); - - let response = client - .express_request() - .business_short_code("174379") - .phone_number("254708374149") - .party_a("600584") - .party_b("174379") - .amount(500) - .try_callback_url("https://test.example.com/api")? - .account_ref("Test") - .transaction_type(mpesa::CommandId::CustomerPayBillOnline) // Optional, defaults to `CommandId::CustomerPayBillOnline` - .transaction_desc("Description") // Optional, defaults to "None" - .build()? - .send() - .await; - - assert!(response.is_ok()); - - Ok(()) -} -``` - -## Mpesa Express Query - -M-PESA Express Query API checks the status of a Lipa Na M-PESA Online Payment. - -Safaricom API docs [reference](https://developer.safaricom.co.ke/APIs/MpesaExpressQuery) - -### Example - -```rust,ignore -use mpesa::{Mpesa, Environment}; - -#[tokio::main] -async fn main() -> Result<(), Box>{ - dotenvy::dotenv().ok(); - - let client = Mpesa::new( - dotenvy::var("CLIENT_KEY").unwrap(), - dotenvy::var("CLIENT_SECRET").unwrap(), - Environment::Sandbox, - ); - - let response = client - .express_query() - .business_short_code("174379") - .checkout_request_id("ws_CO_271120201234567891") - .build()? - .send() - .await; - - assert!(response.is_ok()); - - Ok(()) -} -``` diff --git a/src/client.rs b/src/client.rs index 9bfe4c9d8..5b804b635 100644 --- a/src/client.rs +++ b/src/client.rs @@ -237,13 +237,13 @@ impl Mpesa { } #[cfg(feature = "express")] - #[doc = include_str!("../docs/client/express_request.md")] + #[doc = include_str!("../docs/client/express.md")] pub fn express_request(&self) -> MpesaExpressBuilder { MpesaExpress::builder(self) } #[cfg(feature = "express")] - #[doc = include_str!("../docs/client/express_request.md")] + #[doc = include_str!("../docs/client/express.md")] pub fn express_query(&self) -> MpesaExpressQueryBuilder { MpesaExpressQuery::builder(self) } diff --git a/src/services/express/express_query.rs b/src/services/express/express_query.rs index 84419da1b..3e92f6df4 100644 --- a/src/services/express/express_query.rs +++ b/src/services/express/express_query.rs @@ -1,4 +1,4 @@ -#![doc = include_str!("../../../docs/client/express_request.md")] +#![doc = include_str!("../../../docs/client/express.md")] use chrono::prelude::Local; use chrono::DateTime; diff --git a/src/services/express/express_request.rs b/src/services/express/express_request.rs index 80ad67eb3..cd0e755a5 100644 --- a/src/services/express/express_request.rs +++ b/src/services/express/express_request.rs @@ -1,4 +1,4 @@ -#![doc = include_str!("../../../docs/client/express_request.md")] +#![doc = include_str!("../../../docs/client/express.md")] use chrono::prelude::Local; use chrono::DateTime; From 76a269c0894da6b57b2a1eba5dc4a32fbf7ddccf Mon Sep 17 00:00:00 2001 From: Yasir Shariff Date: Mon, 5 Feb 2024 19:36:43 +0530 Subject: [PATCH 09/10] chore(file): Update filename --- docs/client/express.md | 83 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/client/express.md diff --git a/docs/client/express.md b/docs/client/express.md new file mode 100644 index 000000000..a1392d182 --- /dev/null +++ b/docs/client/express.md @@ -0,0 +1,83 @@ +# Mpesa Express + +## Mpese Express Request + +Lipa na M-PESA online API also known as M-PESA express (STK Push/NI push) is a Merchant/Business initiated C2B (Customer to Business) Payment. + +Once you, our merchant integrate with the API, you will be able to send a payment prompt on the customer's phone (Popularly known as STK Push Prompt) to your customer's M-PESA registered phone number requesting them to enter their M-PESA pin to authorize and complete payment. + +Requires a `business_short_code` - The organization shortcode used to receive the transaction and +returns a `MpesaExpressRequestBuilder` struct + +Safaricom API docs [reference](https://developer.safaricom.co.ke/APIs/MpesaExpressSimulate) + +### Example + +```rust,ignore +use mpesa::{Mpesa, Environment}; + +#[tokio::main] +async fn main() -> Result<(), Box>{ + dotenvy::dotenv().ok(); + + let client = Mpesa::new( + dotenvy::var("CONSUMER_KEY").unwrap(), + dotenvy::var("CONSUMER_SECRET").unwrap(), + Environment::Sandbox, + ); + + let response = client + .express_request() + .business_short_code("174379") + .phone_number("254708374149") + .party_a("600584") + .party_b("174379") + .amount(500) + .try_callback_url("https://test.example.com/api")? + .account_ref("Test") + .transaction_type(mpesa::CommandId::CustomerPayBillOnline) // Optional, defaults to `CommandId::CustomerPayBillOnline` + .transaction_desc("Description") // Optional, defaults to "None" + .build()? + .send() + .await; + + assert!(response.is_ok()); + + Ok(()) +} +``` + +## Mpesa Express Query + +M-PESA Express Query API checks the status of a Lipa Na M-PESA Online Payment. + +Safaricom API docs [reference](https://developer.safaricom.co.ke/APIs/MpesaExpressQuery) + +### Example + +```rust,ignore +use mpesa::{Mpesa, Environment}; + +#[tokio::main] +async fn main() -> Result<(), Box>{ + dotenvy::dotenv().ok(); + + let client = Mpesa::new( + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), + Environment::Sandbox, + ); + + let response = client + .express_query() + .business_short_code("174379") + .checkout_request_id("ws_CO_271120201234567891") + .build()? + .send() + .await; + + assert!(response.is_ok()); + + Ok(()) +} +``` From 94055cd9abf8d66a6f986e176b28868729f4d479 Mon Sep 17 00:00:00 2001 From: Yasir Shariff Date: Sun, 31 Mar 2024 12:55:40 +0300 Subject: [PATCH 10/10] chore: bump up deps --- Cargo.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 950ee144b..8a8be47ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,14 +35,14 @@ transaction_status = ["dep:openssl"] [dependencies] -cached = { version = "0.48", features = ["wasm", "async", "proc_macro"] } +cached = { version = "0.49", features = ["wasm", "async", "proc_macro"] } chrono = { version = "0.4", optional = true, default-features = false, features = [ "clock", "serde", ] } -derive_builder = "0.13" +derive_builder = "0.20" openssl = { version = "0.10", optional = true } -reqwest = { version = "0.11", features = ["json"] } +reqwest = { version = "0.12", features = ["json"] } regex = { version = "1.10", default-features = false, features = ["std"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -56,4 +56,4 @@ url = { version = "2", features = ["serde"] } [dev-dependencies] dotenvy = "0.15" tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } -wiremock = "0.5" +wiremock = "0.6"