You've already forked AstralRinth
forked from didirus/AstralRinth
Switch to Trolley for Modrinth Payments (#727)
* most of trolley * Switch to trolley for payments * run prepare * fix clippy * fix more * Fix most tests + bitflags * Update src/auth/flows.rs Co-authored-by: Jackson Kruger <jak.kruger@gmail.com> * Finish trolley * run prep for merge * Update src/queue/payouts.rs Co-authored-by: Jackson Kruger <jak.kruger@gmail.com> --------- Co-authored-by: Jackson Kruger <jak.kruger@gmail.com>
This commit is contained in:
@@ -8,7 +8,8 @@ use crate::file_hosting::FileHost;
|
||||
use crate::models::ids::base62_impl::{parse_base62, to_base62};
|
||||
use crate::models::ids::random_base62_rng;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::users::{Badges, Role};
|
||||
use crate::models::users::{Badges, RecipientStatus, Role, UserPayoutData};
|
||||
use crate::queue::payouts::{AccountUser, PayoutsQueue};
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::queue::socket::ActiveSockets;
|
||||
use crate::routes::ApiError;
|
||||
@@ -30,7 +31,7 @@ use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use validator::Validate;
|
||||
|
||||
pub fn config(cfg: &mut ServiceConfig) {
|
||||
@@ -51,7 +52,8 @@ pub fn config(cfg: &mut ServiceConfig) {
|
||||
.service(resend_verify_email)
|
||||
.service(set_email)
|
||||
.service(verify_email)
|
||||
.service(subscribe_newsletter),
|
||||
.service(subscribe_newsletter)
|
||||
.service(link_trolley),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -225,9 +227,8 @@ impl TempUser {
|
||||
role: Role::Developer.to_string(),
|
||||
badges: Badges::default(),
|
||||
balance: Decimal::ZERO,
|
||||
payout_wallet: None,
|
||||
payout_wallet_type: None,
|
||||
payout_address: None,
|
||||
trolley_id: None,
|
||||
trolley_account_status: None,
|
||||
}
|
||||
.insert(transaction)
|
||||
.await?;
|
||||
@@ -1013,7 +1014,7 @@ pub async fn auth_callback(
|
||||
|
||||
let sockets = active_sockets.clone();
|
||||
let state = state_string.clone();
|
||||
let res: Result<HttpResponse, AuthenticationError> = (|| async move {
|
||||
let res: Result<HttpResponse, AuthenticationError> = async move {
|
||||
|
||||
let flow = Flow::get(&state, &redis).await?;
|
||||
|
||||
@@ -1175,7 +1176,7 @@ pub async fn auth_callback(
|
||||
} else {
|
||||
Err::<HttpResponse, AuthenticationError>(AuthenticationError::InvalidCredentials)
|
||||
}
|
||||
})().await;
|
||||
}.await;
|
||||
|
||||
// Because this is callback route, if we have an error, we need to ensure we close the original socket if it exists
|
||||
if let Err(ref e) = res {
|
||||
@@ -1385,9 +1386,8 @@ pub async fn create_account_with_password(
|
||||
role: Role::Developer.to_string(),
|
||||
badges: Badges::default(),
|
||||
balance: Decimal::ZERO,
|
||||
payout_wallet: None,
|
||||
payout_wallet_type: None,
|
||||
payout_address: None,
|
||||
trolley_id: None,
|
||||
trolley_account_status: None,
|
||||
}
|
||||
.insert(&mut transaction)
|
||||
.await?;
|
||||
@@ -2011,6 +2011,7 @@ pub async fn set_email(
|
||||
redis: Data<RedisPool>,
|
||||
email: web::Json<SetEmail>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
payouts_queue: Data<Mutex<PayoutsQueue>>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
email
|
||||
.0
|
||||
@@ -2064,6 +2065,17 @@ pub async fn set_email(
|
||||
"We need to verify your email address.",
|
||||
)?;
|
||||
|
||||
if let Some(UserPayoutData {
|
||||
trolley_id: Some(trolley_id),
|
||||
..
|
||||
}) = user.payout_data
|
||||
{
|
||||
let queue = payouts_queue.lock().await;
|
||||
queue
|
||||
.update_recipient_email(&trolley_id, &email.email)
|
||||
.await?;
|
||||
}
|
||||
|
||||
crate::database::models::User::clear_caches(&[(user.id.into(), None)], &redis).await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
@@ -2206,3 +2218,59 @@ fn send_email_verify(
|
||||
Some(("Verify email", &format!("{}/{}?flow={}", dotenvy::var("SITE_URL")?, dotenvy::var("SITE_VERIFY_EMAIL_PATH")?, flow))),
|
||||
)
|
||||
}
|
||||
|
||||
#[post("trolley/link")]
|
||||
pub async fn link_trolley(
|
||||
req: HttpRequest,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
payouts_queue: Data<Mutex<PayoutsQueue>>,
|
||||
body: web::Json<AccountUser>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::PAYOUTS_WRITE]),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if let Some(payout_data) = user.payout_data {
|
||||
if payout_data.trolley_id.is_some() {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"User already has a trolley account.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(email) = user.email {
|
||||
let id = payouts_queue.lock().await.register_recipient(&email, body.0).await?;
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET trolley_id = $1, trolley_account_status = $2
|
||||
WHERE id = $3
|
||||
",
|
||||
id,
|
||||
RecipientStatus::Incomplete.as_str(),
|
||||
user.id.0 as i64,
|
||||
)
|
||||
.execute(&mut transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
crate::database::models::User::clear_caches(&[(user.id.into(), None)], &redis).await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
} else {
|
||||
Err(ApiError::InvalidInput(
|
||||
"User needs to have an email set on account.".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,16 +56,15 @@ where
|
||||
created: db_user.created,
|
||||
role: Role::from_string(&db_user.role),
|
||||
badges: db_user.badges,
|
||||
payout_data: Some(UserPayoutData {
|
||||
balance: db_user.balance,
|
||||
payout_wallet: db_user.payout_wallet,
|
||||
payout_wallet_type: db_user.payout_wallet_type,
|
||||
payout_address: db_user.payout_address,
|
||||
}),
|
||||
auth_providers: Some(auth_providers),
|
||||
has_password: Some(db_user.password.is_some()),
|
||||
has_totp: Some(db_user.totp_secret.is_some()),
|
||||
github_id: None,
|
||||
payout_data: Some(UserPayoutData {
|
||||
balance: db_user.balance,
|
||||
trolley_id: db_user.trolley_id,
|
||||
trolley_status: db_user.trolley_account_status,
|
||||
}),
|
||||
};
|
||||
|
||||
if let Some(required_scopes) = required_scopes {
|
||||
|
||||
@@ -76,10 +76,10 @@ pub async fn fetch_playtimes(
|
||||
.bind(start_date)
|
||||
.bind(end_date);
|
||||
|
||||
if projects.is_some() {
|
||||
query = query.bind(projects.unwrap().iter().map(|x| x.0).collect::<Vec<_>>());
|
||||
} else if versions.is_some() {
|
||||
query = query.bind(versions.unwrap().iter().map(|x| x.0).collect::<Vec<_>>());
|
||||
if let Some(projects) = projects {
|
||||
query = query.bind(projects.iter().map(|x| x.0).collect::<Vec<_>>());
|
||||
} else if let Some(versions) = versions {
|
||||
query = query.bind(versions.iter().map(|x| x.0).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
Ok(query.fetch_all().await?)
|
||||
@@ -123,10 +123,10 @@ pub async fn fetch_views(
|
||||
.bind(start_date)
|
||||
.bind(end_date);
|
||||
|
||||
if projects.is_some() {
|
||||
query = query.bind(projects.unwrap().iter().map(|x| x.0).collect::<Vec<_>>());
|
||||
} else if versions.is_some() {
|
||||
query = query.bind(versions.unwrap().iter().map(|x| x.0).collect::<Vec<_>>());
|
||||
if let Some(projects) = projects {
|
||||
query = query.bind(projects.iter().map(|x| x.0).collect::<Vec<_>>());
|
||||
} else if let Some(versions) = versions {
|
||||
query = query.bind(versions.iter().map(|x| x.0).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
Ok(query.fetch_all().await?)
|
||||
@@ -170,10 +170,10 @@ pub async fn fetch_downloads(
|
||||
.bind(start_date)
|
||||
.bind(end_date);
|
||||
|
||||
if projects.is_some() {
|
||||
query = query.bind(projects.unwrap().iter().map(|x| x.0).collect::<Vec<_>>());
|
||||
} else if versions.is_some() {
|
||||
query = query.bind(versions.unwrap().iter().map(|x| x.0).collect::<Vec<_>>());
|
||||
if let Some(projects) = projects {
|
||||
query = query.bind(projects.iter().map(|x| x.0).collect::<Vec<_>>());
|
||||
} else if let Some(versions) = versions {
|
||||
query = query.bind(versions.iter().map(|x| x.0).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
Ok(query.fetch_all().await?)
|
||||
@@ -233,10 +233,10 @@ pub async fn fetch_countries(
|
||||
"
|
||||
)).bind(start_date).bind(end_date).bind(start_date).bind(end_date);
|
||||
|
||||
if projects.is_some() {
|
||||
query = query.bind(projects.unwrap().iter().map(|x| x.0).collect::<Vec<_>>());
|
||||
} else if versions.is_some() {
|
||||
query = query.bind(versions.unwrap().iter().map(|x| x.0).collect::<Vec<_>>());
|
||||
if let Some(projects) = projects {
|
||||
query = query.bind(projects.iter().map(|x| x.0).collect::<Vec<_>>());
|
||||
} else if let Some(versions) = versions {
|
||||
query = query.bind(versions.iter().map(|x| x.0).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
Ok(query.fetch_all().await?)
|
||||
|
||||
@@ -210,7 +210,7 @@ impl Collection {
|
||||
color: m.color.map(|x| x as u32),
|
||||
created: m.created,
|
||||
updated: m.updated,
|
||||
status: CollectionStatus::from_str(&m.status),
|
||||
status: CollectionStatus::from_string(&m.status),
|
||||
projects: m
|
||||
.mods
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -630,10 +630,10 @@ impl Project {
|
||||
license_url: m.license_url.clone(),
|
||||
discord_url: m.discord_url.clone(),
|
||||
client_side: SideTypeId(m.client_side),
|
||||
status: ProjectStatus::from_str(
|
||||
status: ProjectStatus::from_string(
|
||||
&m.status,
|
||||
),
|
||||
requested_status: m.requested_status.map(|x| ProjectStatus::from_str(
|
||||
requested_status: m.requested_status.map(|x| ProjectStatus::from_string(
|
||||
&x,
|
||||
)),
|
||||
server_side: SideTypeId(m.server_side),
|
||||
@@ -647,7 +647,7 @@ impl Project {
|
||||
webhook_sent: m.webhook_sent,
|
||||
color: m.color.map(|x| x as u32),
|
||||
queued: m.queued,
|
||||
monetization_status: MonetizationStatus::from_str(
|
||||
monetization_status: MonetizationStatus::from_string(
|
||||
&m.monetization_status,
|
||||
),
|
||||
loaders: m.loaders,
|
||||
@@ -685,8 +685,8 @@ impl Project {
|
||||
donation_urls: serde_json::from_value(
|
||||
m.donations.unwrap_or_default(),
|
||||
).ok().unwrap_or_default(),
|
||||
client_side: crate::models::projects::SideType::from_str(&m.client_side_type),
|
||||
server_side: crate::models::projects::SideType::from_str(&m.server_side_type),
|
||||
client_side: crate::models::projects::SideType::from_string(&m.client_side_type),
|
||||
server_side: crate::models::projects::SideType::from_string(&m.server_side_type),
|
||||
thread_id: ThreadId(m.thread_id),
|
||||
}}))
|
||||
})
|
||||
|
||||
@@ -148,7 +148,7 @@ impl Thread {
|
||||
id: ThreadId(x.id),
|
||||
project_id: x.mod_id.map(ProjectId),
|
||||
report_id: x.report_id.map(ReportId),
|
||||
type_: ThreadType::from_str(&x.thread_type),
|
||||
type_: ThreadType::from_string(&x.thread_type),
|
||||
messages: {
|
||||
let mut messages: Vec<ThreadMessage> = serde_json::from_value(
|
||||
x.messages.unwrap_or_default(),
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::CollectionId;
|
||||
use crate::database::models::DatabaseError;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::base62_impl::{parse_base62, to_base62};
|
||||
use crate::models::users::{Badges, RecipientType, RecipientWallet};
|
||||
use crate::models::users::{Badges, RecipientStatus};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -35,10 +35,10 @@ pub struct User {
|
||||
pub created: DateTime<Utc>,
|
||||
pub role: String,
|
||||
pub badges: Badges,
|
||||
|
||||
pub balance: Decimal,
|
||||
pub payout_wallet: Option<RecipientWallet>,
|
||||
pub payout_wallet_type: Option<RecipientType>,
|
||||
pub payout_address: Option<String>,
|
||||
pub trolley_id: Option<String>,
|
||||
pub trolley_account_status: Option<RecipientStatus>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
@@ -188,9 +188,9 @@ impl User {
|
||||
SELECT id, name, email,
|
||||
avatar_url, username, bio,
|
||||
created, role, badges,
|
||||
balance, payout_wallet, payout_wallet_type, payout_address,
|
||||
balance,
|
||||
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
|
||||
email_verified, password, totp_secret
|
||||
email_verified, password, totp_secret, trolley_id, trolley_account_status
|
||||
FROM users
|
||||
WHERE id = ANY($1) OR LOWER(username) = ANY($2)
|
||||
",
|
||||
@@ -220,13 +220,13 @@ impl User {
|
||||
role: u.role,
|
||||
badges: Badges::from_bits(u.badges as u64).unwrap_or_default(),
|
||||
balance: u.balance,
|
||||
payout_wallet: u.payout_wallet.map(|x| RecipientWallet::from_string(&x)),
|
||||
payout_wallet_type: u
|
||||
.payout_wallet_type
|
||||
.map(|x| RecipientType::from_string(&x)),
|
||||
payout_address: u.payout_address,
|
||||
password: u.password,
|
||||
totp_secret: u.totp_secret,
|
||||
trolley_id: u.trolley_id,
|
||||
trolley_account_status: u
|
||||
.trolley_account_status
|
||||
.as_ref()
|
||||
.map(|x| RecipientStatus::from_string(x)),
|
||||
}))
|
||||
})
|
||||
.try_collect::<Vec<User>>()
|
||||
@@ -352,7 +352,7 @@ impl User {
|
||||
redis: &RedisPool,
|
||||
) -> Result<(), DatabaseError> {
|
||||
redis
|
||||
.delete_many(user_ids.into_iter().flat_map(|(id, username)| {
|
||||
.delete_many(user_ids.iter().flat_map(|(id, username)| {
|
||||
[
|
||||
(USERS_NAMESPACE, Some(id.0.to_string())),
|
||||
(
|
||||
|
||||
@@ -590,9 +590,9 @@ impl Version {
|
||||
downloads: v.downloads,
|
||||
version_type: v.version_type,
|
||||
featured: v.featured,
|
||||
status: VersionStatus::from_str(&v.status),
|
||||
status: VersionStatus::from_string(&v.status),
|
||||
requested_status: v.requested_status
|
||||
.map(|x| VersionStatus::from_str(&x)),
|
||||
.map(|x| VersionStatus::from_string(&x)),
|
||||
},
|
||||
files: {
|
||||
#[derive(Deserialize)]
|
||||
@@ -803,7 +803,7 @@ impl Version {
|
||||
.unwrap_or_default().into_iter().map(|x| (x.algorithm, x.hash)).collect(),
|
||||
primary: f.is_primary,
|
||||
size: f.size as u32,
|
||||
file_type: f.file_type.map(|x| FileType::from_str(&x)),
|
||||
file_type: f.file_type.map(|x| FileType::from_string(&x)),
|
||||
}
|
||||
}
|
||||
))
|
||||
|
||||
@@ -4,6 +4,7 @@ use bytes::Bytes;
|
||||
use chrono::Utc;
|
||||
use sha2::Digest;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MockHost(());
|
||||
|
||||
impl MockHost {
|
||||
|
||||
@@ -365,9 +365,9 @@ pub fn check_env_vars() -> bool {
|
||||
failed |= true;
|
||||
}
|
||||
|
||||
failed |= check_var::<String>("PAYPAL_API_URL");
|
||||
failed |= check_var::<String>("PAYPAL_CLIENT_ID");
|
||||
failed |= check_var::<String>("PAYPAL_CLIENT_SECRET");
|
||||
failed |= check_var::<String>("TROLLEY_ACCESS_KEY");
|
||||
failed |= check_var::<String>("TROLLEY_SECRET_KEY");
|
||||
failed |= check_var::<String>("TROLLEY_WEBHOOK_SIGNATURE");
|
||||
|
||||
failed |= check_var::<String>("GITHUB_CLIENT_ID");
|
||||
failed |= check_var::<String>("GITHUB_CLIENT_SECRET");
|
||||
|
||||
@@ -31,8 +31,6 @@ async fn main() -> std::io::Result<()> {
|
||||
let sentry = sentry::init(sentry::ClientOptions {
|
||||
release: sentry::release_name!(),
|
||||
traces_sample_rate: 0.1,
|
||||
enable_profiling: true,
|
||||
profiles_sample_rate: 0.1,
|
||||
..Default::default()
|
||||
});
|
||||
if sentry.is_enabled() {
|
||||
|
||||
@@ -80,7 +80,7 @@ impl std::fmt::Display for CollectionStatus {
|
||||
}
|
||||
|
||||
impl CollectionStatus {
|
||||
pub fn from_str(string: &str) -> CollectionStatus {
|
||||
pub fn from_string(string: &str) -> CollectionStatus {
|
||||
match string {
|
||||
"listed" => CollectionStatus::Listed,
|
||||
"unlisted" => CollectionStatus::Unlisted,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::ids::Base62Id;
|
||||
use crate::bitflags_serde_impl;
|
||||
use crate::models::ids::UserId;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -10,8 +11,7 @@ use serde::{Deserialize, Serialize};
|
||||
pub struct PatId(pub u64);
|
||||
|
||||
bitflags::bitflags! {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct Scopes: u64 {
|
||||
// read a user's email
|
||||
const USER_READ_EMAIL = 1 << 0;
|
||||
@@ -107,6 +107,8 @@ bitflags::bitflags! {
|
||||
}
|
||||
}
|
||||
|
||||
bitflags_serde_impl!(Scopes, u64);
|
||||
|
||||
impl Scopes {
|
||||
// these scopes cannot be specified in a personal access token
|
||||
pub fn restricted() -> Scopes {
|
||||
|
||||
@@ -247,7 +247,7 @@ impl SideType {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(string: &str) -> SideType {
|
||||
pub fn from_string(string: &str) -> SideType {
|
||||
match string {
|
||||
"required" => SideType::Required,
|
||||
"optional" => SideType::Optional,
|
||||
@@ -308,7 +308,7 @@ impl std::fmt::Display for ProjectStatus {
|
||||
}
|
||||
|
||||
impl ProjectStatus {
|
||||
pub fn from_str(string: &str) -> ProjectStatus {
|
||||
pub fn from_string(string: &str) -> ProjectStatus {
|
||||
match string {
|
||||
"processing" => ProjectStatus::Processing,
|
||||
"rejected" => ProjectStatus::Rejected,
|
||||
@@ -433,7 +433,7 @@ impl std::fmt::Display for MonetizationStatus {
|
||||
}
|
||||
|
||||
impl MonetizationStatus {
|
||||
pub fn from_str(string: &str) -> MonetizationStatus {
|
||||
pub fn from_string(string: &str) -> MonetizationStatus {
|
||||
match string {
|
||||
"force-demonetized" => MonetizationStatus::ForceDemonetized,
|
||||
"demonetized" => MonetizationStatus::Demonetized,
|
||||
@@ -537,7 +537,7 @@ impl From<QueryVersion> for Version {
|
||||
version_id: d.version_id.map(|i| VersionId(i.0 as u64)),
|
||||
project_id: d.project_id.map(|i| ProjectId(i.0 as u64)),
|
||||
file_name: d.file_name,
|
||||
dependency_type: DependencyType::from_str(d.dependency_type.as_str()),
|
||||
dependency_type: DependencyType::from_string(d.dependency_type.as_str()),
|
||||
})
|
||||
.collect(),
|
||||
game_versions: data.game_versions.into_iter().map(GameVersion).collect(),
|
||||
@@ -570,7 +570,7 @@ impl std::fmt::Display for VersionStatus {
|
||||
}
|
||||
|
||||
impl VersionStatus {
|
||||
pub fn from_str(string: &str) -> VersionStatus {
|
||||
pub fn from_string(string: &str) -> VersionStatus {
|
||||
match string {
|
||||
"listed" => VersionStatus::Listed,
|
||||
"draft" => VersionStatus::Draft,
|
||||
@@ -718,7 +718,7 @@ impl DependencyType {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(string: &str) -> DependencyType {
|
||||
pub fn from_string(string: &str) -> DependencyType {
|
||||
match string {
|
||||
"required" => DependencyType::Required,
|
||||
"optional" => DependencyType::Optional,
|
||||
@@ -753,7 +753,7 @@ impl FileType {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(string: &str) -> FileType {
|
||||
pub fn from_string(string: &str) -> FileType {
|
||||
match string {
|
||||
"required-resource-pack" => FileType::RequiredResourcePack,
|
||||
"optional-resource-pack" => FileType::OptionalResourcePack,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::ids::Base62Id;
|
||||
use crate::bitflags_serde_impl;
|
||||
use crate::models::users::User;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -22,8 +23,7 @@ pub struct Team {
|
||||
}
|
||||
|
||||
bitflags::bitflags! {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct ProjectPermissions: u64 {
|
||||
const UPLOAD_VERSION = 1 << 0;
|
||||
const DELETE_VERSION = 1 << 1;
|
||||
@@ -40,6 +40,8 @@ bitflags::bitflags! {
|
||||
}
|
||||
}
|
||||
|
||||
bitflags_serde_impl!(ProjectPermissions, u64);
|
||||
|
||||
impl Default for ProjectPermissions {
|
||||
fn default() -> ProjectPermissions {
|
||||
ProjectPermissions::UPLOAD_VERSION | ProjectPermissions::DELETE_VERSION
|
||||
@@ -77,8 +79,7 @@ impl ProjectPermissions {
|
||||
}
|
||||
|
||||
bitflags::bitflags! {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct OrganizationPermissions: u64 {
|
||||
const EDIT_DETAILS = 1 << 0;
|
||||
const EDIT_BODY = 1 << 1;
|
||||
@@ -94,6 +95,8 @@ bitflags::bitflags! {
|
||||
}
|
||||
}
|
||||
|
||||
bitflags_serde_impl!(OrganizationPermissions, u64);
|
||||
|
||||
impl Default for OrganizationPermissions {
|
||||
fn default() -> OrganizationPermissions {
|
||||
OrganizationPermissions::NONE
|
||||
|
||||
@@ -78,7 +78,7 @@ impl ThreadType {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(string: &str) -> ThreadType {
|
||||
pub fn from_string(string: &str) -> ThreadType {
|
||||
match string {
|
||||
"report" => ThreadType::Report,
|
||||
"project" => ThreadType::Project,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::ids::Base62Id;
|
||||
use crate::auth::flows::AuthProvider;
|
||||
use crate::bitflags_serde_impl;
|
||||
use chrono::{DateTime, Utc};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -12,8 +13,7 @@ pub struct UserId(pub u64);
|
||||
pub const DELETED_USER: UserId = UserId(127155982985829);
|
||||
|
||||
bitflags::bitflags! {
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct Badges: u64 {
|
||||
// 1 << 0 unused - ignore + replace with something later
|
||||
const MIDAS = 1 << 0;
|
||||
@@ -29,6 +29,8 @@ bitflags::bitflags! {
|
||||
}
|
||||
}
|
||||
|
||||
bitflags_serde_impl!(Badges, u64);
|
||||
|
||||
impl Default for Badges {
|
||||
fn default() -> Badges {
|
||||
Badges::NONE
|
||||
@@ -46,12 +48,12 @@ pub struct User {
|
||||
pub role: Role,
|
||||
pub badges: Badges,
|
||||
|
||||
pub payout_data: Option<UserPayoutData>,
|
||||
pub auth_providers: Option<Vec<AuthProvider>>,
|
||||
pub email: Option<String>,
|
||||
pub email_verified: Option<bool>,
|
||||
pub has_password: Option<bool>,
|
||||
pub has_totp: Option<bool>,
|
||||
pub payout_data: Option<UserPayoutData>,
|
||||
|
||||
// DEPRECATED. Always returns None
|
||||
pub github_id: Option<u64>,
|
||||
@@ -60,77 +62,8 @@ pub struct User {
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct UserPayoutData {
|
||||
pub balance: Decimal,
|
||||
pub payout_wallet: Option<RecipientWallet>,
|
||||
pub payout_wallet_type: Option<RecipientType>,
|
||||
pub payout_address: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RecipientType {
|
||||
Email,
|
||||
Phone,
|
||||
UserHandle,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RecipientType {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
fmt.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl RecipientType {
|
||||
pub fn from_string(string: &str) -> RecipientType {
|
||||
match string {
|
||||
"user_handle" => RecipientType::UserHandle,
|
||||
"phone" => RecipientType::Phone,
|
||||
_ => RecipientType::Email,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
RecipientType::Email => "email",
|
||||
RecipientType::Phone => "phone",
|
||||
RecipientType::UserHandle => "user_handle",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RecipientWallet {
|
||||
Venmo,
|
||||
Paypal,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RecipientWallet {
|
||||
fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
fmt.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl RecipientWallet {
|
||||
pub fn from_string(string: &str) -> RecipientWallet {
|
||||
match string {
|
||||
"venmo" => RecipientWallet::Venmo,
|
||||
_ => RecipientWallet::Paypal,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
RecipientWallet::Paypal => "paypal",
|
||||
RecipientWallet::Venmo => "venmo",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str_api(&self) -> &'static str {
|
||||
match self {
|
||||
RecipientWallet::Paypal => "PayPal",
|
||||
RecipientWallet::Venmo => "Venmo",
|
||||
}
|
||||
}
|
||||
pub trolley_id: Option<String>,
|
||||
pub trolley_status: Option<RecipientStatus>,
|
||||
}
|
||||
|
||||
use crate::database::models::user_item::User as DBUser;
|
||||
@@ -201,3 +134,89 @@ impl Role {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RecipientStatus {
|
||||
Active,
|
||||
Incomplete,
|
||||
Disabled,
|
||||
Archived,
|
||||
Suspended,
|
||||
Blocked,
|
||||
}
|
||||
|
||||
impl RecipientStatus {
|
||||
pub fn from_string(string: &str) -> RecipientStatus {
|
||||
match string {
|
||||
"active" => RecipientStatus::Active,
|
||||
"incomplete" => RecipientStatus::Incomplete,
|
||||
"disabled" => RecipientStatus::Disabled,
|
||||
"archived" => RecipientStatus::Archived,
|
||||
"suspended" => RecipientStatus::Suspended,
|
||||
"blocked" => RecipientStatus::Blocked,
|
||||
_ => RecipientStatus::Disabled,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
RecipientStatus::Active => "active",
|
||||
RecipientStatus::Incomplete => "incomplete",
|
||||
RecipientStatus::Disabled => "disabled",
|
||||
RecipientStatus::Archived => "archived",
|
||||
RecipientStatus::Suspended => "suspended",
|
||||
RecipientStatus::Blocked => "blocked",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Payout {
|
||||
pub created: DateTime<Utc>,
|
||||
pub amount: Decimal,
|
||||
pub status: PayoutStatus,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, PartialEq, Eq, Clone)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PayoutStatus {
|
||||
Pending,
|
||||
Failed,
|
||||
Processed,
|
||||
Returned,
|
||||
Processing,
|
||||
}
|
||||
|
||||
impl PayoutStatus {
|
||||
pub fn from_string(string: &str) -> PayoutStatus {
|
||||
match string {
|
||||
"pending" => PayoutStatus::Pending,
|
||||
"failed" => PayoutStatus::Failed,
|
||||
"processed" => PayoutStatus::Processed,
|
||||
"returned" => PayoutStatus::Returned,
|
||||
"processing" => PayoutStatus::Processing,
|
||||
_ => PayoutStatus::Processing,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
PayoutStatus::Pending => "pending",
|
||||
PayoutStatus::Failed => "failed",
|
||||
PayoutStatus::Processed => "processed",
|
||||
PayoutStatus::Returned => "returned",
|
||||
PayoutStatus::Processing => "processing",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_failed(&self) -> bool {
|
||||
match self {
|
||||
PayoutStatus::Pending => false,
|
||||
PayoutStatus::Failed => true,
|
||||
PayoutStatus::Processed => false,
|
||||
PayoutStatus::Returned => true,
|
||||
PayoutStatus::Processing => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ pub struct AnalyticsQueue {
|
||||
playtime_queue: DashSet<Playtime>,
|
||||
}
|
||||
|
||||
impl Default for AnalyticsQueue {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// Batches analytics data points + transactions every few minutes
|
||||
impl AnalyticsQueue {
|
||||
pub fn new() -> Self {
|
||||
|
||||
@@ -6,6 +6,12 @@ pub struct DownloadQueue {
|
||||
queue: Mutex<Vec<(ProjectId, VersionId)>>,
|
||||
}
|
||||
|
||||
impl Default for DownloadQueue {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// Batches download transactions every thirty seconds
|
||||
impl DownloadQueue {
|
||||
pub fn new() -> Self {
|
||||
|
||||
@@ -1,203 +1,332 @@
|
||||
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 hex::ToHex;
|
||||
use hmac::{Hmac, Mac, NewMac};
|
||||
use reqwest::Method;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::Sha256;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct PayoutsQueue {
|
||||
credential: PaypalCredential,
|
||||
credential_expires: DateTime<Utc>,
|
||||
access_key: String,
|
||||
secret_key: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct PaypalCredential {
|
||||
access_token: String,
|
||||
token_type: String,
|
||||
expires_in: i64,
|
||||
impl Default for PayoutsQueue {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum AccountUser {
|
||||
Business { name: String },
|
||||
Individual { first: String, last: String },
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PayoutItem {
|
||||
pub amount: PayoutAmount,
|
||||
pub receiver: String,
|
||||
pub note: String,
|
||||
pub recipient_type: String,
|
||||
pub recipient_wallet: String,
|
||||
pub sender_item_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct PayoutAmount {
|
||||
pub currency: String,
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub value: Decimal,
|
||||
pub struct PaymentInfo {
|
||||
country: String,
|
||||
payout_method: String,
|
||||
route_minimum: Decimal,
|
||||
estimated_fees: Decimal,
|
||||
deduct_fees: Decimal,
|
||||
}
|
||||
|
||||
// Batches payouts and handles token refresh
|
||||
impl PayoutsQueue {
|
||||
pub fn new() -> Self {
|
||||
PayoutsQueue {
|
||||
credential: Default::default(),
|
||||
credential_expires: Utc::now() - Duration::days(30),
|
||||
access_key: dotenvy::var("TROLLEY_ACCESS_KEY").expect("missing trolley access key"),
|
||||
secret_key: dotenvy::var("TROLLEY_SECRET_KEY").expect("missing trolley secret key"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh_token(&mut self) -> Result<(), ApiError> {
|
||||
pub async fn make_trolley_request<T: Serialize, X: DeserializeOwned>(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<T>,
|
||||
) -> Result<X, ApiError> {
|
||||
let timestamp = Utc::now().timestamp();
|
||||
|
||||
let mut mac: Hmac<Sha256> = Hmac::new_from_slice(self.secret_key.as_bytes())
|
||||
.map_err(|_| ApiError::Payments("error initializing HMAC".to_string()))?;
|
||||
mac.update(
|
||||
if let Some(body) = &body {
|
||||
format!(
|
||||
"{}\n{}\n{}\n{}\n",
|
||||
timestamp,
|
||||
method.as_str(),
|
||||
path,
|
||||
serde_json::to_string(&body)?
|
||||
)
|
||||
} else {
|
||||
format!("{}\n{}\n{}\n\n", timestamp, method.as_str(), path)
|
||||
}
|
||||
.as_bytes(),
|
||||
);
|
||||
let request_signature = mac.finalize().into_bytes().encode_hex::<String>();
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let combined_key = format!(
|
||||
"{}:{}",
|
||||
dotenvy::var("PAYPAL_CLIENT_ID")?,
|
||||
dotenvy::var("PAYPAL_CLIENT_SECRET")?
|
||||
);
|
||||
let formatted_key = format!(
|
||||
"Basic {}",
|
||||
base64::engine::general_purpose::STANDARD.encode(combined_key)
|
||||
);
|
||||
let mut request = client
|
||||
.request(method, format!("https://api.trolley.com{path}"))
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("prsign {}:{}", self.access_key, request_signature),
|
||||
)
|
||||
.header("X-PR-Timestamp", timestamp);
|
||||
|
||||
let mut form = HashMap::new();
|
||||
form.insert("grant_type", "client_credentials");
|
||||
|
||||
let credential: PaypalCredential = client
|
||||
.post(&format!("{}oauth2/token", dotenvy::var("PAYPAL_API_URL")?))
|
||||
.header("Accept", "application/json")
|
||||
.header("Accept-Language", "en_US")
|
||||
.header("Authorization", formatted_key)
|
||||
.form(&form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ApiError::Payments("Error while authenticating with PayPal".to_string()))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ApiError::Payments(
|
||||
"Error while authenticating with PayPal (deser error)".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
self.credential_expires = Utc::now() + Duration::seconds(credential.expires_in);
|
||||
self.credential = credential;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_payout(&mut self, mut payout: PayoutItem) -> Result<Decimal, ApiError> {
|
||||
if self.credential_expires < Utc::now() {
|
||||
self.refresh_token().await.map_err(|_| {
|
||||
ApiError::Payments("Error while authenticating with PayPal".to_string())
|
||||
})?;
|
||||
if let Some(body) = body {
|
||||
request = request.json(&body);
|
||||
}
|
||||
|
||||
let wallet = payout.recipient_wallet.clone();
|
||||
let resp = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ApiError::Payments("could not communicate with Trolley".to_string()))?;
|
||||
|
||||
let fee = if wallet == *"Venmo" {
|
||||
Decimal::ONE / Decimal::from(4)
|
||||
} else {
|
||||
std::cmp::min(
|
||||
std::cmp::max(
|
||||
Decimal::ONE / Decimal::from(4),
|
||||
(Decimal::from(2) / Decimal::ONE_HUNDRED) * payout.amount.value,
|
||||
),
|
||||
Decimal::from(20),
|
||||
)
|
||||
};
|
||||
let value = resp.json::<Value>().await.map_err(|_| {
|
||||
ApiError::Payments("could not retrieve Trolley response body".to_string())
|
||||
})?;
|
||||
|
||||
payout.amount.value -= fee;
|
||||
payout.amount.value = payout.amount.value.round_dp(2);
|
||||
if let Some(obj) = value.as_object() {
|
||||
if !obj.get("ok").and_then(|x| x.as_bool()).unwrap_or(true) {
|
||||
#[derive(Deserialize)]
|
||||
struct TrolleyError {
|
||||
field: Option<String>,
|
||||
message: String,
|
||||
}
|
||||
|
||||
if payout.amount.value <= Decimal::ZERO {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You do not have enough funds to make this payout!".to_string(),
|
||||
if let Some(array) = obj.get("errors") {
|
||||
let err = serde_json::from_value::<Vec<TrolleyError>>(array.clone()).map_err(
|
||||
|_| {
|
||||
ApiError::Payments(
|
||||
"could not retrieve Trolley error json body".to_string(),
|
||||
)
|
||||
},
|
||||
)?;
|
||||
|
||||
if let Some(first) = err.into_iter().next() {
|
||||
return Err(ApiError::Payments(if let Some(field) = &first.field {
|
||||
format!("error - field: {field} message: {}", first.message)
|
||||
} else {
|
||||
first.message
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return Err(ApiError::Payments(
|
||||
"could not retrieve Trolley error body".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::from_value(value)?)
|
||||
}
|
||||
|
||||
pub async fn send_payout(
|
||||
&mut self,
|
||||
recipient: &str,
|
||||
amount: Decimal,
|
||||
) -> Result<(String, Option<String>), ApiError> {
|
||||
#[derive(Deserialize)]
|
||||
struct TrolleyRes {
|
||||
batch: Batch,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Batch {
|
||||
id: String,
|
||||
payments: BatchPayments,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Payment {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BatchPayments {
|
||||
payments: Vec<Payment>,
|
||||
}
|
||||
|
||||
let fee = self.get_estimated_fees(recipient, amount).await?;
|
||||
|
||||
if fee.estimated_fees > amount || fee.route_minimum > amount {
|
||||
return Err(ApiError::Payments(
|
||||
"Account balance is too low to withdraw funds".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let send_amount = amount - fee.deduct_fees;
|
||||
|
||||
let res = client.post(&format!("{}payments/payouts", dotenvy::var("PAYPAL_API_URL")?))
|
||||
.header("Authorization", format!("{} {}", self.credential.token_type, self.credential.access_token))
|
||||
.json(&json! ({
|
||||
"sender_batch_header": {
|
||||
"sender_batch_id": format!("{}-payouts", Utc::now().to_rfc3339()),
|
||||
"email_subject": "You have received a payment from Modrinth!",
|
||||
"email_message": "Thank you for creating projects on Modrinth. Please claim this payment within 30 days.",
|
||||
},
|
||||
"items": vec![payout]
|
||||
}))
|
||||
.send().await.map_err(|_| ApiError::Payments("Error while sending payout to PayPal".to_string()))?;
|
||||
let res = self
|
||||
.make_trolley_request::<_, TrolleyRes>(
|
||||
Method::POST,
|
||||
"/v1/batches/",
|
||||
Some(json!({
|
||||
"currency": "USD",
|
||||
"description": "labrinth payout",
|
||||
"payments": [{
|
||||
"recipient": {
|
||||
"id": recipient
|
||||
},
|
||||
"amount": send_amount.to_string(),
|
||||
"currency": "USD",
|
||||
"memo": "Modrinth ad revenue payout"
|
||||
}],
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
#[derive(Deserialize)]
|
||||
struct PayPalError {
|
||||
pub body: PayPalErrorBody,
|
||||
}
|
||||
self.make_trolley_request::<Value, Value>(
|
||||
Method::POST,
|
||||
&format!("/v1/batches/{}/start-processing", res.batch.id),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayPalErrorBody {
|
||||
pub message: String,
|
||||
}
|
||||
let payment_id = res.batch.payments.payments.into_iter().next().map(|x| x.id);
|
||||
|
||||
let body: PayPalError = res.json().await.map_err(|_| {
|
||||
ApiError::Payments("Error while registering payment in PayPal!".to_string())
|
||||
})?;
|
||||
Ok((res.batch.id, payment_id))
|
||||
}
|
||||
|
||||
return Err(ApiError::Payments(format!(
|
||||
"Error while registering payment in PayPal: {}",
|
||||
body.body.message
|
||||
)));
|
||||
} else if wallet != *"Venmo" {
|
||||
#[derive(Deserialize)]
|
||||
struct PayPalLink {
|
||||
href: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutsResponse {
|
||||
pub links: Vec<PayPalLink>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutDataItem {
|
||||
payout_item_fee: PayoutAmount,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutData {
|
||||
pub items: Vec<PayoutDataItem>,
|
||||
}
|
||||
|
||||
// Calculate actual fee + refund if we took too big of a fee.
|
||||
if let Ok(res) = res.json::<PayoutsResponse>().await {
|
||||
if let Some(link) = res.links.first() {
|
||||
if let Ok(res) = client
|
||||
.get(&link.href)
|
||||
.header(
|
||||
"Authorization",
|
||||
format!(
|
||||
"{} {}",
|
||||
self.credential.token_type, self.credential.access_token
|
||||
),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
if let Ok(res) = res.json::<PayoutData>().await {
|
||||
if let Some(data) = res.items.first() {
|
||||
if (fee - data.payout_item_fee.value) > Decimal::ZERO {
|
||||
return Ok(fee - data.payout_item_fee.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub async fn register_recipient(
|
||||
&self,
|
||||
email: &str,
|
||||
user: AccountUser,
|
||||
) -> Result<String, ApiError> {
|
||||
#[derive(Deserialize)]
|
||||
struct TrolleyRes {
|
||||
recipient: Recipient,
|
||||
}
|
||||
|
||||
Ok(Decimal::ZERO)
|
||||
#[derive(Deserialize)]
|
||||
struct Recipient {
|
||||
id: String,
|
||||
}
|
||||
|
||||
let id = self
|
||||
.make_trolley_request::<_, TrolleyRes>(
|
||||
Method::POST,
|
||||
"/v1/recipients/",
|
||||
Some(match user {
|
||||
AccountUser::Business { name } => json!({
|
||||
"type": "business",
|
||||
"email": email,
|
||||
"name": name,
|
||||
}),
|
||||
AccountUser::Individual { first, last } => json!({
|
||||
"type": "individual",
|
||||
"firstName": first,
|
||||
"lastName": last,
|
||||
"email": email,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(id.recipient.id)
|
||||
}
|
||||
|
||||
// lhs minimum, rhs estimate
|
||||
pub async fn get_estimated_fees(
|
||||
&self,
|
||||
id: &str,
|
||||
amount: Decimal,
|
||||
) -> Result<PaymentInfo, ApiError> {
|
||||
#[derive(Deserialize)]
|
||||
struct TrolleyRes {
|
||||
recipient: Recipient,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Recipient {
|
||||
route_minimum: Option<Decimal>,
|
||||
estimated_fees: Option<Decimal>,
|
||||
address: RecipientAddress,
|
||||
payout_method: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RecipientAddress {
|
||||
country: String,
|
||||
}
|
||||
|
||||
let id = self
|
||||
.make_trolley_request::<Value, TrolleyRes>(
|
||||
Method::GET,
|
||||
&format!("/v1/recipients/{id}"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if &id.recipient.payout_method == "paypal" {
|
||||
// based on https://www.paypal.com/us/webapps/mpp/merchant-fees. see paypal payouts section
|
||||
let fee = if &id.recipient.address.country == "US" {
|
||||
std::cmp::min(
|
||||
std::cmp::max(
|
||||
Decimal::ONE / Decimal::from(4),
|
||||
(Decimal::from(2) / Decimal::ONE_HUNDRED) * amount,
|
||||
),
|
||||
Decimal::from(1),
|
||||
)
|
||||
} else {
|
||||
std::cmp::min(
|
||||
(Decimal::from(2) / Decimal::ONE_HUNDRED) * amount,
|
||||
Decimal::from(20),
|
||||
)
|
||||
};
|
||||
|
||||
Ok(PaymentInfo {
|
||||
country: id.recipient.address.country,
|
||||
payout_method: id.recipient.payout_method,
|
||||
route_minimum: fee,
|
||||
estimated_fees: fee,
|
||||
deduct_fees: fee,
|
||||
})
|
||||
} else if &id.recipient.payout_method == "venmo" {
|
||||
let venmo_fee = Decimal::ONE / Decimal::from(4);
|
||||
|
||||
Ok(PaymentInfo {
|
||||
country: id.recipient.address.country,
|
||||
payout_method: id.recipient.payout_method,
|
||||
route_minimum: id.recipient.route_minimum.unwrap_or(Decimal::ZERO) + venmo_fee,
|
||||
estimated_fees: id.recipient.estimated_fees.unwrap_or(Decimal::ZERO) + venmo_fee,
|
||||
deduct_fees: venmo_fee,
|
||||
})
|
||||
} else {
|
||||
Ok(PaymentInfo {
|
||||
country: id.recipient.address.country,
|
||||
payout_method: id.recipient.payout_method,
|
||||
route_minimum: id.recipient.route_minimum.unwrap_or(Decimal::ZERO),
|
||||
estimated_fees: id.recipient.estimated_fees.unwrap_or(Decimal::ZERO),
|
||||
deduct_fees: Decimal::ZERO,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_recipient_email(&self, id: &str, email: &str) -> Result<(), ApiError> {
|
||||
self.make_trolley_request::<_, Value>(
|
||||
Method::PATCH,
|
||||
&format!("/v1/recipients/{}", id),
|
||||
Some(json!({
|
||||
"email": email,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +335,7 @@ pub async fn process_payout(
|
||||
redis: &RedisPool,
|
||||
client: &clickhouse::Client,
|
||||
) -> Result<(), ApiError> {
|
||||
let start: DateTime<Utc> = DateTime::from_utc(
|
||||
let start: DateTime<Utc> = DateTime::from_naive_utc_and_offset(
|
||||
(Utc::now() - Duration::days(1))
|
||||
.date_naive()
|
||||
.and_hms_nano_opt(0, 0, 0, 0)
|
||||
|
||||
@@ -13,6 +13,12 @@ pub struct AuthQueue {
|
||||
pat_queue: Mutex<HashSet<PatId>>,
|
||||
}
|
||||
|
||||
impl Default for AuthQueue {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// Batches session accessing transactions every 30 seconds
|
||||
impl AuthQueue {
|
||||
pub fn new() -> Self {
|
||||
|
||||
@@ -15,6 +15,12 @@ pub struct MemoryStore {
|
||||
inner: Arc<DashMap<String, (usize, Duration)>>,
|
||||
}
|
||||
|
||||
impl Default for MemoryStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryStore {
|
||||
/// Create a new hashmap
|
||||
///
|
||||
|
||||
@@ -6,10 +6,10 @@ use crate::queue::analytics::AnalyticsQueue;
|
||||
use crate::queue::maxmind::MaxMindIndexer;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::date::get_current_tenths_of_ms;
|
||||
use crate::util::env::parse_strings_from_var;
|
||||
use actix_web::{post, web};
|
||||
use actix_web::{HttpRequest, HttpResponse};
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
@@ -108,7 +108,7 @@ pub async fn page_view_ingest(
|
||||
|
||||
let mut view = PageView {
|
||||
id: Uuid::new_v4(),
|
||||
recorded: Utc::now().timestamp_nanos() / 100_000,
|
||||
recorded: get_current_tenths_of_ms(),
|
||||
domain: domain.to_string(),
|
||||
site_path: url.path().to_string(),
|
||||
user_id: 0,
|
||||
@@ -204,7 +204,7 @@ pub async fn playtime_ingest(
|
||||
if let Some(version) = versions.iter().find(|x| id == x.inner.id.into()) {
|
||||
analytics_queue.add_playtime(Playtime {
|
||||
id: Default::default(),
|
||||
recorded: Utc::now().timestamp_nanos() / 100_000,
|
||||
recorded: get_current_tenths_of_ms(),
|
||||
seconds: playtime.seconds as u64,
|
||||
user_id: user.id.0,
|
||||
project_id: version.inner.project_id.0 as u64,
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
use crate::auth::validate::get_user_record_from_bearer_token;
|
||||
use crate::database::models::User;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::analytics::Download;
|
||||
use crate::models::ids::ProjectId;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::users::{PayoutStatus, RecipientStatus};
|
||||
use crate::queue::analytics::AnalyticsQueue;
|
||||
use crate::queue::download::DownloadQueue;
|
||||
use crate::queue::maxmind::MaxMindIndexer;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::date::get_current_tenths_of_ms;
|
||||
use crate::util::guards::admin_key_guard;
|
||||
use actix_web::{patch, web, HttpRequest, HttpResponse};
|
||||
use chrono::Utc;
|
||||
use crate::util::routes::read_from_payload;
|
||||
use actix_web::{patch, post, web, HttpRequest, HttpResponse};
|
||||
use hex::ToHex;
|
||||
use hmac::{Hmac, Mac, NewMac};
|
||||
use serde::Deserialize;
|
||||
use sha2::Sha256;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
@@ -19,7 +25,11 @@ use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(web::scope("admin").service(count_download));
|
||||
cfg.service(
|
||||
web::scope("admin")
|
||||
.service(count_download)
|
||||
.service(trolley_webhook),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -110,7 +120,7 @@ pub async fn count_download(
|
||||
|
||||
analytics_queue.add_download(Download {
|
||||
id: Uuid::new_v4(),
|
||||
recorded: Utc::now().timestamp_nanos() / 100_000,
|
||||
recorded: get_current_tenths_of_ms(),
|
||||
domain: url.host_str().unwrap_or_default().to_string(),
|
||||
site_path: url.path().to_string(),
|
||||
user_id: user
|
||||
@@ -141,3 +151,171 @@ pub async fn count_download(
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TrolleyWebhook {
|
||||
model: String,
|
||||
action: String,
|
||||
body: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[post("/_trolley")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn trolley_webhook(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
mut payload: web::Payload,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
if let Some(signature) = req.headers().get("X-PaymentRails-Signature") {
|
||||
let payload = read_from_payload(
|
||||
&mut payload,
|
||||
1 << 20,
|
||||
"Webhook payload exceeds the maximum of 1MiB.",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut signature = signature.to_str().ok().unwrap_or_default().split(',');
|
||||
let timestamp = signature
|
||||
.next()
|
||||
.and_then(|x| x.split('=').nth(1))
|
||||
.unwrap_or_default();
|
||||
let v1 = signature
|
||||
.next()
|
||||
.and_then(|x| x.split('=').nth(1))
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut mac: Hmac<Sha256> =
|
||||
Hmac::new_from_slice(dotenvy::var("TROLLEY_WEBHOOK_SIGNATURE")?.as_bytes())
|
||||
.map_err(|_| ApiError::Payments("error initializing HMAC".to_string()))?;
|
||||
mac.update(timestamp.as_bytes());
|
||||
mac.update(&payload);
|
||||
let request_signature = mac.finalize().into_bytes().encode_hex::<String>();
|
||||
|
||||
if &*request_signature == v1 {
|
||||
let webhook = serde_json::from_slice::<TrolleyWebhook>(&payload)?;
|
||||
|
||||
if webhook.model == "recipient" {
|
||||
#[derive(Deserialize)]
|
||||
struct Recipient {
|
||||
pub id: String,
|
||||
pub email: Option<String>,
|
||||
pub status: Option<RecipientStatus>,
|
||||
}
|
||||
|
||||
if let Some(body) = webhook.body.get("recipient") {
|
||||
if let Ok(recipient) = serde_json::from_value::<Recipient>(body.clone()) {
|
||||
let value = sqlx::query!(
|
||||
"SELECT id FROM users WHERE trolley_id = $1",
|
||||
recipient.id
|
||||
)
|
||||
.fetch_optional(&**pool)
|
||||
.await?;
|
||||
|
||||
if let Some(user) = value {
|
||||
let user = User::get_id(
|
||||
crate::database::models::UserId(user.id),
|
||||
&**pool,
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(user) = user {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
if webhook.action == "deleted" {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET trolley_account_status = NULL, trolley_id = NULL
|
||||
WHERE id = $1
|
||||
",
|
||||
user.id.0
|
||||
)
|
||||
.execute(&mut transaction)
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET email = $1, email_verified = $2, trolley_account_status = $3
|
||||
WHERE id = $4
|
||||
",
|
||||
recipient.email.clone(),
|
||||
user.email_verified && recipient.email == user.email,
|
||||
recipient.status.map(|x| x.as_str()),
|
||||
user.id.0
|
||||
)
|
||||
.execute(&mut transaction).await?;
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
User::clear_caches(&[(user.id, None)], &redis).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if webhook.model == "payment" {
|
||||
#[derive(Deserialize)]
|
||||
struct Payment {
|
||||
pub id: String,
|
||||
pub status: PayoutStatus,
|
||||
}
|
||||
|
||||
if let Some(body) = webhook.body.get("payment") {
|
||||
if let Ok(payment) = serde_json::from_value::<Payment>(body.clone()) {
|
||||
let value = sqlx::query!(
|
||||
"SELECT id, amount, user_id, status FROM historical_payouts WHERE payment_id = $1",
|
||||
payment.id
|
||||
)
|
||||
.fetch_optional(&**pool)
|
||||
.await?;
|
||||
|
||||
if let Some(payout) = value {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
if payment.status.is_failed()
|
||||
&& !PayoutStatus::from_string(&payout.status).is_failed()
|
||||
{
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET balance = balance + $1
|
||||
WHERE id = $2
|
||||
",
|
||||
payout.amount,
|
||||
payout.user_id,
|
||||
)
|
||||
.execute(&mut transaction)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE historical_payouts
|
||||
SET status = $1
|
||||
WHERE payment_id = $2
|
||||
",
|
||||
payment.status.as_str(),
|
||||
payment.id,
|
||||
)
|
||||
.execute(&mut transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
User::clear_caches(
|
||||
&[(crate::database::models::UserId(payout.user_id), None)],
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
.configure(moderation::config)
|
||||
.configure(notifications::config)
|
||||
.configure(organizations::config)
|
||||
//.configure(pats::config)
|
||||
.configure(project_creation::config)
|
||||
.configure(collections::config)
|
||||
.configure(images::config)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::auth::{get_user_from_headers, AuthenticationError};
|
||||
use crate::auth::get_user_from_headers;
|
||||
use crate::database::models::User;
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::file_hosting::FileHost;
|
||||
@@ -6,14 +6,15 @@ use crate::models::collections::{Collection, CollectionStatus};
|
||||
use crate::models::notifications::Notification;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::projects::Project;
|
||||
use crate::models::users::{Badges, RecipientType, RecipientWallet, Role, UserId};
|
||||
use crate::queue::payouts::{PayoutAmount, PayoutItem, PayoutsQueue};
|
||||
use crate::models::users::{
|
||||
Badges, Payout, PayoutStatus, RecipientStatus, Role, UserId, UserPayoutData,
|
||||
};
|
||||
use crate::queue::payouts::PayoutsQueue;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::routes::read_from_payload;
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
|
||||
use chrono::{DateTime, Utc};
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -39,6 +40,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.service(user_notifications)
|
||||
.service(user_follows)
|
||||
.service(user_payouts)
|
||||
.service(user_payouts_fees)
|
||||
.service(user_payouts_request),
|
||||
);
|
||||
}
|
||||
@@ -218,21 +220,6 @@ pub struct EditUser {
|
||||
pub bio: Option<Option<String>>,
|
||||
pub role: Option<Role>,
|
||||
pub badges: Option<Badges>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
with = "::serde_with::rust::double_option"
|
||||
)]
|
||||
#[validate]
|
||||
pub payout_data: Option<Option<EditPayoutData>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Validate)]
|
||||
pub struct EditPayoutData {
|
||||
pub payout_wallet: RecipientWallet,
|
||||
pub payout_wallet_type: RecipientType,
|
||||
#[validate(length(max = 128))]
|
||||
pub payout_address: String,
|
||||
}
|
||||
|
||||
#[patch("{id}")]
|
||||
@@ -244,7 +231,7 @@ pub async fn user_edit(
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let (scopes, user) = get_user_from_headers(
|
||||
let (_scopes, user) = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
@@ -364,79 +351,6 @@ pub async fn user_edit(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(payout_data) = &new_user.payout_data {
|
||||
if let Some(payout_data) = payout_data {
|
||||
if payout_data.payout_wallet_type == RecipientType::UserHandle
|
||||
&& payout_data.payout_wallet == RecipientWallet::Paypal
|
||||
{
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You cannot use a paypal wallet with a user handle!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !scopes.contains(Scopes::PAYOUTS_WRITE) {
|
||||
return Err(ApiError::Authentication(
|
||||
AuthenticationError::InvalidCredentials,
|
||||
));
|
||||
}
|
||||
|
||||
if !match payout_data.payout_wallet_type {
|
||||
RecipientType::Email => {
|
||||
validator::validate_email(&payout_data.payout_address)
|
||||
}
|
||||
RecipientType::Phone => {
|
||||
validator::validate_phone(&payout_data.payout_address)
|
||||
}
|
||||
RecipientType::UserHandle => true,
|
||||
} {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Invalid wallet specified!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let results = sqlx::query!(
|
||||
"
|
||||
SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND email IS NULL)
|
||||
",
|
||||
id as crate::database::models::ids::UserId,
|
||||
)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if results.exists.unwrap_or(false) {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You must have an email set on your Modrinth account to enroll in the monetization program!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET payout_wallet = $1, payout_wallet_type = $2, payout_address = $3
|
||||
WHERE (id = $4)
|
||||
",
|
||||
payout_data.payout_wallet.as_str(),
|
||||
payout_data.payout_wallet_type.as_str(),
|
||||
payout_data.payout_address,
|
||||
id as crate::database::models::ids::UserId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET payout_wallet = NULL, payout_wallet_type = NULL, payout_address = NULL
|
||||
WHERE (id = $1)
|
||||
",
|
||||
id as crate::database::models::ids::UserId,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
User::clear_caches(&[(id, Some(actual_user.username))], &redis).await?;
|
||||
transaction.commit().await?;
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
@@ -691,13 +605,6 @@ pub async fn user_notifications(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Payout {
|
||||
pub created: DateTime<Utc>,
|
||||
pub amount: Decimal,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[get("{id}/payouts")]
|
||||
pub async fn user_payouts(
|
||||
req: HttpRequest,
|
||||
@@ -757,7 +664,7 @@ pub async fn user_payouts(
|
||||
Ok(e.right().map(|row| Payout {
|
||||
created: row.created,
|
||||
amount: row.amount,
|
||||
status: row.status,
|
||||
status: PayoutStatus::from_string(&row.status),
|
||||
}))
|
||||
})
|
||||
.try_collect::<Vec<Payout>>(),
|
||||
@@ -776,6 +683,61 @@ pub async fn user_payouts(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FeeEstimateAmount {
|
||||
amount: Decimal,
|
||||
}
|
||||
|
||||
#[get("{id}/payouts_fees")]
|
||||
pub async fn user_payouts_fees(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
web::Query(amount): web::Query<FeeEstimateAmount>,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
payouts_queue: web::Data<Mutex<PayoutsQueue>>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::PAYOUTS_READ]),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
let actual_user = User::get(&info.into_inner().0, &**pool, &redis).await?;
|
||||
|
||||
if let Some(actual_user) = actual_user {
|
||||
if !user.role.is_admin() && user.id != actual_user.id.into() {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to request payouts of this user!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(UserPayoutData {
|
||||
trolley_id: Some(trolley_id),
|
||||
..
|
||||
}) = user.payout_data
|
||||
{
|
||||
let payouts = payouts_queue
|
||||
.lock()
|
||||
.await
|
||||
.get_estimated_fees(&trolley_id, amount.amount)
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(payouts))
|
||||
} else {
|
||||
Err(ApiError::InvalidInput(
|
||||
"You must set up your trolley account first!".to_string(),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Ok(HttpResponse::NotFound().body(""))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PayoutData {
|
||||
amount: Decimal,
|
||||
@@ -811,67 +773,60 @@ pub async fn user_payouts_request(
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(payouts_data) = user.payout_data {
|
||||
if let Some(payout_address) = payouts_data.payout_address {
|
||||
if let Some(payout_wallet_type) = payouts_data.payout_wallet_type {
|
||||
if let Some(payout_wallet) = payouts_data.payout_wallet {
|
||||
return if data.amount < payouts_data.balance {
|
||||
let mut transaction = pool.begin().await?;
|
||||
if let Some(UserPayoutData {
|
||||
trolley_id: Some(trolley_id),
|
||||
trolley_status: Some(trolley_status),
|
||||
balance,
|
||||
..
|
||||
}) = user.payout_data
|
||||
{
|
||||
if trolley_status == RecipientStatus::Active {
|
||||
return if data.amount < balance {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let leftover = payouts_queue
|
||||
.send_payout(PayoutItem {
|
||||
amount: PayoutAmount {
|
||||
currency: "USD".to_string(),
|
||||
value: data.amount,
|
||||
},
|
||||
receiver: payout_address,
|
||||
note: "Payment from Modrinth creator monetization program"
|
||||
.to_string(),
|
||||
recipient_type: payout_wallet_type.to_string().to_uppercase(),
|
||||
recipient_wallet: payout_wallet.as_str_api().to_string(),
|
||||
sender_item_id: format!(
|
||||
"{}-{}",
|
||||
UserId::from(id),
|
||||
Utc::now().timestamp()
|
||||
),
|
||||
})
|
||||
.await?;
|
||||
let (batch_id, payment_id) =
|
||||
payouts_queue.send_payout(&trolley_id, data.amount).await?;
|
||||
|
||||
sqlx::query!(
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO historical_payouts (user_id, amount, status)
|
||||
VALUES ($1, $2, $3)
|
||||
INSERT INTO historical_payouts (user_id, amount, status, batch_id, payment_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
",
|
||||
id as crate::database::models::ids::UserId,
|
||||
data.amount,
|
||||
"success"
|
||||
"processing",
|
||||
batch_id,
|
||||
payment_id,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET balance = balance - $1
|
||||
WHERE id = $2
|
||||
",
|
||||
data.amount - leftover,
|
||||
id as crate::database::models::ids::UserId
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
User::clear_caches(&[(id, None)], &redis).await?;
|
||||
data.amount,
|
||||
id as crate::database::models::ids::UserId
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
User::clear_caches(&[(id, None)], &redis).await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Err(ApiError::InvalidInput(
|
||||
"You do not have enough funds to make this payout!".to_string(),
|
||||
))
|
||||
};
|
||||
}
|
||||
}
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Err(ApiError::InvalidInput(
|
||||
"You do not have enough funds to make this payout!".to_string(),
|
||||
))
|
||||
};
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Please complete payout information via the trolley dashboard!".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,12 @@ pub struct Scheduler {
|
||||
arbiter: Arbiter,
|
||||
}
|
||||
|
||||
impl Default for Scheduler {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Scheduler {
|
||||
pub fn new() -> Self {
|
||||
Scheduler {
|
||||
|
||||
18
src/util/bitflag.rs
Normal file
18
src/util/bitflag.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
#[macro_export]
|
||||
macro_rules! bitflags_serde_impl {
|
||||
($type:ident, $int_type:ident) => {
|
||||
impl serde::Serialize for $type {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_i64(self.bits() as i64)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for $type {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let v: i64 = Deserialize::deserialize(deserializer)?;
|
||||
|
||||
Ok($type::from_bits_truncate(v as $int_type))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
9
src/util/date.rs
Normal file
9
src/util/date.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use chrono::Utc;
|
||||
|
||||
// this converts timestamps to the timestamp format clickhouse requires/uses
|
||||
pub fn get_current_tenths_of_ms() -> i64 {
|
||||
Utc::now()
|
||||
.timestamp_nanos_opt()
|
||||
.expect("value can not be represented in a timestamp with nanosecond precision.")
|
||||
/ 100_000
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod bitflag;
|
||||
pub mod captcha;
|
||||
pub mod cors;
|
||||
pub mod date;
|
||||
pub mod env;
|
||||
pub mod ext;
|
||||
pub mod guards;
|
||||
|
||||
@@ -20,7 +20,7 @@ impl super::Validator for FabricValidator {
|
||||
|
||||
fn get_supported_game_versions(&self) -> SupportedGameVersions {
|
||||
// Time since release of 18w49a, the first fabric version
|
||||
SupportedGameVersions::PastDate(DateTime::from_utc(
|
||||
SupportedGameVersions::PastDate(DateTime::from_naive_utc_and_offset(
|
||||
NaiveDateTime::from_timestamp_opt(1543969469, 0).unwrap(),
|
||||
Utc,
|
||||
))
|
||||
|
||||
@@ -20,7 +20,7 @@ impl super::Validator for ForgeValidator {
|
||||
|
||||
fn get_supported_game_versions(&self) -> SupportedGameVersions {
|
||||
// Time since release of 1.13, the first forge version which uses the new TOML system
|
||||
SupportedGameVersions::PastDate(DateTime::<Utc>::from_utc(
|
||||
SupportedGameVersions::PastDate(DateTime::<Utc>::from_naive_utc_and_offset(
|
||||
NaiveDateTime::from_timestamp_opt(1540122067, 0).unwrap(),
|
||||
Utc,
|
||||
))
|
||||
@@ -58,11 +58,11 @@ impl super::Validator for LegacyForgeValidator {
|
||||
fn get_supported_game_versions(&self) -> SupportedGameVersions {
|
||||
// Times between versions 1.5.2 to 1.12.2, which all use the legacy way of defining mods
|
||||
SupportedGameVersions::Range(
|
||||
DateTime::from_utc(
|
||||
DateTime::from_naive_utc_and_offset(
|
||||
NaiveDateTime::from_timestamp_opt(1366818300, 0).unwrap(),
|
||||
Utc,
|
||||
),
|
||||
DateTime::from_utc(
|
||||
DateTime::from_naive_utc_and_offset(
|
||||
NaiveDateTime::from_timestamp_opt(1505810340, 0).unwrap(),
|
||||
Utc,
|
||||
),
|
||||
|
||||
@@ -19,7 +19,7 @@ impl super::Validator for QuiltValidator {
|
||||
}
|
||||
|
||||
fn get_supported_game_versions(&self) -> SupportedGameVersions {
|
||||
SupportedGameVersions::PastDate(DateTime::from_utc(
|
||||
SupportedGameVersions::PastDate(DateTime::from_naive_utc_and_offset(
|
||||
NaiveDateTime::from_timestamp_opt(1646070100, 0).unwrap(),
|
||||
Utc,
|
||||
))
|
||||
|
||||
@@ -20,7 +20,7 @@ impl super::Validator for PackValidator {
|
||||
|
||||
fn get_supported_game_versions(&self) -> SupportedGameVersions {
|
||||
// Time since release of 13w24a which replaced texture packs with resource packs
|
||||
SupportedGameVersions::PastDate(DateTime::from_utc(
|
||||
SupportedGameVersions::PastDate(DateTime::from_naive_utc_and_offset(
|
||||
NaiveDateTime::from_timestamp_opt(1371137542, 0).unwrap(),
|
||||
Utc,
|
||||
))
|
||||
@@ -58,11 +58,11 @@ impl super::Validator for TexturePackValidator {
|
||||
fn get_supported_game_versions(&self) -> SupportedGameVersions {
|
||||
// a1.2.2a to 13w23b
|
||||
SupportedGameVersions::Range(
|
||||
DateTime::from_utc(
|
||||
DateTime::from_naive_utc_and_offset(
|
||||
NaiveDateTime::from_timestamp_opt(1289339999, 0).unwrap(),
|
||||
Utc,
|
||||
),
|
||||
DateTime::from_utc(
|
||||
DateTime::from_naive_utc_and_offset(
|
||||
NaiveDateTime::from_timestamp_opt(1370651522, 0).unwrap(),
|
||||
Utc,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user