diff --git a/apps/labrinth/.env.docker-compose b/apps/labrinth/.env.docker-compose index 9222b02b..10637dce 100644 --- a/apps/labrinth/.env.docker-compose +++ b/apps/labrinth/.env.docker-compose @@ -25,6 +25,11 @@ MODERATION_SLACK_WEBHOOK= PUBLIC_DISCORD_WEBHOOK= CLOUDFLARE_INTEGRATION=false +PAYOUT_ALERT_SLACK_WEBHOOK=none +TREMENDOUS_BALANCE_ALERT_THRESHOLD=0 +PAYPAL_BALANCE_ALERT_THRESHOLD=0 +BREX_BALANCE_ALERT_THRESHOLD=0 + STORAGE_BACKEND=local MOCK_FILE_PATH=/tmp/modrinth diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local index 04b815de..fa70155e 100644 --- a/apps/labrinth/.env.local +++ b/apps/labrinth/.env.local @@ -25,6 +25,11 @@ MODERATION_SLACK_WEBHOOK= PUBLIC_DISCORD_WEBHOOK= CLOUDFLARE_INTEGRATION=false +PAYOUT_ALERT_SLACK_WEBHOOK=none +TREMENDOUS_BALANCE_ALERT_THRESHOLD=0 +PAYPAL_BALANCE_ALERT_THRESHOLD=0 +BREX_BALANCE_ALERT_THRESHOLD=0 + STORAGE_BACKEND=local MOCK_FILE_PATH=/tmp/modrinth diff --git a/apps/labrinth/src/auth/mod.rs b/apps/labrinth/src/auth/mod.rs index a22fd65c..051f3833 100644 --- a/apps/labrinth/src/auth/mod.rs +++ b/apps/labrinth/src/auth/mod.rs @@ -3,12 +3,12 @@ pub mod email; pub mod oauth; pub mod templates; pub mod validate; -pub use crate::auth::email::send_email; pub use checks::{ filter_enlisted_projects_ids, filter_enlisted_version_ids, filter_visible_collections, filter_visible_project_ids, filter_visible_projects, }; +pub use email::send_email; use serde::{Deserialize, Serialize}; // pub use pat::{generate_pat, PersonalAccessToken}; pub use validate::{check_is_moderator_from_headers, get_user_from_headers}; diff --git a/apps/labrinth/src/background_task.rs b/apps/labrinth/src/background_task.rs index 57301ffa..18e0ae46 100644 --- a/apps/labrinth/src/background_task.rs +++ b/apps/labrinth/src/background_task.rs @@ -1,6 +1,6 @@ use crate::database::redis::RedisPool; use crate::queue::payouts::{ - PayoutsQueue, insert_bank_balances, process_payout, + PayoutsQueue, insert_bank_balances_and_webhook, process_payout, }; use crate::search::indexing::index_projects; use crate::{database, search}; @@ -59,7 +59,7 @@ impl BackgroundTask { pub async fn update_bank_balances(pool: sqlx::Pool) { let payouts_queue = PayoutsQueue::new(); - match insert_bank_balances(&payouts_queue, &pool).await { + match insert_bank_balances_and_webhook(&payouts_queue, &pool).await { Ok(_) => info!("Bank balances updated successfully"), Err(error) => error!(%error, "Bank balances update failed"), } diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index c5f0b5be..ded9b007 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -489,6 +489,8 @@ pub fn check_env_vars() -> bool { failed |= check_var::("COMPLIANCE_PAYOUT_THRESHOLD"); + failed |= check_var::("PAYOUT_ALERT_SLACK_WEBHOOK"); + failed |= check_var::("ARCHON_URL"); failed diff --git a/apps/labrinth/src/queue/moderation.rs b/apps/labrinth/src/queue/moderation.rs index 87ebce31..010e70f3 100644 --- a/apps/labrinth/src/queue/moderation.rs +++ b/apps/labrinth/src/queue/moderation.rs @@ -665,7 +665,7 @@ impl AutomatedModerationQueue { .await?; if let Ok(webhook_url) = dotenvy::var("MODERATION_SLACK_WEBHOOK") { - crate::util::webhook::send_slack_webhook( + crate::util::webhook::send_slack_project_webhook( project.inner.id.into(), &pool, &redis, diff --git a/apps/labrinth/src/queue/payouts.rs b/apps/labrinth/src/queue/payouts.rs index d20f5c06..3f574fc1 100644 --- a/apps/labrinth/src/queue/payouts.rs +++ b/apps/labrinth/src/queue/payouts.rs @@ -4,12 +4,16 @@ use crate::models::payouts::{ }; use crate::models::projects::MonetizationStatus; use crate::routes::ApiError; +use crate::util::webhook::{ + PayoutSourceAlertType, send_slack_payout_source_alert_webhook, +}; use base64::Engine; use chrono::{DateTime, Datelike, Duration, NaiveTime, TimeZone, Utc}; use dashmap::DashMap; use futures::TryStreamExt; use reqwest::Method; use rust_decimal::Decimal; +use rust_decimal::prelude::ToPrimitive; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -1080,29 +1084,34 @@ pub async fn insert_payouts( .await } -pub async fn insert_bank_balances( +pub async fn insert_bank_balances_and_webhook( payouts: &PayoutsQueue, pool: &PgPool, ) -> Result<(), ApiError> { let mut transaction = pool.begin().await?; - let paypal = PayoutsQueue::get_paypal_balance() - .await - .inspect_err(|error| error!(%error, "Failure getting PayPal balance")) - .ok(); + let paypal_result = PayoutsQueue::get_paypal_balance().await; + let brex_result = PayoutsQueue::get_brex_balance().await; + let tremendous_result = payouts.get_tremendous_balance().await; - let brex = PayoutsQueue::get_brex_balance() - .await - .inspect_err(|error| error!(%error, "Failure getting Brex balance")) - .ok(); - - let tremendous = payouts - .get_tremendous_balance() - .await - .inspect_err( - |error| error!(%error, "Failure getting Tremendous balance"), - ) - .ok(); + let paypal = check_balance_with_webhook( + "paypal", + "PAYPAL_BALANCE_ALERT_THRESHOLD", + paypal_result, + ) + .await?; + let brex = check_balance_with_webhook( + "brex", + "BREX_BALANCE_ALERT_THRESHOLD", + brex_result, + ) + .await?; + let tremendous = check_balance_with_webhook( + "tremendous", + "TREMENDOUS_BALANCE_ALERT_THRESHOLD", + tremendous_result, + ) + .await?; let mut insert_account_types = Vec::new(); let mut insert_amounts = Vec::new(); @@ -1112,29 +1121,26 @@ pub async fn insert_bank_balances( let now = Utc::now(); let today = now.date_naive().and_time(NaiveTime::MIN).and_utc(); - let mut add_balance = - |account_type: &str, balance: Option| { - if let Some(balance) = balance { - insert_account_types.push(account_type.to_string()); - insert_amounts.push(balance.available); - insert_pending.push(false); - insert_recorded.push(today); + let mut add_balance = |account_type: &str, balance: &AccountBalance| { + insert_account_types.push(account_type.to_string()); + insert_amounts.push(balance.available); + insert_pending.push(false); + insert_recorded.push(today); - insert_account_types.push(account_type.to_string()); - insert_amounts.push(balance.pending); - insert_pending.push(true); - insert_recorded.push(today); - } - }; + insert_account_types.push(account_type.to_string()); + insert_amounts.push(balance.pending); + insert_pending.push(true); + insert_recorded.push(today); + }; if let Some(paypal) = paypal { - add_balance("paypal", paypal); + add_balance("paypal", &paypal); } if let Some(brex) = brex { - add_balance("brex", brex); + add_balance("brex", &brex); } if let Some(tremendous) = tremendous { - add_balance("tremendous", tremendous); + add_balance("tremendous", &tremendous); } sqlx::query!( @@ -1156,3 +1162,54 @@ pub async fn insert_bank_balances( Ok(()) } + +async fn check_balance_with_webhook( + source: &str, + threshold_env_var_name: &str, + result: Result, ApiError>, +) -> Result, ApiError> { + let maybe_threshold = dotenvy::var(threshold_env_var_name) + .ok() + .and_then(|x| x.parse::().ok()) + .filter(|x| *x != 0); + let payout_alert_webhook = dotenvy::var("PAYOUT_ALERT_SLACK_WEBHOOK")?; + + match &result { + Ok(Some(account_balance)) => { + if let Some(threshold) = maybe_threshold + && let Some(available) = + account_balance.available.trunc().to_u64() + && available <= threshold + { + send_slack_payout_source_alert_webhook( + PayoutSourceAlertType::UnderThreshold { + source: source.to_owned(), + threshold, + current_balance: available, + }, + &payout_alert_webhook, + ) + .await?; + } + } + + Err(error) => { + error!(%error, "Failure getting balance for payout source '{source}'"); + + if maybe_threshold.is_some() { + send_slack_payout_source_alert_webhook( + PayoutSourceAlertType::CheckFailure { + source: source.to_owned(), + display_error: error.to_string(), + }, + &payout_alert_webhook, + ) + .await?; + } + } + + _ => {} + } + + Ok(result.ok().flatten()) +} diff --git a/apps/labrinth/src/routes/internal/admin.rs b/apps/labrinth/src/routes/internal/admin.rs index 3be7e013..9d9de667 100644 --- a/apps/labrinth/src/routes/internal/admin.rs +++ b/apps/labrinth/src/routes/internal/admin.rs @@ -210,7 +210,7 @@ pub async fn delphi_result_ingest( } } - crate::util::webhook::send_slack_webhook( + crate::util::webhook::send_slack_project_webhook( body.project_id, &pool, &redis, diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index 4b3225bd..327f67e7 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -119,6 +119,8 @@ pub enum ApiError { Payments(String), #[error("Discord Error: {0}")] Discord(String), + #[error("Slack Webhook Error: {0}")] + Slack(String), #[error("Captcha Error. Try resubmitting the form.")] Turnstile, #[error("Error while decoding Base62: {0}")] @@ -182,6 +184,7 @@ impl ApiError { ApiError::Io(..) => "io_error", ApiError::RateLimitError(..) => "ratelimit_error", ApiError::Stripe(..) => "stripe_error", + ApiError::Slack(..) => "slack_error", }, description: self.to_string(), } @@ -220,6 +223,7 @@ impl actix_web::ResponseError for ApiError { ApiError::Io(..) => StatusCode::BAD_REQUEST, ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS, ApiError::Stripe(..) => StatusCode::FAILED_DEPENDENCY, + ApiError::Slack(..) => StatusCode::INTERNAL_SERVER_ERROR, } } diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index 6632511e..f8012895 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -434,7 +434,7 @@ pub async fn project_edit( if user.role.is_mod() && let Ok(webhook_url) = dotenvy::var("MODERATION_SLACK_WEBHOOK") { - crate::util::webhook::send_slack_webhook( + crate::util::webhook::send_slack_project_webhook( project_item.inner.id.into(), &pool, &redis, diff --git a/apps/labrinth/src/util/webhook.rs b/apps/labrinth/src/util/webhook.rs index 1d7be03a..7ae4e8a3 100644 --- a/apps/labrinth/src/util/webhook.rs +++ b/apps/labrinth/src/util/webhook.rs @@ -178,7 +178,76 @@ async fn get_webhook_metadata( } } -pub async fn send_slack_webhook( +pub enum PayoutSourceAlertType { + UnderThreshold { + source: String, + threshold: u64, + current_balance: u64, + }, + CheckFailure { + source: String, + display_error: String, + }, +} + +impl PayoutSourceAlertType { + pub fn message(&self) -> String { + match self { + PayoutSourceAlertType::UnderThreshold { + source, + threshold, + current_balance, + } => format!( + "\u{1f6a8} *Payout Source Alert*\n\nPayout source '{source}' has an available balance under the ${threshold} threshold.\nBalance: ${current_balance}." + ), + PayoutSourceAlertType::CheckFailure { + source, + display_error, + } => format!( + "\u{1f6a8} *Payout Source Alert*\n\nFAILED TO CHECK payout source '{source}' balance.\nError: {display_error}" + ), + } + } +} + +pub async fn send_slack_payout_source_alert_webhook( + alert: PayoutSourceAlertType, + webhook_url: &str, +) -> Result<(), ApiError> { + let client = reqwest::Client::new(); + + client + .post(webhook_url) + .json(&serde_json::json!({ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": alert.message() + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": format!("via labrinth • ", Utc::now().timestamp()) + } + ] + } + ], + })) + .send() + .await + .map_err(|_| { + ApiError::Slack("Error while sending projects webhook".to_string()) + })?; + + Ok(()) +} + +pub async fn send_slack_project_webhook( project_id: ProjectId, pool: &PgPool, redis: &RedisPool, @@ -288,7 +357,7 @@ pub async fn send_slack_webhook( .send() .await .map_err(|_| { - ApiError::Discord( + ApiError::Slack( "Error while sending projects webhook".to_string(), ) })?;