├── .env.example ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md └── workflows │ ├── PR.yml │ └── rust.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Cargo.toml ├── LICENSE-MIT ├── README.md ├── SECURITY.md ├── docs ├── images │ └── paystack-rs.png └── uml │ └── paystack-rs.drawio ├── examples └── transaction.rs ├── scripts └── publish.sh ├── src ├── client.rs ├── endpoints │ ├── mod.rs │ ├── subaccount.rs │ ├── transaction.rs │ └── transaction_split.rs ├── errors.rs ├── http │ ├── base.rs │ ├── errors.rs │ ├── mod.rs │ └── reqwest.rs ├── lib.rs ├── macros │ └── mod.rs ├── models │ ├── bearer.rs │ ├── channel.rs │ ├── charge.rs │ ├── currency.rs │ ├── customer_model.rs │ ├── mod.rs │ ├── response.rs │ ├── split.rs │ ├── status.rs │ ├── subaccount_model.rs │ ├── transaction_model.rs │ └── transaction_split_model.rs └── utils.rs └── tests └── api ├── charge.rs ├── helpers.rs ├── main.rs ├── transaction.rs └── transaction_split.rs /.env.example: -------------------------------------------------------------------------------- 1 | PAYSTACK_API_KEY=your_paystack_key_here 2 | BANK_ACCOUNT=0000000001 3 | BANK_CODE=058 4 | BANK_NAME='Guaranty Trust Bank' 5 | BASE_URL='https://api.paystack.co' 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [morukele] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/PR.yml: -------------------------------------------------------------------------------- 1 | name: Rust test on PR fork 2 | on: 3 | pull_request_target: 4 | types: [opened, synchronize] 5 | env: 6 | CARGO_TERM_COLOR: always 7 | PAYSTACK_API_KEY: ${{secrets.PAYSTACK_API_KEY}} 8 | BANK_ACCOUNT: ${{secrets.BANK_ACCOUNT}} 9 | BANK_CODE: ${{secrets.BANK_CODE}} 10 | BANK_NAME: ${{secrets.BANK_NAME}} 11 | jobs: 12 | run-test-on-pr: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Get User Permission 16 | id: checkAccess 17 | uses: actions-cool/check-user-permission@v2 18 | with: 19 | require: write 20 | username: ${{ github.triggering_actor }} 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | - name: Check User Permission 24 | if: steps.checkAccess.outputs.require-result == 'false' 25 | run: | 26 | echo "${{ github.triggering_actor }} does not have permission on this repo." 27 | echo "Current permission level is ${{ steps.checkAccess.outputs.user-permission }}" 28 | echo "Job originally submitted by ${{ github.actor }}" 29 | exit 1 30 | - name: Checkout code 31 | uses: actions/checkout@v3 32 | with: 33 | ref: ${{ github.event.pull_request.head.sha }} 34 | - name: Build 35 | run: cargo build --verbose 36 | - name: Run tests 37 | run: cargo test --verbose 38 | 39 | coverage: 40 | runs-on: ubuntu-latest 41 | env: 42 | CARGO_TERM_COLOR: always 43 | steps: 44 | - uses: actions/checkout@v4 45 | - name: Install Rust 46 | run: rustup update stable 47 | - name: Install cargo-llvm-cov 48 | uses: taiki-e/install-action@cargo-llvm-cov 49 | - name: Generate code coverage 50 | run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info 51 | - name: Upload coverage to Codecov 52 | uses: codecov/codecov-action@v3 53 | with: 54 | files: lcov.info 55 | fail_ci_if_error: true 56 | 57 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | on: 3 | push: 4 | branches: [ "main", "master" ] 5 | env: 6 | CARGO_TERM_COLOR: always 7 | PAYSTACK_API_KEY: ${{secrets.PAYSTACK_API_KEY}} 8 | BANK_ACCOUNT: ${{secrets.BANK_ACCOUNT}} 9 | BANK_CODE: ${{secrets.BANK_CODE}} 10 | BANK_NAME: ${{secrets.BANK_NAME}} 11 | jobs: 12 | build-and-test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Build 17 | run: cargo build --verbose 18 | - name: Run tests 19 | run: cargo test --verbose 20 | coverage: 21 | runs-on: ubuntu-latest 22 | env: 23 | CARGO_TERM_COLOR: always 24 | steps: 25 | - uses: actions/checkout@v4 26 | - name: Install Rust 27 | run: rustup update stable 28 | - name: Install cargo-llvm-cov 29 | uses: taiki-e/install-action@cargo-llvm-cov 30 | - name: Generate code coverage 31 | run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info 32 | - name: Upload coverage to Codecov 33 | uses: codecov/codecov-action@v3 34 | with: 35 | files: lcov.info 36 | fail_ci_if_error: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /Cargo.lock 3 | .env 4 | .vscode 5 | .idea -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.2.3 (15/07/2024) 2 | 3 | - A complete rewrite of the paystack-rs crate. The rewrite is necessary because in the current state, the crate is not 4 | extendable and will be difficult to add support for sync and async code. The rewrite is to achieve the following objectives: 5 | - Make the code less complex (relatively speaking) 6 | - Improve the maintainability of the crate 7 | - Support both async and sync code 8 | - Use high level data construct when creating request body 9 | - Have extensive Rust type for every possible response from the API 10 | - Use advanced rust type and how a better understand of the Rust programming language (personal reason) 11 | 12 | - The following changes have been implemented 13 | - Change the project file layout to improve separation of concerns. The new layout includes the following 14 | - http (to handle all http related functionalities) 15 | - models (holds all request and response models from the API) 16 | - macros (utility macro to simplify code repetition) 17 | 18 | ## 0.2.2 (29/11/2023) 19 | 20 | - Added support for create customer API route -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | orukele.dev@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Paystack-rs 2 | 3 | Welcome to Paystack-rs! We're thrilled that you're interested in contributing to our open-source crate. By participating, you can help us improve and grow the project together. Please take a moment to review this document to ensure a smooth and productive collaboration. 4 | 5 | ## Code of Conduct 6 | 7 | Before you start contributing, please read and adhere to our [Code of Conduct](CODE_OF_CONDUCT.md). We are committed to providing a safe and respectful environment for all contributors. 8 | 9 | ## Getting Started 10 | 11 | 1. **Fork** the repository to your GitHub account. 12 | 2. **Clone** your forked repository to your local machine: 13 | 14 | ```bash 15 | git clone https://github.com/your-username/paystack-rs.git 16 | ``` 17 | 18 | 3. Create a new **branch** for your feature or bug fix: 19 | 20 | ```bash 21 | git checkout -b my-new-feature 22 | ``` 23 | 24 | 4. Make your changes, test them, and ensure that your Rust code is well-documented. 25 | 26 | 5. **Test your code**: Before committing your changes, make sure to test your Rust code thoroughly. We provide a testing environment that requires a .env file for configuration. You can create this file based on the format found in the .env.example file. Make sure to provide any required environment variables. 27 | 28 | ```bash 29 | cp .env.example .env 30 | # Edit the .env file with your configuration 31 | ``` 32 | 33 | 6. **Build and test the code** 34 | - Build the crate 35 | 36 | ```bash 37 | cargo build 38 | ``` 39 | 40 | - Test the crate 41 | 42 | ```bash 43 | cargo test 44 | ``` 45 | 46 | 7. **Commit** your changes with clear and concise messages 47 | 48 | ```bash 49 | git commit -m "Add feature: your feature name" 50 | ``` 51 | 52 | 8. **Push** your changes to your fork 53 | 54 | ```bash 55 | git push origin my-new-feature 56 | ``` 57 | 58 | 9. Create a **Pull Request (PR)** from your fork to the main project's repository. Make sure to describe your changes and reference any related issues. 59 | 60 | 10. After creating the PR, the maintainers will review your changes, provide feedback, and eventually merge the PR. 61 | 62 | ## Help and Support 63 | 64 | If you have any questions or need assistance, feel free to reach out to the maintainers or the community on our Discussion page. 65 | 66 | We look forward to your contributions and thank you for your interest in Paystack-rs! 67 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "paystack-rs" 3 | version = "0.2.2" 4 | description = "Paystack API Wrapper" 5 | authors = ["Oghenemarho Orukele "] 6 | edition = "2021" 7 | include = [ 8 | "src/**/*", 9 | "Cargo.toml", 10 | "README.md", 11 | "LICENCE" 12 | ] 13 | homepage = "https://github.com/morukele/paystack-rs" 14 | repository = "https://github.com/morukele/paystack-rs" 15 | documentation = "https://docs.rs/paystack-rs" 16 | keywords = [ 17 | "payment", 18 | "paystack", 19 | "api", 20 | "finance", 21 | "async" 22 | ] 23 | readme = "README.md" 24 | categories = ["api-bindings", "finance"] 25 | license = "MIT" 26 | 27 | [lib] 28 | name = "paystack" 29 | 30 | [dependencies] 31 | thiserror = "1" 32 | serde_json = "1" 33 | reqwest = { version = "0.12.5", features = ["json"] } 34 | tokio = { version = "1", features = ["full"] } 35 | serde = {version ="1", features = ["derive"]} 36 | log = "0.4.20" 37 | async-trait = "0.1.81" 38 | derive_builder = "0.20.0" 39 | 40 | [dev-dependencies] 41 | fake = "2" 42 | rand = "0.8" 43 | dotenv = "0.15.0" 44 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Orukele 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # paystack-rs 2 | 3 | [![Rust](https://github.com/morukele/paystack-rs/actions/workflows/rust.yml/badge.svg?branch=main)](https://github.com/morukele/paystack-rs/actions/workflows/rust.yml) 4 | [![paystack-rs on crates.io](https://img.shields.io/crates/v/paystack-rs.svg)](https://crates.io/crates/paystack-rs) 5 | [![paystack-rs on docs.rs](https://docs.rs/paystack-rs/badge.svg)](https://docs.rs/paystack-rs) 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 7 | 8 | >*NB: a core rewrite of the project is being carried out, the goal is to make the crate easier to maintain, 9 | improve the performance, abstract certain features to improve flexibility, and support for both blocking and non-blocking 10 | operations.* 11 | 12 | Convenient Rust bindings and types for the [Paystack](https://paystack.com) HTTP API aiming to support the entire API surface. Not the case? Please open an issue. I update the definitions on a weekly basis. 13 | 14 | The client aims to make receiving payments for African business or business with African clients building with Rust as hassle-free as possible. 15 | 16 | **Note** : While the crate aims to support sync and async use cases, only async use case is supported at the moment. 17 | 18 | The client currently covers the following section of the API, and the sections to be implemented in order are left unchecked: 19 | 20 | - [x] Transaction 21 | - [x] Transaction Split 22 | - [ ] Terminal 23 | - [ ] Customers 24 | - [ ] Dedicated Virtual Account 25 | - [ ] Apple Pay 26 | - [ ] Subaccounts 27 | - [ ] Plans 28 | - [ ] Subscriptions 29 | - [ ] Transfer Recipients 30 | - [ ] Transfers 31 | - [ ] Transfers Control 32 | - [ ] Bulk Charges 33 | - [ ] Integration 34 | - [ ] Charge 35 | - [ ] Disputes 36 | - [ ] Refunds 37 | - [ ] Verifications 38 | - [ ] Miscellaneous 39 | 40 | ## Documentation 41 | 42 | See the [Rust API docs](https://docs.rs/paystack-rs) or the [examples](/examples). 43 | 44 | ## Installation 45 | 46 | `paystack-rs` uses the `reqwest` HTTP client under the hood and the `tokio` runtime for async operations. 47 | 48 | ```toml 49 | [dependencies] 50 | paystack-rs = "0.x.x" 51 | ``` 52 | 53 | You can also download the source code and use in your code base directly if you prefer. 54 | 55 | ## Usage 56 | 57 | Initializing an instance of the Paystack client and creating a transaction. 58 | 59 | ```rust 60 | use std::env; 61 | use dotenv::dotenv; 62 | use paystack::{PaystackClient, InitializeTransactionBodyBuilder, PaystackAPIError, Currency, Channel, ReqwestClient}; 63 | 64 | 65 | #[tokio::main] 66 | async fn main() -> Result<(), PaystackAPIError> { 67 | dotenv().ok(); 68 | let api_key = env::var("PAYSTACK_API_KEY").unwrap(); 69 | let client = PaystackClient::new(api_key); 70 | 71 | 72 | let email = "email@example.com".to_string(); 73 | let amount ="10_000".to_string(); 74 | let body = TransactionRequestBuilder::default() 75 | .amount(amount) 76 | .email(email) 77 | .currency(Currency::NGN) 78 | .channel(vec![ 79 | Channel::Card, 80 | Channel::ApplePay, 81 | Channel::BankTransfer, 82 | Channel::Bank, 83 | ]) 84 | .build()?; 85 | 86 | let res = client.transaction.initialize_transaction(body).await?; 87 | 88 | // Assert 89 | println!("{}", res.status); 90 | println!("{}", res.message); 91 | 92 | Ok(()) 93 | } 94 | ``` 95 | 96 | ### Examples 97 | We provide some examples of use cases for the Paystack-rs crate. The examples are located in the [examples](examples) folder. 98 | 99 | 100 | ## Contributing 101 | 102 | See [CONTRIBUTING.md](/CONTRIBUTING.md) for information on contributing to paystack-rs. 103 | 104 | We use Github actions to conduct CI/CD for the crate. It ensure that code is formated properly using `cargo fmt`, as well 105 | as proper linting using `cargo clippy`, and finally run all the integration and unit test using `cargo test`. 106 | 107 | ### Crate module schematic diagram 108 | A conceptual overview of the crate is illustrated below. This is to help improve the understanding of how the different 109 | parts of the crate interact with each other to work efficiently. The `PaystackClient` module is the central module of 110 | the crate and the best entry point to explore the different parts of the crate. 111 | 112 | ![Crate Schematic](docs/images/paystack-rs.png) 113 | 114 | 115 | ## License 116 | 117 | Licensed under MIT license ([LICENSE-MIT](/LICENSE-MIT)). 118 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The API client is still in Beta mode. 6 | The following versions are supported with full security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 0.1.x | :white_check_mark: | 11 | | 0.2.x | :white_check_mark: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | If you find a security vulnerability, please open an issue with the following details: 16 | 17 | - Nature of the vulnerability 18 | - Severity level (could be based on your opinion) 19 | - How to replicate it 20 | - Version of the client that has it 21 | 22 | This project is still being maintained by a single individual so I will implement the fix as fast as I can. 23 | -------------------------------------------------------------------------------- /docs/images/paystack-rs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morukele/paystack-rs/64087ccdf2ba60e5f2701860d367f7b9f18885f1/docs/images/paystack-rs.png -------------------------------------------------------------------------------- /docs/uml/paystack-rs.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /examples/transaction.rs: -------------------------------------------------------------------------------- 1 | //! Transaction 2 | //! =========== 3 | //! 4 | //! Reference: 5 | //! 6 | //! This example shows how to initiate a transaction 7 | //! for a particular price and a particular customer. 8 | //! The transaction generates a URL that the user can use to pay. 9 | //! This requires building a transaction body. 10 | //! Please see the type definition to understand how it is constructed 11 | 12 | #[tokio::main] 13 | async fn main() { 14 | todo!() 15 | } 16 | -------------------------------------------------------------------------------- /scripts/publish.sh: -------------------------------------------------------------------------------- 1 | cargo publish -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- 1 | //! Client 2 | //! ========= 3 | //! This file contains the Paystack API client, and it associated endpoints. 4 | use crate::{HttpClient, SubaccountEndpoints, TransactionEndpoints, TransactionSplitEndpoints}; 5 | use std::sync::Arc; 6 | 7 | /// This is the entry level struct for the paystack API. 8 | /// it allows for authentication of the client 9 | pub struct PaystackClient { 10 | /// Transaction API route 11 | pub transaction: TransactionEndpoints, 12 | /// Transaction Split API route 13 | pub transaction_split: TransactionSplitEndpoints, 14 | /// Subaccount API route 15 | pub subaccount: SubaccountEndpoints, 16 | } 17 | 18 | impl PaystackClient { 19 | pub fn new(api_key: String) -> PaystackClient { 20 | let http = Arc::new(T::default()); 21 | // TODO: consider making api_key work without cloning. Arc or Reference?? 22 | PaystackClient { 23 | transaction: TransactionEndpoints::new(api_key.clone(), Arc::clone(&http)), 24 | transaction_split: TransactionSplitEndpoints::new(api_key.clone(), Arc::clone(&http)), 25 | subaccount: SubaccountEndpoints::new(api_key.clone(), Arc::clone(&http)), 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/endpoints/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod subaccount; 2 | pub mod transaction; 3 | pub mod transaction_split; 4 | 5 | // public re-export 6 | pub use subaccount::*; 7 | pub use transaction::*; 8 | pub use transaction_split::*; 9 | -------------------------------------------------------------------------------- /src/endpoints/subaccount.rs: -------------------------------------------------------------------------------- 1 | //! Subaccounts 2 | //! =========== 3 | //! The Subaccounts API allows you to create and manage subaccounts on your integration. 4 | //! Subaccounts can be used to split payment between two accounts (your main account and a subaccount). 5 | 6 | use crate::{ 7 | HttpClient, PaystackAPIError, PaystackResult, Response, SubaccountRequest, 8 | SubaccountsResponseData, 9 | }; 10 | use std::sync::Arc; 11 | 12 | /// A struct to hold all functions in the subaccount API route 13 | #[derive(Debug, Clone)] 14 | pub struct SubaccountEndpoints { 15 | key: String, 16 | base_url: String, 17 | http: Arc, 18 | } 19 | 20 | impl SubaccountEndpoints { 21 | /// Constructor for the Subaccount object 22 | pub fn new(key: String, http: Arc) -> SubaccountEndpoints { 23 | let base_url = String::from("https://api.paystack.co/subaccount"); 24 | SubaccountEndpoints { 25 | key, 26 | base_url, 27 | http, 28 | } 29 | } 30 | 31 | /// Create a subaccount on your integration 32 | /// 33 | /// Takes in the following parameters 34 | /// - body: subaccount to create `SubaccountRequest`; this is constructed using the 35 | /// `SubaccountRequestBuilder`. 36 | pub async fn create_subaccount( 37 | &self, 38 | subaccount_request: SubaccountRequest, 39 | ) -> PaystackResult { 40 | let url = self.base_url.to_string(); 41 | let body = serde_json::to_value(subaccount_request) 42 | .map_err(|e| PaystackAPIError::Subaccount(e.to_string()))?; 43 | 44 | let response = self.http.post(&url, &self.key, &body).await; 45 | 46 | match response { 47 | Ok(response) => { 48 | let parsed_response: Response = 49 | serde_json::from_str(&response) 50 | .map_err(|e| PaystackAPIError::Subaccount(e.to_string()))?; 51 | Ok(parsed_response) 52 | } 53 | Err(e) => Err(PaystackAPIError::Subaccount(e.to_string())), 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/endpoints/transaction.rs: -------------------------------------------------------------------------------- 1 | //! Transactions 2 | //! ============= 3 | //! The Transaction route allows to create and manage payments on your integration. 4 | 5 | use crate::{ 6 | ChargeRequest, Currency, ExportTransactionData, HttpClient, PartialDebitTransactionRequest, 7 | PaystackAPIError, PaystackResult, Response, Status, TransactionRequest, 8 | TransactionResponseData, TransactionStatusData, TransactionTimelineData, TransactionTotalData, 9 | }; 10 | use std::sync::Arc; 11 | 12 | /// A struct to hold all the functions of the transaction API endpoint 13 | #[derive(Debug, Clone)] 14 | pub struct TransactionEndpoints { 15 | /// Paystack API Key 16 | key: String, 17 | /// Base URL for the transaction route 18 | base_url: String, 19 | /// Http client for the route 20 | http: Arc, 21 | } 22 | 23 | impl TransactionEndpoints { 24 | /// Constructor for the transaction object 25 | pub fn new(key: String, http: Arc) -> TransactionEndpoints { 26 | let base_url = String::from("https://api.paystack.co/transaction"); 27 | TransactionEndpoints { 28 | key, 29 | base_url, 30 | http, 31 | } 32 | } 33 | 34 | /// Initialize a transaction in your integration 35 | /// 36 | /// Takes a `TransactionRequest`struct as input. 37 | pub async fn initialize_transaction( 38 | &self, 39 | transaction_request: TransactionRequest, 40 | ) -> PaystackResult { 41 | let url = format!("{}/initialize", self.base_url); 42 | let body = serde_json::to_value(transaction_request) 43 | .map_err(|e| PaystackAPIError::Transaction(e.to_string()))?; 44 | 45 | let response = self.http.post(&url, &self.key, &body).await; 46 | 47 | match response { 48 | Ok(response) => { 49 | let parsed_response: Response = 50 | serde_json::from_str(&response) 51 | .map_err(|e| PaystackAPIError::Transaction(e.to_string()))?; 52 | Ok(parsed_response) 53 | } 54 | Err(e) => { 55 | // convert the error to a transaction error 56 | Err(PaystackAPIError::Transaction(e.to_string())) 57 | } 58 | } 59 | } 60 | 61 | /// Confirm the status of a transaction. 62 | /// 63 | /// It takes the following parameters: 64 | /// - reference: The transaction reference used to initiate the transaction 65 | pub async fn verify_transaction( 66 | &self, 67 | reference: &str, 68 | ) -> PaystackResult { 69 | let url = format!("{}/verify/{}", self.base_url, reference); 70 | 71 | let response = self.http.get(&url, &self.key, None).await; 72 | 73 | match response { 74 | Ok(response) => { 75 | let parsed_response: Response = 76 | serde_json::from_str(&response) 77 | .map_err(|e| PaystackAPIError::Transaction(e.to_string()))?; 78 | 79 | Ok(parsed_response) 80 | } 81 | Err(e) => Err(PaystackAPIError::Transaction(e.to_string())), 82 | } 83 | } 84 | 85 | /// List transactions carried out on your integration. 86 | /// 87 | /// The method takes the following parameters: 88 | /// - perPage (Optional): Number of transactions to return. If None is passed as the parameter, the last 10 transactions are returned. 89 | /// - status (Optional): Filter transactions by status, defaults to Success if no status is passed. 90 | /// 91 | pub async fn list_transactions( 92 | &self, 93 | number_of_transactions: Option, 94 | status: Option, 95 | ) -> PaystackResult> { 96 | let url = self.base_url.to_string(); 97 | 98 | let per_page = number_of_transactions.unwrap_or(10).to_string(); 99 | let status = status.unwrap_or(Status::Success).to_string(); 100 | let query = vec![("perPage", per_page.as_str()), ("status", status.as_str())]; 101 | 102 | let response = self.http.get(&url, &self.key, Some(&query)).await; 103 | 104 | match response { 105 | Ok(response) => { 106 | let parsed_response: Response> = 107 | serde_json::from_str(&response) 108 | .map_err(|e| PaystackAPIError::Transaction(e.to_string()))?; 109 | 110 | Ok(parsed_response) 111 | } 112 | Err(e) => Err(PaystackAPIError::Transaction(e.to_string())), 113 | } 114 | } 115 | 116 | /// Get details of a transaction carried out on your integration. 117 | /// 118 | /// This method take the ID of the desired transaction as a parameter 119 | pub async fn fetch_transactions( 120 | &self, 121 | transaction_id: u32, 122 | ) -> PaystackResult { 123 | let url = format!("{}/{}", self.base_url, transaction_id); 124 | 125 | let response = self.http.get(&url, &self.key, None).await; 126 | 127 | match response { 128 | Ok(response) => { 129 | let parsed_response: Response = 130 | serde_json::from_str(&response) 131 | .map_err(|e| PaystackAPIError::Transaction(e.to_string()))?; 132 | 133 | Ok(parsed_response) 134 | } 135 | Err(e) => Err(PaystackAPIError::Transaction(e.to_string())), 136 | } 137 | } 138 | 139 | /// All authorizations marked as reusable can be charged with this endpoint whenever you need to receive payments. 140 | /// 141 | /// This function takes a Charge Struct as parameter 142 | pub async fn charge_authorization( 143 | &self, 144 | charge_request: ChargeRequest, 145 | ) -> PaystackResult { 146 | let url = format!("{}/charge_authorization", self.base_url); 147 | let body = serde_json::to_value(charge_request) 148 | .map_err(|e| PaystackAPIError::Transaction(e.to_string()))?; 149 | 150 | let response = self.http.post(&url, &self.key, &body).await; 151 | 152 | match response { 153 | Ok(response) => { 154 | let parsed_response: Response = 155 | serde_json::from_str(&response) 156 | .map_err(|e| PaystackAPIError::Transaction(e.to_string()))?; 157 | 158 | Ok(parsed_response) 159 | } 160 | Err(e) => Err(PaystackAPIError::Transaction(e.to_string())), 161 | } 162 | } 163 | 164 | /// View the timeline of a transaction. 165 | /// 166 | /// This method takes in the Transaction id or reference as a parameter 167 | pub async fn view_transaction_timeline( 168 | &self, 169 | id: Option, 170 | reference: Option<&str>, 171 | ) -> PaystackResult { 172 | // This is a hacky implementation to ensure that the transaction reference or id is not empty. 173 | // If they are empty, a new url without them as parameter is created. 174 | let url = match (id, reference) { 175 | (Some(id), None) => Ok(format!("{}/timeline/{}", self.base_url, id)), 176 | (None, Some(reference)) => Ok(format!("{}/timeline/{}", self.base_url, &reference)), 177 | _ => Err(PaystackAPIError::Transaction( 178 | "Transaction Id or Reference is need to view transaction timeline".to_string(), 179 | )), 180 | }?; // propagate the error upstream 181 | 182 | let response = self.http.get(&url, &self.key, None).await; 183 | 184 | match response { 185 | Ok(response) => { 186 | let parsed_response: Response = 187 | serde_json::from_str(&response) 188 | .map_err(|e| PaystackAPIError::Transaction(e.to_string()))?; 189 | 190 | Ok(parsed_response) 191 | } 192 | Err(e) => Err(PaystackAPIError::Transaction(e.to_string())), 193 | } 194 | } 195 | 196 | /// Total amount received on your account. 197 | /// 198 | /// This route normally takes a perPage or page query, 199 | /// However in this case it is ignored. 200 | /// If you need it in your work please open an issue, 201 | /// and it will be implemented. 202 | pub async fn total_transactions(&self) -> PaystackResult { 203 | let url = format!("{}/totals", self.base_url); 204 | 205 | let response = self.http.get(&url, &self.key, None).await; 206 | 207 | match response { 208 | Ok(response) => { 209 | let parsed_response: Response = 210 | serde_json::from_str(&response) 211 | .map_err(|e| PaystackAPIError::Transaction(e.to_string()))?; 212 | 213 | Ok(parsed_response) 214 | } 215 | Err(e) => Err(PaystackAPIError::Transaction(e.to_string())), 216 | } 217 | } 218 | 219 | /// Export a list of transactions carried out on your integration. 220 | /// 221 | /// This method takes the following parameters 222 | /// - Status (Optional): The status of the transactions to export. Defaults to all 223 | /// - Currency (Optional): The currency of the transactions to export. Defaults to NGN 224 | /// - Settled (Optional): To state of the transactions to export. Defaults to False. 225 | pub async fn export_transaction( 226 | &self, 227 | status: Option, 228 | currency: Option, 229 | settled: Option, 230 | ) -> PaystackResult { 231 | let url = format!("{}/export", self.base_url); 232 | 233 | // Specify a default option for settled transactions. 234 | let settled = match settled { 235 | Some(settled) => settled.to_string(), 236 | None => String::from(""), 237 | }; 238 | 239 | let status = status.unwrap_or(Status::Success).to_string(); 240 | let currency = currency.unwrap_or(Currency::NGN).to_string(); 241 | 242 | let query = vec![ 243 | ("status", status.as_str()), 244 | ("currency", currency.as_str()), 245 | ("settled", settled.as_str()), 246 | ]; 247 | 248 | let response = self.http.get(&url, &self.key, Some(&query)).await; 249 | 250 | match response { 251 | Ok(response) => { 252 | let parsed_response = serde_json::from_str(&response) 253 | .map_err(|e| PaystackAPIError::Transaction(e.to_string()))?; 254 | 255 | Ok(parsed_response) 256 | } 257 | Err(e) => Err(PaystackAPIError::Transaction(e.to_string())), 258 | } 259 | } 260 | 261 | /// Retrieve part of a payment from a customer. 262 | /// 263 | /// It takes a PartialDebitTransaction type as a parameter. 264 | /// 265 | /// NB: it must be created with the PartialDebitTransaction Builder. 266 | pub async fn partial_debit( 267 | &self, 268 | partial_debit_transaction_request: PartialDebitTransactionRequest, 269 | ) -> PaystackResult { 270 | let url = format!("{}/partial_debit", self.base_url); 271 | let body = serde_json::to_value(partial_debit_transaction_request) 272 | .map_err(|e| PaystackAPIError::Transaction(e.to_string()))?; 273 | 274 | let response = self.http.post(&url, &self.key, &body).await; 275 | 276 | match response { 277 | Ok(response) => { 278 | let parsed_response: Response = 279 | serde_json::from_str(&response) 280 | .map_err(|e| PaystackAPIError::Transaction(e.to_string()))?; 281 | 282 | Ok(parsed_response) 283 | } 284 | Err(e) => Err(PaystackAPIError::Transaction(e.to_string())), 285 | } 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /src/endpoints/transaction_split.rs: -------------------------------------------------------------------------------- 1 | //! Transaction Split 2 | //! ================= 3 | //! The Transaction Splits API enables merchants split the settlement for a 4 | //! transaction across their payout account, and one or more subaccounts. 5 | 6 | use crate::{ 7 | DeleteSubAccountBody, HttpClient, PaystackAPIError, PaystackResult, Response, SubaccountBody, 8 | TransactionSplitRequest, TransactionSplitResponseData, UpdateTransactionSplitRequest, 9 | }; 10 | use std::sync::Arc; 11 | 12 | /// A struct to hold all the functions of the transaction split API endpoint 13 | #[derive(Debug, Clone)] 14 | pub struct TransactionSplitEndpoints { 15 | key: String, 16 | base_url: String, 17 | http: Arc, 18 | } 19 | 20 | impl TransactionSplitEndpoints { 21 | /// Constructor for the transaction object 22 | pub fn new(key: String, http: Arc) -> TransactionSplitEndpoints { 23 | let base_url = String::from("https://api.paystack.co/split"); 24 | TransactionSplitEndpoints { 25 | key, 26 | base_url, 27 | http, 28 | } 29 | } 30 | 31 | /// Create a split payment on your integration. 32 | /// 33 | /// This method takes a `TransactionSplitRequest` object as a parameter. 34 | pub async fn create_transaction_split( 35 | &self, 36 | split_body: TransactionSplitRequest, 37 | ) -> PaystackResult { 38 | let url = self.base_url.to_string(); 39 | let body = serde_json::to_value(split_body) 40 | .map_err(|e| PaystackAPIError::TransactionSplit(e.to_string()))?; 41 | 42 | let response = self.http.post(&url, &self.key, &body).await; 43 | 44 | match response { 45 | Ok(response) => { 46 | let parsed_response: Response = 47 | serde_json::from_str(&response) 48 | .map_err(|e| PaystackAPIError::TransactionSplit(e.to_string()))?; 49 | Ok(parsed_response) 50 | } 51 | Err(e) => Err(PaystackAPIError::TransactionSplit(e.to_string())), 52 | } 53 | } 54 | 55 | /// List the transaction splits available on your integration 56 | /// 57 | /// Takes in the following parameters: 58 | /// - `split_name`: (Optional) name of the split to retrieve. 59 | /// - `split_active`: (Optional) status of the split to retrieve. 60 | pub async fn list_transaction_splits( 61 | &self, 62 | split_name: Option<&str>, 63 | split_active: Option, 64 | ) -> PaystackResult> { 65 | let url = self.base_url.to_string(); 66 | 67 | // Specify a default option for active splits 68 | let split_active = match split_active { 69 | Some(active) => active.to_string(), 70 | None => "".to_string(), 71 | }; 72 | 73 | let query = vec![ 74 | ("name", split_name.unwrap_or("")), 75 | ("active", &split_active), 76 | ]; 77 | 78 | let response = self.http.get(&url, &self.key, Some(&query)).await; 79 | 80 | match response { 81 | Ok(response) => { 82 | let parsed_response: Response> = 83 | serde_json::from_str(&response) 84 | .map_err(|e| PaystackAPIError::TransactionSplit(e.to_string()))?; 85 | 86 | Ok(parsed_response) 87 | } 88 | Err(e) => Err(PaystackAPIError::TransactionSplit(e.to_string())), 89 | } 90 | } 91 | 92 | /// Get details of a split on your integration. 93 | /// 94 | /// Takes in the following parameter: 95 | /// - `split_id`: ID of the transaction split. 96 | pub async fn fetch_transaction_split( 97 | &self, 98 | split_id: &str, 99 | ) -> PaystackResult { 100 | let url = format!("{}/{}", self.base_url, split_id); 101 | 102 | let response = self.http.get(&url, &self.key, None).await; 103 | 104 | match response { 105 | Ok(response) => { 106 | let parsed_response: Response = 107 | serde_json::from_str(&response) 108 | .map_err(|e| PaystackAPIError::TransactionSplit(e.to_string()))?; 109 | 110 | Ok(parsed_response) 111 | } 112 | Err(e) => Err(PaystackAPIError::TransactionSplit(e.to_string())), 113 | } 114 | } 115 | 116 | /// Update a transaction split details on your integration. 117 | /// 118 | /// Takes in a 119 | /// - `update_body` as a `UpdateTransactionSplitRequest` struct which is created from the `UpdateTransactionSplitRequestBuilder` struct 120 | /// - `split_id`, the ID of the split to update 121 | pub async fn update_transaction_split( 122 | &self, 123 | split_id: &str, 124 | update_body: UpdateTransactionSplitRequest, 125 | ) -> PaystackResult { 126 | let url = format!("{}/{}", self.base_url, split_id); 127 | let body = serde_json::to_value(update_body) 128 | .map_err(|e| PaystackAPIError::TransactionSplit(e.to_string()))?; 129 | 130 | let response = self.http.put(&url, &self.key, &body).await; 131 | 132 | match response { 133 | Ok(response) => { 134 | let parsed_response: Response = 135 | serde_json::from_str(&response) 136 | .map_err(|e| PaystackAPIError::TransactionSplit(e.to_string()))?; 137 | Ok(parsed_response) 138 | } 139 | Err(e) => Err(PaystackAPIError::TransactionSplit(e.to_string())), 140 | } 141 | } 142 | 143 | /// Add a Subaccount to a Transaction Split, or update the share of an existing Subaccount in a Transaction Split 144 | /// 145 | /// Takes in the following parameters: 146 | /// - `split_id`: Id of the transaction split to update. 147 | /// - `body`: Subaccount to add to the transaction split. 148 | pub async fn add_or_update_subaccount_split( 149 | &self, 150 | split_id: &str, 151 | body: SubaccountBody, 152 | ) -> PaystackResult { 153 | let url = format!("{}/{}/subaccount/add", self.base_url, split_id); 154 | let body = serde_json::to_value(body) 155 | .map_err(|e| PaystackAPIError::TransactionSplit(e.to_string()))?; 156 | 157 | let response = self.http.post(&url, &self.key, &body).await; 158 | 159 | match response { 160 | Ok(response) => { 161 | let parsed_response: Response = 162 | serde_json::from_str(&response) 163 | .map_err(|e| PaystackAPIError::TransactionSplit(e.to_string()))?; 164 | 165 | Ok(parsed_response) 166 | } 167 | Err(e) => Err(PaystackAPIError::TransactionSplit(e.to_string())), 168 | } 169 | } 170 | 171 | /// Remove a subaccount from a transaction split. 172 | /// 173 | /// Takes in the following parameters 174 | /// - split_id: Id of the transaction split 175 | /// - subaccount: subaccount code to remove 176 | pub async fn remove_subaccount_from_transaction_split( 177 | &self, 178 | split_id: &str, 179 | subaccount: DeleteSubAccountBody, 180 | ) -> PaystackResult { 181 | let url = format!("{}/{}/subaccount/remove", self.base_url, split_id); 182 | let body = serde_json::to_value(subaccount) 183 | .map_err(|e| PaystackAPIError::TransactionSplit(e.to_string()))?; 184 | 185 | let response = self.http.post(&url, &self.key, &body).await; 186 | 187 | match response { 188 | Ok(response) => { 189 | let parsed_response: Response = serde_json::from_str(&response) 190 | .map_err(|e| PaystackAPIError::TransactionSplit(e.to_string()))?; 191 | 192 | Ok(parsed_response) 193 | } 194 | Err(e) => Err(PaystackAPIError::TransactionSplit(e.to_string())), 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | //! Error 2 | //! ======== 3 | //! This file contains the structs and definitions of the errors in this crate. 4 | use thiserror::Error; 5 | 6 | /// Custom Error for the Paystack API 7 | #[derive(Error, Debug)] 8 | #[non_exhaustive] 9 | pub enum PaystackAPIError { 10 | /// Generic error, not used frequently 11 | #[error("Generic error: {0}")] 12 | Generic(String), 13 | /// Error associated with Transaction operation 14 | #[error("Transaction Error: {0}")] 15 | Transaction(String), 16 | /// Error associated with Charge 17 | #[error("Charge Error: {0}")] 18 | Charge(String), 19 | /// Error associated with Transaction Split 20 | #[error("Transaction Split Error: {0}")] 21 | TransactionSplit(String), 22 | /// Error associated with Subaccount 23 | #[error("Subaccount Error: {0}")] 24 | Subaccount(String), 25 | /// Error associated with terminal 26 | #[error("Terminal Error: {0}")] 27 | Terminal(String), 28 | /// Error associated with customer 29 | #[error("Customer Error: {0}")] 30 | Customer(String), 31 | } 32 | -------------------------------------------------------------------------------- /src/http/base.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use serde_json::Value; 3 | use std::fmt::{Debug, Display}; 4 | 5 | /// A predefined type for the query type in the HTTP client. 6 | pub type Query<'a> = Vec<(&'a str, &'a str)>; 7 | 8 | /// This trait is a collection of the stand HTTP methods for any client. 9 | /// The aim of the trait is to abstract ways the HTTP implementation found in 10 | /// different HTTP clients. 11 | /// 12 | /// The goal is to give a level of flexibility to the user of the crate to work 13 | /// with their preferred HTTP client. 14 | /// To be as generic as possible, the U generic stands for the HTTP response. 15 | /// Ideally, it should be bounded to specific traits common in all response. 16 | /// TODO: Bound the U generic to the appropriate traits. 17 | 18 | #[async_trait] 19 | pub trait HttpClient: Debug + Default + Clone + Send { 20 | /// HTTP error 21 | type Error: Debug + Display; 22 | 23 | /// Send http get request 24 | async fn get( 25 | &self, 26 | url: &str, 27 | api_key: &str, 28 | query: Option<&Query>, 29 | ) -> Result; 30 | /// Send http post request 31 | async fn post(&self, url: &str, api_key: &str, body: &Value) -> Result; 32 | /// Send http put request 33 | async fn put(&self, url: &str, api_key: &str, body: &Value) -> Result; 34 | /// Send http delete request 35 | async fn delete(&self, url: &str, api_key: &str, body: &Value) -> Result; 36 | } 37 | -------------------------------------------------------------------------------- /src/http/errors.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | /// An error enum to hold errors from reqwest client 4 | #[derive(Error, Debug)] 5 | pub enum ReqwestError { 6 | /// Default HTTP error from the Reqwest crate. 7 | /// This happens when the request cannot be completed. 8 | #[error("request: {0}")] 9 | Reqwest(#[from] reqwest::Error), 10 | 11 | /// The initial request was successful, but the status code is in the 400 12 | /// and 500 range. This signifies that API cannot handle the request sent, 13 | /// We are only interested in the status code of this error 14 | #[error("status code: {}", reqwest::Response::status(.0))] 15 | StatusCode(reqwest::Response), 16 | } 17 | -------------------------------------------------------------------------------- /src/http/mod.rs: -------------------------------------------------------------------------------- 1 | //! The HTTP client can be different and it is toggled during the configuration process of the crate 2 | //! The default client will is th Reqwest client, in the case of none being selected. 3 | //! If both are selected, a compiler error is raised. 4 | 5 | pub mod base; 6 | pub mod errors; 7 | pub mod reqwest; 8 | 9 | // public re-export 10 | pub use base::HttpClient; 11 | pub use errors::ReqwestError; 12 | pub use reqwest::ReqwestClient; 13 | -------------------------------------------------------------------------------- /src/http/reqwest.rs: -------------------------------------------------------------------------------- 1 | use super::ReqwestError; 2 | use crate::http::base::Query; 3 | use crate::HttpClient; 4 | use async_trait::async_trait; 5 | use reqwest::{Client, Method, RequestBuilder}; 6 | use serde_json::Value; 7 | use std::fmt::Debug; 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct ReqwestClient { 11 | /// An instance of the client to perform the http requests with 12 | client: Client, 13 | } 14 | 15 | impl Default for ReqwestClient { 16 | fn default() -> Self { 17 | let client = reqwest::ClientBuilder::new().build().unwrap(); 18 | 19 | Self { client } 20 | } 21 | } 22 | 23 | impl ReqwestClient { 24 | async fn send_request RequestBuilder>( 25 | &self, 26 | method: Method, 27 | url: &str, 28 | auth_key: &str, 29 | add_data: D, 30 | ) -> Result { 31 | // configure the request object 32 | let mut request = self 33 | .client 34 | .request(method.clone(), url) 35 | .bearer_auth(auth_key) 36 | .header("Content-Type", "application/json"); 37 | 38 | // Configure the request for the specific type (get/post/put/delete) 39 | request = add_data(request); 40 | 41 | // Performing the request 42 | log::info!("Making request: {:?}", request); 43 | let response = request.send().await?; 44 | 45 | // Checking that we get a 200 range response 46 | if response.status().is_success() { 47 | response.text().await.map_err(Into::into) 48 | } else { 49 | Err(ReqwestError::StatusCode(response)) 50 | } 51 | } 52 | } 53 | 54 | #[async_trait] 55 | impl HttpClient for ReqwestClient { 56 | type Error = ReqwestError; 57 | 58 | async fn get( 59 | &self, 60 | url: &str, 61 | api_key: &str, 62 | query: Option<&Query>, 63 | ) -> Result { 64 | self.send_request(Method::GET, url, api_key, |req| { 65 | if let Some(query) = query { 66 | req.query(query) 67 | } else { 68 | req 69 | } 70 | }) 71 | .await 72 | } 73 | 74 | async fn post(&self, url: &str, api_key: &str, body: &Value) -> Result { 75 | self.send_request(Method::POST, url, api_key, |req| req.json(body)) 76 | .await 77 | } 78 | 79 | async fn put(&self, url: &str, api_key: &str, body: &Value) -> Result { 80 | self.send_request(Method::PUT, url, api_key, |req| req.json(body)) 81 | .await 82 | } 83 | 84 | async fn delete(&self, url: &str, api_key: &str, body: &Value) -> Result { 85 | self.send_request(Method::DELETE, url, api_key, |req| req.json(body)) 86 | .await 87 | } 88 | } 89 | 90 | #[cfg(test)] 91 | mod tests { 92 | use super::*; 93 | #[tokio::test] 94 | async fn reqwest_client_cannot_get_unauthorized() { 95 | // Set 96 | let api_key = String::from("fake-key"); 97 | let url = "https://api.paystack.co/transaction/initialize"; 98 | 99 | // Run 100 | let client = ReqwestClient::default(); 101 | let res = client.get(url, api_key.as_str(), None).await; 102 | 103 | // Assert 104 | // this should be a 401 error since we are not passing the right API key 105 | assert!(res.is_err()); 106 | if let Err(e) = res { 107 | match e { 108 | ReqwestError::Reqwest(_) => { 109 | // don't need this error here 110 | } 111 | ReqwestError::StatusCode(code) => { 112 | assert_eq!(code.status(), 401); 113 | } 114 | } 115 | } 116 | } 117 | 118 | #[tokio::test] 119 | async fn reqwest_client_can_get() { 120 | // Set 121 | let api_key = "fake-hey"; 122 | let url = "https://api.paystack.co/"; 123 | 124 | // Run 125 | let client = ReqwestClient::default(); 126 | let res = client.get(url, api_key, None).await; 127 | 128 | // Assert 129 | assert!(res.is_ok()); 130 | match res { 131 | Ok(res) => { 132 | assert!(res.contains("true")) 133 | } 134 | Err(_) => { 135 | // Not going to have an error here. 136 | } 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Convenient rust bindings and types for the Paystack HTTP API aiming to support the entire API surface. 2 | //! Not the case? Please open an issue. I update the definitions on a weekly basis. 3 | //! 4 | //! # Documentation 5 | //! See the [Rust API docs](https://docs.rs/paystack-rs) or the [examples](/examples). 6 | //! 7 | //! ## Installation 8 | //! 9 | //! `paystack-rs` uses the `reqwest` http client under the hood and the `tokio` runtime for async operations 10 | //! 11 | //! ```toml 12 | //! [dependencies] 13 | //! paystack-rs = "0.1" 14 | //! ``` 15 | //! 16 | //! ## Usage 17 | //! 18 | //! Initializing an instance of the Paystack client and creating a transaction. 19 | //! 20 | //! ```rust 21 | //! use std::env; 22 | //! use std::error::Error; 23 | //! use dotenv::dotenv; 24 | //! use paystack::{PaystackClient, TransactionRequestBuilder, PaystackAPIError, Currency, Channel, ReqwestClient}; 25 | //! 26 | //! 27 | //! #[tokio::main] 28 | //! async fn main() -> Result<(), Box> { 29 | //! dotenv().ok(); 30 | //! use std::error::Error; 31 | //! let api_key = env::var("PAYSTACK_API_KEY").unwrap(); 32 | //! let client = PaystackClient::::new(api_key); 33 | //! 34 | //! 35 | //! let email = "email@example.com".to_string(); 36 | //! let amount ="10000".to_string(); 37 | //! let body = TransactionRequestBuilder::default() 38 | //! .amount(amount) 39 | //! .email(email) 40 | //! .currency(Currency::NGN) 41 | //! .channel(vec![ 42 | //! Channel::Card, 43 | //! Channel::ApplePay, 44 | //! Channel::BankTransfer, 45 | //! Channel::Bank, 46 | //! ]) 47 | //! .build()?; 48 | //! 49 | //! let res = client 50 | //! .transaction 51 | //! .initialize_transaction(body) 52 | //! .await 53 | //! .expect("Unable to create transaction"); 54 | //! 55 | //! Ok(()) 56 | //! } 57 | //! ``` 58 | //! 59 | //! ## Contributing 60 | //! 61 | //! See [CONTRIBUTING.md](/CONTRIBUTING.md) for information on contributing to paystack-rs. 62 | //! 63 | // ## License 64 | //! 65 | //! Licensed under MIT license ([LICENSE-MIT](/LICENSE-MIT)). 66 | //! 67 | 68 | pub mod client; 69 | pub mod endpoints; 70 | pub mod errors; 71 | pub mod http; 72 | pub mod macros; 73 | pub mod models; 74 | pub mod utils; 75 | 76 | // public re-export of modules 77 | pub use client::*; 78 | pub use endpoints::*; 79 | pub use errors::*; 80 | pub use http::*; 81 | pub use models::*; 82 | pub use utils::*; 83 | 84 | /// Custom result type for the Paystack API 85 | pub type PaystackResult = Result, PaystackAPIError>; 86 | -------------------------------------------------------------------------------- /src/macros/mod.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/models/bearer.rs: -------------------------------------------------------------------------------- 1 | //! Bearer Type 2 | //! ================= 3 | //! This file contains the charge bearer option for the paystack API. 4 | 5 | use serde::Serialize; 6 | use std::fmt; 7 | 8 | /// Represents the type of bearer for a charge. 9 | /// 10 | /// The `BearerType` enum defines the possible types of bearers for a charge, indicating who 11 | /// is responsible for the transaction split. 12 | /// 13 | /// # Variants 14 | /// 15 | /// - `Subaccount`: The subaccount bears the transaction split. 16 | /// - `Account`: The main account bears the transaction split. 17 | /// - `AllProportional`: The transaction is split proportionally to all accounts. 18 | /// - `All`: The transaction is paid by all accounts. 19 | /// 20 | /// # Examples 21 | /// 22 | /// ``` 23 | /// use paystack::BearerType; 24 | /// 25 | /// let subaccount_bearer = BearerType::Subaccount; 26 | /// let account_bearer = BearerType::Account; 27 | /// let all_proportional_bearer = BearerType::AllProportional; 28 | /// let all_bearer = BearerType::All; 29 | /// 30 | /// println!("{:?}", subaccount_bearer); // Prints: Subaccount 31 | /// ``` 32 | /// 33 | /// The example demonstrates the usage of the `BearerType` enum, creating instances of each variant 34 | /// and printing their debug representation. 35 | #[derive(Debug, Serialize, Clone, Default)] 36 | #[serde(rename_all = "lowercase")] 37 | pub enum BearerType { 38 | /// The subaccount bears the transaction split 39 | #[default] 40 | Subaccount, 41 | /// The main account bears the transaction split 42 | Account, 43 | /// The transaction is split proportionally to all accounts 44 | #[serde(rename = "all-proportional")] 45 | AllProportional, 46 | /// The transaction is paid by all accounts 47 | All, 48 | } 49 | 50 | impl fmt::Display for BearerType { 51 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 52 | let lowercase_string = match self { 53 | BearerType::Subaccount => "subaccount", 54 | BearerType::Account => "account", 55 | BearerType::AllProportional => "all-proportional", 56 | BearerType::All => "all", 57 | }; 58 | write!(f, "{}", lowercase_string) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/models/channel.rs: -------------------------------------------------------------------------------- 1 | //! Channel 2 | //! =============== 3 | //! This file contains the Channel option for the paystack API. 4 | 5 | use serde::{Deserialize, Serialize}; 6 | use std::fmt; 7 | 8 | /// Represents the payment channels supported by Paystack. 9 | /// 10 | /// The `Channel` enum defines the possible payment channels that can be used with Paystack, 11 | /// including debit card, bank interface, USSD code, QR code, mobile money, bank transfer, 12 | /// and Apple Pay. 13 | /// 14 | /// # Variants 15 | /// 16 | /// - `Card`: Payment with a debit card. 17 | /// - `Bank`: Payment with a bank interface. 18 | /// - `Ussd`: Payment with a USSD code. 19 | /// - `Qr`: Payment with a QR code. 20 | /// - `MobileMoney`: Payment with mobile money. 21 | /// - `BankTransfer`: Payment with a bank transfer. 22 | /// - `ApplePay`: Payment with Apple Pay. 23 | /// 24 | /// # Examples 25 | /// 26 | /// ``` 27 | /// use paystack::Channel; 28 | /// 29 | /// let card = Channel::Card; 30 | /// let bank = Channel::Bank; 31 | /// let ussd = Channel::Ussd; 32 | /// let qr = Channel::Qr; 33 | /// let mobile_money = Channel::MobileMoney; 34 | /// let bank_transfer = Channel::BankTransfer; 35 | /// let apple_pay = Channel::ApplePay; 36 | /// 37 | /// println!("{:?}", card); // Prints: card 38 | /// println!("{:?}", mobile_money); // Prints: mobile_money 39 | /// ``` 40 | /// 41 | /// The example demonstrates the usage of the `Channel` enum from the Paystack crate, 42 | /// creating instances of each variant and printing their debug representation. 43 | #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] 44 | #[serde(rename_all = "snake_case")] 45 | pub enum Channel { 46 | /// Debit Card 47 | #[default] 48 | Card, 49 | /// Payment with Bank Interface 50 | Bank, 51 | /// Payment with USSD Code 52 | Ussd, 53 | /// Payment with QR Code 54 | Qr, 55 | /// Payment with Mobile Money 56 | MobileMoney, 57 | /// Payment with Bank Transfer 58 | BankTransfer, 59 | /// Payment with Apple Pay 60 | ApplePay, 61 | } 62 | 63 | impl fmt::Display for Channel { 64 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 65 | let lower_case = match self { 66 | Channel::Card => "card", 67 | Channel::Bank => "bank", 68 | Channel::Ussd => "ussd", 69 | Channel::Qr => "qr", 70 | Channel::MobileMoney => "mobile_money", 71 | Channel::BankTransfer => "bank_transfer", 72 | Channel::ApplePay => "mobile_money", 73 | }; 74 | write!(f, "{}", lower_case) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/models/charge.rs: -------------------------------------------------------------------------------- 1 | //! Charge 2 | //! =========== 3 | //! This file contains all the structs and definitions needed to 4 | //! create charges using the Paystack API. 5 | 6 | use crate::{Channel, Currency}; 7 | use derive_builder::Builder; 8 | use serde::Serialize; 9 | 10 | /// This struct is used to create a charge body for creating a Charge Authorization using the Paystack API. 11 | /// The struct is constructed using the `ChargeBodyBuilder` 12 | #[derive(Serialize, Debug, Builder)] 13 | pub struct ChargeRequest { 14 | /// Customer's email address 15 | email: String, 16 | /// Amount should be in the smallest unit of the currency e.g. kobo if in NGN and cents if in USD 17 | amount: String, 18 | /// Valid authorization code to charge 19 | authorization_code: String, 20 | /// Unique transaction reference. Only `-`, `.`, `=` and alphanumeric characters allowed. 21 | #[builder(default = "None")] 22 | reference: Option, 23 | /// Currency in which amount should be charged. 24 | #[builder(default = "None")] 25 | currency: Option, 26 | /// Stringified JSON object. 27 | /// Add a custom_fields attribute which has an array of objects if you would like the fields to be added to your transaction 28 | /// when displayed on the dashboard. 29 | /// Sample: {"custom_fields":[{"display_name":"Cart ID","variable_name": "cart_id","value": "8393"}]} 30 | #[builder(default = "None")] 31 | metadata: Option, 32 | /// Send us 'card' or 'bank' or 'card','bank' as an array to specify what options to show the user paying 33 | #[builder(default = "None")] 34 | channel: Option>, 35 | /// The code for the subaccount that owns the payment. e.g. `ACCT_8f4s1eq7ml6rlzj` 36 | #[builder(default = "None")] 37 | subaccount: Option, 38 | /// A flat fee to charge the subaccount for this transaction in the subunit of the supported currency. 39 | /// This overrides the split percentage set when the subaccount was created. 40 | /// Ideally, you will need to use this if you are splitting in flat rates (since subaccount creation only allows for percentage split). 41 | #[builder(default = "None")] 42 | transaction_charge: Option, 43 | /// Who bears Paystack charges? account or subaccount (defaults to account). 44 | #[builder(default = "None")] 45 | bearer: Option, 46 | /// If you are making a scheduled charge call, it is a good idea to queue them so the processing system does not 47 | /// get overloaded causing transaction processing errors. 48 | /// Send queue:true to take advantage of our queued charging. 49 | #[builder(default = "None")] 50 | queue: Option, 51 | } 52 | -------------------------------------------------------------------------------- /src/models/currency.rs: -------------------------------------------------------------------------------- 1 | //! Currency 2 | //! =============== 3 | //! This file contains the currency options for the paystack API. 4 | 5 | use serde::{Deserialize, Serialize}; 6 | use std::fmt; 7 | 8 | /// Represents different currencies supported by the Paystack API. 9 | /// 10 | /// The `Currency` enum defines the possible currency options that can be used with Paystack, 11 | /// including Nigerian Naira (NGN), Ghanaian Cedis (GHS), American Dollar (USD), 12 | /// and South African Rands (ZAR). It also includes an `EMPTY` variant to represent cases 13 | /// where the currency can be empty. 14 | /// 15 | /// # Variants 16 | /// 17 | /// - `NGN`: Nigerian Naira. 18 | /// - `GHS`: Ghanaian Cedis. 19 | /// - `USD`: American Dollar. 20 | /// - `ZAR`: South African Rands. 21 | /// - `EMPTY`: Used when the currency can be empty. 22 | /// 23 | /// # Examples 24 | /// 25 | /// ``` 26 | /// use paystack::Currency; 27 | /// 28 | /// let ngn = Currency::NGN; 29 | /// let ghs = Currency::GHS; 30 | /// let usd = Currency::USD; 31 | /// let zar = Currency::ZAR; 32 | /// let empty = Currency::EMPTY; 33 | /// 34 | /// println!("{:?}", ngn); // Prints: NGN 35 | /// ``` 36 | /// 37 | /// The example demonstrates the usage of the `Currency` enum from the Paystack crate, 38 | /// creating instances of each variant and printing a debug representation. 39 | #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] 40 | pub enum Currency { 41 | /// Nigerian Naira 42 | #[default] 43 | NGN, 44 | /// Ghanaian Cedis 45 | GHS, 46 | /// American Dollar 47 | USD, 48 | /// South African Rands 49 | ZAR, 50 | /// Used when currency can be empty. 51 | EMPTY, 52 | } 53 | 54 | impl fmt::Display for Currency { 55 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 56 | let currency = match self { 57 | Currency::NGN => "NGN", 58 | Currency::GHS => "GHS", 59 | Currency::USD => "USD", 60 | Currency::ZAR => "ZAR", 61 | Currency::EMPTY => "", 62 | }; 63 | write!(f, "{}", currency) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/models/customer_model.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | /// This struct represents the Paystack customer data 4 | #[derive(Debug, Deserialize, Serialize, Clone)] 5 | pub struct Customer { 6 | /// Customer's Id. 7 | pub id: Option, 8 | /// Customer's first name. 9 | pub first_name: Option, 10 | /// Customer's last name. 11 | pub last_name: Option, 12 | /// Customer's email address. 13 | pub email: Option, 14 | /// Customer's code. 15 | pub customer_code: String, 16 | /// Customer's phone number. 17 | pub phone: Option, 18 | /// Customer's metadata. 19 | pub metadata: Option, 20 | /// Customer's risk action. 21 | pub risk_action: Option, 22 | /// Customer's phone number in international format. 23 | pub international_format_phone: Option, 24 | } 25 | -------------------------------------------------------------------------------- /src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod bearer; 2 | pub mod channel; 3 | pub mod charge; 4 | pub mod currency; 5 | pub mod customer_model; 6 | pub mod response; 7 | pub mod split; 8 | pub mod status; 9 | pub mod subaccount_model; 10 | pub mod transaction_model; 11 | pub mod transaction_split_model; 12 | 13 | // public re-export 14 | pub use bearer::*; 15 | pub use channel::*; 16 | pub use charge::*; 17 | pub use currency::*; 18 | pub use customer_model::*; 19 | pub use response::*; 20 | pub use split::*; 21 | pub use status::*; 22 | pub use subaccount_model::*; 23 | pub use transaction_model::*; 24 | pub use transaction_split_model::*; 25 | -------------------------------------------------------------------------------- /src/models/response.rs: -------------------------------------------------------------------------------- 1 | //! response 2 | //! ======== 3 | //! Holds the generic response templates for the API 4 | use crate::utils::string_or_number_to_u16; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | /// Generic response body template for the API 8 | #[derive(Clone, Debug, Serialize, Deserialize)] 9 | pub struct Response { 10 | /// This lets you know if your request was successful or not. 11 | pub status: bool, 12 | /// This is a summary of the response and its status. 13 | pub message: String, 14 | /// This contains the result of your request 15 | #[serde(default)] 16 | pub data: T, 17 | /// This contains meta data object 18 | pub meta: Option, 19 | } 20 | 21 | /// The Meta object is used to provide context for the contents of the data key. 22 | #[derive(Clone, Debug, Serialize, Deserialize)] 23 | #[serde(rename_all = "camelCase")] 24 | pub struct Meta { 25 | /// This is the total number of transactions that were performed by the customer. 26 | #[serde(deserialize_with = "string_or_number_to_u16")] 27 | pub total: u16, 28 | /// This is the number of records skipped before the first record in the array returned. 29 | #[serde(deserialize_with = "string_or_number_to_u16")] 30 | pub skipped: u16, 31 | /// This is the maximum number of records that will be returned per request. 32 | #[serde(deserialize_with = "string_or_number_to_u16")] 33 | pub per_page: u16, 34 | /// This is the current page being returned. 35 | #[serde(deserialize_with = "string_or_number_to_u16")] 36 | pub page: u16, 37 | /// This is how many pages in total are available for retrieval considering the maximum records per page specified. 38 | #[serde(deserialize_with = "string_or_number_to_u16")] 39 | pub page_count: u16, 40 | } 41 | 42 | /// This struct represents the authorization data of the transaction status response 43 | #[derive(Debug, Deserialize, Serialize, Clone)] 44 | pub struct Authorization { 45 | /// Authorization code generated for the Transaction. 46 | pub authorization_code: Option, 47 | /// Bin number for Transaction authorization. 48 | pub bin: Option, 49 | /// Last 4 digits of authorized card. 50 | pub last4: Option, 51 | /// Authorized card expiry month. 52 | pub exp_month: Option, 53 | /// Authorized card expiry year. 54 | pub exp_year: Option, 55 | /// Authorization channel. It could be `card` or `bank`. 56 | pub channel: Option, 57 | /// Type of card used in the Authorization 58 | pub card_type: Option, 59 | /// Name of bank associated with the Authorization. 60 | pub bank: Option, 61 | /// Country code of the Authorization. 62 | pub country_code: Option, 63 | /// Brand of of the Authorization if it is a card. 64 | pub brand: Option, 65 | /// Specifies if the Authorization is reusable. 66 | pub reusable: Option, 67 | /// Signature of the Authorization. 68 | pub signature: Option, 69 | /// Name of the account associated with the authorization. 70 | pub account_name: Option, 71 | } 72 | -------------------------------------------------------------------------------- /src/models/split.rs: -------------------------------------------------------------------------------- 1 | //! Split Type 2 | //! =============== 3 | //! This file contains the transaction split options for the paystack API. 4 | 5 | use serde::Serialize; 6 | use std::fmt; 7 | 8 | /// Represents the type of transaction split. 9 | /// 10 | /// The `SplitType` enum defines the possible types of transaction splits that can be created, 11 | /// indicating whether the split is based on a percentage or a flat amount. 12 | /// 13 | /// # Variants 14 | /// 15 | /// - `Percentage`: A split based on a percentage. 16 | /// - `Flat`: A split based on an amount. 17 | /// 18 | /// # Examples 19 | /// 20 | /// ``` 21 | /// use paystack::SplitType; 22 | /// 23 | /// let percentage_split = SplitType::Percentage; 24 | /// let flat_split = SplitType::Flat; 25 | /// 26 | /// println!("{:?}", percentage_split); // Prints: Percentage 27 | /// ``` 28 | /// 29 | /// The example demonstrates the usage of the `SplitType` enum, creating instances of each variant 30 | /// and printing their debug representation. 31 | #[derive(Debug, Serialize, Clone, Default)] 32 | #[serde(rename_all = "lowercase")] 33 | pub enum SplitType { 34 | /// A split based on a percentage 35 | #[default] 36 | Percentage, 37 | /// A split based on an amount 38 | Flat, 39 | } 40 | 41 | impl fmt::Display for SplitType { 42 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 43 | let lowercase_string = match self { 44 | SplitType::Percentage => "percentage", 45 | SplitType::Flat => "flat", 46 | }; 47 | write!(f, "{}", lowercase_string) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/models/status.rs: -------------------------------------------------------------------------------- 1 | //! Status 2 | //! =============== 3 | //! This file contains the status options for the paystack API. 4 | 5 | use serde::{Deserialize, Serialize}; 6 | use std::fmt; 7 | 8 | /// Represents the status of a transaction. 9 | /// 10 | /// The `Status` enum defines the possible status values for a transaction, 11 | /// indicating whether the transaction was successful, abandoned, or failed. 12 | /// 13 | /// # Variants 14 | /// 15 | /// - `Success`: Represents a successful transaction. 16 | /// - `Abandoned`: Represents an abandoned transaction. 17 | /// - `Failed`: Represents a failed transaction. 18 | /// 19 | /// # Examples 20 | /// 21 | /// ``` 22 | /// use paystack::Status; 23 | /// 24 | /// let success_status = Status::Success; 25 | /// let abandoned_status = Status::Abandoned; 26 | /// let failed_status = Status::Failed; 27 | /// 28 | /// println!("{:?}", success_status); // Prints: Success 29 | /// ``` 30 | /// 31 | /// The example demonstrates the usage of the `Status` enum, creating instances of each variant 32 | /// and printing their debug representation. 33 | #[derive(Debug, Serialize, Deserialize, Clone)] 34 | #[serde(rename_all = "lowercase")] 35 | pub enum Status { 36 | /// A successful transaction. 37 | Success, 38 | /// An abandoned transaction. 39 | Abandoned, 40 | /// A failed transaction. 41 | Failed, 42 | } 43 | 44 | impl fmt::Display for Status { 45 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 46 | let lowercase_string = match self { 47 | Status::Success => "success", 48 | Status::Abandoned => "abandoned", 49 | Status::Failed => "failed", 50 | }; 51 | write!(f, "{}", lowercase_string) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/models/subaccount_model.rs: -------------------------------------------------------------------------------- 1 | //! Subaccounts 2 | //! ============== 3 | //! This file contains the models for working with the subaccounts endpoint. 4 | 5 | use derive_builder::Builder; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | /// This struct is used to create the body for creating a subaccount on your integration. 9 | /// Use the `SubaccountRequestBuilder` to create this object. 10 | #[derive(Serialize, Debug, Builder, Default)] 11 | pub struct SubaccountRequest { 12 | /// Name of business for subaccount 13 | business_name: String, 14 | /// Bank Code for the bank. 15 | /// You can get the list of Bank Codes by calling the List Banks endpoint. 16 | settlement_bank: String, 17 | /// Bank Account Number 18 | account_number: String, 19 | /// The default percentage charged when receiving on behalf of this subaccount 20 | percentage_charge: f32, 21 | /// A description for this subaccount 22 | description: String, 23 | /// A contact email for the subaccount 24 | #[builder(setter(into, strip_option), default)] 25 | primary_contact_email: Option, 26 | /// A name for the contact person for this subaccount 27 | #[builder(setter(into, strip_option), default)] 28 | primary_contact_name: Option, 29 | /// A phone number to call for this subaccount 30 | #[builder(setter(into, strip_option), default)] 31 | primary_contact_phone: Option, 32 | /// Stringified JSON object. 33 | /// Add a custom_fields attribute which has an array of objects if you would like the fields to be 34 | /// added to your transaction when displayed on the dashboard. 35 | /// Sample: {"custom_fields":[{"display_name":"Cart ID","variable_name": "cart_id","value": "8393"}]} 36 | #[builder(setter(into, strip_option), default)] 37 | metadata: Option, 38 | } 39 | 40 | /// This struct represents the subaccount. 41 | /// It can be used as the payload for the API end points that require a subaccount as a payload. 42 | /// It is also possible to extract a single field from this struct to use as well. 43 | /// The Struct is constructed using the `SubaccountBodyBuilder` 44 | #[derive(Serialize, Debug, Clone, Builder, Default)] 45 | pub struct SubaccountBody { 46 | /// This is the subaccount code 47 | pub subaccount: String, 48 | /// This is the transaction share for the subaccount 49 | pub share: f32, 50 | } 51 | 52 | /// Represents the data of th Subaccounts 53 | #[derive(Debug, Deserialize, Serialize, Default)] 54 | pub struct SubaccountData { 55 | /// Sub account data 56 | pub subaccount: SubaccountsResponseData, 57 | /// Share of split assigned to this sub 58 | pub share: u32, 59 | } 60 | 61 | /// Data of the list Subaccount response 62 | #[derive(Debug, Deserialize, Serialize, Default)] 63 | pub struct SubaccountsResponseData { 64 | /// Integration ID of subaccount. 65 | pub integration: Option, 66 | /// Subaccount domain. 67 | pub domain: Option, 68 | /// The code of the subaccount. 69 | pub subaccount_code: String, 70 | /// The name of the business associated with the subaccount. 71 | pub business_name: String, 72 | /// The description of the business associated with the subaccount. 73 | pub description: Option, 74 | /// The name of the primary contact for the business, if available. 75 | pub primary_contact_name: Option, 76 | /// The email of the primary contact for the business, if available. 77 | pub primary_contact_email: Option, 78 | /// The phone number of the primary contact for the business, if available. 79 | pub primary_contact_phone: Option, 80 | /// Additional metadata associated with the subaccount, if available. 81 | pub metadata: Option, 82 | /// The percentage charge for transactions associated with the subaccount. 83 | pub percentage_charge: Option, 84 | /// Verification status of subaccount. 85 | pub is_verified: Option, 86 | /// The name of the settlement bank for the subaccount. 87 | pub settlement_bank: String, 88 | /// The account number of the subaccount. 89 | pub account_number: String, 90 | /// Settlement schedule of subaccount. 91 | pub settlement_schedule: Option, 92 | /// The ID of the subaccount. 93 | pub id: u32, 94 | /// Creation time of subaccount. 95 | #[serde(rename = "createdAt")] 96 | pub created_at: Option, 97 | /// Last update time of subaccount. 98 | #[serde(rename = "updatedAt")] 99 | pub updated_at: Option, 100 | } 101 | 102 | /// Represents the JSON response for fetch subaccount. 103 | #[derive(Debug, Deserialize, Serialize, Default)] 104 | pub struct FetchSubaccountResponse { 105 | /// The status of the JSON response. 106 | pub status: bool, 107 | /// The message associated with the JSON response. 108 | pub message: String, 109 | /// Fetch Subaccount response data. 110 | pub data: SubaccountsResponseData, 111 | } 112 | 113 | /// This struct is used to create the body for deleting a subaccount on your integration. 114 | #[derive(Debug, Deserialize, Serialize, Builder, Default)] 115 | pub struct DeleteSubAccountBody { 116 | /// This is the subaccount code 117 | pub subaccount: String, 118 | } 119 | -------------------------------------------------------------------------------- /src/models/transaction_model.rs: -------------------------------------------------------------------------------- 1 | //! Transactions Models 2 | //! ==================== 3 | 4 | use derive_builder::Builder; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use crate::{Authorization, Channel, Currency, Customer}; 8 | 9 | /// This struct is used to create a transaction body for creating a transaction using the Paystack API. 10 | /// This struct is built using the `TransactionRequestBuilder` struct. 11 | #[derive(Clone, Default, Debug, Serialize, Builder)] 12 | pub struct TransactionRequest { 13 | /// Amount should be in the subunit of the supported currency 14 | pub amount: String, 15 | /// Customer's email address 16 | pub email: String, 17 | // optional parameters from here on 18 | /// The transaction currency. Defaults to your integration currency. 19 | #[builder(setter(into, strip_option), default)] 20 | pub currency: Option, 21 | /// Unique transaction reference. Only `-`, `.`, `=` and alphanumeric characters allowed. 22 | #[builder(setter(into, strip_option), default)] 23 | pub reference: Option, 24 | /// Fully qualified url, e.g. https://example.com/ . Use this to override the callback url provided on the dashboard for this transaction 25 | #[builder(setter(into, strip_option), default)] 26 | pub callback_url: Option, 27 | /// If transaction is to create a subscription to a predefined plan, provide plan code here. This would invalidate the value provided in `amount` 28 | #[builder(setter(into, strip_option), default)] 29 | pub plan: Option, 30 | /// Number of times to charge customer during subscription to plan 31 | #[builder(setter(into, strip_option), default)] 32 | pub invoice_limit: Option, 33 | /// Stringified JSON object of custom data. Kindly check the Metadata page for more information. 34 | #[builder(setter(into, strip_option), default)] 35 | pub metadata: Option, 36 | /// An array of payment channels to control what channels you want to make available to the user to make a payment with. 37 | #[builder(setter(into, strip_option), default)] 38 | pub channel: Option>, 39 | /// The split code of the transaction split. e.g. `SPL_98WF13Eb3w` 40 | #[builder(setter(into, strip_option), default)] 41 | pub split_code: Option, 42 | /// The code for the subaccount that owns the payment. e.g. `ACCT_8f4s1eq7ml6rlzj` 43 | #[builder(setter(into, strip_option), default)] 44 | pub subaccount: Option, 45 | /// An amount used to override the split configuration for a single split payment. 46 | /// If set, the amount specified goes to the main account regardless of the split configuration. 47 | #[builder(setter(into, strip_option), default)] 48 | pub transaction_charge: Option, 49 | /// Use this param to indicate who bears the transaction charges. Allowed values are: `account` or `subaccount` (defaults to `account`). 50 | #[builder(setter(into, strip_option), default)] 51 | pub bearer: Option, 52 | } 53 | 54 | /// This struct is used to create a partial debit transaction body for creating a partial debit using the Paystack API. 55 | /// This struct should be created using the `PartialDebitTransactionRequestBuilder` 56 | /// The derive Builder allows for the automatic creation of the BuilderPattern 57 | #[derive(Debug, Clone, Serialize, Default, Builder)] 58 | pub struct PartialDebitTransactionRequest { 59 | /// Authorization Code 60 | authorization_code: String, 61 | /// Specify the currency you want to debit. Allowed values are NGN or GHS. 62 | currency: Currency, 63 | /// Amount should be in the subunit of the supported currency 64 | amount: String, 65 | /// Customer's email address (attached to the authorization code) 66 | email: String, 67 | /// Unique transaction reference. Only `-`, `.`, `=` and alphanumeric characters allowed. 68 | #[builder(default = "None")] 69 | reference: Option, 70 | /// Minimum amount to charge 71 | #[builder(default = "None")] 72 | at_least: Option, 73 | } 74 | 75 | /// This struct represents the data of the transaction response. 76 | #[derive(Deserialize, Debug, Clone, Default)] 77 | pub struct TransactionResponseData { 78 | /// Generated URL to authorize the transaction. 79 | pub authorization_url: String, 80 | /// Access code of the transaction. 81 | pub access_code: String, 82 | /// Reference of the transaction. 83 | pub reference: String, 84 | } 85 | 86 | /// This struct represents the data of the transaction status response. 87 | #[derive(Deserialize, Serialize, Debug, Clone, Default)] 88 | pub struct TransactionStatusData { 89 | /// Id of the Transaction 90 | pub id: Option, 91 | /// Status of the Transaction. It can be `success`, `abandoned` or `failed` 92 | pub status: Option, 93 | /// Reference of the Transaction 94 | pub reference: Option, 95 | /// Amount of the transaction in the lowest denomination of the currency e.g. Kobo for NGN and cent for USD. 96 | pub amount: Option, 97 | /// Message from the transaction. 98 | pub message: Option, 99 | /// Response from the payment gateway. 100 | pub gateway_response: Option, 101 | /// Time the Transaction was completed. 102 | pub paid_at: Option, 103 | /// Time the Transaction was created. 104 | pub created_at: Option, 105 | /// Transaction channel. It can be `card` or `bank`. 106 | pub channel: Option, 107 | /// Currency code of the Transaction e.g. `NGN for Nigerian Naira` and `USD for US Dollar`. 108 | pub currency: Option, 109 | /// IP address of the computers the Transaction has passed through. 110 | pub ip_address: Option, 111 | /// Meta data associated with the Transaction. 112 | pub metadata: Option, 113 | /// Transaction fees to override the default fees specified in the integration. 114 | pub fees: Option, 115 | /// Transaction customer data. 116 | pub customer: Option, 117 | /// Transaction authorization data. 118 | pub authorization: Option, 119 | } 120 | 121 | /// This struct represents the transaction timeline data. 122 | #[derive(Deserialize, Serialize, Debug, Clone, Default)] 123 | pub struct TransactionTimelineData { 124 | /// Time spent in carrying out the transaction in ms. 125 | pub time_spent: Option, 126 | /// Number of attempts for the transaction. 127 | pub attempts: Option, 128 | /// Authentication use for the transaction. 129 | pub authentication: Option, 130 | /// Number of errors for the transaction. 131 | pub errors: Option, 132 | /// Success status of the transaction. 133 | pub success: Option, 134 | /// If transaction was carried out with mobile. 135 | pub mobile: Option, 136 | /// Transaction inputs i.e. messages associated with the transaction. 137 | pub input: Option, 138 | /// Transaction channel. 139 | pub channel: Option, 140 | /// Transaction history. 141 | pub history: Option>, 142 | } 143 | 144 | /// This struct represents the transaction history data 145 | #[derive(Deserialize, Serialize, Debug, Clone)] 146 | pub struct TransactionHistoryResponse { 147 | /// Transaction action. 148 | #[serde(rename = "type")] 149 | pub action_type: String, 150 | /// Description of the action. 151 | pub message: String, 152 | /// Time action was taken in ms. 153 | pub time: u32, 154 | } 155 | 156 | /// Transaction total data. 157 | #[derive(Debug, Deserialize, Serialize, Default)] 158 | pub struct TransactionTotalData { 159 | /// Total number of transactions in the integration. 160 | pub total_transactions: Option, 161 | /// Total of unique number of customers in the integration. 162 | pub unique_customers: Option, 163 | /// Total volume of transaction in the integration. 164 | pub total_volume: Option, 165 | /// Total volume of transaction broken down by currency. 166 | pub total_volume_by_currency: Option>, 167 | /// Total volume of pending transfers. 168 | pub pending_transfers: Option, 169 | /// Total volume of pending transfer broken down by currency. 170 | pub pending_transfers_by_currency: Option>, 171 | } 172 | 173 | /// Transaction volume by currency. 174 | #[derive(Debug, Deserialize, Serialize, Default)] 175 | pub struct VolumeByCurrency { 176 | /// Currency code. 177 | pub currency: String, 178 | /// Amount in the lowest denomination of the currency. 179 | pub amount: u32, 180 | } 181 | 182 | /// Export transaction response data. 183 | #[derive(Debug, Serialize, Deserialize, Default)] 184 | pub struct ExportTransactionData { 185 | /// Path to download the exported transaction file. 186 | pub path: String, 187 | } 188 | 189 | #[cfg(test)] 190 | mod test { 191 | use super::*; 192 | use std::error::Error; 193 | 194 | #[test] 195 | fn can_create_transaction_body_with_builder() -> Result<(), Box> { 196 | let transaction = TransactionRequestBuilder::default() 197 | .amount(String::from("10000")) 198 | .email(String::from("email@example.com")) 199 | .currency(Currency::NGN) 200 | .build()?; 201 | 202 | assert_eq!(transaction.email, "email@example.com"); 203 | assert_eq!(transaction.amount, "10000"); 204 | assert_eq!(transaction.currency, Some(Currency::NGN)); 205 | assert_eq!(transaction.bearer, None); 206 | 207 | Ok(()) 208 | } 209 | 210 | #[test] 211 | fn cannot_create_transaction_body_without_compulsory_field() -> Result<(), Box> { 212 | let transaction = TransactionRequestBuilder::default() 213 | .currency(Currency::GHS) 214 | .build(); 215 | 216 | assert!(transaction.is_err()); 217 | 218 | Ok(()) 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/models/transaction_split_model.rs: -------------------------------------------------------------------------------- 1 | //! Transaction Split Models 2 | //! ======================== 3 | //! This file contains the models for working with the transaction splits endpoint. 4 | 5 | use crate::{BearerType, Currency, SplitType, SubaccountBody, SubaccountData}; 6 | use derive_builder::Builder; 7 | use serde::{Deserialize, Serialize}; 8 | 9 | /// This struct is used to create a split payment on your integration. 10 | /// The struct is constructed using the `TransactionSplitRequestBuilder` 11 | #[derive(Serialize, Debug, Default, Builder)] 12 | pub struct TransactionSplitRequest { 13 | /// Name of the transaction split 14 | name: String, 15 | /// The type of transaction split you want to create 16 | #[serde(rename = "type")] 17 | split_type: SplitType, 18 | /// Any of the supported currency 19 | currency: Currency, 20 | /// A list of object containing subaccount code and number of shares: `[{subaccount: ‘ACT_xxxxxxxxxx’, share: xxx},{...}]` 21 | subaccounts: Vec, 22 | /// Any of subaccount 23 | bearer_type: BearerType, 24 | /// Subaccount code 25 | bearer_subaccount: String, 26 | } 27 | 28 | /// Represents the percentage split data received in the JSON response. 29 | #[derive(Debug, Deserialize, Serialize, Default)] 30 | pub struct TransactionSplitResponseData { 31 | /// The ID of the percentage split. 32 | pub id: u32, 33 | /// The name of the percentage split. 34 | pub name: String, 35 | /// The type of the percentage split. 36 | #[serde(rename = "type")] 37 | pub split_type: String, 38 | /// The currency used for the percentage split. 39 | pub currency: String, 40 | /// The integration associated with the percentage split. 41 | pub integration: u32, 42 | /// The domain associated with the percentage split. 43 | pub domain: String, 44 | /// The split code of the percentage split. 45 | pub split_code: String, 46 | /// Indicates whether the percentage split is active or not. 47 | pub active: Option, 48 | /// The bearer type of the percentage split. 49 | pub bearer_type: String, 50 | /// The subaccount ID of the bearer associated with the percentage split. 51 | pub bearer_subaccount: u32, 52 | /// The creation timestamp of the percentage split. 53 | pub created_at: Option, 54 | /// The last update timestamp of the percentage split. 55 | pub updated_at: Option, 56 | /// The list of subaccounts involved in the percentage split. 57 | pub subaccounts: Vec, 58 | /// The total count of subaccounts in the percentage split. 59 | pub total_subaccounts: u32, 60 | } 61 | 62 | /// This struct is used to update a transaction split details on your integration. 63 | /// The struct is constructed using the `UpdateTransactionSplitRequestBuilder` 64 | #[derive(Serialize, Debug, Builder, Default)] 65 | pub struct UpdateTransactionSplitRequest { 66 | /// Name of the transaction split 67 | name: String, 68 | /// True or False 69 | active: bool, 70 | /// Any of subaccount 71 | #[builder(setter(into, strip_option), default)] 72 | bearer_type: Option, 73 | /// Subaccount code of a subaccount in the split group. This should be specified only if the `bearer_type is subaccount 74 | #[builder(setter(into, strip_option), default)] 75 | bearer_subaccount: Option, 76 | } 77 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use serde::de::Error; 2 | use std::fmt::Formatter; 3 | use std::str::FromStr; 4 | 5 | pub fn string_or_number_to_u8<'de, D>(deserializer: D) -> Result 6 | where 7 | D: serde::Deserializer<'de>, 8 | { 9 | struct StringOrNumberVisitor; 10 | 11 | impl<'de> serde::de::Visitor<'de> for StringOrNumberVisitor { 12 | type Value = u8; 13 | 14 | fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { 15 | formatter.write_str("a string or an integer") 16 | } 17 | 18 | fn visit_str(self, v: &str) -> Result 19 | where 20 | E: Error, 21 | { 22 | u8::from_str(v).map_err(serde::de::Error::custom) 23 | } 24 | 25 | fn visit_u64(self, v: u64) -> Result 26 | where 27 | E: Error, 28 | { 29 | if v <= u8::MAX as u64 { 30 | Ok(v as u8) 31 | } else { 32 | Err(E::custom(format!("u64 value {} is out of range for u8", v))) 33 | } 34 | } 35 | } 36 | 37 | deserializer.deserialize_any(StringOrNumberVisitor) 38 | } 39 | 40 | pub fn string_or_number_to_u16<'de, D>(deserializer: D) -> Result 41 | where 42 | D: serde::Deserializer<'de>, 43 | { 44 | struct StringOrNumberVisitor; 45 | 46 | impl<'de> serde::de::Visitor<'de> for StringOrNumberVisitor { 47 | type Value = u16; 48 | 49 | fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { 50 | formatter.write_str("a string or an integer") 51 | } 52 | 53 | fn visit_str(self, v: &str) -> Result 54 | where 55 | E: Error, 56 | { 57 | u16::from_str(v).map_err(serde::de::Error::custom) 58 | } 59 | 60 | fn visit_u64(self, v: u64) -> Result 61 | where 62 | E: Error, 63 | { 64 | if v <= u16::MAX as u64 { 65 | Ok(v as u16) 66 | } else { 67 | Err(E::custom(format!( 68 | "u64 value {} is out of range for u16", 69 | v 70 | ))) 71 | } 72 | } 73 | } 74 | 75 | deserializer.deserialize_any(StringOrNumberVisitor) 76 | } 77 | -------------------------------------------------------------------------------- /tests/api/charge.rs: -------------------------------------------------------------------------------- 1 | use crate::helpers::get_paystack_client; 2 | use paystack::{Channel, ChargeRequestBuilder, Currency}; 3 | use rand::Rng; 4 | use std::error::Error; 5 | 6 | /// Values are hardcoded in this test because of the nature of the test. 7 | /// The values reflect the values in my integration. 8 | /// If you can come up with a way to improve this test, take a stab at it. 9 | #[tokio::test] 10 | async fn charge_authorization_succeeds() -> Result<(), Box> { 11 | // Arrange 12 | let client = get_paystack_client(); 13 | let mut rng = rand::thread_rng(); 14 | 15 | // Act 16 | // In this test, an already created customer in the integration is used 17 | let amount = rng.gen_range(100..=100000).to_string(); 18 | let charge = ChargeRequestBuilder::default() 19 | .email("susanna@example.net".to_string()) 20 | .amount(amount) 21 | .authorization_code("AUTH_ik4t69fo2y".to_string()) 22 | .currency(Some(Currency::NGN)) 23 | .channel(Some(vec![Channel::Card])) 24 | .transaction_charge(Some(100)) 25 | .build()?; 26 | 27 | let charge_response = client.transaction.charge_authorization(charge).await?; 28 | 29 | // Assert 30 | assert!(charge_response.status); 31 | assert_eq!( 32 | charge_response.data.customer.unwrap().email.unwrap(), 33 | "susanna@example.net" 34 | ); 35 | assert_eq!( 36 | charge_response 37 | .data 38 | .authorization 39 | .clone() 40 | .unwrap() 41 | .channel 42 | .unwrap(), 43 | "card" 44 | ); 45 | assert_eq!( 46 | charge_response 47 | .data 48 | .authorization 49 | .unwrap() 50 | .authorization_code 51 | .unwrap(), 52 | "AUTH_ik4t69fo2y" 53 | ); 54 | 55 | Ok(()) 56 | } 57 | -------------------------------------------------------------------------------- /tests/api/helpers.rs: -------------------------------------------------------------------------------- 1 | use dotenv::dotenv; 2 | use paystack::http::reqwest::ReqwestClient; 3 | use paystack::PaystackClient; 4 | use std::env; 5 | 6 | /// A function to get the bank information for the Paystack API 7 | pub fn get_bank_account_number_and_code() -> (String, String, String) { 8 | dotenv().ok(); 9 | 10 | ( 11 | env::var("BANK_ACCOUNT").expect("Unable to read Bank Account number from .env file."), 12 | env::var("BANK_CODE").expect("Unable to read Bank Code from .env file."), 13 | env::var("BANK_NAME").expect("Unable to read Bank Name from .env file."), 14 | ) 15 | } 16 | 17 | /// A function to get the base URL for the Paystack API 18 | pub fn get_base_url() -> String { 19 | dotenv().ok(); 20 | 21 | env::var("BASE_URL").unwrap_or(String::from("https://api.paystack.co")) 22 | } 23 | 24 | /// A function to get the API key 25 | pub fn get_api_key() -> String { 26 | dotenv().ok(); 27 | 28 | env::var("PAYSTACK_API_KEY").expect("Unable to read the paystack API key from .env file.") 29 | } 30 | 31 | /// A function to get an instance of the paystack client for testing 32 | pub fn get_paystack_client() -> PaystackClient { 33 | let api_key = get_api_key(); 34 | 35 | PaystackClient::::new(api_key) 36 | } 37 | -------------------------------------------------------------------------------- /tests/api/main.rs: -------------------------------------------------------------------------------- 1 | pub mod charge; 2 | pub mod helpers; 3 | pub mod transaction; 4 | pub mod transaction_split; 5 | -------------------------------------------------------------------------------- /tests/api/transaction.rs: -------------------------------------------------------------------------------- 1 | use crate::helpers::get_paystack_client; 2 | use fake::faker::internet::en::SafeEmail; 3 | use fake::Fake; 4 | use paystack::{ 5 | Channel, Currency, PartialDebitTransactionRequestBuilder, Status, TransactionRequestBuilder, 6 | }; 7 | use rand::Rng; 8 | 9 | #[tokio::test] 10 | async fn initialize_transaction_valid() { 11 | // Arrange 12 | let client = get_paystack_client(); 13 | let mut rng = rand::thread_rng(); 14 | 15 | // Act 16 | let email: String = SafeEmail().fake(); 17 | let amount: String = rng.gen_range(100..=10_000).to_string(); 18 | let body = TransactionRequestBuilder::default() 19 | .amount(amount) 20 | .email(email) 21 | .currency(Currency::NGN) 22 | .channel(vec![ 23 | Channel::Card, 24 | Channel::ApplePay, 25 | Channel::BankTransfer, 26 | Channel::Bank, 27 | ]) 28 | .build() 29 | .unwrap(); 30 | 31 | let res = client 32 | .transaction 33 | .initialize_transaction(body) 34 | .await 35 | .expect("unable to create transaction"); 36 | 37 | // Assert 38 | assert!(res.status); 39 | assert_eq!("Authorization URL created", res.message); 40 | } 41 | 42 | #[tokio::test] 43 | async fn initialize_transaction_fails_when_currency_is_not_supported_by_merchant() { 44 | // Arrange 45 | let client = get_paystack_client(); 46 | let mut rng = rand::thread_rng(); 47 | 48 | // Act 49 | let email: String = SafeEmail().fake(); 50 | let amount: String = rng.gen_range(100..=100000).to_string(); 51 | let body = TransactionRequestBuilder::default() 52 | .amount(amount) 53 | .email(email) 54 | .currency(Currency::GHS) 55 | .channel(vec![ 56 | Channel::ApplePay, 57 | Channel::BankTransfer, 58 | Channel::Bank, 59 | ]) 60 | .build() 61 | .expect("unable to build Transaction Request"); 62 | 63 | let res = client.transaction.initialize_transaction(body).await; 64 | 65 | // Assert 66 | match res { 67 | Ok(_) => (), 68 | Err(e) => { 69 | let res = e.to_string(); 70 | assert!(res.contains("status code: 403 Forbidden")); 71 | } 72 | } 73 | } 74 | 75 | #[tokio::test] 76 | async fn valid_transaction_is_verified() { 77 | // Arrange 78 | let client = get_paystack_client(); 79 | let mut rng = rand::thread_rng(); 80 | 81 | // Act 82 | let email: String = SafeEmail().fake(); 83 | let amount: String = rng.gen_range(100..=100000).to_string(); 84 | let body = TransactionRequestBuilder::default() 85 | .amount(amount) 86 | .email(email) 87 | .currency(Currency::NGN) 88 | .channel(vec![ 89 | Channel::ApplePay, 90 | Channel::BankTransfer, 91 | Channel::Bank, 92 | ]) 93 | .build() 94 | .unwrap(); 95 | 96 | let content = client 97 | .transaction 98 | .initialize_transaction(body) 99 | .await 100 | .expect("unable to initiate transaction"); 101 | 102 | let response = client 103 | .transaction 104 | .verify_transaction(&content.data.reference) 105 | .await 106 | .expect("unable to verify transaction"); 107 | 108 | // Assert 109 | assert!(response.status); 110 | assert_eq!(response.message, "Verification successful"); 111 | assert!(response.data.status.is_some()); 112 | } 113 | 114 | #[tokio::test] 115 | async fn list_specified_number_of_transactions_in_the_integration() { 116 | // Arrange 117 | let client = get_paystack_client(); 118 | 119 | // Act 120 | let response = client 121 | .transaction 122 | .list_transactions(Some(5), Some(Status::Abandoned)) 123 | .await 124 | .expect("unable to get list of integrated transactions"); 125 | 126 | // Assert 127 | assert_eq!(5, response.data.len()); 128 | assert!(response.status); 129 | assert_eq!("Transactions retrieved", response.message); 130 | } 131 | 132 | #[tokio::test] 133 | async fn list_transactions_passes_with_default_values() { 134 | // Arrange 135 | let client = get_paystack_client(); 136 | 137 | // Act 138 | let response = client 139 | .transaction 140 | .list_transactions(None, None) 141 | .await 142 | .expect("unable to get list of integration transactions"); 143 | 144 | // Assert 145 | assert!(response.status); 146 | assert_eq!(10, response.data.len()); 147 | assert_eq!("Transactions retrieved", response.message); 148 | } 149 | 150 | #[tokio::test] 151 | async fn fetch_transaction_succeeds() { 152 | // Arrange 153 | let client = get_paystack_client(); 154 | 155 | // Act 156 | let response = client 157 | .transaction 158 | .list_transactions(Some(1), Some(Status::Success)) 159 | .await 160 | .expect("unable to get list of integrated transactions"); 161 | 162 | let fetched_transaction = client 163 | .transaction 164 | .fetch_transactions(response.data[0].id.unwrap()) 165 | .await 166 | .expect("unable to fetch transaction"); 167 | 168 | // Assert 169 | assert_eq!(response.data[0].id, fetched_transaction.data.id); 170 | assert_eq!( 171 | response.data[0].reference, 172 | fetched_transaction.data.reference 173 | ); 174 | } 175 | 176 | #[tokio::test] 177 | async fn view_transaction_timeline_passes_with_id() { 178 | // Arrange 179 | let client = get_paystack_client(); 180 | 181 | // Act 182 | let response = client 183 | .transaction 184 | .list_transactions(Some(1), Some(Status::Success)) 185 | .await 186 | .expect("unable to get list of integrated transactions"); 187 | 188 | let transaction_timeline = client 189 | .transaction 190 | .view_transaction_timeline(response.data[0].id, None) 191 | .await 192 | .expect("unable to get transaction timeline"); 193 | 194 | // Assert 195 | assert!(transaction_timeline.status); 196 | assert_eq!(transaction_timeline.message, "Timeline retrieved"); 197 | } 198 | 199 | #[tokio::test] 200 | async fn view_transaction_timeline_passes_with_reference() { 201 | // Arrange 202 | let client = get_paystack_client(); 203 | 204 | // Act 205 | let response = client 206 | .transaction 207 | .list_transactions(Some(1), Some(Status::Success)) 208 | .await 209 | .expect("unable to get list of integrated transactions"); 210 | 211 | // println!("{:#?}", response); 212 | let reference = &response.data[0].reference.clone().unwrap(); 213 | let transaction_timeline = client 214 | .transaction 215 | .view_transaction_timeline(None, Some(reference)) 216 | .await 217 | .expect("unable to get transaction timeline"); 218 | 219 | // Assert 220 | assert!(transaction_timeline.status); 221 | assert_eq!(transaction_timeline.message, "Timeline retrieved"); 222 | } 223 | 224 | #[tokio::test] 225 | async fn view_transaction_timeline_fails_without_id_or_reference() { 226 | // Arrange 227 | let client = get_paystack_client(); 228 | 229 | // Act 230 | let res = client 231 | .transaction 232 | .view_transaction_timeline(None, None) 233 | .await; 234 | 235 | // Assert 236 | match res { 237 | Ok(_) => (), 238 | Err(e) => { 239 | let res = e.to_string(); 240 | assert!( 241 | res.contains("Transaction Id or Reference is need to view transaction timeline") 242 | ); 243 | } 244 | } 245 | } 246 | 247 | #[tokio::test] 248 | async fn get_transaction_total_is_successful() { 249 | // Arrange 250 | let client = get_paystack_client(); 251 | 252 | // Act 253 | let res = client 254 | .transaction 255 | .total_transactions() 256 | .await 257 | .expect("unable to get transaction total"); 258 | 259 | // Assert 260 | assert!(res.status); 261 | assert_eq!(res.message, "Transaction totals"); 262 | assert!(res.data.total_transactions.is_some()); 263 | assert!(res.data.total_volume.is_some()); 264 | } 265 | 266 | #[tokio::test] 267 | async fn export_transaction_succeeds_with_default_parameters() { 268 | // Arrange 269 | let client = get_paystack_client(); 270 | 271 | // Act 272 | let res = client 273 | .transaction 274 | .export_transaction(None, None, None) 275 | .await 276 | .expect("unable to export transactions"); 277 | 278 | // Assert 279 | assert!(res.status); 280 | assert_eq!(res.message, "Export successful"); 281 | assert!(!res.data.path.is_empty()); 282 | } 283 | 284 | #[tokio::test] 285 | async fn partial_debit_transaction_passes_or_fails_depending_on_merchant_status() { 286 | // Arrange 287 | let client = get_paystack_client(); 288 | 289 | // Act 290 | let transaction = client 291 | .transaction 292 | .list_transactions(Some(1), Some(Status::Success)) 293 | .await 294 | .expect("Unable to get transaction list"); 295 | 296 | let transaction = transaction.data[0].clone(); 297 | let email = transaction.customer.unwrap().email.unwrap(); 298 | let authorization_code = transaction 299 | .authorization 300 | .unwrap() 301 | .authorization_code 302 | .unwrap(); 303 | let body = PartialDebitTransactionRequestBuilder::default() 304 | .email(email) 305 | .amount("10000".to_string()) 306 | .authorization_code(authorization_code) 307 | .currency(Currency::NGN) 308 | .build() 309 | .unwrap(); 310 | 311 | let res = client.transaction.partial_debit(body).await; 312 | 313 | // Assert 314 | match res { 315 | Ok(result) => { 316 | assert!(result.status); 317 | assert_eq!(result.message, "Charge attempted"); 318 | assert!(result.data.customer.unwrap().id.is_some()) 319 | } 320 | Err(error) => { 321 | let error = error.to_string(); 322 | assert!(error.contains("status code: 400 Bad Request")); 323 | } 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /tests/api/transaction_split.rs: -------------------------------------------------------------------------------- 1 | use crate::helpers::{get_bank_account_number_and_code, get_paystack_client}; 2 | use fake::{ 3 | faker::{company::en::CompanyName, lorem::en::Sentence, name::en::FirstName}, 4 | Fake, 5 | }; 6 | use paystack::{ 7 | Currency, DeleteSubAccountBody, PaystackClient, ReqwestClient, SubaccountBody, 8 | SubaccountBodyBuilder, SubaccountRequestBuilder, TransactionSplitRequest, 9 | TransactionSplitRequestBuilder, UpdateTransactionSplitRequestBuilder, 10 | }; 11 | 12 | async fn create_subaccount_body( 13 | client: &PaystackClient, 14 | percentage_charge: f32, 15 | share: f32, 16 | ) -> SubaccountBody { 17 | let (account_number, bank_code, _bank_name) = get_bank_account_number_and_code(); 18 | 19 | let business_name: String = CompanyName().fake(); 20 | let description: String = Sentence(5..10).fake(); 21 | 22 | let body = SubaccountRequestBuilder::default() 23 | .business_name(business_name) 24 | .settlement_bank(bank_code.clone()) 25 | .account_number(account_number.clone()) 26 | .percentage_charge(percentage_charge) 27 | .description(description) 28 | .build() 29 | .unwrap(); 30 | 31 | let subaccount = client 32 | .subaccount 33 | .create_subaccount(body) 34 | .await 35 | .expect("Unable to Create a subaccount"); 36 | 37 | SubaccountBodyBuilder::default() 38 | .share(share) 39 | .subaccount(subaccount.data.subaccount_code) 40 | .build() 41 | .unwrap() 42 | } 43 | 44 | async fn build_transaction_split( 45 | client: &PaystackClient, 46 | ) -> (String, TransactionSplitRequest) { 47 | let txn_split_name: String = FirstName().fake(); 48 | 49 | // Create first subaccount body 50 | let first_subaccount_body = create_subaccount_body(client, 18.2, 80.0).await; 51 | 52 | // Create second subaccount body 53 | let second_subaccount_body = create_subaccount_body(client, 10.0, 10.0).await; 54 | 55 | // Create transaction split body 56 | let body = TransactionSplitRequestBuilder::default() 57 | .name(txn_split_name.clone()) 58 | .split_type(paystack::SplitType::Percentage) 59 | .currency(paystack::Currency::NGN) 60 | .bearer_type(paystack::BearerType::Subaccount) 61 | .subaccounts(vec![ 62 | first_subaccount_body.clone(), 63 | second_subaccount_body.clone(), 64 | ]) 65 | .bearer_subaccount(first_subaccount_body.subaccount) 66 | .build() 67 | .unwrap(); 68 | 69 | (txn_split_name, body) 70 | } 71 | 72 | #[tokio::test] 73 | async fn create_transaction_split_passes_with_valid_data() { 74 | // Arrange 75 | let client = get_paystack_client(); 76 | let (_, split_body) = build_transaction_split(&client).await; 77 | 78 | // Act 79 | let res = client 80 | .transaction_split 81 | .create_transaction_split(split_body) 82 | .await 83 | .expect("Failed to create transaction split"); 84 | 85 | // Assert 86 | assert!(res.status); 87 | assert_eq!(res.message, "Split created"); 88 | assert_eq!(res.data.currency, Currency::NGN.to_string()); 89 | } 90 | 91 | #[tokio::test] 92 | async fn create_transaction_split_fails_with_invalid_data() { 93 | //Arrange 94 | let client = get_paystack_client(); 95 | let split_name: String = FirstName().fake(); 96 | let body = TransactionSplitRequestBuilder::default() 97 | .name(split_name) 98 | .split_type(paystack::SplitType::Flat) 99 | .currency(paystack::Currency::EMPTY) 100 | .subaccounts(vec![]) 101 | .bearer_type(paystack::BearerType::Subaccount) 102 | .bearer_subaccount("non_existent_subaccount".to_string()) 103 | .build() 104 | .unwrap(); 105 | 106 | //Act 107 | let res = client 108 | .transaction_split 109 | .create_transaction_split(body) 110 | .await; 111 | 112 | if let Err(err) = res { 113 | assert!(err.to_string().contains("status code: 400 Bad Request")); 114 | } else { 115 | panic!(); 116 | } 117 | } 118 | 119 | #[tokio::test] 120 | async fn list_transaction_splits_in_the_integration() { 121 | // Arrange 122 | let client = get_paystack_client(); 123 | let (split_name, split_body) = build_transaction_split(&client).await; 124 | 125 | // Act 126 | // Create transaction split 127 | client 128 | .transaction_split 129 | .create_transaction_split(split_body) 130 | .await 131 | .expect("Failed to create transaction split"); 132 | 133 | // Fetch the splits 134 | let res = client 135 | .transaction_split 136 | .list_transaction_splits(Some(&split_name), None) 137 | .await; 138 | 139 | // Assert 140 | if let Ok(data) = res { 141 | assert!(data.status); 142 | assert_eq!(data.message, "Split retrieved".to_string()); 143 | assert_eq!(data.data.len(), 1); 144 | 145 | let transaction_split = data.data.first().unwrap(); 146 | assert_eq!( 147 | transaction_split.split_type, 148 | paystack::SplitType::Percentage.to_string() 149 | ); 150 | } else { 151 | dbg!("response: {:?}", &res); 152 | panic!(); 153 | } 154 | } 155 | 156 | #[tokio::test] 157 | async fn fetch_a_transaction_split_in_the_integration() { 158 | //Arrange 159 | let client = get_paystack_client(); 160 | let (_, split_body) = build_transaction_split(&client).await; 161 | 162 | // Act 163 | let transaction_split = client 164 | .transaction_split 165 | .create_transaction_split(split_body) 166 | .await 167 | .expect("Failed to create transaction split"); 168 | 169 | let res = client 170 | .transaction_split 171 | .fetch_transaction_split(&transaction_split.data.id.to_string()) 172 | .await 173 | .unwrap(); 174 | 175 | // Assert 176 | dbg!(&res); 177 | assert!(res.status); 178 | assert_eq!( 179 | res.data.total_subaccounts as usize, 180 | res.data.subaccounts.len() 181 | ); 182 | assert_eq!(res.message, "Split retrieved".to_string()); 183 | } 184 | 185 | #[tokio::test] 186 | async fn update_a_transaction_split_passes_with_valid_data() { 187 | //Arrange 188 | let client = get_paystack_client(); 189 | let transaction_split = client 190 | .transaction_split 191 | .list_transaction_splits(None, Some(true)) 192 | .await 193 | .expect("Failed to create transaction split"); 194 | 195 | // Act 196 | let new_subaccount_body = create_subaccount_body(&client, 44.3, 30.0).await; 197 | let new_split_name: String = FirstName().fake(); 198 | 199 | // create update split body 200 | let update_split_body = UpdateTransactionSplitRequestBuilder::default() 201 | .active(false) 202 | .bearer_type(paystack::BearerType::Account) 203 | .bearer_subaccount(new_subaccount_body) 204 | .name(new_split_name.clone()) 205 | .build() 206 | .unwrap(); 207 | 208 | // Act 209 | let split_id = transaction_split.data[0].id.to_string(); 210 | let res = client 211 | .transaction_split 212 | .update_transaction_split(&split_id, update_split_body) 213 | .await; 214 | 215 | // Assert 216 | if let Ok(data) = res { 217 | assert!(data.status); 218 | assert_eq!(data.message, "Split group updated".to_string()); 219 | assert!(!data.data.active.unwrap()); 220 | assert_eq!(data.data.name, new_split_name); 221 | } else { 222 | panic!(); 223 | } 224 | } 225 | 226 | #[tokio::test] 227 | async fn update_a_transaction_split_fails_with_invalid_data() { 228 | //Arrange 229 | let client = get_paystack_client(); 230 | let (_, split_body) = build_transaction_split(&client).await; 231 | 232 | // Act 233 | let transaction_split = client 234 | .transaction_split 235 | .create_transaction_split(split_body) 236 | .await 237 | .expect("Failed to create transaction split"); 238 | 239 | // create update split body 240 | let update_split_body = UpdateTransactionSplitRequestBuilder::default() 241 | .active(true) 242 | .bearer_type(paystack::BearerType::Subaccount) 243 | .name("".to_string()) 244 | .build() 245 | .unwrap(); 246 | 247 | // Act 248 | let split_id = transaction_split.data.id.to_string(); 249 | let res = client 250 | .transaction_split 251 | .update_transaction_split(&split_id, update_split_body) 252 | .await; 253 | 254 | // Assert 255 | if let Err(err) = res { 256 | assert!(err.to_string().contains("400 Bad Request")); 257 | } else { 258 | panic!(); 259 | } 260 | } 261 | 262 | #[tokio::test] 263 | async fn add_a_transaction_split_subaccount_passes_with_valid_data() { 264 | // Arrange 265 | let client = get_paystack_client(); 266 | let (_, split_body) = build_transaction_split(&client).await; 267 | 268 | // Act 269 | let transaction_split = client 270 | .transaction_split 271 | .create_transaction_split(split_body) 272 | .await 273 | .expect("Failed to create transaction split"); 274 | 275 | let new_subaccount_body = create_subaccount_body(&client, 2.8, 4.0).await; 276 | 277 | let split_id = transaction_split.data.id.to_string(); 278 | let res = client 279 | .transaction_split 280 | .add_or_update_subaccount_split(&split_id, new_subaccount_body.clone()) 281 | .await 282 | .unwrap(); 283 | 284 | // Assert 285 | assert!(res.status); 286 | assert_eq!(res.message, "Subaccount added"); 287 | assert_eq!(res.data.subaccounts.len(), 3); 288 | } 289 | 290 | #[tokio::test] 291 | async fn add_a_transaction_split_subaccount_fails_with_invalid_data() { 292 | // Arrange 293 | let client = get_paystack_client(); 294 | let (_, split_body) = build_transaction_split(&client).await; 295 | 296 | // Act 297 | let transaction_split = client 298 | .transaction_split 299 | .create_transaction_split(split_body) 300 | .await 301 | .expect("Failed to create transaction split"); 302 | 303 | let new_subaccount_body = create_subaccount_body(&client, 55.0, 120.0).await; 304 | 305 | let split_id = transaction_split.data.id.to_string(); 306 | let res = client 307 | .transaction_split 308 | .add_or_update_subaccount_split(&split_id, new_subaccount_body.clone()) 309 | .await; 310 | 311 | // Assert 312 | if let Err(err) = res { 313 | dbg!(&err); 314 | assert!(err.to_string().contains("400 Bad Request")); 315 | } else { 316 | panic!(); 317 | }; 318 | } 319 | 320 | #[tokio::test] 321 | async fn remove_a_subaccount_from_a_transaction_split_passes_with_valid_data() { 322 | // Arrange 323 | let client = get_paystack_client(); 324 | let (_, split_body) = build_transaction_split(&client).await; 325 | 326 | // Act 327 | let transaction_split = client 328 | .transaction_split 329 | .create_transaction_split(split_body) 330 | .await 331 | .expect("Failed to create transaction split"); 332 | let split_id = transaction_split.data.id.to_string(); 333 | 334 | // Validate the number of subaccounts attached 335 | assert_eq!(transaction_split.data.subaccounts.len(), 2); 336 | 337 | let subaccount_data = transaction_split.data.subaccounts.first().unwrap(); 338 | let code = &subaccount_data.subaccount.subaccount_code; 339 | // Remove subaccount 340 | let res = client 341 | .transaction_split 342 | .remove_subaccount_from_transaction_split( 343 | &split_id, 344 | DeleteSubAccountBody { 345 | subaccount: code.to_string(), 346 | }, 347 | ) 348 | .await 349 | .unwrap(); 350 | 351 | // Assert 352 | assert!(res.status); 353 | assert_eq!(res.message, "Subaccount removed"); 354 | 355 | // Revalidate number of subaccounts attached 356 | let transaction_split = client 357 | .transaction_split 358 | .fetch_transaction_split(&split_id) 359 | .await 360 | .unwrap(); 361 | 362 | // Assert 363 | assert!(transaction_split.status); 364 | assert_eq!(transaction_split.data.total_subaccounts, 1); 365 | let remaining_subaccount = transaction_split.data.subaccounts.first().unwrap(); 366 | assert_ne!( 367 | remaining_subaccount.subaccount.subaccount_code, 368 | subaccount_data.subaccount.subaccount_code 369 | ); 370 | } 371 | 372 | #[tokio::test] 373 | async fn remove_a_subaccount_from_a_transaction_split_fails_with_invalid_data() { 374 | // Arrange 375 | let client = get_paystack_client(); 376 | let (_, split_body) = build_transaction_split(&client).await; 377 | 378 | // Act 379 | let transaction_split = client 380 | .transaction_split 381 | .create_transaction_split(split_body) 382 | .await 383 | .expect("Failed to create transaction split"); 384 | let split_id = transaction_split.data.id.to_string(); 385 | 386 | // Validate the number of subaccounts attached 387 | assert_eq!(transaction_split.data.subaccounts.len(), 2); 388 | 389 | // Remove subaccount 390 | let res = client 391 | .transaction_split 392 | .remove_subaccount_from_transaction_split( 393 | &split_id, 394 | DeleteSubAccountBody { 395 | subaccount: "".to_string(), 396 | }, 397 | ) 398 | .await; 399 | 400 | // Assert 401 | if let Err(err) = res { 402 | assert!(err.to_string().contains("400 Bad Request")) 403 | } else { 404 | panic!(); 405 | } 406 | } 407 | --------------------------------------------------------------------------------