Skip to content

Commit

Permalink
feat: add dynamic qr code (#80)
Browse files Browse the repository at this point in the history
* feat: add dynamic qr code

* chore: add builder errors

* Fix doc

* Update src/services/dynamic_qr.rs

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

* chore: add from_request fn

* fix: merge conflicts

---------

Co-authored-by: Collins Muriuki <hello@collinsmuriuki.xyz>
  • Loading branch information
itsyaasir and c12i authored Nov 14, 2023
1 parent b50641d commit 175a93f
Show file tree
Hide file tree
Showing 11 changed files with 369 additions and 7 deletions.
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"]
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
38 changes: 34 additions & 4 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!";
Expand Down Expand Up @@ -507,6 +507,35 @@ impl<'mpesa, Env: ApiEnvironment> Mpesa<Env> {
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.
Expand Down Expand Up @@ -546,6 +575,7 @@ mod tests {
assert_eq!(client.initiator_password(), "foo_bar".to_string());
}

#[derive(Clone)]
struct TestEnvironment;

impl ApiEnvironment for TestEnvironment {
Expand Down
37 changes: 37 additions & 0 deletions src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Self, Self::Error> {
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")),
}
}
}
2 changes: 1 addition & 1 deletion src/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
27 changes: 26 additions & 1 deletion src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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")]
Expand All @@ -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
Expand All @@ -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<String> for BuilderError {
fn from(s: String) -> Self {
Self::ValidationError(s)
}
}

impl From<derive_builder::UninitializedFieldError> for MpesaError {
fn from(e: derive_builder::UninitializedFieldError) -> Self {
Self::BuilderError(BuilderError::UninitializedField(e.field_name()))
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
158 changes: 158 additions & 0 deletions src/services/dynamic_qr.rs
Original file line number Diff line number Diff line change
@@ -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<Env>,
/// 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<DynamicQR<'mpesa, Env>> 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<Env>) -> 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<Env>,
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<DynamicQRResponse> {
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::<DynamicQRRequest>(&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))
}
}
Loading

0 comments on commit 175a93f

Please sign in to comment.