From b4dc8018204455541846a21675b6b5ae5d1c4a03 Mon Sep 17 00:00:00 2001 From: eraden Date: Wed, 4 May 2022 07:26:29 +0200 Subject: [PATCH] Payment --- api/src/actors/database.rs | 35 ++++ api/src/actors/database/account_orders.rs | 8 +- api/src/actors/database/order_items.rs | 30 ++- api/src/actors/database/products.rs | 41 ++++ api/src/actors/payment_manager.rs | 202 ++++++++++++++----- api/src/config.rs | 11 +- api/src/main.rs | 26 +-- api/src/model.rs | 21 +- api/src/model/api.rs | 23 +++ api/src/routes/admin/api_v1/orders.rs | 2 +- api/src/routes/public/api_v1/restricted.rs | 91 ++++++--- api/src/routes/public/api_v1/unrestricted.rs | 2 +- 12 files changed, 396 insertions(+), 96 deletions(-) diff --git a/api/src/actors/database.rs b/api/src/actors/database.rs index 490c611..d8af411 100644 --- a/api/src/actors/database.rs +++ b/api/src/actors/database.rs @@ -35,6 +35,41 @@ macro_rules! db_async_handler { }; } +#[macro_export] +macro_rules! query_db { + ($db: expr, $msg: expr, default $fail: expr) => { + match $db.send($msg).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("{e}"); + $fail + } + Err(e) => { + log::error!("{e:?}"); + $fail + } + } + }; + + ($db: expr, $msg: expr, $fail: expr) => { + $crate::query_db!($db, $msg, $fail, $fail) + }; + + ($db: expr, $msg: expr, $db_fail: expr, $act_fail: expr) => { + match $db.send($msg).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("{e}"); + return Err($db_fail); + } + Err(e) => { + log::error!("{e:?}"); + return Err($act_fail); + } + } + }; +} + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("Failed to connect to database. {0:?}")] diff --git a/api/src/actors/database/account_orders.rs b/api/src/actors/database/account_orders.rs index e2c6adf..d7415c4 100644 --- a/api/src/actors/database/account_orders.rs +++ b/api/src/actors/database/account_orders.rs @@ -31,7 +31,7 @@ pub(crate) async fn all_account_orders( ) -> Result> { sqlx::query_as( r#" -SELECT id, buyer_id, status, order_id, order_ext_id +SELECT id, buyer_id, status, order_ext_id FROM account_orders ORDER BY id DESC "#, @@ -148,7 +148,7 @@ pub(crate) async fn update_account_order( UPDATE account_orders SET buyer_id = $2 AND status = $3 AND order_id = $4 WHERE id = $1 -RETURNING id, buyer_id, status, order_id, order_ext_id +RETURNING id, buyer_id, status, order_ext_id "#, ) .bind(msg.id) @@ -185,7 +185,7 @@ pub(crate) async fn update_account_order_by_ext( UPDATE account_orders SET status = $2 WHERE order_ext_id = $1 -RETURNING id, buyer_id, status, order_id, order_ext_id +RETURNING id, buyer_id, status, order_ext_id "#, ) .bind(msg.order_ext_id) @@ -209,7 +209,7 @@ db_async_handler!(FindAccountOrder, find_account_order, AccountOrder); pub(crate) async fn find_account_order(msg: FindAccountOrder, db: PgPool) -> Result { sqlx::query_as( r#" -SELECT id, buyer_id, status, order_id, order_ext_id +SELECT id, buyer_id, status, order_ext_id FROM account_orders WHERE id = $1 "#, diff --git a/api/src/actors/database/order_items.rs b/api/src/actors/database/order_items.rs index f767884..bdfe4c9 100644 --- a/api/src/actors/database/order_items.rs +++ b/api/src/actors/database/order_items.rs @@ -2,8 +2,8 @@ use sqlx::PgPool; use super::Result; use crate::database::Database; -use crate::db_async_handler; use crate::model::*; +use crate::{db_async_handler, model}; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -15,6 +15,8 @@ pub enum Error { NotExists, #[error("Failed to load all order items")] All, + #[error("Failed to load order items")] + OrderItems, } #[derive(actix::Message)] @@ -101,3 +103,29 @@ WHERE id = $1 super::Error::OrderItem(Error::NotExists) }) } + +#[derive(actix::Message)] +#[rtype(result = "Result>")] +pub struct OrderItems { + pub order_id: model::AccountOrderId, +} + +db_async_handler!(OrderItems, order_items, Vec); + +pub(crate) async fn order_items(msg: OrderItems, pool: PgPool) -> Result> { + sqlx::query_as( + r#" +SELECT id, product_id, order_id, quantity, quantity_unit +FROM order_items +WHERE order_id = $1 +ORDER BY id DESC + "#, + ) + .bind(msg.order_id) + .fetch_all(&pool) + .await + .map_err(|e| { + log::error!("{e:?}"); + super::Error::OrderItem(Error::OrderItems) + }) +} diff --git a/api/src/actors/database/products.rs b/api/src/actors/database/products.rs index 1636f53..f40e400 100644 --- a/api/src/actors/database/products.rs +++ b/api/src/actors/database/products.rs @@ -19,6 +19,8 @@ pub enum Error { Update, #[error("Unable to delete product")] Delete, + #[error("Unable to find products for shopping cart")] + ShoppingCartProducts, } #[derive(Message)] @@ -168,3 +170,42 @@ RETURNING id, database::Error::Product(Error::Delete) }) } + +#[derive(Message)] +#[rtype(result = "Result>")] +pub struct ShoppingCartProducts { + pub shopping_cart_id: model::ShoppingCartId, +} + +crate::db_async_handler!( + ShoppingCartProducts, + shopping_cart_products, + Vec +); + +pub(crate) async fn shopping_cart_products( + msg: ShoppingCartProducts, + pool: PgPool, +) -> Result> { + sqlx::query_as( + r#" +SELECT products.id, + products.name, + products.short_description, + products.long_description, + products.category, + products.price, + products.deliver_days_flag +FROM products +INNER JOIN shopping_cart_items ON shopping_cart_items.product_id = products.id +WHERE shopping_cart_id = $1 + "#, + ) + .bind(msg.shopping_cart_id) + .fetch_all(&pool) + .await + .map_err(|e| { + log::error!("{e:?}"); + database::Error::Product(Error::ShoppingCartProducts) + }) +} diff --git a/api/src/actors/payment_manager.rs b/api/src/actors/payment_manager.rs index 5db43fe..67d9366 100644 --- a/api/src/actors/payment_manager.rs +++ b/api/src/actors/payment_manager.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use actix::Addr; @@ -5,10 +6,8 @@ use parking_lot::Mutex; use crate::config::SharedAppConfig; use crate::database::Database; -use crate::model::{ - AccountId, OrderStatus, Price, ProductId, Quantity, QuantityUnit, ShoppingCartId, -}; -use crate::{database, model}; +use crate::model::{AccountId, OrderStatus, Price, ProductId, Quantity, QuantityUnit}; +use crate::{database, model, query_db}; #[macro_export] macro_rules! pay_async_handler { @@ -20,7 +19,43 @@ macro_rules! pay_async_handler { use actix::WrapFuture; let client = self.client.clone(); let db = self.db.clone(); - Box::pin(async { $async(msg, client, db).await }.into_actor(self)) + let config = self.config.clone(); + Box::pin(async { $async(msg, client, db, config).await }.into_actor(self)) + } + } + }; +} + +#[macro_export] +macro_rules! query_pay { + ($manager: expr, $msg: expr, default $fail: expr) => { + match $manager.send($msg).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("Payment {e}"); + $fail + } + Err(e) => { + log::error!("Payment {e:?}"); + $fail + } + } + }; + + ($manager: expr, $msg: expr, $fail: expr) => { + $crate::query_pay!($manager, $msg, $fail, $fail) + }; + + ($manager: expr, $msg: expr, $db_fail: expr, $act_fail: expr) => { + match $manager.send($msg).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("Payment {e}"); + return Err($db_fail); + } + Err(e) => { + log::error!("Payment {e:?}"); + return Err($act_fail); } } }; @@ -34,6 +69,8 @@ pub enum Error { PayU(#[from] pay_u::Error), #[error("Failed to create order")] CreateOrder, + #[error("Failed to create order. Shopping cart is not available")] + UnavailableShoppingCart, } pub type Result = std::result::Result; @@ -42,19 +79,25 @@ pub type Result = std::result::Result; pub struct PaymentManager { client: PayUClient, db: Addr, + config: SharedAppConfig, } impl PaymentManager { pub async fn build(config: SharedAppConfig, db: Addr) -> Result { - let mut client = pay_u::Client::new( - config.lock().payment().payu_client_id(), - config.lock().payment().payu_client_secret(), - config.lock().payment().payu_client_merchant_id(), - ); + let mut client = { + let l = config.lock(); + let p = l.payment(); + pay_u::Client::new( + p.payu_client_id(), + p.payu_client_secret(), + p.payu_client_merchant_id(), + ) + }; client.authorize().await?; Ok(Self { client: Arc::new(Mutex::new(client)), db, + config, }) } } @@ -103,55 +146,99 @@ impl From for pay_u::Product { } } +pub struct CreatePaymentResult { + pub order: model::AccountOrder, + pub items: Vec, + pub redirect_uri: String, +} + #[derive(Debug, actix::Message)] -#[rtype(result = "Result")] +#[rtype(result = "Result")] pub struct RequestPayment { - pub products: Vec, pub currency: String, - pub description: String, pub buyer: Buyer, pub customer_ip: String, pub buyer_id: AccountId, - pub shopping_cart_id: ShoppingCartId, - pub redirect_uri: String, - pub continue_uri: String, } -pay_async_handler!(RequestPayment, request_payment, pay_u::OrderId); +pay_async_handler!(RequestPayment, request_payment, CreatePaymentResult); pub(crate) async fn request_payment( msg: RequestPayment, client: PayUClient, db: Addr, -) -> Result { - let db_order: model::AccountOrder = match db - .send(database::CreateAccountOrder { + config: SharedAppConfig, +) -> Result { + let (notify_uri, continue_uri) = { + let l = config.lock(); + let w = l.web(); + ( + format!("{}/api/v1/payment/notify", w.host()), + format!("{}/payment/success", w.host()), + ) + }; + let cart: model::ShoppingCart = query_db!( + db, + database::EnsureActiveShoppingCart { + buyer_id: msg.buyer_id + }, + Error::UnavailableShoppingCart + ); + let cart_items: Vec = query_db!( + db, + database::CartItems { + shopping_cart_id: cart.id, + }, + Error::UnavailableShoppingCart + ); + let mut items = + cart_items + .iter() + .fold(HashMap::with_capacity(cart_items.len()), |mut agg, item| { + agg.insert(item.product_id, (item.quantity, item.quantity_unit)); + agg + }); + + let cart_products: Vec = query_db!( + db, + database::ShoppingCartProducts { + shopping_cart_id: cart.id, + }, + Error::UnavailableShoppingCart + ); + + let db_order: model::AccountOrder = query_db!( + db, + database::CreateAccountOrder { buyer_id: msg.buyer_id, - items: msg - .products + items: cart_products .iter() - .map(|product| database::create_order::OrderItem { - product_id: product.id, - quantity: product.quantity, - quantity_unit: product.quantity_unit, + .map(|product| { + let (quantity, quantity_unit) = + items.get(&product.id).cloned().unwrap_or_else(|| { + ( + model::Quantity::try_from(0).unwrap(), + model::QuantityUnit::Gram, + ) + }); + database::create_order::OrderItem { + product_id: product.id, + quantity, + quantity_unit, + } }) .collect(), - shopping_cart_id: msg.shopping_cart_id, - }) - .await - { - Ok(Ok(order)) => order, - Ok(Err(e)) => { - log::error!("{e}"); - return Err(Error::CreateOrder); - } - Err(e) => { - log::error!("{e:?}"); - return Err(Error::CreateOrder); - } - }; + shopping_cart_id: cart.id, + }, + Error::CreateOrder + ); - let order = { + let pay_u::res::CreateOrder { + status: _, + redirect_uri, + order_id: _, + ext_order_id: _, + } = { client .lock() .create_order( @@ -159,16 +246,38 @@ pub(crate) async fn request_payment( msg.buyer.into(), msg.customer_ip, msg.currency, - msg.description, + format!("Order #{}", db_order.id), )? - .with_products(msg.products.into_iter().map(Into::into)) + .with_products(cart_products.into_iter().map(|p| { + pay_u::Product::new( + p.name.to_string(), + **p.price, + items + .remove(&p.id) + .map(|(quantity, _)| **quantity as u32) + .unwrap_or_default(), + ) + })) .with_ext_order_id(db_order.order_ext_id.to_string()) - .with_notify_url(msg.redirect_uri) - .with_continue_url(msg.continue_uri), + .with_notify_url(notify_uri) + .with_continue_url(continue_uri), ) .await? }; - Ok(order.order_id) + + let order_items = query_db!( + db, + database::OrderItems { + order_id: db_order.id + }, + Error::CreateOrder + ); + + Ok(CreatePaymentResult { + order: db_order, + items: order_items, + redirect_uri, + }) } #[derive(Debug, serde::Deserialize)] @@ -187,6 +296,7 @@ pub(crate) async fn update_payment( msg: UpdatePayment, _client: PayUClient, db: Addr, + _config: SharedAppConfig, ) -> Result<()> { let status = msg.notification.0.status(); let order_ext_id = match msg.notification.0.order.ext_order_id { diff --git a/api/src/config.rs b/api/src/config.rs index dab03e5..64e3b04 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -139,11 +139,16 @@ impl WebConfig { .expect("Invalid password hash") } - pub fn session_secret(&self) -> String { + pub fn session_secret(&self) -> actix_web::cookie::Key { + use actix_web::cookie::Key; self.session_secret .as_ref() - .cloned() - .or_else(|| std::env::var("SESSION_SECRET").ok()) + .map(|s| Key::from(s.as_bytes())) + .or_else(|| { + std::env::var("SESSION_SECRET") + .ok() + .map(|s| Key::from(s.as_bytes())) + }) .expect("Web config session_secret nor SESSION_SECRET env was given") } diff --git a/api/src/main.rs b/api/src/main.rs index 7c0eb10..f7b1249 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -5,7 +5,6 @@ use std::io::Write; use actix::Actor; use actix_session::storage::RedisActorSessionStore; use actix_session::SessionMiddleware; -use actix_web::cookie::Key; use actix_web::middleware::Logger; use actix_web::web::Data; use actix_web::{App, HttpServer}; @@ -48,11 +47,6 @@ pub enum Error { pub type Result = std::result::Result; async fn server(opts: ServerOpts) -> Result<()> { - let secret_key = { - let key_secret = std::env::var("SESSION_SECRET") - .expect("session requires secret key with 64 or more characters"); - Key::from(key_secret.as_bytes()) - }; let redis_connection_string = "127.0.0.1:6379"; let app_config = config::default_load(&opts); @@ -64,21 +58,27 @@ async fn server(opts: ServerOpts) -> Result<()> { .await .expect("Failed to start payment manager") .start(); - let addr = ( - app_config.lock().web().bind().unwrap_or(opts.bind), - app_config.lock().web().port().unwrap_or(opts.port), - ); + let addr = { + let l = app_config.lock(); + let w = l.web(); + (w.bind().unwrap_or(opts.bind), w.port().unwrap_or(opts.port)) + }; + + println!("Listen at {:?}", addr); HttpServer::new(move || { let config = app_config.clone(); App::new() .wrap(Logger::default()) - .wrap(actix_web::middleware::Compress::default()) - .wrap(actix_web::middleware::NormalizePath::trim()) .wrap(SessionMiddleware::new( RedisActorSessionStore::new(redis_connection_string), - secret_key.clone(), + { + let l = config.lock(); + l.web().session_secret() + }, )) + .wrap(actix_web::middleware::Compress::default()) + .wrap(actix_web::middleware::NormalizePath::trim()) .app_data(Data::new(config)) .app_data(Data::new(db.clone())) .app_data(Data::new(token_manager.clone())) diff --git a/api/src/model.rs b/api/src/model.rs index f3acd6c..5cd9c5c 100644 --- a/api/src/model.rs +++ b/api/src/model.rs @@ -456,7 +456,20 @@ impl From for Account { } } -#[derive(sqlx::Type, Serialize, Deserialize, Debug, Copy, Clone, Deref, Display, From)] +#[derive( + sqlx::Type, + Serialize, + Deserialize, + Debug, + Copy, + Clone, + PartialEq, + Eq, + Hash, + Deref, + Display, + From, +)] #[sqlx(transparent)] #[serde(transparent)] pub struct ProductId(RecordId); @@ -592,6 +605,12 @@ pub struct ShoppingCartItem { pub quantity_unit: QuantityUnit, } +impl ShoppingCartItem { + pub fn quantity(&self) -> Quantity { + self.quantity + } +} + #[derive(sqlx::Type, Serialize, Deserialize, Copy, Clone, Deref, Display, Debug)] #[sqlx(transparent)] #[serde(transparent)] diff --git a/api/src/model/api.rs b/api/src/model/api.rs index 6ff669f..a421c61 100644 --- a/api/src/model/api.rs +++ b/api/src/model/api.rs @@ -34,6 +34,29 @@ impl From<(Vec, Vec)> for AccountOrders { } } +impl From<(model::AccountOrder, Vec)> for AccountOrder { + fn from( + ( + model::AccountOrder { + id, + buyer_id, + status, + order_id, + order_ext_id: _, + }, + mut items, + ): (model::AccountOrder, Vec), + ) -> Self { + AccountOrder { + id, + buyer_id, + status, + order_id, + items: items.drain_filter(|item| item.order_id == id).collect(), + } + } +} + #[derive(Serialize, Debug)] pub struct AccountOrder { pub id: AccountOrderId, diff --git a/api/src/routes/admin/api_v1/orders.rs b/api/src/routes/admin/api_v1/orders.rs index 847c851..a7299f0 100644 --- a/api/src/routes/admin/api_v1/orders.rs +++ b/api/src/routes/admin/api_v1/orders.rs @@ -1,7 +1,7 @@ use actix::Addr; use actix_session::Session; +use actix_web::get; use actix_web::web::{Data, Json, ServiceConfig}; -use actix_web::{delete, get, patch, post}; use crate::database::Database; use crate::model::api::AccountOrders; diff --git a/api/src/routes/public/api_v1/restricted.rs b/api/src/routes/public/api_v1/restricted.rs index 177162c..0909d5f 100644 --- a/api/src/routes/public/api_v1/restricted.rs +++ b/api/src/routes/public/api_v1/restricted.rs @@ -1,21 +1,21 @@ use actix::Addr; use actix_web::web::{scope, Data, Json, ServiceConfig}; -use actix_web::{delete, get, post, HttpResponse}; +use actix_web::{delete, get, post, HttpRequest, HttpResponse}; use actix_web_httpauth::extractors::bearer::BearerAuth; use crate::actors::cart_manager; use crate::actors::cart_manager::CartManager; use crate::database::Database; use crate::model::{ - AccountId, ProductId, Quantity, QuantityUnit, ShoppingCart, ShoppingCartId, ShoppingCartItem, + AccountId, ProductId, Quantity, QuantityUnit, ShoppingCart, ShoppingCartItem, ShoppingCartItemId, }; -use crate::order_manager::OrderManager; +use crate::payment_manager::PaymentManager; use crate::routes::public::api_v1::ShoppingCartError; use crate::routes::public::Error as PublicError; use crate::routes::{RequireUser, Result}; use crate::token_manager::TokenManager; -use crate::{database, order_manager, routes}; +use crate::{database, model, payment_manager, query_pay, routes}; #[get("/shopping-cart")] async fn shopping_cart( @@ -190,38 +190,77 @@ async fn delete_cart_item( #[derive(serde::Deserialize)] pub struct CreateOrderInput { - pub shopping_cart_id: ShoppingCartId, + /// Required customer e-mail + pub email: String, + /// Required customer phone number + pub phone: String, + /// Required customer first name + pub first_name: String, + /// Required customer last name + pub last_name: String, + /// Required customer language + pub language: String, } #[post("/order")] pub(crate) async fn create_order( + req: HttpRequest, Json(payload): Json, tm: Data>, credentials: BearerAuth, - om: Data>, + payment: Data>, ) -> routes::Result { - let (token, _) = credentials.require_user(tm.into_inner()).await?; - let order = match om - .send(order_manager::CreateOrder { - account_id: AccountId::from(token.subject), - shopping_cart_id: payload.shopping_cart_id, - }) - .await - { - Ok(Ok(order)) => order, - Ok(Err(e)) => { - log::error!("{e}"); - return Err(routes::Error::Public(PublicError::ApiV1( - super::Error::AddOrder, - ))); - } - Err(e) => { - log::error!("{e}"); - return Err(routes::Error::Public(PublicError::DatabaseConnection)); - } + let ( + model::Token { + id: _, + customer_id: _, + role: _, + issuer: _, + subject, + audience: _, + expiration_time: _, + not_before_time: _, + issued_at_time: _, + jwt_id: _, + }, + _, + ) = credentials.require_user(tm.into_inner()).await?; + + let buyer_id = model::AccountId::from(subject); + let CreateOrderInput { + email, + phone, + first_name, + last_name, + language, + } = payload; + let ip = match req.peer_addr() { + Some(ip) => ip, + _ => return Ok(HttpResponse::BadRequest().body("No IP")), }; - Ok(HttpResponse::Created().json(order)) + let payment_manager::CreatePaymentResult { redirect_uri, .. } = query_pay!( + payment, + payment_manager::RequestPayment { + currency: "PLN".to_string(), + buyer: payment_manager::Buyer { + email, + phone, + first_name, + last_name, + language, + }, + customer_ip: ip.to_string(), + buyer_id, + }, + routes::Error::Public(PublicError::DatabaseConnection) + ); + + Ok(HttpResponse::SeeOther() + .append_header(("Location", redirect_uri.as_str())) + .body(format!( + "Go to {redirect_uri}" + ))) } pub(crate) fn configure(config: &mut ServiceConfig) { diff --git a/api/src/routes/public/api_v1/unrestricted.rs b/api/src/routes/public/api_v1/unrestricted.rs index 881b961..74f7f1c 100644 --- a/api/src/routes/public/api_v1/unrestricted.rs +++ b/api/src/routes/public/api_v1/unrestricted.rs @@ -85,7 +85,7 @@ async fn sign_in( Ok(HttpResponse::Created().json(SignInOutput { token: string })) } -#[post("/pay_u/notify")] +#[post("/payment/notify")] async fn handle_notification( Json(notify): Json, payment: Data>,