See available funds history and withdrawls in user payout history (#4537)

* Add GET /v3/payouts/history

* V3 backwards compat

* Sqlx prepare

* Include user ID in GET /v3/payout
This commit is contained in:
aecsocket
2025-10-11 11:51:38 +01:00
committed by GitHub
parent 0c66fa3f12
commit e66b131a5d
2 changed files with 177 additions and 23 deletions

View File

@@ -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"
}

View File

@@ -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<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
) -> Result<web::Json<Vec<crate::models::payouts::Payout>>, 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::<Vec<_>>(),
))
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)]
@@ -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<Utc>,
amount: Decimal,
fee: Option<Decimal>,
method_type: Option<PayoutMethodType>,
method_address: Option<String>,
},
PayoutAvailable {
created: DateTime<Utc>,
payout_source: PayoutSource,
amount: Decimal,
},
}
impl TransactionItem {
pub fn created(&self) -> DateTime<Utc> {
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<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<Vec<TransactionItem>>, 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::<Result<Vec<_>, 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,)>,