Skip to content

Commit 175a93f

Browse files
itsyaasirc12i
andauthored
feat: add dynamic qr code (#80)
* feat: add dynamic qr code * chore: add builder errors * Fix doc * Update src/services/dynamic_qr.rs Co-authored-by: Collins Muriuki <[email protected]> * chore: add from_request fn * fix: merge conflicts --------- Co-authored-by: Collins Muriuki <[email protected]>
1 parent b50641d commit 175a93f

File tree

11 files changed

+369
-7
lines changed

11 files changed

+369
-7
lines changed

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ chrono = { version = "0.4", optional = true, default-features = false, features
1717
] }
1818
openssl = { version = "0.10", optional = true }
1919
reqwest = { version = "0.11", features = ["json"] }
20+
derive_builder = "0.12"
2021
serde = { version = "1.0", features = ["derive"] }
2122
serde_json = "1.0"
2223
serde_repr = "0.1"
@@ -40,7 +41,9 @@ default = [
4041
"express_request",
4142
"transaction_reversal",
4243
"transaction_status",
44+
"dynamic_qr"
4345
]
46+
dynamic_qr = []
4447
account_balance = ["dep:openssl"]
4548
b2b = ["dep:openssl"]
4649
b2c = ["dep:openssl"]

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Optionally, you can disable default-features, which is basically the entire suit
2828
- `transaction_reversal`
2929
- `transaction_status`
3030
- `bill_manager`
31+
- `dynamic_qr`
3132

3233
Example:
3334

@@ -371,6 +372,25 @@ let response = client
371372
assert!(response.is_ok())
372373
```
373374

375+
- Dynamic QR
376+
377+
```rust,ignore
378+
let response = client
379+
.dynamic_qr_code()
380+
.amount(1000)
381+
.ref_no("John Doe")
382+
.size("300")
383+
.merchant_name("John Doe")
384+
.credit_party_identifier("600496")
385+
.try_transaction_type("bg")
386+
.unwrap()
387+
.build()
388+
.unwrap()
389+
.send()
390+
.await;
391+
assert!(response.is_ok())
392+
```
393+
374394
More will be added progressively, pull requests welcome
375395

376396
## Author

src/client.rs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,17 @@ use openssl::base64;
55
use openssl::rsa::Padding;
66
use openssl::x509::X509;
77
use reqwest::Client as HttpClient;
8+
use secrecy::{ExposeSecret, Secret};
89

910
use crate::auth::AUTH;
1011
use crate::environment::ApiEnvironment;
1112
use crate::services::{
1213
AccountBalanceBuilder, B2bBuilder, B2cBuilder, BulkInvoiceBuilder, C2bRegisterBuilder,
13-
C2bSimulateBuilder, CancelInvoiceBuilder, MpesaExpressRequestBuilder, OnboardBuilder,
14-
OnboardModifyBuilder, ReconciliationBuilder, SingleInvoiceBuilder, TransactionReversalBuilder,
15-
TransactionStatusBuilder,
14+
C2bSimulateBuilder, CancelInvoiceBuilder, DynamicQR, DynamicQRBuilder,
15+
MpesaExpressRequestBuilder, OnboardBuilder, OnboardModifyBuilder, ReconciliationBuilder,
16+
SingleInvoiceBuilder, TransactionReversalBuilder, TransactionStatusBuilder,
1617
};
1718
use crate::{auth, MpesaResult};
18-
use secrecy::{ExposeSecret, Secret};
1919

2020
/// Source: [test credentials](https://developer.safaricom.co.ke/test_credentials)
2121
const DEFAULT_INITIATOR_PASSWORD: &str = "Safcom496!";
@@ -507,6 +507,35 @@ impl<'mpesa, Env: ApiEnvironment> Mpesa<Env> {
507507
TransactionStatusBuilder::new(self, initiator_name)
508508
}
509509

510+
/// ** Dynamic QR Code Builder **
511+
///
512+
/// Generates a QR code that can be scanned by a M-Pesa customer to make
513+
/// payments.
514+
///
515+
/// See more from the Safaricom API docs [here](https://developer.safaricom.
516+
/// co.ke/APIs/DynamicQRCode)
517+
///
518+
/// # Example
519+
/// ```ignore
520+
/// let response = client
521+
/// .dynamic_qr_code()
522+
/// .amount(1000)
523+
/// .ref_no("John Doe")
524+
/// .size("300")
525+
/// .merchant_name("John Doe")
526+
/// .credit_party_identifier("600496")
527+
/// .try_transaction_type("bg")
528+
/// .unwrap()
529+
/// .build()
530+
/// .unwrap()
531+
/// .send()
532+
/// .await;
533+
/// ```
534+
///
535+
#[cfg(feature = "dynamic_qr")]
536+
pub fn dynamic_qr(&'mpesa self) -> DynamicQRBuilder<'mpesa, Env> {
537+
DynamicQR::builder(self)
538+
}
510539
/// Generates security credentials
511540
/// M-Pesa Core authenticates a transaction by decrypting the security credentials.
512541
/// 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 {
546575
assert_eq!(client.initiator_password(), "foo_bar".to_string());
547576
}
548577

