You've already forked AstralRinth
forked from didirus/AstralRinth
Mural Pay integration (#4520)
* wip: muralpay integration * Basic Mural Pay API bindings * Fix clippy * use dotenvy in muralpay example * Refactor payout creation code * wip: muralpay payout requests * Mural Pay payouts work * Fix clippy * add mural pay fees API * Work on payout fee API * Fees API for more payment methods * Fix CI * Temporarily disable Venmo and PayPal methods from frontend * wip: counterparties * Start on counterparties and payment methods API * Mural Pay multiple methods when fetching * Don't send supported_countries to frontend * Add countries to muralpay fiat methods * Compile fix * Add exchange rate info to fees endpoint * Add fees to premium Tremendous options * Add delivery email field to Tremendous payouts * Add Tremendous product category to payout methods * Add bank details API to muralpay * Fix CI * Fix CI * Remove prepaid visa, compute fees properly for Tremendous methods * Add more details to Tremendous errors * Add fees to Mural * Payout history route and bank details * Re-add legacy PayPal/Venmo options for US * move the mural bank details route * Add utoipa support to payout endpoints * address some PR comments * add CORS to new utoipa routes * Immediately approve mural payouts * Add currency support to Tremendous payouts * Currency forex * add forex to tremendous fee request * Add Mural balance to bank balance info * Add more Tremendous currencies support * Transaction payouts available use the correct date * Address my own review comment * Address PR comments * Change Mural withdrawal limit to 3k * maybe fix tremendous gift cards * Change how Mural minimum withdrawals are calculated * Tweak min/max withdrawal values --------- Co-authored-by: Calum H. <contact@cal.engineer> Co-authored-by: Alejandro González <me@alegon.dev>
This commit is contained in:
@@ -7,6 +7,7 @@ pub mod gdpr;
|
||||
pub mod gotenberg;
|
||||
pub mod medal;
|
||||
pub mod moderation;
|
||||
pub mod mural;
|
||||
pub mod pats;
|
||||
pub mod session;
|
||||
pub mod statuses;
|
||||
@@ -31,6 +32,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
.configure(statuses::config)
|
||||
.configure(medal::config)
|
||||
.configure(external_notifications::config)
|
||||
.configure(affiliate::config),
|
||||
.configure(affiliate::config)
|
||||
.configure(mural::config),
|
||||
);
|
||||
}
|
||||
|
||||
28
apps/labrinth/src/routes/internal/mural.rs
Normal file
28
apps/labrinth/src/routes/internal/mural.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use actix_web::{get, web};
|
||||
use muralpay::FiatAndRailCode;
|
||||
use strum::IntoEnumIterator;
|
||||
|
||||
use crate::{
|
||||
queue::payouts::PayoutsQueue, routes::ApiError, util::error::Context,
|
||||
};
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(get_bank_details);
|
||||
}
|
||||
|
||||
#[get("/mural/bank-details")]
|
||||
async fn get_bank_details(
|
||||
payouts_queue: web::Data<PayoutsQueue>,
|
||||
) -> Result<web::Json<muralpay::BankDetailsResponse>, ApiError> {
|
||||
let mural = payouts_queue.muralpay.load();
|
||||
let mural = mural
|
||||
.as_ref()
|
||||
.wrap_internal_err("Mural API not available")?;
|
||||
let fiat_and_rail_codes = FiatAndRailCode::iter().collect::<Vec<_>>();
|
||||
let details = mural
|
||||
.client
|
||||
.get_bank_details(&fiat_and_rail_codes)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch bank details")?;
|
||||
Ok(web::Json(details))
|
||||
}
|
||||
@@ -85,12 +85,18 @@ pub fn root_config(cfg: &mut web::ServiceConfig) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Error when calling an HTTP endpoint.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ApiError {
|
||||
/// Error occurred on the server side, which the caller has no fault in.
|
||||
#[error(transparent)]
|
||||
Internal(eyre::Report),
|
||||
/// Caller made an invalid or malformed request.
|
||||
#[error(transparent)]
|
||||
Request(eyre::Report),
|
||||
/// Caller attempted a request which they are not allowed to make.
|
||||
#[error(transparent)]
|
||||
Auth(eyre::Report),
|
||||
#[error("Invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
#[error("Environment error")]
|
||||
@@ -161,41 +167,47 @@ impl ApiError {
|
||||
pub fn as_api_error<'a>(&self) -> crate::models::error::ApiError<'a> {
|
||||
crate::models::error::ApiError {
|
||||
error: match self {
|
||||
ApiError::Internal(..) => "internal_error",
|
||||
Self::Internal(..) => "internal_error",
|
||||
Self::Request(..) => "request_error",
|
||||
ApiError::Env(..) => "environment_error",
|
||||
ApiError::Database(..) => "database_error",
|
||||
ApiError::SqlxDatabase(..) => "database_error",
|
||||
ApiError::RedisDatabase(..) => "database_error",
|
||||
ApiError::Authentication(..) => "unauthorized",
|
||||
ApiError::CustomAuthentication(..) => "unauthorized",
|
||||
ApiError::Xml(..) => "xml_error",
|
||||
ApiError::Json(..) => "json_error",
|
||||
ApiError::Search(..) => "search_error",
|
||||
ApiError::Indexing(..) => "indexing_error",
|
||||
ApiError::FileHosting(..) => "file_hosting_error",
|
||||
ApiError::InvalidInput(..) => "invalid_input",
|
||||
ApiError::Validation(..) => "invalid_input",
|
||||
ApiError::Payments(..) => "payments_error",
|
||||
ApiError::Discord(..) => "discord_error",
|
||||
ApiError::Turnstile => "turnstile_error",
|
||||
ApiError::Decoding(..) => "decoding_error",
|
||||
ApiError::ImageParse(..) => "invalid_image",
|
||||
ApiError::PasswordHashing(..) => "password_hashing_error",
|
||||
ApiError::Mail(..) => "mail_error",
|
||||
ApiError::Clickhouse(..) => "clickhouse_error",
|
||||
ApiError::Reroute(..) => "reroute_error",
|
||||
ApiError::NotFound => "not_found",
|
||||
ApiError::Conflict(..) => "conflict",
|
||||
ApiError::TaxComplianceApi => "tax_compliance_api_error",
|
||||
ApiError::Zip(..) => "zip_error",
|
||||
ApiError::Io(..) => "io_error",
|
||||
ApiError::RateLimitError(..) => "ratelimit_error",
|
||||
ApiError::Stripe(..) => "stripe_error",
|
||||
ApiError::TaxProcessor(..) => "tax_processor_error",
|
||||
ApiError::Slack(..) => "slack_error",
|
||||
Self::Auth(..) => "auth_error",
|
||||
Self::Env(..) => "environment_error",
|
||||
Self::Database(..) => "database_error",
|
||||
Self::SqlxDatabase(..) => "database_error",
|
||||
Self::RedisDatabase(..) => "database_error",
|
||||
Self::Authentication(..) => "unauthorized",
|
||||
Self::CustomAuthentication(..) => "unauthorized",
|
||||
Self::Xml(..) => "xml_error",
|
||||
Self::Json(..) => "json_error",
|
||||
Self::Search(..) => "search_error",
|
||||
Self::Indexing(..) => "indexing_error",
|
||||
Self::FileHosting(..) => "file_hosting_error",
|
||||
Self::InvalidInput(..) => "invalid_input",
|
||||
Self::Validation(..) => "invalid_input",
|
||||
Self::Payments(..) => "payments_error",
|
||||
Self::Discord(..) => "discord_error",
|
||||
Self::Turnstile => "turnstile_error",
|
||||
Self::Decoding(..) => "decoding_error",
|
||||
Self::ImageParse(..) => "invalid_image",
|
||||
Self::PasswordHashing(..) => "password_hashing_error",
|
||||
Self::Mail(..) => "mail_error",
|
||||
Self::Clickhouse(..) => "clickhouse_error",
|
||||
Self::Reroute(..) => "reroute_error",
|
||||
Self::NotFound => "not_found",
|
||||
Self::Conflict(..) => "conflict",
|
||||
Self::TaxComplianceApi => "tax_compliance_api_error",
|
||||
Self::Zip(..) => "zip_error",
|
||||
Self::Io(..) => "io_error",
|
||||
Self::RateLimitError(..) => "ratelimit_error",
|
||||
Self::Stripe(..) => "stripe_error",
|
||||
Self::TaxProcessor(..) => "tax_processor_error",
|
||||
Self::Slack(..) => "slack_error",
|
||||
},
|
||||
description: match self {
|
||||
Self::Internal(e) => format!("{e:#?}"),
|
||||
Self::Request(e) => format!("{e:#?}"),
|
||||
Self::Auth(e) => format!("{e:#?}"),
|
||||
_ => self.to_string(),
|
||||
},
|
||||
description: self.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,39 +215,40 @@ impl ApiError {
|
||||
impl actix_web::ResponseError for ApiError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
ApiError::Internal(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Request(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::InvalidInput(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::Env(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Database(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::SqlxDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::RedisDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Clickhouse(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Authentication(..) => StatusCode::UNAUTHORIZED,
|
||||
ApiError::CustomAuthentication(..) => StatusCode::UNAUTHORIZED,
|
||||
ApiError::Xml(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Json(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::Search(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Indexing(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::FileHosting(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Validation(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::Payments(..) => StatusCode::FAILED_DEPENDENCY,
|
||||
ApiError::Discord(..) => StatusCode::FAILED_DEPENDENCY,
|
||||
ApiError::Turnstile => StatusCode::BAD_REQUEST,
|
||||
ApiError::Decoding(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::ImageParse(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::PasswordHashing(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::NotFound => StatusCode::NOT_FOUND,
|
||||
ApiError::Conflict(..) => StatusCode::CONFLICT,
|
||||
ApiError::TaxComplianceApi => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Zip(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::Io(..) => StatusCode::BAD_REQUEST,
|
||||
ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
|
||||
ApiError::Stripe(..) => StatusCode::FAILED_DEPENDENCY,
|
||||
ApiError::TaxProcessor(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::Slack(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Internal(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Request(..) => StatusCode::BAD_REQUEST,
|
||||
Self::Auth(..) => StatusCode::UNAUTHORIZED,
|
||||
Self::InvalidInput(..) => StatusCode::BAD_REQUEST,
|
||||
Self::Env(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Database(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::SqlxDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::RedisDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Clickhouse(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Authentication(..) => StatusCode::UNAUTHORIZED,
|
||||
Self::CustomAuthentication(..) => StatusCode::UNAUTHORIZED,
|
||||
Self::Xml(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Json(..) => StatusCode::BAD_REQUEST,
|
||||
Self::Search(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Indexing(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::FileHosting(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Validation(..) => StatusCode::BAD_REQUEST,
|
||||
Self::Payments(..) => StatusCode::FAILED_DEPENDENCY,
|
||||
Self::Discord(..) => StatusCode::FAILED_DEPENDENCY,
|
||||
Self::Turnstile => StatusCode::BAD_REQUEST,
|
||||
Self::Decoding(..) => StatusCode::BAD_REQUEST,
|
||||
Self::ImageParse(..) => StatusCode::BAD_REQUEST,
|
||||
Self::PasswordHashing(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::NotFound => StatusCode::NOT_FOUND,
|
||||
Self::Conflict(..) => StatusCode::CONFLICT,
|
||||
Self::TaxComplianceApi => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Zip(..) => StatusCode::BAD_REQUEST,
|
||||
Self::Io(..) => StatusCode::BAD_REQUEST,
|
||||
Self::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS,
|
||||
Self::Stripe(..) => StatusCode::FAILED_DEPENDENCY,
|
||||
Self::TaxProcessor(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Slack(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
.configure(threads::config)
|
||||
.configure(users::config)
|
||||
.configure(version_file::config)
|
||||
.configure(payouts::config)
|
||||
.configure(versions::config)
|
||||
.configure(friends::config),
|
||||
);
|
||||
@@ -61,6 +60,11 @@ pub fn utoipa_config(
|
||||
.wrap(default_cors())
|
||||
.configure(analytics_get::config),
|
||||
);
|
||||
cfg.service(
|
||||
utoipa_actix_web::scope("/v3/payout")
|
||||
.wrap(default_cors())
|
||||
.configure(payouts::config),
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn hello_world() -> Result<HttpResponse, ApiError> {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use crate::auth::validate::get_user_record_from_bearer_token;
|
||||
use crate::auth::{AuthenticationError, get_user_from_headers};
|
||||
use crate::database::models::DBUserId;
|
||||
use crate::database::models::payout_item::DBPayout;
|
||||
use crate::database::models::{DBPayoutId, DBUser, DBUserId};
|
||||
use crate::database::models::{generate_payout_id, users_compliance};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::PayoutId;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::models::payouts::{PayoutMethodType, PayoutStatus};
|
||||
use crate::models::payouts::{
|
||||
MuralPayDetails, PayoutMethodRequest, PayoutMethodType, PayoutStatus,
|
||||
TremendousDetails, TremendousForexResponse,
|
||||
};
|
||||
use crate::queue::payouts::PayoutsQueue;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
@@ -13,6 +17,7 @@ use crate::util::avalara1099;
|
||||
use crate::util::error::Context;
|
||||
use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use eyre::eyre;
|
||||
use hex::ToHex;
|
||||
use hmac::{Hmac, Mac};
|
||||
use reqwest::Method;
|
||||
@@ -28,38 +33,26 @@ use tracing::error;
|
||||
const COMPLIANCE_CHECK_DEBOUNCE: chrono::Duration =
|
||||
chrono::Duration::seconds(15);
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("payout")
|
||||
.service(paypal_webhook)
|
||||
.service(tremendous_webhook)
|
||||
// we use `route` instead of `service` because `user_payouts` uses the logic of `transaction_history`
|
||||
.route(
|
||||
"",
|
||||
web::get().to(
|
||||
#[expect(
|
||||
deprecated,
|
||||
reason = "v3 backwards compatibility"
|
||||
)]
|
||||
user_payouts,
|
||||
),
|
||||
)
|
||||
.route("history", web::get().to(transaction_history))
|
||||
.service(create_payout)
|
||||
.service(cancel_payout)
|
||||
.service(payment_methods)
|
||||
.service(get_balance)
|
||||
.service(platform_revenue)
|
||||
.service(post_compliance_form),
|
||||
);
|
||||
pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
|
||||
cfg.service(paypal_webhook)
|
||||
.service(tremendous_webhook)
|
||||
.service(transaction_history)
|
||||
.service(calculate_fees)
|
||||
.service(create_payout)
|
||||
.service(cancel_payout)
|
||||
.service(payment_methods)
|
||||
.service(get_balance)
|
||||
.service(platform_revenue)
|
||||
.service(post_compliance_form);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct RequestForm {
|
||||
form_type: users_compliance::FormType,
|
||||
}
|
||||
|
||||
#[post("compliance")]
|
||||
#[utoipa::path]
|
||||
#[post("/compliance")]
|
||||
pub async fn post_compliance_form(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -157,7 +150,8 @@ pub async fn post_compliance_form(
|
||||
}
|
||||
}
|
||||
|
||||
#[post("_paypal")]
|
||||
#[utoipa::path]
|
||||
#[post("/_paypal")]
|
||||
pub async fn paypal_webhook(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -314,7 +308,8 @@ pub async fn paypal_webhook(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
#[post("_tremendous")]
|
||||
#[utoipa::path]
|
||||
#[post("/_tremendous")]
|
||||
pub async fn tremendous_webhook(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -424,60 +419,55 @@ pub async fn tremendous_webhook(
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
#[deprecated = "use `transaction_history` instead"]
|
||||
pub async fn user_payouts(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
) -> Result<web::Json<Vec<crate::models::payouts::Payout>>, ApiError> {
|
||||
let (_, user) = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Scopes::PAYOUTS_READ,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let items = transaction_history(req, pool, redis, session_queue)
|
||||
.await?
|
||||
.0
|
||||
.into_iter()
|
||||
.filter_map(|txn_item| match txn_item {
|
||||
TransactionItem::Withdrawal {
|
||||
id,
|
||||
status,
|
||||
created,
|
||||
amount,
|
||||
fee,
|
||||
method_type,
|
||||
method_address,
|
||||
} => Some(crate::models::payouts::Payout {
|
||||
id,
|
||||
user_id: user.id,
|
||||
status,
|
||||
created,
|
||||
amount,
|
||||
fee,
|
||||
method: method_type,
|
||||
method_address,
|
||||
platform_id: None,
|
||||
}),
|
||||
TransactionItem::PayoutAvailable { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(web::Json(items))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct Withdrawal {
|
||||
#[serde(with = "rust_decimal::serde::float")]
|
||||
amount: Decimal,
|
||||
method: PayoutMethodType,
|
||||
#[serde(flatten)]
|
||||
method: PayoutMethodRequest,
|
||||
method_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct WithdrawalFees {
|
||||
pub fee: Decimal,
|
||||
pub exchange_rate: Option<Decimal>,
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[post("/fees")]
|
||||
pub async fn calculate_fees(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
body: web::Json<Withdrawal>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
payouts_queue: web::Data<PayoutsQueue>,
|
||||
) -> Result<web::Json<WithdrawalFees>, ApiError> {
|
||||
// even though we don't use the user, we ensure they're logged in to make API calls
|
||||
let (_, _user) = get_user_record_from_bearer_token(
|
||||
&req,
|
||||
None,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::Authentication(AuthenticationError::InvalidCredentials)
|
||||
})?;
|
||||
|
||||
let fees = payouts_queue
|
||||
.calculate_fees(&body.method, &body.method_id, body.amount)
|
||||
.await?;
|
||||
|
||||
Ok(web::Json(WithdrawalFees {
|
||||
fee: fees.total_fee(),
|
||||
exchange_rate: fees.exchange_rate,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path]
|
||||
#[post("")]
|
||||
pub async fn create_payout(
|
||||
req: HttpRequest,
|
||||
@@ -486,7 +476,7 @@ pub async fn create_payout(
|
||||
body: web::Json<Withdrawal>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
payouts_queue: web::Data<PayoutsQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
) -> Result<(), ApiError> {
|
||||
let (scopes, user) = get_user_record_from_bearer_token(
|
||||
&req,
|
||||
None,
|
||||
@@ -514,9 +504,12 @@ pub async fn create_payout(
|
||||
user.id.0
|
||||
)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch user balance")?;
|
||||
|
||||
let balance = get_user_balance(user.id, &pool).await?;
|
||||
let balance = get_user_balance(user.id, &pool)
|
||||
.await
|
||||
.wrap_internal_err("failed to calculate user balance")?;
|
||||
if balance.available < body.amount || body.amount < Decimal::ZERO {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You do not have enough funds to make this payout!".to_string(),
|
||||
@@ -585,255 +578,372 @@ pub async fn create_payout(
|
||||
));
|
||||
}
|
||||
|
||||
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 fees = payouts_queue
|
||||
.calculate_fees(&body.method, &body.method_id, body.amount)
|
||||
.await?;
|
||||
|
||||
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),
|
||||
);
|
||||
// fees are a bit complicated here, since we have 2 types:
|
||||
// - method fees - this is what Tremendous, Mural, etc. will take from us
|
||||
// without us having a say in it
|
||||
// - platform fees - this is what we deliberately keep for ourselves
|
||||
// - total fees - method fees + platform fees
|
||||
//
|
||||
// we first make sure that `amount - total fees` is greater than zero,
|
||||
// then we issue a payout request with `amount - platform fees`
|
||||
|
||||
let transfer = (body.amount - fee).round_dp(2);
|
||||
if transfer <= Decimal::ZERO {
|
||||
if (body.amount - fees.total_fee()).round_dp(2) <= Decimal::ZERO {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You need to withdraw more to cover the fee!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let payout_id = generate_payout_id(&mut transaction).await?;
|
||||
let sent_to_method = (body.amount - fees.platform_fee).round_dp(2);
|
||||
assert!(sent_to_method > Decimal::ZERO);
|
||||
|
||||
let payout_item = match body.method {
|
||||
PayoutMethodType::Venmo | PayoutMethodType::PayPal => {
|
||||
let (wallet, wallet_type, address, display_address) = if body.method
|
||||
== PayoutMethodType::Venmo
|
||||
{
|
||||
if let Some(venmo) = user.venmo_handle {
|
||||
("Venmo", "user_handle", venmo.clone(), 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 {
|
||||
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 use the International PayPal transfer option!".to_string(),
|
||||
));
|
||||
}
|
||||
let payout_id = generate_payout_id(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to generate payout ID")?;
|
||||
|
||||
(
|
||||
"PayPal",
|
||||
"paypal_id",
|
||||
paypal_id.clone(),
|
||||
user.paypal_email.unwrap_or(paypal_id),
|
||||
)
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Please re-link your PayPal account!".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You have not linked a PayPal account!".to_string(),
|
||||
));
|
||||
};
|
||||
let payout_cx = PayoutContext {
|
||||
body: &body,
|
||||
user: &user,
|
||||
payout_id,
|
||||
raw_amount: body.amount,
|
||||
total_fee: fees.total_fee(),
|
||||
sent_to_method,
|
||||
payouts_queue: &payouts_queue,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayPalLink {
|
||||
href: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutsResponse {
|
||||
pub links: Vec<PayPalLink>,
|
||||
}
|
||||
|
||||
let mut payout_item =
|
||||
crate::database::models::payout_item::DBPayout {
|
||||
id: payout_id,
|
||||
user_id: user.id,
|
||||
created: Utc::now(),
|
||||
status: PayoutStatus::InTransit,
|
||||
amount: transfer,
|
||||
fee: Some(fee),
|
||||
method: Some(body.method),
|
||||
method_address: Some(display_address),
|
||||
platform_id: None,
|
||||
};
|
||||
|
||||
let res: PayoutsResponse = payouts_queue.make_paypal_request(
|
||||
Method::POST,
|
||||
"payments/payouts",
|
||||
Some(
|
||||
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": [{
|
||||
"amount": {
|
||||
"currency": "USD",
|
||||
"value": transfer.to_string()
|
||||
},
|
||||
"receiver": address,
|
||||
"note": "Payment from Modrinth creator monetization program",
|
||||
"recipient_type": wallet_type,
|
||||
"recipient_wallet": wallet,
|
||||
"sender_item_id": crate::models::ids::PayoutId::from(payout_id),
|
||||
}]
|
||||
})
|
||||
),
|
||||
None,
|
||||
None
|
||||
).await?;
|
||||
|
||||
if let Some(link) = res.links.first() {
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutItem {
|
||||
pub payout_item_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutData {
|
||||
pub items: Vec<PayoutItem>,
|
||||
}
|
||||
|
||||
if let Ok(res) = payouts_queue
|
||||
.make_paypal_request::<(), PayoutData>(
|
||||
Method::GET,
|
||||
&link.href,
|
||||
None,
|
||||
None,
|
||||
Some(true),
|
||||
)
|
||||
.await
|
||||
&& let Some(data) = res.items.first()
|
||||
{
|
||||
payout_item.platform_id = Some(data.payout_item_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
payout_item
|
||||
let payout_item = match &body.method {
|
||||
PayoutMethodRequest::PayPal | PayoutMethodRequest::Venmo => {
|
||||
paypal_payout(payout_cx).await?
|
||||
}
|
||||
PayoutMethodType::Tremendous => {
|
||||
if let Some(email) = user.email {
|
||||
if user.email_verified {
|
||||
let mut payout_item =
|
||||
crate::database::models::payout_item::DBPayout {
|
||||
id: payout_id,
|
||||
user_id: user.id,
|
||||
created: Utc::now(),
|
||||
status: PayoutStatus::InTransit,
|
||||
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<Reward>,
|
||||
}
|
||||
|
||||
#[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(),
|
||||
));
|
||||
}
|
||||
PayoutMethodRequest::Tremendous { method_details } => {
|
||||
tremendous_payout(payout_cx, method_details).await?
|
||||
}
|
||||
PayoutMethodType::Unknown => {
|
||||
return Err(ApiError::Payments(
|
||||
"Invalid payment method specified!".to_string(),
|
||||
));
|
||||
PayoutMethodRequest::MuralPay { method_details } => {
|
||||
mural_pay_payout(payout_cx, method_details).await?
|
||||
}
|
||||
};
|
||||
|
||||
payout_item.insert(&mut transaction).await?;
|
||||
payout_item
|
||||
.insert(&mut transaction)
|
||||
.await
|
||||
.wrap_internal_err("failed to insert payout")?;
|
||||
|
||||
transaction.commit().await?;
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.wrap_internal_err("failed to commit transaction")?;
|
||||
crate::database::models::DBUser::clear_caches(&[(user.id, None)], &redis)
|
||||
.await?;
|
||||
.await
|
||||
.wrap_internal_err("failed to clear user caches")?;
|
||||
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct PayoutContext<'a> {
|
||||
body: &'a Withdrawal,
|
||||
user: &'a DBUser,
|
||||
payout_id: DBPayoutId,
|
||||
raw_amount: Decimal,
|
||||
total_fee: Decimal,
|
||||
sent_to_method: Decimal,
|
||||
payouts_queue: &'a PayoutsQueue,
|
||||
}
|
||||
|
||||
fn get_verified_email(user: &DBUser) -> Result<&str, ApiError> {
|
||||
let email = user.email.as_ref().wrap_request_err(
|
||||
"you must add an email to your account to withdraw",
|
||||
)?;
|
||||
if !user.email_verified {
|
||||
return Err(ApiError::Request(eyre!(
|
||||
"you must verify your email to withdraw"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(email)
|
||||
}
|
||||
|
||||
async fn tremendous_payout(
|
||||
PayoutContext {
|
||||
body,
|
||||
user,
|
||||
payout_id,
|
||||
raw_amount,
|
||||
total_fee,
|
||||
sent_to_method,
|
||||
payouts_queue,
|
||||
}: PayoutContext<'_>,
|
||||
TremendousDetails {
|
||||
delivery_email,
|
||||
currency,
|
||||
}: &TremendousDetails,
|
||||
) -> Result<DBPayout, ApiError> {
|
||||
let user_email = get_verified_email(user)?;
|
||||
|
||||
let mut payout_item = DBPayout {
|
||||
id: payout_id,
|
||||
user_id: user.id,
|
||||
created: Utc::now(),
|
||||
status: PayoutStatus::InTransit,
|
||||
amount: raw_amount,
|
||||
fee: Some(total_fee),
|
||||
method: Some(PayoutMethodType::Tremendous),
|
||||
method_address: Some(user_email.to_string()),
|
||||
platform_id: None,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Reward {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Order {
|
||||
pub rewards: Vec<Reward>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TremendousResponse {
|
||||
pub order: Order,
|
||||
}
|
||||
|
||||
let forex: TremendousForexResponse = payouts_queue
|
||||
.make_tremendous_request(Method::GET, "forex", None::<()>)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch Tremendous forex data")?;
|
||||
|
||||
let (denomination, currency_code) = if let Some(currency) = currency {
|
||||
let currency_code = currency.to_string();
|
||||
let exchange_rate =
|
||||
forex.forex.get(¤cy_code).wrap_internal_err_with(|| {
|
||||
eyre!("no Tremendous forex data for {currency}")
|
||||
})?;
|
||||
(sent_to_method * *exchange_rate, Some(currency_code))
|
||||
} else {
|
||||
(sent_to_method, None)
|
||||
};
|
||||
|
||||
let reward_value = if let Some(currency_code) = currency_code {
|
||||
json!({
|
||||
"denomination": denomination,
|
||||
"currency_code": currency_code,
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"denomination": denomination,
|
||||
})
|
||||
};
|
||||
|
||||
let res: TremendousResponse = payouts_queue
|
||||
.make_tremendous_request(
|
||||
Method::POST,
|
||||
"orders",
|
||||
Some(json! ({
|
||||
"payment": {
|
||||
"funding_source_id": "BALANCE",
|
||||
},
|
||||
"rewards": [{
|
||||
"value": reward_value,
|
||||
"delivery": {
|
||||
"method": "EMAIL"
|
||||
},
|
||||
"recipient": {
|
||||
"name": user.username,
|
||||
"email": delivery_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())
|
||||
}
|
||||
|
||||
Ok(payout_item)
|
||||
}
|
||||
|
||||
async fn mural_pay_payout(
|
||||
PayoutContext {
|
||||
body: _body,
|
||||
user,
|
||||
payout_id,
|
||||
raw_amount,
|
||||
total_fee,
|
||||
sent_to_method,
|
||||
payouts_queue,
|
||||
}: PayoutContext<'_>,
|
||||
details: &MuralPayDetails,
|
||||
) -> Result<DBPayout, ApiError> {
|
||||
let user_email = get_verified_email(user)?;
|
||||
|
||||
let payout_request = payouts_queue
|
||||
.create_muralpay_payout_request(
|
||||
user.id.into(),
|
||||
muralpay::TokenAmount {
|
||||
token_symbol: muralpay::USDC.into(),
|
||||
token_amount: sent_to_method,
|
||||
},
|
||||
details.payout_details.clone(),
|
||||
details.recipient_info.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(DBPayout {
|
||||
id: payout_id,
|
||||
user_id: user.id,
|
||||
created: Utc::now(),
|
||||
status: PayoutStatus::Success,
|
||||
amount: raw_amount,
|
||||
fee: Some(total_fee),
|
||||
method: Some(PayoutMethodType::MuralPay),
|
||||
method_address: Some(user_email.to_string()),
|
||||
platform_id: Some(payout_request.id.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn paypal_payout(
|
||||
PayoutContext {
|
||||
body,
|
||||
user,
|
||||
payout_id,
|
||||
raw_amount,
|
||||
total_fee,
|
||||
sent_to_method,
|
||||
payouts_queue,
|
||||
}: PayoutContext<'_>,
|
||||
) -> Result<DBPayout, ApiError> {
|
||||
let (wallet, wallet_type, address, display_address) =
|
||||
if matches!(body.method, PayoutMethodRequest::Venmo) {
|
||||
if let Some(venmo) = &user.venmo_handle {
|
||||
("Venmo", "user_handle", venmo.clone(), 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 {
|
||||
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 use the International PayPal transfer option!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
(
|
||||
"PayPal",
|
||||
"paypal_id",
|
||||
paypal_id.clone(),
|
||||
user.paypal_email.as_ref().unwrap_or(paypal_id),
|
||||
)
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Please re-link your PayPal account!".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"You have not linked a PayPal account!".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayPalLink {
|
||||
href: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutsResponse {
|
||||
pub links: Vec<PayPalLink>,
|
||||
}
|
||||
|
||||
let mut payout_item = crate::database::models::payout_item::DBPayout {
|
||||
id: payout_id,
|
||||
user_id: user.id,
|
||||
created: Utc::now(),
|
||||
status: PayoutStatus::InTransit,
|
||||
amount: raw_amount,
|
||||
fee: Some(total_fee),
|
||||
method: Some(body.method.method_type()),
|
||||
method_address: Some(display_address.clone()),
|
||||
platform_id: None,
|
||||
};
|
||||
|
||||
let res: PayoutsResponse = payouts_queue.make_paypal_request(
|
||||
Method::POST,
|
||||
"payments/payouts",
|
||||
Some(
|
||||
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": [{
|
||||
"amount": {
|
||||
"currency": "USD",
|
||||
"value": sent_to_method.to_string()
|
||||
},
|
||||
"receiver": address,
|
||||
"note": "Payment from Modrinth creator monetization program",
|
||||
"recipient_type": wallet_type,
|
||||
"recipient_wallet": wallet,
|
||||
"sender_item_id": crate::models::ids::PayoutId::from(payout_id),
|
||||
}]
|
||||
})
|
||||
),
|
||||
None,
|
||||
None
|
||||
).await?;
|
||||
|
||||
if let Some(link) = res.links.first() {
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutItem {
|
||||
pub payout_item_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PayoutData {
|
||||
pub items: Vec<PayoutItem>,
|
||||
}
|
||||
|
||||
if let Ok(res) = payouts_queue
|
||||
.make_paypal_request::<(), PayoutData>(
|
||||
Method::GET,
|
||||
&link.href,
|
||||
None,
|
||||
None,
|
||||
Some(true),
|
||||
)
|
||||
.await
|
||||
&& let Some(data) = res.items.first()
|
||||
{
|
||||
payout_item.platform_id = Some(data.payout_item_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(payout_item)
|
||||
}
|
||||
|
||||
/// User performing a payout-related action.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum TransactionItem {
|
||||
/// User withdrew some of their available payout.
|
||||
Withdrawal {
|
||||
id: PayoutId,
|
||||
status: PayoutStatus,
|
||||
@@ -843,6 +953,7 @@ pub enum TransactionItem {
|
||||
method_type: Option<PayoutMethodType>,
|
||||
method_address: Option<String>,
|
||||
},
|
||||
/// User got a payout available for them to withdraw.
|
||||
PayoutAvailable {
|
||||
created: DateTime<Utc>,
|
||||
payout_source: PayoutSource,
|
||||
@@ -859,7 +970,17 @@ impl TransactionItem {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
utoipa::ToSchema,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[non_exhaustive]
|
||||
pub enum PayoutSource {
|
||||
@@ -867,6 +988,10 @@ pub enum PayoutSource {
|
||||
Affilites,
|
||||
}
|
||||
|
||||
/// Get the history of when the authorized user got payouts available, and when
|
||||
/// the user withdrew their payouts.
|
||||
#[utoipa::path(responses((status = OK, body = Vec<TransactionItem>)))]
|
||||
#[get("/history")]
|
||||
pub async fn transaction_history(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -907,7 +1032,7 @@ pub async fn transaction_history(
|
||||
});
|
||||
|
||||
let mut payouts_available = sqlx::query!(
|
||||
"SELECT created, amount
|
||||
"SELECT date_available, amount
|
||||
FROM payouts_values
|
||||
WHERE user_id = $1
|
||||
AND NOW() >= date_available",
|
||||
@@ -918,7 +1043,7 @@ pub async fn transaction_history(
|
||||
let record = record
|
||||
.wrap_internal_err("failed to fetch available payout record")?;
|
||||
Ok(TransactionItem::PayoutAvailable {
|
||||
created: record.created,
|
||||
created: record.date_available,
|
||||
payout_source: PayoutSource::CreatorRewards,
|
||||
amount: record.amount,
|
||||
})
|
||||
@@ -935,7 +1060,8 @@ pub async fn transaction_history(
|
||||
Ok(web::Json(txn_items))
|
||||
}
|
||||
|
||||
#[delete("{id}")]
|
||||
#[utoipa::path]
|
||||
#[delete("/{id}")]
|
||||
pub async fn cancel_payout(
|
||||
info: web::Path<(PayoutId,)>,
|
||||
req: HttpRequest,
|
||||
@@ -995,10 +1121,16 @@ pub async fn cancel_payout(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
PayoutMethodType::Unknown => {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Payout cannot be cancelled!".to_string(),
|
||||
));
|
||||
PayoutMethodType::MuralPay => {
|
||||
let payout_request_id = platform_id
|
||||
.parse::<muralpay::PayoutRequestId>()
|
||||
.wrap_request_err("invalid payout request ID")?;
|
||||
payouts
|
||||
.cancel_muralpay_payout_request(payout_request_id)
|
||||
.await
|
||||
.wrap_internal_err(
|
||||
"failed to cancel payout request",
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1047,7 +1179,8 @@ pub enum FormCompletionStatus {
|
||||
Complete,
|
||||
}
|
||||
|
||||
#[get("methods")]
|
||||
#[utoipa::path]
|
||||
#[get("/methods")]
|
||||
pub async fn payment_methods(
|
||||
payouts_queue: web::Data<PayoutsQueue>,
|
||||
filter: web::Query<MethodFilter>,
|
||||
@@ -1079,7 +1212,8 @@ pub struct UserBalance {
|
||||
pub dates: HashMap<DateTime<Utc>, Decimal>,
|
||||
}
|
||||
|
||||
#[get("balance")]
|
||||
#[utoipa::path]
|
||||
#[get("/balance")]
|
||||
pub async fn get_balance(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
@@ -1217,7 +1351,9 @@ async fn update_compliance_status(
|
||||
user_id: crate::database::models::ids::DBUserId,
|
||||
) -> Result<Option<ComplianceCheck>, ApiError> {
|
||||
let maybe_compliance =
|
||||
users_compliance::UserCompliance::get_by_user_id(pg, user_id).await?;
|
||||
users_compliance::UserCompliance::get_by_user_id(pg, user_id)
|
||||
.await
|
||||
.wrap_internal_err("failed to fetch user tax compliance")?;
|
||||
|
||||
let Some(mut compliance) = maybe_compliance else {
|
||||
return Ok(None);
|
||||
@@ -1233,7 +1369,9 @@ async fn update_compliance_status(
|
||||
compliance_api_check_failed: false,
|
||||
}))
|
||||
} else {
|
||||
let result = avalara1099::check_form(&compliance.reference_id).await?;
|
||||
let result = avalara1099::check_form(&compliance.reference_id)
|
||||
.await
|
||||
.wrap_internal_err("failed to check form using Track1099")?;
|
||||
let mut compliance_api_check_failed = false;
|
||||
|
||||
compliance.last_checked = Utc::now();
|
||||
@@ -1311,7 +1449,8 @@ pub struct RevenueData {
|
||||
pub creator_revenue: Decimal,
|
||||
}
|
||||
|
||||
#[get("platform_revenue")]
|
||||
#[utoipa::path]
|
||||
#[get("/platform_revenue")]
|
||||
pub async fn platform_revenue(
|
||||
query: web::Query<RevenueQuery>,
|
||||
pool: web::Data<PgPool>,
|
||||
|
||||
Reference in New Issue
Block a user