diff --git a/.cargo/config.toml b/.cargo/config.toml index 7a8d7abf6..d26687b8e 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,10 +1,5 @@ [build] -rustdoc = "rustdoc" - -[profile.release] -lto = "fat" -debug = 1 -codegen-units = 1 +rustdoc = "rustdoc" [alias] -t = ["test", "--all-features", "--no-fail-fast"] \ No newline at end of file +t = ["test", "--all-features", "--no-fail-fast"] diff --git a/mpesa_core/.env.example b/.env.example similarity index 100% rename from mpesa_core/.env.example rename to .env.example diff --git a/.github/workflows/audit-cron.yml b/.github/workflows/audit-cron.yml index 5e31924c8..eb81669e7 100644 --- a/.github/workflows/audit-cron.yml +++ b/.github/workflows/audit-cron.yml @@ -10,4 +10,5 @@ jobs: - uses: actions-rs/audit-check@v1 with: token: ${{ secrets.GITHUB_TOKEN }} + args: --ignore RUSTSEC-2020-0159 RUSTSEC-2020-0071 RUSTSEC-2021-0141 continue-on-error: true diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 67b5edeb5..adf6e072c 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -12,4 +12,5 @@ jobs: - uses: actions-rs/audit-check@v1 with: token: ${{ secrets.GITHUB_TOKEN }} + args: --ignore RUSTSEC-2020-0159 RUSTSEC-2020-0071 RUSTSEC-2021-0141 continue-on-error: true diff --git a/.github/workflows/general.yml b/.github/workflows/general.yml index 907658317..500e1fdca 100644 --- a/.github/workflows/general.yml +++ b/.github/workflows/general.yml @@ -74,3 +74,4 @@ jobs: uses: actions-rs/tarpaulin@v0.1 with: args: '--ignore-tests --no-fail-fast' + version: '0.22.0' diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index f51fb163b..01ca67a4b 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -3,13 +3,13 @@ name: publish to crates.io on: push: tags: - - 'core-0.*' + - 'v*' env: CARGO_TERM_COLOR: always jobs: - release_mpesa_core: + publish_to_crate_io: runs-on: ubuntu-latest @@ -19,9 +19,28 @@ jobs: with: toolchain: stable override: true - - name: Publish + - name: Publish to crates.io run: | - cd mpesa_core cargo doc cargo login ${{ secrets.CARGO_REGISTRY_TOKEN }} cargo publish + + sync_docs_to_gh_pages: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v4 + with: + python-version: '3.10' + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - name: Sync Docs + run: | + cargo doc + pip install ghp-import + echo '' > target/doc/index.html + ghp-import -n target/doc + git push -qf https://github.com/collinsmuriuki/mpesa-rust.git gh-pages diff --git a/.github/workflows/release-derive.yml b/.github/workflows/release-derive.yml deleted file mode 100644 index f812091ad..000000000 --- a/.github/workflows/release-derive.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: publish to crates.io - -on: - push: - tags: - - 'derive-0.*' - -env: - CARGO_TERM_COLOR: always - -jobs: - release_mpesa_core: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true - - name: Publish - run: | - cd mpesa_derive - cargo doc - cargo login ${{ secrets.CARGO_REGISTRY_TOKEN }} - cargo publish diff --git a/.gitignore b/.gitignore index 5c8ba229d..24a2d2013 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,4 @@ /.idea Cargo.lock .env -.DS_Store -main.rs \ No newline at end of file +.DS_Store \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..4ce278dc4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +fail_fast: true +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: check-toml + + - repo: local + hooks: + - id: run-cargo-fmt + name: Cargo fmt + entry: /bin/bash -c "cargo fmt --all -- --check" + language: script + files: ^.*\.rs$ + always_run: true + - id: run-cargo-clippy + name: Cargo clippy + entry: /bin/bash -c "cargo clippy -- -D warnings" + language: script + files: ^.*\.rs$ + always_run: true + - id: run-cargo-test + name: Cargo test + entry: /bin/bash -c "cargo test --no-fail-fast" + language: script + files: ^.*\.rs$ + always_run: true diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..a03a9d2e6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "cSpell.words": [ + "Mpesa" + ] +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57afdbd73..4107510fc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,56 +1,5 @@ -# Contributing guide +# How to Contribute -> Instructions for local project setup and contribution. - -## Rust - -You will need Rust installed to run this project. You can find a guide to install on any os [here](https://www.rust-lang.org/tools/install) - -## Install - -```sh -git clone https://github.com/collinsmuriuki/mpesa-rust.git -cd mpesa-rust -``` - -## App Keys -Copy your safaricom credentials to an `.env` file in the root of the project, check `.env.example` for reference or simply run. - -See [here](https://developer.safaricom.co.ke/docs#developer-sign-up) if you need to acquire app keys. -```sh -echo CLIENT_KEY="" >> .env -echo CLIENT_SECRET="" >> .env -``` - -## Test Credentials -You can get test credentials [here](https://developer.safaricom.co.ke/test_credentials) - -## Run tests - -```sh -cargo test -``` - -## RoadMap - -- [x] Create Mpesa Client struct -- [x] Implement Auth -- [x] Error handling -- [x] Generate security credentials -- [x] Implement B2C payment -- [x] Implement B2B payment -- [x] Query transaction status -- [x] Simulate C2B Payment -- [ ] Query status of Lipa na M-Pesa -- [x] Initiate Lipa na M-Pesa online w/ STK push -- [x] Register C2B Confirmation and Validation URLs -- [x] Integration tests -- [ ] Rewrite in async -- [x] Publish on https://crates.io -- [x] Setup travis-ci -- [ ] Improve documentation - - -## Pull Requests - -Fork the repo and create a feature branch. Push your changes and make a PR. \ No newline at end of file +- Create an issue or pick an already existing issue. Please avoid creating duplicate issues +- Fork the repo, add your changes and make a pull request +- Request for review from either me or other contributors \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 16797cd5b..4986fbe8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,2 +1,56 @@ -[workspace] -members = ["mpesa_derive", "mpesa_core"] \ No newline at end of file +[package] +name = "mpesa" +version = "1.1.0" +authors = ["Collins Muriuki "] +edition = "2021" +description = "A wrapper around the M-PESA API in Rust." +keywords = ["api", "mpesa", "mobile"] +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", + "b2b", + "b2c", + "bill_manager", + "c2b_register", + "c2b_simulate", + "express_request", + "transaction_reversal", + "transaction_status", + "dynamic_qr" +] +dynamic_qr = [] +account_balance = ["dep:openssl"] +b2b = ["dep:openssl"] +b2c = ["dep:openssl"] +bill_manager = ["dep:chrono"] +c2b_register = [] +c2b_simulate = [] +express_request = ["dep:chrono"] +transaction_reversal = ["dep:openssl"] +transaction_status = ["dep:openssl"] diff --git a/README.md b/README.md index d6bea7953..1e5cb9c61 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,8 @@ # mpesa-rust -

- - Version - - - Documentation - - - mpesa travis-ci - - - License: MIT - -

