diff --git a/apps/labrinth/.sqlx/query-8f68ea8481d86687ef4ebd6311f8ec5437be07fab8c34a964f7864304681bb94.json b/apps/labrinth/.sqlx/query-8f68ea8481d86687ef4ebd6311f8ec5437be07fab8c34a964f7864304681bb94.json new file mode 100644 index 00000000..7b2b82e2 --- /dev/null +++ b/apps/labrinth/.sqlx/query-8f68ea8481d86687ef4ebd6311f8ec5437be07fab8c34a964f7864304681bb94.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT created, amount\n FROM payouts_values\n WHERE user_id = $1\n AND NOW() >= date_available", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "amount", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "8f68ea8481d86687ef4ebd6311f8ec5437be07fab8c34a964f7864304681bb94" +} diff --git a/apps/labrinth/src/routes/v3/payouts.rs b/apps/labrinth/src/routes/v3/payouts.rs index b56acb96..f77fb262 100644 --- a/apps/labrinth/src/routes/v3/payouts.rs +++ b/apps/labrinth/src/routes/v3/payouts.rs @@ -10,6 +10,7 @@ use crate::queue::payouts::PayoutsQueue; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::avalara1099; +use crate::util::error::Context; use actix_web::{HttpRequest, HttpResponse, delete, get, post, web}; use chrono::{DateTime, Duration, Utc}; use hex::ToHex; @@ -21,6 +22,7 @@ use serde_json::json; use sha2::Sha256; use sqlx::PgPool; use std::collections::HashMap; +use tokio_stream::StreamExt; use tracing::error; const COMPLIANCE_CHECK_DEBOUNCE: chrono::Duration = @@ -31,7 +33,18 @@ pub fn config(cfg: &mut web::ServiceConfig) { web::scope("payout") .service(paypal_webhook) .service(tremendous_webhook) - .service(user_payouts) + // 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) @@ -400,41 +413,50 @@ pub async fn tremendous_webhook( Ok(HttpResponse::NoContent().finish()) } -#[get("")] +#[deprecated = "use `transaction_history` instead"] pub async fn user_payouts( req: HttpRequest, pool: web::Data, redis: web::Data, session_queue: web::Data, -) -> Result { - let user = get_user_from_headers( +) -> Result>, ApiError> { + let (_, user) = get_user_from_headers( &req, &**pool, &redis, &session_queue, Scopes::PAYOUTS_READ, ) - .await? - .1; - - let payout_ids = - crate::database::models::payout_item::DBPayout::get_all_for_user( - user.id.into(), - &**pool, - ) - .await?; - let payouts = crate::database::models::payout_item::DBPayout::get_many( - &payout_ids, - &**pool, - ) .await?; - Ok(HttpResponse::Ok().json( - payouts - .into_iter() - .map(crate::models::payouts::Payout::from) - .collect::>(), - )) + 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::>(); + Ok(web::Json(items)) } #[derive(Deserialize)] @@ -798,6 +820,110 @@ pub async fn create_payout( Ok(HttpResponse::NoContent().finish()) } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum TransactionItem { + Withdrawal { + id: PayoutId, + status: PayoutStatus, + created: DateTime, + amount: Decimal, + fee: Option, + method_type: Option, + method_address: Option, + }, + PayoutAvailable { + created: DateTime, + payout_source: PayoutSource, + amount: Decimal, + }, +} + +impl TransactionItem { + pub fn created(&self) -> DateTime { + match self { + Self::Withdrawal { created, .. } => *created, + Self::PayoutAvailable { created, .. } => *created, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum PayoutSource { + CreatorRewards, + Affilites, +} + +pub async fn transaction_history( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result>, ApiError> { + let (_, user) = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PAYOUTS_READ, + ) + .await?; + + let payout_ids = + crate::database::models::payout_item::DBPayout::get_all_for_user( + user.id.into(), + &**pool, + ) + .await?; + let payouts = crate::database::models::payout_item::DBPayout::get_many( + &payout_ids, + &**pool, + ) + .await?; + let withdrawals = + payouts + .into_iter() + .map(|payout| TransactionItem::Withdrawal { + id: payout.id.into(), + status: payout.status, + created: payout.created, + amount: payout.amount, + fee: payout.fee, + method_type: payout.method, + method_address: payout.method_address, + }); + + let mut payouts_available = sqlx::query!( + "SELECT created, amount + FROM payouts_values + WHERE user_id = $1 + AND NOW() >= date_available", + DBUserId::from(user.id) as DBUserId + ) + .fetch(&**pool) + .map(|record| { + let record = record + .wrap_internal_err("failed to fetch available payout record")?; + Ok(TransactionItem::PayoutAvailable { + created: record.created, + payout_source: PayoutSource::CreatorRewards, + amount: record.amount, + }) + }) + .collect::, ApiError>>() + .await + .wrap_internal_err("failed to fetch available payouts")?; + + let mut txn_items = Vec::new(); + txn_items.extend(withdrawals); + txn_items.append(&mut payouts_available); + txn_items.sort_by_key(|item| item.created()); + + Ok(web::Json(txn_items)) +} + #[delete("{id}")] pub async fn cancel_payout( info: web::Path<(PayoutId,)>,