You've already forked AstralRinth
forked from didirus/AstralRinth
Payouts fees changes (#478)
* Payouts fees changes * Update src/queue/payouts.rs Co-authored-by: triphora <emmaffle@modrinth.com> Co-authored-by: triphora <emmaffle@modrinth.com>
This commit is contained in:
@@ -66,4 +66,4 @@ env_logger = "0.9.1"
|
|||||||
thiserror = "1.0.37"
|
thiserror = "1.0.37"
|
||||||
|
|
||||||
sqlx = { version = "0.6.2", features = ["runtime-actix-rustls", "postgres", "chrono", "offline", "macros", "migrate", "decimal"] }
|
sqlx = { version = "0.6.2", features = ["runtime-actix-rustls", "postgres", "chrono", "offline", "macros", "migrate", "decimal"] }
|
||||||
rust_decimal = { version = "1.26", features = ["serde-with-float"] }
|
rust_decimal = { version = "1.26", features = ["serde-with-float", "serde-with-str"] }
|
||||||
2
migrations/20221111202802_fix-precision-again.sql
Normal file
2
migrations/20221111202802_fix-precision-again.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add migration script here
|
||||||
|
ALTER TABLE users ALTER balance TYPE numeric(40, 20);
|
||||||
@@ -31,6 +31,26 @@
|
|||||||
},
|
},
|
||||||
"query": "\n INSERT INTO users (\n id, github_id, username, name, email,\n avatar_url, bio, created\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8\n )\n "
|
"query": "\n INSERT INTO users (\n id, github_id, username, name, email,\n avatar_url, bio, created\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8\n )\n "
|
||||||
},
|
},
|
||||||
|
"03284fe5b045e2cf93f160863c4d121439382b348b728fffb5ac588dee980731": {
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "exists",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Bool"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"query": "\n SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND email IS NULL)\n "
|
||||||
|
},
|
||||||
"041f499f542ddab1b81bd445d6cabe225b1b2ad3ec7bbc1f755346c016ae06e6": {
|
"041f499f542ddab1b81bd445d6cabe225b1b2ad3ec7bbc1f755346c016ae06e6": {
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::routes::ApiError;
|
use crate::routes::ApiError;
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -26,10 +27,11 @@ pub struct PayoutItem {
|
|||||||
pub sender_item_id: String,
|
pub sender_item_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct PayoutAmount {
|
pub struct PayoutAmount {
|
||||||
pub currency: String,
|
pub currency: String,
|
||||||
pub value: String,
|
#[serde(with = "rust_decimal::serde::str")]
|
||||||
|
pub value: Decimal,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batches payouts and handles token refresh
|
// Batches payouts and handles token refresh
|
||||||
@@ -85,8 +87,8 @@ impl PayoutsQueue {
|
|||||||
|
|
||||||
pub async fn send_payout(
|
pub async fn send_payout(
|
||||||
&mut self,
|
&mut self,
|
||||||
payout: PayoutItem,
|
mut payout: PayoutItem,
|
||||||
) -> Result<(), ApiError> {
|
) -> Result<Decimal, ApiError> {
|
||||||
if self.credential_expires < Utc::now() {
|
if self.credential_expires < Utc::now() {
|
||||||
self.refresh_token().await.map_err(|_| {
|
self.refresh_token().await.map_err(|_| {
|
||||||
ApiError::Payments(
|
ApiError::Payments(
|
||||||
@@ -95,6 +97,22 @@ impl PayoutsQueue {
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fee = std::cmp::min(
|
||||||
|
std::cmp::max(
|
||||||
|
Decimal::ONE / Decimal::from(4),
|
||||||
|
(Decimal::from(2) / Decimal::ONE_HUNDRED) * payout.amount.value,
|
||||||
|
),
|
||||||
|
Decimal::from(20),
|
||||||
|
);
|
||||||
|
|
||||||
|
payout.amount.value -= fee;
|
||||||
|
|
||||||
|
if payout.amount.value <= Decimal::ZERO {
|
||||||
|
return Err(ApiError::InvalidInput(
|
||||||
|
"You do not have enough funds to make this payout!".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
let res = client.post(&format!("{}payments/payouts", dotenvy::var("PAYPAL_API_URL")?))
|
let res = client.post(&format!("{}payments/payouts", dotenvy::var("PAYPAL_API_URL")?))
|
||||||
@@ -130,8 +148,58 @@ impl PayoutsQueue {
|
|||||||
"Error while registering payment in PayPal: {}",
|
"Error while registering payment in PayPal: {}",
|
||||||
body.body.message
|
body.body.message
|
||||||
)));
|
)));
|
||||||
|
} else {
|
||||||
|
#[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 Some(res) = res.json::<PayoutsResponse>().await.ok() {
|
||||||
|
if let Some(link) = res.links.first() {
|
||||||
|
if let Some(res) = client
|
||||||
|
.get(&link.href)
|
||||||
|
.header(
|
||||||
|
"Authorization",
|
||||||
|
format!(
|
||||||
|
"{} {}",
|
||||||
|
self.credential.token_type,
|
||||||
|
self.credential.access_token
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
{
|
||||||
|
if let Some(res) = res.json::<PayoutData>().await.ok() {
|
||||||
|
if let Some(data) = res.items.first() {
|
||||||
|
if (fee - data.payout_item_fee.value)
|
||||||
|
> Decimal::ZERO
|
||||||
|
{
|
||||||
|
return Ok(fee - data.payout_item_fee.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(Decimal::ZERO)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ pub async fn process_payout(
|
|||||||
let members_sum: Decimal =
|
let members_sum: Decimal =
|
||||||
members.iter().map(|x| x.1).sum();
|
members.iter().map(|x| x.1).sum();
|
||||||
|
|
||||||
if members_sum > Decimal::from(0) {
|
if members_sum > Decimal::ZERO {
|
||||||
for member in members {
|
for member in members {
|
||||||
let member_multiplier: Decimal =
|
let member_multiplier: Decimal =
|
||||||
member.1 / members_sum;
|
member.1 / members_sum;
|
||||||
@@ -287,8 +287,8 @@ pub async fn process_payout(
|
|||||||
let project_multiplier: Decimal =
|
let project_multiplier: Decimal =
|
||||||
Decimal::from(**value) / Decimal::from(multipliers.sum);
|
Decimal::from(**value) / Decimal::from(multipliers.sum);
|
||||||
|
|
||||||
let default_split_given = Decimal::from(1);
|
let default_split_given = Decimal::ONE;
|
||||||
let split_given = Decimal::from(1) / Decimal::from(5);
|
let split_given = Decimal::ONE / Decimal::from(5);
|
||||||
let split_retention = Decimal::from(4) / Decimal::from(5);
|
let split_retention = Decimal::from(4) / Decimal::from(5);
|
||||||
|
|
||||||
let sum_splits: Decimal =
|
let sum_splits: Decimal =
|
||||||
@@ -296,7 +296,7 @@ pub async fn process_payout(
|
|||||||
let sum_tm_splits: Decimal =
|
let sum_tm_splits: Decimal =
|
||||||
project.split_team_members.iter().map(|x| x.1).sum();
|
project.split_team_members.iter().map(|x| x.1).sum();
|
||||||
|
|
||||||
if sum_splits > Decimal::from(0) {
|
if sum_splits > Decimal::ZERO {
|
||||||
for (user_id, split) in project.team_members {
|
for (user_id, split) in project.team_members {
|
||||||
let payout: Decimal = data.amount
|
let payout: Decimal = data.amount
|
||||||
* project_multiplier
|
* project_multiplier
|
||||||
@@ -307,7 +307,7 @@ pub async fn process_payout(
|
|||||||
&default_split_given
|
&default_split_given
|
||||||
});
|
});
|
||||||
|
|
||||||
if payout > Decimal::from(0) {
|
if payout > Decimal::ZERO {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"
|
"
|
||||||
INSERT INTO payouts_values (user_id, mod_id, amount, created)
|
INSERT INTO payouts_values (user_id, mod_id, amount, created)
|
||||||
@@ -336,14 +336,14 @@ pub async fn process_payout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if sum_tm_splits > Decimal::from(0) {
|
if sum_tm_splits > Decimal::ZERO {
|
||||||
for (user_id, split, project_id) in project.split_team_members {
|
for (user_id, split, project_id) in project.split_team_members {
|
||||||
let payout: Decimal = data.amount
|
let payout: Decimal = data.amount
|
||||||
* project_multiplier
|
* project_multiplier
|
||||||
* (split / sum_tm_splits)
|
* (split / sum_tm_splits)
|
||||||
* split_retention;
|
* split_retention;
|
||||||
|
|
||||||
if payout > Decimal::from(0) {
|
if payout > Decimal::ZERO {
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"
|
"
|
||||||
INSERT INTO payouts_values (user_id, mod_id, amount, created)
|
INSERT INTO payouts_values (user_id, mod_id, amount, created)
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ pub async fn auth_callback(
|
|||||||
created: Utc::now(),
|
created: Utc::now(),
|
||||||
role: Role::Developer.to_string(),
|
role: Role::Developer.to_string(),
|
||||||
badges: Badges::default(),
|
badges: Badges::default(),
|
||||||
balance: Decimal::from(0),
|
balance: Decimal::ZERO,
|
||||||
payout_wallet: None,
|
payout_wallet: None,
|
||||||
payout_wallet_type: None,
|
payout_wallet_type: None,
|
||||||
payout_address: None,
|
payout_address: None,
|
||||||
|
|||||||
@@ -627,7 +627,7 @@ pub async fn project_create_inner(
|
|||||||
role: crate::models::teams::OWNER_ROLE.to_owned(),
|
role: crate::models::teams::OWNER_ROLE.to_owned(),
|
||||||
permissions: crate::models::teams::Permissions::ALL,
|
permissions: crate::models::teams::Permissions::ALL,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
payouts_split: Decimal::from(100),
|
payouts_split: Decimal::ONE_HUNDRED,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ pub async fn add_team_member(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if new_member.payouts_split < Decimal::from(0)
|
if new_member.payouts_split < Decimal::ZERO
|
||||||
|| new_member.payouts_split > Decimal::from(5000)
|
|| new_member.payouts_split > Decimal::from(5000)
|
||||||
{
|
{
|
||||||
return Err(ApiError::InvalidInput(
|
return Err(ApiError::InvalidInput(
|
||||||
@@ -425,8 +425,7 @@ pub async fn edit_team_member(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(payouts_split) = edit_member.payouts_split {
|
if let Some(payouts_split) = edit_member.payouts_split {
|
||||||
if payouts_split < Decimal::from(0)
|
if payouts_split < Decimal::ZERO || payouts_split > Decimal::from(5000)
|
||||||
|| payouts_split > Decimal::from(5000)
|
|
||||||
{
|
{
|
||||||
return Err(ApiError::InvalidInput(
|
return Err(ApiError::InvalidInput(
|
||||||
"Payouts split must be between 0 and 5000!".to_string(),
|
"Payouts split must be between 0 and 5000!".to_string(),
|
||||||
|
|||||||
@@ -345,6 +345,22 @@ pub async fn user_edit(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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!(
|
sqlx::query!(
|
||||||
"
|
"
|
||||||
UPDATE users
|
UPDATE users
|
||||||
@@ -720,13 +736,25 @@ pub async fn user_payouts_request(
|
|||||||
payouts_data.payout_wallet_type
|
payouts_data.payout_wallet_type
|
||||||
{
|
{
|
||||||
if let Some(payout_wallet) = payouts_data.payout_wallet {
|
if let Some(payout_wallet) = payouts_data.payout_wallet {
|
||||||
let paypal_fee = Decimal::from(1) / Decimal::from(4);
|
return if data.amount < payouts_data.balance {
|
||||||
|
|
||||||
return if data.amount < payouts_data.balance
|
|
||||||
&& data.amount > paypal_fee
|
|
||||||
{
|
|
||||||
let mut transaction = pool.begin().await?;
|
let mut transaction = pool.begin().await?;
|
||||||
|
|
||||||
|
let mut payouts_queue = payouts_queue.lock().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?;
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"
|
"
|
||||||
INSERT INTO historical_payouts (user_id, amount, status)
|
INSERT INTO historical_payouts (user_id, amount, status)
|
||||||
@@ -745,27 +773,12 @@ pub async fn user_payouts_request(
|
|||||||
SET balance = balance - $1
|
SET balance = balance - $1
|
||||||
WHERE id = $2
|
WHERE id = $2
|
||||||
",
|
",
|
||||||
data.amount,
|
data.amount - leftover,
|
||||||
id as crate::database::models::ids::UserId
|
id as crate::database::models::ids::UserId
|
||||||
)
|
)
|
||||||
.execute(&mut *transaction)
|
.execute(&mut *transaction)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut payouts_queue = payouts_queue.lock().await;
|
|
||||||
payouts_queue
|
|
||||||
.send_payout(PayoutItem {
|
|
||||||
amount: PayoutAmount {
|
|
||||||
currency: "USD".to_string(),
|
|
||||||
value: (data.amount - paypal_fee).to_string(),
|
|
||||||
},
|
|
||||||
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?;
|
|
||||||
|
|
||||||
transaction.commit().await?;
|
transaction.commit().await?;
|
||||||
|
|
||||||
Ok(HttpResponse::NoContent().body(""))
|
Ok(HttpResponse::NoContent().body(""))
|
||||||
|
|||||||
Reference in New Issue
Block a user