diff --git a/Cargo.lock b/Cargo.lock index 719b063..0031cde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2138,6 +2138,7 @@ version = "0.1.0" dependencies = [ "chrono", "dotenv", + "log", "reqwest", "serde", "serde_json", diff --git a/pay_u/Cargo.toml b/pay_u/Cargo.toml index acfb3f8..1e7608a 100644 --- a/pay_u/Cargo.toml +++ b/pay_u/Cargo.toml @@ -13,6 +13,8 @@ chrono = { version = "0.4.19", features = ["serde"] } thiserror = { version = "1.0.30" } +log = { version = "0.4.16" } + [dev-dependencies] tokio = { version = "1.17.0", features = ["full"] } dotenv = { version = "0.15.0" } diff --git a/pay_u/Readme.md b/pay_u/Readme.md new file mode 100644 index 0000000..50b09c4 --- /dev/null +++ b/pay_u/Readme.md @@ -0,0 +1,2 @@ +# Payu REST API + diff --git a/pay_u/src/deserialize.rs b/pay_u/src/deserialize.rs new file mode 100644 index 0000000..117dec9 --- /dev/null +++ b/pay_u/src/deserialize.rs @@ -0,0 +1,153 @@ +use std::fmt; + +use serde::de::{self, Error, Visitor}; + +pub(crate) fn deserialize_i32<'de, D>(d: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + d.deserialize_string(I32Visitor) +} + +pub(crate) fn deserialize_u32<'de, D>(d: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + d.deserialize_string(U32Visitor) +} + +struct I32Visitor; + +impl<'de> Visitor<'de> for I32Visitor { + type Value = i32; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("an integer between -2^31 and 2^31") + } + + fn visit_i8(self, value: i8) -> Result + where + E: de::Error, + { + Ok(i32::from(value)) + } + + fn visit_i32(self, value: i32) -> Result + where + E: de::Error, + { + Ok(value) + } + + fn visit_i64(self, value: i64) -> Result + where + E: de::Error, + { + use std::i32; + if value >= i64::from(i32::MIN) && value <= i64::from(i32::MAX) { + Ok(value as i32) + } else { + Err(E::custom(format!("i32 out of range: {}", value))) + } + } + + fn visit_str(self, v: &str) -> Result + where + E: Error, + { + if let Ok(value) = v.parse::() { + Ok(value) + } else { + Err(E::custom(format!("str does not contains valid i32: {}", v))) + } + } + + fn visit_string(self, v: String) -> Result + where + E: Error, + { + if let Ok(value) = v.parse::() { + Ok(value) + } else { + Err(E::custom(format!( + "string does not contains valid i32: {}", + v + ))) + } + } + + // Similar for other methods: + // - visit_i16 + // - visit_u8 + // - visit_u16 + // - visit_u32 + // - visit_u64 +} + +struct U32Visitor; + +impl<'de> Visitor<'de> for U32Visitor { + type Value = u32; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("an integer between -2^31 and 2^31") + } + + fn visit_u8(self, value: u8) -> Result + where + E: de::Error, + { + Ok(u32::from(value)) + } + + fn visit_u32(self, value: u32) -> Result + where + E: de::Error, + { + Ok(value) + } + + fn visit_u64(self, value: u64) -> Result + where + E: de::Error, + { + use std::u32; + if value >= u64::from(u32::MIN) && value <= u64::from(u32::MAX) { + Ok(value as u32) + } else { + Err(E::custom(format!("i32 out of range: {}", value))) + } + } + + fn visit_str(self, v: &str) -> Result + where + E: Error, + { + if let Ok(value) = v.parse::() { + Ok(value) + } else { + Err(E::custom(format!("str does not contains valid i32: {}", v))) + } + } + + fn visit_string(self, v: String) -> Result + where + E: Error, + { + if let Ok(value) = v.parse::() { + Ok(value) + } else { + Err(E::custom(format!( + "string does not contains valid i32: {}", + v + ))) + } + } + + // Similar for other methods: + // - visit_i16 + // - visit_u8 + // - visit_u16 + // - visit_u32 + // - visit_u64 +} diff --git a/pay_u/src/lib.rs b/pay_u/src/lib.rs index 8c272b6..c32c9d2 100644 --- a/pay_u/src/lib.rs +++ b/pay_u/src/lib.rs @@ -1,3 +1,6 @@ +mod deserialize; +mod serialize; + use reqwest::redirect; use serde::{Deserialize, Serialize}; @@ -11,6 +14,16 @@ pub enum Error { IncorrectTotal, #[error("{0}")] Reqwest(#[from] reqwest::Error), + #[error("Buyer is required to place an order")] + NoBuyer, + #[error("Description is required to place an order")] + NoDescription, + #[error("Client is not authorized")] + Unauthorized, + #[error("Refund returned invalid response")] + Refund, + #[error("Create order returned invalid response")] + CreateOrder, } pub type Result = std::result::Result; @@ -37,19 +50,36 @@ pub enum PaymentStatus { Canceled, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Copy, Clone, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RefundStatus { + /// refund was completed successfully + Finalized, + /// refund was cancelled + Canceled, + /// refund in progress + Pending, + /// PayU is currently waiting for the merchant system to receive (capture) + /// the payment. This status is set if auto-receive is disabled on the + /// merchant system. + WaitingForConfirmation, + /// Payment has been accepted. PayU will pay out the funds shortly. + Completed, +} + +#[derive(Serialize, Deserialize, Debug, Default)] #[serde(rename_all = "camelCase")] pub struct Buyer { /// Required customer e-mail - pub email: String, + email: String, /// Required customer phone number - pub phone: String, + phone: String, /// Required customer first name - pub first_name: String, + first_name: String, /// Required customer last name - pub last_name: String, + last_name: String, /// Required customer language - pub language: String, + language: String, } impl Buyer { @@ -75,18 +105,76 @@ impl Buyer { language: lang.into(), } } + + pub fn email(&self) -> &str { + &self.email + } + pub fn with_email(mut self, email: S) -> Self + where + S: Into, + { + self.email = email.into(); + self + } + pub fn phone(&self) -> &str { + &self.phone + } + pub fn with_phone(mut self, phone: S) -> Self + where + S: Into, + { + self.phone = phone.into(); + self + } + pub fn first_name(&self) -> &str { + &self.first_name + } + pub fn with_first_name(mut self, first_name: S) -> Self + where + S: Into, + { + self.first_name = first_name.into(); + self + } + pub fn last_name(&self) -> &str { + &self.last_name + } + pub fn with_last_name(mut self, last_name: S) -> Self + where + S: Into, + { + self.last_name = last_name.into(); + self + } + pub fn language(&self) -> &str { + &self.language + } + pub fn with_language(mut self, language: S) -> Self + where + S: Into, + { + self.language = language.into(); + self + } } pub type Price = i32; pub type Quantity = u32; +pub type MerchantPosId = i32; #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Product { pub name: String, - #[serde(serialize_with = "Product::serialize_unit_price")] + #[serde( + serialize_with = "serialize::serialize_i32", + deserialize_with = "deserialize::deserialize_i32" + )] pub unit_price: Price, - #[serde(serialize_with = "Product::serialize_quantity")] + #[serde( + serialize_with = "serialize::serialize_u32", + deserialize_with = "deserialize::deserialize_u32" + )] pub quantity: Quantity, } @@ -98,25 +186,11 @@ impl Product { quantity, } } - - fn serialize_unit_price(v: &Price, ser: S) -> std::result::Result - where - S: serde::Serializer, - { - ser.serialize_str(&format!("{v}")) - } - - fn serialize_quantity(v: &Quantity, ser: S) -> std::result::Result - where - S: serde::Serializer, - { - ser.serialize_str(&format!("{v}")) - } } #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] -pub struct Order { +pub struct OrderCreateRequest { /// URL to which web hook will be send. It's important to return 200 to all /// notifications. /// @@ -149,33 +223,166 @@ pub struct Order { /// | 19| 60 hours | /// | 20| 72 hours | #[serde(skip_serializing_if = "Option::is_none")] - pub notify_url: Option, + notify_url: Option, /// Customer client IP address - pub customer_ip: String, + customer_ip: String, /// Secret pos ip. This is connected to PayU account - pub merchant_pos_id: String, + #[serde( + serialize_with = "serialize::serialize_i32", + deserialize_with = "deserialize::deserialize_i32" + )] + merchant_pos_id: MerchantPosId, /// Transaction description - pub description: String, + description: String, /// 3 characters currency identifier, ex. PLN - pub currency_code: String, + currency_code: String, /// Total price of the order in pennies (e.g. 1000 is 10.00 EUR). Applies /// also to currencies without subunits (e.g. 1000 is 10 HUF). - #[serde(serialize_with = "Order::serialize_total_amount")] - pub total_amount: Price, + #[serde( + serialize_with = "serialize::serialize_i32", + deserialize_with = "deserialize::deserialize_i32" + )] + total_amount: Price, /// @see [Buyer] - pub buyer: Buyer, + buyer: Option, /// List of products - pub products: Vec, + products: Vec, #[serde(skip_serializing)] - pub order_create_date: Option, + order_create_date: Option, } -impl Order { - fn serialize_total_amount(v: &Price, ser: S) -> std::result::Result +impl OrderCreateRequest { + pub fn new( + buyer: Buyer, + customer_ip: CustomerIp, + currency: Currency, + ) -> Self where - S: serde::Serializer, + CustomerIp: Into, + Currency: Into, { - ser.serialize_str(&format!("{v}")) + Self { + notify_url: None, + customer_ip: customer_ip.into(), + merchant_pos_id: 0, + description: String::from(""), + currency_code: currency.into(), + total_amount: 0, + buyer: Some(buyer), + products: Vec::new(), + order_create_date: None, + } + } + + pub fn with_products(mut self, products: Products) -> Self + where + Products: Iterator, + { + self.products.extend(products); + self.total_amount = self + .products + .iter() + .fold(0, |agg, p| agg + (p.quantity as i32 * p.unit_price as i32)); + self + } + + pub fn with_product(mut self, product: Product) -> Self { + self.products.push(product); + self.total_amount = self + .products + .iter() + .fold(0, |agg, p| agg + (p.quantity as i32 * p.unit_price as i32)); + self + } + + pub fn with_description(mut self, desc: Description) -> Self + where + Description: Into, + { + self.description = String::from(desc.into().trim()); + self + } + + /// Add url to which PayU will be able to send http request with payment + /// status updates + /// + /// All requests from PayU should receive 200 response! + /// + /// See more [Order::notify_url] + pub fn with_notify_url(mut self, notify_url: NotifyUrl) -> Self + where + NotifyUrl: Into, + { + self.notify_url = Some(notify_url.into()); + self + } + + /// URL to which web hook will be send. It's important to return 200 to all + /// notifications. + /// + /// All notifications are send as POST with JSON payload + /// + /// Notifications are sent immediately after a payment status changes. If + /// the notification is not received by the Shop application, it will be + /// sent again in accordance with the table below: + /// + /// | Attempt | Time | + /// |---------|------| + /// | 1 | immediately | + /// | 2 | 1 minute | + /// | 3 | 2 minutes | + /// | 4 | 5 minutes | + /// | 5 | 10 minutes | + /// | 6 | 30 minutes | + /// | 7 | 1 hour | + /// | 8 | 2 hours | + /// | 9 | 3 hours | + /// | 10| 6 hours | + /// | 11| 9 hours | + /// | 12| 12 hours | + /// | 13| 15 hours | + /// | 14| 18 hours | + /// | 15| 21 hours | + /// | 16| 24 hours | + /// | 17| 36 hours | + /// | 18| 48 hours | + /// | 19| 60 hours | + /// | 20| 72 hours | + pub fn notify_url(&self) -> &Option { + &self.notify_url + } + + /// Customer IP address from http request received from client + pub fn customer_ip(&self) -> &String { + &self.customer_ip + } + + /// + pub fn merchant_pos_id(&self) -> MerchantPosId { + self.merchant_pos_id + } + pub fn description(&self) -> &String { + &self.description + } + pub fn currency_code(&self) -> &String { + &self.currency_code + } + pub fn total_amount(&self) -> &Price { + &self.total_amount + } + pub fn buyer(&self) -> &Option { + &self.buyer + } + pub fn products(&self) -> &[Product] { + &self.products + } + pub fn order_create_date(&self) -> &Option { + &self.order_create_date + } + + pub(crate) fn with_merchant_pos_id(mut self, merchant_pos_id: MerchantPosId) -> Self { + self.merchant_pos_id = merchant_pos_id; + self } } @@ -183,10 +390,22 @@ impl Order { #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum PaymentType { Pbl, - CartToken, + CardToken, Installments, } +/// Wrapper around pay method. It's used only for deserializing notifications +/// +/// # Examples +/// +/// ``` +/// # use pay_u::PayMethod; +/// let method: PayMethod = serde_json::from_str(r#" +/// { +/// "type": "INSTALLMENTS" +/// } +/// "#).unwrap(); +/// ``` #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct PayMethod { @@ -194,21 +413,110 @@ pub struct PayMethod { pub payment_type: PaymentType, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Status { - pub status_code: String, + status_code: String, + status_desc: Option, + code: Option, + severity: Option, + code_literal: Option, +} + +#[derive(Serialize, Deserialize, Copy, Clone, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum StatusCode { + ErrorValueMissing, + OpenpayuBusinessError, + OpenpayuErrorValueInvalid, + OpenpayuErrorInternal, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum CodeLiteral { + /// Request lacks "refund" object. + MissingRefundSection, + + /// Transaction has not been finalized + TransNotEnded, + + /// Lack of funds in account + NoBalance, + + /// Refund amount exceeds transaction amount + AmountToBig, + + /// Refund value is too small + AmountToSmall, + + /// Refunds have been disabled + RefundDisabled, + + /// Too many refund attempts have been made + RefundToOften, + + /// Refund was already created + Paid, + + /// Unknown error + UnknownError, + + /// extRefundId was re-used and other params do not match the values + /// sent during the first call. + RefundIdempotencyMismatch, + + /// Shop billing has not yet been completed + TransBillingEntriesNotCompleted, + + /// The available time for refund has passed. + TransTooOld, + + /// Transaction amount that remains after refund creation will be too + /// small to make another refund. + RemainingTransAmountTooSmall, + + #[serde(other)] + /// Implementation changed + Unknown, } impl Status { + /// Check if http request was successful + /// + /// # Examples + /// + /// ``` + /// # use pay_u::Status; + /// let status: Status = serde_json::from_str("{\"statusCode\":\"SUCCESS\"}").unwrap(); + /// assert_eq!(status.is_success(), true); + /// ``` pub fn is_success(&self) -> bool { self.status_code.as_str() == "SUCCESS" } + + /// Returns http status + /// + /// # Examples + /// + /// ``` + /// # use pay_u::Status; + /// let status: Status = serde_json::from_str("{\"statusCode\":\"SUCCESS\"}").unwrap(); + /// assert_eq!(status.status_code(), "SUCCESS"); + /// ``` + pub fn status_code(&self) -> &str { + &self.status_code + } + + pub fn status_desc(&self) -> Option<&str> { + self.status_desc.as_deref() + } } #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct CreateOrderResult { + /// Http status as a text pub status: Status, /// Client should be redirected to this URI pub redirect_uri: String, @@ -218,15 +526,64 @@ pub struct CreateOrderResult { pub ext_order_id: Option, } +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PartialRefundResult { + pub order_id: Option, + pub refund: Option, + pub status: Status, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct RefundRequest { + description: String, + amount: Price, +} + +impl RefundRequest { + pub fn new(description: Description, amount: Price) -> Self + where + Description: Into, + { + Self { + description: description.into(), + amount, + } + } + + pub fn description(&self) -> &str { + &self.description + } + pub fn amount(&self) -> Price { + self.amount + } +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Refund { + pub refund_id: String, + pub ext_refund_id: Option, + pub amount: String, + pub currency_code: String, + pub description: String, + pub creation_date_time: String, + pub status: String, + pub status_date_time: String, +} + pub mod notify { use serde::Deserialize; + /// Payment notification object received on [super::Order].[notify_url] #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct StatusUpdate { pub order: Order, pub local_receipt_date_time: Option, pub properties: Option>, + pub status: Option, } impl StatusUpdate { @@ -235,15 +592,45 @@ pub mod notify { } } + /// Refund notification object + #[derive(Deserialize, Debug)] + #[serde(rename_all = "camelCase")] + pub struct RefundUpdate { + pub ext_order_id: String, + pub order_id: String, + pub refund: Refund, + } + + #[derive(Deserialize, Debug)] + #[serde(rename_all = "camelCase")] + pub struct Refund { + pub refund_id: String, + pub amount: String, + pub currency_code: String, + pub status: super::RefundStatus, + pub status_date_time: String, + pub reason: String, + pub reason_description: String, + pub refund_date: String, + } + #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Order { #[serde(flatten)] - pub order: super::Order, - pub pay_method: Option, + pub order: super::OrderCreateRequest, + pub pay_method: Option, pub status: super::PaymentStatus, } + impl std::ops::Deref for Order { + type Target = super::OrderCreateRequest; + + fn deref(&self) -> &Self::Target { + &self.order + } + } + #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Prop { @@ -253,11 +640,48 @@ pub mod notify { } pub struct Client { - pub bearer: Option, - pub sandbox: bool, + sandbox: bool, + merchant_pos_id: MerchantPosId, + client_id: String, + client_secret: String, + bearer: Option, + bearer_expires_at: chrono::DateTime, } impl Client { + /// Create new PayU client + pub fn new( + client_id: ClientId, + client_secret: ClientSecret, + merchant_pos_id: MerchantPosId, + ) -> Self + where + ClientId: Into, + ClientSecret: Into, + { + Self { + bearer: None, + sandbox: false, + merchant_pos_id, + client_id: client_id.into(), + client_secret: client_secret.into(), + bearer_expires_at: chrono::Utc::now(), + } + } + + /// All operation will be performed in sandbox PayU environment + pub fn sandbox(mut self) -> Self { + self.sandbox = true; + self + } + + /// Set your own bearer key + pub fn with_bearer>(mut self, bearer: Bearer, expires_in: i64) -> Self { + self.bearer = Some(bearer.into()); + self.bearer_expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in); + self + } + /// Create new order in PayU /// /// ### IMPORTANT: @@ -270,31 +694,30 @@ impl Client { /// # Examples /// /// ```rust - /// # use pay_u::{Client, Order, Product, Buyer}; + /// # use pay_u::{Client, OrderCreateRequest, Product, Buyer}; /// async fn pay() { - /// let client = Client { - /// bearer: Some(String::from("d9a4536e-62ba-4f60-8017-6053211d3f47")), - /// sandbox: true, - /// }; - /// let res = client - /// .create_order(Order { - /// notify_url: Some(String::from("https://your.eshop.com/notify")), - /// customer_ip: "127.0.0.1".to_string(), - /// merchant_pos_id: "300746".to_string(), - /// description: "RTV market".to_string(), - /// currency_code: "PLN".to_string(), - /// total_amount: 21000, - /// buyer: Buyer::new("john.doe@example.com", "654111654", "John", "Doe", "pl"), - /// products: vec![ + /// let mut client = Client::new("145227", "12f071174cb7eb79d4aac5bc2f07563f", 300746) + /// .sandbox() + /// .with_bearer("d9a4536e-62ba-4f60-8017-6053211d3f47", 2000); + /// let res = client + /// .create_order( + /// OrderCreateRequest::new( + /// Buyer::new("john.doe@example.com", "654111654", "John", "Doe", "pl"), + /// "127.0.0.1", + /// "PLN", + /// ) + /// .with_notify_url("https://your.eshop.com/notify") + /// .with_description("RTV market") + /// .with_products([ /// Product::new("Wireless Mouse for Laptop", 15000, 1), /// Product::new("HDMI cable", 6000, 1), - /// ], - /// order_create_date: None, - /// }) + /// ].into_iter()), + /// ) /// .await; /// } /// ``` - pub async fn create_order(&self, order: Order) -> Result { + pub async fn create_order(&mut self, order: OrderCreateRequest) -> Result { + self.authorize().await?; if order.total_amount != order .products @@ -303,25 +726,128 @@ impl Client { { return Err(Error::IncorrectTotal); } - let client = reqwest::ClientBuilder::default() - .user_agent("curl/7.82.0") - .use_native_tls() - // Do not follow! - .redirect(redirect::Policy::none()) - .connection_verbose(true) - .build() - .expect("Failed to create client"); + + if order.buyer().is_none() { + return Err(Error::NoBuyer); + } + + if order.description().trim().is_empty() { + return Err(Error::NoDescription); + } + + let client = Self::build_client(); let bearer = self.bearer.as_ref().cloned().unwrap_or_default(); let path = format!("{}/orders", self.base_url()); - client + let text = client .post(path) .bearer_auth(bearer) - .json(&order) + .json(&order.with_merchant_pos_id(self.merchant_pos_id)) .send() .await? - .json::() - .await - .map_err(Into::into) + .text() + .await?; + log::trace!("Response: {}", text); + serde_json::from_str(&text).map_err(|e| { + log::error!("{e:?}"); + Error::CreateOrder + }) + } + + /// The PayU system fully supports refunds for processed payments, the + /// balance of which is transferred directly to the buyer’s account. + /// + /// > For „PayU | Pay later” payment method funds are transferred to a + /// > credit provider. + /// + /// You can process refund requests as either full or partial. For partial + /// refunds, always specify the amount in the lowest unit of a given + /// currency, which must be the same currency as the initial order (np. for + /// Poland lowest currency unit is “grosz” so 10 pln should be given as + /// 1000). + /// + /// You can send several partial refund requests for each single order. The + /// total value of the requests must not exceed the order value. + /// + /// The payu system allows multiple partial refunds to be executed at the + /// same time. To do so, the extRefundId parameter must be sent in the + /// request. In situations where partial refunds will not be executed more + /// than once per second, the extRefundId parameter is not required. + /// + /// # Examples + /// + /// ``` + /// # use pay_u::*; + /// async fn perform_refund() { + /// let mut client = Client::new("145227", "12f071174cb7eb79d4aac5bc2f07563f", 300746) + /// .with_bearer("d9a4536e-62ba-4f60-8017-6053211d3f47", 2000) + /// .sandbox(); + /// let res = client + /// .partial_refund( + /// "H9LL64F37H160126GUEST000P01", + /// RefundRequest::new("Refund", 1000), + /// ) + /// .await; + /// } + /// ``` + pub async fn partial_refund( + &mut self, + order_id: OrderId, + refund: RefundRequest, + ) -> Result + where + OrderId: std::fmt::Display, + { + self.authorize().await?; + if refund.description().trim().is_empty() { + return Err(Error::NoDescription); + } + + let client = Self::build_client(); + + let bearer = self.bearer.as_ref().cloned().unwrap_or_default(); + let path = format!("{}/orders/{}/refunds", self.base_url(), order_id); + let text = client + .post(path) + .bearer_auth(bearer) + .json(&refund) + .send() + .await? + .text() + .await?; + log::trace!("Response: {}", text); + serde_json::from_str::<'_, PartialRefundResult>(&text).map_err(|e| { + log::error!("Invalid PayU response {e:?}"); + Error::Refund + }) + } + + /// Get or refresh token + pub(crate) async fn authorize(&mut self) -> Result { + use chrono::{Duration, Utc}; + if Utc::now() - Duration::seconds(1) < self.bearer_expires_at { + return Ok(true); + } + #[derive(Deserialize)] + struct BearerResult { + access_token: String, + expires_in: i64, + } + + let res = Self::build_client().post(&format!( + "https://secure.payu.com/pl/standard/user/oauth/authorize?grant_type=client_credentials&client_id={}&client_secret={}", + self.client_id, + self.client_secret + )) + .send() + .await?; + let res = res.json::().await.map_err(|e| { + log::error!("{e}"); + Error::Unauthorized + })?; + log::trace!("Bearer is {}", res.access_token); + self.bearer_expires_at = Utc::now() + Duration::seconds(res.expires_in); + self.bearer = Some(res.access_token); + Ok(true) } fn base_url(&self) -> &str { @@ -331,36 +857,122 @@ impl Client { "https://secure.payu.com/api/v2_1" } } + + fn build_client() -> reqwest::Client { + reqwest::ClientBuilder::default() + .user_agent("curl/7.82.0") + .use_native_tls() + // Do not follow redirect! + .redirect(redirect::Policy::none()) + .connection_verbose(true) + .build() + .expect("Failed to create client") + } } #[cfg(test)] mod tests { use super::*; + fn build_client() -> Client { + dotenv::dotenv().ok(); + Client::new("145227", "12f071174cb7eb79d4aac5bc2f07563f", 300746) + .sandbox() + .with_bearer("d9a4536e-62ba-4f60-8017-6053211d3f47", 999999) + } + #[tokio::test] async fn create_order() { - dotenv::dotenv().ok(); - - let client = Client { - bearer: Some(String::from("d9a4536e-62ba-4f60-8017-6053211d3f47")), - sandbox: true, - }; - let res = client - .create_order(Order { - notify_url: Some(String::from("https://your.eshop.com/notify")), - customer_ip: "127.0.0.1".to_string(), - merchant_pos_id: "300746".to_string(), - description: "RTV market".to_string(), - currency_code: "PLN".to_string(), - total_amount: 21000, - buyer: Buyer::new("john.doe@example.com", "654111654", "John", "Doe", "pl"), - products: vec![ - Product::new("Wireless Mouse for Laptop", 15000, 1), - Product::new("HDMI cable", 6000, 1), - ], - order_create_date: None, - }) + let res = build_client() + .create_order( + OrderCreateRequest::new( + Buyer::new("john.doe@example.com", "654111654", "John", "Doe", "pl"), + "127.0.0.1", + "PLN", + ) + .with_notify_url("https://your.eshop.com/notify") + .with_description("RTV market") + .with_products( + [ + Product::new("Wireless Mouse for Laptop", 15000, 1), + Product::new("HDMI cable", 6000, 1), + ] + .into_iter(), + ), + ) .await; assert!(res.is_ok()); } + + #[tokio::test] + async fn partial_refund() { + let res = build_client() + .partial_refund( + "H9LL64F37H160126GUEST000P01", + RefundRequest::new("Refund", 1000), + ) + .await; + eprintln!("{:?}", res); + assert!(matches!(res, Ok(_))); + } + + #[tokio::test] + async fn check_refund() {} + + #[test] + fn check_accepted_refund_json() { + let res = serde_json::from_str::(include_str!( + "../tests/responses/accepted_refund.json" + )); + assert!(res.is_ok()); + } + #[test] + fn check_cancel_json() { + let res = serde_json::from_str::(include_str!( + "../tests/responses/cancel.json" + )); + assert!(res.is_ok()); + } + #[test] + fn check_completed_cart_token_json() { + let res = serde_json::from_str::(include_str!( + "../tests/responses/completed_cart_token.json" + )); + assert!(res.is_ok()); + } + #[test] + fn check_completed_installments_json() { + let res = serde_json::from_str::(include_str!( + "../tests/responses/completed_installments.json" + )); + assert!(res.is_ok()); + } + #[test] + fn check_completed_pbl_json() { + let res = serde_json::from_str::(include_str!( + "../tests/responses/completed_pbl.json" + )); + assert!(res.is_ok()); + } + #[test] + fn check_refund_json() { + let res = serde_json::from_str::(include_str!( + "../tests/responses/refund.json" + )); + assert!(res.is_ok()); + } + #[test] + fn check_rejection_json() { + let res = serde_json::from_str::(include_str!( + "../tests/responses/rejection.json" + )); + assert!(res.is_ok()); + } + #[test] + fn check_custom_literal_json() { + let res = serde_json::from_str::(include_str!( + "../tests/responses/custom_code_literal.json" + )); + assert!(res.is_ok()); + } } diff --git a/pay_u/src/serialize.rs b/pay_u/src/serialize.rs new file mode 100644 index 0000000..992275c --- /dev/null +++ b/pay_u/src/serialize.rs @@ -0,0 +1,13 @@ +pub(crate) fn serialize_i32(v: &i32, ser: S) -> std::result::Result +where + S: serde::Serializer, +{ + ser.serialize_str(&format!("{v}")) +} + +pub(crate) fn serialize_u32(v: &u32, ser: S) -> std::result::Result +where + S: serde::Serializer, +{ + ser.serialize_str(&format!("{v}")) +} diff --git a/pay_u/tests/responses/accepted_refund.json b/pay_u/tests/responses/accepted_refund.json new file mode 100644 index 0000000..5ba37db --- /dev/null +++ b/pay_u/tests/responses/accepted_refund.json @@ -0,0 +1,17 @@ +{ + "orderId": "ZXWZ53KQQM200702GUEST000P01", + "refund": { + "refundId": "5000009987", + "extRefundId": "20200702091903", + "amount": "21000", + "currencyCode": "PLN", + "description": "5000009987 Refund Accepted", + "creationDateTime": "2020-07-02T09:19:03.896+02:00", + "status": "PENDING", + "statusDateTime": "2020-07-02T09:19:04.013+02:00" + }, + "status": { + "statusCode": "SUCCESS", + "statusDesc": "Refund queued for processing" + } +} diff --git a/pay_u/tests/responses/cancel.json b/pay_u/tests/responses/cancel.json new file mode 100644 index 0000000..1fae831 --- /dev/null +++ b/pay_u/tests/responses/cancel.json @@ -0,0 +1,21 @@ +{ + "order": { + "orderId": "LDLW5N7MF4140324GUEST000P01", + "extOrderId": "Order id in your shop", + "orderCreateDate": "2012-12-31T12:00:00", + "notifyUrl": "http://tempuri.org/notify", + "customerIp": "127.0.0.1", + "merchantPosId": "98374", + "description": "My order description", + "currencyCode": "PLN", + "totalAmount": "200", + "products": [ + { + "name": "Product 1", + "unitPrice": "200", + "quantity": "1" + } + ], + "status": "CANCELED" + } +} diff --git a/pay_u/tests/responses/completed_cart_token.json b/pay_u/tests/responses/completed_cart_token.json new file mode 100644 index 0000000..d1407dd --- /dev/null +++ b/pay_u/tests/responses/completed_cart_token.json @@ -0,0 +1,38 @@ +{ + "order": { + "orderId": "LDLW5N7MF4140324GUEST000P01", + "extOrderId": "Order id in your shop", + "orderCreateDate": "2012-12-31T12:00:00", + "notifyUrl": "http://tempuri.org/notify", + "customerIp": "127.0.0.1", + "merchantPosId": "238684", + "description": "My order description", + "currencyCode": "PLN", + "totalAmount": "200", + "buyer": { + "email": "john.doe@example.org", + "phone": "111111111", + "firstName": "John", + "lastName": "Doe", + "language": "en" + }, + "payMethod": { + "type": "CARD_TOKEN" + }, + "products": [ + { + "name": "Product 1", + "unitPrice": "200", + "quantity": "1" + } + ], + "status": "COMPLETED" + }, + "localReceiptDateTime": "2016-03-02T12:58:14.828+01:00", + "properties": [ + { + "name": "PAYMENT_ID", + "value": "151471228" + } + ] +} diff --git a/pay_u/tests/responses/completed_installments.json b/pay_u/tests/responses/completed_installments.json new file mode 100644 index 0000000..3ccf59d --- /dev/null +++ b/pay_u/tests/responses/completed_installments.json @@ -0,0 +1,38 @@ +{ + "order": { + "orderId": "LDLW5N7MF4140324GUEST000P01", + "extOrderId": "Order id in your shop", + "orderCreateDate": "2012-12-31T12:00:00", + "notifyUrl": "http://tempuri.org/notify", + "customerIp": "127.0.0.1", + "merchantPosId": "8236", + "description": "My order description", + "currencyCode": "PLN", + "totalAmount": "200", + "buyer": { + "email": "john.doe@example.org", + "phone": "111111111", + "firstName": "John", + "lastName": "Doe", + "language": "en" + }, + "payMethod": { + "type": "INSTALLMENTS" + }, + "products": [ + { + "name": "Product 1", + "unitPrice": "200", + "quantity": "1" + } + ], + "status": "COMPLETED" + }, + "localReceiptDateTime": "2016-03-02T12:58:14.828+01:00", + "properties": [ + { + "name": "PAYMENT_ID", + "value": "151471228" + } + ] +} diff --git a/pay_u/tests/responses/completed_pbl.json b/pay_u/tests/responses/completed_pbl.json new file mode 100644 index 0000000..2f4e329 --- /dev/null +++ b/pay_u/tests/responses/completed_pbl.json @@ -0,0 +1,38 @@ +{ + "order": { + "orderId": "LDLW5N7MF4140324GUEST000P01", + "extOrderId": "Order id in your shop", + "orderCreateDate": "2012-12-31T12:00:00", + "notifyUrl": "http://tempuri.org/notify", + "customerIp": "127.0.0.1", + "merchantPosId": "928374", + "description": "My order description", + "currencyCode": "PLN", + "totalAmount": "200", + "buyer": { + "email": "john.doe@example.org", + "phone": "111111111", + "firstName": "John", + "lastName": "Doe", + "language": "en" + }, + "payMethod": { + "type": "PBL" + }, + "products": [ + { + "name": "Product 1", + "unitPrice": "200", + "quantity": "1" + } + ], + "status": "COMPLETED" + }, + "localReceiptDateTime": "2016-03-02T12:58:14.828+01:00", + "properties": [ + { + "name": "PAYMENT_ID", + "value": "151471228" + } + ] +} diff --git a/pay_u/tests/responses/custom_code_literal.json b/pay_u/tests/responses/custom_code_literal.json new file mode 100644 index 0000000..90ada03 --- /dev/null +++ b/pay_u/tests/responses/custom_code_literal.json @@ -0,0 +1,9 @@ +{ + "status": { + "statusCode": "ERROR_VALUE_MISSING", + "severity": "ERROR", + "code": "8300", + "codeLiteral": "FOO_BAR", + "statusDesc": "Implementation changed" + } +} diff --git a/pay_u/tests/responses/refund.json b/pay_u/tests/responses/refund.json new file mode 100644 index 0000000..239e375 --- /dev/null +++ b/pay_u/tests/responses/refund.json @@ -0,0 +1,14 @@ +{ + "orderId": "2DVZMPMFPN140219GUEST000P01", + "extOrderId": "Order id in your shop", + "refund": { + "refundId": "912128", + "amount": "15516", + "currencyCode": "PLN", + "status": "FINALIZED", + "statusDateTime": "1395677071799", + "reason": "refund", + "reasonDescription": "on customer’s request", + "refundDate": "1395677071799" + } +} diff --git a/pay_u/tests/responses/rejection.json b/pay_u/tests/responses/rejection.json new file mode 100644 index 0000000..bc78da1 --- /dev/null +++ b/pay_u/tests/responses/rejection.json @@ -0,0 +1,9 @@ +{ + "status": { + "statusCode": "ERROR_VALUE_MISSING", + "severity": "ERROR", + "code": "8300", + "codeLiteral": "MISSING_REFUND_SECTION", + "statusDesc": "Missing required field" + } +}