+[![Rust](https://github.com/collinsmuriuki/mpesa-rust/actions/workflows/general.yml/badge.svg)](https://github.com/collinsmuriuki/mpesa-rust/actions/workflows/general.yml) +[![](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +[![](https://img.shields.io/crates/v/mpesa)](https://crates.io/crates/mpesa) ## About @@ -25,20 +14,32 @@ An unofficial Rust wrapper around the [Safaricom API](https://developer.safarico ```toml [dependencies] -mpesa = "0.4.2" +mpesa = { version = "1.1.0" } ``` -Optionally, you can disable default-features, which is basically the entire suite of MPESA APIs to conditionally select from either `["b2b", "b2c" ,"account_balance", "c2b_register", "c2b_simulate", "express_request"]` services. +Optionally, you can disable default-features, which is basically the entire suite of MPESA APIs to conditionally select from either: + +- `b2b` +- `b2c` +- `account_balance` +- `c2b_register` +- `c2b_simulate` +- `express_request` +- `transaction_reversal` +- `transaction_status` +- `bill_manager` +- `dynamic_qr` + Example: ```toml [dependencies] -mpesa = { version = "0.4.2", default_features = false, features = ["b2b", "express_request"] } +mpesa = { git = "1.1.0", default_features = false, features = ["b2b", "express_request"] } ``` In your lib or binary crate: -```rs +```rust,ignore use mpesa::Mpesa; ``` @@ -51,65 +52,109 @@ You will first need to create an instance of the `Mpesa` instance (the client). environment. It's worth noting that these credentials are only valid in the sandbox environment. To go live and get production keys read the docs [here](https://developer.safaricom.co.ke/docs?javascript#going-live). -_NOTE_: - -- Only calling `unwrap` for demonstration purposes. Errors are handled appropriately in the lib via the `MpesaError` enum. - These are the following ways you can instantiate `Mpesa`: -```rust +```rust,ignore use mpesa::{Mpesa, Environment}; -use std::env; let client = Mpesa::new( - env::var("CLIENT_KEY")?, - env::var("CLIENT_SECRET")?, + env!("CLIENT_KEY"), + env!("CLIENT_SECRET"), Environment::Sandbox, ); -assert!(client.is_connected()) +assert!(client.is_connected().await) ``` -Since the `Environment` enum implements `FromStr` and `TryFrom`, you can pass the name of the environment as a `&str` and call the `parse()` or `try_into()` -method to create an `Environment` type from the string slice (Pascal case and Uppercase string slices also valid): +Since the `Environment` enum implements `FromStr` and `TryFrom` for `String` and `&str` types, you can call `Environment::from_str` or `Environment::try_from` to create an `Environment` type. This is ideal if the environment values are +stored in a `.env` or any other configuration file: + +```rust,ignore +use mpesa::{Mpesa, Environment}; +use std::str::FromStr; +use std::convert::TryFrom; + +let client0 = Mpesa::new( + env!("CLIENT_KEY"), + env!("CLIENT_SECRET"), + Environment::from_str("sandbox")? // "Sandbox" and "SANDBOX" also valid +); +assert!(client0.is_connected().await) + +let client1 = Mpesa::new( + env!("CLIENT_KEY"), + env!("CLIENT_SECRET"), + Environment::try_from("production")? // "Production" and "PRODUCTION" also valid +); +assert!(client1.is_connected().await) +``` + +The `Mpesa` struct's `environment` parameter is generic over any type that implements the `ApiEnvironment` trait. This trait +expects the following methods to be implemented for a given type: ```rust -use mpesa::Mpesa; -use std::env; +pub trait ApiEnvironment { + fn base_url(&self) -> &str; + fn get_certificate(&self) -> &str; +} +``` -let client = Mpesa::new( - env::var("CLIENT_KEY")?, - env::var("CLIENT_SECRET")?, - "sandbox".parse()?, // "production" +This trait allows you to create your own type to pass to the `environment` parameter. With this in place, you are able to mock http requests (for testing purposes) from the MPESA api by returning a mock server uri from the `base_url` method as well as using your own certificates, required to sign select requests to the MPESA api, by providing your own `get_certificate` implementation. + +See the example below (and [here](./src/environment.rs) so see how the trait is implemented for the `Environment` enum): + +```rust,ignore +use mpesa::{Mpesa, ApiEnvironment}; +use std::str::FromStr; +use std::convert::TryFrom; + +pub struct MyCustomEnvironment; + +impl ApiEnvironment for MyCustomEnvironment { + fn base_url(&self) -> &str { + // your base url here + "https://your_base_url.com" + } + + fn get_certificate(&self) -> &str { + // your certificate here + r#"..."# + } +} + +let client: Mpesa = Mpesa::new( + env!("CLIENT_KEY"), + env!("CLIENT_SECRET"), + MyCustomEnvironment // ✔ valid ); -assert!(client.is_connected()) + +//... ``` If you intend to use in production, you will need to call a the `set_initiator_password` method from `Mpesa` after initially creating the client. Here you provide your initiator password, which overrides the default password used in sandbox `"Safcom496!"`: -```rust +```rust,ignore use mpesa::Mpesa; -use std::env; let client = Mpesa::new( - env::var("CLIENT_KEY")?, - env::var("CLIENT_SECRET")?, - "production".parse()?, + env!("CLIENT_KEY"), + env!("CLIENT_SECRET"), + Environment::Sandbox, ); client.set_initiator_password("new_password"); -assert!(client.is_connected()) +assert!(client.is_connected().await) ``` ### Services The following services are currently available from the `Mpesa` client as methods that return builders: -- B2C +- B2C -```rust +```rust,ignore let response = client .b2c("testapi496") .party_a("600496") @@ -117,13 +162,14 @@ let response = client .result_url("https://testdomain.com/ok") .timeout_url("https://testdomain.com/err") .amount(1000) - .send(); + .send() + .await; assert!(response.is_ok()) ``` -- B2B +- B2B -```rust +```rust,ignore let response = client .b2b("testapi496") .party_a("600496") @@ -132,56 +178,216 @@ let response = client .timeout_url("https://testdomain.com/err") .account_ref("254708374149") .amount(1000) - .send(); + .send() + .await; assert!(response.is_ok()) ``` -- C2B Register +- C2B Register -```rust +```rust,ignore let response = client .c2b_register() .short_code("600496") .confirmation_url("https://testdomain.com/true") .validation_url("https://testdomain.com/valid") - .send(); + .send() + .await; assert!(response.is_ok()) ``` -- C2B Simulate - -```rust +- C2B Simulate +```rust,ignore let response = client .c2b_simulate() .short_code("600496") .msisdn("254700000000") .amount(1000) - .send(); + .send() + .await; assert!(response.is_ok()) ``` -- Account Balance +- Account Balance -```rust +```rust,ignore let response = client .account_balance("testapi496") .result_url("https://testdomain.com/ok") .timeout_url("https://testdomain.com/err") .party_a("600496") - .send(); + .send() + .await; assert!(response.is_ok()) ``` -- Mpesa Express Request / STK push / Lipa na M-PESA online +- Mpesa Express Request / STK push / Lipa na M-PESA online -```rust +```rust,ignore let response = client .express_request("174379") .phone_number("254708374149") .amount(500) .callback_url("https://test.example.com/api") - .send(); + .send() + .await; +assert!(response.is_ok()) +``` + +- Transaction Reversal: + +```rust,ignore +let response = client + .transaction_reversal("testapi496") + .result_url("https://testdomain.com/ok") + .timeout_url("https://testdomain.com/err") + .transaction_id("OEI2AK4Q16") + .receiver_identifier_type(IdentifierTypes::ShortCode) + .amount(100.0) + .receiver_party("600111") + .send() + .await; +assert!(response.is_ok()) +``` + +- Transaction Status + +```rust,ignore +let response = client + .transaction_status("testapi496") + .result_url("https://testdomain.com/ok") + .timeout_url("https://testdomain.com/err") + .transaction_id("OEI2AK4Q16") + .identifier_type(IdentifierTypes::ShortCode) + .party_a("600111") + .remarks("status") + .occasion("work") + .send() + .await; +assert!(response.is_ok()) +``` + +- Bill Manager Onboard + +```rust,ignore +let response = client + .onboard() + .callback_url("https://testdomain.com/true") + .email("email@test.com") + .logo("https://file.domain/file.png") + .official_contact("0712345678") + .send_reminders(SendRemindersTypes::Enable) + .short_code("600496") + .send() + .await; +assert!(response.is_ok()) +``` + +- Bill Manager Onboard Modify + +```rust,ignore +let response = client + .onboard_modify() + .callback_url("https://testdomain.com/true") + .email("email@test.com") + .short_code("600496") + .send() + .await; +assert!(response.is_ok()) +``` + +- Bill Manager Bulk Invoice + +```rust,ignore +let response = client + .bulk_invoice() + .invoices(vec![ + Invoice { + amount: 1000.0, + account_reference: "John Doe", + billed_full_name: "John Doe", + billed_period: "August 2021", + billed_phone_number: "0712345678", + due_date: Utc::now(), + external_reference: "INV2345", + invoice_items: Some( + vec![InvoiceItem {amount: 1000.0, item_name: "An item"}] + ), + invoice_name: "Invoice 001" + } + ]) + .send() + .await; +assert!(response.is_ok()) +``` + +- Bill Manager Single Invoice + +```rust,ignore +let response = client + .single_invoice() + .amount(1000.0) + .account_reference("John Doe") + .billed_full_name("John Doe") + .billed_period("August 2021") + .billed_phone_number("0712345678") + .due_date(Utc::now()) + .external_reference("INV2345") + .invoice_items(vec![ + InvoiceItem {amount: 1000.0, item_name: "An item"} + ]) + .invoice_name("Invoice 001") + .send() + .await; +assert!(response.is_ok()) +``` + +- Bill Manager Reconciliation + +```rust,ignore +let response = client + .reconciliation() + .account_reference("John Doe") + .external_reference("INV2345") + .full_name("John Doe") + .invoice_name("Invoice 001") + .paid_amount(1000.0) + .payment_date(Utc::now()) + .phone_number("0712345678") + .transaction_id("TRANSACTION_ID") + .send() + .await; +assert!(response.is_ok()) +``` + +- Bill Manager Cancel Invoice + +```rust,ignore +let response = client + .cancel_invoice() + .external_references(vec!["9KLSS011"]) + .send() + .await; +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()) ``` @@ -191,12 +397,12 @@ More will be added progressively, pull requests welcome **Collins Muriuki** -- Twitter: [@collinsmuriuki\_](https://twitter.com/collinsmuriuki_) -- Not affiliated with Safaricom. +- Twitter: [@collinsmuriuki\_](https://twitter.com/collinsmuriuki_) +- Not affiliated with Safaricom. ## Contributing -Contributions, issues and feature requests are welcome!
Feel free to check [issues page](https://github.com/collinsmuriuki/mpesa-rust/issues). You can also take a look at the [contributing guide](CONTRIBUTING.md). +Contributions, issues and feature requests are welcome!
Feel free to check [issues page](https://github.com/collinsmuriuki/mpesa-rust/issues). You can also take a look at the [contributing guide](https://raw.githubusercontent.com/collinsmuriuki/mpesa-rust/master/CONTRIBUTING.md). -Copyright © 2021 [Collins Muriuki](https://github.com/collinsmuriuki).
-This project is [MIT](LICENSE) licensed. +Copyright © 2023 [Collins Muriuki](https://github.com/collinsmuriuki).
+This project is [MIT](https://raw.githubusercontent.com/collinsmuriuki/mpesa-rust/master/LICENSE) licensed. diff --git a/mpesa_core/Cargo.toml b/mpesa_core/Cargo.toml deleted file mode 100644 index 85c59a4c9..000000000 --- a/mpesa_core/Cargo.toml +++ /dev/null @@ -1,41 +0,0 @@ -[package] -name = "mpesa" -version = "0.4.2" -authors = ["Collins Muriuki "] -edition = "2018" -description = "A wrapper around the M-PESA API in Rust." -keywords = ["api", "mpesa", "mobile"] -repository = "https://github.com/collinsmuriuki/mpesa-rust" -readme = "../README.md" -license = "MIT" - -[dependencies] -serde_json = "1.0" -serde_repr = "0.1" -dotenv = "0.15.0" -openssl = "0.10.30" -base64 = "0.13.0" -failure = "0.1.5" -failure_derive = "0.1.5" -chrono = "0.4" - -[dependencies.mpesa_derive] -version = "0.3.0" -path = "../mpesa_derive" - -[dependencies.reqwest] -version = "0.11.3" -features = ["blocking", "json"] - -[dependencies.serde] -version = "1.0.114" -features = ["derive"] - -[features] -default = ["b2b", "b2c" ,"account_balance", "c2b_register", "c2b_simulate", "express_request"] -b2b = [] -b2c = [] -account_balance = [] -c2b_register = [] -c2b_simulate = [] -express_request = [] \ No newline at end of file diff --git a/mpesa_core/src/client.rs b/mpesa_core/src/client.rs deleted file mode 100644 index 94bcb3d7b..000000000 --- a/mpesa_core/src/client.rs +++ /dev/null @@ -1,269 +0,0 @@ -use super::environment::Environment; -use super::services::{ - AccountBalanceBuilder, B2bBuilder, B2cBuilder, C2bRegisterBuilder, C2bSimulateBuilder, - MpesaExpressRequestBuilder, -}; -use crate::MpesaSecurity; -use mpesa_derive::*; -use reqwest::blocking::Client; -use serde_json::Value; -use std::cell::RefCell; - -/// `Result` enum type alias -pub type MpesaResult = Result; - -/// Mpesa client that will facilitate communication with the Safaricom API -#[derive(Debug, MpesaSecurity)] -pub struct Mpesa { - client_key: String, - client_secret: String, - initiator_password: RefCell>, - environment: Environment, - pub(crate) http_client: Client, -} - -impl<'a> Mpesa { - /// Constructs a new `Mpesa` instance. - /// - /// # Example - /// ```ignore - /// let client: Mpesa = Mpesa::new( - /// env::var("CLIENT_KEY").unwrap(), - /// env::var("CLIENT_SECRET").unwrap(), - /// "sandbox".parse().unwrap(), - /// ); - /// ``` - pub fn new(client_key: String, client_secret: String, environment: Environment) -> Self { - let http_client = Client::builder() - .connect_timeout(std::time::Duration::from_millis(10000)) - .build() - // TODO: Potentialy return a `Result` enum from Mpesa::new? - .expect("Error building http client"); - Self { - client_key, - client_secret, - initiator_password: RefCell::new(None), - environment, - http_client, - } - } - - /// Gets the current `Environment` - pub(crate) fn environment(&'a self) -> &Environment { - &self.environment - } - - /// Gets the initiator password as a byte slice - /// If `None`, the default password is b"Safcom496!" - pub(crate) fn initiator_password(&'a self) -> String { - if let Some(p) = &*self.initiator_password.borrow() { - return p.to_owned(); - } - "Safcom496!".to_owned() - } - - /// Optional in development but required for production, you will need to call this method and set your production initiator password. - /// If in development, default initiator password is already pre-set - /// ```ignore - /// use mpesa::Mpesa; - /// - /// let client: Mpesa = Mpesa::new( - /// env::var("CLIENT_KEY").unwrap(), - /// env::var("CLIENT_SECRET").unwrap(), - /// "sandbox".parse().unwrap(), - /// ); - /// - /// client.set_initiator_password("your_initiator_password"); - /// ``` - pub fn set_initiator_password(&self, initiator_password: &str) { - *self.initiator_password.borrow_mut() = Some(initiator_password.to_string()); - } - - /// Checks if the client can be authenticated - pub fn is_connected(&self) -> bool { - self.auth().is_ok() - } - - /// **Safaricom Oauth** - /// - /// Generates an access token - /// Sends `GET` request to Safaricom oauth to acquire token for token authentication - /// The OAuth access token expires after an hour, after which, you will need to generate another access token - /// - /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/docs#authentication) - /// - /// Returns the auth token as a `String`. - /// - /// # Errors - /// Returns a `MpesaError` on failure - pub(crate) fn auth(&self) -> MpesaResult { - let url = format!( - "{}/oauth/v1/generate?grant_type=client_credentials", - self.environment.base_url() - ); - let resp = self - .http_client - .get(&url) - .basic_auth(&self.client_key, Some(&self.client_secret)) - .send()?; - if resp.status().is_success() { - // TODO: Needs custom return type: currently not casting the response to a custom type - // hence why we need strip out double quotes `"` from the deserialized value - // example: "value" -> value - let value: Value = resp.json()?; - return Ok(value["access_token"].to_string().replace("\"", "")); - } - Err(MpesaError::Message( - "Could not authenticate to Safaricom, please check your credentials", - )) - } - - /// **B2C Builder** - /// - /// Creates a `B2cBuilder` for building a B2C transaction struct. - /// The builder is consumed and request made by calling its `send` method. - /// See more from Safaricom the API docs [here](https://developer.safaricom.co.ke/docs?shell#b2c-api). - /// - /// Requires an `initiator_name`, the credential/ username used to authenticate the transaction request - /// - /// # Example - /// ```ignore - /// let response = client - /// .b2c("testapi496") - /// .party_a("600496") - /// .party_b("600000") - /// .result_url("https://testdomain.com/err") - /// .timeout_url("https://testdomain.com/ok") - /// .amount(1000) - /// .remarks("Your Remark") // optional, defaults to "None" - /// .occasion("Your Occasion") // optional, defaults to "None" - /// .command_id(mpesa::CommandId::BusinessPayment) // optional, defaults to `CommandId::BusinessPayment` - /// .send(); - /// ``` - #[cfg(feature = "b2c")] - pub fn b2c(&'a self, initiator_name: &'a str) -> B2cBuilder<'a> { - B2cBuilder::new(self, initiator_name) - } - - /// **B2B Builder** - /// - /// Creates a `B2bBuilder` for building B2B transaction struct. - /// - /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/docs#b2b-api) - /// - /// Requires an `initiator_name`, the credential/ username used to authenticate the transaction request - /// - /// # Example - /// ```ignore - /// let response = client.b2b("testapi496") - /// .party_a("600496") - /// .party_b("600000") - /// .result_url("https://testdomain.com/err") - /// .timeout_url("https://testdomain.com/ok") - /// .account_ref("254708374149") - /// .amount(1000) - /// .command_id(mpesa::CommandId::BusinessToBusinessTransfer) // optional, defaults to `CommandId::BusinessToBusinessTransfer` - /// .remarks("None") // optional, defaults to "None" - /// .sender_id(mpesa::IdentifierTypes::ShortCode) // optional, defaults to `IdentifierTypes::ShortCode` - /// .receiver_id(mpesa::IdentifierTypes::ShortCode) // optional, defaults to `IdentifierTypes::ShortCode` - /// .send(); - /// ``` - #[cfg(feature = "b2b")] - pub fn b2b(&'a self, initiator_name: &'a str) -> B2bBuilder<'a> { - B2bBuilder::new(self, initiator_name) - } - - /// **C2B Register builder** - /// - /// Creates a `C2bRegisterBuilder` for registering URLs to the 3rd party shortcode. - /// - /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/docs?shell#c2b-api) - /// - /// # Example - /// ```ignore - /// let response = client - /// .c2b_register() - /// .short_code("600496") - /// .confirmation_url("https://testdomain.com/true") - /// .validation_url("https://testdomain.com/valid") - /// .response_type(mpesa::ResponseTypes::Complete) // optional, defaults to `ResponseTypes::Complete` - /// .send(); - /// ``` - #[cfg(feature = "c2b_register")] - pub fn c2b_register(&'a self) -> C2bRegisterBuilder<'a> { - C2bRegisterBuilder::new(self) - } - - /// **C2B Simulate Builder** - /// - /// Creates a `C2bSimulateBuilder` for simulating C2B transactions - /// - /// See more [here](https://developer.safaricom.co.ke/c2b/apis/post/simulate) - /// - /// # Example - /// ```ignore - /// 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(); - /// ``` - #[cfg(feature = "c2b_simulate")] - pub fn c2b_simulate(&'a self) -> C2bSimulateBuilder<'a> { - C2bSimulateBuilder::new(self) - } - - /// **Account Balance Builder** - /// - /// Creates an `AccountBalanceBuilder` for enquiring the balance on an MPESA BuyGoods. - /// Requires an `initiator_name`. - /// - /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/docs#account-balance-api) - /// - /// # Example - /// ```ignore - /// let response = client - /// .account_balance("testapi496") - /// .result_url("https://testdomain.com/err") - /// .timeout_url("https://testdomain.com/ok") - /// .party_a("600496") - /// .command_id(mpesa::CommandId::AccountBalance) // optional, defaults to `CommandId::AccountBalance` - /// .identifier_type(mpesa::IdentifierTypes::ShortCode) // optional, defaults to `IdentifierTypes::ShortCode` - /// .remarks("Your Remarks") // optional, defaults to "None" - /// .send(); - /// ``` - #[cfg(feature = "account_balance")] - pub fn account_balance(&'a self, initiator_name: &'a str) -> AccountBalanceBuilder<'a> { - AccountBalanceBuilder::new(self, initiator_name) - } - - /// **Mpesa Express Request/ STK push Builder** - /// - /// Creates a `MpesaExpressRequestBuilder` struct - /// Requires a `business_short_code` - The organization shortcode used to receive the transaction - /// - /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/docs#lipa-na-m-pesa-online-payment) - /// - /// # Example - ///```ignore - /// let response = client - /// .express_request("174379") - /// .phone_number("254708374149") - /// .party_a("254708374149") - /// .party_b("174379") - /// .amount(500) - /// .callback_url("https://test.example.com/api") - /// .transaction_type(CommandId::CustomerPayBillOnline) // Optional, defaults to `CommandId::CustomerPayBillOnline` - /// .transaction_desc("Description") // Optional, defaults to "None" - /// .send(); - /// ``` - #[cfg(feature = "express_request")] - pub fn express_request( - &'a self, - business_short_code: &'a str, - ) -> MpesaExpressRequestBuilder<'a> { - MpesaExpressRequestBuilder::new(self, business_short_code) - } -} diff --git a/mpesa_core/src/constants.rs b/mpesa_core/src/constants.rs deleted file mode 100644 index 8a7ce96ce..000000000 --- a/mpesa_core/src/constants.rs +++ /dev/null @@ -1,87 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_repr::{Deserialize_repr, Serialize_repr}; -use std::fmt::{Display, Formatter, Result as FmtResult}; - -/// Mpesa command ids -#[derive(Debug, Serialize, Deserialize)] -pub enum CommandId { - TransactionReversal, - SalaryPayment, - BusinessPayment, - PromotionPayment, - AccountBalance, - CustomerPayBillOnline, - TransactionStatusQuery, - CheckIdentity, - BusinessPayBill, - BusinessBuyGoods, - DisburseFundsToBusiness, - BusinessToBusinessTransfer, - BusinessTransferFromMMFToUtility, -} - -impl Display for CommandId { - fn fmt(&self, f: &mut Formatter) -> FmtResult { - write!(f, "{}", self) - } -} - -/// Identifier types - both sender and receiver - identify an M-Pesa transaction’s sending and receiving party as -/// either a shortcode, a till number or a MSISDN (phone number). -/// There are three identifier types that can be used with M-Pesa APIs. -#[derive(Debug, Serialize_repr, Deserialize_repr, Copy, Clone)] -#[repr(u16)] -pub enum IdentifierTypes { - MSISDN = 1, - TillNumber = 2, - ShortCode = 4, -} - -impl Display for IdentifierTypes { - fn fmt(&self, f: &mut Formatter) -> FmtResult { - write!(f, "{:?}", *self as u16) - } -} - -/// M-pesa result and response codes -#[derive(Debug, Copy, Clone, Deserialize_repr)] -#[repr(u16)] -#[allow(unused)] -pub enum MpesaResponseCode { - Success = 0, - InsufficientFunds = 1, - LessThanMinimum = 2, - MoreThanMaximum = 3, - ExceededDailyLimit = 4, - ExceededMinimumBalance = 5, - UnresolvedPrimaryParty = 6, - UnresolvedReceiverParty = 7, - ExceededMaximumBalance = 8, - InvalidDebitAccount = 11, - InvalidCreditAccount = 12, - UnresolvedDebitAccount = 13, - UnresolvedCreditAccount = 14, - DuplicateDetected = 15, - InternalFailure = 17, - UnresolvedInitiator = 20, - TrafficBlocking = 26, -} - -impl Display for MpesaResponseCode { - fn fmt(&self, f: &mut Formatter) -> FmtResult { - write!(f, "{:?}", *self as u16) - } -} - -#[derive(Debug, Serialize, Deserialize)] -/// C2B Register Response types -pub enum ResponseType { - Complete, - Cancelled, -} - -impl Display for ResponseType { - fn fmt(&self, f: &mut Formatter) -> FmtResult { - write!(f, "{}", self) - } -} diff --git a/mpesa_core/src/environment.rs b/mpesa_core/src/environment.rs deleted file mode 100644 index 03f1f32ec..000000000 --- a/mpesa_core/src/environment.rs +++ /dev/null @@ -1,156 +0,0 @@ -//!# MPESA Environment -//! -//! Code related to setting up the desired Safaricom API environment. Environment can be either -//! sandbox or production. -//! you will need environment specific credentials (`CLIENT_KEY` AND `CLIENT_SECRET`) when creating -//! an instance of the `Mpesa` client struct. Note that you cannot use sandbox credentials in -//! production and vice versa. -//! -//! Based on selected environment. You are able to access environment specific data such as the `base_url` -//! and the `public key` an X509 certificate used for encrypting initiator passwords. You can read more about that from -//! the Safaricom API [docs](https://developer.safaricom.co.ke/docs?javascript#security-credentials). - -use crate::MpesaError; -use std::{convert::TryFrom, str::FromStr}; - -#[derive(Debug)] -/// Enum to map to desired environment so as to access certificate -/// and the base url -/// Required to construct a new `Mpesa` struct -pub enum Environment { - Production, - Sandbox, -} - -impl FromStr for Environment { - type Err = MpesaError; - - fn from_str(s: &str) -> Result { - match s { - "production" | "Production" | "PRODUCTION" => Ok(Self::Production), - "sandbox" | "Sandbox" | "SANDBOX" => Ok(Self::Sandbox), - _ => Err(MpesaError::Message( - "Could not parse the provided environment name", - )), - } - } -} - -impl TryFrom<&'static str> for Environment { - type Error = MpesaError; - - fn try_from(v: &'static str) -> Result { - match v { - "production" | "Production" | "PRODUCTION" => Ok(Self::Production), - "sandbox" | "Sandbox" | "SANDBOX" => Ok(Self::Sandbox), - _ => Err(MpesaError::Message( - "Could not parse the provided environment name", - )), - } - } -} - -impl Environment { - /// Matches to intended base_url depending on Environment variant - /// - /// ## Example - /// ``` - /// use mpesa::Environment; - /// - /// let env: Environment = Environment::Production; - /// let base_url: &str = env.base_url(); - /// assert_eq!("https://api.safaricom.co.ke", base_url); - /// ``` - pub fn base_url(&self) -> &'static str { - match self { - Environment::Production => "https://api.safaricom.co.ke", - Environment::Sandbox => "https://sandbox.safaricom.co.ke", - } - } - - /// Match to X509 public key certificate based on - /// environment variant - pub fn get_certificate(&self) -> &'static str { - match self { - Environment::Production => { - r#"-----BEGIN CERTIFICATE----- -MIIGkzCCBXugAwIBAgIKXfBp5gAAAD+hNjANBgkqhkiG9w0BAQsFADBbMRMwEQYK -CZImiZPyLGQBGRYDbmV0MRkwFwYKCZImiZPyLGQBGRYJc2FmYXJpY29tMSkwJwYD -VQQDEyBTYWZhcmljb20gSW50ZXJuYWwgSXNzdWluZyBDQSAwMjAeFw0xNzA0MjUx -NjA3MjRaFw0xODAzMjExMzIwMTNaMIGNMQswCQYDVQQGEwJLRTEQMA4GA1UECBMH -TmFpcm9iaTEQMA4GA1UEBxMHTmFpcm9iaTEaMBgGA1UEChMRU2FmYXJpY29tIExp -bWl0ZWQxEzARBgNVBAsTClRlY2hub2xvZ3kxKTAnBgNVBAMTIGFwaWdlZS5hcGlj -YWxsZXIuc2FmYXJpY29tLmNvLmtlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB -CgKCAQEAoknIb5Tm1hxOVdFsOejAs6veAai32Zv442BLuOGkFKUeCUM2s0K8XEsU -t6BP25rQGNlTCTEqfdtRrym6bt5k0fTDscf0yMCoYzaxTh1mejg8rPO6bD8MJB0c -FWRUeLEyWjMeEPsYVSJFv7T58IdAn7/RhkrpBl1dT7SmIZfNVkIlD35+Cxgab+u7 -+c7dHh6mWguEEoE3NbV7Xjl60zbD/Buvmu6i9EYz+27jNVPI6pRXHvp+ajIzTSsi -eD8Ztz1eoC9mphErasAGpMbR1sba9bM6hjw4tyTWnJDz7RdQQmnsW1NfFdYdK0qD -RKUX7SG6rQkBqVhndFve4SDFRq6wvQIDAQABo4IDJDCCAyAwHQYDVR0OBBYEFG2w -ycrgEBPFzPUZVjh8KoJ3EpuyMB8GA1UdIwQYMBaAFOsy1E9+YJo6mCBjug1evuh5 -TtUkMIIBOwYDVR0fBIIBMjCCAS4wggEqoIIBJqCCASKGgdZsZGFwOi8vL0NOPVNh -ZmFyaWNvbSUyMEludGVybmFsJTIwSXNzdWluZyUyMENBJTIwMDIsQ049U1ZEVDNJ -U1NDQTAxLENOPUNEUCxDTj1QdWJsaWMlMjBLZXklMjBTZXJ2aWNlcyxDTj1TZXJ2 -aWNlcyxDTj1Db25maWd1cmF0aW9uLERDPXNhZmFyaWNvbSxEQz1uZXQ/Y2VydGlm -aWNhdGVSZXZvY2F0aW9uTGlzdD9iYXNlP29iamVjdENsYXNzPWNSTERpc3RyaWJ1 -dGlvblBvaW50hkdodHRwOi8vY3JsLnNhZmFyaWNvbS5jby5rZS9TYWZhcmljb20l -MjBJbnRlcm5hbCUyMElzc3VpbmclMjBDQSUyMDAyLmNybDCCAQkGCCsGAQUFBwEB -BIH8MIH5MIHJBggrBgEFBQcwAoaBvGxkYXA6Ly8vQ049U2FmYXJpY29tJTIwSW50 -ZXJuYWwlMjBJc3N1aW5nJTIwQ0ElMjAwMixDTj1BSUEsQ049UHVibGljJTIwS2V5 -JTIwU2VydmljZXMsQ049U2VydmljZXMsQ049Q29uZmlndXJhdGlvbixEQz1zYWZh -cmljb20sREM9bmV0P2NBQ2VydGlmaWNhdGU/YmFzZT9vYmplY3RDbGFzcz1jZXJ0 -aWZpY2F0aW9uQXV0aG9yaXR5MCsGCCsGAQUFBzABhh9odHRwOi8vY3JsLnNhZmFy -aWNvbS5jby5rZS9vY3NwMAsGA1UdDwQEAwIFoDA9BgkrBgEEAYI3FQcEMDAuBiYr -BgEEAYI3FQiHz4xWhMLEA4XphTaE3tENhqCICGeGwcdsg7m5awIBZAIBDDAdBgNV -HSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwEwJwYJKwYBBAGCNxUKBBowGDAKBggr -BgEFBQcDAjAKBggrBgEFBQcDATANBgkqhkiG9w0BAQsFAAOCAQEAC/hWx7KTwSYr -x2SOyyHNLTRmCnCJmqxA/Q+IzpW1mGtw4Sb/8jdsoWrDiYLxoKGkgkvmQmB2J3zU -ngzJIM2EeU921vbjLqX9sLWStZbNC2Udk5HEecdpe1AN/ltIoE09ntglUNINyCmf -zChs2maF0Rd/y5hGnMM9bX9ub0sqrkzL3ihfmv4vkXNxYR8k246ZZ8tjQEVsKehE -dqAmj8WYkYdWIHQlkKFP9ba0RJv7aBKb8/KP+qZ5hJip0I5Ey6JJ3wlEWRWUYUKh -gYoPHrJ92ToadnFCCpOlLKWc0xVxANofy6fqreOVboPO0qTAYpoXakmgeRNLUiar -0ah6M/q/KA== ------END CERTIFICATE----- -"# - } - Environment::Sandbox => { - r#"-----BEGIN CERTIFICATE----- -MIIGKzCCBROgAwIBAgIQDL7NH8cxSdUpl0ihH0A1wTANBgkqhkiG9w0BAQsFADBN -MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMScwJQYDVQQDEx5E -aWdpQ2VydCBTSEEyIFNlY3VyZSBTZXJ2ZXIgQ0EwHhcNMTgwODI3MDAwMDAwWhcN -MTkwNDA0MTIwMDAwWjBuMQswCQYDVQQGEwJLRTEQMA4GA1UEBxMHTmFpcm9iaTEW -MBQGA1UEChMNU2FmYXJpY29tIFBMQzETMBEGA1UECxMKRGlnaXRhbCBJVDEgMB4G -A1UEAxMXc2FuZGJveC5zYWZhcmljb20uY28ua2UwggEiMA0GCSqGSIb3DQEBAQUA -A4IBDwAwggEKAoIBAQC78yeC/wLoZY6TJeqc4g/9eAKIpeCwEsjX09pD8ZxAGXqT -Oi7ssdIGJBPmJZNeEVyf8ocFhisCuLngJ9Z5e/AvH52PhrEFmVu2D03zSf4C+rhZ -ndEKP6G79pUAb/bemOliU9zM8xYYkpCRzPWUzk6zSDarg0ZDLw5FrtZj/VJ9YEDL -WGgAfwExEgSN3wjyUlJ2UwI3wqQXLka0VNFWoZxUH5j436gbSWRIL6NJUmrq8V8S -aTEPz3eJHj3NOToDu245c7VKdF/KExyZjRjD2p5I+Aip80TXzKlZj6DjMb3DlfXF -Hsnu0+1uJE701mvKX7BiscxKr8tCRphL63as4dqvAgMBAAGjggLkMIIC4DAfBgNV -HSMEGDAWgBQPgGEcgjFh1S8o541GOLQs4cbZ4jAdBgNVHQ4EFgQUzZmY7ZORLw9w -qRbAQN5m9lJ28qMwIgYDVR0RBBswGYIXc2FuZGJveC5zYWZhcmljb20uY28ua2Uw -DgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBr -BgNVHR8EZDBiMC+gLaArhilodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vc3NjYS1z -aGEyLWc2LmNybDAvoC2gK4YpaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL3NzY2Et -c2hhMi1nNi5jcmwwTAYDVR0gBEUwQzA3BglghkgBhv1sAQEwKjAoBggrBgEFBQcC -ARYcaHR0cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAIBgZngQwBAgIwfAYIKwYB -BQUHAQEEcDBuMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20w -RgYIKwYBBQUHMAKGOmh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2Vy -dFNIQTJTZWN1cmVTZXJ2ZXJDQS5jcnQwCQYDVR0TBAIwADCCAQUGCisGAQQB1nkC -BAIEgfYEgfMA8QB2AKS5CZC0GFgUh7sTosxncAo8NZgE+RvfuON3zQ7IDdwQAAAB -ZXs1FvEAAAQDAEcwRQIgBzVMkm7SNprjJ1GBqiXIc9rNzY+y7gt6s/O02oMkyFoC -IQDBuThGlpmUKpeZoHhK6HGwB4jDMIecmKaOcMS18R2jxwB3AId1v+dZfPiMQ5lf -vfNu/1aNR1Y2/0q1YMG06v9eoIMPAAABZXs1F8IAAAQDAEgwRgIhAIRq2XFiC+RS -uDCYq8ICJg0QafSV+e9BLpJnElEdaSjiAiEAyiiW4vxwv4cWcAXE6FAipctyUBs6 -bE5QyaCnmNpoDiQwDQYJKoZIhvcNAQELBQADggEBAB0YoWve9Sxhb0PBS3Hc46Rf -a7H1jhHuwE+UyscSQsdJdk8uPAgDuKRZMvJPGEaCkNHm36NfcaXXFjPOl7LI1d1a -9zqSP0xeZBI6cF0x96WuQGrI9/WR2tfxjmaUSp8a/aJ6n+tZA28eJZNPrIaMm+6j -gh7AkKnqcf+g8F/MvCCVdNAiVMdz6UpCscf6BRPHNZ5ifvChGh7aUKjrVLLuF4Ls -HE05qm6HNyV5eTa6wvcbc4ewguN1UDZvPWetSyfBk10Wbpor4znQ4TJ3Y9uCvsJH -41ldblDvZZ2z4kB2UYQ7iBkPlJSxSOaFgW/GGDXq49sz/995xzhVITHxh2SdLkI= ------END CERTIFICATE----- - "# - } - } - } -} diff --git a/mpesa_core/src/errors.rs b/mpesa_core/src/errors.rs deleted file mode 100644 index 27b7a2b47..000000000 --- a/mpesa_core/src/errors.rs +++ /dev/null @@ -1,63 +0,0 @@ -use failure_derive::*; -use std::env::VarError; - -#[derive(Debug, Fail)] -/// Mpesa error stack -pub enum MpesaError { - #[fail(display = "Error Authenticating: {}", 0)] - AuthenticationError(serde_json::Value), - #[fail(display = "Error performing B2B transaction: {}", 0)] - B2bError(serde_json::Value), - #[fail(display = "Error performing B2C transaction: {}", 0)] - B2cError(serde_json::Value), - #[fail(display = "Error performing C2B registration: {}", 0)] - C2bRegisterError(serde_json::Value), - #[fail(display = "Error performing C2B simulation: {}", 0)] - C2bSimulateError(serde_json::Value), - #[fail(display = "Error getting account balance: {}", 0)] - AccountBalanceError(serde_json::Value), - #[fail(display = "Error making mpesa express request: {}", 0)] - MpesaExpressRequestError(serde_json::Value), - #[fail(display = "Network Error: {}", 0)] - NetworkError(reqwest::Error), - #[fail(display = "Error parsing JSON data: {}", 0)] - ParseError(serde_json::Error), - #[fail(display = "Error getting environmental variables: {}", 0)] - EnvironmentalVariableError(VarError), - #[fail(display = "Error extracting X509 from pem: {}", 0)] - EncryptionError(openssl::error::ErrorStack), - #[fail(display = "Error: {}", 0)] - Message(&'static str), - #[fail(display = "Error: {:#?}", 0)] - ErrorResponse(serde_json::Value), -} - -impl From for MpesaError { - fn from(e: serde_json::Error) -> Self { - MpesaError::ParseError(e) - } -} - -impl From for MpesaError { - fn from(e: reqwest::Error) -> Self { - MpesaError::NetworkError(e) - } -} - -impl From for MpesaError { - fn from(e: VarError) -> Self { - MpesaError::EnvironmentalVariableError(e) - } -} - -impl From<&'static str> for MpesaError { - fn from(e: &'static str) -> Self { - MpesaError::Message(e) - } -} - -impl From for MpesaError { - fn from(e: openssl::error::ErrorStack) -> Self { - MpesaError::EncryptionError(e) - } -} diff --git a/mpesa_core/src/lib.rs b/mpesa_core/src/lib.rs deleted file mode 100644 index ef1a4763a..000000000 --- a/mpesa_core/src/lib.rs +++ /dev/null @@ -1,266 +0,0 @@ -//!## About -//! -//! An unofficial Rust wrapper around the [Safaricom API](https://developer.safaricom.co.ke/docs?shell#introduction) for accessing M-Pesa services. -//! -//!## Install -//! `Cargo.toml` -//! -//! ```toml -//! [dependencies] -//! mpesa = "0.4.2" -//! ``` -//! Optionally, you can disable default-features, which is basically the entire suite of MPESA APIs to conditionally select from either `["b2b", "b2c" ,"account_balance", "c2b_register", "c2b_simulate", "express_request"]` services. -//! Example: -//! -//! ```toml -//! [dependencies] -//! mpesa = { version = "0.4.2", default_features = false, features = ["b2b", "express_request"] } -//! ``` -//! -//! In your lib or binary crate: -//! ```rs -//! use mpesa::Mpesa; -//! ``` -//! -//!## Usage -//! -//!### Creating a `Mpesa` client -//! You will first need to create an instance of the `Mpesa` instance (the client). You are required to provide a **CLIENT_KEY** and -//! **CLIENT_SECRET**. [Here](https://developer.safaricom.co.ke/test_credentials) is how you can get these credentials for the Safaricom sandbox -//! environment. It's worth noting that these credentials are only valid in the sandbox environment. To go live and get production keys -//! read the docs [here](https://developer.safaricom.co.ke/docs?javascript#going-live). -//! -//! These are the following ways you can instantiate `Mpesa`: -//! -//! _NOTE_: -//! * Only calling `unwrap` for demonstration purposes. Errors are handled appropriately in the lib via the `MpesaError` enum. -//! * Use of `dotenv` is optional. -//! -//! ```rust -//! use mpesa::{Mpesa, Environment}; -//! use std::env; -//! use dotenv::dotenv; -//! -//! dotenv().ok(); -//! -//! let client: Mpesa = Mpesa::new( -//! env::var("CLIENT_KEY").unwrap(), -//! env::var("CLIENT_SECRET").unwrap(), -//! Environment::Sandbox, -//! ); -//! assert!(client.is_connected()) -//! ``` -//! -//! Since the `Environment` enum implements `FromStr` and `TryFrom`, you can pass the name of the environment as a `&str` and call the `parse()` or `try_into()` -//! method to create an `Environment` type from the string slice (Pascal case or Uppercase string slices also valid): -//! -//! ```rust -//! use mpesa::Mpesa; -//! use std::env; -//! use dotenv::dotenv; -//! -//! dotenv().ok(); -//! -//! let client: Mpesa = Mpesa::new( -//! env::var("CLIENT_KEY").unwrap(), -//! env::var("CLIENT_SECRET").unwrap(), -//! "sandbox".parse().unwrap(), // "production" -//! ); -//! assert!(client.is_connected()) -//! ``` -//! If you intend to use in production, you will need to call a the `set_initiator_password` method from `Mpesa` after initially -//! creating the client. Here you provide your initiator password, which overrides the default password used in sandbox `"Safcom496!"`: -//! -//! ```rust -//! use mpesa::Mpesa; -//! use std::env; -//! use dotenv::dotenv; -//! -//! dotenv().ok(); -//! -//! let client: Mpesa = Mpesa::new( -//! env::var("CLIENT_KEY").unwrap(), -//! env::var("CLIENT_SECRET").unwrap(), -//! "sandbox".parse().unwrap(), -//! ); -//! -//! client.set_initiator_password("new_password"); -//! -//! assert!(client.is_connected()) -//! ``` -//! -//!### Services -//! The following services are currently available from the `Mpesa` client as methods that return builders: -//! * B2C -//! ```rust -//! use mpesa::{Mpesa, MpesaResult, B2cResponse}; -//! use std::env; -//! use dotenv::dotenv; -//! -//! dotenv().ok(); -//! -//! let client: Mpesa = Mpesa::new( -//! env::var("CLIENT_KEY").unwrap(), -//! env::var("CLIENT_SECRET").unwrap(), -//! "sandbox".parse().unwrap(), -//! ); -//! -//! let response: MpesaResult = client -//! .b2c("testapi496") -//! .party_a("600496") -//! .party_b("254708374149") -//! .result_url("https://testdomain.com/ok") -//! .timeout_url("https://testdomain.com/err") -//! .amount(1000) -//! .send(); -//! assert!(response.is_ok()) -//! ``` -//! -//! * B2B -//! ```rust -//! use mpesa::{Mpesa, MpesaResult, B2bResponse}; -//! use std::env; -//! use dotenv::dotenv; -//! -//! dotenv().ok(); -//! -//! let client: Mpesa = Mpesa::new( -//! env::var("CLIENT_KEY").unwrap(), -//! env::var("CLIENT_SECRET").unwrap(), -//! "sandbox".parse().unwrap(), -//! ); -//! -//! let response: MpesaResult = client -//! .b2b("testapi496") -//! .party_a("600496") -//! .party_b("600000") -//! .result_url("https://testdomain.com/ok") -//! .timeout_url("https://testdomain.com/err") -//! .account_ref("254708374149") -//! .amount(1000) -//! .send(); -//! assert!(response.is_ok()) -//! ``` -//! -//! * C2B Register -//! ```rust -//! use mpesa::{Mpesa, MpesaResult, C2bRegisterResponse}; -//! use serde_json::Value; -//! use std::env; -//! use dotenv::dotenv; -//! -//! dotenv().ok(); -//! -//! let client: Mpesa = Mpesa::new( -//! env::var("CLIENT_KEY").unwrap(), -//! env::var("CLIENT_SECRET").unwrap(), -//! "sandbox".parse().unwrap(), -//! ); -//! -//! let response: MpesaResult = client -//! .c2b_register() -//! .short_code("600496") -//! .confirmation_url("https://testdomain.com/true") -//! .validation_url("https://testdomain.com/valid") -//! .send(); -//! assert!(response.is_ok()) -//! ``` -//! -//! * C2B Simulate -//! ```rust -//! use mpesa::{Mpesa, MpesaResult, C2bSimulateResponse}; -//! use std::env; -//! use dotenv::dotenv; -//! -//! dotenv().ok(); -//! -//! let client: Mpesa = Mpesa::new( -//! env::var("CLIENT_KEY").unwrap(), -//! env::var("CLIENT_SECRET").unwrap(), -//! "sandbox".parse().unwrap(), -//! ); -//! -//! let response: MpesaResult = client -//! .c2b_simulate() -//! .short_code("600496") -//! .msisdn("254700000000") -//! .amount(1000) -//! .send(); -//! assert!(response.is_ok()) -//! ``` -//! -//! * Account Balance -//! -//! ```rust -//! use mpesa::{Mpesa, MpesaResult, AccountBalanceResponse}; -//! use std::env; -//! use dotenv::dotenv; -//! -//! dotenv().ok(); -//! -//! let client: Mpesa = Mpesa::new( -//! env::var("CLIENT_KEY").unwrap(), -//! env::var("CLIENT_SECRET").unwrap(), -//! "sandbox".parse().unwrap(), -//! ); -//! -//! let response: MpesaResult = client -//! .account_balance("testapi496") -//! .result_url("https://testdomain.com/ok") -//! .timeout_url("https://testdomain.com/err") -//! .party_a("600496") -//! .send(); -//! assert!(response.is_ok()) -//! ``` -//! -//! * Mpesa Express Request / STK push/ Lipa na M-PESA online -//! -//! ```rust -//! use mpesa::{Mpesa, MpesaResult, MpesaExpressRequestResponse}; -//! use std::env; -//! use dotenv::dotenv; -//! -//! dotenv().ok(); -//! -//! let client: Mpesa = Mpesa::new( -//! env::var("CLIENT_KEY").unwrap(), -//! env::var("CLIENT_SECRET").unwrap(), -//! "sandbox".parse().unwrap(), -//! ); -//! -//! let response: MpesaResult = client -//! .express_request("174379") -//! .phone_number("254708374149") -//! .amount(500) -//! .callback_url("https://testdomain.com/ok") -//! .send(); -//! -//! assert!(response.is_ok()) -//! ``` -//! More will be added progressively, pull requests welcome -//!## Author -//! -//! **Collins Muriuki** -//! -//! * Twitter: [@collinsmuriuki_](https://twitter.com/collinsmuriuki_) -//! * Not affiliated with Safaricom. -//! -//!## License -//! This project is MIT licensed - -mod client; -mod constants; -pub mod environment; -mod errors; -mod mpesa_security; -pub mod services; - -pub use client::{Mpesa, MpesaResult}; -pub use constants::{CommandId, IdentifierTypes, ResponseType}; -pub use environment::Environment::{self, Production, Sandbox}; -pub use errors::MpesaError; -use mpesa_security::MpesaSecurity; -pub use services::{ - AccountBalanceResponse, B2bResponse, B2cResponse, C2bRegisterResponse, C2bSimulateResponse, - MpesaExpressRequestResponse, -}; diff --git a/mpesa_core/src/mpesa_security.rs b/mpesa_core/src/mpesa_security.rs deleted file mode 100644 index 1233e75ad..000000000 --- a/mpesa_core/src/mpesa_security.rs +++ /dev/null @@ -1,13 +0,0 @@ -use crate::client::MpesaResult; - -/// Trait responsible for implementation of security configs for Mpesa -pub trait MpesaSecurity { - /// 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. - /// Returns base64 encoded string. - /// - /// # Error - /// Returns `EncryptionError` variant of `MpesaError` - fn gen_security_credentials(&self) -> MpesaResult; -} diff --git a/mpesa_core/src/services/c2b_simulate.rs b/mpesa_core/src/services/c2b_simulate.rs deleted file mode 100644 index e5a04bbcf..000000000 --- a/mpesa_core/src/services/c2b_simulate.rs +++ /dev/null @@ -1,141 +0,0 @@ -use crate::client::{Mpesa, MpesaResult}; -use crate::constants::CommandId; -use crate::errors::MpesaError; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Debug, Serialize)] -/// Payload to make payment requests from C2B. -/// See more: https://developer.safaricom.co.ke/docs#c2b-api -struct C2bSimulatePayload<'a> { - #[serde(rename(serialize = "CommandID"))] - command_id: CommandId, - #[serde(rename(serialize = "Amount"))] - amount: u32, - #[serde(rename(serialize = "Msisdn"))] - msisdn: &'a str, - #[serde(rename(serialize = "BillRefNumber"))] - bill_ref_number: &'a str, - #[serde(rename(serialize = "ShortCode"))] - short_code: &'a str, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct C2bSimulateResponse { - #[serde(rename(deserialize = "ConversationID"), skip_serializing_if = "None")] - pub conversation_id: Option, - #[serde(rename(deserialize = "OriginatorCoversationID"))] - pub originator_coversation_id: String, - #[serde(rename(deserialize = "ResponseDescription"))] - pub response_description: String, -} - -#[derive(Debug)] -pub struct C2bSimulateBuilder<'a> { - client: &'a Mpesa, - command_id: Option, - amount: Option, - msisdn: Option<&'a str>, - bill_ref_number: Option<&'a str>, - short_code: Option<&'a str>, -} - -impl<'a> C2bSimulateBuilder<'a> { - /// Creates a new C2B Simulate builder - pub fn new(client: &'a Mpesa) -> C2bSimulateBuilder<'a> { - C2bSimulateBuilder { - client, - command_id: None, - amount: None, - msisdn: None, - bill_ref_number: None, - short_code: None, - } - } - - /// Adds `CommandId`. Defaults to `CommandId::CustomerPaybillOnline` if no value explicitly passed - /// - /// # Errors - /// If `CommandId` is not valid - pub fn command_id(mut self, command_id: CommandId) -> C2bSimulateBuilder<'a> { - self.command_id = Some(command_id); - self - } - - /// Adds the amount being transacted. This is a required field - /// - /// # Errors - /// If invalid amount, less than 10? - pub fn amount(mut self, amount: u32) -> C2bSimulateBuilder<'a> { - self.amount = Some(amount); - self - } - - /// Adds the MSISDN(phone number) sending the transaction, start by country code without the `+`. - /// This is a required field - /// - /// # Errors - /// If `MSISDN` is invalid - pub fn msisdn(mut self, msisdn: &'a str) -> C2bSimulateBuilder<'a> { - self.msisdn = Some(msisdn); - self - } - - /// Adds `ShortCode`; the 6 digit MPESA Till Number or PayBill Number - /// - /// # Errors - /// If Till or PayBill number is invalid - pub fn short_code(mut self, short_code: &'a str) -> C2bSimulateBuilder<'a> { - self.short_code = Some(short_code); - self - } - - /// Adds Bull reference number. This field is optional and will by default be `"None"`. - pub fn bill_ref_number(mut self, bill_ref_number: &'a str) -> C2bSimulateBuilder<'a> { - self.bill_ref_number = Some(bill_ref_number); - self - } - - /// **C2B Simulate API** - /// - /// Make payment requests from Client to Business - /// - /// This enables you to receive the payment requests in real time. - /// See more [here](https://developer.safaricom.co.ke/c2b/apis/post/simulate) - /// - /// A successful request returns a `serde_json::Value` type - /// - /// # Errors - /// Returns a `MpesaError` on failure - pub fn send(self) -> MpesaResult { - let url = format!( - "{}/mpesa/c2b/v1/simulate", - self.client.environment().base_url() - ); - - let payload = C2bSimulatePayload { - command_id: self.command_id.unwrap_or(CommandId::CustomerPayBillOnline), - amount: self.amount.unwrap_or(10), - msisdn: self.msisdn.unwrap_or("None"), - bill_ref_number: self.bill_ref_number.unwrap_or("None"), - short_code: self.short_code.unwrap_or("None"), - }; - - let response = self - .client - .http_client - .post(&url) - .bearer_auth(self.client.auth()?) - .json(&payload) - .send()? - .error_for_status()?; - - if response.status().is_success() { - let value: C2bSimulateResponse = response.json()?; - return Ok(value); - } - - let value: Value = response.json()?; - Err(MpesaError::C2bSimulateError(value)) - } -} diff --git a/mpesa_core/tests/account_balance_test.rs b/mpesa_core/tests/account_balance_test.rs deleted file mode 100644 index 920faab60..000000000 --- a/mpesa_core/tests/account_balance_test.rs +++ /dev/null @@ -1,23 +0,0 @@ -use dotenv; -use mpesa::{Environment, Mpesa}; -use std::env; - -#[test] -fn account_balance_test() { - dotenv::dotenv().ok(); - - let client = Mpesa::new( - env::var("CLIENT_KEY").unwrap(), - env::var("CLIENT_SECRET").unwrap(), - Environment::Sandbox, - ); - - let response = client - .account_balance("testapi496") - .result_url("https://testdomain.com/ok") - .timeout_url("https://testdomain.com/err") - .party_a("600496") - .send(); - - assert!(response.is_ok()) -} diff --git a/mpesa_core/tests/b2b_test.rs b/mpesa_core/tests/b2b_test.rs deleted file mode 100644 index 0261a2d74..000000000 --- a/mpesa_core/tests/b2b_test.rs +++ /dev/null @@ -1,26 +0,0 @@ -use dotenv; -use mpesa::Mpesa; -use std::env; - -#[test] -fn b2b_test() { - dotenv::dotenv().ok(); - - let client = Mpesa::new( - env::var("CLIENT_KEY").unwrap(), - env::var("CLIENT_SECRET").unwrap(), - "sandbox".parse().unwrap(), - ); - - let response = client - .b2b("testapi496") - .party_a("600496") - .party_b("600000") - .result_url("https://testdomain.com/ok") - .timeout_url("https://testdomain.com/err") - .account_ref("254708374149") - .amount(1000) - .send(); - - assert!(response.is_ok()) -} diff --git a/mpesa_core/tests/b2c_test.rs b/mpesa_core/tests/b2c_test.rs deleted file mode 100644 index 0e84e162f..000000000 --- a/mpesa_core/tests/b2c_test.rs +++ /dev/null @@ -1,25 +0,0 @@ -use dotenv; -use mpesa::{Environment, Mpesa}; -use std::env; - -#[test] -fn b2c_test() { - dotenv::dotenv().ok(); - - let client = Mpesa::new( - env::var("CLIENT_KEY").unwrap(), - env::var("CLIENT_SECRET").unwrap(), - Environment::Sandbox, - ); - - let response = client - .b2c("testapi496") - .party_a("600496") - .party_b("254708374149") - .result_url("https://testdomain.com/ok") - .timeout_url("https://testdomain.com/err") - .amount(1000) - .send(); - - assert!(response.is_ok()) -} diff --git a/mpesa_core/tests/c2b_register_test.rs b/mpesa_core/tests/c2b_register_test.rs deleted file mode 100644 index 62dac1acc..000000000 --- a/mpesa_core/tests/c2b_register_test.rs +++ /dev/null @@ -1,23 +0,0 @@ -use dotenv; -use mpesa::Mpesa; -use std::env; - -#[test] -fn c2b_register_test() { - dotenv::dotenv().ok(); - - let client = Mpesa::new( - env::var("CLIENT_KEY").unwrap(), - env::var("CLIENT_SECRET").unwrap(), - "sandbox".parse().unwrap(), - ); - - let response = client - .c2b_register() - .short_code("600496") - .confirmation_url("https://testdomain.com/true") - .validation_url("https://testdomain.com/valid") - .send(); - - assert!(response.is_ok()) -} diff --git a/mpesa_core/tests/c2b_simulate_test.rs b/mpesa_core/tests/c2b_simulate_test.rs deleted file mode 100644 index ef7175639..000000000 --- a/mpesa_core/tests/c2b_simulate_test.rs +++ /dev/null @@ -1,23 +0,0 @@ -use dotenv; -use mpesa::{Environment, Mpesa}; -use std::env; - -#[test] -fn c2b_simulate_test() { - dotenv::dotenv().ok(); - - let client = Mpesa::new( - env::var("CLIENT_KEY").unwrap(), - env::var("CLIENT_SECRET").unwrap(), - Environment::Sandbox, - ); - - let response = client - .c2b_simulate() - .short_code("600496") - .msisdn("254700000000") - .amount(1000) - .send(); - - assert!(response.is_ok()) -} diff --git a/mpesa_core/tests/stk_push_test.rs b/mpesa_core/tests/stk_push_test.rs deleted file mode 100644 index a3d483272..000000000 --- a/mpesa_core/tests/stk_push_test.rs +++ /dev/null @@ -1,23 +0,0 @@ -use dotenv; -use mpesa::Mpesa; -use std::env; - -#[test] -fn stk_push_test() { - dotenv::dotenv().ok(); - - let client = Mpesa::new( - env::var("CLIENT_KEY").unwrap(), - env::var("CLIENT_SECRET").unwrap(), - "sandbox".parse().unwrap(), - ); - - let response = client - .express_request("174379") - .phone_number("254708374149") - .amount(500) - .callback_url("https://test.example.com/api") - .send(); - - assert!(response.is_ok()) -} diff --git a/mpesa_derive/Cargo.toml b/mpesa_derive/Cargo.toml deleted file mode 100644 index b108612f1..000000000 --- a/mpesa_derive/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "mpesa_derive" -version = "0.3.0" -authors = ["Collins Muriuki "] -edition = "2018" -description = "`MpesaSecurity` trait derive macro crate." -repository = "https://github.com/collinsmuriuki/mpesa-rust" -license = "MIT" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[lib] -proc-macro = true - -[dependencies] -syn = "1.0" -quote = "1.0" \ No newline at end of file diff --git a/mpesa_derive/src/lib.rs b/mpesa_derive/src/lib.rs deleted file mode 100644 index 0a401240a..000000000 --- a/mpesa_derive/src/lib.rs +++ /dev/null @@ -1,44 +0,0 @@ -extern crate proc_macro; - -use proc_macro::TokenStream; -use quote::quote; - -#[proc_macro_derive(MpesaSecurity)] -pub fn mpesa_security_derive(input: TokenStream) -> TokenStream { - let ast = syn::parse(input).expect("Error parsing input"); - - impl_mpesa_security(&ast) -} - -fn impl_mpesa_security(ast: &syn::DeriveInput) -> TokenStream { - let name = &ast.ident; - - let gen = quote! { - use openssl::x509::X509; - use openssl::rsa::Padding; - use base64::encode; - use std::error::Error; - use crate::MpesaError; - - impl MpesaSecurity for #name { - fn gen_security_credentials(&self) -> Result { - let pem = self.environment().get_certificate().as_bytes(); - let cert = X509::from_pem(pem)?; - // getting the public and rsa keys - let pub_key = cert.public_key()?; - let rsa_key = pub_key.rsa()?; - // configuring the buffer - let buf_len = pub_key.size(); - let mut buffer = vec![0; buf_len]; - - rsa_key.public_encrypt( - self.initiator_password().as_bytes(), - &mut buffer, - Padding::PKCS1, - )?; - Ok(encode(buffer)) - } - } - }; - gen.into() -} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 000000000..4392d3073 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,117 @@ +use cached::proc_macro::cached; +use serde::{Deserialize, Serialize}; +use serde_aux::field_attributes::deserialize_number_from_string; + +use crate::{ApiEnvironment, ApiError, Mpesa, MpesaError, MpesaResult}; + +const AUTHENTICATION_URL: &str = "/oauth/v1/generate?grant_type=client_credentials"; + +#[cached( + size = 1, + time = 3600, + key = "String", + result = true, + convert = r#"{ format!("{}", client.client_key()) }"# +)] +pub(crate) async fn auth(client: &Mpesa) -> MpesaResult { + let url = format!("{}{}", client.environment.base_url(), AUTHENTICATION_URL); + + let response = client + .http_client + .get(&url) + .basic_auth(client.client_key(), Some(&client.client_secret())) + .send() + .await?; + + if response.status().is_success() { + let value = response.json::().await?; + let access_token = value.access_token; + + return Ok(access_token); + } + + let error = response.json::().await?; + Err(MpesaError::AuthenticationError(error)) +} + +/// Response returned from the authentication function +#[derive(Debug, Serialize, Deserialize)] +pub struct AuthenticationResponse { + /// Access token which is used as the Bearer-Auth-Token + pub access_token: String, + /// Expiry time in seconds + #[serde(deserialize_with = "deserialize_number_from_string")] + pub expires_in: u64, +} + +impl std::fmt::Display for AuthenticationResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "token :{} expires in: {}", + self.access_token, self.expires_in + ) + } +} + +#[cfg(test)] +mod tests { + use wiremock::{Mock, MockServer}; + + use super::*; + + #[derive(Debug, Clone)] + pub struct TestEnvironment { + pub server_url: String, + } + + impl TestEnvironment { + pub async fn new(server: &MockServer) -> Self { + TestEnvironment { + server_url: server.uri(), + } + } + } + + impl ApiEnvironment for TestEnvironment { + fn base_url(&self) -> &str { + &self.server_url + } + + fn get_certificate(&self) -> &str { + include_str!("../src/certificates/sandbox") + } + } + + #[tokio::test] + async fn test_cached_auth() { + use cached::Cached; + + use crate::Mpesa; + + let server = MockServer::start().await; + + let env = TestEnvironment::new(&server).await; + + let client = Mpesa::new("test_api_key", "test_public_key", env); + + Mock::given(wiremock::matchers::method("GET")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + AuthenticationResponse { + access_token: "test_token".to_string(), + expires_in: 3600, + }, + )) + .expect(1) + .mount(&server) + .await; + + auth_prime_cache(&client).await.unwrap(); + + let mut cache = AUTH.lock().await; + + assert!(cache.cache_get(&client.client_key().to_string()).is_some()); + assert_eq!(cache.cache_hits().unwrap(), 1); + assert_eq!(cache.cache_capacity().unwrap(), 1); + } +} diff --git a/src/certificates/production b/src/certificates/production new file mode 100644 index 000000000..13e811765 --- /dev/null +++ b/src/certificates/production @@ -0,0 +1,38 @@ +-----BEGIN CERTIFICATE----- +MIIGkzCCBXugAwIBAgIKXfBp5gAAAD+hNjANBgkqhkiG9w0BAQsFADBbMRMwEQYK +CZImiZPyLGQBGRYDbmV0MRkwFwYKCZImiZPyLGQBGRYJc2FmYXJpY29tMSkwJwYD +VQQDEyBTYWZhcmljb20gSW50ZXJuYWwgSXNzdWluZyBDQSAwMjAeFw0xNzA0MjUx +NjA3MjRaFw0xODAzMjExMzIwMTNaMIGNMQswCQYDVQQGEwJLRTEQMA4GA1UECBMH +TmFpcm9iaTEQMA4GA1UEBxMHTmFpcm9iaTEaMBgGA1UEChMRU2FmYXJpY29tIExp +bWl0ZWQxEzARBgNVBAsTClRlY2hub2xvZ3kxKTAnBgNVBAMTIGFwaWdlZS5hcGlj +YWxsZXIuc2FmYXJpY29tLmNvLmtlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAoknIb5Tm1hxOVdFsOejAs6veAai32Zv442BLuOGkFKUeCUM2s0K8XEsU +t6BP25rQGNlTCTEqfdtRrym6bt5k0fTDscf0yMCoYzaxTh1mejg8rPO6bD8MJB0c +FWRUeLEyWjMeEPsYVSJFv7T58IdAn7/RhkrpBl1dT7SmIZfNVkIlD35+Cxgab+u7 ++c7dHh6mWguEEoE3NbV7Xjl60zbD/Buvmu6i9EYz+27jNVPI6pRXHvp+ajIzTSsi +eD8Ztz1eoC9mphErasAGpMbR1sba9bM6hjw4tyTWnJDz7RdQQmnsW1NfFdYdK0qD +RKUX7SG6rQkBqVhndFve4SDFRq6wvQIDAQABo4IDJDCCAyAwHQYDVR0OBBYEFG2w +ycrgEBPFzPUZVjh8KoJ3EpuyMB8GA1UdIwQYMBaAFOsy1E9+YJo6mCBjug1evuh5 +TtUkMIIBOwYDVR0fBIIBMjCCAS4wggEqoIIBJqCCASKGgdZsZGFwOi8vL0NOPVNh +ZmFyaWNvbSUyMEludGVybmFsJTIwSXNzdWluZyUyMENBJTIwMDIsQ049U1ZEVDNJ +U1NDQTAxLENOPUNEUCxDTj1QdWJsaWMlMjBLZXklMjBTZXJ2aWNlcyxDTj1TZXJ2 +aWNlcyxDTj1Db25maWd1cmF0aW9uLERDPXNhZmFyaWNvbSxEQz1uZXQ/Y2VydGlm +aWNhdGVSZXZvY2F0aW9uTGlzdD9iYXNlP29iamVjdENsYXNzPWNSTERpc3RyaWJ1 +dGlvblBvaW50hkdodHRwOi8vY3JsLnNhZmFyaWNvbS5jby5rZS9TYWZhcmljb20l +MjBJbnRlcm5hbCUyMElzc3VpbmclMjBDQSUyMDAyLmNybDCCAQkGCCsGAQUFBwEB +BIH8MIH5MIHJBggrBgEFBQcwAoaBvGxkYXA6Ly8vQ049U2FmYXJpY29tJTIwSW50 +ZXJuYWwlMjBJc3N1aW5nJTIwQ0ElMjAwMixDTj1BSUEsQ049UHVibGljJTIwS2V5 +JTIwU2VydmljZXMsQ049U2VydmljZXMsQ049Q29uZmlndXJhdGlvbixEQz1zYWZh +cmljb20sREM9bmV0P2NBQ2VydGlmaWNhdGU/YmFzZT9vYmplY3RDbGFzcz1jZXJ0 +aWZpY2F0aW9uQXV0aG9yaXR5MCsGCCsGAQUFBzABhh9odHRwOi8vY3JsLnNhZmFy +aWNvbS5jby5rZS9vY3NwMAsGA1UdDwQEAwIFoDA9BgkrBgEEAYI3FQcEMDAuBiYr +BgEEAYI3FQiHz4xWhMLEA4XphTaE3tENhqCICGeGwcdsg7m5awIBZAIBDDAdBgNV +HSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwEwJwYJKwYBBAGCNxUKBBowGDAKBggr +BgEFBQcDAjAKBggrBgEFBQcDATANBgkqhkiG9w0BAQsFAAOCAQEAC/hWx7KTwSYr +x2SOyyHNLTRmCnCJmqxA/Q+IzpW1mGtw4Sb/8jdsoWrDiYLxoKGkgkvmQmB2J3zU +ngzJIM2EeU921vbjLqX9sLWStZbNC2Udk5HEecdpe1AN/ltIoE09ntglUNINyCmf +zChs2maF0Rd/y5hGnMM9bX9ub0sqrkzL3ihfmv4vkXNxYR8k246ZZ8tjQEVsKehE +dqAmj8WYkYdWIHQlkKFP9ba0RJv7aBKb8/KP+qZ5hJip0I5Ey6JJ3wlEWRWUYUKh +gYoPHrJ92ToadnFCCpOlLKWc0xVxANofy6fqreOVboPO0qTAYpoXakmgeRNLUiar +0ah6M/q/KA== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/src/certificates/sandbox b/src/certificates/sandbox new file mode 100644 index 000000000..6fd6123bd --- /dev/null +++ b/src/certificates/sandbox @@ -0,0 +1,35 @@ +-----BEGIN CERTIFICATE----- +MIIGKzCCBROgAwIBAgIQDL7NH8cxSdUpl0ihH0A1wTANBgkqhkiG9w0BAQsFADBN +MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMScwJQYDVQQDEx5E +aWdpQ2VydCBTSEEyIFNlY3VyZSBTZXJ2ZXIgQ0EwHhcNMTgwODI3MDAwMDAwWhcN +MTkwNDA0MTIwMDAwWjBuMQswCQYDVQQGEwJLRTEQMA4GA1UEBxMHTmFpcm9iaTEW +MBQGA1UEChMNU2FmYXJpY29tIFBMQzETMBEGA1UECxMKRGlnaXRhbCBJVDEgMB4G +A1UEAxMXc2FuZGJveC5zYWZhcmljb20uY28ua2UwggEiMA0GCSqGSIb3DQEBAQUA +A4IBDwAwggEKAoIBAQC78yeC/wLoZY6TJeqc4g/9eAKIpeCwEsjX09pD8ZxAGXqT +Oi7ssdIGJBPmJZNeEVyf8ocFhisCuLngJ9Z5e/AvH52PhrEFmVu2D03zSf4C+rhZ +ndEKP6G79pUAb/bemOliU9zM8xYYkpCRzPWUzk6zSDarg0ZDLw5FrtZj/VJ9YEDL +WGgAfwExEgSN3wjyUlJ2UwI3wqQXLka0VNFWoZxUH5j436gbSWRIL6NJUmrq8V8S +aTEPz3eJHj3NOToDu245c7VKdF/KExyZjRjD2p5I+Aip80TXzKlZj6DjMb3DlfXF +Hsnu0+1uJE701mvKX7BiscxKr8tCRphL63as4dqvAgMBAAGjggLkMIIC4DAfBgNV +HSMEGDAWgBQPgGEcgjFh1S8o541GOLQs4cbZ4jAdBgNVHQ4EFgQUzZmY7ZORLw9w +qRbAQN5m9lJ28qMwIgYDVR0RBBswGYIXc2FuZGJveC5zYWZhcmljb20uY28ua2Uw +DgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBr +BgNVHR8EZDBiMC+gLaArhilodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vc3NjYS1z +aGEyLWc2LmNybDAvoC2gK4YpaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL3NzY2Et +c2hhMi1nNi5jcmwwTAYDVR0gBEUwQzA3BglghkgBhv1sAQEwKjAoBggrBgEFBQcC +ARYcaHR0cHM6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAIBgZngQwBAgIwfAYIKwYB +BQUHAQEEcDBuMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20w +RgYIKwYBBQUHMAKGOmh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2Vy +dFNIQTJTZWN1cmVTZXJ2ZXJDQS5jcnQwCQYDVR0TBAIwADCCAQUGCisGAQQB1nkC +BAIEgfYEgfMA8QB2AKS5CZC0GFgUh7sTosxncAo8NZgE+RvfuON3zQ7IDdwQAAAB +ZXs1FvEAAAQDAEcwRQIgBzVMkm7SNprjJ1GBqiXIc9rNzY+y7gt6s/O02oMkyFoC +IQDBuThGlpmUKpeZoHhK6HGwB4jDMIecmKaOcMS18R2jxwB3AId1v+dZfPiMQ5lf +vfNu/1aNR1Y2/0q1YMG06v9eoIMPAAABZXs1F8IAAAQDAEgwRgIhAIRq2XFiC+RS +uDCYq8ICJg0QafSV+e9BLpJnElEdaSjiAiEAyiiW4vxwv4cWcAXE6FAipctyUBs6 +bE5QyaCnmNpoDiQwDQYJKoZIhvcNAQELBQADggEBAB0YoWve9Sxhb0PBS3Hc46Rf +a7H1jhHuwE+UyscSQsdJdk8uPAgDuKRZMvJPGEaCkNHm36NfcaXXFjPOl7LI1d1a +9zqSP0xeZBI6cF0x96WuQGrI9/WR2tfxjmaUSp8a/aJ6n+tZA28eJZNPrIaMm+6j +gh7AkKnqcf+g8F/MvCCVdNAiVMdz6UpCscf6BRPHNZ5ifvChGh7aUKjrVLLuF4Ls +HE05qm6HNyV5eTa6wvcbc4ewguN1UDZvPWetSyfBk10Wbpor4znQ4TJ3Y9uCvsJH +41ldblDvZZ2z4kB2UYQ7iBkPlJSxSOaFgW/GGDXq49sz/995xzhVITHxh2SdLkI= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 000000000..d52e5cb53 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,605 @@ +use std::cell::RefCell; + +use cached::Cached; +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, DynamicQR, DynamicQRBuilder, + MpesaExpressRequestBuilder, OnboardBuilder, OnboardModifyBuilder, ReconciliationBuilder, + SingleInvoiceBuilder, TransactionReversalBuilder, TransactionStatusBuilder, +}; +use crate::{auth, MpesaResult}; + +/// Source: [test credentials](https://developer.safaricom.co.ke/test_credentials) +const DEFAULT_INITIATOR_PASSWORD: &str = "Safcom496!"; +/// Get current package version from metadata +const CARGO_PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Mpesa client that will facilitate communication with the Safaricom API +#[derive(Clone, Debug)] +pub struct Mpesa { + client_key: String, + client_secret: Secret, + initiator_password: RefCell>>, + pub(crate) environment: Env, + pub(crate) http_client: HttpClient, +} + +impl<'mpesa, Env: ApiEnvironment> Mpesa { + /// Constructs a new `Mpesa` instance. + /// + /// # Example + /// ```ignore + /// let client: Mpesa = Mpesa::new( + /// env!("CLIENT_KEY"), + /// env!("CLIENT_SECRET"), + /// Environment::Sandbox, + /// ); + /// ``` + /// + /// # Panics + /// This method can panic if a TLS backend cannot be initialized for the internal http_client + pub fn new>(client_key: S, client_secret: S, environment: Env) -> Self { + let http_client = HttpClient::builder() + .connect_timeout(std::time::Duration::from_millis(10_000)) + .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"); + Self { + client_key: client_key.into(), + client_secret: Secret::new(client_secret.into()), + initiator_password: RefCell::new(None), + environment, + http_client, + } + } + + /// Gets the initiator password + /// If `None`, the default password is `"Safcom496!"` + pub(crate) fn initiator_password(&'mpesa self) -> String { + let Some(p) = &*self.initiator_password.borrow() else { + return DEFAULT_INITIATOR_PASSWORD.to_owned(); + }; + p.expose_secret().into() + } + + /// Get the client key + pub(crate) fn client_key(&self) -> &str { + &self.client_key + } + + /// Get the client secret + pub(crate) fn client_secret(&self) -> &str { + self.client_secret.expose_secret() + } + + /// Optional in development but required for production, you will need to call this method and set your production initiator password. + /// If in development, default initiator password is already pre-set + /// ```ignore + /// use mpesa::Mpesa; + /// + /// let client: Mpesa = Mpesa::new( + /// env::var("CLIENT_KEY").unwrap(), + /// env::var("CLIENT_SECRET").unwrap(), + /// Environment::Sandbox, + /// ); + /// + /// client.set_initiator_password("your_initiator_password"); + /// ``` + pub fn set_initiator_password>(&self, initiator_password: S) { + *self.initiator_password.borrow_mut() = Some(Secret::new(initiator_password.into())); + } + + /// Checks if the client can be authenticated + pub async fn is_connected(&self) -> bool { + self.auth().await.is_ok() + } + + /// **Safaricom Oauth** + /// + /// Generates an access token + /// Sends `GET` request to Safaricom oauth to acquire token for token authentication + /// The OAuth access token expires after an hour, after which, you will need to generate another access token + /// + /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/docs#authentication) + /// + /// Returns the auth token as a `String`. + /// + /// # Errors + /// Returns a `MpesaError` on failure + pub(crate) async fn auth(&self) -> MpesaResult { + if let Some(token) = AUTH.lock().await.cache_get(&self.client_key) { + return Ok(token.to_owned()); + } + + // Generate a new access token + let new_token = match auth::auth_prime_cache(self).await { + Ok(token) => token, + Err(e) => return Err(e), + }; + + // Double-check if the access token is cached by another thread + if let Some(token) = AUTH.lock().await.cache_get(&self.client_key) { + return Ok(token.to_owned()); + } + + // Cache the new token + AUTH.lock() + .await + .cache_set(self.client_key.clone(), new_token.to_owned()); + + Ok(new_token) + } + + /// **B2C Builder** + /// + /// Creates a `B2cBuilder` for building a B2C transaction struct. + /// The builder is consumed and request made by calling its `send` method. + /// See more from Safaricom the API docs [here](https://developer.safaricom.co.ke/docs?shell#b2c-api). + /// + /// Requires an `initiator_name`, the credential/ username used to authenticate the transaction request + /// + /// # Example + /// ```ignore + /// let response = client + /// .b2c("testapi496") + /// .party_a("600496") + /// .party_b("600000") + /// .result_url("https://testdomain.com/err") + /// .timeout_url("https://testdomain.com/ok") + /// .amount(1000) + /// .remarks("Your Remark") // optional, defaults to "None" + /// .occasion("Your Occasion") // optional, defaults to "None" + /// .command_id(mpesa::CommandId::BusinessPayment) // optional, defaults to `CommandId::BusinessPayment` + /// .send(); + /// ``` + #[cfg(feature = "b2c")] + pub fn b2c(&'mpesa self, initiator_name: &'mpesa str) -> B2cBuilder<'mpesa, Env> { + B2cBuilder::new(self, initiator_name) + } + + /// **B2B Builder** + /// + /// Creates a `B2bBuilder` for building B2B transaction struct. + /// + /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/docs#b2b-api) + /// + /// Requires an `initiator_name`, the credential/ username used to authenticate the transaction request + /// + /// # Example + /// ```ignore + /// let response = client.b2b("testapi496") + /// .party_a("600496") + /// .party_b("600000") + /// .result_url("https://testdomain.com/err") + /// .timeout_url("https://testdomain.com/ok") + /// .account_ref("254708374149") + /// .amount(1000) + /// .command_id(mpesa::CommandId::BusinessToBusinessTransfer) // optional, defaults to `CommandId::BusinessToBusinessTransfer` + /// .remarks("None") // optional, defaults to "None" + /// .sender_id(mpesa::IdentifierTypes::ShortCode) // optional, defaults to `IdentifierTypes::ShortCode` + /// .receiver_id(mpesa::IdentifierTypes::ShortCode) // optional, defaults to `IdentifierTypes::ShortCode` + /// .send(); + /// ``` + #[cfg(feature = "b2b")] + pub fn b2b(&'mpesa self, initiator_name: &'mpesa str) -> B2bBuilder<'mpesa, Env> { + B2bBuilder::new(self, initiator_name) + } + + /// **Bill Manager Onboard Builder** + /// + /// Creates a `OnboardBuilder` which allows you to opt in as a biller to the bill manager features. + /// + /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/APIs/BillManager) + /// + /// # Example + /// ```ignore + /// let response = client + /// .onboard() + /// .callback_url("https://testdomain.com/true") + /// .email("email@test.com") + /// .logo("https://file.domain/file.png") + /// .official_contact("0712345678") + /// .send_reminders(SendRemindersTypes::Enable) + /// .short_code("600496") + /// .send() + /// .await; + /// ``` + #[cfg(feature = "bill_manager")] + pub fn onboard(&'mpesa self) -> OnboardBuilder<'mpesa, Env> { + OnboardBuilder::new(self) + } + + /// **Bill Manager Onboard Modify Builder** + /// + /// Creates a `OnboardModifyBuilder` which allows you to opt in as a biller to the bill manager features. + /// + /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/APIs/BillManager) + /// + /// # Example + /// ```ignore + /// let response = client + /// .onboard_modify() + /// .callback_url("https://testdomain.com/true") + /// .email("email@test.com") + /// .logo("https://file.domain/file.png") + /// .official_contact("0712345678") + /// .send_reminders(SendRemindersTypes::Enable) + /// .short_code("600496") + /// .send() + /// .await; + /// ``` + #[cfg(feature = "bill_manager")] + pub fn onboard_modify(&'mpesa self) -> OnboardModifyBuilder<'mpesa, Env> { + OnboardModifyBuilder::new(self) + } + + /// **Bill Manager Bulk Invoice Builder** + /// + /// Creates a `BulkInvoiceBuilder` which allows you to send invoices to your customers in bulk. + /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/APIs/BillManager) + /// + /// # Example + /// ```ignore + /// use chrone::prelude::Utc; + /// + /// let response = client + /// .bulk_invoice() + /// + /// // Add multiple invoices at once + /// .invoices(vec![ + /// Invoice { + /// amount: 1000.0, + /// account_reference: "John Doe", + /// billed_full_name: "John Doe", + /// billed_period: "August 2021", + /// billed_phone_number: "0712345678", + /// due_date: Utc::now(), + /// external_reference: "INV2345", + /// invoice_items: Some( + /// vec![InvoiceItem {amount: 1000.0, item_name: "An item"}] + /// ), + /// invoice_name: "Invoice 001" + /// } + /// ]) + /// + /// // Add a single invoice + /// .invoice( + /// Invoice { + /// amount: 1000.0, + /// account_reference: "John Doe", + /// billed_full_name: "John Doe", + /// billed_period: "August 2021", + /// billed_phone_number: "0712345678", + /// due_date: Utc::now(), + /// external_reference: "INV2345", + /// invoice_items: Some(vec![InvoiceItem { + /// amount: 1000.0, + /// item_name: "An item", + /// }]), + /// invoice_name: "Invoice 001", + /// } + /// ) + /// .send() + /// .await; + /// ``` + #[cfg(feature = "bill_manager")] + pub fn bulk_invoice(&'mpesa self) -> BulkInvoiceBuilder<'mpesa, Env> { + BulkInvoiceBuilder::new(self) + } + + /// **Bill Manager Single Invoice Builder** + /// + /// Creates a `SingleInvoiceBuilder` which allows you to create and send invoices to your customers. + /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/APIs/BillManager) + /// + /// # Example + /// ```ignore + /// use chrono::prelude::Utc; + /// + /// let response = client + /// .single_invoice() + /// .amount(1000.0) + /// .account_reference("John Doe") + /// .billed_full_name("John Doe") + /// .billed_period("August 2021") + /// .billed_phone_number("0712345678") + /// .due_date(Utc::now()) + /// .external_reference("INV2345") + /// .invoice_items(vec![ + /// InvoiceItem {amount: 1000.0, item_name: "An item"} + /// ]) + /// .invoice_name("Invoice 001") + /// .send() + /// .await; + /// ``` + #[cfg(feature = "bill_manager")] + pub fn single_invoice(&'mpesa self) -> SingleInvoiceBuilder<'mpesa, Env> { + SingleInvoiceBuilder::new(self) + } + + /// **Bill Manager Reconciliation Builder** + /// + /// Creates a `ReconciliationBuilder` which enables your customers to receive e-receipts for payments made to your paybill account. + /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/APIs/BillManager) + /// + /// # Example + /// ```ignore + /// use chrono::prelude::Utc; + /// + /// let response = client + /// .reconciliation() + /// .account_reference("John Doe") + /// .external_reference("INV2345") + /// .full_name("John Doe") + /// .invoice_name("Invoice 001") + /// .paid_amount(1000.0) + /// .payment_date(Utc::now()) + /// .phone_number("0712345678") + /// .transaction_id("TRANSACTION_ID") + /// .send() + /// .await; + /// ``` + #[cfg(feature = "bill_manager")] + pub fn reconciliation(&'mpesa self) -> ReconciliationBuilder<'mpesa, Env> { + ReconciliationBuilder::new(self) + } + + /// **Bill Manager Cancel Invoice Builder** + /// + /// Creates a `CancelInvoiceBuilder` which allows you to recall a sent invoice. + /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/APIs/BillManager) + /// + /// # Example + /// ```ignore + /// use chrono::prelude::Utc; + /// + /// let response = client + /// .cancel_invoice() + /// .external_references(vec!["9KLSS011"]) + /// .send() + /// .await; + /// ``` + #[cfg(feature = "bill_manager")] + pub fn cancel_invoice(&'mpesa self) -> CancelInvoiceBuilder<'mpesa, Env> { + CancelInvoiceBuilder::new(self) + } + + /// **C2B Register builder** + /// + /// Creates a `C2bRegisterBuilder` for registering URLs to the 3rd party shortcode. + /// + /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/docs?shell#c2b-api) + /// + /// # Example + /// ```ignore + /// let response = client + /// .c2b_register() + /// .short_code("600496") + /// .confirmation_url("https://testdomain.com/true") + /// .validation_url("https://testdomain.com/valid") + /// .response_type(mpesa::ResponseTypes::Complete) // optional, defaults to `ResponseTypes::Complete` + /// .send(); + /// ``` + #[cfg(feature = "c2b_register")] + pub fn c2b_register(&'mpesa self) -> C2bRegisterBuilder<'mpesa, Env> { + C2bRegisterBuilder::new(self) + } + + /// **C2B Simulate Builder** + /// + /// Creates a `C2bSimulateBuilder` for simulating C2B transactions + /// + /// See more [here](https://developer.safaricom.co.ke/c2b/apis/post/simulate) + /// + /// # Example + /// ```ignore + /// 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(); + /// ``` + #[cfg(feature = "c2b_simulate")] + pub fn c2b_simulate(&'mpesa self) -> C2bSimulateBuilder<'mpesa, Env> { + C2bSimulateBuilder::new(self) + } + + /// **Account Balance Builder** + /// + /// Creates an `AccountBalanceBuilder` for enquiring the balance on an MPESA BuyGoods. + /// Requires an `initiator_name`. + /// + /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/docs#account-balance-api) + /// + /// # Example + /// ```ignore + /// let response = client + /// .account_balance("testapi496") + /// .result_url("https://testdomain.com/err") + /// .timeout_url("https://testdomain.com/ok") + /// .party_a("600496") + /// .command_id(mpesa::CommandId::AccountBalance) // optional, defaults to `CommandId::AccountBalance` + /// .identifier_type(mpesa::IdentifierTypes::ShortCode) // optional, defaults to `IdentifierTypes::ShortCode` + /// .remarks("Your Remarks") // optional, defaults to "None" + /// .send(); + /// ``` + #[cfg(feature = "account_balance")] + pub fn account_balance( + &'mpesa self, + initiator_name: &'mpesa str, + ) -> AccountBalanceBuilder<'mpesa, Env> { + AccountBalanceBuilder::new(self, initiator_name) + } + + /// **Mpesa Express Request/ STK push Builder** + /// + /// Creates a `MpesaExpressRequestBuilder` struct + /// Requires a `business_short_code` - The organization shortcode used to receive the transaction + /// + /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/docs#lipa-na-m-pesa-online-payment) + /// + /// # Example + ///```ignore + /// let response = client + /// .express_request("174379") + /// .phone_number("254708374149") + /// .party_a("254708374149") + /// .party_b("174379") + /// .amount(500) + /// .callback_url("https://test.example.com/api") + /// .transaction_type(CommandId::CustomerPayBillOnline) // Optional, defaults to `CommandId::CustomerPayBillOnline` + /// .transaction_desc("Description") // Optional, defaults to "None" + /// .send(); + /// ``` + #[cfg(feature = "express_request")] + pub fn express_request( + &'mpesa self, + business_short_code: &'mpesa str, + ) -> MpesaExpressRequestBuilder<'mpesa, Env> { + MpesaExpressRequestBuilder::new(self, business_short_code) + } + + ///**Transaction Reversal Builder** + /// Reverses a B2B, B2C or C2B M-Pesa transaction. + /// + /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/Documentation) + #[cfg(feature = "transaction_reversal")] + pub fn transaction_reversal( + &'mpesa self, + initiator_name: &'mpesa str, + ) -> TransactionReversalBuilder<'mpesa, Env> { + TransactionReversalBuilder::new(self, initiator_name) + } + + ///**Transaction Status Builder** + /// Queries the status of a B2B, B2C or C2B M-Pesa transaction. + /// + /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/Documentation) + /// # Example + /// ```ignore + /// let response = client + /// .transaction_status("testapi496") + /// .party_a("600496") + /// .identifier_type(mpesa::IdentifierTypes::ShortCode) // optional, defaults to `IdentifierTypes::ShortCode` + /// .remarks("Your Remarks") // optional, defaults to "None" + /// .result_url("https://testdomain.com/err") + /// .timeout_url("https://testdomain.com/ok") + /// .send() + /// .await; + /// ``` + #[cfg(feature = "transaction_status")] + pub fn transaction_status( + &'mpesa self, + initiator_name: &'mpesa str, + ) -> TransactionStatusBuilder<'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. + /// Returns base64 encoded string. + /// + /// # Errors + /// Returns `EncryptionError` variant of `MpesaError` + pub(crate) fn gen_security_credentials(&self) -> MpesaResult { + let pem = self.environment.get_certificate().as_bytes(); + let cert = X509::from_pem(pem)?; + // getting the public and rsa keys + let pub_key = cert.public_key()?; + let rsa_key = pub_key.rsa()?; + // configuring the buffer + let buf_len = pub_key.size(); + let mut buffer = vec![0; buf_len]; + + rsa_key.public_encrypt( + self.initiator_password().as_bytes(), + &mut buffer, + Padding::PKCS1, + )?; + Ok(base64::encode_block(&buffer)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Sandbox; + + #[test] + fn test_setting_initator_password() { + let client = Mpesa::new("client_key", "client_secret", Sandbox); + assert_eq!(client.initiator_password(), DEFAULT_INITIATOR_PASSWORD); + client.set_initiator_password("foo_bar"); + assert_eq!(client.initiator_password(), "foo_bar".to_string()); + } + + #[derive(Clone)] + struct TestEnvironment; + + impl ApiEnvironment for TestEnvironment { + fn base_url(&self) -> &str { + "https://example.com" + } + + fn get_certificate(&self) -> &str { + // not a valid pem + "certificate" + } + } + + #[test] + fn test_custom_environment() { + let client = Mpesa::new("client_key", "client_secret", TestEnvironment); + assert_eq!(client.environment.base_url(), "https://example.com"); + assert_eq!(client.environment.get_certificate(), "certificate"); + } + + #[test] + #[should_panic] + fn test_gen_security_credentials_fails_with_invalid_pem() { + let client = Mpesa::new("client_key", "client_secret", TestEnvironment); + let _ = client.gen_security_credentials().unwrap(); + } +} diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 000000000..756fdc996 --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,180 @@ +use std::fmt::{Display, Formatter, Result as FmtResult}; + +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 { + TransactionReversal, + SalaryPayment, + BusinessPayment, + PromotionPayment, + AccountBalance, + CustomerPayBillOnline, + TransactionStatusQuery, + CheckIdentity, + BusinessPayBill, + BusinessBuyGoods, + DisburseFundsToBusiness, + BusinessToBusinessTransfer, + BusinessTransferFromMMFToUtility, +} + +impl Display for CommandId { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + write!(f, "{self:?}") + } +} + +/// Identifier types - both sender and receiver - identify an M-Pesa transaction’s sending and receiving party as +/// either a shortcode, a till number or a MSISDN (phone number). +/// There are three identifier types that can be used with M-Pesa APIs. +#[derive(Debug, Serialize_repr, Deserialize_repr, Copy, Clone)] +#[repr(u16)] +pub enum IdentifierTypes { + MSISDN = 1, + TillNumber = 2, + ShortCode = 4, +} + +impl Display for IdentifierTypes { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + write!(f, "{:?}", *self as u16) + } +} + +/// TODO: Enable deserializing of json numbers/ strings to `MpesaResponseCode` +/// M-pesa result and response codes +#[derive(Debug, Copy, Clone, Deserialize_repr)] +#[repr(u16)] +#[allow(unused)] +pub enum MpesaResponseCode { + Success = 0, + InsufficientFunds = 1, + LessThanMinimum = 2, + MoreThanMaximum = 3, + ExceededDailyLimit = 4, + ExceededMinimumBalance = 5, + UnresolvedPrimaryParty = 6, + UnresolvedReceiverParty = 7, + ExceededMaximumBalance = 8, + InvalidDebitAccount = 11, + InvalidCreditAccount = 12, + UnresolvedDebitAccount = 13, + UnresolvedCreditAccount = 14, + DuplicateDetected = 15, + InternalFailure = 17, + UnresolvedInitiator = 20, + TrafficBlocking = 26, +} + +impl Display for MpesaResponseCode { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + write!(f, "{:?}", *self as u16) + } +} + +#[derive(Debug, Serialize, Deserialize)] +/// C2B Register Response types +pub enum ResponseType { + Completed, + Cancelled, +} + +impl Display for ResponseType { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + write!(f, "{self:?}") + } +} + +#[derive(Debug, Deserialize_repr, Serialize_repr, Copy, Clone)] +#[repr(u16)] +pub enum SendRemindersTypes { + Disable = 0, + Enable = 1, +} + +impl Display for SendRemindersTypes { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + write!(f, "{:?}", *self as u16) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Invoice<'i> { + pub amount: f64, + pub account_reference: &'i str, + pub billed_full_name: &'i str, + pub billed_period: &'i str, + pub billed_phone_number: &'i str, + pub due_date: DateTime, + pub external_reference: &'i str, + #[serde(skip_serializing_if = "Option::is_none")] + pub invoice_items: Option>>, + pub invoice_name: &'i str, +} + +impl<'i> Display for Invoice<'i> { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + write!( + f, + "amount: {}, account_reference: {}, due_date: {}, invoice_name: {}", + self.amount, + self.account_reference, + self.due_date.format("%Y-%m-%d"), + self.invoice_name, + ) + } +} + +#[derive(Debug, Serialize)] +pub struct InvoiceItem<'i> { + pub amount: f64, + pub item_name: &'i str, +} + +impl<'i> Display for InvoiceItem<'i> { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + write!(f, "amount: {}, item_name: {}", self.amount, self.item_name) + } +} + +#[derive(Debug, Clone, Copy, Serialize)] +pub enum TransactionType { + /// Send Money(Mobile number). + SendMoney, + /// Withdraw Cash at Agent Till + Withdraw, + /// Pay Merchant (Buy Goods) + BG, + /// Paybill or Business number + PayBill, + /// Sent to Business. Business number CPI in MSISDN format. + SendBusiness, +} + +impl Display for TransactionType { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + write!(f, "{self:?}") + } +} + +impl TryFrom<&str> for TransactionType { + type Error = MpesaError; + + fn try_from(value: &str) -> Result { + match value.to_lowercase().as_str() { + "bg" => Ok(TransactionType::BG), + "wa" => Ok(TransactionType::Withdraw), + "pb" => Ok(TransactionType::PayBill), + "sm" => Ok(TransactionType::SendMoney), + "sb" => Ok(TransactionType::SendBusiness), + _ => Err(MpesaError::Message("Invalid transaction type")), + } + } +} diff --git a/src/environment.rs b/src/environment.rs new file mode 100644 index 000000000..84ee51c86 --- /dev/null +++ b/src/environment.rs @@ -0,0 +1,124 @@ +//!# MPESA Environment +//! +//! Code related to setting up the desired Safaricom API environment. Environment can be either +//! sandbox or production. +//! you will need environment specific credentials (`CLIENT_KEY` AND `CLIENT_SECRET`) when creating +//! an instance of the `Mpesa` client struct. Note that you cannot use sandbox credentials in +//! production and vice versa. +//! +//! Based on selected environment. You are able to access environment specific data such as the `base_url` +//! and the `public key` an X509 certificate used for encrypting initiator passwords. You can read more about that from +//! the Safaricom API [docs](https://developer.safaricom.co.ke/docs?javascript#security-credentials). + +use std::convert::TryFrom; +use std::str::FromStr; + +use crate::MpesaError; + +#[derive(Debug, Clone)] +/// Enum to map to desired environment so as to access certificate +/// and the base url +/// Required to construct a new `Mpesa` struct +pub enum Environment { + /// Production environment + Production, + /// Sandbox environment: for testing and development purposes + Sandbox, +} + +/// 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: Clone { + fn base_url(&self) -> &str; + fn get_certificate(&self) -> &str; +} + +macro_rules! environment_from_string { + ($v:expr) => { + match $v { + "production" => Ok(Self::Production), + "sandbox" => Ok(Self::Sandbox), + _ => Err(MpesaError::Message( + "Could not parse the provided environment name", + )), + } + }; +} + +impl FromStr for Environment { + type Err = MpesaError; + + fn from_str(s: &str) -> Result { + environment_from_string!(s.to_lowercase().as_str()) + } +} + +impl TryFrom<&str> for Environment { + type Error = MpesaError; + + fn try_from(v: &str) -> Result { + environment_from_string!(v.to_lowercase().as_str()) + } +} + +impl TryFrom for Environment { + type Error = MpesaError; + + fn try_from(v: String) -> Result { + environment_from_string!(v.to_lowercase().as_str()) + } +} + +impl ApiEnvironment for Environment { + /// Matches to base_url based on `Environment` variant + fn base_url(&self) -> &str { + match self { + Environment::Production => "https://api.safaricom.co.ke", + Environment::Sandbox => "https://sandbox.safaricom.co.ke", + } + } + + /// Match to X509 public key certificate based on `Environment` + fn get_certificate(&self) -> &str { + match self { + Environment::Production => include_str!("./certificates/production"), + Environment::Sandbox => include_str!("./certificates/sandbox"), + } + } +} + +#[cfg(test)] +mod tests { + use std::convert::TryInto; + + use super::*; + + #[test] + fn test_valid_string_is_parsed_as_environment() { + let accepted_production_values = + vec!["production", "Production", "PRODUCTION", "prODUctIoN"]; + let accepted_sandbox_values = vec!["sandbox", "Sandbox", "SANDBOX", "sanDBoX"]; + accepted_production_values.into_iter().for_each(|v| { + let environment: Environment = v.parse().unwrap(); + assert_eq!(environment.base_url(), "https://api.safaricom.co.ke"); + assert_eq!( + environment.get_certificate(), + include_str!("./certificates/production") + ) + }); + accepted_sandbox_values.into_iter().for_each(|v| { + let environment: Environment = v.try_into().unwrap(); + assert_eq!(environment.base_url(), "https://sandbox.safaricom.co.ke"); + assert_eq!( + environment.get_certificate(), + include_str!("./certificates/sandbox") + ) + }) + } + + #[test] + #[should_panic] + fn test_invalid_string_panics() { + let _: Environment = "foo_bar".try_into().unwrap(); + } +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 000000000..6b2489d73 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,95 @@ +use std::env::VarError; +use std::fmt; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Mpesa error stack +#[derive(Error, Debug)] +pub enum MpesaError { + #[error("{0}")] + AuthenticationError(ApiError), + #[error("B2B request failed: {0}")] + B2bError(ApiError), + #[error("B2C request failed: {0}")] + B2cError(ApiError), + #[error("C2B register request failed: {0}")] + C2bRegisterError(ApiError), + #[error("C2B simulate request failed: {0}")] + C2bSimulateError(ApiError), + #[error("Account Balance request failed: {0}")] + AccountBalanceError(ApiError), + #[error("Bill manager onboarding failed: {0}")] + OnboardError(ApiError), + #[error("Bill manager onboarding modify failed: {0}")] + OnboardModifyError(ApiError), + #[error("Bill manager bulk invoice failed: {0}")] + BulkInvoiceError(ApiError), + #[error("Bill manager reconciliation failed: {0}")] + ReconciliationError(ApiError), + #[error("Bill manager single invoice failed: {0}")] + SingleInvoiceError(ApiError), + #[error("Bill manager cancel invoice failed: {0}")] + CancelInvoiceError(ApiError), + #[error("Mpesa Express request/ STK push failed: {0}")] + MpesaExpressRequestError(ApiError), + #[error("Mpesa Transaction reversal failed: {0}")] + 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")] + ParseError(#[from] serde_json::Error), + #[error("An error has occured while retreiving an environmental variable")] + EnvironmentalVariableError(#[from] VarError), + #[error("An error has occurred while generating security credentials")] + 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 +pub type MpesaResult = Result; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all(deserialize = "camelCase"))] +pub struct ApiError { + pub request_id: String, + pub error_code: String, + pub error_message: String, +} + +impl fmt::Display for ApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "requestID: {}, errorCode:{}, errorMessage:{}", + self.request_id, self.error_code, self.error_message + ) + } +} + +#[derive(Debug, Error)] +pub enum BuilderError { + #[error("Field [{0}] is required")] + UninitializedField(&'static str), + #[error("Field [{0}] is invalid")] + ValidationError(String), +} + +impl From for BuilderError { + fn from(s: String) -> Self { + Self::ValidationError(s) + } +} + +impl From for MpesaError { + fn from(e: derive_builder::UninitializedFieldError) -> Self { + Self::BuilderError(BuilderError::UninitializedField(e.field_name())) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 000000000..240eb0a5d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,17 @@ +#![doc = include_str!("../README.md")] + +mod auth; +mod client; +mod constants; +pub mod environment; +mod errors; +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}; +pub use errors::{ApiError, MpesaError, MpesaResult}; diff --git a/mpesa_core/src/services/account_balance.rs b/src/services/account_balance.rs similarity index 63% rename from mpesa_core/src/services/account_balance.rs rename to src/services/account_balance.rs index 2beb17a5a..6ed46db88 100644 --- a/mpesa_core/src/services/account_balance.rs +++ b/src/services/account_balance.rs @@ -1,28 +1,28 @@ -use crate::client::MpesaResult; -use crate::constants::{CommandId, IdentifierTypes}; -use crate::{Mpesa, MpesaError, MpesaSecurity}; use serde::{Deserialize, Serialize}; -use serde_json::Value; + +use crate::constants::{CommandId, IdentifierTypes}; +use crate::environment::ApiEnvironment; +use crate::{Mpesa, MpesaError, MpesaResult}; #[derive(Debug, Serialize)] /// Account Balance payload -struct AccountBalancePayload<'a> { +struct AccountBalancePayload<'mpesa> { #[serde(rename(serialize = "Initiator"))] - initiator: &'a str, + initiator: &'mpesa str, #[serde(rename(serialize = "SecurityCredential"))] - security_credential: &'a str, + security_credential: &'mpesa str, #[serde(rename(serialize = "CommandID"))] command_id: CommandId, #[serde(rename(serialize = "PartyA"))] - party_a: &'a str, + party_a: &'mpesa str, #[serde(rename(serialize = "IdentifierType"))] - identifier_type: &'a str, + identifier_type: &'mpesa str, #[serde(rename(serialize = "Remarks"))] - remarks: &'a str, + remarks: &'mpesa str, #[serde(rename(serialize = "QueueTimeOutURL"))] - queue_time_out_url: &'a str, + queue_time_out_url: &'mpesa str, #[serde(rename(serialize = "ResultURL"))] - result_url: &'a str, + result_url: &'mpesa str, } #[derive(Debug, Deserialize, Clone)] @@ -37,21 +37,24 @@ pub struct AccountBalanceResponse { pub response_description: String, } #[derive(Debug)] -pub struct AccountBalanceBuilder<'a> { - initiator_name: &'a str, - client: &'a Mpesa, +pub struct AccountBalanceBuilder<'mpesa, Env: ApiEnvironment> { + initiator_name: &'mpesa str, + client: &'mpesa Mpesa, command_id: Option, - party_a: Option<&'a str>, + party_a: Option<&'mpesa str>, identifier_type: Option, - remarks: Option<&'a str>, - queue_timeout_url: Option<&'a str>, - result_url: Option<&'a str>, + remarks: Option<&'mpesa str>, + queue_timeout_url: Option<&'mpesa str>, + result_url: Option<&'mpesa str>, } -impl<'a> AccountBalanceBuilder<'a> { +impl<'mpesa, Env: ApiEnvironment> AccountBalanceBuilder<'mpesa, Env> { /// Creates a new `AccountBalanceBuilder`. /// Requires an `initiator_name`, the credential/ username used to authenticate the transaction request - pub fn new(client: &'a Mpesa, initiator_name: &'a str) -> AccountBalanceBuilder<'a> { + pub fn new( + client: &'mpesa Mpesa, + initiator_name: &'mpesa str, + ) -> AccountBalanceBuilder<'mpesa, Env> { AccountBalanceBuilder { initiator_name, client, @@ -69,7 +72,7 @@ impl<'a> AccountBalanceBuilder<'a> { /// /// # Errors /// If `CommandId` is invalid - pub fn command_id(mut self, command_id: CommandId) -> AccountBalanceBuilder<'a> { + pub fn command_id(mut self, command_id: CommandId) -> AccountBalanceBuilder<'mpesa, Env> { self.command_id = Some(command_id); self } @@ -79,7 +82,7 @@ impl<'a> AccountBalanceBuilder<'a> { /// /// # Errors /// If `Party A` is not provided or invalid - pub fn party_a(mut self, party_a: &'a str) -> AccountBalanceBuilder<'a> { + pub fn party_a(mut self, party_a: &'mpesa str) -> AccountBalanceBuilder<'mpesa, Env> { self.party_a = Some(party_a); self } @@ -92,14 +95,14 @@ impl<'a> AccountBalanceBuilder<'a> { pub fn identifier_type( mut self, identifier_type: IdentifierTypes, - ) -> AccountBalanceBuilder<'a> { + ) -> AccountBalanceBuilder<'mpesa, Env> { self.identifier_type = Some(identifier_type); self } /// Adds `Remarks`, a comment sent along transaction. /// Optional field that defaults to `"None"` if no value is provided - pub fn remarks(mut self, remarks: &'a str) -> AccountBalanceBuilder<'a> { + pub fn remarks(mut self, remarks: &'mpesa str) -> AccountBalanceBuilder<'mpesa, Env> { self.remarks = Some(remarks); self } @@ -108,7 +111,7 @@ impl<'a> AccountBalanceBuilder<'a> { /// /// # Error /// If `QueueTimeoutUrl` is invalid or not provided - pub fn timeout_url(mut self, timeout_url: &'a str) -> AccountBalanceBuilder<'a> { + pub fn timeout_url(mut self, timeout_url: &'mpesa str) -> AccountBalanceBuilder<'mpesa, Env> { self.queue_timeout_url = Some(timeout_url); self } @@ -117,7 +120,7 @@ impl<'a> AccountBalanceBuilder<'a> { /// /// # Error /// If `ResultUrl` is invalid or not provided - pub fn result_url(mut self, result_url: &'a str) -> AccountBalanceBuilder<'a> { + pub fn result_url(mut self, result_url: &'mpesa str) -> AccountBalanceBuilder<'mpesa, Env> { self.result_url = Some(result_url); self } @@ -127,39 +130,50 @@ impl<'a> AccountBalanceBuilder<'a> { /// # Error /// If either `QueueTimeoutUrl` and `ResultUrl` is invalid or not provided #[deprecated] - pub fn urls(mut self, timeout_url: &'a str, result_url: &'a str) -> AccountBalanceBuilder<'a> { + pub fn urls( + mut self, + timeout_url: &'mpesa str, + result_url: &'mpesa str, + ) -> AccountBalanceBuilder<'mpesa, Env> { self.queue_timeout_url = Some(timeout_url); self.result_url = Some(result_url); self } - /// **AccountBalance API** + /// # AccountBalance API /// /// Enquire the balance on an M-Pesa BuyGoods (Till Number). - /// A successful request returns a `serde_json::Value` type. + /// A successful request returns a `C2bRegisterResponse` type. /// See more [here](https://developer.safaricom.co.ke/docs#account-balance-api) /// /// # Errors /// Returns a `MpesaError` on failure - pub fn send(self) -> MpesaResult { + #[allow(clippy::unnecessary_lazy_evaluations)] + pub async fn send(self) -> MpesaResult { let url = format!( "{}/mpesa/accountbalance/v1/query", - self.client.environment().base_url() + self.client.environment.base_url() ); let credentials = self.client.gen_security_credentials()?; let payload = AccountBalancePayload { - command_id: self.command_id.unwrap_or(CommandId::AccountBalance), - party_a: self.party_a.unwrap_or("None"), + command_id: self.command_id.unwrap_or_else(|| CommandId::AccountBalance), + party_a: self + .party_a + .ok_or(MpesaError::Message("party_a is required"))?, identifier_type: &self .identifier_type - .unwrap_or(IdentifierTypes::ShortCode) + .unwrap_or_else(|| IdentifierTypes::ShortCode) .to_string(), - remarks: self.remarks.unwrap_or("None"), + remarks: self.remarks.unwrap_or_else(|| stringify!(None)), initiator: self.initiator_name, - queue_time_out_url: self.queue_timeout_url.unwrap_or("None"), - result_url: self.result_url.unwrap_or("None"), + queue_time_out_url: self + .queue_timeout_url + .ok_or(MpesaError::Message("queue_timeout_url is required"))?, + result_url: self + .result_url + .ok_or(MpesaError::Message("result_url is required"))?, security_credential: &credentials, }; @@ -167,17 +181,17 @@ impl<'a> AccountBalanceBuilder<'a> { .client .http_client .post(&url) - .bearer_auth(self.client.auth()?) + .bearer_auth(self.client.auth().await?) .json(&payload) - .send()? - .error_for_status()?; + .send() + .await?; if response.status().is_success() { - let value: AccountBalanceResponse = response.json()?; + let value = response.json::<_>().await?; return Ok(value); } - let value: Value = response.json()?; + let value = response.json().await?; Err(MpesaError::AccountBalanceError(value)) } } diff --git a/mpesa_core/src/services/b2b.rs b/src/services/b2b.rs similarity index 61% rename from mpesa_core/src/services/b2b.rs rename to src/services/b2b.rs index a78cac808..431e07a70 100644 --- a/mpesa_core/src/services/b2b.rs +++ b/src/services/b2b.rs @@ -1,36 +1,45 @@ -use crate::client::{Mpesa, MpesaResult}; -use crate::constants::{CommandId, IdentifierTypes}; -use crate::errors::MpesaError; -use crate::MpesaSecurity; use serde::{Deserialize, Serialize}; -use serde_json::Value; + +use crate::client::Mpesa; +use crate::constants::{CommandId, IdentifierTypes}; +use crate::environment::ApiEnvironment; +use crate::errors::{MpesaError, MpesaResult}; #[derive(Debug, Serialize)] -struct B2bPayload<'a> { +struct B2bPayload<'mpesa> { #[serde(rename(serialize = "Initiator"))] - initiator: &'a str, + initiator: &'mpesa str, #[serde(rename(serialize = "SecurityCredential"))] - security_credential: &'a str, + security_credential: &'mpesa str, #[serde(rename(serialize = "CommandID"))] command_id: CommandId, #[serde(rename(serialize = "Amount"))] - amount: u32, + amount: f64, #[serde(rename(serialize = "PartyA"))] - party_a: &'a str, + party_a: &'mpesa str, #[serde(rename(serialize = "SenderIdentifierType"))] - sender_identifier_type: &'a str, + sender_identifier_type: &'mpesa str, #[serde(rename(serialize = "PartyB"))] - party_b: &'a str, + party_b: &'mpesa str, #[serde(rename(serialize = "RecieverIdentifierType"))] - reciever_identifier_type: &'a str, + reciever_identifier_type: &'mpesa str, #[serde(rename(serialize = "Remarks"))] - remarks: &'a str, - #[serde(rename(serialize = "QueueTimeOutURL"))] - queue_time_out_url: &'a str, - #[serde(rename(serialize = "ResultURL"))] - result_url: &'a str, - #[serde(rename(serialize = "AccountReference"))] - account_reference: &'a str, + remarks: &'mpesa str, + #[serde( + rename(serialize = "QueueTimeOutURL"), + skip_serializing_if = "Option::is_none" + )] + queue_time_out_url: Option<&'mpesa str>, + #[serde( + rename(serialize = "ResultURL"), + skip_serializing_if = "Option::is_none" + )] + result_url: Option<&'mpesa str>, + #[serde( + rename(serialize = "AccountReference"), + skip_serializing_if = "Option::is_none" + )] + account_reference: Option<&'mpesa str>, } #[derive(Debug, Deserialize, Clone)] @@ -47,25 +56,25 @@ pub struct B2bResponse { #[derive(Debug)] /// B2B transaction builder struct -pub struct B2bBuilder<'a> { - initiator_name: &'a str, - client: &'a Mpesa, +pub struct B2bBuilder<'mpesa, Env: ApiEnvironment> { + initiator_name: &'mpesa str, + client: &'mpesa Mpesa, command_id: Option, - amount: Option, - party_a: Option<&'a str>, + amount: Option, + party_a: Option<&'mpesa str>, sender_id: Option, - party_b: Option<&'a str>, + party_b: Option<&'mpesa str>, receiver_id: Option, - remarks: Option<&'a str>, - queue_timeout_url: Option<&'a str>, - result_url: Option<&'a str>, - account_ref: Option<&'a str>, + remarks: Option<&'mpesa str>, + queue_timeout_url: Option<&'mpesa str>, + result_url: Option<&'mpesa str>, + account_ref: Option<&'mpesa str>, } -impl<'a> B2bBuilder<'a> { +impl<'mpesa, Env: ApiEnvironment> B2bBuilder<'mpesa, Env> { /// Creates a new B2B builder /// Requires an `initiator_name`, the credential/ username used to authenticate the transaction request - pub fn new(client: &'a Mpesa, initiator_name: &'a str) -> B2bBuilder<'a> { + pub fn new(client: &'mpesa Mpesa, initiator_name: &'mpesa str) -> B2bBuilder<'mpesa, Env> { B2bBuilder { client, initiator_name, @@ -86,7 +95,7 @@ impl<'a> B2bBuilder<'a> { /// /// # Errors /// If invalid `CommandId` is provided - pub fn command_id(mut self, command_id: CommandId) -> B2bBuilder<'a> { + pub fn command_id(mut self, command_id: CommandId) -> B2bBuilder<'mpesa, Env> { self.command_id = Some(command_id); self } @@ -96,7 +105,7 @@ impl<'a> B2bBuilder<'a> { /// /// # Errors /// If `Party A` is invalid or not provided - pub fn party_a(mut self, party_a: &'a str) -> B2bBuilder<'a> { + pub fn party_a(mut self, party_a: &'mpesa str) -> B2bBuilder<'mpesa, Env> { self.party_a = Some(party_a); self } @@ -106,7 +115,7 @@ impl<'a> B2bBuilder<'a> { /// /// # Errors /// If `Party B` is invalid or not provided - pub fn party_b(mut self, party_b: &'a str) -> B2bBuilder<'a> { + pub fn party_b(mut self, party_b: &'mpesa str) -> B2bBuilder<'mpesa, Env> { self.party_b = Some(party_b); self } @@ -117,7 +126,11 @@ impl<'a> B2bBuilder<'a> { /// # Errors /// If either `Party A` or `Party B` is invalid or not provided #[deprecated] - pub fn parties(mut self, party_a: &'a str, party_b: &'a str) -> B2bBuilder<'a> { + pub fn parties( + mut self, + party_a: &'mpesa str, + party_b: &'mpesa str, + ) -> B2bBuilder<'mpesa, Env> { self.party_a = Some(party_a); self.party_b = Some(party_b); self @@ -127,7 +140,7 @@ impl<'a> B2bBuilder<'a> { /// /// # Error /// If `QueueTimeoutUrl` is invalid or not provided - pub fn timeout_url(mut self, timeout_url: &'a str) -> B2bBuilder<'a> { + pub fn timeout_url(mut self, timeout_url: &'mpesa str) -> B2bBuilder<'mpesa, Env> { self.queue_timeout_url = Some(timeout_url); self } @@ -136,7 +149,7 @@ impl<'a> B2bBuilder<'a> { /// /// # Error /// If `ResultUrl` is invalid or not provided - pub fn result_url(mut self, result_url: &'a str) -> B2bBuilder<'a> { + pub fn result_url(mut self, result_url: &'mpesa str) -> B2bBuilder<'mpesa, Env> { self.result_url = Some(result_url); self } @@ -146,7 +159,11 @@ impl<'a> B2bBuilder<'a> { /// # Error /// If either `QueueTimeoutUrl` and `ResultUrl` is invalid or not provided #[deprecated] - pub fn urls(mut self, timeout_url: &'a str, result_url: &'a str) -> B2bBuilder<'a> { + pub fn urls( + mut self, + timeout_url: &'mpesa str, + result_url: &'mpesa str, + ) -> B2bBuilder<'mpesa, Env> { // TODO: validate urls self.queue_timeout_url = Some(timeout_url); self.result_url = Some(result_url); @@ -154,40 +171,38 @@ impl<'a> B2bBuilder<'a> { } /// Adds `sender_id`. Will default to `IdentifierTypes::ShortCode` if not explicitly provided - pub fn sender_id(mut self, sender_id: IdentifierTypes) -> B2bBuilder<'a> { + pub fn sender_id(mut self, sender_id: IdentifierTypes) -> B2bBuilder<'mpesa, Env> { self.sender_id = Some(sender_id); self } /// Adds `receiver_id`. Will default to `IdentifierTypes::ShortCode` if not explicitly provided - pub fn receiver_id(mut self, receiver_id: IdentifierTypes) -> B2bBuilder<'a> { + pub fn receiver_id(mut self, receiver_id: IdentifierTypes) -> B2bBuilder<'mpesa, Env> { self.receiver_id = Some(receiver_id); self } /// Adds `account_ref`. This field is required - pub fn account_ref(mut self, account_ref: &'a str) -> B2bBuilder<'a> { + pub fn account_ref(mut self, account_ref: &'mpesa str) -> B2bBuilder<'mpesa, Env> { // TODO: add validation self.account_ref = Some(account_ref); self } + /// Adds an `amount` to the request /// This is a required field - /// - /// # Errors - /// If the amount is less than 10? - pub fn amount(mut self, amount: u32) -> B2bBuilder<'a> { - self.amount = Some(amount); + pub fn amount>(mut self, amount: Number) -> B2bBuilder<'mpesa, Env> { + self.amount = Some(amount.into()); self } /// Adds `remarks`. This field is optional, will default to "None" if not explicitly passed - pub fn remarks(mut self, remarks: &'a str) -> B2bBuilder<'a> { + pub fn remarks(mut self, remarks: &'mpesa str) -> B2bBuilder<'mpesa, Env> { self.remarks = Some(remarks); self } - /// **B2B API** + /// # B2B API /// /// Sends b2b payment request. /// @@ -196,14 +211,15 @@ impl<'a> B2bBuilder<'a> { /// business initiating the transaction and the both businesses involved in the transaction /// See more [here](https://developer.safaricom.co.ke/docs?shell#b2b-api) /// - /// A successful request returns a `serde_json::Value` type + /// A successful request returns a `B2bResponse` type /// /// # Errors /// Returns a `MpesaError` on failure - pub fn send(self) -> MpesaResult { + #[allow(clippy::unnecessary_lazy_evaluations)] + pub async fn send(self) -> MpesaResult { let url = format!( "{}/mpesa/b2b/v1/paymentrequest", - self.client.environment().base_url() + self.client.environment.base_url() ); let credentials = self.client.gen_security_credentials()?; @@ -212,39 +228,45 @@ impl<'a> B2bBuilder<'a> { security_credential: &credentials, command_id: self .command_id - .unwrap_or(CommandId::BusinessToBusinessTransfer), - amount: self.amount.unwrap_or(10), - party_a: self.party_a.unwrap_or(""), + .unwrap_or_else(|| CommandId::BusinessToBusinessTransfer), + amount: self + .amount + .ok_or(MpesaError::Message("amount is required"))?, + party_a: self + .party_a + .ok_or(MpesaError::Message("party_a is required"))?, sender_identifier_type: &self .sender_id - .unwrap_or(IdentifierTypes::ShortCode) + .unwrap_or_else(|| IdentifierTypes::ShortCode) .to_string(), - party_b: self.party_b.unwrap_or(""), + party_b: self + .party_b + .ok_or(MpesaError::Message("party_b is required"))?, reciever_identifier_type: &self .receiver_id - .unwrap_or(IdentifierTypes::ShortCode) + .unwrap_or_else(|| IdentifierTypes::ShortCode) .to_string(), - remarks: self.remarks.unwrap_or("None"), - queue_time_out_url: self.queue_timeout_url.unwrap_or(""), - result_url: self.result_url.unwrap_or(""), - account_reference: self.account_ref.unwrap_or(""), + remarks: self.remarks.unwrap_or_else(|| stringify!(None)), + queue_time_out_url: self.queue_timeout_url, + result_url: self.result_url, + account_reference: self.account_ref, }; let response = self .client .http_client .post(&url) - .bearer_auth(self.client.auth()?) + .bearer_auth(self.client.auth().await?) .json(&payload) - .send()? - .error_for_status()?; + .send() + .await?; if response.status().is_success() { - let value: B2bResponse = response.json()?; + let value = response.json::<_>().await?; return Ok(value); } - let value: Value = response.json()?; + let value = response.json().await?; Err(MpesaError::B2bError(value)) } } diff --git a/mpesa_core/src/services/b2c.rs b/src/services/b2c.rs similarity index 61% rename from mpesa_core/src/services/b2c.rs rename to src/services/b2c.rs index 6eabe08b7..d2dc96059 100644 --- a/mpesa_core/src/services/b2c.rs +++ b/src/services/b2c.rs @@ -1,31 +1,31 @@ -use crate::client::MpesaResult; -use crate::{CommandId, Mpesa, MpesaError, MpesaSecurity}; use serde::{Deserialize, Serialize}; -use serde_json::Value; + +use crate::environment::ApiEnvironment; +use crate::{CommandId, Mpesa, MpesaError, MpesaResult}; #[derive(Debug, Serialize)] /// Payload to allow for b2c transactions: -struct B2cPayload<'a> { +struct B2cPayload<'mpesa> { #[serde(rename(serialize = "InitiatorName"))] - initiator_name: &'a str, + initiator_name: &'mpesa str, #[serde(rename(serialize = "SecurityCredential"))] - security_credential: &'a str, + security_credential: &'mpesa str, #[serde(rename(serialize = "CommandID"))] command_id: CommandId, #[serde(rename(serialize = "Amount"))] - amount: u32, + amount: f64, #[serde(rename(serialize = "PartyA"))] - party_a: &'a str, + party_a: &'mpesa str, #[serde(rename(serialize = "PartyB"))] - party_b: &'a str, + party_b: &'mpesa str, #[serde(rename(serialize = "Remarks"))] - remarks: &'a str, + remarks: &'mpesa str, #[serde(rename(serialize = "QueueTimeOutURL"))] - queue_time_out_url: &'a str, + queue_time_out_url: &'mpesa str, #[serde(rename(serialize = "ResultURL"))] - result_url: &'a str, + result_url: &'mpesa str, #[serde(rename(serialize = "Occasion"))] - occasion: &'a str, + occasion: &'mpesa str, } #[derive(Debug, Deserialize, Clone)] @@ -42,23 +42,23 @@ pub struct B2cResponse { #[derive(Debug)] /// B2C transaction builder struct -pub struct B2cBuilder<'a> { - initiator_name: &'a str, - client: &'a Mpesa, +pub struct B2cBuilder<'mpesa, Env: ApiEnvironment> { + initiator_name: &'mpesa str, + client: &'mpesa Mpesa, command_id: Option, - amount: Option, - party_a: Option<&'a str>, - party_b: Option<&'a str>, - remarks: Option<&'a str>, - queue_timeout_url: Option<&'a str>, - result_url: Option<&'a str>, - occasion: Option<&'a str>, + amount: Option, + party_a: Option<&'mpesa str>, + party_b: Option<&'mpesa str>, + remarks: Option<&'mpesa str>, + queue_timeout_url: Option<&'mpesa str>, + result_url: Option<&'mpesa str>, + occasion: Option<&'mpesa str>, } -impl<'a> B2cBuilder<'a> { +impl<'mpesa, Env: ApiEnvironment> B2cBuilder<'mpesa, Env> { /// Create a new B2C builder. /// Requires an `initiator_name`, the credential/ username used to authenticate the transaction request - pub fn new(client: &'a Mpesa, initiator_name: &'a str) -> B2cBuilder<'a> { + pub fn new(client: &'mpesa Mpesa, initiator_name: &'mpesa str) -> B2cBuilder<'mpesa, Env> { B2cBuilder { client, initiator_name, @@ -74,7 +74,7 @@ impl<'a> B2cBuilder<'a> { } /// Adds the `CommandId`. Defaults to `CommandId::BusinessPayment` if not explicitly provided. - pub fn command_id(mut self, command_id: CommandId) -> B2cBuilder<'a> { + pub fn command_id(mut self, command_id: CommandId) -> B2cBuilder<'mpesa, Env> { self.command_id = Some(command_id); self } @@ -84,7 +84,7 @@ impl<'a> B2cBuilder<'a> { /// /// # Errors /// If `Party A` is invalid or not provided - pub fn party_a(mut self, party_a: &'a str) -> B2cBuilder<'a> { + pub fn party_a(mut self, party_a: &'mpesa str) -> B2cBuilder<'mpesa, Env> { self.party_a = Some(party_a); self } @@ -94,7 +94,7 @@ impl<'a> B2cBuilder<'a> { /// /// # Errors /// If `Party B` is invalid or not provided - pub fn party_b(mut self, party_b: &'a str) -> B2cBuilder<'a> { + pub fn party_b(mut self, party_b: &'mpesa str) -> B2cBuilder<'mpesa, Env> { self.party_b = Some(party_b); self } @@ -105,7 +105,11 @@ impl<'a> B2cBuilder<'a> { /// # Errors /// If either `Party A` or `Party B` is invalid or not provided #[deprecated] - pub fn parties(mut self, party_a: &'a str, party_b: &'a str) -> B2cBuilder<'a> { + pub fn parties( + mut self, + party_a: &'mpesa str, + party_b: &'mpesa str, + ) -> B2cBuilder<'mpesa, Env> { // TODO: add validation self.party_a = Some(party_a); self.party_b = Some(party_b); @@ -113,23 +117,21 @@ impl<'a> B2cBuilder<'a> { } /// Adds `Remarks`. This is an optional field, will default to "None" if not explicitly provided - pub fn remarks(mut self, remarks: &'a str) -> B2cBuilder<'a> { + pub fn remarks(mut self, remarks: &'mpesa str) -> B2cBuilder<'mpesa, Env> { self.remarks = Some(remarks); self } /// Adds `Occasion`. This is an optional field, will default to an empty string - pub fn occasion(mut self, occasion: &'a str) -> B2cBuilder<'a> { + pub fn occasion(mut self, occasion: &'mpesa str) -> B2cBuilder<'mpesa, Env> { self.occasion = Some(occasion); self } + /// Adds an `amount` to the request /// This is a required field - /// - /// # Errors - /// If the amount is less than 10? - pub fn amount(mut self, amount: u32) -> B2cBuilder<'a> { - self.amount = Some(amount); + pub fn amount>(mut self, amount: Number) -> B2cBuilder<'mpesa, Env> { + self.amount = Some(amount.into()); self } @@ -137,7 +139,7 @@ impl<'a> B2cBuilder<'a> { /// /// # Error /// If `QueueTimeoutUrl` is invalid or not provided - pub fn timeout_url(mut self, timeout_url: &'a str) -> B2cBuilder<'a> { + pub fn timeout_url(mut self, timeout_url: &'mpesa str) -> B2cBuilder<'mpesa, Env> { self.queue_timeout_url = Some(timeout_url); self } @@ -146,7 +148,7 @@ impl<'a> B2cBuilder<'a> { /// /// # Error /// If `ResultUrl` is invalid or not provided - pub fn result_url(mut self, result_url: &'a str) -> B2cBuilder<'a> { + pub fn result_url(mut self, result_url: &'mpesa str) -> B2cBuilder<'mpesa, Env> { self.result_url = Some(result_url); self } @@ -156,14 +158,18 @@ impl<'a> B2cBuilder<'a> { /// # Error /// If either `QueueTimeoutUrl` and `ResultUrl` is invalid or not provided #[deprecated] - pub fn urls(mut self, timeout_url: &'a str, result_url: &'a str) -> B2cBuilder<'a> { + pub fn urls( + mut self, + timeout_url: &'mpesa str, + result_url: &'mpesa str, + ) -> B2cBuilder<'mpesa, Env> { // TODO: validate urls; will probably return a `Result` from this self.queue_timeout_url = Some(timeout_url); self.result_url = Some(result_url); self } - /// **B2C API** + /// # B2C API /// /// Sends b2c payment request. /// @@ -172,14 +178,14 @@ impl<'a> B2cBuilder<'a> { /// valid and verified B2C M-Pesa Short code. /// See more [here](https://developer.safaricom.co.ke/docs?shell#b2c-api) /// - /// A successful request returns a `serde_json::Value` type + /// A successful request returns a `B2cResponse` type /// /// # Errors /// Returns a `MpesaError` on failure. - pub fn send(self) -> MpesaResult { + pub async fn send(self) -> MpesaResult { let url = format!( "{}/mpesa/b2c/v1/paymentrequest", - self.client.environment().base_url() + self.client.environment.base_url() ); let credentials = self.client.gen_security_credentials()?; @@ -187,30 +193,40 @@ impl<'a> B2cBuilder<'a> { initiator_name: self.initiator_name, security_credential: &credentials, command_id: self.command_id.unwrap_or(CommandId::BusinessPayment), - amount: self.amount.unwrap_or(10), - party_a: self.party_a.unwrap_or("None"), - party_b: self.party_b.unwrap_or("None"), - remarks: self.remarks.unwrap_or("None"), - queue_time_out_url: self.queue_timeout_url.unwrap_or("None"), - result_url: self.result_url.unwrap_or("None"), - occasion: self.occasion.unwrap_or("None"), + amount: self + .amount + .ok_or(MpesaError::Message("amount is required"))?, + party_a: self + .party_a + .ok_or(MpesaError::Message("party_a is required"))?, + party_b: self + .party_b + .ok_or(MpesaError::Message("party_b is required"))?, + remarks: self.remarks.unwrap_or_else(|| stringify!(None)), + queue_time_out_url: self + .queue_timeout_url + .ok_or(MpesaError::Message("queue_timeout_url is required"))?, + result_url: self + .result_url + .ok_or(MpesaError::Message("result_url is required"))?, + occasion: self.occasion.unwrap_or_else(|| stringify!(None)), }; let response = self .client .http_client .post(&url) - .bearer_auth(self.client.auth()?) + .bearer_auth(self.client.auth().await?) .json(&payload) - .send()? - .error_for_status()?; + .send() + .await?; if response.status().is_success() { - let value: B2cResponse = response.json()?; + let value = response.json::<_>().await?; return Ok(value); } - let value: Value = response.json()?; + let value = response.json().await?; Err(MpesaError::B2cError(value)) } } diff --git a/src/services/bill_manager.rs b/src/services/bill_manager.rs new file mode 100644 index 000000000..6394e53eb --- /dev/null +++ b/src/services/bill_manager.rs @@ -0,0 +1,13 @@ +mod bulk_invoice; +mod cancel_invoice; +mod onboard; +mod onboard_modify; +mod reconciliation; +mod single_invoice; + +pub use bulk_invoice::{BulkInvoiceBuilder, BulkInvoiceResponse}; +pub use cancel_invoice::{CancelInvoiceBuilder, CancelInvoiceResponse}; +pub use onboard::{OnboardBuilder, OnboardResponse}; +pub use onboard_modify::{OnboardModifyBuilder, OnboardModifyResponse}; +pub use reconciliation::{ReconciliationBuilder, ReconciliationResponse}; +pub use single_invoice::{SingleInvoiceBuilder, SingleInvoiceResponse}; diff --git a/src/services/bill_manager/bulk_invoice.rs b/src/services/bill_manager/bulk_invoice.rs new file mode 100644 index 000000000..5004076c2 --- /dev/null +++ b/src/services/bill_manager/bulk_invoice.rs @@ -0,0 +1,82 @@ +use serde::Deserialize; + +use crate::client::Mpesa; +use crate::constants::Invoice; +use crate::environment::ApiEnvironment; +use crate::errors::{MpesaError, MpesaResult}; + +#[derive(Clone, Debug, Deserialize)] +pub struct BulkInvoiceResponse { + #[serde(rename(deserialize = "rescode"))] + pub response_code: String, + #[serde(rename(deserialize = "resmsg"))] + pub response_message: String, + #[serde(rename(deserialize = "Status_Message"))] + pub status_message: String, +} + +#[derive(Debug)] +pub struct BulkInvoiceBuilder<'mpesa, Env: ApiEnvironment> { + client: &'mpesa Mpesa, + invoices: Vec>, +} + +impl<'mpesa, Env: ApiEnvironment> BulkInvoiceBuilder<'mpesa, Env> { + /// Creates a new Bill Manager Bulk Invoice builder + pub fn new(client: &'mpesa Mpesa) -> BulkInvoiceBuilder<'mpesa, Env> { + BulkInvoiceBuilder { + client, + invoices: vec![], + } + } + + /// Adds a single `invoice` + pub fn invoice(mut self, invoice: Invoice<'mpesa>) -> BulkInvoiceBuilder<'mpesa, Env> { + self.invoices.push(invoice); + self + } + + /// Adds multiple `invoices` + pub fn invoices( + mut self, + mut invoices: Vec>, + ) -> BulkInvoiceBuilder<'mpesa, Env> { + self.invoices.append(&mut invoices); + self + } + + /// Bill Manager Bulk Invoice API + /// + /// Sends invoices to your customers in bulk + /// + /// # Errors + /// Returns an `MpesaError` on failure. + #[allow(clippy::unnecessary_lazy_evaluations)] + pub async fn send(self) -> MpesaResult { + let url = format!( + "{}/v1/billmanager-invoice/bulk-invoicing", + self.client.environment.base_url() + ); + + if self.invoices.is_empty() { + return Err(MpesaError::Message("invoices cannot be empty")); + } + + let response = self + .client + .http_client + .post(&url) + .bearer_auth(self.client.auth().await?) + .json(&self.invoices) + .send() + .await?; + + if response.status().is_success() { + let value = response.json().await?; + return Ok(value); + } + + let value = response.json().await?; + Err(MpesaError::BulkInvoiceError(value)) + } +} diff --git a/src/services/bill_manager/cancel_invoice.rs b/src/services/bill_manager/cancel_invoice.rs new file mode 100644 index 000000000..0b12f4f18 --- /dev/null +++ b/src/services/bill_manager/cancel_invoice.rs @@ -0,0 +1,94 @@ +use serde::{Deserialize, Serialize}; + +use crate::client::Mpesa; +use crate::environment::ApiEnvironment; +use crate::errors::{MpesaError, MpesaResult}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CancelInvoicePayload<'mpesa> { + external_reference: &'mpesa str, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct CancelInvoiceResponse { + #[serde(rename(deserialize = "rescode"))] + pub response_code: String, + #[serde(rename(deserialize = "resmsg"))] + pub response_message: String, + #[serde(rename(deserialize = "Status_Message"))] + pub status_message: String, +} + +#[derive(Debug)] +pub struct CancelInvoiceBuilder<'mpesa, Env: ApiEnvironment> { + client: &'mpesa Mpesa, + external_references: Vec>, +} + +impl<'mpesa, Env: ApiEnvironment> CancelInvoiceBuilder<'mpesa, Env> { + /// Creates a new Bill Manager Cancel invoice builder + pub fn new(client: &'mpesa Mpesa) -> CancelInvoiceBuilder<'mpesa, Env> { + CancelInvoiceBuilder { + client, + external_references: vec![], + } + } + + /// Adds an `external_reference` + pub fn external_reference( + mut self, + external_reference: &'mpesa str, + ) -> CancelInvoiceBuilder<'mpesa, Env> { + self.external_references + .push(CancelInvoicePayload { external_reference }); + self + } + + /// Adds `external_references` + pub fn external_references( + mut self, + external_references: Vec<&'mpesa str>, + ) -> CancelInvoiceBuilder<'mpesa, Env> { + self.external_references.append( + &mut external_references + .into_iter() + .map(|external_reference| CancelInvoicePayload { external_reference }) + .collect(), + ); + self + } + + /// Bill Manager Cancel Invoice API + /// + /// Cancels a list of invoices by their `external_reference` + /// + /// A successful request returns a `CancelInvoiceResponse` type + /// + /// # Errors + /// Returns an `MpesaError` on failure + #[allow(clippy::unnecessary_lazy_evaluations)] + pub async fn send(self) -> MpesaResult { + let url = format!( + "{}/v1/billmanager-invoice/cancel-single-invoice", + self.client.environment.base_url() + ); + + let response = self + .client + .http_client + .post(&url) + .bearer_auth(self.client.auth().await?) + .json(&self.external_references) + .send() + .await?; + + if response.status().is_success() { + let value = response.json().await?; + return Ok(value); + } + + let value = response.json().await?; + Err(MpesaError::CancelInvoiceError(value)) + } +} diff --git a/src/services/bill_manager/onboard.rs b/src/services/bill_manager/onboard.rs new file mode 100644 index 000000000..79e23619b --- /dev/null +++ b/src/services/bill_manager/onboard.rs @@ -0,0 +1,169 @@ +use serde::{Deserialize, Serialize}; + +use crate::client::Mpesa; +use crate::constants::SendRemindersTypes; +use crate::environment::ApiEnvironment; +use crate::errors::{MpesaError, MpesaResult}; + +#[derive(Debug, Serialize)] +/// Payload to opt you in as a biller to the bill manager features. +struct OnboardPayload<'mpesa> { + #[serde(rename(serialize = "callbackUrl"))] + callback_url: &'mpesa str, + #[serde(rename(serialize = "email"))] + email: &'mpesa str, + #[serde(rename(serialize = "logo"))] + logo: &'mpesa str, + #[serde(rename(serialize = "officialContact"))] + official_contact: &'mpesa str, + #[serde(rename(serialize = "sendReminders"))] + send_reminders: SendRemindersTypes, + #[serde(rename(serialize = "shortcode"))] + short_code: &'mpesa str, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct OnboardResponse { + #[serde(rename(deserialize = "app_key"))] + pub app_key: String, + #[serde(rename(deserialize = "rescode"))] + pub response_code: String, + #[serde(rename(deserialize = "resmsg"))] + pub response_message: String, +} + +#[derive(Debug)] +pub struct OnboardBuilder<'mpesa, Env: ApiEnvironment> { + client: &'mpesa Mpesa, + callback_url: Option<&'mpesa str>, + email: Option<&'mpesa str>, + logo: Option<&'mpesa str>, + official_contact: Option<&'mpesa str>, + send_reminders: Option, + short_code: Option<&'mpesa str>, +} + +impl<'mpesa, Env: ApiEnvironment> OnboardBuilder<'mpesa, Env> { + /// Creates a new Bill Manager Onboard builder + pub fn new(client: &'mpesa Mpesa) -> OnboardBuilder<'mpesa, Env> { + OnboardBuilder { + client, + callback_url: None, + email: None, + logo: None, + official_contact: None, + send_reminders: None, + short_code: None, + } + } + + /// Adds `callbackUrl`. + /// + /// # Errors + /// If 'callbackUrl` is not provided. + pub fn callback_url(mut self, callback_url: &'mpesa str) -> OnboardBuilder<'mpesa, Env> { + self.callback_url = Some(callback_url); + self + } + + /// Adds an `email` address to the request. + /// + /// # Errors + /// If `email` is not provided. + pub fn email(mut self, email: &'mpesa str) -> OnboardBuilder<'mpesa, Env> { + self.email = Some(email); + self + } + + /// Adds `logo`; a file with your organizions's logo. + /// + /// # Errors + /// If `logo` is not provided. + pub fn logo(mut self, logo: &'mpesa str) -> OnboardBuilder<'mpesa, Env> { + self.logo = Some(logo); + self + } + + /// Adds `officialContact` to the request; must be in the format `07XXXXXXXX` + /// + /// # Errors + /// If `officialContact` is invalid or not provided. + pub fn official_contact( + mut self, + official_contact: &'mpesa str, + ) -> OnboardBuilder<'mpesa, Env> { + self.official_contact = Some(official_contact); + self + } + + /// Adds `sendReminders`. Defaults to `SendRemindersTypes::Disable` if no value is explicitely passed. + /// + /// # Errors + /// If `sendReminders` is not valid. + pub fn send_reminders( + mut self, + send_reminders: SendRemindersTypes, + ) -> OnboardBuilder<'mpesa, Env> { + self.send_reminders = Some(send_reminders); + self + } + + /// Adds `ShortCode`; the 6 digit MPESA Till Number or PayBill Number + /// + /// # Errors + /// If Till or PayBill number is invalid or not provided + pub fn short_code(mut self, short_code: &'mpesa str) -> OnboardBuilder<'mpesa, Env> { + self.short_code = Some(short_code); + self + } + + /// # Bill Manager Onboarding API + /// + /// Opt in as a biller to mpesa's bill manager features. + /// + /// A successful request returns a `OnboardResponse` type + /// + /// # Errors + /// Returns an `MpesaError` on failure + #[allow(clippy::unnecessary_lazy_evaluations)] + pub async fn send(self) -> MpesaResult { + let url = format!( + "{}/v1/billmanager-invoice/optin", + self.client.environment.base_url() + ); + + let payload = OnboardPayload { + callback_url: self + .callback_url + .ok_or(MpesaError::Message("callback_url is required"))?, + email: self.email.ok_or(MpesaError::Message("email is required"))?, + logo: self.logo.ok_or(MpesaError::Message("logo is required"))?, + official_contact: self + .official_contact + .ok_or(MpesaError::Message("official_contact is required"))?, + send_reminders: self + .send_reminders + .unwrap_or_else(|| SendRemindersTypes::Disable), + short_code: self + .short_code + .ok_or(MpesaError::Message("short_code is required"))?, + }; + + let response = self + .client + .http_client + .post(&url) + .bearer_auth(self.client.auth().await?) + .json(&payload) + .send() + .await?; + + if response.status().is_success() { + let value = response.json().await?; + return Ok(value); + } + + let value = response.json().await?; + Err(MpesaError::OnboardError(value)) + } +} diff --git a/src/services/bill_manager/onboard_modify.rs b/src/services/bill_manager/onboard_modify.rs new file mode 100644 index 000000000..bf990a58a --- /dev/null +++ b/src/services/bill_manager/onboard_modify.rs @@ -0,0 +1,153 @@ +use serde::{Deserialize, Serialize}; + +use crate::client::Mpesa; +use crate::constants::SendRemindersTypes; +use crate::environment::ApiEnvironment; +use crate::errors::{MpesaError, MpesaResult}; + +#[derive(Debug, Serialize)] +/// Payload to modify opt-in details to the bill manager api. +struct OnboardModifyPayload<'mpesa> { + #[serde( + rename(serialize = "callbackUrl"), + skip_serializing_if = "Option::is_none" + )] + callback_url: Option<&'mpesa str>, + #[serde(rename(serialize = "email"), skip_serializing_if = "Option::is_none")] + email: Option<&'mpesa str>, + #[serde(rename(serialize = "logo"), skip_serializing_if = "Option::is_none")] + logo: Option<&'mpesa str>, + #[serde( + rename(serialize = "officialContact"), + skip_serializing_if = "Option::is_none" + )] + official_contact: Option<&'mpesa str>, + #[serde( + rename(serialize = "sendReminders"), + skip_serializing_if = "Option::is_none" + )] + send_reminders: Option, + #[serde( + rename(serialize = "shortcode"), + skip_serializing_if = "Option::is_none" + )] + short_code: Option<&'mpesa str>, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct OnboardModifyResponse { + #[serde(rename(deserialize = "rescode"))] + pub response_code: String, + #[serde(rename(deserialize = "resmsg"))] + pub response_message: String, +} + +#[derive(Debug)] +pub struct OnboardModifyBuilder<'mpesa, Env: ApiEnvironment> { + client: &'mpesa Mpesa, + callback_url: Option<&'mpesa str>, + email: Option<&'mpesa str>, + logo: Option<&'mpesa str>, + official_contact: Option<&'mpesa str>, + send_reminders: Option, + short_code: Option<&'mpesa str>, +} + +impl<'mpesa, Env: ApiEnvironment> OnboardModifyBuilder<'mpesa, Env> { + /// Creates a new Bill Manager Onboard Modify builder + pub fn new(client: &'mpesa Mpesa) -> OnboardModifyBuilder<'mpesa, Env> { + OnboardModifyBuilder { + client, + callback_url: None, + email: None, + logo: None, + official_contact: None, + send_reminders: None, + short_code: None, + } + } + + /// Adds `callbackUrl`. + pub fn callback_url(mut self, callback_url: &'mpesa str) -> OnboardModifyBuilder<'mpesa, Env> { + self.callback_url = Some(callback_url); + self + } + + /// Adds an `email` address to the request. + pub fn email(mut self, email: &'mpesa str) -> OnboardModifyBuilder<'mpesa, Env> { + self.email = Some(email); + self + } + + /// Adds `logo`; a file with your organizions's logo. + pub fn logo(mut self, logo: &'mpesa str) -> OnboardModifyBuilder<'mpesa, Env> { + self.logo = Some(logo); + self + } + + /// Adds `officialContact` to the request; must be in the format `07XXXXXXXX` + pub fn official_contact( + mut self, + official_contact: &'mpesa str, + ) -> OnboardModifyBuilder<'mpesa, Env> { + self.official_contact = Some(official_contact); + self + } + + /// Adds `sendReminders`. + pub fn send_reminders( + mut self, + send_reminders: SendRemindersTypes, + ) -> OnboardModifyBuilder<'mpesa, Env> { + self.send_reminders = Some(send_reminders); + self + } + + /// Adds `ShortCode`; the 6 digit MPESA Till Number or PayBill Number + pub fn short_code(mut self, short_code: &'mpesa str) -> OnboardModifyBuilder<'mpesa, Env> { + self.short_code = Some(short_code); + self + } + + /// # Bill Manager Onboarding Modify API + /// + /// Modifies opt-in details to the bill manager api. + /// + /// A successful request returns a `OnboardModifyResponse` type + /// + /// # Errors + /// Returns an `MpesaError` on failure + #[allow(clippy::unnecessary_lazy_evaluations)] + pub async fn send(self) -> MpesaResult { + let url = format!( + "{}/v1/billmanager-invoice/change-optin-details", + self.client.environment.base_url() + ); + + let payload = OnboardModifyPayload { + callback_url: self.callback_url, + email: self.email, + logo: self.logo, + official_contact: self.official_contact, + send_reminders: self.send_reminders, + short_code: self.short_code, + }; + + let response = self + .client + .http_client + .post(&url) + .bearer_auth(self.client.auth().await?) + .json(&payload) + .send() + .await?; + + if response.status().is_success() { + let value = response.json().await?; + return Ok(value); + } + + let value = response.json().await?; + Err(MpesaError::OnboardModifyError(value)) + } +} diff --git a/src/services/bill_manager/reconciliation.rs b/src/services/bill_manager/reconciliation.rs new file mode 100644 index 000000000..0cbc75f2f --- /dev/null +++ b/src/services/bill_manager/reconciliation.rs @@ -0,0 +1,180 @@ +use chrono::prelude::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::client::Mpesa; +use crate::environment::ApiEnvironment; +use crate::errors::{MpesaError, MpesaResult}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ReconciliationPayload<'mpesa> { + account_reference: &'mpesa str, + external_reference: &'mpesa str, + full_name: &'mpesa str, + invoice_name: &'mpesa str, + paid_amount: f64, + payment_date: DateTime, + phone_number: &'mpesa str, + transaction_id: &'mpesa str, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ReconciliationResponse { + #[serde(rename(deserialize = "rescode"))] + pub response_code: String, + #[serde(rename(deserialize = "resmsg"))] + pub response_message: String, +} + +#[derive(Debug)] +pub struct ReconciliationBuilder<'mpesa, Env: ApiEnvironment> { + client: &'mpesa Mpesa, + account_reference: Option<&'mpesa str>, + external_reference: Option<&'mpesa str>, + full_name: Option<&'mpesa str>, + invoice_name: Option<&'mpesa str>, + paid_amount: Option, + payment_date: Option>, + phone_number: Option<&'mpesa str>, + transaction_id: Option<&'mpesa str>, +} + +impl<'mpesa, Env: ApiEnvironment> ReconciliationBuilder<'mpesa, Env> { + /// Creates a new Bill Manager Reconciliation Builder + pub fn new(client: &'mpesa Mpesa) -> ReconciliationBuilder<'mpesa, Env> { + ReconciliationBuilder { + client, + account_reference: None, + external_reference: None, + full_name: None, + invoice_name: None, + paid_amount: None, + payment_date: None, + phone_number: None, + transaction_id: None, + } + } + + /// Adds `account_reference` + pub fn account_reference( + mut self, + account_reference: &'mpesa str, + ) -> ReconciliationBuilder<'mpesa, Env> { + self.account_reference = Some(account_reference); + self + } + + /// Adds `external_reference` + pub fn external_reference( + mut self, + external_reference: &'mpesa str, + ) -> ReconciliationBuilder<'mpesa, Env> { + self.external_reference = Some(external_reference); + self + } + + /// Adds `full_name` + pub fn full_name(mut self, full_name: &'mpesa str) -> ReconciliationBuilder<'mpesa, Env> { + self.full_name = Some(full_name); + self + } + + /// Adds `invoice_name` + pub fn invoice_name(mut self, invoice_name: &'mpesa str) -> ReconciliationBuilder<'mpesa, Env> { + self.invoice_name = Some(invoice_name); + self + } + + /// Adds `paid_amount` + pub fn paid_amount>( + mut self, + paid_amount: Number, + ) -> ReconciliationBuilder<'mpesa, Env> { + self.paid_amount = Some(paid_amount.into()); + self + } + + /// Adds `payment_date` + pub fn payment_date( + mut self, + payment_date: DateTime, + ) -> ReconciliationBuilder<'mpesa, Env> { + self.payment_date = Some(payment_date); + self + } + + /// Adds `phone_number` + pub fn phone_number(mut self, phone_number: &'mpesa str) -> ReconciliationBuilder<'mpesa, Env> { + self.phone_number = Some(phone_number); + self + } + + /// Adds `transaction_id` + pub fn transaction_id( + mut self, + transaction_id: &'mpesa str, + ) -> ReconciliationBuilder<'mpesa, Env> { + self.transaction_id = Some(transaction_id); + self + } + + /// Bill Manager Reconciliation API + /// + /// Enables your customers to receive e-receipts for payments made to your paybill account + /// + /// A successful request returns a `ReconciliationResponse` type. + /// + /// # Errors + /// Returns an `MpesaError` on failure. + #[allow(clippy::unnecessary_lazy_evaluations)] + pub async fn send(self) -> MpesaResult { + let url = format!( + "{}/v1/billmanager-invoice/reconciliation", + self.client.environment.base_url() + ); + + let payload = ReconciliationPayload { + account_reference: self + .account_reference + .ok_or(MpesaError::Message("account_reference is required"))?, + external_reference: self + .external_reference + .ok_or(MpesaError::Message("external_reference is required"))?, + full_name: self + .full_name + .ok_or(MpesaError::Message("full_name is required"))?, + invoice_name: self + .invoice_name + .ok_or(MpesaError::Message("invoice_name is required"))?, + paid_amount: self + .paid_amount + .ok_or(MpesaError::Message("paid_amount is required"))?, + payment_date: self + .payment_date + .ok_or(MpesaError::Message("payment_date is required"))?, + phone_number: self + .phone_number + .ok_or(MpesaError::Message("phone_number is required"))?, + transaction_id: self + .transaction_id + .ok_or(MpesaError::Message("transaction_id is required"))?, + }; + + let response = self + .client + .http_client + .post(&url) + .bearer_auth(self.client.auth().await?) + .json(&payload) + .send() + .await?; + + if response.status().is_success() { + let value = response.json().await?; + return Ok(value); + } + + let value = response.json().await?; + Err(MpesaError::ReconciliationError(value)) + } +} diff --git a/src/services/bill_manager/single_invoice.rs b/src/services/bill_manager/single_invoice.rs new file mode 100644 index 000000000..a62f0b7b5 --- /dev/null +++ b/src/services/bill_manager/single_invoice.rs @@ -0,0 +1,185 @@ +use chrono::prelude::{DateTime, Utc}; +use serde::Deserialize; + +use crate::client::Mpesa; +use crate::constants::{Invoice, InvoiceItem}; +use crate::environment::ApiEnvironment; +use crate::errors::{MpesaError, MpesaResult}; + +#[derive(Clone, Debug, Deserialize)] +pub struct SingleInvoiceResponse { + #[serde(rename(deserialize = "rescode"))] + pub response_code: String, + #[serde(rename(deserialize = "resmsg"))] + pub response_message: String, + #[serde(rename(deserialize = "Status_Message"))] + pub status_message: String, +} + +#[derive(Debug)] +pub struct SingleInvoiceBuilder<'mpesa, Env: ApiEnvironment> { + client: &'mpesa Mpesa, + amount: Option, + account_reference: Option<&'mpesa str>, + billed_full_name: Option<&'mpesa str>, + billed_period: Option<&'mpesa str>, + billed_phone_number: Option<&'mpesa str>, + due_date: Option>, + external_reference: Option<&'mpesa str>, + invoice_items: Option>>, + invoice_name: Option<&'mpesa str>, +} + +impl<'mpesa, Env: ApiEnvironment> SingleInvoiceBuilder<'mpesa, Env> { + /// Creates a new Bill Manager Single Invoice Builder + pub fn new(client: &'mpesa Mpesa) -> SingleInvoiceBuilder<'mpesa, Env> { + SingleInvoiceBuilder { + client, + amount: None, + account_reference: None, + billed_full_name: None, + billed_period: None, + billed_phone_number: None, + due_date: None, + external_reference: None, + invoice_items: None, + invoice_name: None, + } + } + + /// Adds `amount` + pub fn amount>( + mut self, + amount: Number, + ) -> SingleInvoiceBuilder<'mpesa, Env> { + self.amount = Some(amount.into()); + self + } + + /// Adds `account_reference` + pub fn account_reference( + mut self, + account_refernce: &'mpesa str, + ) -> SingleInvoiceBuilder<'mpesa, Env> { + self.account_reference = Some(account_refernce); + self + } + + /// Adds `billed_full_name` + pub fn billed_full_name( + mut self, + billed_full_name: &'mpesa str, + ) -> SingleInvoiceBuilder<'mpesa, Env> { + self.billed_full_name = Some(billed_full_name); + self + } + + /// Adds `billed_period`; must be in the format `"Month Year"` e.g. `"March 2023"` + pub fn billed_period( + mut self, + billed_period: &'mpesa str, + ) -> SingleInvoiceBuilder<'mpesa, Env> { + self.billed_period = Some(billed_period); + self + } + + /// Adds `billed_phone_number`; must be in the format `0722XXXXXX` + pub fn billed_phone_number( + mut self, + billed_phone_number: &'mpesa str, + ) -> SingleInvoiceBuilder<'mpesa, Env> { + self.billed_phone_number = Some(billed_phone_number); + self + } + + /// Adds `due_date` + pub fn due_date(mut self, due_date: DateTime) -> SingleInvoiceBuilder<'mpesa, Env> { + self.due_date = Some(due_date); + self + } + + /// Adds `external_reference` + pub fn external_reference( + mut self, + external_reference: &'mpesa str, + ) -> SingleInvoiceBuilder<'mpesa, Env> { + self.external_reference = Some(external_reference); + self + } + + /// Adds `invoice_items` + pub fn invoice_items( + mut self, + invoice_items: Vec>, + ) -> SingleInvoiceBuilder<'mpesa, Env> { + self.invoice_items = Some(invoice_items); + self + } + + /// Adds `invoice_name` + pub fn invoice_name(mut self, invoice_name: &'mpesa str) -> SingleInvoiceBuilder<'mpesa, Env> { + self.invoice_name = Some(invoice_name); + self + } + + /// Bill Manager Single Invoice API + /// + /// Creates and sends invoices to your customers + /// + /// A successful request returns a `SingleInvoiceResponse` type + /// + /// # Errors + /// Returns an `MpesaError` on failure + #[allow(clippy::unnecessary_lazy_evaluations)] + pub async fn send(self) -> MpesaResult { + let url = format!( + "{}/v1/billmanager-invoice/single-invoicing", + self.client.environment.base_url() + ); + + let payload = Invoice { + amount: self + .amount + .ok_or(MpesaError::Message("amount is required"))?, + account_reference: self + .account_reference + .ok_or(MpesaError::Message("account_reference is required"))?, + billed_full_name: self + .billed_full_name + .ok_or(MpesaError::Message("billed_full_name is required"))?, + billed_period: self + .billed_period + .ok_or(MpesaError::Message("billed_period is required"))?, + billed_phone_number: self + .billed_phone_number + .ok_or(MpesaError::Message("billed_phone_number is required"))?, + due_date: self + .due_date + .ok_or(MpesaError::Message("due_date is required"))?, + external_reference: self + .external_reference + .ok_or(MpesaError::Message("external_reference is required"))?, + invoice_items: self.invoice_items, + invoice_name: self + .invoice_name + .ok_or(MpesaError::Message("invoice_name is required"))?, + }; + + let response = self + .client + .http_client + .post(&url) + .bearer_auth(self.client.auth().await?) + .json(&payload) + .send() + .await?; + + if response.status().is_success() { + let value = response.json().await?; + return Ok(value); + } + + let value = response.json().await?; + Err(MpesaError::SingleInvoiceError(value)) + } +} diff --git a/mpesa_core/src/services/c2b_register.rs b/src/services/c2b_register.rs similarity index 56% rename from mpesa_core/src/services/c2b_register.rs rename to src/services/c2b_register.rs index 38b37806e..0e0d844c8 100644 --- a/mpesa_core/src/services/c2b_register.rs +++ b/src/services/c2b_register.rs @@ -1,45 +1,46 @@ -use crate::client::{Mpesa, MpesaResult}; -use crate::constants::ResponseType; -use crate::errors::MpesaError; use serde::{Deserialize, Serialize}; -use serde_json::Value; + +use crate::client::Mpesa; +use crate::constants::ResponseType; +use crate::environment::ApiEnvironment; +use crate::errors::{MpesaError, MpesaResult}; #[derive(Debug, Serialize)] /// Payload to register the 3rd party’s confirmation and validation URLs to M-Pesa -struct C2bRegisterPayload<'a> { +struct C2bRegisterPayload<'mpesa> { #[serde(rename(serialize = "ValidationURL"))] - validation_url: &'a str, + validation_url: &'mpesa str, #[serde(rename(serialize = "ConfirmationURL"))] - confirmation_url: &'a str, + confirmation_url: &'mpesa str, #[serde(rename(serialize = "ResponseType"))] response_type: ResponseType, #[serde(rename(serialize = "ShortCode"))] - short_code: &'a str, + short_code: &'mpesa str, } #[derive(Debug, Deserialize, Clone)] pub struct C2bRegisterResponse { - #[serde(rename(deserialize = "ConversationID"), skip_serializing_if = "None")] - pub conversation_id: Option, - #[serde(rename(deserialize = "OriginatorCoversationID"))] - pub originator_coversation_id: String, + #[serde(rename(deserialize = "OriginatorConverstionID"))] + pub originator_conversation_id: String, + #[serde(rename(deserialize = "ResponseCode"))] + pub response_code: String, #[serde(rename(deserialize = "ResponseDescription"))] pub response_description: String, } #[derive(Debug)] /// C2B Register builder -pub struct C2bRegisterBuilder<'a> { - client: &'a Mpesa, - validation_url: Option<&'a str>, - confirmation_url: Option<&'a str>, +pub struct C2bRegisterBuilder<'mpesa, Env: ApiEnvironment> { + client: &'mpesa Mpesa, + validation_url: Option<&'mpesa str>, + confirmation_url: Option<&'mpesa str>, response_type: Option, - short_code: Option<&'a str>, + short_code: Option<&'mpesa str>, } -impl<'a> C2bRegisterBuilder<'a> { +impl<'mpesa, Env: ApiEnvironment> C2bRegisterBuilder<'mpesa, Env> { /// Creates a new C2B Builder - pub fn new(client: &'a Mpesa) -> C2bRegisterBuilder<'a> { + pub fn new(client: &'mpesa Mpesa) -> C2bRegisterBuilder<'mpesa, Env> { C2bRegisterBuilder { client, validation_url: None, @@ -53,7 +54,10 @@ impl<'a> C2bRegisterBuilder<'a> { /// /// # Error /// If `ValidationURL` is invalid or not provided - pub fn validation_url(mut self, validation_url: &'a str) -> C2bRegisterBuilder<'a> { + pub fn validation_url( + mut self, + validation_url: &'mpesa str, + ) -> C2bRegisterBuilder<'mpesa, Env> { self.validation_url = Some(validation_url); self } @@ -62,13 +66,16 @@ impl<'a> C2bRegisterBuilder<'a> { /// /// # Error /// If `ConfirmationUrl` is invalid or not provided - pub fn confirmation_url(mut self, confirmation_url: &'a str) -> C2bRegisterBuilder<'a> { + pub fn confirmation_url( + mut self, + confirmation_url: &'mpesa str, + ) -> C2bRegisterBuilder<'mpesa, Env> { self.confirmation_url = Some(confirmation_url); self } /// Adds `ResponseType` for timeout. Will default to `ResponseType::Complete` if not explicitly provided - pub fn response_type(mut self, response_type: ResponseType) -> C2bRegisterBuilder<'a> { + pub fn response_type(mut self, response_type: ResponseType) -> C2bRegisterBuilder<'mpesa, Env> { self.response_type = Some(response_type); self } @@ -76,8 +83,8 @@ impl<'a> C2bRegisterBuilder<'a> { /// Adds `ShortCode` for the organization. This is a required field. /// /// # Error - /// If `ShortCode` is invalid - pub fn short_code(mut self, short_code: &'a str) -> C2bRegisterBuilder<'a> { + /// If `ShortCode` is invalid or not provided + pub fn short_code(mut self, short_code: &'mpesa str) -> C2bRegisterBuilder<'mpesa, Env> { self.short_code = Some(short_code); self } @@ -94,38 +101,47 @@ impl<'a> C2bRegisterBuilder<'a> { /// /// The response expected is the success code the 3rd party /// - /// A successful request returns a `serde_json::Value` type + /// A successful request returns a `C2bRegisterResponse` type /// /// # Errors /// Returns a `MpesaError` on failure - pub fn send(self) -> MpesaResult { + #[allow(clippy::unnecessary_lazy_evaluations)] + pub async fn send(self) -> MpesaResult { let url = format!( "{}/mpesa/c2b/v1/registerurl", - self.client.environment().base_url() + self.client.environment.base_url() ); let payload = C2bRegisterPayload { - validation_url: self.validation_url.unwrap_or("None"), - confirmation_url: self.confirmation_url.unwrap_or("None"), - response_type: self.response_type.unwrap_or(ResponseType::Complete), - short_code: self.short_code.unwrap_or("None"), + validation_url: self + .validation_url + .ok_or(MpesaError::Message("validation_url is required"))?, + confirmation_url: self + .confirmation_url + .ok_or(MpesaError::Message("confirmation_url is required"))?, + response_type: self + .response_type + .unwrap_or_else(|| ResponseType::Completed), + short_code: self + .short_code + .ok_or(MpesaError::Message("short_code is required"))?, }; let response = self .client .http_client .post(&url) - .bearer_auth(self.client.auth()?) + .bearer_auth(self.client.auth().await?) .json(&payload) - .send()? - .error_for_status()?; + .send() + .await?; if response.status().is_success() { - let value: C2bRegisterResponse = response.json()?; + let value = response.json::<_>().await?; return Ok(value); } - let value: Value = response.json()?; + let value = response.json().await?; Err(MpesaError::C2bRegisterError(value)) } } diff --git a/src/services/c2b_simulate.rs b/src/services/c2b_simulate.rs new file mode 100644 index 000000000..dc7c78b6d --- /dev/null +++ b/src/services/c2b_simulate.rs @@ -0,0 +1,164 @@ +use serde::{Deserialize, Serialize}; + +use crate::client::Mpesa; +use crate::constants::CommandId; +use crate::environment::ApiEnvironment; +use crate::errors::{MpesaError, MpesaResult}; + +#[derive(Debug, Serialize)] +/// Payload to make payment requests from C2B. +/// See more: https://developer.safaricom.co.ke/docs#c2b-api +struct C2bSimulatePayload<'mpesa> { + #[serde(rename(serialize = "CommandID"))] + command_id: CommandId, + #[serde(rename(serialize = "Amount"))] + amount: f64, + #[serde(rename(serialize = "Msisdn"))] + msisdn: &'mpesa str, + #[serde(rename(serialize = "BillRefNumber"))] + bill_ref_number: &'mpesa str, + #[serde(rename(serialize = "ShortCode"))] + short_code: &'mpesa str, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct C2bSimulateResponse { + #[serde( + rename(deserialize = "ConversationID"), + skip_serializing_if = "Option::is_none" + )] + pub conversation_id: Option, + #[serde(rename(deserialize = "OriginatorConversationID"))] + pub originator_conversation_id: String, + #[serde(rename(deserialize = "ResponseCode"))] + pub response_code: String, + #[serde(rename(deserialize = "ResponseDescription"))] + pub response_description: String, +} + +#[derive(Debug)] +pub struct C2bSimulateBuilder<'mpesa, Env: ApiEnvironment> { + client: &'mpesa Mpesa, + command_id: Option, + amount: Option, + msisdn: Option<&'mpesa str>, + bill_ref_number: Option<&'mpesa str>, + short_code: Option<&'mpesa str>, +} + +impl<'mpesa, Env: ApiEnvironment> C2bSimulateBuilder<'mpesa, Env> { + /// Creates a new C2B Simulate builder + pub fn new(client: &'mpesa Mpesa) -> C2bSimulateBuilder<'mpesa, Env> { + C2bSimulateBuilder { + client, + command_id: None, + amount: None, + msisdn: None, + bill_ref_number: None, + short_code: None, + } + } + + /// Adds `CommandId`. Defaults to `CommandId::CustomerPaybillOnline` if no value explicitly passed + /// + /// # Errors + /// If `CommandId` is not valid + pub fn command_id(mut self, command_id: CommandId) -> C2bSimulateBuilder<'mpesa, Env> { + self.command_id = Some(command_id); + self + } + + /// Adds an `amount` to the request + /// + /// # Errors + /// If `Amount` is not provided + pub fn amount>(mut self, amount: Number) -> C2bSimulateBuilder<'mpesa, Env> { + self.amount = Some(amount.into()); + self + } + + /// Adds the MSISDN(phone number) sending the transaction, start by country code without the `+`. + /// This is a required field + /// + /// # Errors + /// If `MSISDN` is invalid or not provided + pub fn msisdn(mut self, msisdn: &'mpesa str) -> C2bSimulateBuilder<'mpesa, Env> { + self.msisdn = Some(msisdn); + self + } + + /// Adds `ShortCode`; the 6 digit MPESA Till Number or PayBill Number + /// + /// # Errors + /// If Till or PayBill number is invalid or not provided + pub fn short_code(mut self, short_code: &'mpesa str) -> C2bSimulateBuilder<'mpesa, Env> { + self.short_code = Some(short_code); + self + } + + /// Adds Bill reference number. + /// + /// # Errors + /// If `BillRefNumber` is invalid or not provided + pub fn bill_ref_number( + mut self, + bill_ref_number: &'mpesa str, + ) -> C2bSimulateBuilder<'mpesa, Env> { + self.bill_ref_number = Some(bill_ref_number); + self + } + + /// # C2B Simulate API + /// + /// Make payment requests from Client to Business + /// + /// This enables you to receive the payment requests in real time. + /// See more [here](https://developer.safaricom.co.ke/c2b/apis/post/simulate) + /// + /// A successful request returns a `C2bSimulateResponse` type + /// + /// # Errors + /// Returns a `MpesaError` on failure + #[allow(clippy::unnecessary_lazy_evaluations)] + pub async fn send(self) -> MpesaResult { + let url = format!( + "{}/mpesa/c2b/v1/simulate", + self.client.environment.base_url() + ); + + let payload = C2bSimulatePayload { + command_id: self + .command_id + .unwrap_or_else(|| CommandId::CustomerPayBillOnline), + amount: self + .amount + .ok_or(MpesaError::Message("amount is required"))?, + msisdn: self + .msisdn + .ok_or(MpesaError::Message("msisdn is required"))?, + bill_ref_number: self + .bill_ref_number + .ok_or(MpesaError::Message("bill_ref_number is required"))?, + short_code: self + .short_code + .ok_or(MpesaError::Message("short_code is required"))?, + }; + + let response = self + .client + .http_client + .post(&url) + .bearer_auth(self.client.auth().await?) + .json(&payload) + .send() + .await?; + + if response.status().is_success() { + let value = response.json::<_>().await?; + return Ok(value); + } + + let value = response.json().await?; + Err(MpesaError::C2bSimulateError(value)) + } +} diff --git a/src/services/dynamic_qr.rs b/src/services/dynamic_qr.rs new file mode 100644 index 000000000..25d2ce9ee --- /dev/null +++ b/src/services/dynamic_qr.rs @@ -0,0 +1,158 @@ +use derive_builder::Builder; +use serde::{Deserialize, Serialize}; + +use crate::client::Mpesa; +use crate::constants::TransactionType; +use crate::environment::ApiEnvironment; +use crate::errors::{MpesaError, MpesaResult}; + +const DYNAMIC_QR_URL: &str = "/mpesa/qrcode/v1/generate"; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct DynamicQRRequest<'mpesa> { + /// Name of the Company/M-Pesa Merchant Name + pub merchant_name: &'mpesa str, + /// Transaction Reference Number + pub ref_no: &'mpesa str, + /// The total amount of the transaction + pub amount: f64, + #[serde(rename = "TrxCode")] + /// Transaction Type + /// + /// This can be a `TransactionType` or a `&str` + /// The `&str` must be one of the following: + /// - `BG` for Buy Goods + /// - `PB` for Pay Bill + /// - `WA` Withdraw Cash + /// - `SM` Send Money (Mobile Number) + /// - `SB` Sent to Business. Business number CPI in MSISDN format. + pub transaction_type: TransactionType, + ///Credit Party Identifier. + /// + /// Can be a Mobile Number, Business Number, Agent + /// Till, Paybill or Business number, or Merchant Buy Goods. + #[serde(rename = "CPI")] + pub credit_party_identifier: &'mpesa str, + /// Size of the QR code image in pixels. + /// + /// QR code image will always be a square image. + pub size: &'mpesa str, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct DynamicQRResponse { + #[serde(rename(deserialize = "QRCode"))] + pub qr_code: String, + pub response_code: String, + pub response_description: String, +} + +/// Dynamic QR builder struct +#[derive(Builder, Debug, Clone)] +#[builder(build_fn(error = "MpesaError"))] +pub struct DynamicQR<'mpesa, Env: ApiEnvironment> { + #[builder(pattern = "immutable")] + client: &'mpesa Mpesa, + /// Name of the Company/M-Pesa Merchant Name + #[builder(setter(into))] + merchant_name: &'mpesa str, + /// Transaction Reference Number + #[builder(setter(into))] + amount: f64, + /// The total amount of the transaction + ref_no: &'mpesa str, + /// Transaction Type + /// + /// This can be a `TransactionType` or a `&str` + /// The `&str` must be one of the following: + /// - `BG` for Buy Goods + /// - `PB` for Pay Bill + /// - `WA` Withdraw Cash + /// - `SM` Send Money (Mobile Number) + /// - `SB` Sent to Business. Business number CPI in MSISDN format. + #[builder(try_setter, setter(into))] + transaction_type: TransactionType, + /// Credit Party Identifier. + /// Can be a Mobile Number, Business Number, Agent + /// Till, Paybill or Business number, or Merchant Buy Goods. + #[builder(setter(into))] + credit_party_identifier: &'mpesa str, + /// Size of the QR code image in pixels. + /// + /// QR code image will always be a square image. + #[builder(setter(into))] + size: &'mpesa str, +} + +impl<'mpesa, Env: ApiEnvironment> From> for DynamicQRRequest<'mpesa> { + fn from(express: DynamicQR<'mpesa, Env>) -> DynamicQRRequest<'mpesa> { + DynamicQRRequest { + merchant_name: express.merchant_name, + ref_no: express.ref_no, + amount: express.amount, + transaction_type: express.transaction_type, + credit_party_identifier: express.credit_party_identifier, + size: express.size, + } + } +} + +impl<'mpesa, Env: ApiEnvironment> DynamicQR<'mpesa, Env> { + pub(crate) fn builder(client: &'mpesa Mpesa) -> DynamicQRBuilder<'mpesa, Env> { + DynamicQRBuilder::default().client(client) + } + + /// # Build Dynamic QR + /// + /// Returns a `DynamicQR` which can be used to send a request + pub fn from_request( + client: &'mpesa Mpesa, + request: DynamicQRRequest<'mpesa>, + ) -> DynamicQR<'mpesa, Env> { + DynamicQR { + client, + merchant_name: request.merchant_name, + ref_no: request.ref_no, + amount: request.amount, + transaction_type: request.transaction_type, + credit_party_identifier: request.credit_party_identifier, + size: request.size, + } + } + + /// # Generate a Dynamic QR + /// + /// This enables Safaricom M-PESA customers who + /// have My Safaricom App or M-PESA app, to scan a QR (Quick Response) + /// code, to capture till number and amount then authorize to pay for goods + /// and services at select LIPA NA M-PESA (LNM) merchant outlets. + /// + /// # Response + /// A successful request returns a `DynamicQRResponse` type + /// which contains the QR code + /// + /// # Errors + /// Returns a `MpesaError` on failure + pub async fn send(self) -> MpesaResult { + let url = format!("{}{}", self.client.environment.base_url(), DYNAMIC_QR_URL); + + let response = self + .client + .http_client + .post(&url) + .bearer_auth(self.client.auth().await?) + .json::(&self.into()) + .send() + .await?; + + if response.status().is_success() { + let value = response.json::<_>().await?; + return Ok(value); + } + + let value = response.json().await?; + Err(MpesaError::MpesaDynamicQrError(value)) + } +} diff --git a/mpesa_core/src/services/express_request.rs b/src/services/express_request.rs similarity index 53% rename from mpesa_core/src/services/express_request.rs rename to src/services/express_request.rs index aee455cc7..d6340a78b 100644 --- a/mpesa_core/src/services/express_request.rs +++ b/src/services/express_request.rs @@ -1,34 +1,39 @@ -use crate::client::{Mpesa, MpesaResult}; -use crate::constants::CommandId; -use crate::errors::MpesaError; use chrono::prelude::Local; +use openssl::base64; use serde::{Deserialize, Serialize}; -use serde_json::Value; + +use crate::client::Mpesa; +use crate::constants::CommandId; +use crate::environment::ApiEnvironment; +use crate::errors::{MpesaError, MpesaResult}; + +/// Source: [test credentials](https://developer.safaricom.co.ke/test_credentials) +static DEFAULT_PASSKEY: &str = "bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919"; #[derive(Debug, Serialize)] -struct MpesaExpressRequestPayload<'a> { +struct MpesaExpressRequestPayload<'mpesa> { #[serde(rename(serialize = "BusinessShortCode"))] - business_short_code: &'a str, + business_short_code: &'mpesa str, #[serde(rename(serialize = "Password"))] - password: &'a str, + password: &'mpesa str, #[serde(rename(serialize = "Timestamp"))] - timestamp: &'a str, + timestamp: &'mpesa str, #[serde(rename(serialize = "TransactionType"))] transaction_type: CommandId, #[serde(rename(serialize = "Amount"))] - amount: u32, - #[serde(rename(serialize = "PartyA"))] - party_a: &'a str, - #[serde(rename(serialize = "PartyB"))] - party_b: &'a str, + 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: &'a str, + phone_number: &'mpesa str, #[serde(rename(serialize = "CallBackURL"))] - call_back_url: &'a str, + call_back_url: &'mpesa str, #[serde(rename(serialize = "AccountReference"))] - account_reference: &'a str, + account_reference: &'mpesa str, #[serde(rename(serialize = "TransactionDesc"))] - transaction_desc: &'a str, + transaction_desc: &'mpesa str, } #[derive(Debug, Clone, Deserialize)] @@ -45,22 +50,25 @@ pub struct MpesaExpressRequestResponse { pub response_description: String, } -pub struct MpesaExpressRequestBuilder<'a> { - business_short_code: &'a str, - client: &'a Mpesa, +pub struct MpesaExpressRequestBuilder<'mpesa, Env: ApiEnvironment> { + business_short_code: &'mpesa str, + client: &'mpesa Mpesa, transaction_type: Option, - amount: Option, - party_a: Option<&'a str>, - party_b: Option<&'a str>, - phone_number: Option<&'a str>, - callback_url: Option<&'a str>, - account_ref: Option<&'a str>, - transaction_desc: Option<&'a str>, - pass_key: Option<&'a str>, + 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>, + transaction_desc: Option<&'mpesa str>, + pass_key: Option<&'mpesa str>, } -impl<'a> MpesaExpressRequestBuilder<'a> { - pub fn new(client: &'a Mpesa, business_short_code: &'a str) -> MpesaExpressRequestBuilder<'a> { +impl<'mpesa, Env: ApiEnvironment> MpesaExpressRequestBuilder<'mpesa, Env> { + pub fn new( + client: &'mpesa Mpesa, + business_short_code: &'mpesa str, + ) -> MpesaExpressRequestBuilder<'mpesa, Env> { MpesaExpressRequestBuilder { client, business_short_code, @@ -77,23 +85,23 @@ impl<'a> MpesaExpressRequestBuilder<'a> { } /// Public method get the `business_short_code` - pub fn business_short_code(&'a self) -> &'a str { + pub fn business_short_code(&'mpesa self) -> &'mpesa str { self.business_short_code } /// 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(&'a self) -> &'a str { + fn get_pass_key(&'mpesa self) -> &'mpesa str { if let Some(key) = self.pass_key { return key; } - "bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c919" + DEFAULT_PASSKEY } /// 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( + let encoded_password = base64::encode_block( format!( "{}{}{}", self.business_short_code(), @@ -111,17 +119,18 @@ impl<'a> MpesaExpressRequestBuilder<'a> { /// /// # Errors /// If thee `pass_key` is invalid - pub fn pass_key(mut self, pass_key: &'a str) -> MpesaExpressRequestBuilder<'a> { + pub fn pass_key(mut self, pass_key: &'mpesa str) -> MpesaExpressRequestBuilder<'mpesa, Env> { self.pass_key = Some(pass_key); self } - /// Amount to be transacted - /// - /// # Errors - /// If `amount` is invalid - pub fn amount(mut self, amount: u32) -> MpesaExpressRequestBuilder<'a> { - self.amount = Some(amount); + /// Adds an `amount` to the request + /// This is a required field + pub fn amount>( + mut self, + amount: Number, + ) -> MpesaExpressRequestBuilder<'mpesa, Env> { + self.amount = Some(amount.into()); self } @@ -129,7 +138,10 @@ impl<'a> MpesaExpressRequestBuilder<'a> { /// /// # Errors /// If `phone_number` is invalid - pub fn phone_number(mut self, phone_number: &'a str) -> MpesaExpressRequestBuilder<'a> { + pub fn phone_number( + mut self, + phone_number: &'mpesa str, + ) -> MpesaExpressRequestBuilder<'mpesa, Env> { self.phone_number = Some(phone_number); self } @@ -138,7 +150,10 @@ impl<'a> MpesaExpressRequestBuilder<'a> { /// /// # Errors /// If the `callback_url` is invalid - pub fn callback_url(mut self, callback_url: &'a str) -> MpesaExpressRequestBuilder<'a> { + pub fn callback_url( + mut self, + callback_url: &'mpesa str, + ) -> MpesaExpressRequestBuilder<'mpesa, Env> { self.callback_url = Some(callback_url); self } @@ -147,7 +162,7 @@ impl<'a> MpesaExpressRequestBuilder<'a> { /// /// # Errors /// If `party_a` is invalid - pub fn party_a(mut self, party_a: &'a str) -> MpesaExpressRequestBuilder<'a> { + pub fn party_a(mut self, party_a: &'mpesa str) -> MpesaExpressRequestBuilder<'mpesa, Env> { self.party_a = Some(party_a); self } @@ -156,13 +171,16 @@ impl<'a> MpesaExpressRequestBuilder<'a> { /// /// # Errors /// If `party_b` is invalid - pub fn party_b(mut self, party_b: &'a str) -> MpesaExpressRequestBuilder<'a> { + pub fn party_b(mut self, party_b: &'mpesa str) -> MpesaExpressRequestBuilder<'mpesa, Env> { self.party_b = Some(party_b); self } /// Optional - Used with M-Pesa PayBills. - pub fn account_ref(mut self, account_ref: &'a str) -> MpesaExpressRequestBuilder<'a> { + pub fn account_ref( + mut self, + account_ref: &'mpesa str, + ) -> MpesaExpressRequestBuilder<'mpesa, Env> { self.account_ref = Some(account_ref); self } @@ -171,29 +189,38 @@ impl<'a> MpesaExpressRequestBuilder<'a> { /// /// # Errors /// If the `CommandId` is invalid - pub fn transaction_type(mut self, command_id: CommandId) -> MpesaExpressRequestBuilder<'a> { + pub fn transaction_type( + mut self, + command_id: CommandId, + ) -> MpesaExpressRequestBuilder<'mpesa, Env> { self.transaction_type = Some(command_id); self } /// A description of the transaction. /// Optional - defaults to "None" - pub fn transaction_desc(mut self, description: &'a str) -> MpesaExpressRequestBuilder<'a> { + pub fn transaction_desc( + mut self, + description: &'mpesa str, + ) -> MpesaExpressRequestBuilder<'mpesa, Env> { self.transaction_desc = Some(description); self } - /// *Lipa na M-Pesa Online Payment / Mpesa Express/ Stk push* + /// # 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 + /// /// # Errors /// Returns a `MpesaError` on failure #[allow(clippy::or_fun_call)] - pub fn send(self) -> MpesaResult { + #[allow(clippy::unnecessary_lazy_evaluations)] + pub async fn send(self) -> MpesaResult { let url = format!( "{}/mpesa/stkpush/v1/processrequest", - self.client.environment().base_url() + self.client.environment.base_url() ); let (password, timestamp) = self.generate_password_and_timestamp(); @@ -202,33 +229,47 @@ impl<'a> MpesaExpressRequestBuilder<'a> { business_short_code: self.business_short_code, password: &password, timestamp: ×tamp, - amount: self.amount.unwrap_or(10), - party_a: self.party_a.unwrap_or(self.phone_number.unwrap_or("None")), - party_b: self.party_b.unwrap_or(self.business_short_code), - phone_number: self.phone_number.unwrap_or("None"), - call_back_url: self.callback_url.unwrap_or("None"), - account_reference: self.account_ref.unwrap_or("None"), + 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("None"), + .unwrap_or_else(|| CommandId::CustomerPayBillOnline), + transaction_desc: self.transaction_desc.unwrap_or_else(|| stringify!(None)), }; let response = self .client .http_client .post(&url) - .bearer_auth(self.client.auth()?) + .bearer_auth(self.client.auth().await?) .json(&payload) - .send()? - .error_for_status()?; + .send() + .await?; if response.status().is_success() { - let value: MpesaExpressRequestResponse = response.json()?; + let value = response.json::<_>().await?; return Ok(value); } - let value: Value = response.json()?; + let value = response.json().await?; Err(MpesaError::MpesaExpressRequestError(value)) } } diff --git a/mpesa_core/src/services/mod.rs b/src/services/mod.rs similarity index 56% rename from mpesa_core/src/services/mod.rs rename to src/services/mod.rs index 3d8cc46d8..b688f819f 100644 --- a/mpesa_core/src/services/mod.rs +++ b/src/services/mod.rs @@ -11,20 +11,39 @@ //! 4. [C2B Register](https://developer.safaricom.co.ke/docs?shell#c2b-api) //! 5. [C2B Simulate](https://developer.safaricom.co.ke/docs#account-balance-api) //! 6. [Mpesa Express/ STK Push](https://developer.safaricom.co.ke/docs#lipa-na-m-pesa-online-payment) -//! -//! Also worth noting I am using `reqwest::blocking::Client` to make http requests. Research ongoing -//! on how to make this crate async. +//! 7. [Transaction Reversal](https://developer.safaricom.co.ke/docs#reversal) +//! 8. [Bill Manager](https://developer.safaricom.co.ke/APIs/BillManager) +//! 9. [Transaction Status](https://developer.safaricom.co.ke/docs#transaction-status) +//! 10. [Dynamic QR](https://developer.safaricom.co.ke/APIs/DynamicQRCode) mod account_balance; mod b2b; mod b2c; +mod bill_manager; mod c2b_register; mod c2b_simulate; +mod dynamic_qr; mod express_request; +mod transaction_reversal; +mod transaction_status; +#[cfg(feature = "account_balance")] pub use account_balance::{AccountBalanceBuilder, AccountBalanceResponse}; +#[cfg(feature = "b2b")] pub use b2b::{B2bBuilder, B2bResponse}; +#[cfg(feature = "b2c")] pub use b2c::{B2cBuilder, B2cResponse}; +#[cfg(feature = "bill_manager")] +pub use bill_manager::*; +#[cfg(feature = "c2b_register")] pub use c2b_register::{C2bRegisterBuilder, C2bRegisterResponse}; +#[cfg(feature = "c2b_simulate")] pub use c2b_simulate::{C2bSimulateBuilder, C2bSimulateResponse}; +#[cfg(feature = "dynamic_qr")] +pub use dynamic_qr::{DynamicQR, DynamicQRBuilder, DynamicQRRequest, DynamicQRResponse}; +#[cfg(feature = "express_request")] pub use express_request::{MpesaExpressRequestBuilder, MpesaExpressRequestResponse}; +#[cfg(feature = "transaction_reversal")] +pub use transaction_reversal::{TransactionReversalBuilder, 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 new file mode 100644 index 000000000..0080485ce --- /dev/null +++ b/src/services/transaction_reversal.rs @@ -0,0 +1,222 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ApiEnvironment, CommandId, IdentifierTypes, Mpesa, MpesaError, MpesaResult}; + +#[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(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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionReversalResponse { + #[serde(rename(deserialize = "ConversationID"))] + pub conversation_id: String, + #[serde(rename(deserialize = "OriginatorConversationID"))] + pub originator_conversation_id: String, + #[serde(rename(deserialize = "ResponseDescription"))] + pub response_description: String, +} + +#[derive(Debug)] +pub struct TransactionReversalBuilder<'mpesa, Env: ApiEnvironment> { + client: &'mpesa Mpesa, + 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>, + occasion: Option<&'mpesa str>, + amount: Option, +} + +impl<'mpesa, Env: ApiEnvironment> TransactionReversalBuilder<'mpesa, Env> { + /// Creates new `TransactionReversalBuilder` + pub fn new( + client: &'mpesa Mpesa, + initiator: &'mpesa str, + ) -> TransactionReversalBuilder<'mpesa, Env> { + 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 + } + + /// 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 + } + + /// 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 + } + + /// # Transaction Reversal API + /// + /// Requests for transaction reversal + /// + /// This API enables reversal of a B2B, B2C or C2B M-Pesa transaction + /// Required parameters: + /// + /// `transaction_id`: This is the Mpesa Transaction ID of the transaction which you wish to reverse + /// + /// `amount` : The amount transacted in the transaction to be reversed , down to the cent + /// + /// `receiver_party`: Your organization's short code. + /// + /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/Documentation) + /// + /// A successful request returns a `TransactionReversalResponse` type + /// + /// # Errors + /// Returns a `MpesaError` on failure. + pub async fn send(self) -> MpesaResult { + let url = format!( + "{}/mpesa/reversal/v1/request", + self.client.environment.base_url() + ); + + 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::ShortCode), + 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"))?, + }; + + let response = self + .client + .http_client + .post(&url) + .bearer_auth(self.client.auth().await?) + .json(&payload) + .send() + .await?; + + if !response.status().is_success() { + let value = response.json().await?; + return Err(MpesaError::MpesaTransactionReversalError(value)); + }; + + let response = response.json::<_>().await?; + Ok(response) + } +} diff --git a/src/services/transaction_status.rs b/src/services/transaction_status.rs new file mode 100644 index 000000000..5a7a50b99 --- /dev/null +++ b/src/services/transaction_status.rs @@ -0,0 +1,202 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ApiEnvironment, CommandId, IdentifierTypes, Mpesa, MpesaError, MpesaResult}; + +#[derive(Debug, Serialize)] +pub struct TransactionStatusPayload<'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 = "PartyA")] + party_a: &'mpesa str, + #[serde(rename(serialize = "IdentifierType"))] + 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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionStatusResponse { + #[serde(rename(deserialize = "ConversationID"))] + pub conversation_id: String, + #[serde(rename(deserialize = "OriginatorConversationID"))] + pub originator_conversation_id: String, + #[serde(rename(deserialize = "ResponseDescription"))] + pub response_description: String, +} + +#[derive(Debug)] +pub struct TransactionStatusBuilder<'mpesa, Env: ApiEnvironment> { + client: &'mpesa Mpesa, + initiator: &'mpesa str, + command_id: Option, + transaction_id: Option<&'mpesa str>, + party_a: Option<&'mpesa str>, + identifier_type: Option, + result_url: Option<&'mpesa str>, + timeout_url: Option<&'mpesa str>, + remarks: Option<&'mpesa str>, + occasion: Option<&'mpesa str>, +} + +impl<'mpesa, Env: ApiEnvironment> TransactionStatusBuilder<'mpesa, Env> { + /// Creates new `TransactionStatusBuilder` + pub fn new( + client: &'mpesa Mpesa, + initiator: &'mpesa str, + ) -> TransactionStatusBuilder<'mpesa, Env> { + TransactionStatusBuilder { + client, + initiator, + command_id: None, + transaction_id: None, + party_a: None, + identifier_type: None, + result_url: None, + timeout_url: None, + remarks: None, + occasion: None, + } + } + + /// Adds `CommandId`. Defaults to `CommandId::TransactionStatus` 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 party_a(mut self, party_a: &'mpesa str) -> Self { + self.party_a = Some(party_a); + self + } + + /// Type of organization receiving the transaction + /// + /// This is an optional field, defaults to `IdentifierTypes::ShortCode` + pub fn identifier_type(mut self, identifier_type: IdentifierTypes) -> Self { + self.identifier_type = Some(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 optional field, defaults to "None" + pub fn remarks(mut self, remarks: &'mpesa str) -> Self { + self.remarks = Some(remarks); + self + } + + /// 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 + } + + /// # Transaction Status API + /// + /// Requests for the status of a transaction + /// + /// This API enables the status of a B2B, B2C or C2B M-Pesa transaction + /// Required parameters: + /// + /// `transaction_id`: This is the Mpesa Transaction ID of the transaction which you wish to reverse + /// + /// + /// See more from the Safaricom API docs [here](https://developer.safaricom.co.ke/Documentation) + /// + /// A successful request returns a `TransactionStatusResponse` type + /// + /// # Errors + /// Returns a `MpesaError` on failure. + pub async fn send(self) -> MpesaResult { + let url = format!( + "{}/mpesa/transactionstatus/v1/query", + self.client.environment.base_url() + ); + + let credentials = self.client.gen_security_credentials()?; + + let payload = TransactionStatusPayload { + initiator: self.initiator, + security_credentials: &credentials, + command_id: self.command_id.unwrap_or(CommandId::TransactionStatusQuery), + transaction_id: self + .transaction_id + .ok_or(MpesaError::Message("transaction_id is required"))?, + party_a: self + .party_a + .ok_or(MpesaError::Message("party_a is required"))?, + identifier_type: self.identifier_type.unwrap_or(IdentifierTypes::ShortCode), + 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)), + }; + + let response = self + .client + .http_client + .post(&url) + .bearer_auth(self.client.auth().await?) + .json(&payload) + .send() + .await?; + + if !response.status().is_success() { + let value = response.json().await?; + return Err(MpesaError::MpesaTransactionStatusError(value)); + }; + + let response = response.json::<_>().await?; + Ok(response) + } +} diff --git a/tests/mpesa-rust/account_balance_test.rs b/tests/mpesa-rust/account_balance_test.rs new file mode 100644 index 000000000..ebbc65a9d --- /dev/null +++ b/tests/mpesa-rust/account_balance_test.rs @@ -0,0 +1,131 @@ +use mpesa::MpesaError; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::get_mpesa_client; + +#[tokio::test] +async fn account_balance_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/accountbalance/v1/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(1) + .mount(&server) + .await; + let response = client + .account_balance("testapi496") + .result_url("https://testdomain.com/ok") + .timeout_url("https://testdomain.com/err") + .party_a("600496") + .send() + .await + .unwrap(); + 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." + ); + assert_eq!(response.response_code, "0"); +} + +#[tokio::test] +async fn account_balance_fails_if_party_a_is_not_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.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/accountbalance/v1/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .account_balance("testapi496") + .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, "party_a is required") + } else { + panic!("Expected error"); + } +} + +#[tokio::test] +async fn account_balance_fails_if_result_url_is_not_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.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/accountbalance/v1/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .account_balance("testapi496") + .party_a("600496") + .timeout_url("https://testdomain.com/err") + .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"); + } +} + +#[tokio::test] +async fn account_balance_fails_if_queue_timeout_url_is_not_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.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/accountbalance/v1/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .account_balance("testapi496") + .party_a("600496") + .result_url("https://testdomain.com/ok") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "queue_timeout_url is required") + } else { + panic!("Expected error"); + } +} diff --git a/tests/mpesa-rust/b2b_test.rs b/tests/mpesa-rust/b2b_test.rs new file mode 100644 index 000000000..efd0d8a51 --- /dev/null +++ b/tests/mpesa-rust/b2b_test.rs @@ -0,0 +1,143 @@ +use mpesa::MpesaError; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::get_mpesa_client; + +#[tokio::test] +async fn b2b_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/b2b/v1/paymentrequest")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(1) + .mount(&server) + .await; + let response = client + .b2b("testapi496") + .party_a("600496") + .party_b("600000") + .result_url("https://testdomain.com/ok") + .timeout_url("https://testdomain.com/err") + .account_ref("254708374149") + .amount(1000) + .send() + .await + .unwrap(); + 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." + ); + assert_eq!(response.response_code, "0"); +} + +#[tokio::test] +async fn b2b_fails_if_no_amount_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.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/b2b/v1/paymentrequest")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .b2b("testapi496") + .party_a("600496") + .party_b("600000") + .result_url("https://testdomain.com/ok") + .timeout_url("https://testdomain.com/err") + .account_ref("254708374149") + .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 b2b_fails_if_no_party_a_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.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/b2b/v1/paymentrequest")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .b2b("testapi496") + .party_b("600000") + .amount(1000) + .result_url("https://testdomain.com/ok") + .timeout_url("https://testdomain.com/err") + .account_ref("254708374149") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "party_a is required"); + } else { + panic!("Expected error"); + } +} + +#[tokio::test] +async fn b2b_fails_if_no_party_b_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.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/b2b/v1/paymentrequest")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .b2b("testapi496") + .party_a("600496") + .amount(1000) + .result_url("https://testdomain.com/ok") + .timeout_url("https://testdomain.com/err") + .account_ref("254708374149") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "party_b is required"); + } else { + panic!("Expected error"); + } +} diff --git a/tests/mpesa-rust/b2c_test.rs b/tests/mpesa-rust/b2c_test.rs new file mode 100644 index 000000000..39dec8ed4 --- /dev/null +++ b/tests/mpesa-rust/b2c_test.rs @@ -0,0 +1,205 @@ +use mpesa::MpesaError; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::get_mpesa_client; + +#[tokio::test] +async fn b2c_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/b2c/v1/paymentrequest")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(1) + .mount(&server) + .await; + let response = client + .b2c("testapi496") + .party_a("600496") + .party_b("254708374149") + .result_url("https://testdomain.com/ok") + .timeout_url("https://testdomain.com/err") + .amount(1000) + .send() + .await + .unwrap(); + 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." + ); + assert_eq!(response.response_code, "0"); +} + +#[tokio::test] +async fn b2c_fails_if_no_amount_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.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/b2c/v1/paymentrequest")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .b2c("testapi496") + .party_a("600496") + .party_b("254708374149") + .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, "amount is required"); + } else { + panic!("Expected error"); + } +} + +#[tokio::test] +async fn b2c_fails_if_no_party_a_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.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/b2c/v1/paymentrequest")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .b2c("testapi496") + .amount(1000) + .party_b("254708374149") + .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, "party_a is required"); + } else { + panic!("Expected error"); + } +} + +#[tokio::test] +async fn b2c_fails_if_no_party_b_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.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/b2c/v1/paymentrequest")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .b2c("testapi496") + .amount(1000) + .party_a("600496") + .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, "party_b is required"); + } else { + panic!("Expected error"); + } +} + +#[tokio::test] +async fn b2c_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.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/b2c/v1/paymentrequest")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .b2c("testapi496") + .amount(1000) + .party_a("600496") + .party_b("254708374149") + .timeout_url("https://testdomain.com/err") + .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"); + } +} + +#[tokio::test] +async fn b2c_fails_if_no_queue_timeout_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.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/b2c/v1/paymentrequest")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .b2c("testapi496") + .amount(1000) + .party_a("600496") + .party_b("254708374149") + .result_url("https://testdomain.com/ok") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "queue_timeout_url is required"); + } else { + panic!("Expected error"); + } +} diff --git a/tests/mpesa-rust/bill_manager_test.rs b/tests/mpesa-rust/bill_manager_test.rs new file mode 100644 index 000000000..8bc018adf --- /dev/null +++ b/tests/mpesa-rust/bill_manager_test.rs @@ -0,0 +1,6 @@ +mod bulk_invoice_test; +mod cancel_invoice_test; +mod onboard_modify_test; +mod onboard_test; +mod reconciliation_test; +mod single_invoice_test; diff --git a/tests/mpesa-rust/bill_manager_test/bulk_invoice_test.rs b/tests/mpesa-rust/bill_manager_test/bulk_invoice_test.rs new file mode 100644 index 000000000..6e05928e9 --- /dev/null +++ b/tests/mpesa-rust/bill_manager_test/bulk_invoice_test.rs @@ -0,0 +1,68 @@ +use chrono::prelude::Utc; +use mpesa::{Invoice, InvoiceItem, MpesaError}; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::get_mpesa_client; + +fn sample_response() -> ResponseTemplate { + let sample_response = json!({ + "rescode": "200", + "resmsg": "Success", + "Status_Message": "Invoice sent successfully" + }); + ResponseTemplate::new(200).set_body_json(sample_response) +} + +#[tokio::test] +async fn bulk_invoice_success() { + let (client, server) = get_mpesa_client!(); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/bulk-invoicing")) + .respond_with(sample_response()) + .expect(1) + .mount(&server) + .await; + let response = client + .bulk_invoice() + .invoices(vec![Invoice { + amount: 1000.0, + account_reference: "John Doe", + billed_full_name: "John Doe", + billed_period: "August 2021", + billed_phone_number: "0712345678", + due_date: Utc::now(), + external_reference: "INV2345", + invoice_items: Some(vec![InvoiceItem { + amount: 1000.0, + item_name: "An item", + }]), + invoice_name: "Invoice 001", + }]) + .send() + .await + .unwrap(); + assert_eq!(response.response_code, "200"); + assert_eq!(response.response_message, "Success"); + assert_eq!(response.status_message, "Invoice sent successfully"); +} + +#[tokio::test] +async fn bulk_invoice_fails_if_invoices_is_empty() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/bulk-invoicing")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client.bulk_invoice().send().await { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "invoices cannot be empty"); + } else { + panic!("Expected Error") + } +} diff --git a/tests/mpesa-rust/bill_manager_test/cancel_invoice_test.rs b/tests/mpesa-rust/bill_manager_test/cancel_invoice_test.rs new file mode 100644 index 000000000..201bbce3c --- /dev/null +++ b/tests/mpesa-rust/bill_manager_test/cancel_invoice_test.rs @@ -0,0 +1,35 @@ +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::get_mpesa_client; + +fn sample_response() -> ResponseTemplate { + let sample_response = json!({ + "rescode": "200", + "resmsg": "Success", + "Status_Message": "Invoice cancelled successfully" + }); + ResponseTemplate::new(200).set_body_json(sample_response) +} + +#[tokio::test] +async fn cancel_invoice_success() { + let (client, server) = get_mpesa_client!(); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/cancel-single-invoice")) + .respond_with(sample_response()) + .expect(1) + .mount(&server) + .await; + let response = client + .cancel_invoice() + .external_references(vec!["9KLSS011"]) + .external_reference("87TH7JK1") + .send() + .await + .unwrap(); + assert_eq!(response.response_code, "200"); + assert_eq!(response.response_message, "Success"); + assert_eq!(response.status_message, "Invoice cancelled successfully"); +} diff --git a/tests/mpesa-rust/bill_manager_test/onboard_modify_test.rs b/tests/mpesa-rust/bill_manager_test/onboard_modify_test.rs new file mode 100644 index 000000000..3e0376940 --- /dev/null +++ b/tests/mpesa-rust/bill_manager_test/onboard_modify_test.rs @@ -0,0 +1,34 @@ +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::get_mpesa_client; + +fn sample_response() -> ResponseTemplate { + let sample_response_body = json!({ + "rescode": "200", + "resmsg": "Biller updated successfully" + }); + ResponseTemplate::new(200).set_body_json(sample_response_body) +} + +#[tokio::test] +async fn onboard_modify_success() { + let (client, server) = get_mpesa_client!(); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/change-optin-details")) + .respond_with(sample_response()) + .expect(1) + .mount(&server) + .await; + let response = client + .onboard_modify() + .callback_url("https://testdomain.com/true") + .email("email@test.com") + .logo("https://file.domain/file.png") + .send() + .await + .unwrap(); + assert_eq!(response.response_code, "200"); + assert_eq!(response.response_message, "Biller updated successfully"); +} diff --git a/tests/mpesa-rust/bill_manager_test/onboard_test.rs b/tests/mpesa-rust/bill_manager_test/onboard_test.rs new file mode 100644 index 000000000..b9a813112 --- /dev/null +++ b/tests/mpesa-rust/bill_manager_test/onboard_test.rs @@ -0,0 +1,174 @@ +use mpesa::MpesaError; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::get_mpesa_client; + +fn sample_response() -> ResponseTemplate { + let sample_response_body = json!({ + "app_key": "kfpB9X4o0H", + "rescode": "200", + "resmsg": "Success" + }); + ResponseTemplate::new(200).set_body_json(sample_response_body) +} + +#[tokio::test] +async fn onboard_success() { + let (client, server) = get_mpesa_client!(); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/optin")) + .respond_with(sample_response()) + .expect(1) + .mount(&server) + .await; + let response = client + .onboard() + .callback_url("https://testdomain.com/true") + .email("email@test.com") + .logo("https://file.domain/file.png") + .official_contact("0712345678") + .short_code("600496") + .send() + .await + .unwrap(); + assert_eq!(response.app_key, "kfpB9X4o0H"); + assert_eq!(response.response_code, "200"); + assert_eq!(response.response_message, "Success"); +} + +#[tokio::test] +async fn onboard_fails_if_no_callback_url_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/optin")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .onboard() + .email("email@test.com") + .logo("https://file.domain/file.png") + .official_contact("0712345678") + .short_code("600496") + .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") + } +} + +#[tokio::test] +async fn onboard_fails_if_no_email_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/optin")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .onboard() + .callback_url("https://testdomain.com/true") + .logo("https://file.domain/file.png") + .official_contact("0712345678") + .short_code("600496") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "email is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn onboard_fails_if_no_logo_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/optin")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .onboard() + .callback_url("https://testdomain.com/true") + .email("email@test.com") + .official_contact("0712345678") + .short_code("600496") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "logo is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn onboard_fails_if_no_official_contact_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/optin")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .onboard() + .callback_url("https://testdomain.com/true") + .email("email@test.com") + .logo("https://file.domain/file.png") + .short_code("600496") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "official_contact is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn onboard_fails_if_short_code_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/optin")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .onboard() + .callback_url("https://testdomain.com/true") + .email("email@test.com") + .logo("https://file.domain/file.png") + .official_contact("0712345678") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "short_code is required"); + } else { + panic!("Expected error") + } +} diff --git a/tests/mpesa-rust/bill_manager_test/reconciliation_test.rs b/tests/mpesa-rust/bill_manager_test/reconciliation_test.rs new file mode 100644 index 000000000..c555c8b1c --- /dev/null +++ b/tests/mpesa-rust/bill_manager_test/reconciliation_test.rs @@ -0,0 +1,281 @@ +use chrono::prelude::Utc; +use mpesa::MpesaError; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::get_mpesa_client; + +fn sample_response() -> ResponseTemplate { + let sample_response = json!({ + "rescode": "200", + "resmsg": "Success", + }); + ResponseTemplate::new(200).set_body_json(sample_response) +} + +#[tokio::test] +async fn reconciliation_success() { + let (client, server) = get_mpesa_client!(); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/reconciliation")) + .respond_with(sample_response()) + .expect(1) + .mount(&server) + .await; + let response = client + .reconciliation() + .account_reference("John Doe") + .external_reference("INV2345") + .full_name("John Doe") + .invoice_name("Invoice 001") + .paid_amount(1000.0) + .payment_date(Utc::now()) + .phone_number("0712345678") + .transaction_id("TRANSACTION_ID") + .send() + .await + .unwrap(); + assert_eq!(response.response_code, "200"); + assert_eq!(response.response_message, "Success"); +} + +#[tokio::test] +async fn reconciliation_fails_if_no_account_reference_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/reconciliation")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .reconciliation() + .external_reference("INV2345") + .full_name("John Doe") + .invoice_name("Invoice 001") + .paid_amount(1000.0) + .payment_date(Utc::now()) + .phone_number("0712345678") + .transaction_id("TRANSACTION_ID") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "account_reference is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn reconciliation_fails_if_no_external_reference_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/reconciliation")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .reconciliation() + .account_reference("John Doe") + .full_name("John Doe") + .invoice_name("Invoice 001") + .paid_amount(1000.0) + .payment_date(Utc::now()) + .phone_number("0712345678") + .transaction_id("TRANSACTION_ID") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "external_reference is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn reconciliation_fails_if_no_full_name_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/reconciliation")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .reconciliation() + .account_reference("John Doe") + .external_reference("INV2345") + .invoice_name("Invoice 001") + .paid_amount(1000.0) + .payment_date(Utc::now()) + .phone_number("0712345678") + .transaction_id("TRANSACTION_ID") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "full_name is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn reconciliation_fails_if_no_invoice_name_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/reconciliation")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .reconciliation() + .account_reference("John Doe") + .external_reference("INV2345") + .full_name("John Doe") + .paid_amount(1000.0) + .payment_date(Utc::now()) + .phone_number("0712345678") + .transaction_id("TRANSACTION_ID") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "invoice_name is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn reconciliation_fails_if_no_paid_amount_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/reconciliation")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .reconciliation() + .account_reference("John Doe") + .external_reference("INV2345") + .full_name("John Doe") + .invoice_name("Invoice 001") + .payment_date(Utc::now()) + .phone_number("0712345678") + .transaction_id("TRANSACTION_ID") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "paid_amount is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn reconciliation_fails_if_no_payment_date_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/reconciliation")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .reconciliation() + .account_reference("John Doe") + .external_reference("INV2345") + .full_name("John Doe") + .invoice_name("Invoice 001") + .paid_amount(1000.0) + .phone_number("0712345678") + .transaction_id("TRANSACTION_ID") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "payment_date is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn reconciliation_fails_if_no_phone_number_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/reconciliation")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .reconciliation() + .account_reference("John Doe") + .external_reference("INV2345") + .full_name("John Doe") + .invoice_name("Invoice 001") + .paid_amount(1000.0) + .payment_date(Utc::now()) + .transaction_id("TRANSACTION_ID") + .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") + } +} + +#[tokio::test] +async fn reconciliation_fails_if_no_transaction_id_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/reconciliation")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .reconciliation() + .account_reference("John Doe") + .external_reference("INV2345") + .full_name("John Doe") + .invoice_name("Invoice 001") + .paid_amount(1000.0) + .payment_date(Utc::now()) + .phone_number("0712345678") + .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") + } +} diff --git a/tests/mpesa-rust/bill_manager_test/single_invoice_test.rs b/tests/mpesa-rust/bill_manager_test/single_invoice_test.rs new file mode 100644 index 000000000..b876de4f9 --- /dev/null +++ b/tests/mpesa-rust/bill_manager_test/single_invoice_test.rs @@ -0,0 +1,287 @@ +use chrono::prelude::Utc; +use mpesa::{InvoiceItem, MpesaError}; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::get_mpesa_client; + +fn sample_response() -> ResponseTemplate { + let sample_response = json!({ + "rescode": "200", + "resmsg": "Success", + "Status_Message": "Invoice sent successfully" + }); + ResponseTemplate::new(200).set_body_json(sample_response) +} + +#[tokio::test] +async fn single_invoice_success() { + let (client, server) = get_mpesa_client!(); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/single-invoicing")) + .respond_with(sample_response()) + .expect(1) + .mount(&server) + .await; + let response = client + .single_invoice() + .amount(1000.0) + .account_reference("John Doe") + .billed_full_name("John Doe") + .billed_period("August 2021") + .billed_phone_number("0712345678") + .due_date(Utc::now()) + .external_reference("INV2345") + .invoice_items(vec![InvoiceItem { + amount: 1000.0, + item_name: "An item", + }]) + .invoice_name("Invoice 001") + .send() + .await + .unwrap(); + assert_eq!(response.response_code, "200"); + assert_eq!(response.response_message, "Success"); + assert_eq!(response.status_message, "Invoice sent successfully"); +} + +#[tokio::test] +async fn single_invoice_fails_if_no_amount_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/single-invoicing")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .single_invoice() + .account_reference("John Doe") + .billed_full_name("John Doe") + .billed_period("August 2021") + .billed_phone_number("0712345678") + .due_date(Utc::now()) + .external_reference("INV2345") + .invoice_name("Invoice 001") + .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 single_invoice_fails_if_no_account_reference_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/single-invoicing")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .single_invoice() + .amount(1000.0) + .billed_full_name("John Doe") + .billed_period("August 2021") + .billed_phone_number("0712345678") + .due_date(Utc::now()) + .external_reference("INV2345") + .invoice_name("Invoice 001") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "account_reference is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn single_invoice_fails_if_no_billed_full_name_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/single-invoicing")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .single_invoice() + .amount(1000.0) + .account_reference("John Doe") + .billed_period("August 2021") + .billed_phone_number("0712345678") + .due_date(Utc::now()) + .external_reference("INV2345") + .invoice_name("Invoice 001") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "billed_full_name is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn single_invoice_fails_if_no_billed_period_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/single-invoicing")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .single_invoice() + .amount(1000.0) + .account_reference("John Doe") + .billed_full_name("John Doe") + .billed_phone_number("0712345678") + .due_date(Utc::now()) + .external_reference("INV2345") + .invoice_name("Invoice 001") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "billed_period is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn single_invoice_fails_if_no_billed_phone_number_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/single-invoicing")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .single_invoice() + .amount(1000.0) + .account_reference("John Doe") + .billed_full_name("John Doe") + .billed_period("August 2021") + .due_date(Utc::now()) + .external_reference("INV2345") + .invoice_name("Invoice 001") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message but found {}", e); + }; + assert_eq!(msg, "billed_phone_number is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn single_invoice_fails_if_no_due_date_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/single-invoicing")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .single_invoice() + .amount(1000.0) + .account_reference("John Doe") + .billed_full_name("John Doe") + .billed_period("August 2021") + .billed_phone_number("0712345678") + .external_reference("INV2345") + .invoice_name("Invoice 001") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "due_date is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn single_invoice_fails_if_no_external_reference_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/single-invoicing")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .single_invoice() + .amount(1000.0) + .account_reference("John Doe") + .billed_full_name("John Doe") + .billed_period("August 2021") + .billed_phone_number("0712345678") + .due_date(Utc::now()) + .invoice_name("Invoice 001") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "external_reference is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn single_invoice_fails_if_no_invoice_name_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + Mock::given(method("POST")) + .and(path("/v1/billmanager-invoice/single-invoicing")) + .respond_with(sample_response()) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .single_invoice() + .amount(1000.0) + .account_reference("John Doe") + .billed_full_name("John Doe") + .billed_period("August 2021") + .billed_phone_number("0712345678") + .due_date(Utc::now()) + .external_reference("INV2345") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "invoice_name is required"); + } else { + panic!("Expected error") + } +} diff --git a/tests/mpesa-rust/c2b_register_test.rs b/tests/mpesa-rust/c2b_register_test.rs new file mode 100644 index 000000000..18bcee648 --- /dev/null +++ b/tests/mpesa-rust/c2b_register_test.rs @@ -0,0 +1,126 @@ +use mpesa::MpesaError; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::get_mpesa_client; + +#[tokio::test] +async fn c2b_register_success() { + let (client, server) = get_mpesa_client!(); + let sample_response_body = json!({ + "OriginatorConverstionID": "29464-48063588-1", + "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/c2b/v1/registerurl")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(1) + .mount(&server) + .await; + let response = client + .c2b_register() + .short_code("600496") + .confirmation_url("https://testdomain.com/true") + .validation_url("https://testdomain.com/valid") + .send() + .await + .unwrap(); + assert_eq!(response.originator_conversation_id, "29464-48063588-1"); + assert_eq!( + response.response_description, + "Accept the service request successfully." + ); + assert_eq!(response.response_code, "0"); +} + +#[tokio::test] +async fn c2b_register_fails_if_short_code_is_not_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + let sample_response_body = json!({ + "OriginatorConverstionID": "29464-48063588-1", + "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/c2b/v1/registerurl")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .c2b_register() + .confirmation_url("https://testdomain.com/true") + .validation_url("https://testdomain.com/valid") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "short_code is required"); + } else { + panic!("Expected error"); + } +} + +#[tokio::test] +async fn c2b_register_fails_if_confirmation_url_is_not_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + let sample_response_body = json!({ + "OriginatorConverstionID": "29464-48063588-1", + "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/c2b/v1/registerurl")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .c2b_register() + .short_code("600496") + .validation_url("https://testdomain.com/valid") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "confirmation_url is required"); + } else { + panic!("Expected error"); + } +} + +#[tokio::test] +async fn c2b_register_fails_if_validation_url_is_not_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + let sample_response_body = json!({ + "OriginatorConverstionID": "29464-48063588-1", + "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/c2b/v1/registerurl")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .c2b_register() + .short_code("600496") + .confirmation_url("https://testdomain.com/true") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "validation_url is required"); + } else { + panic!("Expected error"); + } +} diff --git a/tests/mpesa-rust/c2b_simulate_test.rs b/tests/mpesa-rust/c2b_simulate_test.rs new file mode 100644 index 000000000..0ff085188 --- /dev/null +++ b/tests/mpesa-rust/c2b_simulate_test.rs @@ -0,0 +1,162 @@ +use mpesa::MpesaError; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::get_mpesa_client; + +#[tokio::test] +async fn c2b_simulate_success() { + let (client, server) = get_mpesa_client!(); + let sample_response_body = json!({ + "OriginatorConversationID": "29464-48063588-1", + "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/c2b/v1/simulate")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(1) + .mount(&server) + .await; + let response = client + .c2b_simulate() + .amount(1000) + .bill_ref_number("2") + .msisdn("254700000000") + .short_code("600496") + .send() + .await + .unwrap(); + assert_eq!(response.originator_conversation_id, "29464-48063588-1"); + assert_eq!( + response.response_description, + "Accept the service request successfully." + ); + assert_eq!(response.response_code, "0"); + assert_eq!(response.conversation_id, None); +} + +#[tokio::test] +async fn c2b_simulate_fails_if_no_amount_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + let sample_response_body = json!({ + "OriginatorConversationID": "29464-48063588-1", + "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/c2b/v1/simulate")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .c2b_simulate() + .bill_ref_number("2") + .msisdn("254700000000") + .short_code("600496") + .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 c2b_simulate_fails_if_no_short_code_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + let sample_response_body = json!({ + "OriginatorConversationID": "29464-48063588-1", + "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/c2b/v1/simulate")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .c2b_simulate() + .amount(1000) + .bill_ref_number("2") + .msisdn("254700000000") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "short_code is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn c2b_simulate_fails_if_no_bill_ref_number_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + let sample_response_body = json!({ + "OriginatorConversationID": "29464-48063588-1", + "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/c2b/v1/simulate")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .c2b_simulate() + .amount(1000) + .msisdn("254700000000") + .short_code("600496") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "bill_ref_number is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn c2b_simulate_fails_if_no_msisdn_is_provided() { + let (client, server) = get_mpesa_client!(expected_auth_requests = 0); + let sample_response_body = json!({ + "OriginatorConversationID": "29464-48063588-1", + "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0" + }); + Mock::given(method("POST")) + .and(path("/mpesa/c2b/v1/simulate")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .c2b_simulate() + .amount(1000) + .bill_ref_number("2") + .short_code("600496") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "msisdn is required"); + } else { + panic!("Expected error") + } +} diff --git a/tests/mpesa-rust/dynamic_qr_tests.rs b/tests/mpesa-rust/dynamic_qr_tests.rs new file mode 100644 index 000000000..710fbe5de --- /dev/null +++ b/tests/mpesa-rust/dynamic_qr_tests.rs @@ -0,0 +1,82 @@ +use mpesa::services::{DynamicQR, DynamicQRRequest}; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::get_mpesa_client; + +#[tokio::test] +async fn dynamic_qr_code_test_using_builder_pattern() { + let (client, server) = get_mpesa_client!(); + + let sample_response_body = json!({ + "QRCode": "A3F7B1H", + "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0" + }); + + Mock::given(method("POST")) + .and(path("/mpesa/qrcode/v1/generate")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(1) + .mount(&server) + .await; + + let response = client + .dynamic_qr() + .amount(2000) + .credit_party_identifier("17408") + .merchant_name("SafaricomLTD") + .ref_no("rf38f04") + .size("300") + .try_transaction_type("bg") + //.transaction_type(TransactionType::BuyGoods) // This is the same as the above + .unwrap() + .build() + .unwrap() + .send() + .await + .unwrap(); + + assert_eq!( + response.response_description, + "Accept the service request successfully." + ); + assert_eq!(response.response_code, "0"); +} + +#[tokio::test] +async fn dynamic_qr_code_test_using_struct_initialization() { + let (client, server) = get_mpesa_client!(); + + let sample_response_body = json!({ + "QRCode": "A3F7B1H", + "ResponseDescription": "Accept the service request successfully.", + "ResponseCode": "0" + }); + + let request = DynamicQRRequest { + amount: 2000.0, + credit_party_identifier: "17408", + merchant_name: "SafaricomLTD", + ref_no: "rf38f04", + size: "300", + transaction_type: "bg".try_into().unwrap(), + }; + + Mock::given(method("POST")) + .and(path("/mpesa/qrcode/v1/generate")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(1) + .mount(&server) + .await; + + let response = DynamicQR::from_request(&client, request); + let response = response.send().await.unwrap(); + + assert_eq!( + response.response_description, + "Accept the service request successfully." + ); + assert_eq!(response.response_code, "0"); +} diff --git a/tests/mpesa-rust/helpers.rs b/tests/mpesa-rust/helpers.rs new file mode 100644 index 000000000..8626b03f3 --- /dev/null +++ b/tests/mpesa-rust/helpers.rs @@ -0,0 +1,116 @@ +use mpesa::ApiEnvironment; +use wiremock::MockServer; + +#[derive(Debug, Clone)] +pub struct TestEnvironment { + pub server_url: String, +} + +impl TestEnvironment { + pub async fn new(server: &MockServer) -> Self { + TestEnvironment { + server_url: server.uri(), + } + } +} + +impl ApiEnvironment for TestEnvironment { + fn base_url(&self) -> &str { + &self.server_url + } + + fn get_certificate(&self) -> &str { + include_str!("../../src/certificates/sandbox") + } +} + +#[macro_export] +macro_rules! get_mpesa_client { + () => {{ + use $crate::helpers::TestEnvironment; + use mpesa::Mpesa; + use wiremock::{MockServer, Mock, ResponseTemplate}; + use serde_json::json; + use wiremock::matchers::{path, query_param, method}; + + dotenv::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(), + test_environment, + ); + Mock::given(method("GET")) + .and(path("/oauth/v1/generate")) + .and(query_param("grant_type", "client_credentials")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "dummy_access_token", + "expires_in": "3600" + }))) + .mount(&server) + .await; + (client, server) + }}; + + (expected_auth_requests = $expected_requests: expr) => {{ + use $crate::helpers::TestEnvironment; + use mpesa::Mpesa; + use wiremock::{MockServer, Mock, ResponseTemplate}; + use serde_json::json; + use wiremock::matchers::{path, query_param, method}; + + dotenv::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(), + test_environment, + ); + Mock::given(method("GET")) + .and(path("/oauth/v1/generate")) + .and(query_param("grant_type", "client_credentials")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "dummy_access_token", + "expires_in": "3600" + }))) + .mount(&server) + .await; + (client, server) + }}; + + ($client_key:expr, $client_secret:expr) => {{ + use mpesa::{Environment, Mpesa}; + use std::str::FromStr; + dotenv::dotenv().ok(); + let client = Mpesa::new( + $client_key, + $client_secret, + Environment::from_str("sandbox").unwrap(), + ); + client + }}; + + ($client_key:expr, $client_secret:expr, $environment:expr) => {{ + use mpesa::{Environment, Mpesa}; + use std::str::FromStr; + dotenv::dotenv().ok(); + let client = Mpesa::new($client_key, $client_secret, $environment); + client + }}; +} + +#[cfg(test)] +mod tests { + use crate::get_mpesa_client; + + #[tokio::test] + async fn test_client_will_not_authenticate_with_wrong_credentials() { + let client = get_mpesa_client!( + "not a client key".to_string(), + "not a client secret".to_string() + ); + assert!(!client.is_connected().await); + } +} diff --git a/tests/mpesa-rust/main.rs b/tests/mpesa-rust/main.rs new file mode 100644 index 000000000..ed859273a --- /dev/null +++ b/tests/mpesa-rust/main.rs @@ -0,0 +1,21 @@ +#[cfg(test)] +mod account_balance_test; +#[cfg(test)] +mod b2b_test; +#[cfg(test)] +mod b2c_test; +#[cfg(test)] +mod bill_manager_test; +#[cfg(test)] +mod c2b_register_test; +#[cfg(test)] +mod c2b_simulate_test; + +mod dynamic_qr_tests; +mod helpers; +#[cfg(test)] +mod stk_push_test; +#[cfg(test)] +mod transaction_reversal_test; +#[cfg(test)] +mod transaction_status_test; diff --git a/tests/mpesa-rust/stk_push_test.rs b/tests/mpesa-rust/stk_push_test.rs new file mode 100644 index 000000000..dec5815da --- /dev/null +++ b/tests/mpesa-rust/stk_push_test.rs @@ -0,0 +1,138 @@ +use mpesa::MpesaError; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::get_mpesa_client; + +#[tokio::test] +async fn stk_push_success_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" + }); + Mock::given(method("POST")) + .and(path("/mpesa/stkpush/v1/processrequest")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(1) + .mount(&server) + .await; + let response = client + .express_request("174379") + .phone_number("254708374149") + .amount(500) + .callback_url("https://test.example.com/api") + .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 accepeted for processing" + ); +} + +#[tokio::test] +async fn stk_push_fails_if_no_amount_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") + .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") + .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"); + } +} + +#[tokio::test] +async fn stk_push_fails_if_no_phone_number_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") + .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"); + } +} diff --git a/tests/mpesa-rust/transaction_reversal_test.rs b/tests/mpesa-rust/transaction_reversal_test.rs new file mode 100644 index 000000000..017ac4583 --- /dev/null +++ b/tests/mpesa-rust/transaction_reversal_test.rs @@ -0,0 +1,199 @@ +use mpesa::MpesaError; +use serde_json::json; +use wiremock::matchers::{method, path}; +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.", + }); + Mock::given(method("POST")) + .and(path("/mpesa/reversal/v1/request")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(1) + .mount(&server) + .await; + let response = client + .transaction_reversal("testapi496") + .result_url("https://testdomain.com/ok") + .timeout_url("https://testdomain.com/err") + .transaction_id("OEI2AK4Q16") + .amount(1.0) + .receiver_party("600111") + .remarks("wrong recipient") + .send() + .await + .unwrap(); + 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." + ); +} + +#[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); + 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") + .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"); + } +} + +#[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") + .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"); + } +} + +#[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"); + } +} diff --git a/tests/mpesa-rust/transaction_status_test.rs b/tests/mpesa-rust/transaction_status_test.rs new file mode 100644 index 000000000..46f0aa008 --- /dev/null +++ b/tests/mpesa-rust/transaction_status_test.rs @@ -0,0 +1,163 @@ +use mpesa::MpesaError; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +use crate::get_mpesa_client; + +#[tokio::test] +async fn transaction_status_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.", + }); + Mock::given(method("POST")) + .and(path("/mpesa/transactionstatus/v1/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(1) + .mount(&server) + .await; + let response = client + .transaction_status("testapi496") + .result_url("https://testdomain.com/ok") + .timeout_url("https://testdomain.com/err") + .transaction_id("OEI2AK4Q16") + .party_a("600111") + .remarks("status") + .occasion("work") + .send() + .await + .unwrap(); + 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." + ); +} + +#[tokio::test] +async fn transaction_status_fails_if_transaction_id_is_not_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/transactionstatus/v1/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .transaction_status("testapi496") + .result_url("https://testdomain.com/ok") + .timeout_url("https://testdomain.com/err") + .party_a("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_status_fails_if_party_a_is_not_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/transactionstatus/v1/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .transaction_status("testapi496") + .result_url("https://testdomain.com/ok") + .timeout_url("https://testdomain.com/err") + .transaction_id("OEI2AK4Q16") + .send() + .await + { + let MpesaError::Message(msg) = e else { + panic!("Expected MpesaError::Message, but found {}", e); + }; + assert_eq!(msg, "party_a is required"); + } else { + panic!("Expected error") + } +} + +#[tokio::test] +async fn transaction_status_fails_if_result_url_is_not_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/transactionstatus/v1/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .transaction_status("testapi496") + .timeout_url("https://testdomain.com/err") + .transaction_id("OEI2AK4Q16") + .party_a("600111") + .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") + } +} + +#[tokio::test] +async fn transaction_status_fails_if_timeout_url_is_not_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/transactionstatus/v1/query")) + .respond_with(ResponseTemplate::new(200).set_body_json(sample_response_body)) + .expect(0) + .mount(&server) + .await; + if let Err(e) = client + .transaction_status("testapi496") + .result_url("https://testdomain.com/ok") + .transaction_id("OEI2AK4Q16") + .party_a("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") + } +}