From b313747438366ca8f353be63b0e010c15de53b8b Mon Sep 17 00:00:00 2001 From: Yasir Date: Sun, 28 Jan 2024 16:46:53 +0300 Subject: [PATCH] chore: move express-request and transaction reversal to builder pattern (#88) * chore: move express-request to builder pattern * chore: migrate api, reorganise imports and remove redundant tests * chore: remove unecessary deps * chore: fix imports order * chore: remove vscode files * Add more tests * Add validator module and implement phone number validation * chore: rename method * Fix phone number validation and remove unnecessary receiver identifier type * Add more variants for phone validator * Fix URL path in express_request.rs and transaction_reversal.rs * Fix import order in lib.rs * Update amount field from f64 to u32 in express_request.rs and transaction_reversal.rs * Update dependencies and add receiver identifier type * Fix merge conflicts * Fix doc tests * Fix dotenvy crate * Fix: dotenv failure to read .env messages * fix: uninitialized pass key * fix: timestamp * chore: clean up codes * fix: compilation * chore: update intiaitor password * chore: revert removal of identifier receiver * chore: revert removal of identifier receiver * chore: revert removal of identifier receiver * fix: express request tests * fix: express request tests * fix: express request tests * fix: express request tests * chore: refactor codes * further investigation --- .gitignore | 4 +- .vscode/settings.json | 5 - Cargo.toml | 49 +-- README.md | 28 +- docs/client/account_balance.md | 11 +- docs/client/b2b.md | 11 +- docs/client/b2c.md | 11 +- docs/client/bill_manager/bulk_invoice.md | 11 +- docs/client/bill_manager/cancel_invoice.md | 11 +- docs/client/bill_manager/onboard.md | 11 +- docs/client/bill_manager/onboard_modify.md | 11 +- docs/client/bill_manager/reconciliation.md | 11 +- docs/client/bill_manager/single_invoice.md | 11 +- docs/client/c2b_register.md | 11 +- docs/client/c2b_simulate.md | 41 +- docs/client/dynamic_qr.md | 11 +- docs/client/express_request.md | 30 +- docs/client/transaction_reversal.md | 33 +- docs/client/transaction_status.md | 11 +- src/auth.rs | 2 +- src/client.rs | 58 ++- src/constants.rs | 2 +- src/errors.rs | 6 + src/lib.rs | 3 +- src/services/express_request.rs | 388 +++++++++--------- src/services/mod.rs | 9 +- src/services/transaction_reversal.rs | 267 +++++------- src/validator.rs | 104 +++++ tests/mpesa-rust/helpers.rs | 16 +- tests/mpesa-rust/stk_push_test.rs | 145 +++---- tests/mpesa-rust/transaction_reversal_test.rs | 187 ++------- 31 files changed, 765 insertions(+), 744 deletions(-) delete mode 100644 .vscode/settings.json create mode 100644 src/validator.rs diff --git a/.gitignore b/.gitignore index 24a2d2013..56c16255d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ /.idea Cargo.lock .env -.DS_Store \ No newline at end of file +.DS_Store + +.vscode diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index a03a9d2e6..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "cSpell.words": [ - "Mpesa" - ] -} \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 4986fbe8d..6093a777b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,28 +9,6 @@ repository = "https://github.com/collinsmuriuki/mpesa-rust" readme = "./README.md" license = "MIT" -[dependencies] -cached = { version = "0.46", features = ["wasm", "async", "proc_macro"] } -chrono = { version = "0.4", optional = true, default-features = false, features = [ - "clock", - "serde", -] } -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" -thiserror = "1.0.37" -wiremock = "0.5" -secrecy = "0.8.0" -serde-aux = "4.2.0" - -[dev-dependencies] -dotenv = "0.15" -tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } -wiremock = "0.5" - [features] default = [ "account_balance", @@ -42,7 +20,7 @@ default = [ "express_request", "transaction_reversal", "transaction_status", - "dynamic_qr" + "dynamic_qr", ] dynamic_qr = [] account_balance = ["dep:openssl"] @@ -54,3 +32,28 @@ c2b_simulate = [] express_request = ["dep:chrono"] transaction_reversal = ["dep:openssl"] transaction_status = ["dep:openssl"] + + +[dependencies] +cached = { version = "0.46", features = ["wasm", "async", "proc_macro"] } +chrono = { version = "0.4", optional = true, default-features = false, features = [ + "clock", + "serde", +] } +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" +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" +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } +wiremock = "0.5" diff --git a/README.md b/README.md index f97d49f38..65e401452 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,11 @@ use mpesa::{Mpesa, Environment}; #[tokio::main] async fn main() { - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), Environment::Sandbox, ); @@ -73,11 +73,11 @@ use std::str::FromStr; #[tokio::main] async fn main() -> Result<(), Box> { - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), Environment::from_str("sandbox")?, // or // Environment::try_from("sandbox")?, ); @@ -121,11 +121,11 @@ impl ApiEnvironment for CustomEnvironment { #[tokio::main] async fn main() { - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), CustomEnvironment, ); } @@ -139,11 +139,11 @@ use mpesa::{Mpesa, Environment}; #[tokio::main] async fn main() { - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), Environment::Sandbox, ); @@ -177,8 +177,8 @@ The table below shows all the MPESA APIs from Safaricom and those supported by t **Collins Muriuki** -- Twitter: [@c12i\_](https://twitter.com/c12i_) -- Not affiliated with Safaricom. +- Twitter: [@c12i\_](https://twitter.com/c12i_) +- Not affiliated with Safaricom. ## Contributing diff --git a/docs/client/account_balance.md b/docs/client/account_balance.md index a6392e627..f0844cc5b 100644 --- a/docs/client/account_balance.md +++ b/docs/client/account_balance.md @@ -1,3 +1,5 @@ +# Account Balance + The Account Balance API is used to request the account balance of a short code. This can be used for both B2C, buy goods and pay bill accounts. Requires an `initiator_name`. @@ -5,17 +7,18 @@ Returns an `AccountBalanceBuilder` for enquiring the balance on an MPESA BuyGood Safaricom API docs [reference](https://developer.safaricom.co.ke/APIs/AccountBalance) -# Example +## Example + ```rust use mpesa::{Mpesa, Environment}; #[tokio::main] async fn main() { - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), Environment::Sandbox, ); diff --git a/docs/client/b2b.md b/docs/client/b2b.md index 786580214..bcdfc20ab 100644 --- a/docs/client/b2b.md +++ b/docs/client/b2b.md @@ -1,3 +1,5 @@ +# B2B + This API enables you to pay bills directly from your business account to a pay bill number, or a paybill store. You can use this API to pay on behalf of a consumer/requester. The transaction moves money from your MMF/Working account to the recipient’s utility account. @@ -7,17 +9,18 @@ Requires an `initiator_name`, the credential/ username used to authenticate the Safaricom API docs [reference](https://developer.safaricom.co.ke/APIs/BusinessPayBill) -# Example +## Example + ```rust use mpesa::{Mpesa, Environment}; #[tokio::main] async fn main() { - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), Environment::Sandbox, ); diff --git a/docs/client/b2c.md b/docs/client/b2c.md index bb1188b5c..6e674d10e 100644 --- a/docs/client/b2c.md +++ b/docs/client/b2c.md @@ -1,19 +1,22 @@ +# B2C + Requires an `initiator_name`, the credential/ username used to authenticate the transaction request Returns a `B2cBuilder` for building a B2C transaction struct. Safaricom the API docs [reference](https://developer.safaricom.co.ke/APIs/BusinessToCustomer). -# Example +## Example + ```rust use mpesa::{Mpesa, Environment}; #[tokio::main] async fn main() { - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), Environment::Sandbox, ); diff --git a/docs/client/bill_manager/bulk_invoice.md b/docs/client/bill_manager/bulk_invoice.md index b02e324db..ad7a919e9 100644 --- a/docs/client/bill_manager/bulk_invoice.md +++ b/docs/client/bill_manager/bulk_invoice.md @@ -1,19 +1,22 @@ +# Bulk Invoice + Creates a `BulkInvoiceBuilder` which allows you to send invoices to your customers in bulk. Safaricom API docs [reference](https://developer.safaricom.co.ke/APIs/BillManager) -# Example +## Example + ```rust,ignore use mpesa::{Mpesa, Environment, Invoice, InvoiceItem}; use chrono::prelude::Utc; #[tokio::main] async fn main() { - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), Environment::Sandbox, ); diff --git a/docs/client/bill_manager/cancel_invoice.md b/docs/client/bill_manager/cancel_invoice.md index bdcffac32..07dc152a2 100644 --- a/docs/client/bill_manager/cancel_invoice.md +++ b/docs/client/bill_manager/cancel_invoice.md @@ -1,19 +1,22 @@ +# Cancel Invoice + Creates a `CancelInvoiceBuilder` which allows you to recall a sent invoice. Safaricom API docs [reference](https://developer.safaricom.co.ke/APIs/BillManager) -# Example +## Example + ```rust,ignore use mpesa::{Mpesa, Environment, SendRemindersTypes}; use chrono::prelude::Utc; #[tokio::main] async fn main() { - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), Environment::Sandbox, ); diff --git a/docs/client/bill_manager/onboard.md b/docs/client/bill_manager/onboard.md index 7e17a9c32..e797cc509 100644 --- a/docs/client/bill_manager/onboard.md +++ b/docs/client/bill_manager/onboard.md @@ -1,18 +1,21 @@ +# Onboard + Creates a `OnboardBuilder` which allows you to opt in as a biller to the bill manager features. Safaricom API docs [reference](https://developer.safaricom.co.ke/APIs/BillManager) -# Example +## Example + ```rust,ignore use mpesa::{Mpesa, Environment, SendRemindersTypes}; #[tokio::main] async fn main() { - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), Environment::Sandbox, ); diff --git a/docs/client/bill_manager/onboard_modify.md b/docs/client/bill_manager/onboard_modify.md index 4016d49a9..a27501b0c 100644 --- a/docs/client/bill_manager/onboard_modify.md +++ b/docs/client/bill_manager/onboard_modify.md @@ -1,18 +1,21 @@ +# Onboard Modify + Creates a `OnboardModifyBuilder` which allows you to opt in as a biller to the bill manager features. Safaricom API docs [reference](https://developer.safaricom.co.ke/APIs/BillManager) -# Example +## Example + ```rust,ignore use mpesa::{Mpesa, Environment, SendRemindersTypes}; #[tokio::main] async fn main() { - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), Environment::Sandbox, ); diff --git a/docs/client/bill_manager/reconciliation.md b/docs/client/bill_manager/reconciliation.md index f32dfdb1e..0bf99dde7 100644 --- a/docs/client/bill_manager/reconciliation.md +++ b/docs/client/bill_manager/reconciliation.md @@ -1,19 +1,22 @@ +# Reconciliation + Creates a `ReconciliationBuilder` which enables your customers to receive e-receipts for payments made to your paybill account. Safaricom API docs [reference](https://developer.safaricom.co.ke/APIs/BillManager) -# Example +## Example + ```rust,ignore use mpesa::{Mpesa, Environment}; use chrono::prelude::Utc; #[tokio::main] async fn main() { - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), Environment::Sandbox, ); diff --git a/docs/client/bill_manager/single_invoice.md b/docs/client/bill_manager/single_invoice.md index 46b95de4f..5dcd28b5e 100644 --- a/docs/client/bill_manager/single_invoice.md +++ b/docs/client/bill_manager/single_invoice.md @@ -1,19 +1,22 @@ +# Single Invoice + Creates a `SingleInvoiceBuilder` which allows you to create and send invoices to your customers. Safaricom API docs [reference](https://developer.safaricom.co.ke/APIs/BillManager) -# Example +## Example + ```rust,ignore use mpesa::{Mpesa, Environment, InvoiceItem}; use chrono::prelude::Utc; #[tokio::main] async fn main() { - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), Environment::Sandbox, ); diff --git a/docs/client/c2b_register.md b/docs/client/c2b_register.md index 96e4fac45..a9d5e921d 100644 --- a/docs/client/c2b_register.md +++ b/docs/client/c2b_register.md @@ -1,3 +1,5 @@ +# C2B Register + Register URL API works hand in hand with Customer to Business (C2B) APIs and allows receiving payment notifications to your paybill. This API enables you to register the callback URLs via which you shall receive notifications for payments to your pay bill/till number. There are two URLs required for Register URL API: Validation URL and Confirmation URL. @@ -6,17 +8,18 @@ Returns a `C2bRegisterBuilder` See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/APIs/CustomerToBusinessRegisterURL) -# Example +## Example + ```rust,no_run use mpesa::{Mpesa, Environment}; #[tokio::main] async fn main() { - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), Environment::Sandbox, ); diff --git a/docs/client/c2b_simulate.md b/docs/client/c2b_simulate.md index 2352348ef..bd07f87b0 100644 --- a/docs/client/c2b_simulate.md +++ b/docs/client/c2b_simulate.md @@ -1,30 +1,33 @@ +# C2B Simulate + Creates a `C2bSimulateBuilder` for simulating C2B transactions See more [here](https://developer.safaricom.co.ke/c2b/apis/post/simulate) -# Example +## Example + ```rust use mpesa::{Mpesa, Environment}; #[tokio::main] async fn main() { - dotenv::dotenv().ok(); - - let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), - Environment::Sandbox, - ); - - let response = client.c2b_simulate() - .short_code("600496") - .msisdn("254700000000") - .amount(1000) - .command_id(mpesa::CommandId::CustomerPayBillOnline) // optional, defaults to `CommandId::CustomerPayBillOnline` - .bill_ref_number("Your_BillRefNumber") // optional, defaults to "None" - .send() - .await; - - assert!(response.is_ok()) + dotenvy::dotenv().ok(); + + let client = Mpesa::new( + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), + Environment::Sandbox, + ); + + let response = client.c2b_simulate() + .short_code("600496") + .msisdn("254700000000") + .amount(1000) + .command_id(mpesa::CommandId::CustomerPayBillOnline) // optional, defaults to `CommandId::CustomerPayBillOnline` + .bill_ref_number("Your_BillRefNumber") // optional, defaults to "None" + .send() + .await; + + assert!(response.is_ok()) } ``` diff --git a/docs/client/dynamic_qr.md b/docs/client/dynamic_qr.md index d626cf579..9cef08a3e 100644 --- a/docs/client/dynamic_qr.md +++ b/docs/client/dynamic_qr.md @@ -1,3 +1,5 @@ +# Dynamic QR + Generates a QR code that can be scanned by a M-Pesa customer to make payments. @@ -5,17 +7,18 @@ Returns a `DynamicQRBuilder` Safaricom API docs [reference](https://developer.safaricom.co.ke/APIs/DynamicQRCode) -# Example +## Example + ```rust use mpesa::{Mpesa, Environment}; #[tokio::main] async fn main() { - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), Environment::Sandbox, ); diff --git a/docs/client/express_request.md b/docs/client/express_request.md index 13a10eabd..a58a95dcb 100644 --- a/docs/client/express_request.md +++ b/docs/client/express_request.md @@ -1,3 +1,5 @@ +# 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. Once you, our merchant integrate with the API, you will be able to send a payment prompt on the customer's phone (Popularly known as STK Push Prompt) to your customer's M-PESA registered phone number requesting them to enter their M-PESA pin to authorize and complete payment. @@ -7,32 +9,40 @@ returns a `MpesaExpressRequestBuilder` struct Safaricom API docs [reference](https://developer.safaricom.co.ke/APIs/MpesaExpressSimulate) -# Example -```rust +## Example + +TODO::Should be investigated why the test fails + +```rust,ignore use mpesa::{Mpesa, Environment}; #[tokio::main] -async fn main() { - dotenv::dotenv().ok(); +async fn main() -> Result<(), Box>{ + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), Environment::Sandbox, ); let response = client - .express_request("174379") + .express_request() + .business_short_code("174379") .phone_number("254708374149") - .party_a("254708374149") + .party_a("600584") .party_b("174379") .amount(500) - .callback_url("https://test.example.com/api") + .try_callback_url("https://test.example.com/api")? + .account_ref("Test") .transaction_type(mpesa::CommandId::CustomerPayBillOnline) // Optional, defaults to `CommandId::CustomerPayBillOnline` .transaction_desc("Description") // Optional, defaults to "None" + .build()? .send() .await; - assert!(response.is_ok()) + assert!(response.is_ok()); + + Ok(()) } ``` diff --git a/docs/client/transaction_reversal.md b/docs/client/transaction_reversal.md index 6b24ceaca..08543ddbd 100644 --- a/docs/client/transaction_reversal.md +++ b/docs/client/transaction_reversal.md @@ -1,36 +1,43 @@ -Reverses a C2B M-Pesa transaction. +# Transaction Reversal + +## Reverses a C2B M-Pesa transaction Requires an `initiator_name`, the credential/ username used to authenticate the transaction request Returns a `TransactionReversalBuilder` -See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/Documentation) +See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/APIs/Reversal) + +## Example -# Example ```rust use mpesa::{Mpesa, Environment}; #[tokio::main] -async fn main() { - dotenv::dotenv().ok(); +async fn main() -> Result<(), Box> { + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), Environment::Sandbox, ); let response = client - .transaction_reversal("testapi496") - .result_url("https://testdomain.com/ok") - .timeout_url("https://testdomain.com/err") + .transaction_reversal() + .initiator("testapi496") + .try_result_url("https://testdomain.com/ok")? + .try_timeout_url("https://testdomain.com/err")? .transaction_id("OEI2AK4Q16") - .command_id(mpesa::CommandId::TransactionReversal) // optional will default to CommandId::TransactionReversal - .receiver_identifier_type(mpesa::IdentifierTypes::Reversal) // optional will default to IdentifierTypes::Reversal + .receiver_identifier_type(mpesa::IdentifierTypes::Reversal) + .remarks("test") .amount(100) .receiver_party("600111") + .build()? .send() .await; - assert!(response.is_ok()) + assert!(response.is_ok()); + + Ok(()) } ``` diff --git a/docs/client/transaction_status.md b/docs/client/transaction_status.md index 70d67a630..03bdcac7d 100644 --- a/docs/client/transaction_status.md +++ b/docs/client/transaction_status.md @@ -1,3 +1,5 @@ +# Transaction Status + Queries the status of a B2B, B2C or C2B M-Pesa transaction. Requires an `initiator_name`, the credential/ username used to authenticate the transaction request @@ -5,17 +7,18 @@ Returns a `TransactionStatusBuilder`. See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/Documentation) -# Example +## Example + ```rust use mpesa::{Mpesa, Environment}; #[tokio::main] async fn main() { - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new( - env!("CLIENT_KEY"), - env!("CLIENT_SECRET"), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), Environment::Sandbox, ); diff --git a/src/auth.rs b/src/auth.rs index 6ffed9192..aaed769bc 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -56,10 +56,10 @@ impl std::fmt::Display for AuthenticationResponse { #[cfg(test)] mod tests { - use crate::ApiEnvironment; use wiremock::{Mock, MockServer}; use super::*; + use crate::ApiEnvironment; #[derive(Debug, Clone)] pub struct TestEnvironment { diff --git a/src/client.rs b/src/client.rs index c2a057053..df08e43d5 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,4 +1,5 @@ use std::cell::RefCell; +use std::time::Duration; use cached::Cached; use openssl::base64; @@ -13,14 +14,15 @@ use crate::auth::AUTH; use crate::environment::ApiEnvironment; use crate::services::{ AccountBalanceBuilder, B2bBuilder, B2cBuilder, BulkInvoiceBuilder, C2bRegisterBuilder, - C2bSimulateBuilder, CancelInvoiceBuilder, DynamicQR, DynamicQRBuilder, - MpesaExpressRequestBuilder, OnboardBuilder, OnboardModifyBuilder, ReconciliationBuilder, - SingleInvoiceBuilder, TransactionReversalBuilder, TransactionStatusBuilder, + C2bSimulateBuilder, CancelInvoiceBuilder, DynamicQR, DynamicQRBuilder, MpesaExpress, + MpesaExpressBuilder, OnboardBuilder, OnboardModifyBuilder, ReconciliationBuilder, + SingleInvoiceBuilder, TransactionReversal, TransactionReversalBuilder, + TransactionStatusBuilder, }; -use crate::{auth, MpesaResult}; +use crate::{auth, MpesaError, MpesaResult, ResponseError}; /// Source: [test credentials](https://developer.safaricom.co.ke/test_credentials) -const DEFAULT_INITIATOR_PASSWORD: &str = "Safcom496!"; +const DEFAULT_INITIATOR_PASSWORD: &str = "Safaricom999!*!"; /// Get current package version from metadata const CARGO_PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -45,11 +47,11 @@ impl Mpesa { /// /// #[tokio::main] /// async fn main() { - /// dotenv::dotenv().ok(); + /// dotenvy::dotenv().ok(); /// /// let client = Mpesa::new( - /// env!("CLIENT_KEY"), - /// env!("CLIENT_SECRET"), + /// dotenvy::var("CLIENT_KEY").unwrap(), + /// dotenvy::var("CLIENT_SECRET").unwrap(), /// Environment::Sandbox, /// ); /// @@ -64,10 +66,8 @@ impl Mpesa { environment: impl ApiEnvironment, ) -> Self { let http_client = HttpClient::builder() - .connect_timeout(std::time::Duration::from_millis(10_000)) + .connect_timeout(Duration::from_secs(10)) .user_agent(format!("mpesa-rust@{CARGO_PACKAGE_VERSION}")) - // TODO: Potentialy return a `Result` enum from Mpesa::new? - // Making assumption that creation of http client cannot fail .build() .expect("Error building http client"); @@ -121,11 +121,11 @@ impl Mpesa { /// /// #[tokio::main] /// async fn main() { - /// dotenv::dotenv().ok(); + /// dotenvy::dotenv().ok(); /// /// let client = Mpesa::new( - /// env!("CLIENT_KEY"), - /// env!("CLIENT_SECRET"), + /// dotenvy::var("CLIENT_KEY").unwrap(), + /// dotenvy::var("CLIENT_SECRET").unwrap(), /// Environment::Sandbox, /// ); /// client.set_initiator_password("your_initiator_password"); @@ -155,7 +155,7 @@ impl Mpesa { } // Generate a new access token - let new_token = auth::auth_prime_cache(self).await?; + let new_token = auth::auth(self).await?; // Double-check if the access token is cached by another thread if let Some(token) = AUTH.lock().await.cache_get(&self.client_key) { @@ -238,20 +238,14 @@ impl Mpesa { #[cfg(feature = "express_request")] #[doc = include_str!("../docs/client/express_request.md")] - pub fn express_request<'a>( - &'a self, - business_short_code: &'a str, - ) -> MpesaExpressRequestBuilder { - MpesaExpressRequestBuilder::new(self, business_short_code) + pub fn express_request(&self) -> MpesaExpressBuilder { + MpesaExpress::builder(self) } #[cfg(feature = "transaction_reversal")] #[doc = include_str!("../docs/client/transaction_reversal.md")] - pub fn transaction_reversal<'a>( - &'a self, - initiator_name: &'a str, - ) -> TransactionReversalBuilder { - TransactionReversalBuilder::new(self, initiator_name) + pub fn transaction_reversal(&self) -> TransactionReversalBuilder { + TransactionReversal::builder(self) } #[cfg(feature = "transaction_status")] @@ -301,22 +295,20 @@ impl Mpesa { { let url = format!("{}/{}", self.base_url, req.path); - let req = self + let res = self .http_client .request(req.method, url) .bearer_auth(self.auth().await?) - .json(&req.body); - - let res = req.send().await?; + .json(&req.body) + .send() + .await?; if res.status().is_success() { let body = res.json().await?; - Ok(body) } else { - let err = res.json::().await?; - - Err(crate::MpesaError::Service(err)) + let err = res.json::().await?; + Err(MpesaError::Service(err)) } } } diff --git a/src/constants.rs b/src/constants.rs index 0388c6291..cf99c0865 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -7,7 +7,7 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; use crate::MpesaError; /// Mpesa command ids -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum CommandId { TransactionReversal, SalaryPayment, diff --git a/src/errors.rs b/src/errors.rs index 13316e9a7..546ade537 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -63,3 +63,9 @@ impl From for MpesaError { Self::BuilderError(BuilderError::UninitializedField(e.field_name())) } } + +impl From for MpesaError { + fn from(e: url::ParseError) -> Self { + Self::BuilderError(BuilderError::ValidationError(e.to_string())) + } +} diff --git a/src/lib.rs b/src/lib.rs index 55959d6fe..f764ec1f9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ mod constants; pub mod environment; mod errors; pub mod services; +pub mod validator; pub use client::Mpesa; pub use constants::{ @@ -14,4 +15,4 @@ pub use constants::{ }; pub use environment::ApiEnvironment; pub use environment::Environment::{self, Production, Sandbox}; -pub use errors::{MpesaError, MpesaResult, ResponseError}; +pub use errors::{BuilderError, MpesaError, MpesaResult, ResponseError}; diff --git a/src/services/express_request.rs b/src/services/express_request.rs index 8330632a9..59828b27a 100644 --- a/src/services/express_request.rs +++ b/src/services/express_request.rs @@ -1,249 +1,253 @@ #![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 url::Url; use crate::client::Mpesa; use crate::constants::CommandId; use crate::errors::{MpesaError, MpesaResult}; - -const EXPRESS_REQUEST_URL: &str = "mpesa/stkpush/v1/processrequest"; +use crate::validator::PhoneNumberValidator; /// Source: [test credentials](https://developer.safaricom.co.ke/test_credentials) -static DEFAULT_PASSKEY: &str = "bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919"; +pub static DEFAULT_PASSKEY: &str = + "bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919"; + +const EXPRESS_REQUEST_URL: &str = "mpesa/stkpush/v1/processrequest"; #[derive(Debug, Serialize)] -struct MpesaExpressRequestPayload<'mpesa> { - #[serde(rename(serialize = "BusinessShortCode"))] - business_short_code: &'mpesa str, - #[serde(rename(serialize = "Password"))] - password: &'mpesa str, - #[serde(rename(serialize = "Timestamp"))] - timestamp: &'mpesa str, - #[serde(rename(serialize = "TransactionType"))] - transaction_type: CommandId, - #[serde(rename(serialize = "Amount"))] - amount: f64, - #[serde(rename(serialize = "PartyA"), skip_serializing_if = "Option::is_none")] - party_a: Option<&'mpesa str>, - #[serde(rename(serialize = "PartyB"), skip_serializing_if = "Option::is_none")] - party_b: Option<&'mpesa str>, - #[serde(rename(serialize = "PhoneNumber"))] - phone_number: &'mpesa str, - #[serde(rename(serialize = "CallBackURL"))] - call_back_url: &'mpesa str, - #[serde(rename(serialize = "AccountReference"))] - account_reference: &'mpesa str, - #[serde(rename(serialize = "TransactionDesc"))] - transaction_desc: &'mpesa str, +#[serde(rename_all = "PascalCase")] +pub struct MpesaExpressRequest<'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, + /// This is the transaction type that is used to identify the transaction + /// when sending the request to M-PESA + /// + /// The TransactionType for Mpesa Express is either + /// `CommandId::BusinessBuyGoods` or + /// `CommandId::CustomerPayBillOnline` + pub transaction_type: CommandId, + /// This is the Amount transacted normally a numeric value + pub amount: u32, + ///The phone number sending money. + pub party_a: &'mpesa str, + /// The organization that receives the funds + pub party_b: &'mpesa str, + /// The Mobile Number to receive the STK Pin Prompt. + /// This number can be the same as PartyA value above. + /// + /// The parameter expected is a Valid Safaricom Mobile Number that is + /// M-PESA registered in the format 2547XXXXXXXX + pub phone_number: &'mpesa str, + /// A CallBack URL is a valid secure URL that is used to receive + /// notifications from M-Pesa API. + /// It is the endpoint to which the results will be sent by M-Pesa API. + #[serde(rename = "CallBackURL")] + pub call_back_url: Url, + /// Account Reference: This is an Alpha-Numeric parameter that is defined + /// by your system as an Identifier of the transaction for + /// CustomerPayBillOnline + pub account_reference: &'mpesa str, + /// This is any additional information/comment that can be sent along with + /// the request from your system + pub transaction_desc: Option<&'mpesa str>, +} + +fn serialize_utc_to_string(date: &DateTime, serializer: S) -> Result +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)] -pub struct MpesaExpressRequestResponse { - #[serde(rename(deserialize = "CheckoutRequestID"))] +#[serde(rename_all = "PascalCase")] +pub struct MpesaExpressResponse { + ///This is a global unique identifier of the processed checkout transaction + /// request. + #[serde(rename = "CheckoutRequestID")] pub checkout_request_id: String, - #[serde(rename(deserialize = "CustomerMessage"))] + /// This is a message that your system can display to the customer as an + /// acknowledgment of the payment request submission. pub customer_message: String, - #[serde(rename(deserialize = "MerchantRequestID"))] + /// This is a global unique Identifier for any submitted payment request. + #[serde(rename = "MerchantRequestID")] pub merchant_request_id: String, - #[serde(rename(deserialize = "ResponseCode"))] + /// 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, - #[serde(rename(deserialize = "ResponseDescription"))] + ///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, } -pub struct MpesaExpressRequestBuilder<'mpesa> { - business_short_code: &'mpesa str, +#[derive(Builder, Debug, Clone)] +#[builder(build_fn(error = "MpesaError", validate = "Self::validate"))] +pub struct MpesaExpress<'mpesa> { + #[builder(pattern = "immutable")] client: &'mpesa Mpesa, - transaction_type: Option, - amount: Option, - party_a: Option<&'mpesa str>, - party_b: Option<&'mpesa str>, - phone_number: Option<&'mpesa str>, - callback_url: Option<&'mpesa str>, - account_ref: Option<&'mpesa str>, + /// 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 transaction type that is used to identify the transaction + /// when sending the request to M-PESA + /// + /// The TransactionType for Mpesa Express is either + /// `CommandId::BusinessBuyGoods` or + /// `CommandId::CustomerPayBillOnline` + transaction_type: CommandId, + /// This is the Amount transacted normally a numeric value + amount: u32, + /// The phone number sending money. + party_a: &'mpesa str, + /// The organization that receives the funds + party_b: &'mpesa str, + /// The Mobile Number to receive the STK Pin Prompt. + phone_number: &'mpesa str, + /// A CallBack URL is a valid secure URL that is used to receive + /// notifications from M-Pesa API. + /// It is the endpoint to which the results will be sent by M-Pesa API. + #[builder(try_setter, setter(into))] + callback_url: Url, + /// Account Reference: This is an Alpha-Numeric parameter that is defined + /// by your system as an Identifier of the transaction for + /// CustomerPayBillOnline + #[builder(setter(into))] + account_ref: &'mpesa str, + /// This is any additional information/comment that can be sent along with + /// the request from your system + #[builder(setter(into, strip_option), default)] transaction_desc: Option<&'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>, } -impl<'mpesa> MpesaExpressRequestBuilder<'mpesa> { - pub fn new( - client: &'mpesa Mpesa, - business_short_code: &'mpesa str, - ) -> MpesaExpressRequestBuilder<'mpesa> { - MpesaExpressRequestBuilder { - client, - business_short_code, - transaction_type: None, - transaction_desc: None, - amount: None, - party_a: None, - party_b: None, - phone_number: None, - callback_url: None, - account_ref: None, - pass_key: None, +impl<'mpesa> From> for MpesaExpressRequest<'mpesa> { + fn from(express: MpesaExpress<'mpesa>) -> MpesaExpressRequest<'mpesa> { + let timestamp = chrono::Local::now(); + + let encoded_password = + MpesaExpress::encode_password(express.business_short_code, express.pass_key); + + MpesaExpressRequest { + business_short_code: express.business_short_code, + password: encoded_password, + timestamp, + transaction_type: express.transaction_type, + amount: express.amount, + party_a: express.party_a, + party_b: express.party_b, + phone_number: express.phone_number, + call_back_url: express.callback_url, + account_reference: express.account_ref, + transaction_desc: express.transaction_desc, } } +} - /// Public method get the `business_short_code` - pub fn business_short_code(&'mpesa self) -> &'mpesa str { - self.business_short_code - } +impl MpesaExpressBuilder<'_> { + /// Validates the request, returning a `MpesaError` if validation fails + /// + /// Express requests can only be of type `BusinessBuyGoods` or + /// `CustomerPayBillOnline` + fn validate(&self) -> MpesaResult<()> { + if self.transaction_type != Some(CommandId::BusinessBuyGoods) + && self.transaction_type != Some(CommandId::CustomerPayBillOnline) + { + return Err(MpesaError::Message( + "Invalid transaction type. Expected BusinessBuyGoods or CustomerPayBillOnline", + )); + } - /// Retrieves the production passkey if present or defaults to the key provided in Safaricom's [test credentials](https://developer.safaricom.co.ke/test_credentials) - fn get_pass_key(&'mpesa self) -> &'mpesa str { - if let Some(key) = self.pass_key { - return key; + if let Some(phone_number) = self.phone_number { + phone_number.validate()?; } - DEFAULT_PASSKEY + + Ok(()) + } +} + +impl<'mpesa> MpesaExpress<'mpesa> { + /// Creates new `MpesaExpressBuilder` + pub(crate) fn builder(client: &'mpesa Mpesa) -> MpesaExpressBuilder<'mpesa> { + MpesaExpressBuilder::default().client(client) } - /// Utility method to generate base64 encoded password as per Safaricom's [specifications](https://developer.safaricom.co.ke/docs#lipa-na-m-pesa-online-payment) - /// Returns the encoded password and a timestamp string - fn generate_password_and_timestamp(&self) -> (String, String) { - let timestamp = Local::now().format("%Y%m%d%H%M%S").to_string(); - let encoded_password = base64::encode_block( + /// 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!( "{}{}{}", - self.business_short_code(), - self.get_pass_key(), + business_short_code, + pass_key.unwrap_or(DEFAULT_PASSKEY), timestamp ) .as_bytes(), - ); - (encoded_password, timestamp) + ) } - /// Your passkey. - /// Optional in sandbox, will default to key provided in Safaricom's [test credentials](https://developer.safaricom.co.ke/test_credentials) - /// Required in production - /// - /// # Errors - /// If thee `pass_key` is invalid - pub fn pass_key(mut self, pass_key: &'mpesa str) -> MpesaExpressRequestBuilder<'mpesa> { - self.pass_key = Some(pass_key); - self - } - - /// Adds an `amount` to the request - /// This is a required field - pub fn amount>( - mut self, - amount: Number, - ) -> MpesaExpressRequestBuilder<'mpesa> { - self.amount = Some(amount.into()); - self - } - - /// The MSISDN sending the funds - /// - /// # Errors - /// If `phone_number` is invalid - pub fn phone_number(mut self, phone_number: &'mpesa str) -> MpesaExpressRequestBuilder<'mpesa> { - self.phone_number = Some(phone_number); - self - } - - /// The url to where responses from M-Pesa will be sent to. - /// - /// # Errors - /// If the `callback_url` is invalid - pub fn callback_url(mut self, callback_url: &'mpesa str) -> MpesaExpressRequestBuilder<'mpesa> { - self.callback_url = Some(callback_url); - self - } - - /// The MSISDN sending the funds - /// - /// # Errors - /// If `party_a` is invalid - pub fn party_a(mut self, party_a: &'mpesa str) -> MpesaExpressRequestBuilder<'mpesa> { - self.party_a = Some(party_a); - self - } - - /// The organization shortcode receiving the funds - /// - /// # Errors - /// If `party_b` is invalid - pub fn party_b(mut self, party_b: &'mpesa str) -> MpesaExpressRequestBuilder<'mpesa> { - self.party_b = Some(party_b); - self - } - - /// Optional - Used with M-Pesa PayBills. - pub fn account_ref(mut self, account_ref: &'mpesa str) -> MpesaExpressRequestBuilder<'mpesa> { - self.account_ref = Some(account_ref); - self - } - - /// Optional, defaults to `CommandId::CustomerPayBillOnline` - /// - /// # Errors - /// If the `CommandId` is invalid - pub fn transaction_type(mut self, command_id: CommandId) -> MpesaExpressRequestBuilder<'mpesa> { - self.transaction_type = Some(command_id); - self - } - - /// A description of the transaction. - /// Optional - defaults to "None" - pub fn transaction_desc( - mut self, - description: &'mpesa str, - ) -> MpesaExpressRequestBuilder<'mpesa> { - self.transaction_desc = Some(description); - self + /// Creates a new `MpesaExpress` from a `MpesaExpressRequest` + pub fn from_request( + client: &'mpesa Mpesa, + request: MpesaExpressRequest<'mpesa>, + pass_key: Option<&'mpesa str>, + ) -> MpesaExpress<'mpesa> { + MpesaExpress { + client, + business_short_code: request.business_short_code, + transaction_type: request.transaction_type, + amount: request.amount, + party_a: request.party_a, + party_b: request.party_b, + phone_number: request.phone_number, + callback_url: request.call_back_url, + account_ref: request.account_reference, + transaction_desc: request.transaction_desc, + 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 sucessfult request returns a `MpesaExpressRequestResponse` type + /// A successful request returns a `MpesaExpressRequestResponse` type /// /// # Errors /// Returns a `MpesaError` on failure - pub async fn send(self) -> MpesaResult { - let (password, timestamp) = self.generate_password_and_timestamp(); - - let payload = MpesaExpressRequestPayload { - business_short_code: self.business_short_code, - password: &password, - timestamp: ×tamp, - amount: self - .amount - .ok_or(MpesaError::Message("amount is required"))?, - party_a: if self.party_a.is_some() { - self.party_a - } else { - self.phone_number - }, - party_b: if self.party_b.is_some() { - self.party_b - } else { - Some(self.business_short_code) - }, - phone_number: self - .phone_number - .ok_or(MpesaError::Message("phone_number is required"))?, - call_back_url: self - .callback_url - .ok_or(MpesaError::Message("callback_url is required"))?, - account_reference: self.account_ref.unwrap_or_else(|| stringify!(None)), - transaction_type: self - .transaction_type - .unwrap_or(CommandId::CustomerPayBillOnline), - transaction_desc: self.transaction_desc.unwrap_or_else(|| stringify!(None)), - }; - + pub async fn send(self) -> MpesaResult { self.client - .send(crate::client::Request { + .send::(crate::client::Request { method: reqwest::Method::POST, path: EXPRESS_REQUEST_URL, - body: payload, + body: self.into(), }) .await } diff --git a/src/services/mod.rs b/src/services/mod.rs index 210c27da3..5f2acc123 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -43,8 +43,13 @@ 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}; +pub use express_request::{ + MpesaExpress, MpesaExpressBuilder, MpesaExpressRequest, MpesaExpressResponse, +}; #[cfg(feature = "transaction_reversal")] -pub use transaction_reversal::{TransactionReversalBuilder, TransactionReversalResponse}; +pub use transaction_reversal::{ + TransactionReversal, TransactionReversalBuilder, TransactionReversalRequest, + TransactionReversalResponse, +}; #[cfg(feature = "transaction_status")] pub use transaction_status::{TransactionStatusBuilder, TransactionStatusResponse}; diff --git a/src/services/transaction_reversal.rs b/src/services/transaction_reversal.rs index f83b4f51a..945ef5391 100644 --- a/src/services/transaction_reversal.rs +++ b/src/services/transaction_reversal.rs @@ -1,156 +1,141 @@ #![doc = include_str!("../../docs/client/transaction_reversal.md")] +use derive_builder::Builder; use serde::{Deserialize, Serialize}; +use url::Url; use crate::{CommandId, IdentifierTypes, Mpesa, MpesaError, MpesaResult}; const TRANSACTION_REVERSAL_URL: &str = "mpesa/reversal/v1/request"; #[derive(Debug, Serialize)] -pub struct TransactionReversalPayload<'mpesa> { - #[serde(rename(serialize = "Initiator"))] - initiator: &'mpesa str, - #[serde(rename(serialize = "SecurityCredential"))] - security_credentials: &'mpesa str, - #[serde(rename(serialize = "CommandID"))] - command_id: CommandId, - #[serde(rename(serialize = "TransactionID"))] - transaction_id: &'mpesa str, - #[serde(rename(serialize = "ReceiverParty"))] - receiver_party: &'mpesa str, +#[serde(rename_all = "PascalCase")] +pub struct TransactionReversalRequest<'mpesa> { + /// The name of the initiator to initiate the request. + pub initiator: &'mpesa str, + /// Encrypted Credential of user getting transaction reversed. + pub security_credential: String, + /// Unique command for each transaction type. + #[serde(rename = "CommandID")] + pub command_id: CommandId, + /// This is the Mpesa Transaction ID of the transaction which you wish to + #[serde(rename = "TransactionID")] + pub transaction_id: &'mpesa str, + /// The organization that receives the transaction. + pub receiver_party: &'mpesa str, + /// Type of organization that receives the transaction. #[serde(rename(serialize = "RecieverIdentifierType"))] - receiver_identifier_type: IdentifierTypes, - #[serde(rename(serialize = "ResultURL"))] - result_url: &'mpesa str, - #[serde(rename(serialize = "QueueTimeOutURL"))] - timeout_url: &'mpesa str, - #[serde(rename(serialize = "Remarks"))] - remarks: &'mpesa str, - #[serde(rename(serialize = "Occasion"))] - occasion: &'mpesa str, - #[serde(rename(serialize = "Amount"))] - amount: f64, + pub receiver_identifier_type: IdentifierTypes, + /// The path that stores information about the transaction. + #[serde(rename = "ResultURL")] + pub result_url: Url, + /// The path that stores information about the time-out transaction. + #[serde(rename = "QueueTimeOutURL")] + pub queue_timeout_url: Url, + /// Comments that are sent along with the transaction. + pub remarks: &'mpesa str, + /// Comments that are sent along with the transaction. + pub occasion: Option<&'mpesa str>, + /// The amount transacted in the transaction is to be reversed, down to the + /// cent. + pub amount: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] pub struct TransactionReversalResponse { - #[serde(rename(deserialize = "ConversationID"))] + /// The unique request ID for tracking a transaction. + #[serde(rename = "ConversationID")] pub conversation_id: String, - #[serde(rename(deserialize = "OriginatorConversationID"))] + /// The unique request ID is returned by mpesa for each request made. + #[serde(rename = "OriginatorConversationID")] pub originator_conversation_id: String, - #[serde(rename(deserialize = "ResponseDescription"))] + /// Response Description message pub response_description: String, + /// Response Code + pub response_code: String, } -#[derive(Debug)] -pub struct TransactionReversalBuilder<'mpesa> { +#[derive(Builder, Debug)] +#[builder(build_fn(error = "MpesaError"))] +pub struct TransactionReversal<'mpesa> { + #[builder(pattern = "immutable")] client: &'mpesa Mpesa, + /// The name of the initiator to initiate the request. initiator: &'mpesa str, - command_id: Option, - transaction_id: Option<&'mpesa str>, - receiver_party: Option<&'mpesa str>, - receiver_identifier_type: Option, - result_url: Option<&'mpesa str>, - timeout_url: Option<&'mpesa str>, - remarks: Option<&'mpesa str>, + /// This is the Mpesa Transaction ID of the transaction which you wish to + /// reverse. + #[builder(setter(into))] + transaction_id: &'mpesa str, + /// The organization that receives the transaction. + #[builder(setter(into))] + receiver_party: &'mpesa str, + /// The path that stores information about the transaction. + #[builder(try_setter, setter(into))] + result_url: Url, + /// The path that stores information about the time-out transaction. + #[builder(try_setter, setter(into))] + timeout_url: Url, + /// Comments that are sent along with the transaction. + #[builder(setter(into))] + remarks: &'mpesa str, + /// Comments that are sent along with the transaction. + #[builder(setter(into, strip_option), default)] occasion: Option<&'mpesa str>, - amount: Option, + /// Type of organization that receives the transaction. + pub receiver_identifier_type: IdentifierTypes, + /// The amount transacted in the transaction is to be reversed, down to the + /// cent. + amount: u32, } -impl<'mpesa> TransactionReversalBuilder<'mpesa> { - /// Creates new `TransactionReversalBuilder` - pub fn new( - client: &'mpesa Mpesa, - initiator: &'mpesa str, - ) -> TransactionReversalBuilder<'mpesa> { - TransactionReversalBuilder { - client, - initiator, - command_id: None, - transaction_id: None, - receiver_party: None, - receiver_identifier_type: None, - result_url: None, - timeout_url: None, - remarks: None, - occasion: None, - amount: None, - } - } - - /// Adds `CommandId`. Defaults to `CommandId::TransactionReversal` if no value explicitly passed - /// - /// # Errors - /// If `CommandId` is not valid - pub fn command_id(mut self, command_id: CommandId) -> Self { - self.command_id = Some(command_id); - self - } - - /// Add the Mpesa Transaction ID of the transaction which you wish to reverse - /// - /// This is a required field. - pub fn transaction_id(mut self, transaction_id: &'mpesa str) -> Self { - self.transaction_id = Some(transaction_id); - self - } - - /// Organization receiving the transaction - /// - /// This is required field - pub fn receiver_party(mut self, receiver_party: &'mpesa str) -> Self { - self.receiver_party = Some(receiver_party); - self - } - - /// Type of organization receiving the transaction - /// - /// This is an optional field, will default to `IdentifierTypes::ShortCode` - pub fn receiver_identifier_type(mut self, receiver_identifier_type: IdentifierTypes) -> Self { - self.receiver_identifier_type = Some(receiver_identifier_type); - self - } - - // Adds `ResultUrl` This is a required field - /// - /// # Error - /// If `ResultUrl` is invalid or not provided - pub fn result_url(mut self, result_url: &'mpesa str) -> Self { - self.result_url = Some(result_url); - self - } - - /// Adds `QueueTimeoutUrl` and `ResultUrl`. This is a required field - /// - /// # Error - /// If either `QueueTimeoutUrl` and `ResultUrl` is invalid or not provided - pub fn timeout_url(mut self, timeout_url: &'mpesa str) -> Self { - self.timeout_url = Some(timeout_url); - self - } - - /// Comments that are sent along with the transaction. - /// - /// This is an optiona field; defaults to "None" - pub fn remarks(mut self, remarks: &'mpesa str) -> Self { - self.remarks = Some(remarks); - self +impl<'mpesa> TryFrom> for TransactionReversalRequest<'mpesa> { + type Error = MpesaError; + + fn try_from( + value: TransactionReversal<'mpesa>, + ) -> Result, Self::Error> { + let credentials = value.client.gen_security_credentials()?; + + Ok(TransactionReversalRequest { + initiator: value.initiator, + security_credential: credentials, + command_id: CommandId::TransactionReversal, + transaction_id: value.transaction_id, + receiver_party: value.receiver_party, + receiver_identifier_type: value.receiver_identifier_type, + result_url: value.result_url, + queue_timeout_url: value.timeout_url, + remarks: value.remarks, + occasion: value.occasion, + amount: value.amount, + }) } +} - /// Adds any additional information to be associated with the transaction. - /// - /// This is an optional Parameter, defaults to "None" - pub fn occasion(mut self, occasion: &'mpesa str) -> Self { - self.occasion = Some(occasion); - self +impl<'mpesa> TransactionReversal<'mpesa> { + /// Creates new `TransactionReversalBuilder` + pub(crate) fn builder(client: &'mpesa Mpesa) -> TransactionReversalBuilder<'mpesa> { + TransactionReversalBuilder::default().client(client) } - /// Adds an `amount` to the request - /// - /// This is a required field - pub fn amount>(mut self, amount: Number) -> Self { - self.amount = Some(amount.into()); - self + /// Creates a new `TransactionReversal` from a `TransactionReversalRequest` + pub fn from_request( + client: &'mpesa Mpesa, + request: TransactionReversalRequest<'mpesa>, + ) -> TransactionReversal<'mpesa> { + TransactionReversal { + client, + initiator: request.initiator, + transaction_id: request.transaction_id, + receiver_party: request.receiver_party, + result_url: request.result_url, + timeout_url: request.queue_timeout_url, + remarks: request.remarks, + occasion: request.occasion, + amount: request.amount, + receiver_identifier_type: request.receiver_identifier_type, + } } /// # Transaction Reversal API @@ -173,39 +158,11 @@ impl<'mpesa> TransactionReversalBuilder<'mpesa> { /// # Errors /// Returns a `MpesaError` on failure. pub async fn send(self) -> MpesaResult { - let credentials = self.client.gen_security_credentials()?; - - let payload = TransactionReversalPayload { - initiator: self.initiator, - security_credentials: &credentials, - command_id: self.command_id.unwrap_or(CommandId::TransactionReversal), - transaction_id: self - .transaction_id - .ok_or(MpesaError::Message("transaction_id is required"))?, - receiver_party: self - .receiver_party - .ok_or(MpesaError::Message("receiver_party is required"))?, - receiver_identifier_type: self - .receiver_identifier_type - .unwrap_or(IdentifierTypes::Reversal), - result_url: self - .result_url - .ok_or(MpesaError::Message("result_url is required"))?, - timeout_url: self - .timeout_url - .ok_or(MpesaError::Message("timeout_url is required"))?, - remarks: self.remarks.unwrap_or(stringify!(None)), - occasion: self.occasion.unwrap_or(stringify!(None)), - amount: self - .amount - .ok_or(MpesaError::Message("amount is required"))?, - }; - self.client - .send(crate::client::Request { + .send::(crate::client::Request { method: reqwest::Method::POST, path: TRANSACTION_REVERSAL_URL, - body: payload, + body: self.try_into()?, }) .await } diff --git a/src/validator.rs b/src/validator.rs new file mode 100644 index 000000000..979c627f2 --- /dev/null +++ b/src/validator.rs @@ -0,0 +1,104 @@ +use regex::Regex; + +use crate::{MpesaError, MpesaResult}; + +pub trait PhoneNumberValidator { + fn validate(&self) -> MpesaResult<()>; +} + +impl PhoneNumberValidator for &str { + fn validate(&self) -> MpesaResult<()> { + let phone_regex = + Regex::new(r"^(254\d{9}|07\d{8}|011\d{7}|7\d{8}|1\d{8})$").map_err(|_| { + MpesaError::Message( + "Invalid phone number, must be in the format 2547XXXXXXXX, 07XXXXXXXX, 011XXXXXXX", + ) + })?; + + if phone_regex.is_match(self) { + Ok(()) + } else { + Err(MpesaError::Message( + "Invalid phone number, must be in the format 2547XXXXXXXX, 07XXXXXXXX, 011XXXXXXX", + )) + } + } +} + +impl PhoneNumberValidator for String { + fn validate(&self) -> MpesaResult<()> { + self.as_str().validate() + } +} + +impl PhoneNumberValidator for u64 { + fn validate(&self) -> MpesaResult<()> { + self.to_string().validate() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_phone() { + assert!("254712345678".validate().is_ok()); + assert!("254012345678".validate().is_ok()); + assert!("0712345678".validate().is_ok()); + assert!("712345678".validate().is_ok()); + assert!("112345678".validate().is_ok()); + assert!("0112345678".validate().is_ok()); + assert!("07987654321".validate().is_err()); + assert!("011987654321".validate().is_err()); + assert!("254712345678900".validate().is_err()); + assert!("25471234567".validate().is_err()); + assert!("2547".validate().is_err()); + assert!("2547a".validate().is_err()); + assert!("254".validate().is_err()); + assert!("254a".validate().is_err()); + assert!("25".validate().is_err()); + assert!("25a".validate().is_err()); + assert!("2".validate().is_err()); + assert!("2a".validate().is_err()); + assert!("".validate().is_err()); + assert!("a".validate().is_err()); + } + + #[test] + fn test_validate_phone_string() { + assert!("254712345678".to_string().validate().is_ok()); + assert!("254012345678".to_string().validate().is_ok()); + assert!("254712345678900".to_string().validate().is_err()); + assert!("25471234567".to_string().validate().is_err()); + assert!("2547".to_string().validate().is_err()); + assert!("2547a".to_string().validate().is_err()); + assert!("254".to_string().validate().is_err()); + assert!("254a".to_string().validate().is_err()); + assert!("25".to_string().validate().is_err()); + assert!("25a".to_string().validate().is_err()); + assert!("2".to_string().validate().is_err()); + assert!("2a".to_string().validate().is_err()); + assert!("".to_string().validate().is_err()); + assert!("a".to_string().validate().is_err()); + } + + #[test] + fn test_validate_phone_u64() { + assert!(254712345678u64.validate().is_ok()); + assert!(254012345678u64.validate().is_ok()); + assert!(712345678u64.validate().is_ok()); + assert!(112345678u64.validate().is_ok()); + assert!(254712345678900u64.validate().is_err()); + assert!(25471234567u64.validate().is_err()); + assert!(2547u64.validate().is_err()); + assert!(2547u64.validate().is_err()); + assert!(254u64.validate().is_err()); + assert!(254u64.validate().is_err()); + assert!(25u64.validate().is_err()); + assert!(25u64.validate().is_err()); + assert!(2u64.validate().is_err()); + assert!(2u64.validate().is_err()); + assert!(0u64.validate().is_err()); + } +} diff --git a/tests/mpesa-rust/helpers.rs b/tests/mpesa-rust/helpers.rs index 8626b03f3..afecc8ec2 100644 --- a/tests/mpesa-rust/helpers.rs +++ b/tests/mpesa-rust/helpers.rs @@ -33,12 +33,12 @@ macro_rules! get_mpesa_client { use serde_json::json; use wiremock::matchers::{path, query_param, method}; - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let server = MockServer::start().await; let test_environment = TestEnvironment::new(&server).await; let client = Mpesa::new( - std::env::var("CLIENT_KEY").unwrap(), - std::env::var("CLIENT_SECRET").unwrap(), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), test_environment, ); Mock::given(method("GET")) @@ -60,12 +60,12 @@ macro_rules! get_mpesa_client { use serde_json::json; use wiremock::matchers::{path, query_param, method}; - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let server = MockServer::start().await; let test_environment = TestEnvironment::new(&server).await; let client = Mpesa::new( - std::env::var("CLIENT_KEY").unwrap(), - std::env::var("CLIENT_SECRET").unwrap(), + dotenvy::var("CLIENT_KEY").unwrap(), + dotenvy::var("CLIENT_SECRET").unwrap(), test_environment, ); Mock::given(method("GET")) @@ -83,7 +83,7 @@ macro_rules! get_mpesa_client { ($client_key:expr, $client_secret:expr) => {{ use mpesa::{Environment, Mpesa}; use std::str::FromStr; - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new( $client_key, $client_secret, @@ -95,7 +95,7 @@ macro_rules! get_mpesa_client { ($client_key:expr, $client_secret:expr, $environment:expr) => {{ use mpesa::{Environment, Mpesa}; use std::str::FromStr; - dotenv::dotenv().ok(); + dotenvy::dotenv().ok(); let client = Mpesa::new($client_key, $client_secret, $environment); client }}; diff --git a/tests/mpesa-rust/stk_push_test.rs b/tests/mpesa-rust/stk_push_test.rs index dec5815da..71760e7d1 100644 --- a/tests/mpesa-rust/stk_push_test.rs +++ b/tests/mpesa-rust/stk_push_test.rs @@ -1,4 +1,5 @@ -use mpesa::MpesaError; +use mpesa::services::{MpesaExpress, MpesaExpressRequest}; +use mpesa::CommandId; use serde_json::json; use wiremock::matchers::{method, path}; use wiremock::{Mock, ResponseTemplate}; @@ -6,14 +7,14 @@ use wiremock::{Mock, ResponseTemplate}; use crate::get_mpesa_client; #[tokio::test] -async fn stk_push_success_success() { +async fn stk_push_success() { let (client, server) = get_mpesa_client!(); let sample_response_body = json!({ "MerchantRequestID": "16813-1590513-1", "CheckoutRequestID": "ws_CO_DMZ_12321_23423476", "ResponseDescription": "Accept the service request successfully.", "ResponseCode": "0", - "CustomerMessage": "Success. Request accepeted for processing" + "CustomerMessage": "Success. Request accepted for processing" }); Mock::given(method("POST")) .and(path("/mpesa/stkpush/v1/processrequest")) @@ -22,13 +23,23 @@ async fn stk_push_success_success() { .mount(&server) .await; let response = client - .express_request("174379") + .express_request() + .business_short_code("174379") + .transaction_type(mpesa::CommandId::BusinessBuyGoods) + .party_a("254708374149") + .party_b("174379") + .account_ref("test") .phone_number("254708374149") .amount(500) - .callback_url("https://test.example.com/api") + .pass_key("test") + .try_callback_url("https://test.example.com/api") + .unwrap() + .build() + .unwrap() .send() .await .unwrap(); + assert_eq!(response.merchant_request_id, "16813-1590513-1"); assert_eq!(response.checkout_request_id, "ws_CO_DMZ_12321_23423476"); assert_eq!( @@ -37,19 +48,19 @@ async fn stk_push_success_success() { ); assert_eq!( response.customer_message, - "Success. Request accepeted for processing" + "Success. Request accepted for processing" ); } #[tokio::test] -async fn stk_push_fails_if_no_amount_is_provided() { +async fn stk_push_only_accepts_specific_tx_type() { let (client, server) = get_mpesa_client!(expected_auth_requests = 0); let sample_response_body = json!({ "MerchantRequestID": "16813-1590513-1", "CheckoutRequestID": "ws_CO_DMZ_12321_23423476", "ResponseDescription": "Accept the service request successfully.", "ResponseCode": "0", - "CustomerMessage": "Success. Request accepeted for processing" + "CustomerMessage": "Success. Request accepted for processing" }); Mock::given(method("POST")) .and(path("/mpesa/stkpush/v1/processrequest")) @@ -57,82 +68,74 @@ async fn stk_push_fails_if_no_amount_is_provided() { .expect(0) .mount(&server) .await; - if let Err(e) = client - .express_request("174379") - .phone_number("254708374149") - .callback_url("https://test.example.com/api") - .send() - .await - { - let MpesaError::Message(msg) = e else { - panic!("Expected MpesaError::Message, but found {}", e); - }; - assert_eq!(msg, "amount is required") - } else { - panic!("Expected error"); - } -} - -#[tokio::test] -async fn stk_push_fails_if_no_callback_url_is_provided() { - let (client, server) = get_mpesa_client!(expected_auth_requests = 0); - let sample_response_body = json!({ - "MerchantRequestID": "16813-1590513-1", - "CheckoutRequestID": "ws_CO_DMZ_12321_23423476", - "ResponseDescription": "Accept the service request successfully.", - "ResponseCode": "0", - "CustomerMessage": "Success. Request accepeted for processing" - }); - Mock::given(method("POST")) - .and(path("/mpesa/stkpush/v1/processrequest")) - .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) - .expect(0) - .mount(&server) - .await; - if let Err(e) = client - .express_request("174379") - .phone_number("254708374149") + let err = client + .express_request() + .business_short_code("174379") + .transaction_type(mpesa::CommandId::SalaryPayment) + .party_a("254704837414") + .party_b("174379") + .account_ref("test") + .phone_number("254708437414") .amount(500) - .send() - .await - { - let MpesaError::Message(msg) = e else { - panic!("Expected MpesaError::Message, but found {}", e); - }; - assert_eq!(msg, "callback_url is required") - } else { - panic!("Expected error"); - } + .pass_key("test") + .try_callback_url("https://test.example.com/api") + .unwrap() + .build() + .unwrap_err(); + + assert_eq!( + err.to_string(), + "Invalid transaction type. Expected BusinessBuyGoods or CustomerPayBillOnline" + ); } #[tokio::test] -async fn stk_push_fails_if_no_phone_number_is_provided() { - let (client, server) = get_mpesa_client!(expected_auth_requests = 0); +async fn express_request_test_using_struct_initialization() { + let (client, server) = get_mpesa_client!(); + let sample_response_body = json!({ "MerchantRequestID": "16813-1590513-1", "CheckoutRequestID": "ws_CO_DMZ_12321_23423476", "ResponseDescription": "Accept the service request successfully.", "ResponseCode": "0", - "CustomerMessage": "Success. Request accepeted for processing" + "CustomerMessage": "Success. Request accepted for processing" }); + + let password = MpesaExpress::encode_password("174379", None); + + let request = MpesaExpressRequest { + business_short_code: "174379", + transaction_type: CommandId::BusinessBuyGoods, + amount: 500, + party_a: "254708374149", + party_b: "174379", + phone_number: "254708374149", + password, + timestamp: chrono::Local::now(), + call_back_url: "https://test.example.com/api".try_into().unwrap(), + account_reference: "test", + transaction_desc: None, + }; + Mock::given(method("POST")) .and(path("/mpesa/stkpush/v1/processrequest")) .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) - .expect(0) + .expect(1) .mount(&server) .await; - if let Err(e) = client - .express_request("174379") - .amount(500) - .callback_url("https://test.example.com/api") - .send() - .await - { - let MpesaError::Message(msg) = e else { - panic!("Expected MpesaError::Message, but found {}", e); - }; - assert_eq!(msg, "phone_number is required") - } else { - panic!("Expected error"); - } + + let request = MpesaExpress::from_request(&client, request, None); + + let response = request.send().await.unwrap(); + + assert_eq!(response.merchant_request_id, "16813-1590513-1"); + assert_eq!(response.checkout_request_id, "ws_CO_DMZ_12321_23423476"); + assert_eq!( + response.response_description, + "Accept the service request successfully." + ); + assert_eq!( + response.customer_message, + "Success. Request accepted for processing" + ); } diff --git a/tests/mpesa-rust/transaction_reversal_test.rs b/tests/mpesa-rust/transaction_reversal_test.rs index 017ac4583..4d337b019 100644 --- a/tests/mpesa-rust/transaction_reversal_test.rs +++ b/tests/mpesa-rust/transaction_reversal_test.rs @@ -1,4 +1,5 @@ -use mpesa::MpesaError; +use mpesa::services::{TransactionReversal, TransactionReversalRequest}; +use mpesa::IdentifierTypes; use serde_json::json; use wiremock::matchers::{method, path}; use wiremock::{Mock, ResponseTemplate}; @@ -6,12 +7,14 @@ use wiremock::{Mock, ResponseTemplate}; use crate::get_mpesa_client; #[tokio::test] + async fn transaction_reversal_success() { let (client, server) = get_mpesa_client!(); let sample_response_body = json!({ "OriginatorConversationID": "29464-48063588-1", "ConversationID": "AG_20230206_201056794190723278ff", "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0" }); Mock::given(method("POST")) .and(path("/mpesa/reversal/v1/request")) @@ -20,16 +23,23 @@ async fn transaction_reversal_success() { .mount(&server) .await; let response = client - .transaction_reversal("testapi496") - .result_url("https://testdomain.com/ok") - .timeout_url("https://testdomain.com/err") + .transaction_reversal() + .initiator("testapi496") + .try_result_url("https://testdomain.com/ok") + .unwrap() + .try_timeout_url("https://testdomain.com/err") + .unwrap() .transaction_id("OEI2AK4Q16") - .amount(1.0) + .amount(100) .receiver_party("600111") .remarks("wrong recipient") + .receiver_identifier_type(IdentifierTypes::Reversal) + .build() + .unwrap() .send() .await .unwrap(); + assert_eq!(response.originator_conversation_id, "29464-48063588-1"); assert_eq!(response.conversation_id, "AG_20230206_201056794190723278ff"); assert_eq!( @@ -39,161 +49,44 @@ async fn transaction_reversal_success() { } #[tokio::test] -async fn transaction_reversal_fails_if_no_transaction_id_is_provided() { - let (client, server) = get_mpesa_client!(expected_auth_requests = 0); - let sample_response_body = json!({ - "OriginatorConversationID": "29464-48063588-1", - "ConversationID": "AG_20230206_201056794190723278ff", - "ResponseDescription": "Accept the service request successfully.", - }); - Mock::given(method("POST")) - .and(path("/mpesa/reversal/v1/request")) - .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) - .expect(0) - .mount(&server) - .await; - if let Err(e) = client - .transaction_reversal("testapi496") - .result_url("https://testdomain.com/ok") - .timeout_url("https://testdomain.com/err") - .amount(1.0) - .receiver_party("600111") - .send() - .await - { - let MpesaError::Message(msg) = e else { - panic!("Expected MpesaError::Message, but found {}", e); - }; - assert_eq!(msg, "transaction_id is required"); - } else { - panic!("Expected error"); - } -} - -#[tokio::test] -async fn transaction_reversal_fails_if_no_amount_is_provided() { - let (client, server) = get_mpesa_client!(expected_auth_requests = 0); +async fn transaction_reversal_test_using_struct_initialization() { + let (client, server) = get_mpesa_client!(); let sample_response_body = json!({ "OriginatorConversationID": "29464-48063588-1", "ConversationID": "AG_20230206_201056794190723278ff", "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0" }); Mock::given(method("POST")) .and(path("/mpesa/reversal/v1/request")) .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) - .expect(0) + .expect(1) .mount(&server) .await; - if let Err(e) = client - .transaction_reversal("testapi496") - .transaction_id("OEI2AK4Q16") - .result_url("https://testdomain.com/ok") - .timeout_url("https://testdomain.com/err") - .receiver_party("600111") - .send() - .await - { - let MpesaError::Message(msg) = e else { - panic!("Expected MpesaError::Message, but found {}", e); - }; - assert_eq!(msg, "amount is required") - } else { - panic!("Expected error"); - } -} -#[tokio::test] -async fn transaction_reversal_fails_if_no_result_url_is_provided() { - let (client, server) = get_mpesa_client!(expected_auth_requests = 0); - let sample_response_body = json!({ - "OriginatorConversationID": "29464-48063588-1", - "ConversationID": "AG_20230206_201056794190723278ff", - "ResponseDescription": "Accept the service request successfully.", - }); - Mock::given(method("POST")) - .and(path("/mpesa/reversal/v1/request")) - .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) - .expect(0) - .mount(&server) - .await; - if let Err(e) = client - .transaction_reversal("testapi496") - .transaction_id("OEI2AK4Q16") - .amount(1.0) - .result_url("https://testdomain.com/ok") - .receiver_party("600111") - .send() - .await - { - let MpesaError::Message(msg) = e else { - panic!("Expected MpesaError::Message, but found {}", e); - }; - assert_eq!(msg, "timeout_url is required") - } else { - panic!("Expected error"); - } -} + let payload = TransactionReversalRequest { + initiator: "testapi496", + security_credential: "testapi496".to_string(), + command_id: mpesa::CommandId::TransactionReversal, + transaction_id: "OEI2AK4Q16", + receiver_party: "600111", + receiver_identifier_type: IdentifierTypes::ShortCode, + result_url: "https://testdomain.com/ok".parse().unwrap(), + queue_timeout_url: "https://testdomain.com/err".parse().unwrap(), + remarks: "wrong recipient", + occasion: None, + amount: 100, + }; -#[tokio::test] -async fn transaction_reversal_fails_if_no_timeout_url_is_provided() { - let (client, server) = get_mpesa_client!(expected_auth_requests = 0); - let sample_response_body = json!({ - "OriginatorConversationID": "29464-48063588-1", - "ConversationID": "AG_20230206_201056794190723278ff", - "ResponseDescription": "Accept the service request successfully.", - }); - Mock::given(method("POST")) - .and(path("/mpesa/reversal/v1/request")) - .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) - .expect(0) - .mount(&server) - .await; - if let Err(e) = client - .transaction_reversal("testapi496") - .transaction_id("OEI2AK4Q16") - .amount(1.0) - .timeout_url("https://testdomain.com/err") - .receiver_party("600111") + let response = TransactionReversal::from_request(&client, payload) .send() .await - { - let MpesaError::Message(msg) = e else { - panic!("Expected MpesaError::Message, but found {}", e); - }; - assert_eq!(msg, "result_url is required") - } else { - panic!("Expected error"); - } -} + .unwrap(); -#[tokio::test] -async fn transaction_reversal_fails_if_no_receiver_party_is_provided() { - let (client, server) = get_mpesa_client!(expected_auth_requests = 0); - let sample_response_body = json!({ - "OriginatorConversationID": "29464-48063588-1", - "ConversationID": "AG_20230206_201056794190723278ff", - "ResponseDescription": "Accept the service request successfully.", - }); - Mock::given(method("POST")) - .and(path("/mpesa/reversal/v1/request")) - .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) - .expect(0) - .mount(&server) - .await; - if let Err(e) = client - .transaction_reversal("testapi496") - .transaction_id("OEI2AK4Q16") - .amount(1.0) - .result_url("https://testdomain.com/ok") - .timeout_url("https://testdomain.com/err") - .send() - .await - { - let MpesaError::Message(msg) = e else { - panic!("Expected MpesaError::Message, but found {}", e); - }; - assert_eq!(msg, "receiver_party is required") - } else { - panic!("Expected error"); - } + assert_eq!(response.originator_conversation_id, "29464-48063588-1"); + assert_eq!(response.conversation_id, "AG_20230206_201056794190723278ff"); + assert_eq!( + response.response_description, + "Accept the service request successfully." + ); }