From 749f45eac49fca9b167c2221d337285c4c61dfe1 Mon Sep 17 00:00:00 2001 From: Oghenemarho Orukele Date: Wed, 25 Jun 2025 18:15:13 +0200 Subject: [PATCH 1/5] fix(builder): stripped all options as input in builder structs for easier development --- src/client.rs | 5 +- src/endpoints/mod.rs | 2 + src/endpoints/terminal.rs | 29 +++++---- src/endpoints/virtual_terminal.rs | 31 ++++++++++ src/models/charge.rs | 16 ++--- src/models/mod.rs | 2 + src/models/subaccount.rs | 8 +-- src/models/terminal.rs | 11 ++-- src/models/transaction.rs | 26 ++++----- src/models/transaction_split.rs | 4 +- src/models/virtual_terminal.rs | 97 +++++++++++++++++++++++++++++++ tests/api/charge.rs | 6 +- 12 files changed, 192 insertions(+), 45 deletions(-) create mode 100644 src/endpoints/virtual_terminal.rs create mode 100644 src/models/virtual_terminal.rs diff --git a/src/client.rs b/src/client.rs index 30ffe81..3cede40 100644 --- a/src/client.rs +++ b/src/client.rs @@ -3,7 +3,7 @@ //! This file contains the Paystack API client, and it associated endpoints. use crate::{ HttpClient, SubaccountEndpoints, TerminalEndpoints, TransactionEndpoints, - TransactionSplitEndpoints, + TransactionSplitEndpoints, VirtualTerminalEndpoints, }; use std::sync::Arc; @@ -18,6 +18,8 @@ pub struct PaystackClient { pub subaccount: SubaccountEndpoints, /// Terminal API route pub terminal: TerminalEndpoints, + /// Virutal Terminal API route + pub virutal_terminal: VirtualTerminalEndpoints, } impl PaystackClient { @@ -29,6 +31,7 @@ impl PaystackClient { transaction_split: TransactionSplitEndpoints::new(Arc::clone(&key), Arc::clone(&http)), subaccount: SubaccountEndpoints::new(Arc::clone(&key), Arc::clone(&http)), terminal: TerminalEndpoints::new(Arc::clone(&key), Arc::clone(&http)), + virutal_terminal: VirtualTerminalEndpoints::new(Arc::clone(&key), Arc::clone(&http)), } } } diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index 652fe1f..be02bac 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -2,9 +2,11 @@ pub mod subaccount; pub mod terminal; pub mod transaction; pub mod transaction_split; +pub mod virtual_terminal; // public re-export pub use subaccount::*; pub use terminal::*; pub use transaction::*; pub use transaction_split::*; +pub use virtual_terminal::*; diff --git a/src/endpoints/terminal.rs b/src/endpoints/terminal.rs index 70027f7..40b0d07 100644 --- a/src/endpoints/terminal.rs +++ b/src/endpoints/terminal.rs @@ -2,7 +2,7 @@ //! ======== //! The Terminal API allows you to build delightful in-person payment experiences. -use std::sync::Arc; +use std::{marker::PhantomData, sync::Arc}; use crate::{ EventRequest, FetchEventStatusResponseData, FetchTerminalStatusResponseData, HttpClient, @@ -164,7 +164,7 @@ impl TerminalEndpoints { &self, terminal_id: String, update_request: UpdateTerminalRequest, - ) -> PaystackResult { + ) -> PaystackResult> { let url = format!("{}/{}", self.base_url, terminal_id); let body = serde_json::to_value(update_request) .map_err(|e| PaystackAPIError::Terminal(e.to_string()))?; @@ -173,8 +173,9 @@ impl TerminalEndpoints { match response { Ok(response) => { - let parsed_response: Response = serde_json::from_str(&response) - .map_err(|e| PaystackAPIError::Terminal(e.to_string()))?; + let parsed_response: Response> = + serde_json::from_str(&response) + .map_err(|e| PaystackAPIError::Terminal(e.to_string()))?; Ok(parsed_response) } @@ -188,7 +189,10 @@ impl TerminalEndpoints { /// - `serial_number`: The device serial number /// NB: The generic for the result here is a `String`, because there is no data field in the response from the API. /// The string will be ignored because the underlying `data` field in the `response` is an `Option`. - pub async fn commission_terminal(&self, serial_number: String) -> PaystackResult { + pub async fn commission_terminal( + &self, + serial_number: String, + ) -> PaystackResult> { let url = format!("{}/commission_device", self.base_url); let body = serde_json::json!({ "serial_number": serial_number @@ -198,8 +202,9 @@ impl TerminalEndpoints { match response { Ok(response) => { - let parsed_response: Response = serde_json::from_str(&response) - .map_err(|e| PaystackAPIError::Terminal(e.to_string()))?; + let parsed_response: Response> = + serde_json::from_str(&response) + .map_err(|e| PaystackAPIError::Terminal(e.to_string()))?; Ok(parsed_response) } @@ -213,7 +218,10 @@ impl TerminalEndpoints { /// - `serial_number`: The device serial number /// NB: The generic for the result here is a `String`, because there is no data field in the response from the API. /// The string will be ignored because the underlying `data` field in the `response` is an `Option`. - pub async fn decommission_terminal(&self, serial_number: String) -> PaystackResult { + pub async fn decommission_terminal( + &self, + serial_number: String, + ) -> PaystackResult> { let url = format!("{}/decommission_device", self.base_url); let body = serde_json::json!({ "serial_number": serial_number @@ -223,8 +231,9 @@ impl TerminalEndpoints { match response { Ok(response) => { - let parsed_response: Response = serde_json::from_str(&response) - .map_err(|e| PaystackAPIError::Terminal(e.to_string()))?; + let parsed_response: Response> = + serde_json::from_str(&response) + .map_err(|e| PaystackAPIError::Terminal(e.to_string()))?; Ok(parsed_response) } diff --git a/src/endpoints/virtual_terminal.rs b/src/endpoints/virtual_terminal.rs new file mode 100644 index 0000000..c9943d7 --- /dev/null +++ b/src/endpoints/virtual_terminal.rs @@ -0,0 +1,31 @@ +//! Virtual Terminal +//! ================ +//! The Virtual Terminal API allows you to accept in-person payments without a POS device. + +use std::sync::Arc; + +use crate::{HttpClient, PaystackResult}; + +#[derive(Debug, Clone)] +pub struct VirtualTerminalEndpoints { + /// Paystack API key + key: String, + /// Base URL for the transaction route + base_url: String, + /// Http client for the route + http: Arc, +} + +impl VirtualTerminalEndpoints { + /// Constructor + pub fn new(key: Arc, http: Arc) -> VirtualTerminalEndpoints { + let base_url = String::from("https://api.paystack.co/virtual_terminal"); + VirtualTerminalEndpoints { + key: key.to_string(), + base_url, + http, + } + } + + // pub async fn create_virtual_terminal() -> PaystackResult {} +} diff --git a/src/models/charge.rs b/src/models/charge.rs index bb6a155..ee1962a 100644 --- a/src/models/charge.rs +++ b/src/models/charge.rs @@ -20,35 +20,35 @@ pub struct ChargeRequest { /// Valid authorization code to charge authorization_code: String, /// Unique transaction reference. Only `-`, `.`, `=` and alphanumeric characters allowed. - #[builder(default = "None")] + #[builder(setter(strip_option), default)] reference: Option, /// Currency in which amount should be charged. - #[builder(default = "None")] + #[builder(setter(strip_option), default)] currency: Option, /// Stringified JSON object. /// Add a custom_fields attribute which has an array of objects if you would like the fields to be added to your transaction /// when displayed on the dashboard. /// Sample: {"custom_fields":[{"display_name":"Cart ID","variable_name": "cart_id","value": "8393"}]} - #[builder(default = "None")] + #[builder(setter(strip_option), default)] metadata: Option, /// Send us 'card' or 'bank' or 'card','bank' as an array to specify what options to show the user paying - #[builder(default = "None")] + #[builder(setter(strip_option), default)] channel: Option>, /// The code for the subaccount that owns the payment. e.g. `ACCT_8f4s1eq7ml6rlzj` - #[builder(default = "None")] + #[builder(setter(strip_option), default)] subaccount: Option, /// A flat fee to charge the subaccount for this transaction in the subunit of the supported currency. /// This overrides the split percentage set when the subaccount was created. /// Ideally, you will need to use this if you are splitting in flat rates (since subaccount creation only allows for percentage split). - #[builder(default = "None")] + #[builder(setter(strip_option), default)] transaction_charge: Option, /// Who bears Paystack charges? account or subaccount (defaults to account). - #[builder(default = "None")] + #[builder(setter(strip_option), default)] bearer: Option, /// If you are making a scheduled charge call, it is a good idea to queue them so the processing system does not /// get overloaded causing transaction processing errors. /// Send queue:true to take advantage of our queued charging. - #[builder(default = "None")] + #[builder(setter(strip_option), default)] queue: Option, } diff --git a/src/models/mod.rs b/src/models/mod.rs index 66f9333..eeb45a9 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -11,6 +11,7 @@ pub mod subaccount; pub mod terminal; pub mod transaction; pub mod transaction_split; +pub mod virtual_terminal; // public re-export pub use authorization::*; @@ -26,3 +27,4 @@ pub use subaccount::*; pub use terminal::*; pub use transaction::*; pub use transaction_split::*; +pub use virtual_terminal::*; diff --git a/src/models/subaccount.rs b/src/models/subaccount.rs index 8924d08..69db4bc 100644 --- a/src/models/subaccount.rs +++ b/src/models/subaccount.rs @@ -21,19 +21,19 @@ pub struct SubaccountRequest { /// A description for this subaccount description: String, /// A contact email for the subaccount - #[builder(setter(into, strip_option), default)] + #[builder(setter(strip_option), default)] primary_contact_email: Option, /// A name for the contact person for this subaccount - #[builder(setter(into, strip_option), default)] + #[builder(setter(strip_option), default)] primary_contact_name: Option, /// A phone number to call for this subaccount - #[builder(setter(into, strip_option), default)] + #[builder(setter(strip_option), default)] primary_contact_phone: Option, /// Stringified JSON object. /// Add a custom_fields attribute which has an array of objects if you would like the fields to be /// added to your transaction when displayed on the dashboard. /// Sample: {"custom_fields":[{"display_name":"Cart ID","variable_name": "cart_id","value": "8393"}]} - #[builder(setter(into, strip_option), default)] + #[builder(setter(strip_option), default)] metadata: Option, } diff --git a/src/models/terminal.rs b/src/models/terminal.rs index bb2ff31..a65f614 100644 --- a/src/models/terminal.rs +++ b/src/models/terminal.rs @@ -23,6 +23,7 @@ pub struct EventRequest { #[derive(Debug, Serialize, Deserialize, Clone, Builder)] pub struct EventRequestData { pub id: String, + #[builder(setter(strip_option), default)] pub reference: Option, } @@ -71,8 +72,10 @@ impl fmt::Display for EventType { #[derive(Debug, Serialize, Deserialize, Builder, Default)] pub struct UpdateTerminalRequest { /// Name of the terminal + #[builder(setter(strip_option), default)] pub address: Option, /// The address of the terminal + #[builder(setter(strip_option), default)] pub name: Option, } @@ -116,8 +119,8 @@ mod tests { #[test] fn create_event_request() { let even_request_data = EventRequestDataBuilder::default() - .id("some-id".into()) - .reference(Some("some-ref".into())) + .id("some-id".to_string()) + .reference("some-ref".to_string()) .build() .expect("failed to build event request data"); @@ -137,8 +140,8 @@ mod tests { #[test] fn create_update_terminal_request() { let update_request = UpdateTerminalRequestBuilder::default() - .address(Some("some-address".to_string())) - .name(Some("some-name".to_string())) + .address("some-address".to_string()) + .name("some-name".to_string()) .build() .expect("failed to build update terminal request"); diff --git a/src/models/transaction.rs b/src/models/transaction.rs index e9ee18f..6fc0b43 100644 --- a/src/models/transaction.rs +++ b/src/models/transaction.rs @@ -16,38 +16,38 @@ pub struct TransactionRequest { pub email: String, // optional parameters from here on /// The transaction currency. Defaults to your integration currency. - #[builder(setter(into, strip_option), default)] + #[builder(setter(strip_option), default)] pub currency: Option, /// Unique transaction reference. Only `-`, `.`, `=` and alphanumeric characters allowed. - #[builder(setter(into, strip_option), default)] + #[builder(setter(strip_option), default)] pub reference: Option, /// Fully qualified url, e.g. https://example.com/ . Use this to override the callback url provided on the dashboard for this transaction - #[builder(setter(into, strip_option), default)] + #[builder(setter(strip_option), default)] pub callback_url: Option, /// If transaction is to create a subscription to a predefined plan, provide plan code here. This would invalidate the value provided in `amount` - #[builder(setter(into, strip_option), default)] + #[builder(setter(strip_option), default)] pub plan: Option, /// Number of times to charge customer during subscription to plan - #[builder(setter(into, strip_option), default)] + #[builder(setter(strip_option), default)] pub invoice_limit: Option, /// Stringified JSON object of custom data. Kindly check the Metadata page for more information. - #[builder(setter(into, strip_option), default)] + #[builder(setter(strip_option), default)] pub metadata: Option, /// An array of payment channels to control what channels you want to make available to the user to make a payment with. - #[builder(setter(into, strip_option), default)] + #[builder(setter(strip_option), default)] pub channel: Option>, /// The split code of the transaction split. e.g. `SPL_98WF13Eb3w` - #[builder(setter(into, strip_option), default)] + #[builder(setter(strip_option), default)] pub split_code: Option, /// The code for the subaccount that owns the payment. e.g. `ACCT_8f4s1eq7ml6rlzj` - #[builder(setter(into, strip_option), default)] + #[builder(setter(strip_option), default)] pub subaccount: Option, /// An amount used to override the split configuration for a single split payment. /// If set, the amount specified goes to the main account regardless of the split configuration. - #[builder(setter(into, strip_option), default)] + #[builder(setter(strip_option), default)] pub transaction_charge: Option, /// Use this param to indicate who bears the transaction charges. Allowed values are: `account` or `subaccount` (defaults to `account`). - #[builder(setter(into, strip_option), default)] + #[builder(setter(strip_option), default)] pub bearer: Option, } @@ -65,10 +65,10 @@ pub struct PartialDebitTransactionRequest { /// Customer's email address (attached to the authorization code) email: String, /// Unique transaction reference. Only `-`, `.`, `=` and alphanumeric characters allowed. - #[builder(default = "None")] + #[builder(default)] reference: Option, /// Minimum amount to charge - #[builder(default = "None")] + #[builder(default)] at_least: Option, } diff --git a/src/models/transaction_split.rs b/src/models/transaction_split.rs index fef6aec..ef53085 100644 --- a/src/models/transaction_split.rs +++ b/src/models/transaction_split.rs @@ -68,9 +68,9 @@ pub struct UpdateTransactionSplitRequest { /// True or False active: bool, /// Any of subaccount - #[builder(setter(into, strip_option), default)] + #[builder(setter(strip_option), default)] bearer_type: Option, /// Subaccount code of a subaccount in the split group. This should be specified only if the `bearer_type is subaccount - #[builder(setter(into, strip_option), default)] + #[builder(setter(strip_option), default)] bearer_subaccount: Option, } diff --git a/src/models/virtual_terminal.rs b/src/models/virtual_terminal.rs new file mode 100644 index 0000000..6cb9d97 --- /dev/null +++ b/src/models/virtual_terminal.rs @@ -0,0 +1,97 @@ +use derive_builder::Builder; +use serde::{Deserialize, Serialize}; + +use super::Currency; + +#[derive(Debug, Serialize, Deserialize, Clone, Builder, Default)] +pub struct VirtualTerminalRequestData { + /// Name of the virtual terminal + pub name: String, + /// An array of objects containing the notification recipients for payments to the Virtual Terminal. + /// Create with the `DestinationRequestDataBuilder` struct. + pub destinations: Vec, + /// Stringified JSON object of custom data. + /// Kindly check the Paystack API Metadata page for more information + #[builder(setter(strip_option), default)] + pub metadata: Option, + /// The transaction currency for the Virtual Terminal. Defaults to your integration currency + #[builder(setter(strip_option), default)] + pub currency: Option>, + /// An array of objects representing custom fields to display on the form. + /// Create with `CustomFieldBuilder` struct. + #[builder(setter(strip_option), default)] + pub custom_field: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Builder)] +pub struct DestinationRequest { + /// The Whatsapp phone number to send notifications to. + pub target: String, + /// A descriptive label + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Builder)] +pub struct CustomField { + /// What will be displayed on the Virtual Terminal page + pub display_name: String, + /// Parameter for referencing the custom field programmatically + pub variable_name: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_virtual_terminal_request() { + let destinations = vec![ + DestinationRequestBuilder::default() + .target("Whatsapp-phone-number".to_string()) + .name("Test-name".to_string()) + .build() + .expect("unable to build destination request"), + DestinationRequestBuilder::default() + .target("Whatsapp-phone-number-2".to_string()) + .name("Test-name-2".to_string()) + .build() + .expect("unable to build destination request"), + ]; + let currencies = vec![Currency::NGN, Currency::USD]; + let custom_field = vec![ + CustomFieldBuilder::default() + .display_name("display-name".to_string()) + .variable_name("variable-name".to_string()) + .build() + .expect("unable to build custome field"), + CustomFieldBuilder::default() + .display_name("display-name-2".to_string()) + .variable_name("variable-name-2".to_string()) + .build() + .expect("unable to build custome field"), + ]; + + let request = VirtualTerminalRequestDataBuilder::default() + .name("Some name".to_string()) + .destinations(destinations) + .currency(currencies) + .custom_field(custom_field) + .build() + .expect("unable to build virtual terminal request"); + + assert_eq!(request.name, "Some name"); + assert!(request.destinations.len() > 0); + assert!(request.currency.is_some()); + assert!(request.custom_field.is_some()); + assert!(request.metadata.is_none()); + } + + #[test] + fn cannot_create_virtual_terminal_request_without_compulsory_field() { + let request = VirtualTerminalRequestDataBuilder::default() + .currency(vec![Currency::GHS, Currency::NGN]) + .build(); + + assert!(request.is_err()); + } +} diff --git a/tests/api/charge.rs b/tests/api/charge.rs index ddf91a6..34049ff 100644 --- a/tests/api/charge.rs +++ b/tests/api/charge.rs @@ -19,9 +19,9 @@ async fn charge_authorization_succeeds() -> Result<(), Box> { .email("susanna@example.net".to_string()) .amount(amount) .authorization_code("AUTH_ik4t69fo2y".to_string()) - .currency(Some(Currency::NGN)) - .channel(Some(vec![Channel::Card])) - .transaction_charge(Some(100)) + .currency(Currency::NGN) + .channel(vec![Channel::Card]) + .transaction_charge(100) .build()?; let charge_response = client.transaction.charge_authorization(charge).await?; From 07b08aa068512a35447309ca2a58789ec2ca6379 Mon Sep 17 00:00:00 2001 From: Oghenemarho Orukele Date: Wed, 25 Jun 2025 18:38:41 +0200 Subject: [PATCH 2/5] feat(virtual_terminal): added support for creating virtual terminal --- src/endpoints/virtual_terminal.rs | 31 +++++++++++++++++++++++++++++-- src/errors.rs | 3 +++ src/models/virtual_terminal.rs | 29 +++++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/endpoints/virtual_terminal.rs b/src/endpoints/virtual_terminal.rs index c9943d7..bc84354 100644 --- a/src/endpoints/virtual_terminal.rs +++ b/src/endpoints/virtual_terminal.rs @@ -4,7 +4,10 @@ use std::sync::Arc; -use crate::{HttpClient, PaystackResult}; +use crate::{ + HttpClient, PaystackAPIError, PaystackResult, Response, VirtualTerminalRequestData, + VirtualTerminalResponseData, +}; #[derive(Debug, Clone)] pub struct VirtualTerminalEndpoints { @@ -27,5 +30,29 @@ impl VirtualTerminalEndpoints { } } - // pub async fn create_virtual_terminal() -> PaystackResult {} + /// Create a Virtual Terminal on your integration. + /// + /// Takes in the following: + /// - `VirtualTerminalRequestData`: The request data to create the virtual terminal. It is created with the `VirtualTerminalRequestDataBuilder` struct. + pub async fn create_virtual_terminal( + &self, + virtual_terminal_request: VirtualTerminalRequestData, + ) -> PaystackResult { + let url = format!("{}", self.base_url); + let body = serde_json::to_value(virtual_terminal_request) + .map_err(|e| PaystackAPIError::VirtualTerminal(e.to_string()))?; + + let response = self.http.post(&url, &self.key, &body).await; + + match response { + Ok(response) => { + let parsed_response: Response = + serde_json::from_str(&response) + .map_err(|e| PaystackAPIError::VirtualTerminal(e.to_string()))?; + + Ok(parsed_response) + } + Err(e) => Err(PaystackAPIError::VirtualTerminal(e.to_string())), + } + } } diff --git a/src/errors.rs b/src/errors.rs index 68f2050..4f4b609 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -25,6 +25,9 @@ pub enum PaystackAPIError { /// Error associated with terminal #[error("Terminal Error: {0}")] Terminal(String), + /// Error associated with virtual terminal + #[error("Virtual Terminal Error: {0}")] + VirtualTerminal(String), /// Error associated with customer #[error("Customer Error: {0}")] Customer(String), diff --git a/src/models/virtual_terminal.rs b/src/models/virtual_terminal.rs index 6cb9d97..4a1bced 100644 --- a/src/models/virtual_terminal.rs +++ b/src/models/virtual_terminal.rs @@ -23,7 +23,7 @@ pub struct VirtualTerminalRequestData { pub custom_field: Option>, } -#[derive(Debug, Serialize, Deserialize, Clone, Builder)] +#[derive(Debug, Serialize, Deserialize, Clone, Builder, Default)] pub struct DestinationRequest { /// The Whatsapp phone number to send notifications to. pub target: String, @@ -31,7 +31,7 @@ pub struct DestinationRequest { pub name: String, } -#[derive(Debug, Serialize, Deserialize, Clone, Builder)] +#[derive(Debug, Serialize, Deserialize, Clone, Builder, Default)] pub struct CustomField { /// What will be displayed on the Virtual Terminal page pub display_name: String, @@ -39,6 +39,31 @@ pub struct CustomField { pub variable_name: String, } +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct VirtualTerminalResponseData { + pub id: u64, + pub name: String, + pub integration: u64, + pub domain: String, + pub code: String, + pub payment_methods: Option>, + pub active: bool, + pub metadata: Option, + pub destinations: Option>, + pub currency: Option, + pub created_at: Option, +} + +#[derive(Debug, Deserialize, Clone, Serialize, Default)] +pub struct DestinationResponse { + pub target: Option, + #[serde(rename = "type")] + pub destination_type: Option, + pub name: Option, + pub created_at: Option, +} + #[cfg(test)] mod tests { use super::*; From d576fcdf1aa5f36b8ed32a0c606953bdeef11001 Mon Sep 17 00:00:00 2001 From: Oghenemarho Orukele Date: Wed, 25 Jun 2025 20:10:27 +0200 Subject: [PATCH 3/5] chore: added a new deserialiser for option_string_or_number_to_u8 and for the u16 variant --- src/endpoints/virtual_terminal.rs | 34 +++++++++++++- src/models/response.rs | 25 +++++----- src/models/virtual_terminal.rs | 18 +++++++ src/utils.rs | 78 +++++++++++++++++++++++++++++++ tests/api/main.rs | 1 + tests/api/virtual_terminal.rs | 21 +++++++++ 6 files changed, 164 insertions(+), 13 deletions(-) create mode 100644 tests/api/virtual_terminal.rs diff --git a/src/endpoints/virtual_terminal.rs b/src/endpoints/virtual_terminal.rs index bc84354..3647008 100644 --- a/src/endpoints/virtual_terminal.rs +++ b/src/endpoints/virtual_terminal.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use crate::{ HttpClient, PaystackAPIError, PaystackResult, Response, VirtualTerminalRequestData, - VirtualTerminalResponseData, + VirtualTerminalResponseData, VirtualTerminalStatus, }; #[derive(Debug, Clone)] @@ -55,4 +55,36 @@ impl VirtualTerminalEndpoints { Err(e) => Err(PaystackAPIError::VirtualTerminal(e.to_string())), } } + + /// List Virtual Terminals on your integration. + /// + /// Takes in the following: + /// - `status`: Filter terminal by status. + /// - `per_page`: Number of records per page. + pub async fn list_virtual_terminals( + &self, + status: VirtualTerminalStatus, + per_page: i32, + ) -> PaystackResult> { + let url = format!("{}", self.base_url); + let status = status.to_string(); + let per_page = per_page.to_string(); + + let query = vec![("status", status.as_str()), ("perPage", per_page.as_str())]; + + let response = self.http.get(&url, &self.key, Some(&query)).await; + + dbg!("{:#?}", &response); + + match response { + Ok(response) => { + let parsed_response: Response> = + serde_json::from_str(&response) + .map_err(|e| PaystackAPIError::VirtualTerminal(e.to_string()))?; + + Ok(parsed_response) + } + Err(e) => Err(PaystackAPIError::VirtualTerminal(e.to_string())), + } + } } diff --git a/src/models/response.rs b/src/models/response.rs index 75c2dd8..a4cdf5a 100644 --- a/src/models/response.rs +++ b/src/models/response.rs @@ -1,7 +1,7 @@ //! response //! ======== //! Holds the generic response templates for the API -use crate::utils::string_or_number_to_u16; +use crate::utils::option_string_or_number_to_u16; use serde::{Deserialize, Serialize}; /// Generic response body template for the API @@ -19,24 +19,25 @@ pub struct Response { } /// The Meta object is used to provide context for the contents of the data key. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] +#[serde(default)] pub struct Meta { /// This is the total number of transactions that were performed by the customer. - #[serde(deserialize_with = "string_or_number_to_u16")] - pub total: u16, + #[serde(deserialize_with = "option_string_or_number_to_u16")] + pub total: Option, /// This is the number of records skipped before the first record in the array returned. - #[serde(deserialize_with = "string_or_number_to_u16")] - pub skipped: u16, + #[serde(deserialize_with = "option_string_or_number_to_u16")] + pub skipped: Option, /// This is the maximum number of records that will be returned per request. - #[serde(deserialize_with = "string_or_number_to_u16")] - pub per_page: u16, + #[serde(deserialize_with = "option_string_or_number_to_u16")] + pub per_page: Option, /// This is the current page being returned. - #[serde(deserialize_with = "string_or_number_to_u16")] - pub page: u16, + #[serde(deserialize_with = "option_string_or_number_to_u16")] + pub page: Option, /// This is how many pages in total are available for retrieval considering the maximum records per page specified. - #[serde(deserialize_with = "string_or_number_to_u16")] - pub page_count: u16, + #[serde(deserialize_with = "option_string_or_number_to_u16")] + pub page_count: Option, pub next: Option, pub previous: Option, } diff --git a/src/models/virtual_terminal.rs b/src/models/virtual_terminal.rs index 4a1bced..67994cf 100644 --- a/src/models/virtual_terminal.rs +++ b/src/models/virtual_terminal.rs @@ -1,3 +1,5 @@ +use std::fmt; + use derive_builder::Builder; use serde::{Deserialize, Serialize}; @@ -64,6 +66,22 @@ pub struct DestinationResponse { pub created_at: Option, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub enum VirtualTerminalStatus { + Active, + Inactive, +} + +impl fmt::Display for VirtualTerminalStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let lowercase_string = match self { + VirtualTerminalStatus::Active => "active", + VirtualTerminalStatus::Inactive => "inactive", + }; + write!(f, "{}", lowercase_string) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/utils.rs b/src/utils.rs index 28283c9..4bcad73 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -74,3 +74,81 @@ where deserializer.deserialize_any(StringOrNumberVisitor) } + +pub fn option_string_or_number_to_u8<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + struct OptionStringOrNumberVisitor; + + impl<'de> serde::de::Visitor<'de> for OptionStringOrNumberVisitor { + type Value = Option; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("an optional u8, either as a number, a string, or null") + } + + fn visit_none(self) -> Result + where + E: Error, + { + Ok(None) + } + + fn visit_unit(self) -> Result + where + E: Error, + { + Ok(None) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // Delegate to the exisiting deserializer + super::string_or_number_to_u8(deserializer).map(Some) + } + } + + deserializer.deserialize_option(OptionStringOrNumberVisitor) +} + +pub fn option_string_or_number_to_u16<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + struct OptionStringOrNumberVisitor; + + impl<'de> serde::de::Visitor<'de> for OptionStringOrNumberVisitor { + type Value = Option; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("an optional u8, either as a number, a string, or null") + } + + fn visit_none(self) -> Result + where + E: Error, + { + Ok(None) + } + + fn visit_unit(self) -> Result + where + E: Error, + { + Ok(None) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // Delegate to the exisiting deserializer + super::string_or_number_to_u16(deserializer).map(Some) + } + } + + deserializer.deserialize_option(OptionStringOrNumberVisitor) +} diff --git a/tests/api/main.rs b/tests/api/main.rs index f636cdf..0361b57 100644 --- a/tests/api/main.rs +++ b/tests/api/main.rs @@ -3,3 +3,4 @@ pub mod helpers; pub mod terminal; pub mod transaction; pub mod transaction_split; +pub mod virtual_terminal; diff --git a/tests/api/virtual_terminal.rs b/tests/api/virtual_terminal.rs new file mode 100644 index 0000000..19fe884 --- /dev/null +++ b/tests/api/virtual_terminal.rs @@ -0,0 +1,21 @@ +use paystack::VirtualTerminalStatus; + +use crate::helpers::get_paystack_client; + +// TODO: to conduct the test, you need access to a paystack terminal which I do not have +#[tokio::test] +async fn can_list_virtual_terminals_in_integration() { + // Arrange + let client = get_paystack_client(); + + // Act + let res = client + .virutal_terminal + .list_virtual_terminals(VirtualTerminalStatus::Active, 10) + .await + .unwrap(); + + // Assert + assert!(res.status); + assert!(res.message.contains("Virtual Terminals retrieved")) +} From aeff88aaa225d6a780fdc0bc18b367bffdb61a62 Mon Sep 17 00:00:00 2001 From: Oghenemarho Orukele Date: Wed, 25 Jun 2025 21:17:43 +0200 Subject: [PATCH 4/5] fix(response): added support for response_type and code field in the response struct --- src/endpoints/terminal.rs | 2 +- src/endpoints/virtual_terminal.rs | 84 +++++++++++++++++++++++++++++-- src/http/reqwest.rs | 2 +- src/models/response.rs | 4 ++ src/models/virtual_terminal.rs | 1 + 5 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/endpoints/terminal.rs b/src/endpoints/terminal.rs index 40b0d07..854988f 100644 --- a/src/endpoints/terminal.rs +++ b/src/endpoints/terminal.rs @@ -169,7 +169,7 @@ impl TerminalEndpoints { let body = serde_json::to_value(update_request) .map_err(|e| PaystackAPIError::Terminal(e.to_string()))?; - let response = self.http.post(&url, &self.key, &body).await; + let response = self.http.put(&url, &self.key, &body).await; match response { Ok(response) => { diff --git a/src/endpoints/virtual_terminal.rs b/src/endpoints/virtual_terminal.rs index 3647008..24b3eb9 100644 --- a/src/endpoints/virtual_terminal.rs +++ b/src/endpoints/virtual_terminal.rs @@ -2,7 +2,9 @@ //! ================ //! The Virtual Terminal API allows you to accept in-person payments without a POS device. -use std::sync::Arc; +use std::{marker::PhantomData, sync::Arc}; + +use serde_json::json; use crate::{ HttpClient, PaystackAPIError, PaystackResult, Response, VirtualTerminalRequestData, @@ -74,8 +76,6 @@ impl VirtualTerminalEndpoints { let response = self.http.get(&url, &self.key, Some(&query)).await; - dbg!("{:#?}", &response); - match response { Ok(response) => { let parsed_response: Response> = @@ -87,4 +87,82 @@ impl VirtualTerminalEndpoints { Err(e) => Err(PaystackAPIError::VirtualTerminal(e.to_string())), } } + + /// Fetch a Virtual Terminal on your integration + /// + /// Takes in the following: + /// - `code`: Code of the Virtual Terminal + pub async fn fetch_virtual_terminal( + self, + code: String, + ) -> PaystackResult { + let url = format!("{}/{}", self.base_url, code); + + let response = self.http.get(&url, &self.key, None).await; + + match response { + Ok(response) => { + let parsed_response: Response = + serde_json::from_str(&response) + .map_err(|e| PaystackAPIError::VirtualTerminal(e.to_string()))?; + + Ok(parsed_response) + } + Err(e) => Err(PaystackAPIError::VirtualTerminal(e.to_string())), + } + } + + /// Update a Virtual Terminal on your integration + /// + /// Takes in the following: + /// - `code`: Code of the Virtual Terminal to update. + /// - `name`: Name of the Virtual Terminal. + pub async fn update_virtual_terminal( + &self, + code: String, + name: String, + ) -> PaystackResult> { + let url = format!("{}/{}", self.base_url, code); + let body = json!({ + "name": name + }); + + let response = self.http.put(&url, &self.key, &body).await; + + match response { + Ok(response) => { + let parsed_response: Response> = + serde_json::from_str(&response) + .map_err(|e| PaystackAPIError::VirtualTerminal(e.to_string()))?; + + Ok(parsed_response) + } + Err(e) => Err(PaystackAPIError::VirtualTerminal(e.to_string())), + } + } + + /// Deactivate a Virtual Terminal on your integration + /// + /// Takes in the following: + /// - `code`: Code of the Virtual Terminal to deactivate. + pub async fn deactivate_virtual_terminal( + &self, + code: String, + ) -> PaystackResult> { + let url = format!("{}/{}/deactivate", self.base_url, code); + let body = json!({}); // empty body cause the route takes none + + let response = self.http.put(&url, &self.key, &body).await; + + match response { + Ok(response) => { + let parsed_response: Response> = + serde_json::from_str(&response) + .map_err(|e| PaystackAPIError::VirtualTerminal(e.to_string()))?; + + Ok(parsed_response) + } + Err(e) => Err(PaystackAPIError::VirtualTerminal(e.to_string())), + } + } } diff --git a/src/http/reqwest.rs b/src/http/reqwest.rs index 8e577d9..607528d 100644 --- a/src/http/reqwest.rs +++ b/src/http/reqwest.rs @@ -118,7 +118,7 @@ mod tests { #[tokio::test] async fn reqwest_client_can_get() { // Set - let api_key = "fake-hey"; + let api_key = "fake-key"; let url = "https://api.paystack.co/"; // Run diff --git a/src/models/response.rs b/src/models/response.rs index a4cdf5a..72f2f70 100644 --- a/src/models/response.rs +++ b/src/models/response.rs @@ -16,6 +16,9 @@ pub struct Response { pub data: Option, /// This contains meta data object pub meta: Option, + #[serde(rename = "type")] + pub response_type: Option, + pub code: Option, } /// The Meta object is used to provide context for the contents of the data key. @@ -40,4 +43,5 @@ pub struct Meta { pub page_count: Option, pub next: Option, pub previous: Option, + pub next_step: Option, } diff --git a/src/models/virtual_terminal.rs b/src/models/virtual_terminal.rs index 67994cf..46d3885 100644 --- a/src/models/virtual_terminal.rs +++ b/src/models/virtual_terminal.rs @@ -52,6 +52,7 @@ pub struct VirtualTerminalResponseData { pub payment_methods: Option>, pub active: bool, pub metadata: Option, + pub connect_account_id: Option, pub destinations: Option>, pub currency: Option, pub created_at: Option, From a6d5c57b5c79f92fb663245bbc8252b0fcd77c74 Mon Sep 17 00:00:00 2001 From: Oghenemarho Orukele Date: Wed, 25 Jun 2025 22:55:59 +0200 Subject: [PATCH 5/5] feat(virtual_terminal): added support for all the endpoints in the virtual terminal route --- src/endpoints/virtual_terminal.rs | 119 +++++++++++++++++++++++++++++- src/models/transaction_split.rs | 3 + src/models/virtual_terminal.rs | 7 +- 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/src/endpoints/virtual_terminal.rs b/src/endpoints/virtual_terminal.rs index 24b3eb9..2b139f4 100644 --- a/src/endpoints/virtual_terminal.rs +++ b/src/endpoints/virtual_terminal.rs @@ -7,7 +7,8 @@ use std::{marker::PhantomData, sync::Arc}; use serde_json::json; use crate::{ - HttpClient, PaystackAPIError, PaystackResult, Response, VirtualTerminalRequestData, + DestinationRequest, DestinationResponse, HttpClient, PaystackAPIError, PaystackResult, + Response, TransactionSplitResponseData, VirtualTerminalRequestData, VirtualTerminalResponseData, VirtualTerminalStatus, }; @@ -165,4 +166,120 @@ impl VirtualTerminalEndpoints { Err(e) => Err(PaystackAPIError::VirtualTerminal(e.to_string())), } } + + /// Add a destination (WhatsApp number) to a Virtual Terminal on your integration + /// + /// Takes in the following: + /// - `code`: Code of the Virtual Terminal + /// - `destinations`: A vector of `DestinationRequest` containing the notification recipients for payments to the Virtual Terminal. + pub async fn assign_virtual_terminal_destination( + &self, + code: String, + destinations: Vec, + ) -> PaystackResult> { + let url = format!("{}/{}/destination/assign", self.base_url, code); + let body = json!({ + "destinations": destinations + }); + + let response = self.http.post(&url, &self.key, &body).await; + + match response { + Ok(response) => { + let parsed_response: Response> = + serde_json::from_str(&response) + .map_err(|e| PaystackAPIError::VirtualTerminal(e.to_string()))?; + + Ok(parsed_response) + } + Err(e) => Err(PaystackAPIError::VirtualTerminal(e.to_string())), + } + } + + /// Unassign a destination (WhatsApp Number) summary of transactions from a Virtual Terminal on your integration + /// + /// Takes in the following: + /// - `code`: Code of the Virtual Terminal. + /// - `targets`: A vector of destination targets to unassign. + pub async fn unassign_virtual_terminal_destination( + &self, + code: String, + targets: Vec, + ) -> PaystackResult> { + let url = format!("{}/{}/destination/unassign", self.base_url, code); + let body = json!({ + "targets": targets + }); + + let response = self.http.post(&url, &self.key, &body).await; + + match response { + Ok(response) => { + let parsed_response: Response> = + serde_json::from_str(&response) + .map_err(|e| PaystackAPIError::VirtualTerminal(e.to_string()))?; + + Ok(parsed_response) + } + Err(e) => Err(PaystackAPIError::VirtualTerminal(e.to_string())), + } + } + + /// Add a split code to a Virtual Terminal on your integration + /// + /// Takes in the following: + /// - `code`: Code of the Virtual Terminal + /// - `split_code`: Split code to be added to the Virtual Terminal + pub async fn add_split_code_to_virtual_terminal( + &self, + code: String, + split_code: String, + ) -> PaystackResult { + let url = format!("{}/{}/split_code", self.base_url, code); + let body = json!({ + "split_code": split_code + }); + + let response = self.http.put(&url, &self.key, &body).await; + + match response { + Ok(response) => { + let parsed_response: Response = + serde_json::from_str(&response) + .map_err(|e| PaystackAPIError::VirtualTerminal(e.to_string()))?; + + Ok(parsed_response) + } + Err(e) => Err(PaystackAPIError::VirtualTerminal(e.to_string())), + } + } + + /// Remove a split code from a Virtual Terminal on your integration + /// + /// Takes in the following: + /// - `code`: Code of the Virtual Terminal + /// - `split_code`: Split code to be removed from the Virtual Terminal + pub async fn remove_split_code_from_virtual_terminal( + &self, + code: String, + split_code: String, + ) -> PaystackResult> { + let url = format!("{}/{}/split_code", self.base_url, code); + let body = json!({ + "split_code": split_code + }); + + let response = self.http.delete(&url, &self.key, &body).await; + + match response { + Ok(response) => { + let parsed_response: Response> = + serde_json::from_str(&response) + .map_err(|e| PaystackAPIError::VirtualTerminal(e.to_string()))?; + + Ok(parsed_response) + } + Err(e) => Err(PaystackAPIError::VirtualTerminal(e.to_string())), + } + } } diff --git a/src/models/transaction_split.rs b/src/models/transaction_split.rs index ef53085..96d4808 100644 --- a/src/models/transaction_split.rs +++ b/src/models/transaction_split.rs @@ -50,9 +50,12 @@ pub struct TransactionSplitResponseData { /// The subaccount ID of the bearer associated with the percentage split. pub bearer_subaccount: u32, /// The creation timestamp of the percentage split. + #[serde(rename = "createdAt")] pub created_at: Option, /// The last update timestamp of the percentage split. + #[serde(rename = "updatedAt")] pub updated_at: Option, + pub is_dynamic: Option, /// The list of subaccounts involved in the percentage split. pub subaccounts: Vec, /// The total count of subaccounts in the percentage split. diff --git a/src/models/virtual_terminal.rs b/src/models/virtual_terminal.rs index 46d3885..56725ca 100644 --- a/src/models/virtual_terminal.rs +++ b/src/models/virtual_terminal.rs @@ -60,11 +60,16 @@ pub struct VirtualTerminalResponseData { #[derive(Debug, Deserialize, Clone, Serialize, Default)] pub struct DestinationResponse { + pub integration: Option, pub target: Option, + pub name: Option, #[serde(rename = "type")] pub destination_type: Option, - pub name: Option, + pub id: Option, + #[serde(rename = "createdAt")] pub created_at: Option, + #[serde(rename = "updatedAt")] + pub updated_at: Option, } #[derive(Debug, Serialize, Deserialize, Clone)]