From 1f2ba825193faad2fc655adfe2cb385aeac569e3 Mon Sep 17 00:00:00 2001 From: eraden Date: Wed, 27 Apr 2022 07:33:16 +0200 Subject: [PATCH] Add full refund and order details --- api/src/actors/payment_manager.rs | 7 +- api/src/main.rs | 4 +- pay_u/Cargo.toml | 2 +- pay_u/README.md | 2 +- pay_u/src/deserialize.rs | 9 + pay_u/src/lib.rs | 350 +++++++++++++++++++++++++----- pay_u/src/serialize.rs | 12 + 7 files changed, 324 insertions(+), 62 deletions(-) diff --git a/api/src/actors/payment_manager.rs b/api/src/actors/payment_manager.rs index edb7a28..22a4c4a 100644 --- a/api/src/actors/payment_manager.rs +++ b/api/src/actors/payment_manager.rs @@ -42,10 +42,11 @@ impl PaymentManager { merchant_pos_id: MerchantPosId, ) -> Result where - ClientId: Into, - ClientSecret: Into, + ClientId: Into, + ClientSecret: Into, { - let mut client = pay_u::Client::new(client_id, client_secret, merchant_pos_id); + let mut client = + pay_u::Client::new(client_id.into(), client_secret.into(), merchant_pos_id); client.authorize().await?; Ok(Self { client: Arc::new(Mutex::new(client)), diff --git a/api/src/main.rs b/api/src/main.rs index 1ca762c..8160984 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -11,6 +11,7 @@ use actix_web::{App, HttpServer}; use gumdrop::Options; use jemallocator::Jemalloc; use password_hash::SaltString; +use pay_u::MerchantPosId; use validator::{validate_email, validate_length}; use crate::actors::{database, email_manager, order_manager, payment_manager, token_manager}; @@ -189,7 +190,8 @@ async fn server(opts: ServerOpts) -> Result<()> { std::env::var("PAYU_CLIENT_SECRET").expect("Missing PAYU_CLIENT_SECRET env"); let merchant_id = std::env::var("PAYU_CLIENT_MERCHANT_ID") .expect("Missing PAYU_CLIENT_MERCHANT_ID env") - .parse() + .parse::() + .map(MerchantPosId::from) .expect("Variable PAYU_CLIENT_MERCHANT_ID must be number"); payment_manager::PaymentManager::build(client_id, client_secret, merchant_id) .await diff --git a/pay_u/Cargo.toml b/pay_u/Cargo.toml index 71cd86b..4f5083b 100644 --- a/pay_u/Cargo.toml +++ b/pay_u/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "pay_u" description = "PayU Rest API wrapper" -version = "0.1.4" +version = "0.1.5" edition = "2021" license = "MIT" diff --git a/pay_u/README.md b/pay_u/README.md index 01723a0..337eded 100644 --- a/pay_u/README.md +++ b/pay_u/README.md @@ -12,7 +12,7 @@ cargo add pay_u async fn usage() { let client_id = std::env::var("PAYU_CLIENT_ID").unwrap(); let client_secret = std::env::var("PAYU_CLIENT_SECRET").unwrap(); - let merchant_id = std::env::var("PAYU_CLIENT_MERCHANT_ID").unwrap().parse().unwrap(); + let merchant_id = std::env::var("PAYU_CLIENT_MERCHANT_ID").unwrap().parse::().map(MerchantPosId::from).unwrap(); let mut client = Client::new(client_id, client_secret, merchant_id); client.authorize().await.expect("Invalid credentials"); diff --git a/pay_u/src/deserialize.rs b/pay_u/src/deserialize.rs index 117dec9..9aa1736 100644 --- a/pay_u/src/deserialize.rs +++ b/pay_u/src/deserialize.rs @@ -9,6 +9,15 @@ where d.deserialize_string(I32Visitor) } +pub(crate) fn deserialize_i32_newtype<'de, N: From, D>( + d: D, +) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + d.deserialize_string(I32Visitor).map(N::from) +} + pub(crate) fn deserialize_u32<'de, D>(d: D) -> std::result::Result where D: serde::Deserializer<'de>, diff --git a/pay_u/src/lib.rs b/pay_u/src/lib.rs index 29423b4..5ca368b 100644 --- a/pay_u/src/lib.rs +++ b/pay_u/src/lib.rs @@ -6,6 +6,8 @@ use std::sync::Arc; use reqwest::redirect; use serde::{Deserialize, Serialize}; +use crate::res::OrdersInfo; + macro_rules! get_client { ($self:expr) => {{ #[cfg(feature = "single-client")] @@ -19,6 +21,8 @@ macro_rules! get_client { }}; } +pub static SUCCESS: &str = "SUCCESS"; + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("Client is not authorized. No bearer token available")] @@ -41,7 +45,9 @@ pub enum Error { CreateOrder, #[error("Failed to fetch order transactions")] OrderTransactions, - #[error("PayU rejected to create order. {status_code:?}")] + #[error("Failed to fetch order details")] + OrderDetails, + #[error("PayU rejected to create order with status {status_code:?}")] CreateFailed { status_code: String, status_desc: Option, @@ -49,7 +55,7 @@ pub enum Error { severity: Option, code_literal: Option, }, - #[error("PayU rejected to perform refund. {status_code:?}")] + #[error("PayU rejected to perform refund with status {status_code:?}")] RefundFailed { status_code: String, status_desc: Option, @@ -57,6 +63,24 @@ pub enum Error { severity: Option, code_literal: Option, }, + #[error("PayU rejected order details request with status {status_code:?}")] + OrderDetailsFailed { + status_code: String, + status_desc: Option, + code: Option, + severity: Option, + code_literal: Option, + }, + #[error("PayU rejected order transactions details request with status {status_code:?}")] + OrderTransactionsFailed { + status_code: String, + status_desc: Option, + code: Option, + severity: Option, + code_literal: Option, + }, + #[error("PayU returned order details but without any order")] + NoOrderInDetails, } pub type Result = std::result::Result; @@ -80,6 +104,57 @@ impl OrderId { } } +/// PayU internal order id +#[derive( + Debug, + serde::Deserialize, + serde::Serialize, + Copy, + Clone, + derive_more::Display, + derive_more::From, + derive_more::Deref, + derive_more::Constructor, +)] +#[serde(transparent)] +pub struct MerchantPosId(pub i32); + +#[derive( + Debug, + Clone, + serde::Deserialize, + serde::Serialize, + derive_more::Display, + derive_more::From, + derive_more::Deref, +)] +#[serde(transparent)] +pub struct ClientId(pub String); + +impl ClientId { + pub fn new>(id: S) -> Self { + Self(id.into()) + } +} + +#[derive( + Debug, + Clone, + serde::Deserialize, + serde::Serialize, + derive_more::Display, + derive_more::From, + derive_more::Deref, +)] +#[serde(transparent)] +pub struct ClientSecret(pub String); + +impl ClientSecret { + pub fn new>(id: S) -> Self { + Self(id.into()) + } +} + /// PayU payment status. /// /// Each payment is initially Pending and can change according to following @@ -212,7 +287,6 @@ impl Buyer { pub type Price = i32; pub type Quantity = u32; -pub type MerchantPosId = i32; #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] @@ -280,8 +354,8 @@ pub struct OrderCreateRequest { customer_ip: String, /// Secret pos ip. This is connected to PayU account #[serde( - serialize_with = "serialize::serialize_i32", - deserialize_with = "deserialize::deserialize_i32" + serialize_with = "serialize::serialize_newtype", + deserialize_with = "deserialize::deserialize_i32_newtype" )] merchant_pos_id: MerchantPosId, /// Transaction description @@ -316,7 +390,7 @@ impl OrderCreateRequest { Self { notify_url: None, customer_ip: customer_ip.into(), - merchant_pos_id: 0, + merchant_pos_id: 0.into(), description: String::from(""), currency_code: currency.into(), total_amount: 0, @@ -544,7 +618,7 @@ impl Status { /// assert_eq!(status.is_success(), true); /// ``` pub fn is_success(&self) -> bool { - self.status_code.as_str() == "SUCCESS" + self.status_code.as_str() == SUCCESS } /// Returns http status @@ -565,6 +639,13 @@ impl Status { } } +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Prop { + pub name: String, + pub value: String, +} + pub mod res { use crate::{OrderId, Refund, Status}; @@ -588,27 +669,27 @@ pub mod res { pub refund: Option, pub status: Status, } - #[derive(serde::Deserialize)] + #[derive(serde::Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct TransactionPayMethod { pub value: String, } - #[derive(serde::Deserialize)] + #[derive(serde::Deserialize, Debug)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum CardProfile { Consumer, Business, } - #[derive(serde::Deserialize)] + #[derive(serde::Deserialize, Debug)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum CardClassification { Debit, Credit, } - #[derive(serde::Deserialize)] + #[derive(serde::Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct TransactionCartData { /// // "543402******4014", @@ -632,40 +713,89 @@ pub mod res { pub first_transaction_id: String, } - #[derive(serde::Deserialize)] + /// > Installment proposal on the Sandbox environment is not related to the + /// > order amount and always returns data for 480 PLN. + #[derive(serde::Deserialize, Debug)] #[serde(rename_all = "camelCase")] - pub struct TransactionCardInstallmentProposal {} + pub struct TransactionCardInstallmentProposal { + /// Example: "5aff3ba8-0c37-4da1-ba4a-4ff24bcc2eed" + pub proposal_id: String, + } - #[derive(serde::Deserialize)] + #[derive(serde::Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct TransactionCart { pub cart_data: TransactionCartData, pub card_installment_proposal: TransactionCardInstallmentProposal, } - #[derive(serde::Deserialize)] + #[derive(serde::Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Transaction { pub pay_method: TransactionPayMethod, pub payment_flow: String, } - #[derive(serde::Deserialize)] + #[derive(serde::Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Transactions { pub transactions: Vec, } + + #[derive(serde::Deserialize, Debug)] + #[serde(rename_all = "camelCase")] + pub struct Order { + /// Example: "{orderId}", + pub order_id: super::OrderId, + /// Example: "358766", + pub ext_order_id: Option, + /// Example: "2014-10-27T14:58:17.443+01:00", + pub order_create_date: String, + /// Example: "http://localhost/OrderNotify/", + pub notify_url: Option, + /// Example: "127.0.0.1", + pub customer_ip: String, + /// Example: "145227", + pub merchant_pos_id: String, + /// Example: "New order", + pub description: String, + /// Example: "PLN", + pub currency_code: String, + /// Example: "3200", + pub total_amount: String, + /// Example: "NEW", + pub status: String, + /// Example: `[{"name":"Product1","unitPrice":"1000","quantity":"1"}]` + pub products: Vec, + } + + #[derive(serde::Deserialize, Debug)] + #[serde(rename_all = "camelCase")] + pub struct OrdersInfo { + pub orders: Vec, + pub status: super::Status, + pub properties: Option>, + } + + #[derive(serde::Deserialize, Debug)] + #[serde(rename_all = "camelCase")] + pub struct OrderInfo { + pub order: Order, + pub status: super::Status, + pub properties: Option>, + } } #[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct RefundRequest { description: String, - amount: Price, + #[serde(skip_serializing_if = "Option::is_none")] + amount: Option, } impl RefundRequest { - pub fn new(description: Description, amount: Price) -> Self + pub fn new(description: Description, amount: Option) -> Self where Description: Into, { @@ -679,7 +809,7 @@ impl RefundRequest { &self.description } - pub fn amount(&self) -> Price { + pub fn amount(&self) -> Option { self.amount } } @@ -709,7 +839,7 @@ pub mod notify { pub struct StatusUpdate { pub order: Order, pub local_receipt_date_time: Option, - pub properties: Option>, + pub properties: Option>, pub status: Option, } @@ -748,7 +878,7 @@ pub mod notify { /// Customer client IP address pub customer_ip: String, /// Secret pos ip. This is connected to PayU account - #[serde(deserialize_with = "deserialize::deserialize_i32")] + #[serde(deserialize_with = "deserialize::deserialize_i32_newtype")] pub merchant_pos_id: super::MerchantPosId, /// Transaction description pub description: String, @@ -768,20 +898,13 @@ pub mod notify { pub pay_method: Option, pub status: super::PaymentStatus, } - - #[derive(Deserialize, Debug)] - #[serde(rename_all = "camelCase")] - pub struct Prop { - pub name: String, - pub value: String, - } } pub struct Client { sandbox: bool, merchant_pos_id: MerchantPosId, - client_id: String, - client_secret: String, + client_id: ClientId, + client_secret: ClientSecret, bearer: Option, bearer_expires_at: chrono::DateTime, #[cfg(feature = "single-client")] @@ -790,23 +913,19 @@ pub struct Client { impl Client { /// Create new PayU client - pub fn new( + pub fn new( client_id: ClientId, client_secret: ClientSecret, merchant_pos_id: MerchantPosId, - ) -> Self - where - ClientId: Into, - ClientSecret: Into, - { + ) -> Self { #[cfg(feature = "single-client")] { Self { bearer: None, sandbox: false, merchant_pos_id, - client_id: client_id.into(), - client_secret: client_secret.into(), + client_id, + client_secret, bearer_expires_at: chrono::Utc::now(), client: Arc::new(Self::build_client()), } @@ -817,8 +936,8 @@ impl Client { bearer: None, sandbox: false, merchant_pos_id, - client_id: client_id.into(), - client_secret: client_secret.into(), + client_id, + client_secret, bearer_expires_at: chrono::Utc::now(), } } @@ -849,9 +968,9 @@ impl Client { /// # Examples /// /// ```rust - /// # use pay_u::{Client, OrderCreateRequest, Product, Buyer}; + /// # use pay_u::*; /// async fn pay() { - /// let mut client = Client::new("145227", "12f071174cb7eb79d4aac5bc2f07563f", 300746) + /// let mut client = Client::new(ClientId::new("145227"), ClientSecret::new("12f071174cb7eb79d4aac5bc2f07563f"), MerchantPosId::new(300746)) /// .sandbox() /// .with_bearer("d9a4536e-62ba-4f60-8017-6053211d3f47", 2000); /// let res = client @@ -906,7 +1025,7 @@ impl Client { log::error!("{e:?}"); Error::CreateOrder })?; - if res.status.status_code != "Success" { + if !res.status.is_success() { let Status { status_code, status_desc, @@ -950,18 +1069,29 @@ impl Client { /// ``` /// # use pay_u::*; /// async fn perform_refund() { - /// let mut client = Client::new("145227", "12f071174cb7eb79d4aac5bc2f07563f", 300746) + /// let mut client = Client::new(ClientId::new("145227"), ClientSecret::new("12f071174cb7eb79d4aac5bc2f07563f"), MerchantPosId::new(300746)) /// .with_bearer("d9a4536e-62ba-4f60-8017-6053211d3f47", 2000) /// .sandbox(); /// let res = client - /// .partial_refund( + /// .refund( /// OrderId::new("H9LL64F37H160126GUEST000P01"), - /// RefundRequest::new("Refund", 1000), + /// RefundRequest::new("Refund", Some(1000)), + /// ) + /// .await; + /// } + /// async fn full_refund() { + /// let mut client = Client::new(ClientId::new("145227"), ClientSecret::new("12f071174cb7eb79d4aac5bc2f07563f"), MerchantPosId::new(300746)) + /// .with_bearer("d9a4536e-62ba-4f60-8017-6053211d3f47", 2000) + /// .sandbox(); + /// let res = client + /// .refund( + /// OrderId::new("H9LL64F37H160126GUEST000P01"), + /// RefundRequest::new("Refund", None), /// ) /// .await; /// } /// ``` - pub async fn partial_refund( + pub async fn refund( &mut self, order_id: OrderId, refund: RefundRequest, @@ -987,7 +1117,7 @@ impl Client { log::error!("Invalid PayU response {e:?}"); Error::Refund })?; - if res.status.status_code.as_str() != "Success" { + if !res.status.is_success() { let Status { status_code, status_desc, @@ -1006,6 +1136,66 @@ impl Client { Ok(res) } + /// Order details request. You may use it to completely remove `Order` + /// persistence and use extOrderId to connect your data with PayU data. + /// + /// # Examples + /// + /// ``` + /// # use pay_u::*; + /// async fn order_details() { + /// let mut client = Client::new(ClientId::new("145227"), ClientSecret::new("12f071174cb7eb79d4aac5bc2f07563f"), MerchantPosId::new(300746)) + /// .with_bearer("d9a4536e-62ba-4f60-8017-6053211d3f47", 2000) + /// .sandbox(); + /// let res = client + /// .order_details(OrderId::new("H9LL64F37H160126GUEST000P01")) + /// .await; + /// } + /// ``` + pub async fn order_details(&mut self, order_id: OrderId) -> Result { + self.authorize().await?; + let bearer = self.bearer.as_ref().cloned().unwrap_or_default(); + let path = format!("{}/orders/{}", self.base_url(), order_id); + let client = get_client!(self); + let text = client + .post(path) + .bearer_auth(bearer) + .send() + .await? + .text() + .await?; + log::trace!("Response: {}", text); + let mut res: OrdersInfo = serde_json::from_str(&text).map_err(|e| { + log::error!("{e:?}"); + Error::OrderDetails + })?; + if !res.status.is_success() { + let Status { + status_code, + status_desc, + code, + severity, + code_literal, + } = res.status; + return Err(Error::OrderDetailsFailed { + status_code, + status_desc, + code, + severity, + code_literal, + }); + } + Ok(res::OrderInfo { + order: if res.orders.is_empty() { + return Err(Error::NoOrderInDetails); + } else { + res.orders.remove(0) + }, + status: res.status, + properties: res.properties, + }) + } + /// The transaction retrieve request message enables you to retrieve the /// details of transactions created for an order. /// @@ -1016,12 +1206,13 @@ impl Client { /// > transaction has been processed, the bank details may be available /// > either after few minutes or on the next business day, depending on the /// > bank. + /// /// # Examples /// /// ``` /// # use pay_u::*; /// async fn order_transactions() { - /// let mut client = Client::new("145227", "12f071174cb7eb79d4aac5bc2f07563f", 300746) + /// let mut client = Client::new(ClientId::new("145227"), ClientSecret::new("12f071174cb7eb79d4aac5bc2f07563f"), MerchantPosId::new(300746)) /// .with_bearer("d9a4536e-62ba-4f60-8017-6053211d3f47", 2000) /// .sandbox(); /// let res = client @@ -1104,9 +1295,13 @@ mod tests { fn build_client() -> Client { dotenv::dotenv().ok(); - Client::new("145227", "12f071174cb7eb79d4aac5bc2f07563f", 300746) - .sandbox() - .with_bearer("d9a4536e-62ba-4f60-8017-6053211d3f47", 999999) + Client::new( + ClientId::new("145227"), + ClientSecret::new("12f071174cb7eb79d4aac5bc2f07563f"), + MerchantPosId::new(300746), + ) + .sandbox() + .with_bearer("d9a4536e-62ba-4f60-8017-6053211d3f47", 999999) } #[tokio::test] @@ -1129,22 +1324,65 @@ mod tests { ), ) .await; + + if res.is_err() { + eprintln!("create_order res is {res:?}"); + } assert!(res.is_ok()); } #[tokio::test] async fn partial_refund() { let res = build_client() - .partial_refund( + .refund( OrderId::new("H9LL64F37H160126GUEST000P01"), - RefundRequest::new("Refund", 1000), + RefundRequest::new("Refund", Some(1000)), ) .await; - assert!(matches!(res, Ok(_))); + + if res.is_err() { + eprintln!("partial_refund res is {res:?}"); + } + assert!(matches!(res, Err(Error::RefundFailed { .. }))); } #[tokio::test] - async fn check_refund() {} + async fn full_refund() { + let res = build_client() + .refund( + OrderId::new("H9LL64F37H160126GUEST000P01"), + RefundRequest::new("Refund", None), + ) + .await; + + if res.is_err() { + eprintln!("full_refund res is {res:?}"); + } + assert!(matches!(res, Err(Error::RefundFailed { .. }))); + } + + #[tokio::test] + async fn order_details() { + let res = build_client() + .order_details(OrderId::new("H9LL64F37H160126GUEST000P01")) + .await; + + if res.is_err() { + eprintln!("order_details res is {res:?}"); + } + assert!(matches!(res, Err(Error::OrderDetails))); + } + + #[tokio::test] + async fn order_transactions() { + let res = build_client() + .order_transactions(OrderId::new("H9LL64F37H160126GUEST000P01")) + .await; + if res.is_err() { + eprintln!("order_transactions res is {res:?}"); + } + assert!(matches!(res, Err(Error::OrderTransactions))); + } #[test] fn check_accepted_refund_json() { diff --git a/pay_u/src/serialize.rs b/pay_u/src/serialize.rs index 992275c..54682ad 100644 --- a/pay_u/src/serialize.rs +++ b/pay_u/src/serialize.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + pub(crate) fn serialize_i32(v: &i32, ser: S) -> std::result::Result where S: serde::Serializer, @@ -5,6 +7,16 @@ where ser.serialize_str(&format!("{v}")) } +pub(crate) fn serialize_newtype( + v: &N, + 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,