diff --git a/.env b/.env index 2c99c0c9..21aa0d99 100644 --- a/.env +++ b/.env @@ -74,6 +74,7 @@ STEAM_API_KEY=none TREMENDOUS_API_URL=https://testflight.tremendous.com/api/v2/ TREMENDOUS_API_KEY=none TREMENDOUS_PRIVATE_KEY=none +TREMENDOUS_CAMPAIGN_ID=none TURNSTILE_SECRET=none diff --git a/.sqlx/query-105c44658c58739b933ae3ef0504c66b7926390c9f30e09c635161544177762a.json b/.sqlx/query-105c44658c58739b933ae3ef0504c66b7926390c9f30e09c635161544177762a.json new file mode 100644 index 00000000..807d8545 --- /dev/null +++ b/.sqlx/query-105c44658c58739b933ae3ef0504c66b7926390c9f30e09c635161544177762a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE payouts\n SET status = $1\n WHERE platform_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "105c44658c58739b933ae3ef0504c66b7926390c9f30e09c635161544177762a" +} diff --git a/.sqlx/query-375abd1749adff1bccb4345a78cdc1ddd340355218f6137ad9ef659a146e90dd.json b/.sqlx/query-375abd1749adff1bccb4345a78cdc1ddd340355218f6137ad9ef659a146e90dd.json new file mode 100644 index 00000000..8010400a --- /dev/null +++ b/.sqlx/query-375abd1749adff1bccb4345a78cdc1ddd340355218f6137ad9ef659a146e90dd.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT user_id, amount, fee FROM payouts WHERE platform_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "amount", + "type_info": "Numeric" + }, + { + "ordinal": 2, + "name": "fee", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "375abd1749adff1bccb4345a78cdc1ddd340355218f6137ad9ef659a146e90dd" +} diff --git a/.sqlx/query-bbc96f470cb7819a5c8aa0725e630311cfa8da5b3b119b9ce791e8e978250c6e.json b/.sqlx/query-bbc96f470cb7819a5c8aa0725e630311cfa8da5b3b119b9ce791e8e978250c6e.json new file mode 100644 index 00000000..72792633 --- /dev/null +++ b/.sqlx/query-bbc96f470cb7819a5c8aa0725e630311cfa8da5b3b119b9ce791e8e978250c6e.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE payouts\n SET status = $1\n WHERE platform_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "bbc96f470cb7819a5c8aa0725e630311cfa8da5b3b119b9ce791e8e978250c6e" +} diff --git a/.sqlx/query-ff474fba4d18f7788b1a1900a6e5549f0899689e857634cfc5d85bd7b8718c46.json b/.sqlx/query-ff474fba4d18f7788b1a1900a6e5549f0899689e857634cfc5d85bd7b8718c46.json new file mode 100644 index 00000000..6b1ae87a --- /dev/null +++ b/.sqlx/query-ff474fba4d18f7788b1a1900a6e5549f0899689e857634cfc5d85bd7b8718c46.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET balance = balance + $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Numeric", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "ff474fba4d18f7788b1a1900a6e5549f0899689e857634cfc5d85bd7b8718c46" +} diff --git a/Cargo.lock b/Cargo.lock index 06b62adc..8a8d55cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1107,6 +1107,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "csv" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" +dependencies = [ + "memchr", +] + [[package]] name = "curl" version = "0.4.44" @@ -1368,6 +1389,16 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + [[package]] name = "dirs-sys" version = "0.3.7" @@ -1379,6 +1410,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "dlv-list" version = "0.3.0" @@ -1416,6 +1458,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2153bd83ebc09db15bcbdc3e2194d901804952e3dc96967e1cd3b0c5c32d112" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.33" @@ -2279,6 +2327,7 @@ dependencies = [ "reqwest", "rust-s3", "rust_decimal", + "rust_iso3166", "sentry", "sentry-actix", "serde", @@ -2945,6 +2994,48 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.39", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + [[package]] name = "phonenumber" version = "0.3.3+8.13.9" @@ -3066,6 +3157,20 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "prettytable-rs" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" +dependencies = [ + "csv", + "encode_unicode", + "is-terminal", + "lazy_static", + "term", + "unicode-width", +] + [[package]] name = "proc-macro-crate" version = "0.1.5" @@ -3552,6 +3657,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "rust_iso3166" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc46f436f726b768364d35d099f43a94f22fd34857ff4f679b1f5cbcb03b9f71" +dependencies = [ + "js-sys", + "phf", + "prettytable-rs", + "wasm-bindgen", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -4012,6 +4129,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "slab" version = "0.4.9" @@ -4444,6 +4567,17 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "termcolor" version = "1.3.0" @@ -4731,6 +4865,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" +[[package]] +name = "unicode-width" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" + [[package]] name = "unicode_categories" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index bb38733c..6df58ff0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,6 +106,7 @@ woothee = "0.13.0" lettre = "0.10.4" derive-new = "0.5.9" +rust_iso3166 = "0.1.11" [dev-dependencies] actix-http = "3.4.0" diff --git a/src/database/models/payout_item.rs b/src/database/models/payout_item.rs index 74389464..c2d03f48 100644 --- a/src/database/models/payout_item.rs +++ b/src/database/models/payout_item.rs @@ -1,4 +1,4 @@ -use crate::models::payouts::{PayoutMethod, PayoutStatus}; +use crate::models::payouts::{PayoutMethodType, PayoutStatus}; use chrono::{DateTime, Utc}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; @@ -14,7 +14,7 @@ pub struct Payout { pub amount: Decimal, pub fee: Option, - pub method: Option, + pub method: Option, pub method_address: Option, pub platform_id: Option, } @@ -82,7 +82,7 @@ impl Payout { created: r.created, status: PayoutStatus::from_string(&r.status), amount: r.amount, - method: r.method.map(|x| PayoutMethod::from_string(&x)), + method: r.method.map(|x| PayoutMethodType::from_string(&x)), method_address: r.method_address, platform_id: r.platform_id, fee: r.fee, diff --git a/src/models/v3/payouts.rs b/src/models/v3/payouts.rs index 493c738e..bb49f293 100644 --- a/src/models/v3/payouts.rs +++ b/src/models/v3/payouts.rs @@ -1,7 +1,7 @@ use crate::models::ids::{Base62Id, UserId}; use chrono::{DateTime, Utc}; use rust_decimal::Decimal; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; #[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] #[serde(from = "Base62Id")] @@ -14,10 +14,12 @@ pub struct Payout { pub user_id: UserId, pub status: PayoutStatus, pub created: DateTime, + #[serde(with = "rust_decimal::serde::float")] pub amount: Decimal, + #[serde(with = "rust_decimal::serde::float_option")] pub fee: Option, - pub method: Option, + pub method: Option, /// the address this payout was sent to: ex: email, paypal email, venmo handle pub method_address: Option, pub platform_id: Option, @@ -41,35 +43,35 @@ impl Payout { #[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)] #[serde(rename_all = "lowercase")] -pub enum PayoutMethod { +pub enum PayoutMethodType { Venmo, PayPal, Tremendous, Unknown, } -impl std::fmt::Display for PayoutMethod { +impl std::fmt::Display for PayoutMethodType { fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { write!(fmt, "{}", self.as_str()) } } -impl PayoutMethod { +impl PayoutMethodType { pub fn as_str(&self) -> &'static str { match self { - PayoutMethod::Venmo => "venmo", - PayoutMethod::PayPal => "paypal", - PayoutMethod::Tremendous => "tremendous", - PayoutMethod::Unknown => "unknown", + PayoutMethodType::Venmo => "venmo", + PayoutMethodType::PayPal => "paypal", + PayoutMethodType::Tremendous => "tremendous", + PayoutMethodType::Unknown => "unknown", } } - pub fn from_string(string: &str) -> PayoutMethod { + pub fn from_string(string: &str) -> PayoutMethodType { match string { - "venmo" => PayoutMethod::Venmo, - "paypal" => PayoutMethod::PayPal, - "tremendous" => PayoutMethod::Tremendous, - _ => PayoutMethod::Unknown, + "venmo" => PayoutMethodType::Venmo, + "paypal" => PayoutMethodType::PayPal, + "tremendous" => PayoutMethodType::Tremendous, + _ => PayoutMethodType::Unknown, } } } @@ -80,6 +82,8 @@ pub enum PayoutStatus { Success, Processing, Cancelled, + Cancelling, + Failed, Unknown, } @@ -95,6 +99,8 @@ impl PayoutStatus { PayoutStatus::Success => "success", PayoutStatus::Processing => "processing", PayoutStatus::Cancelled => "cancelled", + PayoutStatus::Cancelling => "cancelling", + PayoutStatus::Failed => "failed", PayoutStatus::Unknown => "unknown", } } @@ -104,7 +110,67 @@ impl PayoutStatus { "success" => PayoutStatus::Success, "processing" => PayoutStatus::Processing, "cancelled" => PayoutStatus::Cancelled, + "cancelling" => PayoutStatus::Cancelling, + "failed" => PayoutStatus::Failed, _ => PayoutStatus::Unknown, } } } + +#[derive(Serialize, Deserialize, Clone)] +pub struct PayoutMethod { + pub id: String, + #[serde(rename = "type")] + pub type_: PayoutMethodType, + pub name: String, + pub supported_countries: Vec, + pub image_url: Option, + pub interval: PayoutInterval, + pub fee: PayoutMethodFee, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct PayoutMethodFee { + #[serde(with = "rust_decimal::serde::float")] + pub percentage: Decimal, + #[serde(with = "rust_decimal::serde::float")] + pub min: Decimal, + #[serde(with = "rust_decimal::serde::float_option")] + pub max: Option, +} + +#[derive(Clone)] +pub struct PayoutDecimal(pub Decimal); + +impl Serialize for PayoutDecimal { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + rust_decimal::serde::float::serialize(&self.0, serializer) + } +} + +impl<'de> Deserialize<'de> for PayoutDecimal { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let decimal = rust_decimal::serde::float::deserialize(deserializer)?; + Ok(PayoutDecimal(decimal)) + } +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum PayoutInterval { + Standard { + #[serde(with = "rust_decimal::serde::float")] + min: Decimal, + #[serde(with = "rust_decimal::serde::float")] + max: Decimal, + }, + Fixed { + values: Vec, + }, +} diff --git a/src/models/v3/users.rs b/src/models/v3/users.rs index ab68ef4b..56be0752 100644 --- a/src/models/v3/users.rs +++ b/src/models/v3/users.rs @@ -5,7 +5,7 @@ use chrono::{DateTime, Utc}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; -#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug, Hash)] #[serde(from = "Base62Id")] #[serde(into = "Base62Id")] pub struct UserId(pub u64); @@ -65,6 +65,7 @@ pub struct UserPayoutData { pub paypal_address: Option, pub paypal_country: Option, pub venmo_handle: Option, + #[serde(with = "rust_decimal::serde::float")] pub balance: Decimal, } diff --git a/src/queue/payouts.rs b/src/queue/payouts.rs index 2d21e6d3..a6d99a6e 100644 --- a/src/queue/payouts.rs +++ b/src/queue/payouts.rs @@ -1,8 +1,13 @@ +use crate::models::ids::UserId; +use crate::models::payouts::{ + PayoutDecimal, PayoutInterval, PayoutMethod, PayoutMethodFee, PayoutMethodType, +}; use crate::routes::ApiError; use crate::util::env::parse_var; use crate::{database::redis::RedisPool, models::projects::MonetizationStatus}; use base64::Engine; use chrono::{DateTime, Datelike, Duration, Utc, Weekday}; +use dashmap::DashMap; use reqwest::Method; use rust_decimal::Decimal; use serde::de::DeserializeOwned; @@ -10,24 +15,40 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::PgPool; use std::collections::HashMap; -use tokio::sync::RwLock; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; pub struct PayoutsQueue { credential: RwLock>, + payout_options: RwLock>, + payouts_locks: DashMap>>, } -#[derive(Deserialize, Clone)] +#[derive(Clone)] struct PayPalCredentials { access_token: String, token_type: String, expires: DateTime, } +#[derive(Clone)] +struct PayoutMethods { + options: Vec, + expires: DateTime, +} + +impl Default for PayoutsQueue { + fn default() -> Self { + Self::new() + } +} // Batches payouts and handles token refresh impl PayoutsQueue { pub fn new() -> Self { PayoutsQueue { credential: RwLock::new(None), + payout_options: RwLock::new(None), + payouts_locks: DashMap::new(), } } @@ -95,7 +116,7 @@ impl PayoutsQueue { if credentials.expires < Utc::now() { drop(read); self.refresh_token().await.map_err(|_| { - ApiError::Payments("Error while authenticating with PayPal".to_string()) + ApiError::Payments("Error while authenticating with PayPal".to_string()) })? } else { credentials.clone() @@ -103,7 +124,7 @@ impl PayoutsQueue { } else { drop(read); self.refresh_token().await.map_err(|_| { - ApiError::Payments("Error while authenticating with PayPal".to_string()) + ApiError::Payments("Error while authenticating with PayPal".to_string()) })? }; @@ -111,7 +132,7 @@ impl PayoutsQueue { let mut request = client .request( method, - &if no_api_prefix.unwrap_or(false) { + if no_api_prefix.unwrap_or(false) { path.to_string() } else { format!("{}{path}", dotenvy::var("PAYPAL_API_URL")?) @@ -137,9 +158,6 @@ impl PayoutsQueue { ApiError::Payments("could not retrieve PayPal response body".to_string()) })?; - // TODO: remove - println!("{}", serde_json::to_string(&value)?); - if !status.is_success() { #[derive(Deserialize)] struct PayPalError { @@ -207,9 +225,6 @@ impl PayoutsQueue { ApiError::Payments("could not retrieve Tremendous response body".to_string()) })?; - // TODO: remove - println!("{}", serde_json::to_string(&value)?); - if !status.is_success() { if let Some(obj) = value.as_object() { if let Some(array) = obj.get("errors") { @@ -236,6 +251,262 @@ impl PayoutsQueue { Ok(serde_json::from_value(value)?) } + + pub async fn get_payout_methods(&self) -> Result, ApiError> { + async fn refresh_payout_methods(queue: &PayoutsQueue) -> Result { + let mut options = queue.payout_options.write().await; + + let mut methods = Vec::new(); + + #[derive(Deserialize)] + pub struct Sku { + pub min: Decimal, + pub max: Decimal, + } + + #[derive(Deserialize, Eq, PartialEq)] + #[serde(rename_all = "snake_case")] + pub enum ProductImageType { + Card, + Logo, + } + + #[derive(Deserialize)] + pub struct ProductImage { + pub src: String, + #[serde(rename = "type")] + pub type_: ProductImageType, + } + + #[derive(Deserialize)] + pub struct ProductCountry { + pub abbr: String, + } + + #[derive(Deserialize)] + pub struct Product { + pub id: String, + pub category: String, + pub name: String, + pub description: String, + pub disclosure: String, + pub skus: Vec, + pub currency_codes: Vec, + pub countries: Vec, + pub images: Vec, + } + + #[derive(Deserialize)] + pub struct TremendousResponse { + pub products: Vec, + } + + let response = queue + .make_tremendous_request::<(), TremendousResponse>(Method::GET, "products", None) + .await?; + + for product in response.products { + const BLACKLISTED_IDS: &[&str] = &[ + // physical visa + "A2J05SWPI2QG", + // crypto + "1UOOSHUUYTAM", + "5EVJN47HPDFT", + "NI9M4EVAVGFJ", + "VLY29QHTMNGT", + "7XU98H109Y3A", + "0CGEDFP2UIKV", + "PDYLQU0K073Y", + "HCS5Z7O2NV5G", + "IY1VMST1MOXS", + "VRPZLJ7HCA8X", + // bitcard (crypto) + "GWQQS5RM8IZS", + "896MYD4SGOGZ", + "PWLEN1VZGMZA", + "A2VRM96J5K5W", + "HV9ICIM3JT7P", + "K2KLSPVWC2Q4", + "HRBRQLLTDF95", + "UUBYLZVK7QAB", + "BH8W3XEDEOJN", + "7WGE043X1RYQ", + "2B13MHUZZVTF", + "JN6R44P86EYX", + "DA8H43GU84SO", + "QK2XAQHSDEH4", + "J7K1IQFS76DK", + "NL4JQ2G7UPRZ", + "OEFTMSBA5ELH", + "A3CQK6UHNV27", + ]; + const SUPPORTED_METHODS: &[&str] = + &["merchant_cards", "visa", "bank", "ach", "visa_card"]; + + if !SUPPORTED_METHODS.contains(&&*product.category) + || BLACKLISTED_IDS.contains(&&*product.id) + { + continue; + }; + + let method = PayoutMethod { + id: product.id, + type_: PayoutMethodType::Tremendous, + name: product.name.clone(), + supported_countries: product.countries.into_iter().map(|x| x.abbr).collect(), + image_url: product + .images + .into_iter() + .find(|x| x.type_ == ProductImageType::Card) + .map(|x| x.src), + interval: if product.skus.len() > 1 { + let mut values = product + .skus + .into_iter() + .map(|x| PayoutDecimal(x.min)) + .collect::>(); + values.sort_by(|a, b| a.0.cmp(&b.0)); + + PayoutInterval::Fixed { values } + } else if let Some(first) = product.skus.first() { + PayoutInterval::Standard { + min: first.min, + max: first.max, + } + } else { + PayoutInterval::Standard { + min: Decimal::ZERO, + max: Decimal::from(5_000), + } + }, + fee: if product.category == "ach" { + PayoutMethodFee { + percentage: Decimal::from(4) / Decimal::from(100), + min: Decimal::from(1) / Decimal::from(4), + max: None, + } + } else { + PayoutMethodFee { + percentage: Default::default(), + min: Default::default(), + max: None, + } + }, + }; + + // we do not support interval gift cards with non US based currencies since we cannot do currency conversions properly + if let PayoutInterval::Fixed { .. } = method.interval { + if !product.currency_codes.contains(&"USD".to_string()) { + continue; + } + } + + methods.push(method); + } + + const UPRANK_IDS: &[&str] = &["ET0ZVETV5ILN", "Q24BD9EZ332JT", "UIL1ZYJU5MKN"]; + const DOWNRANK_IDS: &[&str] = &["EIPF8Q00EMM1", "OU2MWXYWPNWQ"]; + + methods.sort_by(|a, b| { + let a_top = UPRANK_IDS.contains(&&*a.id); + let a_bottom = DOWNRANK_IDS.contains(&&*a.id); + let b_top = UPRANK_IDS.contains(&&*b.id); + let b_bottom = DOWNRANK_IDS.contains(&&*b.id); + + match (a_top, a_bottom, b_top, b_bottom) { + (true, _, true, _) => a.name.cmp(&b.name), // Both in top_priority: sort alphabetically + (_, true, _, true) => a.name.cmp(&b.name), // Both in bottom_priority: sort alphabetically + (true, _, _, _) => std::cmp::Ordering::Less, // a in top_priority: a comes first + (_, _, true, _) => std::cmp::Ordering::Greater, // b in top_priority: b comes first + (_, true, _, _) => std::cmp::Ordering::Greater, // a in bottom_priority: b comes first + (_, _, _, true) => std::cmp::Ordering::Less, // b in bottom_priority: a comes first + (_, _, _, _) => a.name.cmp(&b.name), // Neither in priority: sort alphabetically + } + }); + + { + let paypal_us = PayoutMethod { + id: "paypal_us".to_string(), + type_: PayoutMethodType::PayPal, + name: "PayPal".to_string(), + supported_countries: vec!["US".to_string()], + image_url: None, + interval: PayoutInterval::Standard { + min: Decimal::from(1) / Decimal::from(4), + max: Decimal::from(100_000), + }, + fee: PayoutMethodFee { + percentage: Decimal::from(2) / Decimal::from(100), + min: Decimal::from(1) / Decimal::from(4), + max: Some(Decimal::from(1)), + }, + }; + + let mut venmo = paypal_us.clone(); + venmo.id = "venmo".to_string(); + venmo.name = "Venmo".to_string(); + venmo.type_ = PayoutMethodType::Venmo; + + methods.insert(0, paypal_us); + methods.insert(1, venmo) + } + + methods.insert( + 2, + PayoutMethod { + id: "paypal_in".to_string(), + type_: PayoutMethodType::PayPal, + name: "PayPal".to_string(), + supported_countries: rust_iso3166::ALL + .iter() + .filter(|x| x.alpha2 != "US") + .map(|x| x.alpha2.to_string()) + .collect(), + image_url: None, + interval: PayoutInterval::Standard { + min: Decimal::from(1) / Decimal::from(4), + max: Decimal::from(100_000), + }, + fee: PayoutMethodFee { + percentage: Decimal::from(2) / Decimal::from(100), + min: Decimal::ZERO, + max: Some(Decimal::from(20)), + }, + }, + ); + + let new_options = PayoutMethods { + options: methods, + expires: Utc::now() + Duration::hours(6), + }; + + *options = Some(new_options.clone()); + + Ok(new_options) + } + + let read = self.payout_options.read().await; + let options = if let Some(options) = read.as_ref() { + if options.expires < Utc::now() { + drop(read); + refresh_payout_methods(self).await? + } else { + options.clone() + } + } else { + drop(read); + refresh_payout_methods(self).await? + }; + + Ok(options.options) + } + + pub fn lock_user_payouts(&self, user_id: UserId) -> Arc> { + self.payouts_locks + .entry(user_id) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } } pub async fn process_payout( diff --git a/src/routes/v2/teams.rs b/src/routes/v2/teams.rs index 87b2df16..fca310d3 100644 --- a/src/routes/v2/teams.rs +++ b/src/routes/v2/teams.rs @@ -112,6 +112,7 @@ pub struct NewTeamMember { #[serde(default)] pub organization_permissions: Option, #[serde(default)] + #[serde(with = "rust_decimal::serde::float")] pub payouts_split: Decimal, #[serde(default = "default_ordering")] pub ordering: i64, diff --git a/src/routes/v3/payouts.rs b/src/routes/v3/payouts.rs index 02b0d6d8..65932e2d 100644 --- a/src/routes/v3/payouts.rs +++ b/src/routes/v3/payouts.rs @@ -4,7 +4,7 @@ use crate::database::models::generate_payout_id; use crate::database::redis::RedisPool; use crate::models::ids::PayoutId; use crate::models::pats::Scopes; -use crate::models::payouts::{PayoutMethod, PayoutStatus}; +use crate::models::payouts::{PayoutMethodType, PayoutStatus}; use crate::queue::payouts::PayoutsQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; @@ -26,7 +26,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { .service(tremendous_webhook) .service(user_payouts) .service(create_payout) - .service(cancel_payout), + .service(cancel_payout) + .service(payment_methods), ); } @@ -69,7 +70,7 @@ pub async fn paypal_webhook( verification_status: String, } - let payouts: WebHookResponse = payouts + let webhook_res: WebHookResponse = payouts .make_paypal_request( Method::POST, "notifications/verify-webhook-signature", @@ -86,16 +87,103 @@ pub async fn paypal_webhook( ) .await?; - if &payouts.verification_status != "SUCCESS" { + if &webhook_res.verification_status != "SUCCESS" { return Err(ApiError::InvalidInput( "Invalid webhook signature".to_string(), )); } - println!("{}", payouts.verification_status); + println!("{}", webhook_res.verification_status); println!("{:?}", body.0); - // TODO: Actually handle stuff here!! + #[derive(Deserialize)] + struct PayPalResource { + pub id: String, + } + + #[derive(Deserialize)] + struct PayPalWebhook { + pub event_type: String, + pub resource: PayPalResource, + } + + let webhook = serde_json::from_value::(body.0)?; + + match &*webhook.event_type { + "PAYMENT.PAYOUTS-ITEM.BLOCKED" + | "PAYMENT.PAYOUTS-ITEM.DENIED" + | "PAYMENT.PAYOUTS-ITEM.REFUNDED" + | "PAYMENT.PAYOUTS-ITEM.RETURNED" + | "PAYMENT.PAYOUTS-ITEM.CANCELED" => { + let mut transaction = pool.begin().await?; + + let result = sqlx::query!( + "SELECT user_id, amount, fee FROM payouts WHERE platform_id = $1", + webhook.resource.id + ) + .fetch_optional(&mut *transaction) + .await?; + + if let Some(result) = result { + let mtx = + payouts.lock_user_payouts(crate::models::ids::UserId(result.user_id as u64)); + let _guard = mtx.lock().await; + + sqlx::query!( + " + UPDATE users + SET balance = balance + $1 + WHERE id = $2 + ", + result.amount + result.fee.unwrap_or(Decimal::ZERO), + result.user_id + ) + .execute(&mut *transaction) + .await?; + + crate::database::models::user_item::User::clear_caches( + &[(crate::database::models::UserId(result.user_id), None)], + &redis, + ) + .await?; + + sqlx::query!( + " + UPDATE payouts + SET status = $1 + WHERE platform_id = $2 + ", + if &*webhook.event_type == "PAYMENT.PAYOUTS-ITEM.CANCELED" { + PayoutStatus::Cancelled + } else { + PayoutStatus::Failed + } + .as_str(), + webhook.resource.id + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + } + } + "PAYMENT.PAYOUTS-ITEM.SUCCEEDED" => { + let mut transaction = pool.begin().await?; + sqlx::query!( + " + UPDATE payouts + SET status = $1 + WHERE platform_id = $2 + ", + PayoutStatus::Success.as_str(), + webhook.resource.id + ) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + } + _ => {} + } Ok(HttpResponse::NoContent().finish()) } @@ -105,6 +193,7 @@ pub async fn tremendous_webhook( req: HttpRequest, pool: web::Data, redis: web::Data, + payouts: web::Data, body: String, ) -> Result { let signature = req @@ -126,13 +215,95 @@ pub async fn tremendous_webhook( )); } - // TODO: finish this + #[derive(Deserialize)] + pub struct TremendousResource { + pub id: String, + } + + #[derive(Deserialize)] + struct TremendousPayload { + pub resource: TremendousResource, + } + + #[derive(Deserialize)] + struct TremendousWebhook { + pub event: String, + pub payload: TremendousPayload, + } + + let webhook = serde_json::from_str::(&body)?; + + match &*webhook.event { + "REWARDS.CANCELED" | "REWARDS.DELIVERY.FAILED" => { + let mut transaction = pool.begin().await?; + + let result = sqlx::query!( + "SELECT user_id, amount, fee FROM payouts WHERE platform_id = $1", + webhook.payload.resource.id + ) + .fetch_optional(&mut *transaction) + .await?; - // https://developers.tremendous.com/docs/webhooks-1 - // events: - // REWARDS.CANCELED - // REWARDS.DELIVERY.FAILED - // REWARDS.DELIVERY.SUCCEEDED + if let Some(result) = result { + let mtx = + payouts.lock_user_payouts(crate::models::ids::UserId(result.user_id as u64)); + let _guard = mtx.lock().await; + + sqlx::query!( + " + UPDATE users + SET balance = balance + $1 + WHERE id = $2 + ", + result.amount + result.fee.unwrap_or(Decimal::ZERO), + result.user_id + ) + .execute(&mut *transaction) + .await?; + + crate::database::models::user_item::User::clear_caches( + &[(crate::database::models::UserId(result.user_id), None)], + &redis, + ) + .await?; + + sqlx::query!( + " + UPDATE payouts + SET status = $1 + WHERE platform_id = $2 + ", + if &*webhook.event == "REWARDS.CANCELED" { + PayoutStatus::Cancelled + } else { + PayoutStatus::Failed + } + .as_str(), + webhook.payload.resource.id + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + } + } + "PAYMENT.PAYOUTS-ITEM.SUCCEEDED" => { + let mut transaction = pool.begin().await?; + sqlx::query!( + " + UPDATE payouts + SET status = $1 + WHERE platform_id = $2 + ", + PayoutStatus::Success.as_str(), + webhook.payload.resource.id + ) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + } + _ => {} + } Ok(HttpResponse::NoContent().finish()) } @@ -163,15 +334,17 @@ pub async fn user_payouts( Ok(HttpResponse::Ok().json( payouts .into_iter() - .map(|x| crate::models::payouts::Payout::from(x)) + .map(crate::models::payouts::Payout::from) .collect::>(), )) } #[derive(Deserialize)] pub struct Withdrawal { + #[serde(with = "rust_decimal::serde::float")] amount: Decimal, - method: PayoutMethod, + method: PayoutMethodType, + method_id: String, } #[post("")] @@ -194,74 +367,73 @@ pub async fn create_payout( )); } - // TODO: hold payouts mutex + let mtx = payouts_queue.lock_user_payouts(user.id.into()); + let _guard = mtx.lock().await; - if user.balance > body.amount { + if user.balance < body.amount || body.amount < Decimal::ZERO { return Err(ApiError::InvalidInput( "You do not have enough funds to make this payout!".to_string(), )); } + let payout_method = payouts_queue + .get_payout_methods() + .await? + .into_iter() + .find(|x| x.id == body.method_id) + .ok_or_else(|| ApiError::InvalidInput("Invalid payment method specified!".to_string()))?; + + let fee = std::cmp::min( + std::cmp::max( + payout_method.fee.min, + payout_method.fee.percentage * body.amount, + ), + payout_method.fee.max.unwrap_or(Decimal::MAX), + ); + + let transfer = (body.amount - fee).round_dp(2); + if transfer <= Decimal::ZERO { + return Err(ApiError::InvalidInput( + "You need to withdraw more to cover the fee!".to_string(), + )); + } + let mut transaction = pool.begin().await?; let payout_id = generate_payout_id(&mut transaction).await?; let payout_item = match body.method { - PayoutMethod::Venmo | PayoutMethod::PayPal => { - let (fee, wallet, wallet_type, address) = if body.method == PayoutMethod::Venmo { + PayoutMethodType::Venmo | PayoutMethodType::PayPal => { + let (wallet, wallet_type, address) = if body.method == PayoutMethodType::Venmo { if let Some(venmo) = user.venmo_handle { - ( - Decimal::from(1) / Decimal::from(4), - "Venmo", - "user_handle", - venmo, - ) + ("Venmo", "user_handle", venmo) } else { return Err(ApiError::InvalidInput( "Venmo address has not been set for account!".to_string(), )); } - } else { - if let Some(paypal_id) = user.paypal_id { - if let Some(paypal_country) = user.paypal_country { - let fee = if &paypal_country == "US" { - std::cmp::min( - std::cmp::max( - Decimal::ONE / Decimal::from(4), - (Decimal::from(2) / Decimal::ONE_HUNDRED) * body.amount, - ), - Decimal::from(1), - ) - } else { - std::cmp::min( - (Decimal::from(2) / Decimal::ONE_HUNDRED) * body.amount, - Decimal::from(20), - ) - }; - - ( - Decimal::from(1) / Decimal::from(4), - "PayPal", - "paypal_id", - paypal_id, - ) - } else { + } else if let Some(paypal_id) = user.paypal_id { + if let Some(paypal_country) = user.paypal_country { + if &*paypal_country == "US" && &*body.method_id != "paypal_us" { + return Err(ApiError::InvalidInput( + "Please use the US PayPal transfer option!".to_string(), + )); + } else if &*paypal_country != "US" && &*body.method_id == "paypal_us" { return Err(ApiError::InvalidInput( - "Please re-link your PayPal account!".to_string(), + "Please use the International PayPal transfer option!".to_string(), )); } + + ("PayPal", "paypal_id", paypal_id) } else { return Err(ApiError::InvalidInput( - "You have not linked a PayPal account!".to_string(), + "Please re-link your PayPal account!".to_string(), )); } - }; - - let transfer = (user.balance - fee).round_dp(2); - if transfer <= Decimal::ZERO { + } else { return Err(ApiError::InvalidInput( - "You do not have enough funds to make this payout!".to_string(), + "You have not linked a PayPal account!".to_string(), )); - } + }; #[derive(Deserialize)] struct PayPalLink { @@ -339,18 +511,81 @@ pub async fn create_payout( payout_item } - PayoutMethod::Tremendous => { + PayoutMethodType::Tremendous => { if let Some(email) = user.email { if user.email_verified { + let mut payout_item = crate::database::models::payout_item::Payout { + id: payout_id, + user_id: user.id, + created: Utc::now(), + status: PayoutStatus::Processing, + amount: transfer, + fee: Some(fee), + method: Some(PayoutMethodType::Tremendous), + method_address: Some(email.clone()), + platform_id: None, + }; + + #[derive(Deserialize)] + struct Reward { + pub id: String, + } + + #[derive(Deserialize)] + struct Order { + pub rewards: Vec, + } + + #[derive(Deserialize)] + struct TremendousResponse { + pub order: Order, + } + + let res: TremendousResponse = payouts_queue + .make_tremendous_request( + Method::POST, + "orders", + Some(json! ({ + "payment": { + "funding_source_id": "BALANCE", + }, + "rewards": [{ + "value": { + "denomination": transfer + }, + "delivery": { + "method": "EMAIL" + }, + "recipient": { + "name": user.username, + "email": email + }, + "products": [ + &body.method_id, + ], + "campaign_id": dotenvy::var("TREMENDOUS_CAMPAIGN_ID")?, + }] + })), + ) + .await?; + + if let Some(reward) = res.order.rewards.first() { + payout_item.platform_id = Some(reward.id.clone()) + } + + payout_item } else { + return Err(ApiError::InvalidInput( + "You must verify your account email to proceed!".to_string(), + )); } } else { + return Err(ApiError::InvalidInput( + "You must add an email to your account to proceed!".to_string(), + )); } - - // TODO: finish tremendous - unreachable!() } - PayoutMethod::Unknown => { + PayoutMethodType::Unknown => { return Err(ApiError::Payments( "Invalid payment method specified!".to_string(), )) @@ -363,7 +598,7 @@ pub async fn create_payout( SET balance = balance - $1 WHERE id = $2 ", - payout_item.amount, + body.amount, user.id as crate::database::models::ids::UserId ) .execute(&mut *transaction) @@ -373,8 +608,6 @@ pub async fn create_payout( transaction.commit().await?; - // todo: remove hold on user payouts mutex and release when error - Ok(HttpResponse::NoContent().finish()) } @@ -407,14 +640,14 @@ pub async fn cancel_payout( if let Some(platform_id) = payout.platform_id { if let Some(method) = payout.method { - if payout.status == PayoutStatus::Success { + if payout.status != PayoutStatus::Processing { return Err(ApiError::InvalidInput( "Payout cannot be cancelled!".to_string(), )); } match method { - PayoutMethod::Venmo | PayoutMethod::PayPal => { + PayoutMethodType::Venmo | PayoutMethodType::PayPal => { payouts .make_paypal_request::<(), ()>( Method::POST, @@ -423,12 +656,8 @@ pub async fn cancel_payout( None, ) .await?; - - // TODO: maybe update database status here? also credit back user - - Ok(HttpResponse::NoContent().finish()) } - PayoutMethod::Tremendous => { + PayoutMethodType::Tremendous => { payouts .make_tremendous_request::<(), ()>( Method::POST, @@ -436,15 +665,29 @@ pub async fn cancel_payout( None, ) .await?; - - // TODO: maybe update database status here? also credit back user - - Ok(HttpResponse::NoContent().finish()) } - PayoutMethod::Unknown => Err(ApiError::InvalidInput( - "Payout cannot be cancelled!".to_string(), - )), + PayoutMethodType::Unknown => { + return Err(ApiError::InvalidInput( + "Payout cannot be cancelled!".to_string(), + )) + } } + + let mut transaction = pool.begin().await?; + sqlx::query!( + " + UPDATE payouts + SET status = $1 + WHERE platform_id = $2 + ", + PayoutStatus::Cancelling.as_str(), + platform_id + ) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + + Ok(HttpResponse::NoContent().finish()) } else { Err(ApiError::InvalidInput( "Payout cannot be cancelled!".to_string(), @@ -460,18 +703,30 @@ pub async fn cancel_payout( } } -pub struct Currency {} +#[derive(Deserialize)] +pub struct MethodFilter { + pub country: Option, +} #[get("methods")] pub async fn payment_methods( - req: HttpRequest, - pool: web::Data, - redis: web::Data, - session_queue: web::Data, payouts_queue: web::Data, + filter: web::Query, ) -> Result { - // TODO: route for estimated fees? - // todo: maybe payout method / gift card list + filtering? (tremendous), with fees? + let methods = payouts_queue + .get_payout_methods() + .await? + .into_iter() + .filter(|x| { + let mut val = true; + + if let Some(country) = &filter.country { + val &= x.supported_countries.contains(country); + } - Ok(HttpResponse::NoContent().finish()) + val + }) + .collect::>(); + + Ok(HttpResponse::Ok().json(methods)) } diff --git a/src/routes/v3/teams.rs b/src/routes/v3/teams.rs index 05f19c69..fef51474 100644 --- a/src/routes/v3/teams.rs +++ b/src/routes/v3/teams.rs @@ -378,6 +378,7 @@ pub struct NewTeamMember { #[serde(default)] pub organization_permissions: Option, #[serde(default)] + #[serde(with = "rust_decimal::serde::float")] pub payouts_split: Decimal, #[serde(default = "default_ordering")] pub ordering: i64,