diff --git a/Cargo.toml b/Cargo.toml index 6093a777b..8a8be47ed 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,21 @@ 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.49", features = ["wasm", "async", "proc_macro"] } chrono = { version = "0.4", optional = true, default-features = false, features = [ "clock", "serde", ] } +derive_builder = "0.20" openssl = { version = "0.10", optional = true } -reqwest = { version = "0.11", features = ["json"] } -derive_builder = "0.12" +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" serde_repr = "0.1" @@ -50,10 +51,9 @@ 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] -dotenvy = "0.15.7" +dotenvy = "0.15" tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } -wiremock = "0.5" +wiremock = "0.6" 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.md similarity index 65% rename from docs/client/express_request.md rename to docs/client/express.md index df6b515cc..a1392d182 100644 --- a/docs/client/express_request.md +++ b/docs/client/express.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,38 @@ 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. + +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 5721f5e11..5b804b635 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}; @@ -236,12 +236,18 @@ impl Mpesa { AccountBalanceBuilder::new(self, initiator_name) } - #[cfg(feature = "express_request")] - #[doc = include_str!("../docs/client/express_request.md")] + #[cfg(feature = "express")] + #[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.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 new file mode 100644 index 000000000..3e92f6df4 --- /dev/null +++ b/src/services/express/express_query.rs @@ -0,0 +1,158 @@ +#![doc = include_str!("../../../docs/client/express.md")] + +use chrono::prelude::Local; +use chrono::DateTime; +use derive_builder::Builder; +use openssl::base64; +use serde::{Deserialize, Serialize}; + +use super::{serialize_utc_to_string, DEFAULT_PASSKEY}; +use crate::client::Mpesa; +use crate::errors::{MpesaError, MpesaResult}; + +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, +} + +#[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, strip_option), default = "Some(DEFAULT_PASSKEY)")] + 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 95% rename from src/services/express_request.rs rename to src/services/express/express_request.rs index 59828b27a..cd0e755a5 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.md")] use chrono::prelude::Local; use chrono::DateTime; @@ -7,15 +7,11 @@ use openssl::base64; use serde::{Deserialize, Serialize}; use url::Url; +use super::{serialize_utc_to_string, 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)] @@ -64,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 new file mode 100644 index 000000000..379add18c --- /dev/null +++ b/src/services/express/mod.rs @@ -0,0 +1,21 @@ +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, +}; + +/// 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/src/services/mod.rs b/src/services/mod.rs index 5f2acc123..cb7d579eb 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; @@ -42,9 +42,10 @@ 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_request::{ - 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::{ diff --git a/tests/mpesa-rust/stk_push_test.rs b/tests/mpesa-rust/express.rs similarity index 77% rename from tests/mpesa-rust/stk_push_test.rs rename to tests/mpesa-rust/express.rs index 71760e7d1..9ba57a257 100644 --- a/tests/mpesa-rust/stk_push_test.rs +++ b/tests/mpesa-rust/express.rs @@ -139,3 +139,48 @@ async fn express_request_test_using_struct_initialization() { "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"); +} diff --git a/tests/mpesa-rust/main.rs b/tests/mpesa-rust/main.rs index ed859273a..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 stk_push_test; +mod express; +mod helpers; #[cfg(test)] mod transaction_reversal_test; #[cfg(test)]