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:
@@ -249,6 +249,7 @@ redundant_clone = "warn"
|
|||||||
redundant_feature_names = "warn"
|
redundant_feature_names = "warn"
|
||||||
redundant_type_annotations = "warn"
|
redundant_type_annotations = "warn"
|
||||||
todo = "warn"
|
todo = "warn"
|
||||||
|
too_many_arguments = "allow"
|
||||||
uninlined_format_args = "warn"
|
uninlined_format_args = "warn"
|
||||||
unnested_or_patterns = "warn"
|
unnested_or_patterns = "warn"
|
||||||
wildcard_dependencies = "warn"
|
wildcard_dependencies = "warn"
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ ANROK_API_URL=none
|
|||||||
|
|
||||||
GOTENBERG_URL=http://labrinth-gotenberg:13000
|
GOTENBERG_URL=http://labrinth-gotenberg:13000
|
||||||
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
|
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
|
||||||
|
GOTENBERG_TIMEOUT=30000
|
||||||
|
|
||||||
ARCHON_URL=none
|
ARCHON_URL=none
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ MEILISEARCH_KEY=modrinth
|
|||||||
REDIS_URL=redis://localhost
|
REDIS_URL=redis://localhost
|
||||||
REDIS_MAX_CONNECTIONS=10000
|
REDIS_MAX_CONNECTIONS=10000
|
||||||
|
|
||||||
BIND_ADDR=127.0.0.1:8000
|
# Must bind to broadcast, not localhost, because some
|
||||||
|
# Docker services (Gotenberg) must be able to reach the backend
|
||||||
|
# from a different network interface
|
||||||
|
BIND_ADDR=0.0.0.0:8000
|
||||||
SELF_ADDR=http://127.0.0.1:8000
|
SELF_ADDR=http://127.0.0.1:8000
|
||||||
|
|
||||||
MODERATION_SLACK_WEBHOOK=
|
MODERATION_SLACK_WEBHOOK=
|
||||||
@@ -145,6 +148,7 @@ ANROK_API_URL=none
|
|||||||
|
|
||||||
GOTENBERG_URL=http://localhost:13000
|
GOTENBERG_URL=http://localhost:13000
|
||||||
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
|
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
|
||||||
|
GOTENBERG_TIMEOUT=30000
|
||||||
|
|
||||||
ARCHON_URL=none
|
ARCHON_URL=none
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ use dashmap::DashMap;
|
|||||||
use deadpool_redis::{Config, Runtime};
|
use deadpool_redis::{Config, Runtime};
|
||||||
use futures::future::Either;
|
use futures::future::Either;
|
||||||
use prometheus::{IntGauge, Registry};
|
use prometheus::{IntGauge, Registry};
|
||||||
use redis::{Cmd, ExistenceCheck, SetExpiry, SetOptions, cmd};
|
use redis::{
|
||||||
|
AsyncTypedCommands, Cmd, ExistenceCheck, SetExpiry, SetOptions,
|
||||||
|
ToRedisArgs, cmd,
|
||||||
|
};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -680,6 +683,30 @@ impl RedisConnection {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn lpush(
|
||||||
|
&mut self,
|
||||||
|
namespace: &str,
|
||||||
|
key: &str,
|
||||||
|
value: impl ToRedisArgs + Send + Sync,
|
||||||
|
) -> Result<(), DatabaseError> {
|
||||||
|
let key = format!("{}_{namespace}:{key}", self.meta_namespace);
|
||||||
|
self.connection.lpush(key, value).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn brpop(
|
||||||
|
&mut self,
|
||||||
|
namespace: &str,
|
||||||
|
key: &str,
|
||||||
|
timeout: Option<f64>,
|
||||||
|
) -> Result<Option<[String; 2]>, DatabaseError> {
|
||||||
|
let key = format!("{}_{namespace}:{key}", self.meta_namespace);
|
||||||
|
// a timeout of 0 is infinite
|
||||||
|
let timeout = timeout.unwrap_or(0.0);
|
||||||
|
let values = self.connection.brpop(key, timeout).await?;
|
||||||
|
Ok(values)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -503,6 +503,7 @@ pub fn check_env_vars() -> bool {
|
|||||||
|
|
||||||
failed |= check_var::<String>("GOTENBERG_URL");
|
failed |= check_var::<String>("GOTENBERG_URL");
|
||||||
failed |= check_var::<String>("GOTENBERG_CALLBACK_BASE");
|
failed |= check_var::<String>("GOTENBERG_CALLBACK_BASE");
|
||||||
|
failed |= check_var::<String>("GOTENBERG_TIMEOUT");
|
||||||
|
|
||||||
failed |= check_var::<String>("STRIPE_API_KEY");
|
failed |= check_var::<String>("STRIPE_API_KEY");
|
||||||
failed |= check_var::<String>("STRIPE_WEBHOOK_SECRET");
|
failed |= check_var::<String>("STRIPE_WEBHOOK_SECRET");
|
||||||
|
|||||||
@@ -153,8 +153,8 @@ async fn main() -> std::io::Result<()> {
|
|||||||
let email_queue =
|
let email_queue =
|
||||||
EmailQueue::init(pool.clone(), redis_pool.clone()).unwrap();
|
EmailQueue::init(pool.clone(), redis_pool.clone()).unwrap();
|
||||||
|
|
||||||
let gotenberg_client =
|
let gotenberg_client = GotenbergClient::from_env(redis_pool.clone())
|
||||||
GotenbergClient::from_env().expect("Failed to create Gotenberg client");
|
.expect("Failed to create Gotenberg client");
|
||||||
|
|
||||||
if let Some(task) = args.run_background_task {
|
if let Some(task) = args.run_background_task {
|
||||||
info!("Running task {task:?} and exiting");
|
info!("Running task {task:?} and exiting");
|
||||||
|
|||||||
@@ -726,7 +726,7 @@ impl PayoutsQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct PayoutFees {
|
pub struct PayoutFees {
|
||||||
/// Fee which is taken by the underlying method we're using.
|
/// Fee which is taken by the underlying method we're using.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
use ariadne::ids::UserId;
|
use ariadne::ids::UserId;
|
||||||
|
use chrono::Utc;
|
||||||
use eyre::{Result, eyre};
|
use eyre::{Result, eyre};
|
||||||
use muralpay::{MuralError, TokenFeeRequest};
|
use muralpay::{MuralError, TokenFeeRequest};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::{Decimal, prelude::ToPrimitive};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
queue::payouts::{AccountBalance, PayoutsQueue},
|
database::models::DBPayoutId,
|
||||||
|
queue::payouts::{AccountBalance, PayoutFees, PayoutsQueue},
|
||||||
routes::ApiError,
|
routes::ApiError,
|
||||||
util::error::Context,
|
util::{
|
||||||
|
error::Context,
|
||||||
|
gotenberg::{GotenbergClient, PaymentStatement},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
@@ -54,10 +59,13 @@ impl PayoutsQueue {
|
|||||||
|
|
||||||
pub async fn create_muralpay_payout_request(
|
pub async fn create_muralpay_payout_request(
|
||||||
&self,
|
&self,
|
||||||
|
payout_id: DBPayoutId,
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
amount: muralpay::TokenAmount,
|
gross_amount: Decimal,
|
||||||
|
fees: PayoutFees,
|
||||||
payout_details: MuralPayoutRequest,
|
payout_details: MuralPayoutRequest,
|
||||||
recipient_info: muralpay::PayoutRecipientInfo,
|
recipient_info: muralpay::PayoutRecipientInfo,
|
||||||
|
gotenberg: &GotenbergClient,
|
||||||
) -> Result<muralpay::PayoutRequest, ApiError> {
|
) -> Result<muralpay::PayoutRequest, ApiError> {
|
||||||
let muralpay = self.muralpay.load();
|
let muralpay = self.muralpay.load();
|
||||||
let muralpay = muralpay
|
let muralpay = muralpay
|
||||||
@@ -86,11 +94,71 @@ impl PayoutsQueue {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mural takes `fees.method_fee` off the top of the amount we tell them to send
|
||||||
|
let sent_to_method = gross_amount - fees.platform_fee;
|
||||||
|
// ..so the net is `gross - platform_fee - method_fee`
|
||||||
|
let net_amount = gross_amount - fees.total_fee();
|
||||||
|
|
||||||
|
let recipient_address = recipient_info.physical_address();
|
||||||
|
let recipient_email = recipient_info.email().to_string();
|
||||||
|
let gross_amount_cents = gross_amount * Decimal::from(100);
|
||||||
|
let net_amount_cents = net_amount * Decimal::from(100);
|
||||||
|
let fees_cents = fees.total_fee() * Decimal::from(100);
|
||||||
|
let address_line_3 = format!(
|
||||||
|
"{}, {}, {}",
|
||||||
|
recipient_address.city,
|
||||||
|
recipient_address.state,
|
||||||
|
recipient_address.zip
|
||||||
|
);
|
||||||
|
|
||||||
|
let payment_statement = PaymentStatement {
|
||||||
|
payment_id: payout_id.into(),
|
||||||
|
recipient_address_line_1: Some(recipient_address.address1.clone()),
|
||||||
|
recipient_address_line_2: recipient_address.address2.clone(),
|
||||||
|
recipient_address_line_3: Some(address_line_3),
|
||||||
|
recipient_email,
|
||||||
|
payment_date: Utc::now(),
|
||||||
|
gross_amount_cents: gross_amount_cents
|
||||||
|
.to_i64()
|
||||||
|
.wrap_internal_err_with(|| eyre!("gross amount of cents `{gross_amount_cents}` cannot be expressed as an `i64`"))?,
|
||||||
|
net_amount_cents: net_amount_cents
|
||||||
|
.to_i64()
|
||||||
|
.wrap_internal_err_with(|| eyre!("net amount of cents `{net_amount_cents}` cannot be expressed as an `i64`"))?,
|
||||||
|
fees_cents: fees_cents
|
||||||
|
.to_i64()
|
||||||
|
.wrap_internal_err_with(|| eyre!("fees amount of cents `{fees_cents}` cannot be expressed as an `i64`"))?,
|
||||||
|
currency_code: "USD".into(),
|
||||||
|
};
|
||||||
|
let payment_statement_doc = gotenberg
|
||||||
|
.wait_for_payment_statement(&payment_statement)
|
||||||
|
.await
|
||||||
|
.wrap_internal_err("failed to generate payment statement")?;
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
// std::fs::write(
|
||||||
|
// "/tmp/modrinth-payout-statement.pdf",
|
||||||
|
// base64::Engine::decode(
|
||||||
|
// &base64::engine::general_purpose::STANDARD,
|
||||||
|
// &payment_statement_doc.body,
|
||||||
|
// )
|
||||||
|
// .unwrap(),
|
||||||
|
// )
|
||||||
|
// .unwrap();
|
||||||
|
|
||||||
let payout = muralpay::CreatePayout {
|
let payout = muralpay::CreatePayout {
|
||||||
amount,
|
amount: muralpay::TokenAmount {
|
||||||
|
token_amount: sent_to_method,
|
||||||
|
token_symbol: muralpay::USDC.into(),
|
||||||
|
},
|
||||||
payout_details,
|
payout_details,
|
||||||
recipient_info,
|
recipient_info,
|
||||||
supporting_details: None,
|
supporting_details: Some(muralpay::SupportingDetails {
|
||||||
|
supporting_document: Some(format!(
|
||||||
|
"data:application/pdf;base64,{}",
|
||||||
|
payment_statement_doc.body
|
||||||
|
)),
|
||||||
|
payout_purpose: Some(muralpay::PayoutPurpose::VendorPayment),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
let payout_request = muralpay
|
let payout_request = muralpay
|
||||||
@@ -103,7 +171,9 @@ impl PayoutsQueue {
|
|||||||
.await
|
.await
|
||||||
.map_err(|err| match err {
|
.map_err(|err| match err {
|
||||||
MuralError::Api(err) => ApiError::Request(err.into()),
|
MuralError::Api(err) => ApiError::Request(err.into()),
|
||||||
err => ApiError::Internal(err.into()),
|
err => ApiError::Internal(
|
||||||
|
eyre!(err).wrap_err("failed to create payout request"),
|
||||||
|
),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// try to immediately execute the payout request...
|
// try to immediately execute the payout request...
|
||||||
|
|||||||
@@ -1,21 +1,35 @@
|
|||||||
use actix_web::{
|
use std::fmt;
|
||||||
HttpMessage, HttpResponse, error::ParseError, http::header, post, web,
|
|
||||||
};
|
use actix_web::{HttpMessage, error::ParseError, http::header, post, web};
|
||||||
use serde::Deserialize;
|
use ariadne::ids::base62_impl::parse_base62;
|
||||||
|
use base64::Engine;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
use tracing::trace;
|
use tracing::trace;
|
||||||
|
|
||||||
|
use crate::database::redis::RedisPool;
|
||||||
|
use crate::models::ids::PayoutId;
|
||||||
use crate::routes::ApiError;
|
use crate::routes::ApiError;
|
||||||
|
use crate::util::error::Context;
|
||||||
use crate::util::gotenberg::{
|
use crate::util::gotenberg::{
|
||||||
GeneratedPdfType, MODRINTH_GENERATED_PDF_TYPE, MODRINTH_PAYMENT_ID,
|
GeneratedPdfType, MODRINTH_GENERATED_PDF_TYPE, MODRINTH_PAYMENT_ID,
|
||||||
|
PAYMENT_STATEMENTS_NAMESPACE,
|
||||||
};
|
};
|
||||||
use crate::util::guards::internal_network_guard;
|
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) {
|
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")]
|
#[post("/gotenberg/success", guard = "internal_network_guard")]
|
||||||
pub async fn success(
|
pub async fn success_callback(
|
||||||
web::Header(header::ContentDisposition {
|
web::Header(header::ContentDisposition {
|
||||||
disposition,
|
disposition,
|
||||||
parameters: disposition_parameters,
|
parameters: disposition_parameters,
|
||||||
@@ -26,7 +40,8 @@ pub async fn success(
|
|||||||
>,
|
>,
|
||||||
maybe_payment_id: Option<web::Header<ModrinthPaymentId>>,
|
maybe_payment_id: Option<web::Header<ModrinthPaymentId>>,
|
||||||
body: web::Bytes,
|
body: web::Bytes,
|
||||||
) -> Result<HttpResponse, ApiError> {
|
redis: web::Data<RedisPool>,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
trace!(
|
trace!(
|
||||||
%trace,
|
%trace,
|
||||||
%disposition,
|
%disposition,
|
||||||
@@ -37,25 +52,65 @@ pub async fn success(
|
|||||||
"Received Gotenberg generated PDF"
|
"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, Clone, Serialize, Deserialize, Error)]
|
||||||
#[derive(Debug, Deserialize)]
|
pub struct GotenbergError {
|
||||||
pub struct ErrorBody {
|
pub status: Option<String>,
|
||||||
status: Option<String>,
|
pub message: Option<String>,
|
||||||
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")]
|
#[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(GotenbergTrace(trace)): web::Header<GotenbergTrace>,
|
||||||
web::Header(ModrinthGeneratedPdfType(r#type)): web::Header<
|
web::Header(ModrinthGeneratedPdfType(r#type)): web::Header<
|
||||||
ModrinthGeneratedPdfType,
|
ModrinthGeneratedPdfType,
|
||||||
>,
|
>,
|
||||||
maybe_payment_id: Option<web::Header<ModrinthPaymentId>>,
|
maybe_payment_id: Option<web::Header<ModrinthPaymentId>>,
|
||||||
web::Json(error_body): web::Json<ErrorBody>,
|
web::Json(error_body): web::Json<GotenbergError>,
|
||||||
) -> Result<HttpResponse, ApiError> {
|
redis: web::Data<RedisPool>,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
trace!(
|
trace!(
|
||||||
%trace,
|
%trace,
|
||||||
r#type = r#type.as_str(),
|
r#type = r#type.as_str(),
|
||||||
@@ -64,7 +119,31 @@ pub async fn error(
|
|||||||
"Received Gotenberg error webhook"
|
"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)]
|
#[derive(Debug)]
|
||||||
@@ -122,14 +201,14 @@ impl header::Header for ModrinthGeneratedPdfType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
struct ModrinthPaymentId(String);
|
pub struct ModrinthPaymentId(pub PayoutId);
|
||||||
|
|
||||||
impl header::TryIntoHeaderValue for ModrinthPaymentId {
|
impl header::TryIntoHeaderValue for ModrinthPaymentId {
|
||||||
type Error = header::InvalidHeaderValue;
|
type Error = header::InvalidHeaderValue;
|
||||||
|
|
||||||
fn try_into_value(self) -> Result<header::HeaderValue, Self::Error> {
|
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)?
|
.ok_or(ParseError::Header)?
|
||||||
.to_str()
|
.to_str()
|
||||||
.map_err(|_| ParseError::Header)
|
.map_err(|_| ParseError::Header)
|
||||||
.map(ToOwned::to_owned)
|
.and_then(|s| parse_base62(s).map_err(|_| ParseError::Header))
|
||||||
.map(ModrinthPaymentId)
|
.map(|id| Self(PayoutId(id)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ use crate::models::payouts::{
|
|||||||
MuralPayDetails, PayoutMethodRequest, PayoutMethodType, PayoutStatus,
|
MuralPayDetails, PayoutMethodRequest, PayoutMethodType, PayoutStatus,
|
||||||
TremendousDetails, TremendousForexResponse,
|
TremendousDetails, TremendousForexResponse,
|
||||||
};
|
};
|
||||||
use crate::queue::payouts::PayoutsQueue;
|
use crate::queue::payouts::{PayoutFees, PayoutsQueue};
|
||||||
use crate::queue::session::AuthQueue;
|
use crate::queue::session::AuthQueue;
|
||||||
use crate::routes::ApiError;
|
use crate::routes::ApiError;
|
||||||
use crate::util::avalara1099;
|
use crate::util::avalara1099;
|
||||||
use crate::util::error::Context;
|
use crate::util::error::Context;
|
||||||
|
use crate::util::gotenberg::GotenbergClient;
|
||||||
use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
|
use actix_web::{HttpRequest, HttpResponse, delete, get, post, web};
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use eyre::eyre;
|
use eyre::eyre;
|
||||||
@@ -476,6 +477,7 @@ pub async fn create_payout(
|
|||||||
body: web::Json<Withdrawal>,
|
body: web::Json<Withdrawal>,
|
||||||
session_queue: web::Data<AuthQueue>,
|
session_queue: web::Data<AuthQueue>,
|
||||||
payouts_queue: web::Data<PayoutsQueue>,
|
payouts_queue: web::Data<PayoutsQueue>,
|
||||||
|
gotenberg: web::Data<GotenbergClient>,
|
||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
let (scopes, user) = get_user_record_from_bearer_token(
|
let (scopes, user) = get_user_record_from_bearer_token(
|
||||||
&req,
|
&req,
|
||||||
@@ -609,6 +611,8 @@ pub async fn create_payout(
|
|||||||
body: &body,
|
body: &body,
|
||||||
user: &user,
|
user: &user,
|
||||||
payout_id,
|
payout_id,
|
||||||
|
gross_amount: body.amount,
|
||||||
|
fees,
|
||||||
amount_minus_fee,
|
amount_minus_fee,
|
||||||
total_fee: fees.total_fee(),
|
total_fee: fees.total_fee(),
|
||||||
sent_to_method,
|
sent_to_method,
|
||||||
@@ -623,7 +627,7 @@ pub async fn create_payout(
|
|||||||
tremendous_payout(payout_cx, method_details).await?
|
tremendous_payout(payout_cx, method_details).await?
|
||||||
}
|
}
|
||||||
PayoutMethodRequest::MuralPay { method_details } => {
|
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,
|
body: &'a Withdrawal,
|
||||||
user: &'a DBUser,
|
user: &'a DBUser,
|
||||||
payout_id: DBPayoutId,
|
payout_id: DBPayoutId,
|
||||||
|
gross_amount: Decimal,
|
||||||
|
fees: PayoutFees,
|
||||||
/// Set as the [`DBPayout::amount`] field.
|
/// Set as the [`DBPayout::amount`] field.
|
||||||
amount_minus_fee: Decimal,
|
amount_minus_fee: Decimal,
|
||||||
/// Set as the [`DBPayout::fee`] field.
|
/// Set as the [`DBPayout::fee`] field.
|
||||||
@@ -674,6 +680,8 @@ async fn tremendous_payout(
|
|||||||
body,
|
body,
|
||||||
user,
|
user,
|
||||||
payout_id,
|
payout_id,
|
||||||
|
gross_amount: _,
|
||||||
|
fees: _,
|
||||||
amount_minus_fee,
|
amount_minus_fee,
|
||||||
total_fee,
|
total_fee,
|
||||||
sent_to_method,
|
sent_to_method,
|
||||||
@@ -686,18 +694,6 @@ async fn tremendous_payout(
|
|||||||
) -> Result<DBPayout, ApiError> {
|
) -> Result<DBPayout, ApiError> {
|
||||||
let user_email = get_verified_email(user)?;
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct Reward {
|
struct Reward {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -766,36 +762,47 @@ async fn tremendous_payout(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(reward) = res.order.rewards.first() {
|
let platform_id = res.order.rewards.first().map(|reward| reward.id.clone());
|
||||||
payout_item.platform_id = Some(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(
|
async fn mural_pay_payout(
|
||||||
PayoutContext {
|
PayoutContext {
|
||||||
body: _body,
|
body: _,
|
||||||
user,
|
user,
|
||||||
payout_id,
|
payout_id,
|
||||||
|
gross_amount,
|
||||||
|
fees,
|
||||||
amount_minus_fee,
|
amount_minus_fee,
|
||||||
total_fee,
|
total_fee,
|
||||||
sent_to_method,
|
sent_to_method: _,
|
||||||
payouts_queue,
|
payouts_queue,
|
||||||
}: PayoutContext<'_>,
|
}: PayoutContext<'_>,
|
||||||
details: &MuralPayDetails,
|
details: &MuralPayDetails,
|
||||||
|
gotenberg: &GotenbergClient,
|
||||||
) -> Result<DBPayout, ApiError> {
|
) -> Result<DBPayout, ApiError> {
|
||||||
let user_email = get_verified_email(user)?;
|
let user_email = get_verified_email(user)?;
|
||||||
|
|
||||||
let payout_request = payouts_queue
|
let payout_request = payouts_queue
|
||||||
.create_muralpay_payout_request(
|
.create_muralpay_payout_request(
|
||||||
|
payout_id,
|
||||||
user.id.into(),
|
user.id.into(),
|
||||||
muralpay::TokenAmount {
|
gross_amount,
|
||||||
token_symbol: muralpay::USDC.into(),
|
fees,
|
||||||
token_amount: sent_to_method,
|
|
||||||
},
|
|
||||||
details.payout_details.clone(),
|
details.payout_details.clone(),
|
||||||
details.recipient_info.clone(),
|
details.recipient_info.clone(),
|
||||||
|
gotenberg,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -817,6 +824,8 @@ async fn paypal_payout(
|
|||||||
body,
|
body,
|
||||||
user,
|
user,
|
||||||
payout_id,
|
payout_id,
|
||||||
|
gross_amount: _,
|
||||||
|
fees: _,
|
||||||
amount_minus_fee,
|
amount_minus_fee,
|
||||||
total_fee,
|
total_fee,
|
||||||
sent_to_method,
|
sent_to_method,
|
||||||
@@ -948,18 +957,39 @@ async fn paypal_payout(
|
|||||||
pub enum TransactionItem {
|
pub enum TransactionItem {
|
||||||
/// User withdrew some of their available payout.
|
/// User withdrew some of their available payout.
|
||||||
Withdrawal {
|
Withdrawal {
|
||||||
|
/// ID of the payout.
|
||||||
id: PayoutId,
|
id: PayoutId,
|
||||||
|
/// Status of this payout.
|
||||||
status: PayoutStatus,
|
status: PayoutStatus,
|
||||||
|
/// When the payout was created.
|
||||||
created: DateTime<Utc>,
|
created: DateTime<Utc>,
|
||||||
|
/// How much the user got from this payout, excluding fees.
|
||||||
amount: Decimal,
|
amount: Decimal,
|
||||||
|
/// How much the user paid in fees for this payout, on top of `amount`.
|
||||||
fee: Option<Decimal>,
|
fee: Option<Decimal>,
|
||||||
|
/// What payout method type was used for this.
|
||||||
method_type: Option<PayoutMethodType>,
|
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>,
|
method_address: Option<String>,
|
||||||
},
|
},
|
||||||
/// User got a payout available for them to withdraw.
|
/// User got a payout available for them to withdraw.
|
||||||
PayoutAvailable {
|
PayoutAvailable {
|
||||||
|
/// When this payout was made available for the user to withdraw.
|
||||||
created: DateTime<Utc>,
|
created: DateTime<Utc>,
|
||||||
|
/// Where this payout came from.
|
||||||
payout_source: PayoutSource,
|
payout_source: PayoutSource,
|
||||||
|
/// How much the payout was worth.
|
||||||
amount: Decimal,
|
amount: Decimal,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1031,6 +1061,9 @@ pub async fn transaction_history(
|
|||||||
amount: payout.amount,
|
amount: payout.amount,
|
||||||
fee: payout.fee,
|
fee: payout.fee,
|
||||||
method_type: payout.method,
|
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,
|
method_address: payout.method_address,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig {
|
|||||||
let anrok_client = anrok::Client::from_env().unwrap();
|
let anrok_client = anrok::Client::from_env().unwrap();
|
||||||
let email_queue =
|
let email_queue =
|
||||||
EmailQueue::init(pool.clone(), redis_pool.clone()).unwrap();
|
EmailQueue::init(pool.clone(), redis_pool.clone()).unwrap();
|
||||||
let gotenberg_client =
|
let gotenberg_client = GotenbergClient::from_env(redis_pool.clone())
|
||||||
GotenbergClient::from_env().expect("Failed to create Gotenberg client");
|
.expect("Failed to create Gotenberg client");
|
||||||
|
|
||||||
crate::app_setup(
|
crate::app_setup(
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
|
use crate::database::redis::RedisPool;
|
||||||
|
use crate::models::ids::PayoutId;
|
||||||
use crate::routes::ApiError;
|
use crate::routes::ApiError;
|
||||||
|
use crate::routes::internal::gotenberg::{GotenbergDocument, GotenbergError};
|
||||||
|
use crate::util::env::env_var;
|
||||||
use crate::util::error::Context;
|
use crate::util::error::Context;
|
||||||
use actix_web::http::header::HeaderName;
|
use actix_web::http::header::HeaderName;
|
||||||
|
use chrono::{DateTime, Datelike, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
pub const MODRINTH_GENERATED_PDF_TYPE: HeaderName =
|
pub const MODRINTH_GENERATED_PDF_TYPE: HeaderName =
|
||||||
HeaderName::from_static("modrinth-generated-pdf-type");
|
HeaderName::from_static("modrinth-generated-pdf-type");
|
||||||
pub const MODRINTH_PAYMENT_ID: HeaderName =
|
pub const MODRINTH_PAYMENT_ID: HeaderName =
|
||||||
HeaderName::from_static("modrinth-payment-id");
|
HeaderName::from_static("modrinth-payment-id");
|
||||||
|
pub const PAYMENT_STATEMENTS_NAMESPACE: &str = "payment_statements";
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct PaymentStatement {
|
pub struct PaymentStatement {
|
||||||
pub payment_id: String,
|
pub payment_id: PayoutId,
|
||||||
pub recipient_address_line_1: Option<String>,
|
pub recipient_address_line_1: Option<String>,
|
||||||
pub recipient_address_line_2: Option<String>,
|
pub recipient_address_line_2: Option<String>,
|
||||||
pub recipient_address_line_3: Option<String>,
|
pub recipient_address_line_3: Option<String>,
|
||||||
pub recipient_email: String,
|
pub recipient_email: String,
|
||||||
pub payment_date: String,
|
pub payment_date: DateTime<Utc>,
|
||||||
pub gross_amount_cents: i64,
|
pub gross_amount_cents: i64,
|
||||||
pub net_amount_cents: i64,
|
pub net_amount_cents: i64,
|
||||||
pub fees_cents: i64,
|
pub fees_cents: i64,
|
||||||
@@ -53,28 +60,27 @@ pub struct GotenbergClient {
|
|||||||
gotenberg_url: String,
|
gotenberg_url: String,
|
||||||
site_url: String,
|
site_url: String,
|
||||||
callback_base: String,
|
callback_base: String,
|
||||||
|
redis: RedisPool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GotenbergClient {
|
impl GotenbergClient {
|
||||||
/// Initialize the client from environment variables.
|
/// Initialize the client from environment variables.
|
||||||
pub fn from_env() -> Result<Self, ApiError> {
|
pub fn from_env(redis: RedisPool) -> eyre::Result<Self> {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.user_agent("Modrinth")
|
.user_agent("Modrinth")
|
||||||
.build()
|
.build()
|
||||||
.wrap_internal_err("failed to build reqwest client")?;
|
.wrap_err("failed to build reqwest client")?;
|
||||||
|
|
||||||
let gotenberg_url = dotenvy::var("GOTENBERG_URL")
|
let gotenberg_url = env_var("GOTENBERG_URL")?;
|
||||||
.wrap_internal_err("GOTENBERG_URL is not set")?;
|
let site_url = env_var("SITE_URL")?;
|
||||||
let site_url = dotenvy::var("SITE_URL")
|
let callback_base = env_var("GOTENBERG_CALLBACK_BASE")?;
|
||||||
.wrap_internal_err("SITE_URL is not set")?;
|
|
||||||
let callback_base = dotenvy::var("GOTENBERG_CALLBACK_BASE")
|
|
||||||
.wrap_internal_err("GOTENBERG_CALLBACK_BASE is not set")?;
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
client,
|
client,
|
||||||
gotenberg_url: gotenberg_url.trim_end_matches('/').to_owned(),
|
gotenberg_url: gotenberg_url.trim_end_matches('/').to_owned(),
|
||||||
site_url: site_url.trim_end_matches('/').to_owned(),
|
site_url: site_url.trim_end_matches('/').to_owned(),
|
||||||
callback_base: callback_base.trim_end_matches('/').to_owned(),
|
callback_base: callback_base.trim_end_matches('/').to_owned(),
|
||||||
|
redis,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +146,7 @@ impl GotenbergClient {
|
|||||||
)
|
)
|
||||||
.header(
|
.header(
|
||||||
"Modrinth-Payment-Id",
|
"Modrinth-Payment-Id",
|
||||||
&statement.payment_id,
|
statement.payment_id.to_string(),
|
||||||
)
|
)
|
||||||
.header(
|
.header(
|
||||||
"Gotenberg-Output-Filename",
|
"Gotenberg-Output-Filename",
|
||||||
@@ -155,11 +161,67 @@ impl GotenbergClient {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tells Gotenberg to generate a payment statement PDF, and waits until we
|
||||||
|
/// get a response for that PDF.
|
||||||
|
///
|
||||||
|
/// This submits the PDF via [`GotenbergClient::generate_payment_statement`]
|
||||||
|
/// then waits until some Labrinth instance receives a response on the
|
||||||
|
/// Gotenberg webhook, sends the response over Redis to our instance, and
|
||||||
|
/// returns that from this function.
|
||||||
|
///
|
||||||
|
/// In a local environment, the Labrinth instance that receives the webhook
|
||||||
|
/// response will be the same one that is waiting on the response, so the
|
||||||
|
/// Redis step is unnecessary, but in a real deployment, we have multiple
|
||||||
|
/// Labrinth instances so we need Redis in between.
|
||||||
|
///
|
||||||
|
/// If Gotenberg does not return a response to us within `GOTENBERG_TIMEOUT`
|
||||||
|
/// number of milliseconds, this will fail.
|
||||||
|
pub async fn wait_for_payment_statement(
|
||||||
|
&self,
|
||||||
|
statement: &PaymentStatement,
|
||||||
|
) -> Result<GotenbergDocument, ApiError> {
|
||||||
|
let mut redis = self
|
||||||
|
.redis
|
||||||
|
.connect()
|
||||||
|
.await
|
||||||
|
.wrap_internal_err("failed to get Redis connection")?;
|
||||||
|
|
||||||
|
self.generate_payment_statement(statement).await?;
|
||||||
|
|
||||||
|
let timeout_ms = env_var("GOTENBERG_TIMEOUT")
|
||||||
|
.map_err(ApiError::Internal)?
|
||||||
|
.parse::<u64>()
|
||||||
|
.wrap_internal_err(
|
||||||
|
"`GOTENBERG_TIMEOUT` is not a valid number of milliseconds",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let [_key, document] = tokio::time::timeout(
|
||||||
|
Duration::from_millis(timeout_ms),
|
||||||
|
redis.brpop(
|
||||||
|
PAYMENT_STATEMENTS_NAMESPACE,
|
||||||
|
&statement.payment_id.to_string(),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.wrap_internal_err("Gotenberg document generation timed out")?
|
||||||
|
.wrap_internal_err("failed to get document over Redis")?
|
||||||
|
.wrap_internal_err("no document was returned from Redis")?;
|
||||||
|
|
||||||
|
let document = serde_json::from_str::<
|
||||||
|
Result<GotenbergDocument, GotenbergError>,
|
||||||
|
>(&document)
|
||||||
|
.wrap_internal_err("failed to deserialize Redis document response")?
|
||||||
|
.wrap_internal_err("Gotenberg document generation failed")?;
|
||||||
|
|
||||||
|
Ok(document)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fill_statement_template(html: &str, s: &PaymentStatement) -> String {
|
fn fill_statement_template(html: &str, s: &PaymentStatement) -> String {
|
||||||
let variables: Vec<(&str, String)> = vec![
|
let variables: Vec<(&str, String)> = vec![
|
||||||
("statement.payment_id", s.payment_id.clone()),
|
("statement.payment_id", s.payment_id.to_string()),
|
||||||
(
|
(
|
||||||
"statement.recipient_address_line_1",
|
"statement.recipient_address_line_1",
|
||||||
s.recipient_address_line_1.clone().unwrap_or_default(),
|
s.recipient_address_line_1.clone().unwrap_or_default(),
|
||||||
@@ -173,7 +235,15 @@ fn fill_statement_template(html: &str, s: &PaymentStatement) -> String {
|
|||||||
s.recipient_address_line_3.clone().unwrap_or_default(),
|
s.recipient_address_line_3.clone().unwrap_or_default(),
|
||||||
),
|
),
|
||||||
("statement.recipient_email", s.recipient_email.clone()),
|
("statement.recipient_email", s.recipient_email.clone()),
|
||||||
("statement.payment_date", s.payment_date.clone()),
|
(
|
||||||
|
"statement.payment_date",
|
||||||
|
format!(
|
||||||
|
"{:04}-{:02}-{:02}",
|
||||||
|
s.payment_date.year(),
|
||||||
|
s.payment_date.month(),
|
||||||
|
s.payment_date.day()
|
||||||
|
),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"statement.gross_amount",
|
"statement.gross_amount",
|
||||||
format_money(s.gross_amount_cents, &s.currency_code),
|
format_money(s.gross_amount_cents, &s.currency_code),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: labrinth
|
POSTGRES_PASSWORD: labrinth
|
||||||
POSTGRES_HOST_AUTH_METHOD: trust
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ 'CMD', 'pg_isready' ]
|
test: ['CMD', 'pg_isready']
|
||||||
interval: 3s
|
interval: 3s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -28,7 +28,7 @@ services:
|
|||||||
MEILI_MASTER_KEY: modrinth
|
MEILI_MASTER_KEY: modrinth
|
||||||
MEILI_HTTP_PAYLOAD_SIZE_LIMIT: 107374182400
|
MEILI_HTTP_PAYLOAD_SIZE_LIMIT: 107374182400
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ 'CMD', 'curl', '--fail', 'http://localhost:7700/health' ]
|
test: ['CMD', 'curl', '--fail', 'http://localhost:7700/health']
|
||||||
interval: 3s
|
interval: 3s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ 'CMD', 'redis-cli', 'PING' ]
|
test: ['CMD', 'redis-cli', 'PING']
|
||||||
interval: 3s
|
interval: 3s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -54,7 +54,7 @@ services:
|
|||||||
CLICKHOUSE_USER: default
|
CLICKHOUSE_USER: default
|
||||||
CLICKHOUSE_PASSWORD: default
|
CLICKHOUSE_PASSWORD: default
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ 'CMD-SHELL', 'clickhouse-client --query "SELECT 1"' ]
|
test: ['CMD-SHELL', 'clickhouse-client --query "SELECT 1"']
|
||||||
interval: 3s
|
interval: 3s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -67,7 +67,14 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
MP_ENABLE_SPAMASSASSIN: postmark
|
MP_ENABLE_SPAMASSASSIN: postmark
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ 'CMD', 'wget', '-q', '-O/dev/null', 'http://localhost:8025/api/v1/info' ]
|
test:
|
||||||
|
[
|
||||||
|
'CMD',
|
||||||
|
'wget',
|
||||||
|
'-q',
|
||||||
|
'-O/dev/null',
|
||||||
|
'http://localhost:8025/api/v1/info',
|
||||||
|
]
|
||||||
interval: 3s
|
interval: 3s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -75,7 +82,11 @@ services:
|
|||||||
image: gotenberg/gotenberg:8
|
image: gotenberg/gotenberg:8
|
||||||
container_name: labrinth-gotenberg
|
container_name: labrinth-gotenberg
|
||||||
ports:
|
ports:
|
||||||
- "3000:13000"
|
- '13000:3000'
|
||||||
|
extra_hosts:
|
||||||
|
# Gotenberg must send a message on a webhook to our backend,
|
||||||
|
# so it must have access to our local network
|
||||||
|
- 'host.docker.internal:host-gateway'
|
||||||
labrinth:
|
labrinth:
|
||||||
profiles:
|
profiles:
|
||||||
- with-labrinth
|
- with-labrinth
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use muralpay::{
|
|||||||
AccountId, CounterpartyId, CreatePayout, CreatePayoutDetails, Dob,
|
AccountId, CounterpartyId, CreatePayout, CreatePayoutDetails, Dob,
|
||||||
FiatAccountType, FiatAndRailCode, FiatAndRailDetails, FiatFeeRequest,
|
FiatAccountType, FiatAndRailCode, FiatAndRailDetails, FiatFeeRequest,
|
||||||
FiatPayoutFee, MuralPay, PayoutMethodId, PayoutRecipientInfo,
|
FiatPayoutFee, MuralPay, PayoutMethodId, PayoutRecipientInfo,
|
||||||
PhysicalAddress, TokenAmount, TokenFeeRequest, TokenPayoutFee, UsdSymbol,
|
PayoutRequestId, PhysicalAddress, TokenAmount, TokenFeeRequest,
|
||||||
|
TokenPayoutFee, UsdSymbol,
|
||||||
};
|
};
|
||||||
use rust_decimal::{Decimal, dec};
|
use rust_decimal::{Decimal, dec};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -54,6 +55,11 @@ enum PayoutCommand {
|
|||||||
/// List all payout requests
|
/// List all payout requests
|
||||||
#[clap(alias = "ls")]
|
#[clap(alias = "ls")]
|
||||||
List,
|
List,
|
||||||
|
/// Get details for a single payout request
|
||||||
|
Get {
|
||||||
|
/// ID of the payout request
|
||||||
|
payout_request_id: PayoutRequestId,
|
||||||
|
},
|
||||||
/// Create a payout request
|
/// Create a payout request
|
||||||
Create {
|
Create {
|
||||||
/// ID of the Mural account to send from
|
/// ID of the Mural account to send from
|
||||||
@@ -140,6 +146,9 @@ async fn main() -> Result<()> {
|
|||||||
Command::Payout {
|
Command::Payout {
|
||||||
command: PayoutCommand::List,
|
command: PayoutCommand::List,
|
||||||
} => run(of, muralpay.search_payout_requests(None, None).await?),
|
} => run(of, muralpay.search_payout_requests(None, None).await?),
|
||||||
|
Command::Payout {
|
||||||
|
command: PayoutCommand::Get { payout_request_id },
|
||||||
|
} => run(of, muralpay.get_payout_request(payout_request_id).await?),
|
||||||
Command::Payout {
|
Command::Payout {
|
||||||
command:
|
command:
|
||||||
PayoutCommand::Create {
|
PayoutCommand::Create {
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ impl MuralPay {
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct Body {
|
struct Body {
|
||||||
|
// if we submit `null`, Mural errors; we have to explicitly exclude this field
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
filter: Option<PayoutStatusFilter>,
|
filter: Option<PayoutStatusFilter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +50,7 @@ impl MuralPay {
|
|||||||
&self,
|
&self,
|
||||||
id: PayoutRequestId,
|
id: PayoutRequestId,
|
||||||
) -> Result<PayoutRequest, MuralError> {
|
) -> Result<PayoutRequest, MuralError> {
|
||||||
self.http_get(|base| format!("{base}/api/payouts/{id}"))
|
self.http_get(|base| format!("{base}/api/payouts/payout/{id}"))
|
||||||
.send_mural()
|
.send_mural()
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -606,6 +608,26 @@ pub enum PayoutRecipientInfo {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PayoutRecipientInfo {
|
||||||
|
pub fn email(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
PayoutRecipientInfo::Individual { email, .. } => email,
|
||||||
|
PayoutRecipientInfo::Business { email, .. } => email,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn physical_address(&self) -> &PhysicalAddress {
|
||||||
|
match self {
|
||||||
|
PayoutRecipientInfo::Individual {
|
||||||
|
physical_address, ..
|
||||||
|
} => physical_address,
|
||||||
|
PayoutRecipientInfo::Business {
|
||||||
|
physical_address, ..
|
||||||
|
} => physical_address,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Display, Clone, Copy, SerializeDisplay, DeserializeFromStr)]
|
#[derive(Debug, Display, Clone, Copy, SerializeDisplay, DeserializeFromStr)]
|
||||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||||
#[display("{year:04}-{month:02}-{day:02}")]
|
#[display("{year:04}-{month:02}-{day:02}")]
|
||||||
|
|||||||
Reference in New Issue
Block a user