You've already forked AstralRinth
forked from didirus/AstralRinth
Supporting documents for Mural payouts (#4721)
* wip: gotenberg * Generate and provide supporting docs for Mural payouts * Correct docs * shear * update cargo lock because r-a complains otherwise * Remove local Gotenberg queue and use Redis instead * Store platform_id in database correctly * Address PR comments * Fix up CI * fix rebase * Add timeout to default env vars
This commit is contained in:
@@ -1,21 +1,35 @@
|
||||
use actix_web::{
|
||||
HttpMessage, HttpResponse, error::ParseError, http::header, post, web,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::fmt;
|
||||
|
||||
use actix_web::{HttpMessage, error::ParseError, http::header, post, web};
|
||||
use ariadne::ids::base62_impl::parse_base62;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tracing::trace;
|
||||
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models::ids::PayoutId;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::error::Context;
|
||||
use crate::util::gotenberg::{
|
||||
GeneratedPdfType, MODRINTH_GENERATED_PDF_TYPE, MODRINTH_PAYMENT_ID,
|
||||
PAYMENT_STATEMENTS_NAMESPACE,
|
||||
};
|
||||
use crate::util::guards::internal_network_guard;
|
||||
|
||||
/// Document generated by Gotenberg and returned to us.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GotenbergDocument {
|
||||
/// Body of the document as a base 64 string.
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
cfg.service(success).service(error);
|
||||
cfg.service(success_callback).service(error_callback);
|
||||
}
|
||||
|
||||
#[post("/gotenberg/success", guard = "internal_network_guard")]
|
||||
pub async fn success(
|
||||
pub async fn success_callback(
|
||||
web::Header(header::ContentDisposition {
|
||||
disposition,
|
||||
parameters: disposition_parameters,
|
||||
@@ -26,7 +40,8 @@ pub async fn success(
|
||||
>,
|
||||
maybe_payment_id: Option<web::Header<ModrinthPaymentId>>,
|
||||
body: web::Bytes,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
redis: web::Data<RedisPool>,
|
||||
) -> Result<(), ApiError> {
|
||||
trace!(
|
||||
%trace,
|
||||
%disposition,
|
||||
@@ -37,25 +52,65 @@ pub async fn success(
|
||||
"Received Gotenberg generated PDF"
|
||||
);
|
||||
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
let payout_id = maybe_payment_id
|
||||
.wrap_request_err("no payout ID for document")?
|
||||
.0
|
||||
.0;
|
||||
|
||||
let mut redis = redis
|
||||
.connect()
|
||||
.await
|
||||
.wrap_internal_err("failed to get Redis connection")?;
|
||||
|
||||
let body = base64::engine::general_purpose::STANDARD.encode(&body);
|
||||
|
||||
let redis_msg = serde_json::to_string(&Ok::<
|
||||
GotenbergDocument,
|
||||
GotenbergError,
|
||||
>(GotenbergDocument { body }))
|
||||
.wrap_internal_err("failed to serialize document to JSON")?;
|
||||
|
||||
redis
|
||||
.lpush(
|
||||
PAYMENT_STATEMENTS_NAMESPACE,
|
||||
&payout_id.to_string(),
|
||||
&redis_msg,
|
||||
)
|
||||
.await
|
||||
.wrap_internal_err("failed to send document over Redis")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ErrorBody {
|
||||
status: Option<String>,
|
||||
message: Option<String>,
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Error)]
|
||||
pub struct GotenbergError {
|
||||
pub status: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Display for GotenbergError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match (&self.status, &self.message) {
|
||||
(None, None) => write!(f, "(unknown error)"),
|
||||
(Some(status), None) => write!(f, "status {status}"),
|
||||
(None, Some(message)) => write!(f, "(no status) {message}"),
|
||||
(Some(status), Some(message)) => {
|
||||
write!(f, "status {status}: {message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/gotenberg/error", guard = "internal_network_guard")]
|
||||
pub async fn error(
|
||||
pub async fn error_callback(
|
||||
web::Header(GotenbergTrace(trace)): web::Header<GotenbergTrace>,
|
||||
web::Header(ModrinthGeneratedPdfType(r#type)): web::Header<
|
||||
ModrinthGeneratedPdfType,
|
||||
>,
|
||||
maybe_payment_id: Option<web::Header<ModrinthPaymentId>>,
|
||||
web::Json(error_body): web::Json<ErrorBody>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
web::Json(error_body): web::Json<GotenbergError>,
|
||||
redis: web::Data<RedisPool>,
|
||||
) -> Result<(), ApiError> {
|
||||
trace!(
|
||||
%trace,
|
||||
r#type = r#type.as_str(),
|
||||
@@ -64,7 +119,31 @@ pub async fn error(
|
||||
"Received Gotenberg error webhook"
|
||||
);
|
||||
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
let payout_id = maybe_payment_id
|
||||
.wrap_request_err("no payout ID for document")?
|
||||
.0
|
||||
.0;
|
||||
let mut redis = redis
|
||||
.connect()
|
||||
.await
|
||||
.wrap_internal_err("failed to get Redis connection")?;
|
||||
|
||||
let redis_msg = serde_json::to_string(&Err::<
|
||||
GotenbergDocument,
|
||||
GotenbergError,
|
||||
>(error_body))
|
||||
.wrap_internal_err("failed to serialize error to JSON")?;
|
||||
|
||||
redis
|
||||
.lpush(
|
||||
PAYMENT_STATEMENTS_NAMESPACE,
|
||||
&payout_id.to_string(),
|
||||
&redis_msg,
|
||||
)
|
||||
.await
|
||||
.wrap_internal_err("failed to send error over Redis")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -122,14 +201,14 @@ impl header::Header for ModrinthGeneratedPdfType {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ModrinthPaymentId(String);
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ModrinthPaymentId(pub PayoutId);
|
||||
|
||||
impl header::TryIntoHeaderValue for ModrinthPaymentId {
|
||||
type Error = header::InvalidHeaderValue;
|
||||
|
||||
fn try_into_value(self) -> Result<header::HeaderValue, Self::Error> {
|
||||
header::HeaderValue::from_str(&self.0)
|
||||
header::HeaderValue::from_str(&self.0.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +223,7 @@ impl header::Header for ModrinthPaymentId {
|
||||
.ok_or(ParseError::Header)?
|
||||
.to_str()
|
||||
.map_err(|_| ParseError::Header)
|
||||
.map(ToOwned::to_owned)
|
||||
.map(ModrinthPaymentId)
|
||||
.and_then(|s| parse_base62(s).map_err(|_| ParseError::Header))
|
||||
.map(|id| Self(PayoutId(id)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,12 @@ use crate::models::payouts::{
|
||||
MuralPayDetails, PayoutMethodRequest, PayoutMethodType, PayoutStatus,
|
||||
TremendousDetails, TremendousForexResponse,
|
||||
};
|
||||
use crate::queue::payouts::PayoutsQueue;
|
||||
use crate::queue::payouts::{PayoutFees, PayoutsQueue};
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::avalara1099;
|
||||
use crate::util::error::Context;
|
||||
use crate::util::gotenberg::GotenbergClient;
|
||||
use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use eyre::eyre;
|
||||
@@ -476,6 +477,7 @@ pub async fn create_payout(
|
||||
body: web::Json<Withdrawal>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
payouts_queue: web::Data<PayoutsQueue>,
|
||||
gotenberg: web::Data<GotenbergClient>,
|
||||
) -> Result<(), ApiError> {
|
||||
let (scopes, user) = get_user_record_from_bearer_token(
|
||||
&req,
|
||||
@@ -609,6 +611,8 @@ pub async fn create_payout(
|
||||
body: &body,
|
||||
user: &user,
|
||||
payout_id,
|
||||
gross_amount: body.amount,
|
||||
fees,
|
||||
amount_minus_fee,
|
||||
total_fee: fees.total_fee(),
|
||||
sent_to_method,
|
||||
@@ -623,7 +627,7 @@ pub async fn create_payout(
|
||||
tremendous_payout(payout_cx, method_details).await?
|
||||
}
|
||||
PayoutMethodRequest::MuralPay { method_details } => {
|
||||
mural_pay_payout(payout_cx, method_details).await?
|
||||
mural_pay_payout(payout_cx, method_details, &gotenberg).await?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -648,6 +652,8 @@ struct PayoutContext<'a> {
|
||||
body: &'a Withdrawal,
|
||||
user: &'a DBUser,
|
||||
payout_id: DBPayoutId,
|
||||
gross_amount: Decimal,
|
||||
fees: PayoutFees,
|
||||
/// Set as the [`DBPayout::amount`] field.
|
||||
amount_minus_fee: Decimal,
|
||||
/// Set as the [`DBPayout::fee`] field.
|
||||
@@ -674,6 +680,8 @@ async fn tremendous_payout(
|
||||
body,
|
||||
user,
|
||||
payout_id,
|
||||
gross_amount: _,
|
||||
fees: _,
|
||||
amount_minus_fee,
|
||||
total_fee,
|
||||
sent_to_method,
|
||||
@@ -686,18 +694,6 @@ async fn tremendous_payout(
|
||||
) -> 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: amount_minus_fee,
|
||||
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,
|
||||
@@ -766,36 +762,47 @@ async fn tremendous_payout(
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(reward) = res.order.rewards.first() {
|
||||
payout_item.platform_id = Some(reward.id.clone())
|
||||
}
|
||||
let platform_id = res.order.rewards.first().map(|reward| reward.id.clone());
|
||||
|
||||
Ok(payout_item)
|
||||
Ok(DBPayout {
|
||||
id: payout_id,
|
||||
user_id: user.id,
|
||||
created: Utc::now(),
|
||||
status: PayoutStatus::InTransit,
|
||||
amount: amount_minus_fee,
|
||||
fee: Some(total_fee),
|
||||
method: Some(PayoutMethodType::Tremendous),
|
||||
method_address: Some(user_email.to_string()),
|
||||
platform_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn mural_pay_payout(
|
||||
PayoutContext {
|
||||
body: _body,
|
||||
body: _,
|
||||
user,
|
||||
payout_id,
|
||||
gross_amount,
|
||||
fees,
|
||||
amount_minus_fee,
|
||||
total_fee,
|
||||
sent_to_method,
|
||||
sent_to_method: _,
|
||||
payouts_queue,
|
||||
}: PayoutContext<'_>,
|
||||
details: &MuralPayDetails,
|
||||
gotenberg: &GotenbergClient,
|
||||
) -> Result<DBPayout, ApiError> {
|
||||
let user_email = get_verified_email(user)?;
|
||||
|
||||
let payout_request = payouts_queue
|
||||
.create_muralpay_payout_request(
|
||||
payout_id,
|
||||
user.id.into(),
|
||||
muralpay::TokenAmount {
|
||||
token_symbol: muralpay::USDC.into(),
|
||||
token_amount: sent_to_method,
|
||||
},
|
||||
gross_amount,
|
||||
fees,
|
||||
details.payout_details.clone(),
|
||||
details.recipient_info.clone(),
|
||||
gotenberg,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -817,6 +824,8 @@ async fn paypal_payout(
|
||||
body,
|
||||
user,
|
||||
payout_id,
|
||||
gross_amount: _,
|
||||
fees: _,
|
||||
amount_minus_fee,
|
||||
total_fee,
|
||||
sent_to_method,
|
||||
@@ -948,18 +957,39 @@ async fn paypal_payout(
|
||||
pub enum TransactionItem {
|
||||
/// User withdrew some of their available payout.
|
||||
Withdrawal {
|
||||
/// ID of the payout.
|
||||
id: PayoutId,
|
||||
/// Status of this payout.
|
||||
status: PayoutStatus,
|
||||
/// When the payout was created.
|
||||
created: DateTime<Utc>,
|
||||
/// How much the user got from this payout, excluding fees.
|
||||
amount: Decimal,
|
||||
/// How much the user paid in fees for this payout, on top of `amount`.
|
||||
fee: Option<Decimal>,
|
||||
/// What payout method type was used for this.
|
||||
method_type: Option<PayoutMethodType>,
|
||||
/// Payout-method-specific ID for the type of payout the user got.
|
||||
///
|
||||
/// - Tremendous: the rewarded gift card ID.
|
||||
/// - Mural: the payment rail used, i.e. crypto USDC or fiat USD.
|
||||
/// - PayPal: `paypal_us`
|
||||
/// - Venmo: `venmo`
|
||||
///
|
||||
/// For legacy transactions, this may be [`None`] as we did not always
|
||||
/// store this payout info.
|
||||
method_id: Option<String>,
|
||||
/// Payout-method-specific address which the payout was sent to, like
|
||||
/// an email address.
|
||||
method_address: Option<String>,
|
||||
},
|
||||
/// User got a payout available for them to withdraw.
|
||||
PayoutAvailable {
|
||||
/// When this payout was made available for the user to withdraw.
|
||||
created: DateTime<Utc>,
|
||||
/// Where this payout came from.
|
||||
payout_source: PayoutSource,
|
||||
/// How much the payout was worth.
|
||||
amount: Decimal,
|
||||
},
|
||||
}
|
||||
@@ -1031,6 +1061,9 @@ pub async fn transaction_history(
|
||||
amount: payout.amount,
|
||||
fee: payout.fee,
|
||||
method_type: payout.method,
|
||||
// TODO: store the `method_id` in the database, and return it here
|
||||
// don't use the `platform_id`, that's something else
|
||||
method_id: None,
|
||||
method_address: payout.method_address,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user