Add full refund and order details

This commit is contained in:
eraden 2022-04-27 07:33:16 +02:00
parent 4886a76a76
commit 1f2ba82519
7 changed files with 324 additions and 62 deletions

View File

@ -42,10 +42,11 @@ impl PaymentManager {
merchant_pos_id: MerchantPosId,
) -> Result<Self>
where
ClientId: Into<String>,
ClientSecret: Into<String>,
ClientId: Into<pay_u::ClientId>,
ClientSecret: Into<pay_u::ClientSecret>,
{
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)),

View File

@ -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::<i32>()
.map(MerchantPosId::from)
.expect("Variable PAYU_CLIENT_MERCHANT_ID must be number");
payment_manager::PaymentManager::build(client_id, client_secret, merchant_id)
.await

View File

@ -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"

View File

@ -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::<i32>().map(MerchantPosId::from).unwrap();
let mut client = Client::new(client_id, client_secret, merchant_id);
client.authorize().await.expect("Invalid credentials");

View File

@ -9,6 +9,15 @@ where
d.deserialize_string(I32Visitor)
}
pub(crate) fn deserialize_i32_newtype<'de, N: From<i32>, D>(
d: D,
) -> std::result::Result<N, D::Error>
where
D: serde::Deserializer<'de>,
{
d.deserialize_string(I32Visitor).map(N::from)
}
pub(crate) fn deserialize_u32<'de, D>(d: D) -> std::result::Result<u32, D::Error>
where
D: serde::Deserializer<'de>,

View File

@ -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<String>,
@ -49,7 +55,7 @@ pub enum Error {
severity: Option<String>,
code_literal: Option<CodeLiteral>,
},
#[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<String>,
@ -57,6 +63,24 @@ pub enum Error {
severity: Option<String>,
code_literal: Option<CodeLiteral>,
},
#[error("PayU rejected order details request with status {status_code:?}")]
OrderDetailsFailed {
status_code: String,
status_desc: Option<String>,
code: Option<String>,
severity: Option<String>,
code_literal: Option<CodeLiteral>,
},
#[error("PayU rejected order transactions details request with status {status_code:?}")]
OrderTransactionsFailed {
status_code: String,
status_desc: Option<String>,
code: Option<String>,
severity: Option<String>,
code_literal: Option<CodeLiteral>,
},
#[error("PayU returned order details but without any order")]
NoOrderInDetails,
}
pub type Result<T> = std::result::Result<T, Error>;
@ -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<S: Into<String>>(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<S: Into<String>>(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<Refund>,
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<Transaction>,
}
#[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<String>,
/// Example: "2014-10-27T14:58:17.443+01:00",
pub order_create_date: String,
/// Example: "http://localhost/OrderNotify/",
pub notify_url: Option<String>,
/// 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<super::Product>,
}
#[derive(serde::Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct OrdersInfo {
pub orders: Vec<Order>,
pub status: super::Status,
pub properties: Option<Vec<crate::Prop>>,
}
#[derive(serde::Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct OrderInfo {
pub order: Order,
pub status: super::Status,
pub properties: Option<Vec<crate::Prop>>,
}
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct RefundRequest {
description: String,
amount: Price,
#[serde(skip_serializing_if = "Option::is_none")]
amount: Option<Price>,
}
impl RefundRequest {
pub fn new<Description>(description: Description, amount: Price) -> Self
pub fn new<Description>(description: Description, amount: Option<Price>) -> Self
where
Description: Into<String>,
{
@ -679,7 +809,7 @@ impl RefundRequest {
&self.description
}
pub fn amount(&self) -> Price {
pub fn amount(&self) -> Option<Price> {
self.amount
}
}
@ -709,7 +839,7 @@ pub mod notify {
pub struct StatusUpdate {
pub order: Order,
pub local_receipt_date_time: Option<String>,
pub properties: Option<Vec<Prop>>,
pub properties: Option<Vec<super::Prop>>,
pub status: Option<super::Status>,
}
@ -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<super::PayMethod>,
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<String>,
bearer_expires_at: chrono::DateTime<chrono::Utc>,
#[cfg(feature = "single-client")]
@ -790,23 +913,19 @@ pub struct Client {
impl Client {
/// Create new PayU client
pub fn new<ClientId, ClientSecret>(
pub fn new(
client_id: ClientId,
client_secret: ClientSecret,
merchant_pos_id: MerchantPosId,
) -> Self
where
ClientId: Into<String>,
ClientSecret: Into<String>,
{
) -> 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<res::OrderInfo> {
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,7 +1295,11 @@ mod tests {
fn build_client() -> Client {
dotenv::dotenv().ok();
Client::new("145227", "12f071174cb7eb79d4aac5bc2f07563f", 300746)
Client::new(
ClientId::new("145227"),
ClientSecret::new("12f071174cb7eb79d4aac5bc2f07563f"),
MerchantPosId::new(300746),
)
.sandbox()
.with_bearer("d9a4536e-62ba-4f60-8017-6053211d3f47", 999999)
}
@ -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() {

View File

@ -1,3 +1,5 @@
use std::fmt::Display;
pub(crate) fn serialize_i32<S>(v: &i32, ser: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
@ -5,6 +7,16 @@ where
ser.serialize_str(&format!("{v}"))
}
pub(crate) fn serialize_newtype<N: Display, S>(
v: &N,
ser: S,
) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
ser.serialize_str(&format!("{v}"))
}
pub(crate) fn serialize_u32<S>(v: &u32, ser: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,