diff --git a/Cargo.toml b/Cargo.toml index 341af1203..461944b2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ chrono = { version = "0.4", optional = true, default-features = false, features ] } openssl = { version = "0.10", optional = true } reqwest = { version = "0.11", features = ["json"] } +derive_builder = "0.12" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_repr = "0.1" @@ -40,7 +41,9 @@ default = [ "express_request", "transaction_reversal", "transaction_status", + "dynamic_qr" ] +dynamic_qr = [] account_balance = ["dep:openssl"] b2b = ["dep:openssl"] b2c = ["dep:openssl"] diff --git a/README.md b/README.md index de7c66298..7a48132cb 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Optionally, you can disable default-features, which is basically the entire suit - `transaction_reversal` - `transaction_status` - `bill_manager` +- `dynamic_qr` Example: @@ -371,6 +372,25 @@ let response = client assert!(response.is_ok()) ``` +- Dynamic QR + +```rust,ignore +let response = client + .dynamic_qr_code() + .amount(1000) + .ref_no("John Doe") + .size("300") + .merchant_name("John Doe") + .credit_party_identifier("600496") + .try_transaction_type("bg") + .unwrap() + .build() + .unwrap() + .send() + .await; +assert!(response.is_ok()) +``` + More will be added progressively, pull requests welcome ## Author diff --git a/src/client.rs b/src/client.rs index b4918556c..d52e5cb53 100644 --- a/src/client.rs +++ b/src/client.rs @@ -5,17 +5,17 @@ use openssl::base64; use openssl::rsa::Padding; use openssl::x509::X509; use reqwest::Client as HttpClient; +use secrecy::{ExposeSecret, Secret}; use crate::auth::AUTH; use crate::environment::ApiEnvironment; use crate::services::{ AccountBalanceBuilder, B2bBuilder, B2cBuilder, BulkInvoiceBuilder, C2bRegisterBuilder, - C2bSimulateBuilder, CancelInvoiceBuilder, MpesaExpressRequestBuilder, OnboardBuilder, - OnboardModifyBuilder, ReconciliationBuilder, SingleInvoiceBuilder, TransactionReversalBuilder, - TransactionStatusBuilder, + C2bSimulateBuilder, CancelInvoiceBuilder, DynamicQR, DynamicQRBuilder, + MpesaExpressRequestBuilder, OnboardBuilder, OnboardModifyBuilder, ReconciliationBuilder, + SingleInvoiceBuilder, TransactionReversalBuilder, TransactionStatusBuilder, }; use crate::{auth, MpesaResult}; -use secrecy::{ExposeSecret, Secret}; /// Source: [test credentials](https://developer.safaricom.co.ke/test_credentials) const DEFAULT_INITIATOR_PASSWORD: &str = "Safcom496!"; @@ -507,6 +507,35 @@ impl<'mpesa, Env: ApiEnvironment> Mpesa { TransactionStatusBuilder::new(self, initiator_name) } + /// ** Dynamic QR Code Builder ** + /// + /// Generates a QR code that can be scanned by a M-Pesa customer to make + /// payments. + /// + /// See more from the Safaricom API docs [here](https://developer.safaricom. + /// co.ke/APIs/DynamicQRCode) + /// + /// # Example + /// ```ignore + /// let response = client + /// .dynamic_qr_code() + /// .amount(1000) + /// .ref_no("John Doe") + /// .size("300") + /// .merchant_name("John Doe") + /// .credit_party_identifier("600496") + /// .try_transaction_type("bg") + /// .unwrap() + /// .build() + /// .unwrap() + /// .send() + /// .await; + /// ``` + /// + #[cfg(feature = "dynamic_qr")] + pub fn dynamic_qr(&'mpesa self) -> DynamicQRBuilder<'mpesa, Env> { + DynamicQR::builder(self) + } /// Generates security credentials /// M-Pesa Core authenticates a transaction by decrypting the security credentials. /// Security credentials are generated by encrypting the base64 encoded initiator password with M-Pesa’s public key, a X509 certificate. @@ -546,6 +575,7 @@ mod tests { assert_eq!(client.initiator_password(), "foo_bar".to_string()); } + #[derive(Clone)] struct TestEnvironment; impl ApiEnvironment for TestEnvironment { diff --git a/src/constants.rs b/src/constants.rs index 0bdd9fa1b..756fdc996 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -4,6 +4,8 @@ use chrono::prelude::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; +use crate::MpesaError; + /// Mpesa command ids #[derive(Debug, Serialize, Deserialize)] pub enum CommandId { @@ -141,3 +143,38 @@ impl<'i> Display for InvoiceItem<'i> { write!(f, "amount: {}, item_name: {}", self.amount, self.item_name) } } + +#[derive(Debug, Clone, Copy, Serialize)] +pub enum TransactionType { + /// Send Money(Mobile number). + SendMoney, + /// Withdraw Cash at Agent Till + Withdraw, + /// Pay Merchant (Buy Goods) + BG, + /// Paybill or Business number + PayBill, + /// Sent to Business. Business number CPI in MSISDN format. + SendBusiness, +} + +impl Display for TransactionType { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + write!(f, "{self:?}") + } +} + +impl TryFrom<&str> for TransactionType { + type Error = MpesaError; + + fn try_from(value: &str) -> Result { + match value.to_lowercase().as_str() { + "bg" => Ok(TransactionType::BG), + "wa" => Ok(TransactionType::Withdraw), + "pb" => Ok(TransactionType::PayBill), + "sm" => Ok(TransactionType::SendMoney), + "sb" => Ok(TransactionType::SendBusiness), + _ => Err(MpesaError::Message("Invalid transaction type")), + } + } +} diff --git a/src/environment.rs b/src/environment.rs index 0000cb959..84ee51c86 100644 --- a/src/environment.rs +++ b/src/environment.rs @@ -28,7 +28,7 @@ pub enum Environment { /// Expected behavior of an `Mpesa` client environment /// This abstraction exists to make it possible to mock the MPESA api server for tests -pub trait ApiEnvironment { +pub trait ApiEnvironment: Clone { fn base_url(&self) -> &str; fn get_certificate(&self) -> &str; } diff --git a/src/errors.rs b/src/errors.rs index 60f191ff3..cb3bed48a 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -2,9 +2,10 @@ use std::env::VarError; use std::fmt; use serde::{Deserialize, Serialize}; +use thiserror::Error; /// Mpesa error stack -#[derive(thiserror::Error, Debug)] +#[derive(Error, Debug)] pub enum MpesaError { #[error("{0}")] AuthenticationError(ApiError), @@ -36,6 +37,8 @@ pub enum MpesaError { MpesaTransactionReversalError(ApiError), #[error("Mpesa Transaction status failed: {0}")] MpesaTransactionStatusError(ApiError), + #[error("Mpesa Dynamic QR failed: {0}")] + MpesaDynamicQrError(ApiError), #[error("An error has occured while performing the http request")] NetworkError(#[from] reqwest::Error), #[error("An error has occured while serializig/ deserializing")] @@ -46,6 +49,8 @@ pub enum MpesaError { EncryptionError(#[from] openssl::error::ErrorStack), #[error("{0}")] Message(&'static str), + #[error("An error has occurred while building the request: {0}")] + BuilderError(BuilderError), } /// `Result` enum type alias @@ -67,3 +72,23 @@ impl fmt::Display for ApiError { ) } } + +#[derive(Debug, Error)] +pub enum BuilderError { + #[error("Field [{0}] is required")] + UninitializedField(&'static str), + #[error("Field [{0}] is invalid")] + ValidationError(String), +} + +impl From for BuilderError { + fn from(s: String) -> Self { + Self::ValidationError(s) + } +} + +impl From for MpesaError { + fn from(e: derive_builder::UninitializedFieldError) -> Self { + Self::BuilderError(BuilderError::UninitializedField(e.field_name())) + } +} diff --git a/src/lib.rs b/src/lib.rs index 03f490d81..240eb0a5d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod services; pub use client::Mpesa; pub use constants::{ CommandId, IdentifierTypes, Invoice, InvoiceItem, ResponseType, SendRemindersTypes, + TransactionType, }; pub use environment::ApiEnvironment; pub use environment::Environment::{self, Production, Sandbox}; diff --git a/src/services/dynamic_qr.rs b/src/services/dynamic_qr.rs new file mode 100644 index 000000000..25d2ce9ee --- /dev/null +++ b/src/services/dynamic_qr.rs @@ -0,0 +1,158 @@ +use derive_builder::Builder; +use serde::{Deserialize, Serialize}; + +use crate::client::Mpesa; +use crate::constants::TransactionType; +use crate::environment::ApiEnvironment; +use crate::errors::{MpesaError, MpesaResult}; + +const DYNAMIC_QR_URL: &str = "/mpesa/qrcode/v1/generate"; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct DynamicQRRequest<'mpesa> { + /// Name of the Company/M-Pesa Merchant Name + pub merchant_name: &'mpesa str, + /// Transaction Reference Number + pub ref_no: &'mpesa str, + /// The total amount of the transaction + pub amount: f64, + #[serde(rename = "TrxCode")] + /// Transaction Type + /// + /// This can be a `TransactionType` or a `&str` + /// The `&str` must be one of the following: + /// - `BG` for Buy Goods + /// - `PB` for Pay Bill + /// - `WA` Withdraw Cash + /// - `SM` Send Money (Mobile Number) + /// - `SB` Sent to Business. Business number CPI in MSISDN format. + pub transaction_type: TransactionType, + ///Credit Party Identifier. + /// + /// Can be a Mobile Number, Business Number, Agent + /// Till, Paybill or Business number, or Merchant Buy Goods. + #[serde(rename = "CPI")] + pub credit_party_identifier: &'mpesa str, + /// Size of the QR code image in pixels. + /// + /// QR code image will always be a square image. + pub size: &'mpesa str, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct DynamicQRResponse { + #[serde(rename(deserialize = "QRCode"))] + pub qr_code: String, + pub response_code: String, + pub response_description: String, +} + +/// Dynamic QR builder struct +#[derive(Builder, Debug, Clone)] +#[builder(build_fn(error = "MpesaError"))] +pub struct DynamicQR<'mpesa, Env: ApiEnvironment> { + #[builder(pattern = "immutable")] + client: &'mpesa Mpesa, + /// Name of the Company/M-Pesa Merchant Name + #[builder(setter(into))] + merchant_name: &'mpesa str, + /// Transaction Reference Number + #[builder(setter(into))] + amount: f64, + /// The total amount of the transaction + ref_no: &'mpesa str, + /// Transaction Type + /// + /// This can be a `TransactionType` or a `&str` + /// The `&str` must be one of the following: + /// - `BG` for Buy Goods + /// - `PB` for Pay Bill + /// - `WA` Withdraw Cash + /// - `SM` Send Money (Mobile Number) + /// - `SB` Sent to Business. Business number CPI in MSISDN format. + #[builder(try_setter, setter(into))] + transaction_type: TransactionType, + /// Credit Party Identifier. + /// Can be a Mobile Number, Business Number, Agent + /// Till, Paybill or Business number, or Merchant Buy Goods. + #[builder(setter(into))] + credit_party_identifier: &'mpesa str, + /// Size of the QR code image in pixels. + /// + /// QR code image will always be a square image. + #[builder(setter(into))] + size: &'mpesa str, +} + +impl<'mpesa, Env: ApiEnvironment> From> for DynamicQRRequest<'mpesa> { + fn from(express: DynamicQR<'mpesa, Env>) -> DynamicQRRequest<'mpesa> { + DynamicQRRequest { + merchant_name: express.merchant_name, + ref_no: express.ref_no, + amount: express.amount, + transaction_type: express.transaction_type, + credit_party_identifier: express.credit_party_identifier, + size: express.size, + } + } +} + +impl<'mpesa, Env: ApiEnvironment> DynamicQR<'mpesa, Env> { + pub(crate) fn builder(client: &'mpesa Mpesa) -> DynamicQRBuilder<'mpesa, Env> { + DynamicQRBuilder::default().client(client) + } + + /// # Build Dynamic QR + /// + /// Returns a `DynamicQR` which can be used to send a request + pub fn from_request( + client: &'mpesa Mpesa, + request: DynamicQRRequest<'mpesa>, + ) -> DynamicQR<'mpesa, Env> { + DynamicQR { + client, + merchant_name: request.merchant_name, + ref_no: request.ref_no, + amount: request.amount, + transaction_type: request.transaction_type, + credit_party_identifier: request.credit_party_identifier, + size: request.size, + } + } + + /// # Generate a Dynamic QR + /// + /// This enables Safaricom M-PESA customers who + /// have My Safaricom App or M-PESA app, to scan a QR (Quick Response) + /// code, to capture till number and amount then authorize to pay for goods + /// and services at select LIPA NA M-PESA (LNM) merchant outlets. + /// + /// # Response + /// A successful request returns a `DynamicQRResponse` type + /// which contains the QR code + /// + /// # Errors + /// Returns a `MpesaError` on failure + pub async fn send(self) -> MpesaResult { + let url = format!("{}{}", self.client.environment.base_url(), DYNAMIC_QR_URL); + + let response = self + .client + .http_client + .post(&url) + .bearer_auth(self.client.auth().await?) + .json::(&self.into()) + .send() + .await?; + + if response.status().is_success() { + let value = response.json::<_>().await?; + return Ok(value); + } + + let value = response.json().await?; + Err(MpesaError::MpesaDynamicQrError(value)) + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 3ea340f88..b688f819f 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -13,6 +13,8 @@ //! 6. [Mpesa Express/ STK Push](https://developer.safaricom.co.ke/docs#lipa-na-m-pesa-online-payment) //! 7. [Transaction Reversal](https://developer.safaricom.co.ke/docs#reversal) //! 8. [Bill Manager](https://developer.safaricom.co.ke/APIs/BillManager) +//! 9. [Transaction Status](https://developer.safaricom.co.ke/docs#transaction-status) +//! 10. [Dynamic QR](https://developer.safaricom.co.ke/APIs/DynamicQRCode) mod account_balance; mod b2b; @@ -20,6 +22,7 @@ mod b2c; mod bill_manager; mod c2b_register; mod c2b_simulate; +mod dynamic_qr; mod express_request; mod transaction_reversal; mod transaction_status; @@ -36,6 +39,8 @@ pub use bill_manager::*; pub use c2b_register::{C2bRegisterBuilder, C2bRegisterResponse}; #[cfg(feature = "c2b_simulate")] 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::{MpesaExpressRequestBuilder, MpesaExpressRequestResponse}; #[cfg(feature = "transaction_reversal")] diff --git a/tests/mpesa-rust/dynamic_qr_tests.rs b/tests/mpesa-rust/dynamic_qr_tests.rs new file mode 100644 index 000000000..710fbe5de --- /dev/null +++ b/tests/mpesa-rust/dynamic_qr_tests.rs @@ -0,0 +1,82 @@ +use mpesa::services::{DynamicQR, DynamicQRRequest}; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::get_mpesa_client; + +#[tokio::test] +async fn dynamic_qr_code_test_using_builder_pattern() { + let (client, server) = get_mpesa_client!(); + + let sample_response_body = json!({ + "QRCode": "A3F7B1H", + "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0" + }); + + Mock::given(method("POST")) + .and(path("/mpesa/qrcode/v1/generate")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(1) + .mount(&server) + .await; + + let response = client + .dynamic_qr() + .amount(2000) + .credit_party_identifier("17408") + .merchant_name("SafaricomLTD") + .ref_no("rf38f04") + .size("300") + .try_transaction_type("bg") + //.transaction_type(TransactionType::BuyGoods) // This is the same as the above + .unwrap() + .build() + .unwrap() + .send() + .await + .unwrap(); + + assert_eq!( + response.response_description, + "Accept the service request successfully." + ); + assert_eq!(response.response_code, "0"); +} + +#[tokio::test] +async fn dynamic_qr_code_test_using_struct_initialization() { + let (client, server) = get_mpesa_client!(); + + let sample_response_body = json!({ + "QRCode": "A3F7B1H", + "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0" + }); + + let request = DynamicQRRequest { + amount: 2000.0, + credit_party_identifier: "17408", + merchant_name: "SafaricomLTD", + ref_no: "rf38f04", + size: "300", + transaction_type: "bg".try_into().unwrap(), + }; + + Mock::given(method("POST")) + .and(path("/mpesa/qrcode/v1/generate")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(1) + .mount(&server) + .await; + + let response = DynamicQR::from_request(&client, request); + let response = response.send().await.unwrap(); + + assert_eq!( + response.response_description, + "Accept the service request successfully." + ); + assert_eq!(response.response_code, "0"); +} diff --git a/tests/mpesa-rust/main.rs b/tests/mpesa-rust/main.rs index 644d95585..ed859273a 100644 --- a/tests/mpesa-rust/main.rs +++ b/tests/mpesa-rust/main.rs @@ -10,7 +10,8 @@ mod bill_manager_test; mod c2b_register_test; #[cfg(test)] mod c2b_simulate_test; -#[cfg(test)] + +mod dynamic_qr_tests; mod helpers; #[cfg(test)] mod stk_push_test;