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..854988f 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,17 +164,18 @@ 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()))?; - let response = self.http.post(&url, &self.key, &body).await; + 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::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..2b139f4 --- /dev/null +++ b/src/endpoints/virtual_terminal.rs @@ -0,0 +1,285 @@ +//! Virtual Terminal +//! ================ +//! The Virtual Terminal API allows you to accept in-person payments without a POS device. + +use std::{marker::PhantomData, sync::Arc}; + +use serde_json::json; + +use crate::{ + DestinationRequest, DestinationResponse, HttpClient, PaystackAPIError, PaystackResult, + Response, TransactionSplitResponseData, VirtualTerminalRequestData, + VirtualTerminalResponseData, VirtualTerminalStatus, +}; + +#[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, + } + } + + /// 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())), + } + } + + /// 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; + + 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())), + } + } + + /// 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())), + } + } + + /// 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/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/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/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/response.rs b/src/models/response.rs index 75c2dd8..72f2f70 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 @@ -16,27 +16,32 @@ 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. -#[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, + pub next_step: Option, } 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..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. @@ -68,9 +71,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..56725ca --- /dev/null +++ b/src/models/virtual_terminal.rs @@ -0,0 +1,146 @@ +use std::fmt; + +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, Default)] +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, Default)] +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, +} + +#[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 connect_account_id: Option, + pub destinations: Option>, + pub currency: Option, + pub created_at: Option, +} + +#[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 id: Option, + #[serde(rename = "createdAt")] + pub created_at: Option, + #[serde(rename = "updatedAt")] + pub updated_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::*; + + #[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/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/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?; 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")) +}