Skip to content

Commit

Permalink
feat(service): Add M-Pesa Express Query service (#108)
Browse files Browse the repository at this point in the history
* feat(service): Add M-Pesa Express Query service

* feat: add docs and doc tests

* chore: Add mock tests

* fix!: express tests

* chore: fix imports

* chore: Add doc link

* Apply suggestions from code review

Co-authored-by: Collins Muriuki <hello@collinsmuriuki.xyz>

* chore(doc): Update readme

* chore(file): Update filename

* chore: bump up deps

---------

Co-authored-by: Collins Muriuki <hello@collinsmuriuki.xyz>
  • Loading branch information
itsyaasir and c12i authored Jul 8, 2024
1 parent 8694aef commit a590bcd
Show file tree
Hide file tree
Showing 10 changed files with 294 additions and 40 deletions.
16 changes: 8 additions & 8 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.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"
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"
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 39 additions & 4 deletions docs/client/express_request.md → docs/client/express.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(())
}
```
16 changes: 11 additions & 5 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")]
#[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 {
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.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
/// 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<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.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

0 comments on commit a590bcd

Please sign in to comment.