578+
#[derive(Clone)]
549579
struct TestEnvironment;
550580

551581
impl ApiEnvironment for TestEnvironment {

src/constants.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use chrono::prelude::{DateTime, Utc};
44
use serde::{Deserialize, Serialize};
55
use serde_repr::{Deserialize_repr, Serialize_repr};
66

7+
use crate::MpesaError;
8+
79
/// Mpesa command ids
810
#[derive(Debug, Serialize, Deserialize)]
911
pub enum CommandId {
@@ -141,3 +143,38 @@ impl<'i> Display for InvoiceItem<'i> {
141143
write!(f, "amount: {}, item_name: {}", self.amount, self.item_name)
142144
}
143145
}
146+
147+
#[derive(Debug, Clone, Copy, Serialize)]
148+
pub enum TransactionType {
149+
/// Send Money(Mobile number).
150+
SendMoney,
151+
/// Withdraw Cash at Agent Till
152+
Withdraw,
153+
/// Pay Merchant (Buy Goods)
154+
BG,
155+
/// Paybill or Business number
156+
PayBill,
157+
/// Sent to Business. Business number CPI in MSISDN format.
158+
SendBusiness,
159+
}
160+
161+
impl Display for TransactionType {
162+
fn fmt(&self, f: &mut Formatter) -> FmtResult {
163+
write!(f, "{self:?}")
164+
}
165+
}
166+
167+
impl TryFrom<&str> for TransactionType {
168+
type Error = MpesaError;
169+
170+
fn try_from(value: &str) -> Result<Self, Self::Error> {
171+
match value.to_lowercase().as_str() {
172+
"bg" => Ok(TransactionType::BG),
173+
"wa" => Ok(TransactionType::Withdraw),
174+
"pb" => Ok(TransactionType::PayBill),
175+
"sm" => Ok(TransactionType::SendMoney),
176+
"sb" => Ok(TransactionType::SendBusiness),
177+
_ => Err(MpesaError::Message("Invalid transaction type")),
178+
}
179+
}
180+
}

src/environment.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ pub enum Environment {
2828

2929
/// Expected behavior of an `Mpesa` client environment
3030
/// This abstraction exists to make it possible to mock the MPESA api server for tests
31-
pub trait ApiEnvironment {
31+
pub trait ApiEnvironment: Clone {
3232
fn base_url(&self) -> &str;
3333
fn get_certificate(&self) -> &str;
3434
}

src/errors.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ use std::env::VarError;
22
use std::fmt;
33

44
use serde::{Deserialize, Serialize};
5+
use thiserror::Error;
56

67
/// Mpesa error stack
7-
#[derive(thiserror::Error, Debug)]
8+
#[derive(Error, Debug)]
89
pub enum MpesaError {
910
#[error("{0}")]
1011
AuthenticationError(ApiError),
@@ -36,6 +37,8 @@ pub enum MpesaError {
3637
MpesaTransactionReversalError(ApiError),
3738
#[error("Mpesa Transaction status failed: {0}")]
3839
MpesaTransactionStatusError(ApiError),
40+
#[error("Mpesa Dynamic QR failed: {0}")]
41+
MpesaDynamicQrError(ApiError),
3942
#[error("An error has occured while performing the http request")]
4043
NetworkError(#[from] reqwest::Error),
4144
#[error("An error has occured while serializig/ deserializing")]
@@ -46,6 +49,8 @@ pub enum MpesaError {
4649
EncryptionError(#[from] openssl::error::ErrorStack),
4750
#[error("{0}")]
4851
Message(&'static str),
52+
#[error("An error has occurred while building the request: {0}")]
53+
BuilderError(BuilderError),
4954
}
5055

5156
/// `Result` enum type alias
@@ -67,3 +72,23 @@ impl fmt::Display for ApiError {
6772
)
6873
}
6974
}
75+
76+
#[derive(Debug, Error)]
77+
pub enum BuilderError {
78+
#[error("Field [{0}] is required")]
79+
UninitializedField(&'static str),
80+
#[error("Field [{0}] is invalid")]
81+
ValidationError(String),
82+
}
83+
84+
impl From<String> for BuilderError {
85+
fn from(s: String) -> Self {
86+
Self::ValidationError(s)
87+
}
88+
}
89+
90+
impl From<derive_builder::UninitializedFieldError> for MpesaError {
91+
fn from(e: derive_builder::UninitializedFieldError) -> Self {
92+
Self::BuilderError(BuilderError::UninitializedField(e.field_name()))
93+
}
94+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub mod services;
1010
pub use client::Mpesa;
1111
pub use constants::{
1212
CommandId, IdentifierTypes, Invoice, InvoiceItem, ResponseType, SendRemindersTypes,
13+
TransactionType,
1314
};
1415
pub use environment::ApiEnvironment;
1516
pub use environment::Environment::{self, Production, Sandbox};

src/services/dynamic_qr.rs

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
use derive_builder::Builder;
2+
use serde::{Deserialize, Serialize};
3+
4+
use crate::client::Mpesa;
5+
use crate::constants::TransactionType;
6+
use crate::environment::ApiEnvironment;
7+
use crate::errors::{MpesaError, MpesaResult};
8+
9+
const DYNAMIC_QR_URL: &str = "/mpesa/qrcode/v1/generate";
10+
11+
#[derive(Debug, Serialize)]
12+
#[serde(rename_all = "PascalCase")]
13+
pub struct DynamicQRRequest<'mpesa> {
14+
/// Name of the Company/M-Pesa Merchant Name
15+
pub merchant_name: &'mpesa str,
16+
/// Transaction Reference Number
17+
pub ref_no: &'mpesa str,
18+
/// The total amount of the transaction
19+
pub amount: f64,
20+
#[serde(rename = "TrxCode")]
21+
/// Transaction Type
22+
///
23+
/// This can be a `TransactionType` or a `&str`
24+
/// The `&str` must be one of the following:
25+
/// - `BG` for Buy Goods
26+
/// - `PB` for Pay Bill
27+
/// - `WA` Withdraw Cash
28+
/// - `SM` Send Money (Mobile Number)
29+
/// - `SB` Sent to Business. Business number CPI in MSISDN format.
30+
pub transaction_type: TransactionType,
31+
///Credit Party Identifier.
32+
///
33+
/// Can be a Mobile Number, Business Number, Agent
34+
/// Till, Paybill or Business number, or Merchant Buy Goods.
35+
#[serde(rename = "CPI")]
36+
pub credit_party_identifier: &'mpesa str,
37+
/// Size of the QR code image in pixels.
38+
///
39+
/// QR code image will always be a square image.
40+
pub size: &'mpesa str,
41+
}
42+
43+
#[derive(Debug, Clone, Deserialize)]
44+
#[serde(rename_all = "PascalCase")]
45+
pub struct DynamicQRResponse {
46+
#[serde(rename(deserialize = "QRCode"))]
47+
pub qr_code: String,
48+
pub response_code: String,
49+
pub response_description: String,
50+
}
51+
52+
/// Dynamic QR builder struct
53+
#[derive(Builder, Debug, Clone)]
54+
#[builder(build_fn(error = "MpesaError"))]
55+
pub struct DynamicQR<'mpesa, Env: ApiEnvironment> {
56+
#[builder(pattern = "immutable")]
57+
client: &'mpesa Mpesa<Env>,
58+
/// Name of the Company/M-Pesa Merchant Name
59+
#[builder(setter(into))]
60+
merchant_name: &'mpesa str,
61+
/// Transaction Reference Number
62+
#[builder(setter(into))]
63+
amount: f64,
64+
/// The total amount of the transaction
65+
ref_no: &'mpesa str,
66+
/// Transaction Type
67+
///
68+
/// This can be a `TransactionType` or a `&str`
69+
/// The `&str` must be one of the following:
70+
/// - `BG` for Buy Goods
71+
/// - `PB` for Pay Bill
72+
/// - `WA` Withdraw Cash
73+
/// - `SM` Send Money (Mobile Number)
74+
/// - `SB` Sent to Business. Business number CPI in MSISDN format.
75+
#[builder(try_setter, setter(into))]
76+
transaction_type: TransactionType,
77+
/// Credit Party Identifier.
78+
/// Can be a Mobile Number, Business Number, Agent
79+
/// Till, Paybill or Business number, or Merchant Buy Goods.
80+
#[builder(setter(into))]
81+
credit_party_identifier: &'mpesa str,
82+
/// Size of the QR code image in pixels.
83+
///
84+
/// QR code image will always be a square image.
85+
#[builder(setter(into))]
86+
size: &'mpesa str,
87+
}
88+
89+
impl<'mpesa, Env: ApiEnvironment> From<DynamicQR<'mpesa, Env>> for DynamicQRRequest<'mpesa> {
90+
fn from(express: DynamicQR<'mpesa, Env>) -> DynamicQRRequest<'mpesa> {
91+
DynamicQRRequest {
92+
merchant_name: express.merchant_name,
93+
ref_no: express.ref_no,
94+
amount: express.amount,
95+
transaction_type: express.transaction_type,
96+
credit_party_identifier: express.credit_party_identifier,
97+
size: express.size,
98+
}
99+
}
100+
}
101+
102+
impl<'mpesa, Env: ApiEnvironment> DynamicQR<'mpesa, Env> {
103+
pub(crate) fn builder(client: &'mpesa Mpesa<Env>) -> DynamicQRBuilder<'mpesa, Env> {
104+
DynamicQRBuilder::default().client(client)
105+
}
106+
107+
/// # Build Dynamic QR
108+
///
109+
/// Returns a `DynamicQR` which can be used to send a request
110+
pub fn from_request(
111+
client: &'mpesa Mpesa<Env>,
112+
request: DynamicQRRequest<'mpesa>,
113+
) -> DynamicQR<'mpesa, Env> {
114+
DynamicQR {
115+
client,
116+
merchant_name: request.merchant_name,
117+
ref_no: request.ref_no,
118+
amount: request.amount,
119+
transaction_type: request.transaction_type,
120+
credit_party_identifier: request.credit_party_identifier,
121+
size: request.size,
122+
}
123+
}
124+
125+
/// # Generate a Dynamic QR
126+
///
127+
/// This enables Safaricom M-PESA customers who
128+
/// have My Safaricom App or M-PESA app, to scan a QR (Quick Response)
129+
/// code, to capture till number and amount then authorize to pay for goods
130+
/// and services at select LIPA NA M-PESA (LNM) merchant outlets.
131+
///
132+
/// # Response
133+
/// A successful request returns a `DynamicQRResponse` type
134+
/// which contains the QR code
135+
///
136+
/// # Errors
137+
/// Returns a `MpesaError` on failure
138+
pub async fn send(self) -> MpesaResult<DynamicQRResponse> {
139+
let url = format!("{}{}", self.client.environment.base_url(), DYNAMIC_QR_URL);
140+
141+
let response = self
142+
.client
143+
.http_client
144+
.post(&url)
145+
.bearer_auth(self.client.auth().await?)
146+
.json::<DynamicQRRequest>(&self.into())
147+
.send()
148+
.await?;
149+
150+
if response.status().is_success() {
151+
let value = response.json::<_>().await?;
152+
return Ok(value);
153+
}
154+
155+
let value = response.json().await?;
156+
Err(MpesaError::MpesaDynamicQrError(value))
157+
}
158+
}

0 commit comments

Comments
 (0)