diff --git a/.env b/.env index 34ba4c4..61af1b4 100644 --- a/.env +++ b/.env @@ -4,8 +4,9 @@ RUST_LOG=debug KEY_SECRET="NEPJs#8jjn8SK8GC7QEC^*P844UgsyEbQB8mRWXkT%3mPrwewZoc25MMby9H#R*w2KzaQgMkk#Pif$kxrLy*N5L!Ch%jxbWoa%gb" JWT_SECRET="42^iFq&ZnQbUf!hwGWXd&CpyY6QQyJmkPU%esFCvne5&Ejcb3nJ4&GyHZp!MArZLf^9*5c6!!VgM$iZ8T%d#&bWTi&xbZk2S@4RN" PGDATESTYLE= -SENDMAIL_SECRET=1a9dd7ba41edc5fd030e2281c52b7b49-02fa25a3-02e6d387 -SENDMAIL_KEY_ID=02fa25a3-02e6d387 -SMTP_HOST=smtp.mailgun.org -SMTP_USER=bazzar@sandbox69e5633f64264d8c973b3c82408dfb17.mailgun.org -SMTP_PASSWORD=49940d86d0742959f677c74cc542abb0-02fa25a3-f65fc7b4 + +SENDGRID_SECRET=SG.CUWRM-eoQfGJNqSU2bbwkg.NW5aBy5vZueCSOwIIyWUBqq5USChGiwAFrWzreBKvOU +SENDGRID_API_KEY=CUWRM-eoQfGJNqSU2bbwkg +SMTP_FROM=adrian.wozniak@ita-prog.pl + +PAY_U_BEARER=d9a4536e-62ba-4f60-8017-6053211d3f47 diff --git a/Cargo.lock b/Cargo.lock index ff5e836..1298121 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -609,13 +609,13 @@ dependencies = [ "hmac", "jemallocator", "jwt", - "lettre", "log", "oauth2", "parking_lot 0.12.0", "password-hash", "pretty_env_logger", "rand_core", + "sendgrid", "serde", "serde_json", "sha2 0.10.2", @@ -815,6 +815,12 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "chunked_transfer" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fff857943da45f546682664a79488be82e69e43c1a7a2307679ab9afb3a66d2e" + [[package]] name = "cipher" version = "0.3.0" @@ -854,6 +860,22 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" + [[package]] name = "cpufeatures" version = "0.2.2" @@ -961,6 +983,12 @@ dependencies = [ "cipher", ] +[[package]] +name = "data-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee2393c4a91429dffb4bedf19f4d6abf27d8a732c8ce4980305d782e5426d57" + [[package]] name = "derive_more" version = "0.99.17" @@ -1047,15 +1075,6 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" -[[package]] -name = "email-encoding" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6690291166824e467790ac08ba42f241791567e8337bbf00c5a6e87889629f98" -dependencies = [ - "base64", -] - [[package]] name = "encoding_rs" version = "0.8.31" @@ -1123,6 +1142,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.0.1" @@ -1417,17 +1451,6 @@ dependencies = [ "digest 0.10.3", ] -[[package]] -name = "hostname" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" -dependencies = [ - "libc", - "match_cfg", - "winapi", -] - [[package]] name = "http" version = "0.2.6" @@ -1520,6 +1543,19 @@ dependencies = [ "tokio-rustls 0.23.3", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "idna" version = "0.2.3" @@ -1661,34 +1697,6 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" -[[package]] -name = "lettre" -version = "0.10.0-rc.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5144148f337be14dabfc0f0d85b691a68ac6c77ef22a5c47c5504b70a7c9fcf3" -dependencies = [ - "async-trait", - "base64", - "email-encoding", - "fastrand", - "futures-io", - "futures-util", - "hostname", - "httpdate", - "idna", - "mime", - "nom", - "once_cell", - "quoted_printable", - "regex", - "rustls 0.20.4", - "rustls-pemfile", - "tokio", - "tokio-rustls 0.23.3", - "tracing", - "webpki-roots 0.22.3", -] - [[package]] name = "libc" version = "0.2.123" @@ -1772,12 +1780,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" -[[package]] -name = "match_cfg" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" - [[package]] name = "matches" version = "0.1.9" @@ -1862,6 +1864,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "native-tls" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd7e2f3618557f980e0b17e8856252eee3c97fa12c54dff0ca290fb6266ca4a9" +dependencies = [ + "lazy_static", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nix" version = "0.23.1" @@ -1981,6 +2001,39 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +[[package]] +name = "openssl" +version = "0.10.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7ae222234c30df141154f159066c5093ff73b63204dcda7121eb082fc56a95" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-sys", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e46109c383602735fa0a2e48dd2b7c892b048e1bf69e5c3b1d804b7d9c203cb" +dependencies = [ + "autocfg", + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "opentelemetry" version = "0.17.0" @@ -2085,6 +2138,18 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c520e05135d6e763148b6426a837e239041653ba7becd2e538c076c738025fc" +[[package]] +name = "pay_u" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "thiserror", + "tokio", + "ureq", +] + [[package]] name = "percent-encoding" version = "2.1.0" @@ -2269,12 +2334,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "quoted_printable" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fee2dce59f7a43418e3382c766554c614e06a552d53a8f07ef499ea4b332c0f" - [[package]] name = "rand" version = "0.8.5" @@ -2381,6 +2440,15 @@ version = "0.6.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + [[package]] name = "reqwest" version = "0.11.10" @@ -2397,11 +2465,13 @@ dependencies = [ "http-body", "hyper", "hyper-rustls", + "hyper-tls", "ipnet", "js-sys", "lazy_static", "log", "mime", + "native-tls", "percent-encoding", "pin-project-lite", "rustls 0.20.4", @@ -2410,6 +2480,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "tokio", + "tokio-native-tls", "tokio-rustls 0.23.3", "url", "wasm-bindgen", @@ -2512,6 +2583,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f05ba609c234e60bee0d547fe94a4c7e9da733d1c962cf6e59efa4cd9c8bc75" +dependencies = [ + "lazy_static", + "winapi", +] + [[package]] name = "scopeguard" version = "1.1.0" @@ -2538,6 +2619,29 @@ dependencies = [ "untrusted", ] +[[package]] +name = "security-framework" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dc14f172faf8a0194a3aded622712b0de276821addc574fa54fc0a1167e10dc" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0160a13a177a45bfb43ce71c01580998474f556ad854dcbca936dd2841a5c556" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "0.9.0" @@ -2559,6 +2663,20 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" +[[package]] +name = "sendgrid" +version = "0.17.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88afc7511b68247bf469e0dff55eb7ef305aab293211242695fd07cc17e36a78" +dependencies = [ + "data-encoding", + "reqwest", + "serde", + "serde_json", + "thiserror", + "url", +] + [[package]] name = "serde" version = "1.0.136" @@ -2926,6 +3044,20 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "tempfile" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +dependencies = [ + "cfg-if", + "fastrand", + "libc", + "redox_syscall", + "remove_dir_all", + "winapi", +] + [[package]] name = "tera" version = "1.15.0" @@ -3099,6 +3231,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.22.0" @@ -3359,6 +3501,25 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" +[[package]] +name = "ureq" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9399fa2f927a3d327187cbd201480cee55bee6ac5d3c77dd27f0c6814cff16d5" +dependencies = [ + "base64", + "chunked_transfer", + "flate2", + "log", + "once_cell", + "rustls 0.20.4", + "serde", + "serde_json", + "url", + "webpki 0.22.0", + "webpki-roots 0.22.3", +] + [[package]] name = "url" version = "2.2.2" diff --git a/Cargo.toml b/Cargo.toml index 5045187..139290e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,2 +1,2 @@ [workspace] -members = ["api"] +members = ["api","pay_u"] diff --git a/api/Cargo.toml b/api/Cargo.toml index 16d386b..f08ddca 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -63,4 +63,4 @@ async-trait = { version = "0.1.53", features = [] } jemallocator = { version = "0.3.2", features = [] } -lettre = { version = "0.10.0-rc.5", default-features = false, features = ["tracing", "smtp-transport", "hostname", "builder", "tokio1-rustls-tls"] } +sendgrid = { version = "0.17.4", features = ["async"] } diff --git a/api/src/actors/email_manager.rs b/api/src/actors/email_manager.rs index 7b800bf..d447942 100644 --- a/api/src/actors/email_manager.rs +++ b/api/src/actors/email_manager.rs @@ -1,13 +1,36 @@ -use lettre::transport::smtp::authentication::Credentials; -use lettre::{AsyncSmtpTransport, Tokio1Executor}; +use std::sync::Arc; + +use crate::Email; + +#[macro_export] +macro_rules! mail_async_handler { + ($msg: ty, $async: ident, $res: ty) => { + impl actix::Handler<$msg> for EmailManager { + type Result = actix::ResponseActFuture>; + + fn handle(&mut self, msg: $msg, _ctx: &mut Self::Context) -> Self::Result { + use actix::WrapFuture; + let inner = self.0.clone(); + Box::pin(async { $async(msg, inner).await }.into_actor(self)) + } + } + }; +} #[derive(Debug, thiserror::Error)] pub enum Error {} pub type Result = std::result::Result; -pub struct EmailManager { - mailer: AsyncSmtpTransport, +pub struct SendState { + pub success: bool, +} + +pub struct EmailManager(Arc); + +pub(crate) struct Inner { + from: Email, + send_grid: sendgrid::SGClient, } impl actix::Actor for EmailManager { @@ -16,17 +39,41 @@ impl actix::Actor for EmailManager { impl EmailManager { pub fn build() -> Result { - let smtp_credentials = Credentials::new( - std::env::var("SMTP_USER").expect("Missing SMTP_USER variable"), - std::env::var("SMTP_PASSWORD").expect("Missing SMTP_PASSWORD variable"), - ); + let from = std::env::var("SMTP_FROM").expect("Missing SMTP_FROM variable"); - let mailer = AsyncSmtpTransport::::relay( - &std::env::var("SMTP_USER").expect("Missing SMTP_USER env variable"), - ) - .expect("Can't construct async smtp mailer") - .credentials(smtp_credentials) - .build(); - Ok(Self { mailer }) + Ok(Self(Arc::new(Inner { + from: Email::from(from), + send_grid: sendgrid::SGClient::new( + std::env::var("SENDGRID_SECRET").expect("Missing SENDGRID_SECRET variable"), + ), + }))) } } + +#[derive(actix::Message)] +#[rtype(result = "Result")] +pub struct TestMail { + pub receiver: Email, +} + +mail_async_handler!(TestMail, test_mail, SendState); + +pub(crate) async fn test_mail(msg: TestMail, inner: Arc) -> Result { + let from: &str = &*inner.from; + let status = inner + .send_grid + .send( + sendgrid::Mail::new() + .add_to((msg.receiver.as_str(), "User").into()) + .add_from(from) + .add_subject("Test e-mail") + .add_html("

Test e-mail

") + .build(), + ) + .await; + + log::debug!("{:?}", status); + Ok(SendState { + success: status.is_ok(), + }) +} diff --git a/api/src/main.rs b/api/src/main.rs index c3b047d..173bf15 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -13,7 +13,8 @@ use jemallocator::Jemalloc; use password_hash::SaltString; use validator::{validate_email, validate_length}; -use crate::actors::{database, order_manager, token_manager}; +use crate::actors::{database, email_manager, order_manager, token_manager}; +use crate::email_manager::TestMail; use crate::logic::encrypt_password; use crate::model::{Email, Login, PassHash, Password, Role}; @@ -66,6 +67,8 @@ enum Command { GenerateHash(GenerateHashOpts), #[options(help = "Create new account")] CreateAccount(CreateAccountOpts), + #[options(help = "Check mailer config")] + TestMailer(TestMailerOpts), } impl Default for Command { @@ -104,6 +107,13 @@ impl ResolveDbUrl for ServerOpts { } } +#[derive(Options, Debug)] +pub struct TestMailerOpts { + help: bool, + #[options(help = "E-mail receiver")] + receiver: Option, +} + #[derive(Options, Debug)] struct MigrateOpts { help: bool, @@ -270,6 +280,27 @@ async fn create_account(opts: CreateAccountOpts) -> Result<()> { Ok(()) } +async fn test_mailer(opts: TestMailerOpts) -> Result<()> { + let manager = email_manager::EmailManager::build() + .expect("Invalid email manager config") + .start(); + if manager + .send(TestMail { + receiver: opts.receiver.expect("e-mail address is required"), + }) + .await + .expect("Failed to execute actor") + .expect("Failed to send email") + .success + { + println!("Success!"); + } else { + eprintln!("Failure!"); + std::process::exit(1); + } + Ok(()) +} + #[actix_web::main] async fn main() -> Result<()> { dotenv::dotenv().ok(); @@ -281,5 +312,6 @@ async fn main() -> Result<()> { Command::Server(opts) => server(opts).await, Command::GenerateHash(opts) => generate_hash(opts).await, Command::CreateAccount(opts) => create_account(opts).await, + Command::TestMailer(opts) => test_mailer(opts).await, } } diff --git a/api/src/model.rs b/api/src/model.rs index 5a959c4..e723444 100644 --- a/api/src/model.rs +++ b/api/src/model.rs @@ -1,4 +1,5 @@ use std::fmt::Formatter; +use std::str::FromStr; use derive_more::{Deref, Display, From}; use serde::de::{Error, Visitor}; @@ -10,6 +11,8 @@ pub enum TransformError { BelowMinimal, #[error("Given value is not valid day flag")] NotDay, + #[error("Given value is not valid email address")] + NotEmail, } pub type RecordId = i32; @@ -156,6 +159,18 @@ pub struct Login(String); #[serde(transparent)] pub struct Email(String); +impl FromStr for Email { + type Err = TransformError; + + fn from_str(s: &str) -> Result { + if validator::validate_email(s) { + Ok(Self(String::from(s))) + } else { + Err(TransformError::NotEmail) + } + } +} + impl<'de> serde::Deserialize<'de> for Email { fn deserialize(deserializer: D) -> Result where diff --git a/pay_u/Cargo.toml b/pay_u/Cargo.toml new file mode 100644 index 0000000..ebc186e --- /dev/null +++ b/pay_u/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "pay_u" +version = "0.1.0" +edition = "2021" + +[dependencies] +ureq = { version = "2.4.0", features = ["json", "serde_json"] } + +serde = { version = "1.0.136", features = ["derive"] } +serde_json = { version = "1.0.79" } + +chrono = { version = "0.4.19", features = ["serde"] } + +thiserror = { version = "1.0.30" } + +[dev-dependencies] +tokio = { version = "1.17.0", features = ["full"] } diff --git a/pay_u/src/lib.rs b/pay_u/src/lib.rs new file mode 100644 index 0000000..c3f2e8b --- /dev/null +++ b/pay_u/src/lib.rs @@ -0,0 +1,324 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Client is not authorized. No bearer token available")] + NoToken, + #[error("{0}")] + UReq(#[from] ureq::Error), + #[error("{0}")] + Io(#[from] std::io::Error), + #[error("Total value is not sum of products price")] + IncorrectTotal, +} + +pub type Result = std::result::Result; + +/// PayU payment status. +/// +/// Each payment is initially Pending and can change according to following +/// graph: +/// +/// +#[derive(Serialize, Deserialize, Copy, Clone, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PaymentStatus { + /// Payment is currently being processed. + 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, + /// Payment has been cancelled and the buyer has not been charged (no money + /// was taken from buyer's account). + Canceled, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Buyer { + /// 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, +} + +impl Buyer { + pub fn new( + email: Email, + phone: Phone, + first_name: FirstName, + last_name: LastName, + lang: Language, + ) -> Self + where + Email: Into, + Phone: Into, + FirstName: Into, + LastName: Into, + Language: Into, + { + Self { + email: email.into(), + phone: phone.into(), + first_name: first_name.into(), + last_name: last_name.into(), + language: lang.into(), + } + } +} + +pub type Price = i32; +pub type Quantity = u32; + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Product { + pub name: String, + #[serde(serialize_with = "Product::serialize_unit_price")] + pub unit_price: Price, + #[serde(serialize_with = "Product::serialize_quantity")] + pub quantity: Quantity, +} + +impl Product { + pub fn new>(name: Name, unit_price: Price, quantity: Quantity) -> Self { + Self { + name: name.into(), + unit_price, + 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 { + /// 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 | + #[serde(skip_serializing_if = "Option::is_none")] + pub notify_url: Option, + /// Customer client IP address + pub customer_ip: String, + /// Secret pos ip. This is connected to PayU account + pub merchant_pos_ip: String, + /// Transaction description + pub description: String, + /// 3 characters currency identifier, ex. PLN + pub 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, + /// @see [Buyer] + pub buyer: Buyer, + /// List of products + pub products: Vec, + #[serde(skip_serializing)] + pub order_create_date: Option, +} + +impl Order { + fn serialize_total_amount(v: &Price, ser: S) -> std::result::Result + where + S: serde::Serializer, + { + ser.serialize_str(&format!("{v}")) + } +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum PaymentType { + Pbl, + CartToken, + Installments, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PayMethod { + #[serde(rename = "type")] + pub payment_type: PaymentType, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Status { + pub status_code: String, +} + +impl Status { + pub fn is_success(&self) -> bool { + self.status_code.as_str() == "SUCCESS" + } +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CreateOrderResult { + pub status: Status, + /// Client should be redirected to this URI + pub redirect_uri: String, + /// This should be connected to your own order + pub order_id: String, + /// This is YOUR_EXT_ORDER_ID + pub ext_order_id: String, +} + +pub mod notify { + use serde::Deserialize; + + #[derive(Deserialize, Debug)] + #[serde(rename_all = "camelCase")] + pub struct StatusUpdate { + pub order: Order, + pub local_receipt_date_time: Option, + pub properties: Option>, + } + + impl StatusUpdate { + pub fn status(&self) -> super::PaymentStatus { + self.order.status + } + } + + #[derive(Deserialize, Debug)] + #[serde(rename_all = "camelCase")] + pub struct Order { + #[serde(flatten)] + pub order: super::Order, + 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 { + pub bearer: Option, + pub sandbox: bool, +} + +impl Client { + pub fn create_order(&self, order: Order) -> Result { + println!("{}", serde_json::to_string_pretty(&order).unwrap()); + if order.total_amount + != order + .products + .iter() + .fold(0, |memo, p| memo + (p.unit_price * p.quantity as i32)) + { + return Err(Error::IncorrectTotal); + } + + let req = ureq::post(&format!("{}/orders", self.base_url())) + .set("Content-Type", "application/json") + .set( + "Authorization", + &self + .bearer + .as_ref() + .map(|s| format!("Bearer {s}")) + .ok_or(Error::NoToken)?, + ); + eprintln!("{:?}", req); + let res = req.send_string(&serde_json::to_string_pretty(&order).unwrap())?; + res.into_json::().map_err(Into::into) + } + + fn base_url(&self) -> &str { + if self.sandbox { + "https://secure.snd.payu.com/api/v2_1" + } else { + "https://secure.payu.com/api/v2_1" + } + } +} + +#[cfg(test)] +mod tests { + use tokio::test; + + use super::*; + + #[test] + async fn create_order() { + 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_ip: "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, + }); + eprintln!("{:#?}", res); + assert!(res.is_ok()); + } +}