Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(service): Add M-Pesa Express Query service #108

Merged
merged 11 commits into from
Jul 8, 2024
12 changes: 6 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ default = [
"bill_manager",
"c2b_register",
"c2b_simulate",
"express_request",
"express",
"transaction_reversal",
"transaction_status",
"dynamic_qr",
Expand All @@ -29,31 +29,31 @@ 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",
] }
derive_builder = "0.13"
openssl = { version = "0.10", optional = true }
reqwest = { version = "0.11", features = ["json"] }
derive_builder = "0.12"
regex = { version = "1.10", default-features = false, features = ["std"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_repr = "0.1"
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"
43 changes: 39 additions & 4 deletions docs/client/express_request.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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};
Expand Down Expand Up @@ -46,3 +46,38 @@ async fn main() -> Result<(), Box<dyn std::error::Error>>{
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<dyn std::error::Error>>{
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(())
}
```
14 changes: 10 additions & 4 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -236,12 +236,18 @@ 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)
}

#[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 {
Expand Down
158 changes: 158 additions & 0 deletions src/services/express/express_query.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#![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 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<Local>,
///This is a global unique identifier of the processed checkout transaction
itsyaasir marked this conversation as resolved.
Show resolved Hide resolved
/// 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
itsyaasir marked this conversation as resolved.
Show resolved Hide resolved
/// 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<MpesaExpressQuery<'mpesa>> 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<MpesaExpressQueryResponse> {
self.client
.send::<MpesaExpressQueryRequest, _>(crate::client::Request {
method: reqwest::Method::POST,
path: EXPRESS_QUERY_URL,
body: self.into(),
})
.await
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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)]
Expand Down Expand Up @@ -64,14 +60,6 @@ pub struct MpesaExpressRequest<'mpesa> {
pub transaction_desc: Option<&'mpesa str>,
}

fn serialize_utc_to_string<S>(date: &DateTime<Local>, serializer: S) -> Result<S::Ok, S::Error>
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")]
Expand Down
21 changes: 21 additions & 0 deletions src/services/express/mod.rs
Original file line number Diff line number Diff line change
@@ -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<Local>` to a string
fn serialize_utc_to_string<S>(date: &DateTime<Local>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let s = date.format("%Y%m%d%H%M%S").to_string();
serializer.serialize_str(&s)
}
9 changes: 5 additions & 4 deletions src/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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::{
Expand Down
Loading
Loading