From 6549d047dc0e2468652f22cfdbaa2a12a46882a5 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:50:19 +0100 Subject: [PATCH 1/8] wip: payouts runs migration --- .../migrations/20260720114640_creator_payouts.sql | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 apps/labrinth/migrations/20260720114640_creator_payouts.sql diff --git a/apps/labrinth/migrations/20260720114640_creator_payouts.sql b/apps/labrinth/migrations/20260720114640_creator_payouts.sql new file mode 100644 index 0000000000..b52772a06b --- /dev/null +++ b/apps/labrinth/migrations/20260720114640_creator_payouts.sql @@ -0,0 +1,8 @@ +CREATE TABLE payouts_runs( + period_start TIMESTAMPTZ PRIMARY KEY, + started_at TIMESTAMPTZ NOT NULL, + started_by BIGINT REFERENCES users(id) + ON DELETE SET NULL, + completed_at TIMESTAMPTZ, + adjustments JSONB NOT NULL +); From 5c4c30e5142849e1b67271b57dfe9932ec179703 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:17:50 +0100 Subject: [PATCH 2/8] wip --- .../20260720114640_creator_payouts.sql | 10 +- apps/labrinth/src/models/mod.rs | 1 + apps/labrinth/src/models/v3/mod.rs | 1 + apps/labrinth/src/models/v3/payout_runs.rs | 51 +++++ apps/labrinth/src/routes/internal/mod.rs | 3 + apps/labrinth/src/routes/internal/payouts.rs | 130 +++++++++++++ apps/labrinth/src/util/mod.rs | 1 + apps/labrinth/src/util/time.rs | 174 ++++++++++++++++++ 8 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 apps/labrinth/src/models/v3/payout_runs.rs create mode 100644 apps/labrinth/src/routes/internal/payouts.rs create mode 100644 apps/labrinth/src/util/time.rs diff --git a/apps/labrinth/migrations/20260720114640_creator_payouts.sql b/apps/labrinth/migrations/20260720114640_creator_payouts.sql index b52772a06b..82776cfe9c 100644 --- a/apps/labrinth/migrations/20260720114640_creator_payouts.sql +++ b/apps/labrinth/migrations/20260720114640_creator_payouts.sql @@ -1,8 +1,16 @@ CREATE TABLE payouts_runs( - period_start TIMESTAMPTZ PRIMARY KEY, + id BIGINT PRIMARY KEY, + -- timestamp on the 1st of a month at midnight, + -- representing what month this run is for. + -- if a row exists for a month, then a payout run + -- is running/has completed for this month (see + -- `completed_at`). + period_start TIMESTAMPTZ NOT NULL, started_at TIMESTAMPTZ NOT NULL, started_by BIGINT REFERENCES users(id) ON DELETE SET NULL, completed_at TIMESTAMPTZ, + completed_result JSONB, adjustments JSONB NOT NULL ); +CREATE INDEX payouts_runs_period_start ON payouts_runs(period_start); diff --git a/apps/labrinth/src/models/mod.rs b/apps/labrinth/src/models/mod.rs index c39de80557..7a3498ba96 100644 --- a/apps/labrinth/src/models/mod.rs +++ b/apps/labrinth/src/models/mod.rs @@ -14,6 +14,7 @@ pub use v3::oauth_clients; pub use v3::organizations; pub use v3::pack; pub use v3::pats; +pub use v3::payout_runs; pub use v3::payouts; pub use v3::projects; pub use v3::reports; diff --git a/apps/labrinth/src/models/v3/mod.rs b/apps/labrinth/src/models/v3/mod.rs index 8925b7caec..40494dd7a9 100644 --- a/apps/labrinth/src/models/v3/mod.rs +++ b/apps/labrinth/src/models/v3/mod.rs @@ -11,6 +11,7 @@ pub mod oauth_clients; pub mod organizations; pub mod pack; pub mod pats; +pub mod payout_runs; pub mod payouts; pub mod projects; pub mod reports; diff --git a/apps/labrinth/src/models/v3/payout_runs.rs b/apps/labrinth/src/models/v3/payout_runs.rs new file mode 100644 index 0000000000..764f3efa2f --- /dev/null +++ b/apps/labrinth/src/models/v3/payout_runs.rs @@ -0,0 +1,51 @@ +use ariadne::ids::UserId; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::util::time::YearMonth; + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PayoutRun { + /// What period this payout run is for. + /// + /// Payout runs are always for the period of a specific year and month - + /// they are not associated with any specific day. + pub period_start: YearMonth, + /// What state this run is in. + pub status: PayoutRunStatus, + /// When this run started running. + /// + /// Only accessible to admins. + pub started_at: Option>, + /// What user started this run. + /// + /// Only accessible to admins. + pub started_by: Option, + /// When this run completed. + /// + /// Only accessible to admins. + pub completed_at: Option>, + /// What payout adjustments were specified in this run. + /// + /// Only accessible to admins. + pub adjustments: Option>, +} + +#[derive( + Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum PayoutRunStatus { + /// We are still waiting on the ad provider to issue payouts to us. + Pending, + /// The ad provider should have issued payouts to us by now, and we will + /// soon run the payouts. + Review, + /// Payouts run is currently being performed. + Running, + /// Payouts run is complete and payouts have been distributed to users. + Done, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Adjustment {} diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index 8ab03e1d12..2fcb4369e8 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -13,6 +13,7 @@ pub mod medal; pub mod moderation; pub mod mural; pub mod pats; +pub mod payouts; pub mod search; pub mod server_ping; pub mod session; @@ -32,6 +33,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { .configure(session::config) .configure(flows::config) .configure(pats::config) + .configure(payouts::config) .configure(oauth_clients::config) .service(web::scope("/moderation").configure(moderation::config)) .service(web::scope("/affiliate").configure(affiliate::config)) @@ -100,6 +102,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { pats::create_pat, pats::edit_pat, pats::delete_pat, + payouts::get, moderation::get_projects, moderation::get_project_ids, moderation::get_project_meta, diff --git a/apps/labrinth/src/routes/internal/payouts.rs b/apps/labrinth/src/routes/internal/payouts.rs new file mode 100644 index 0000000000..f8ca9d995b --- /dev/null +++ b/apps/labrinth/src/routes/internal/payouts.rs @@ -0,0 +1,130 @@ +use std::cmp::Reverse; +use std::collections::HashSet; + +use actix_web::{HttpRequest, get, web}; +use chrono::{Months, Utc}; + +use crate::auth::get_user_from_headers; +use crate::database::models::DBUserId; +use crate::database::redis::RedisPool; +use crate::database::{PgPool, ReadOnlyPgPool}; +use crate::models::pats::Scopes; +use crate::models::payout_runs::{Adjustment, PayoutRun, PayoutRunStatus}; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::util::error::Context; +use crate::util::time::YearMonth; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(get); +} + +/// List creator payout runs. +#[utoipa::path( + tag = "payouts", + responses((status = OK, body = inline(Vec))) +)] +#[get("/payout-runs")] +pub async fn get( + req: HttpRequest, + pool: web::Data, + ro_pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result>, ApiError> { + let is_admin = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::empty(), + ) + .await + .is_ok_and(|(_, user)| user.role.is_admin()); + + let stored_runs = sqlx::query!( + r#" + SELECT + period_start, + started_at, + started_by, + completed_at, + adjustments AS "adjustments!: sqlx::types::Json>" + FROM payouts_runs + ORDER BY period_start DESC + "#, + ) + .fetch_all(&***ro_pool) + .await + .wrap_internal_err("failed to fetch payout runs")?; + + let newest_created = sqlx::query_scalar!( + r#" + SELECT created + FROM payouts_values + ORDER BY created DESC + LIMIT 1 + "#, + ) + .fetch_optional(&***ro_pool) + .await + .wrap_internal_err("failed to fetch newest payout value")?; + + let mut stored_periods = HashSet::with_capacity(stored_runs.len()); + let mut runs = Vec::with_capacity(stored_runs.len()); + for run in stored_runs { + let period_start = YearMonth::from_day1(run.period_start.date_naive()); + let status = if run.completed_at.is_some() { + PayoutRunStatus::Done + } else { + PayoutRunStatus::Running + }; + + stored_periods.insert(period_start); + runs.push(PayoutRun { + period_start, + status, + started_at: is_admin.then_some(run.started_at), + started_by: is_admin + .then_some(run.started_by.map(|id| DBUserId(id).into())) + .flatten(), + completed_at: is_admin.then_some(run.completed_at).flatten(), + adjustments: is_admin.then_some(run.adjustments.0), + }); + } + + if let Some(newest_created) = newest_created { + let now = Utc::now(); + let newest_period = YearMonth::from_day1(newest_created.date_naive()); + let mut period = YearMonth::from_day1(now.date_naive()); + + while period <= newest_period { + if !stored_periods.contains(&period) { + runs.push(PayoutRun { + period_start: period, + status: PayoutRunStatus::Pending, + started_at: None, + started_by: None, + completed_at: None, + adjustments: None, + }); + } + + if period == newest_period { + break; + } + + let next_month = period + .date() + .checked_add_months(Months::new(1)) + .wrap_internal_err( + "failed to calculate next payout month", + )?; + period = YearMonth::from_day1(next_month); + } + } + + runs.sort_by_key(|run| Reverse(run.period_start)); + + Ok(web::Json(runs)) +} diff --git a/apps/labrinth/src/util/mod.rs b/apps/labrinth/src/util/mod.rs index 8c04d596de..bbdb18b6fb 100644 --- a/apps/labrinth/src/util/mod.rs +++ b/apps/labrinth/src/util/mod.rs @@ -21,5 +21,6 @@ pub mod routes; pub mod sentry; pub mod tags; pub mod tiltify; +pub mod time; pub mod validate; pub mod webhook; diff --git a/apps/labrinth/src/util/time.rs b/apps/labrinth/src/util/time.rs new file mode 100644 index 0000000000..59c82eed08 --- /dev/null +++ b/apps/labrinth/src/util/time.rs @@ -0,0 +1,174 @@ +use std::{fmt, str::FromStr}; + +use chrono::{Datelike, NaiveDate}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct YearMonth(NaiveDate); + +impl YearMonth { + pub fn new(date: NaiveDate) -> Result { + if date.day() == 1 { + Ok(Self(date)) + } else { + return Err(InvalidYearMonth); + } + } + + pub fn from_year_month(year: i32, month: u32) -> Option { + NaiveDate::from_ymd_opt(year, month, 1).map(Self) + } + + pub fn from_day1(date: NaiveDate) -> Self { + Self(date.with_day(1).expect("every month has a first day")) + } + + pub fn date(self) -> NaiveDate { + self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("a year-month must use the first day of its month")] +pub struct InvalidYearMonth; + +impl TryFrom for YearMonth { + type Error = InvalidYearMonth; + + fn try_from(date: NaiveDate) -> Result { + Self::new(date) + } +} + +impl From for NaiveDate { + fn from(year_month: YearMonth) -> Self { + year_month.0 + } +} + +impl fmt::Display for YearMonth { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}", self.0.format("%Y-%m")) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("expected a valid year and month in `YYYY-MM` format")] +pub struct ParseYearMonthError; + +impl FromStr for YearMonth { + type Err = ParseYearMonthError; + + fn from_str(value: &str) -> Result { + let mut segments = value.split('-'); + let (Some(year), Some(month), None) = + (segments.next(), segments.next(), segments.next()) + else { + return Err(ParseYearMonthError); + }; + + let year = year.parse().map_err(|_| ParseYearMonthError)?; + let month = month.parse().map_err(|_| ParseYearMonthError)?; + Self::from_year_month(year, month).ok_or(ParseYearMonthError) + } +} + +impl Serialize for YearMonth { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for YearMonth { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(D::Error::custom) + } +} + +impl utoipa::PartialSchema for YearMonth { + fn schema() -> utoipa::openapi::RefOr { + utoipa::openapi::ObjectBuilder::new() + .schema_type(utoipa::openapi::schema::Type::String) + .pattern(Some(r"^\d{4}-(0[1-9]|1[0-2])$")) + .into() + } +} + +impl utoipa::ToSchema for YearMonth {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_dates_after_the_first() { + let date = NaiveDate::from_ymd_opt(2026, 7, 2).unwrap(); + + assert_eq!(YearMonth::new(date), Err(InvalidYearMonth)); + } + + #[test] + fn constructs_from_year_and_month() { + let year_month = YearMonth::from_year_month(2026, 7).unwrap(); + + assert_eq!( + year_month.date(), + NaiveDate::from_ymd_opt(2026, 7, 1).unwrap() + ); + assert!(YearMonth::from_year_month(2026, 13).is_none()); + } + + #[test] + fn constructs_from_date_using_first_day() { + let date = NaiveDate::from_ymd_opt(2026, 7, 20).unwrap(); + + assert_eq!( + YearMonth::from_day1(date).date(), + NaiveDate::from_ymd_opt(2026, 7, 1).unwrap() + ); + } + + #[test] + fn serializes_as_year_and_month() { + let date = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(); + let year_month = YearMonth::new(date).unwrap(); + + assert_eq!(serde_json::to_string(&year_month).unwrap(), r#""2026-07""#); + } + + #[test] + fn deserializes_year_and_month_to_the_first() { + let year_month: YearMonth = + serde_json::from_str(r#""2026-07""#).unwrap(); + + assert_eq!( + year_month.date(), + NaiveDate::from_ymd_opt(2026, 7, 1).unwrap() + ); + } + + #[test] + fn parses_year_and_month_to_the_first() { + let year_month = YearMonth::from_str("2026-7").unwrap(); + + assert_eq!( + year_month.date(), + NaiveDate::from_ymd_opt(2026, 7, 1).unwrap() + ); + assert_eq!(year_month.to_string(), "2026-07"); + } + + #[test] + fn rejects_other_serialized_formats() { + for value in [r#""2026""#, r#""2026-07-01""#, r#""2026-13""#] { + assert!(serde_json::from_str::(value).is_err()); + } + } +} From 1c3fa44049733bcdcfe0f107a3834d673e8b6166 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:50:48 +0100 Subject: [PATCH 3/8] fix compile --- apps/labrinth/src/routes/internal/payouts.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/labrinth/src/routes/internal/payouts.rs b/apps/labrinth/src/routes/internal/payouts.rs index f8ca9d995b..e0b7d5b241 100644 --- a/apps/labrinth/src/routes/internal/payouts.rs +++ b/apps/labrinth/src/routes/internal/payouts.rs @@ -6,7 +6,6 @@ use chrono::{Months, Utc}; use crate::auth::get_user_from_headers; use crate::database::models::DBUserId; -use crate::database::redis::RedisPool; use crate::database::{PgPool, ReadOnlyPgPool}; use crate::models::pats::Scopes; use crate::models::payout_runs::{Adjustment, PayoutRun, PayoutRunStatus}; @@ -14,6 +13,7 @@ use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::error::Context; use crate::util::time::YearMonth; +use xredis::RedisPool; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(get); From 8e8640bfb828d3622dc8f9f53d6bdedac8f2da86 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:02:24 +0100 Subject: [PATCH 4/8] add net-60 in-review payout rows --- apps/labrinth/src/models/v3/payout_runs.rs | 2 +- apps/labrinth/src/queue/payouts/affiliate.rs | 21 +++-------- apps/labrinth/src/queue/payouts/mod.rs | 23 +++--------- apps/labrinth/src/routes/internal/payouts.rs | 14 ++++++- apps/labrinth/src/util/time.rs | 39 +++++++++++++++++++- 5 files changed, 62 insertions(+), 37 deletions(-) diff --git a/apps/labrinth/src/models/v3/payout_runs.rs b/apps/labrinth/src/models/v3/payout_runs.rs index 764f3efa2f..a0ddb7006c 100644 --- a/apps/labrinth/src/models/v3/payout_runs.rs +++ b/apps/labrinth/src/models/v3/payout_runs.rs @@ -40,7 +40,7 @@ pub enum PayoutRunStatus { Pending, /// The ad provider should have issued payouts to us by now, and we will /// soon run the payouts. - Review, + InReview, /// Payouts run is currently being performed. Running, /// Payouts run is complete and payouts have been distributed to users. diff --git a/apps/labrinth/src/queue/payouts/affiliate.rs b/apps/labrinth/src/queue/payouts/affiliate.rs index 913e973749..8a7ca2d57c 100644 --- a/apps/labrinth/src/queue/payouts/affiliate.rs +++ b/apps/labrinth/src/queue/payouts/affiliate.rs @@ -1,11 +1,11 @@ use crate::database::PgPool; use crate::env::ENV; -use chrono::{Datelike, Duration, TimeZone, Utc}; use eyre::{Context, Result, eyre}; use rust_decimal::{Decimal, dec}; use tracing::warn; use crate::database::models::{DBAffiliateCodeId, DBUserId}; +use crate::util::time::{YearMonth, net_60_payout_available_at}; pub async fn process_affiliate_payouts(postgres: &PgPool) -> Result<()> { // process: @@ -91,21 +91,10 @@ pub async fn process_affiliate_payouts(postgres: &PgPool) -> Result<()> { continue; }; - // affiliate payouts are Net 60 from the end of the month - // this is net 60 relative to the time of the charge's last attempt, not from now - let available = { - let year = last_attempt.year(); - let month = last_attempt.month(); - - // get the first day of the next month - let last_day_of_month = if month == 12 { - Utc.with_ymd_and_hms(year + 1, 1, 1, 0, 0, 0).unwrap() - } else { - Utc.with_ymd_and_hms(year, month + 1, 1, 0, 0, 0).unwrap() - }; - - last_day_of_month + Duration::days(59) - }; + let available = net_60_payout_available_at(YearMonth::from_day1( + last_attempt.date_naive(), + )) + .ok_or_else(|| eyre!("failed to calculate affiliate payout date"))?; let revenue_split = row .revenue_split diff --git a/apps/labrinth/src/queue/payouts/mod.rs b/apps/labrinth/src/queue/payouts/mod.rs index c237f6d624..944946e26b 100644 --- a/apps/labrinth/src/queue/payouts/mod.rs +++ b/apps/labrinth/src/queue/payouts/mod.rs @@ -9,12 +9,13 @@ use crate::models::payouts::{ use crate::models::projects::MonetizationStatus; use crate::routes::ApiError; use crate::util::error::Context; +use crate::util::time::{YearMonth, net_60_payout_available_at}; use crate::util::webhook::{ PayoutSourceAlertType, send_slack_payout_source_alert_webhook, }; use arc_swap::ArcSwapOption; use base64::Engine; -use chrono::{DateTime, Datelike, Duration, NaiveTime, TimeZone, Utc}; +use chrono::{DateTime, Duration, NaiveTime, Utc}; use dashmap::DashMap; use eyre::Result; use futures::TryStreamExt; @@ -1132,22 +1133,10 @@ pub async fn process_payout( let payout = net_revenue * (Decimal::from(1) - modrinth_cut); - // Ad payouts are Net 60 from the end of the month - let available = { - let now = Utc::now().date_naive(); - - let year = now.year(); - let month = now.month(); - - // Get the first day of the next month - let last_day_of_month = if month == 12 { - Utc.with_ymd_and_hms(year + 1, 1, 1, 0, 0, 0).unwrap() - } else { - Utc.with_ymd_and_hms(year, month + 1, 1, 0, 0, 0).unwrap() - }; - - last_day_of_month + Duration::days(59) - }; + let available = net_60_payout_available_at(YearMonth::from_day1( + Utc::now().date_naive(), + )) + .wrap_internal_err("failed to calculate creator payout date")?; let ( mut insert_user_ids, diff --git a/apps/labrinth/src/routes/internal/payouts.rs b/apps/labrinth/src/routes/internal/payouts.rs index e0b7d5b241..abd77fa8dc 100644 --- a/apps/labrinth/src/routes/internal/payouts.rs +++ b/apps/labrinth/src/routes/internal/payouts.rs @@ -12,7 +12,7 @@ use crate::models::payout_runs::{Adjustment, PayoutRun, PayoutRunStatus}; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::error::Context; -use crate::util::time::YearMonth; +use crate::util::time::{YearMonth, net_60_payout_available_at}; use xredis::RedisPool; pub fn config(cfg: &mut web::ServiceConfig) { @@ -100,9 +100,19 @@ pub async fn get( while period <= newest_period { if !stored_periods.contains(&period) { + let status = + if net_60_payout_available_at(period).wrap_internal_err( + "failed to calculate payout review date", + )? <= newest_created + { + PayoutRunStatus::InReview + } else { + PayoutRunStatus::Pending + }; + runs.push(PayoutRun { period_start: period, - status: PayoutRunStatus::Pending, + status, started_at: None, started_by: None, completed_at: None, diff --git a/apps/labrinth/src/util/time.rs b/apps/labrinth/src/util/time.rs index 59c82eed08..7deca2a753 100644 --- a/apps/labrinth/src/util/time.rs +++ b/apps/labrinth/src/util/time.rs @@ -1,6 +1,6 @@ use std::{fmt, str::FromStr}; -use chrono::{Datelike, NaiveDate}; +use chrono::{DateTime, Datelike, Days, Months, NaiveDate, Utc}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error}; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -28,6 +28,16 @@ impl YearMonth { } } +/// Calculate when a payout period becomes available under Net-60 terms. +pub fn net_60_payout_available_at(period: YearMonth) -> Option> { + period + .date() + .checked_add_months(Months::new(1))? + .and_hms_opt(0, 0, 0)? + .and_utc() + .checked_add_days(Days::new(59)) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] #[error("a year-month must use the first day of its month")] pub struct InvalidYearMonth; @@ -171,4 +181,31 @@ mod tests { assert!(serde_json::from_str::(value).is_err()); } } + + #[test] + fn calculates_net_60_payout_availability() { + let august = YearMonth::from_year_month(2026, 8).unwrap(); + let december = YearMonth::from_year_month(2026, 12).unwrap(); + + assert_eq!( + net_60_payout_available_at(august), + Some( + NaiveDate::from_ymd_opt(2026, 10, 30) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + ) + ); + assert_eq!( + net_60_payout_available_at(december), + Some( + NaiveDate::from_ymd_opt(2027, 3, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + ) + ); + } } From 5605ee578ae1fae53d707c4cad08d4fab5b2460b Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:09:35 +0000 Subject: [PATCH 5/8] wip: payout run reports --- apps/labrinth/src/models/v3/payout_runs.rs | 55 ++++++++-- apps/labrinth/src/queue/payouts/mod.rs | 103 ++++++++++++++++++- apps/labrinth/src/routes/internal/payouts.rs | 85 ++++++++++++--- packages/xredis/src/cache.rs | 47 ++++++++- packages/xredis/src/lib.rs | 29 ++++++ 5 files changed, 298 insertions(+), 21 deletions(-) diff --git a/apps/labrinth/src/models/v3/payout_runs.rs b/apps/labrinth/src/models/v3/payout_runs.rs index a0ddb7006c..907616a104 100644 --- a/apps/labrinth/src/models/v3/payout_runs.rs +++ b/apps/labrinth/src/models/v3/payout_runs.rs @@ -1,5 +1,6 @@ use ariadne::ids::UserId; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, NaiveDate, Utc}; +use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use crate::util::time::YearMonth; @@ -13,6 +14,8 @@ pub struct PayoutRun { pub period_start: YearMonth, /// What state this run is in. pub status: PayoutRunStatus, + #[serde(flatten)] + pub report: PayoutRunReport, /// When this run started running. /// /// Only accessible to admins. @@ -36,15 +39,55 @@ pub struct PayoutRun { )] #[serde(rename_all = "snake_case")] pub enum PayoutRunStatus { - /// We are still waiting on the ad provider to issue payouts to us. + /// The payout period is still receiving revenue estimates. + Open, + /// The payout period is closed, but is still within Net-60 terms. Pending, /// The ad provider should have issued payouts to us by now, and we will /// soon run the payouts. - InReview, - /// Payouts run is currently being performed. - Running, + Review, /// Payouts run is complete and payouts have been distributed to users. - Done, + Paid, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PayoutRunReport { + pub revenue: PayoutRunRevenue, + #[serde(with = "rust_decimal::serde::float")] + pub fees_deducted_usd: Decimal, + #[serde(with = "rust_decimal::serde::float")] + pub variance_adjustment_usd: Decimal, + #[serde(with = "rust_decimal::serde::float")] + pub net_estimated_revenue_usd: Decimal, + #[serde(with = "rust_decimal::serde::float")] + pub creator_net_estimated_revenue_usd: Decimal, + #[serde(with = "rust_decimal::serde::float")] + pub modrinth_net_estimated_revenue_usd: Decimal, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PayoutRunCompletion { + #[serde(with = "rust_decimal::serde::float")] + pub revenue_usd: Decimal, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PayoutRunRevenue { + Estimated { + days: Vec, + }, + Actual { + #[serde(with = "rust_decimal::serde::float")] + amount_usd: Decimal, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct DayRevenue { + pub date: NaiveDate, + #[serde(with = "rust_decimal::serde::float")] + pub amount_usd: Decimal, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] diff --git a/apps/labrinth/src/queue/payouts/mod.rs b/apps/labrinth/src/queue/payouts/mod.rs index 944946e26b..8377e04796 100644 --- a/apps/labrinth/src/queue/payouts/mod.rs +++ b/apps/labrinth/src/queue/payouts/mod.rs @@ -2,6 +2,7 @@ use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::payouts_values_notifications; use crate::database::{PgPool, PgTransaction}; use crate::env::ENV; +use crate::models::payout_runs::DayRevenue; use crate::models::payouts::{ PayoutDecimal, PayoutInterval, PayoutMethod, PayoutMethodType, TremendousForexResponse, @@ -15,7 +16,7 @@ use crate::util::webhook::{ }; use arc_swap::ArcSwapOption; use base64::Engine; -use chrono::{DateTime, Duration, NaiveTime, Utc}; +use chrono::{DateTime, Duration, Months, NaiveTime, Utc}; use dashmap::DashMap; use eyre::Result; use futures::TryStreamExt; @@ -887,6 +888,106 @@ pub async fn make_aditude_request( Ok(json) } +const ADITUDE_MONTH_ESTIMATE_CACHE_NAMESPACE: &str = "aditude_month_estimates"; +const ADITUDE_MONTH_ESTIMATE_CACHE_EXPIRY: i64 = 60 * 60 * 24; + +pub async fn get_cached_aditude_month_estimates( + periods: &[YearMonth], + redis: &RedisPool, +) -> Result>, ApiError> { + redis + .get_cached_keys_raw_with_expiry( + ADITUDE_MONTH_ESTIMATE_CACHE_NAMESPACE, + periods, + ADITUDE_MONTH_ESTIMATE_CACHE_EXPIRY, + fetch_aditude_month_estimates, + ) + .await +} + +async fn fetch_aditude_month_estimates( + periods: Vec, +) -> Result>, ApiError> { + let first_period = periods + .iter() + .min() + .copied() + .wrap_internal_err("missing first Aditude estimate period")?; + let last_period = periods + .iter() + .max() + .copied() + .wrap_internal_err("missing last Aditude estimate period")?; + let range_end = last_period + .date() + .checked_add_months(Months::new(1)) + .wrap_internal_err("failed to calculate payout period end")?; + let range = format!( + "{}/{}", + first_period.date().format("%Y-%m-%d"), + range_end.format("%Y-%m-%d") + ); + let estimates = periods + .into_iter() + .map(|period| { + let period_end = period + .date() + .checked_add_months(Months::new(1)) + .wrap_internal_err( + "failed to calculate payout period end", + )?; + let day_count = usize::try_from( + period_end.signed_duration_since(period.date()).num_days(), + ) + .wrap_internal_err("failed to calculate payout period day count")?; + let days = (0..day_count) + .map(|day| { + let day = i64::try_from(day).wrap_internal_err( + "failed to calculate payout period day", + )?; + let date = period + .date() + .checked_add_signed(Duration::days(day)) + .wrap_internal_err( + "failed to calculate payout period day", + )?; + Ok(DayRevenue { + date, + amount_usd: Decimal::ZERO, + }) + }) + .collect::, ApiError>>()?; + Ok((period, days)) + }) + .collect::, ApiError>>()?; + + let response = + make_aditude_request(&["METRIC_REVENUE"], &range, "1d").await?; + for point in response.into_iter().flat_map(|points| points.points_list) { + let Some(revenue) = point.metric.revenue else { + continue; + }; + let timestamp = i64::try_from(point.time.seconds) + .ok() + .and_then(|seconds| DateTime::from_timestamp(seconds, 0)) + .wrap_internal_err("invalid Aditude estimate timestamp")?; + let date = timestamp.date_naive(); + let period = YearMonth::from_day1(date); + let Some(mut days) = estimates.get_mut(&period) else { + continue; + }; + + let day_index = usize::try_from( + date.signed_duration_since(period.date()).num_days(), + ) + .wrap_internal_err("invalid Aditude estimate day")?; + days[day_index].date = date; + days[day_index].amount_usd += revenue; + } + + Ok(estimates) +} + pub async fn process_payout( pool: &PgPool, client: &clickhouse::Client, diff --git a/apps/labrinth/src/routes/internal/payouts.rs b/apps/labrinth/src/routes/internal/payouts.rs index abd77fa8dc..d28c834f88 100644 --- a/apps/labrinth/src/routes/internal/payouts.rs +++ b/apps/labrinth/src/routes/internal/payouts.rs @@ -8,7 +8,11 @@ use crate::auth::get_user_from_headers; use crate::database::models::DBUserId; use crate::database::{PgPool, ReadOnlyPgPool}; use crate::models::pats::Scopes; -use crate::models::payout_runs::{Adjustment, PayoutRun, PayoutRunStatus}; +use crate::models::payout_runs::{ + Adjustment, PayoutRun, PayoutRunCompletion, PayoutRunReport, + PayoutRunRevenue, PayoutRunStatus, +}; +use crate::queue::payouts::get_cached_aditude_month_estimates; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::error::Context; @@ -49,6 +53,7 @@ pub async fn get( started_at, started_by, completed_at, + completed_result AS "completed_result?: sqlx::types::Json", adjustments AS "adjustments!: sqlx::types::Json>" FROM payouts_runs ORDER BY period_start DESC @@ -71,19 +76,48 @@ pub async fn get( .wrap_internal_err("failed to fetch newest payout value")?; let mut stored_periods = HashSet::with_capacity(stored_runs.len()); + let mut estimate_periods = HashSet::new(); let mut runs = Vec::with_capacity(stored_runs.len()); for run in stored_runs { let period_start = YearMonth::from_day1(run.period_start.date_naive()); - let status = if run.completed_at.is_some() { - PayoutRunStatus::Done + let (status, report) = if run.completed_at.is_some() { + let amount_usd = run + .completed_result + .map(|completion| completion.revenue_usd) + .wrap_internal_err( + "paid payout run is missing its completion result", + )?; + ( + PayoutRunStatus::Paid, + PayoutRunReport { + revenue: PayoutRunRevenue::Actual { amount_usd }, + fees_deducted_usd: todo!(), + variance_adjustment_usd: todo!(), + net_estimated_revenue_usd: todo!(), + creator_net_estimated_revenue_usd: todo!(), + modrinth_net_estimated_revenue_usd: todo!(), + }, + ) } else { - PayoutRunStatus::Running + estimate_periods.insert(period_start); + ( + PayoutRunStatus::Review, + PayoutRunReport { + revenue: PayoutRunRevenue::Estimated { days: Vec::new() }, + fees_deducted_usd: todo!(), + variance_adjustment_usd: todo!(), + net_estimated_revenue_usd: todo!(), + creator_net_estimated_revenue_usd: todo!(), + modrinth_net_estimated_revenue_usd: todo!(), + }, + ) }; stored_periods.insert(period_start); runs.push(PayoutRun { period_start, status, + report, started_at: is_admin.then_some(run.started_at), started_by: is_admin .then_some(run.started_by.map(|id| DBUserId(id).into())) @@ -100,19 +134,32 @@ pub async fn get( while period <= newest_period { if !stored_periods.contains(&period) { - let status = - if net_60_payout_available_at(period).wrap_internal_err( - "failed to calculate payout review date", - )? <= newest_created - { - PayoutRunStatus::InReview - } else { - PayoutRunStatus::Pending - }; + let status = if period == newest_period { + PayoutRunStatus::Open + } else if net_60_payout_available_at(period).wrap_internal_err( + "failed to calculate payout review date", + )? <= newest_created + { + PayoutRunStatus::Review + } else { + PayoutRunStatus::Pending + }; + + estimate_periods.insert(period); runs.push(PayoutRun { period_start: period, status, + report: PayoutRunReport { + revenue: PayoutRunRevenue::Estimated { + days: Vec::new(), + }, + fees_deducted_usd: todo!(), + variance_adjustment_usd: todo!(), + net_estimated_revenue_usd: todo!(), + creator_net_estimated_revenue_usd: todo!(), + modrinth_net_estimated_revenue_usd: todo!(), + }, started_at: None, started_by: None, completed_at: None, @@ -134,6 +181,18 @@ pub async fn get( } } + let estimate_periods = estimate_periods.into_iter().collect::>(); + let estimates = + get_cached_aditude_month_estimates(&estimate_periods, &redis).await?; + for run in &mut runs { + if let Some(days) = estimates.get(&run.period_start) { + run.report.net_estimated_revenue_usd = + days.iter().map(|day| day.amount_usd).sum(); + run.report.revenue = + PayoutRunRevenue::Estimated { days: days.clone() }; + } + } + runs.sort_by_key(|run| Reverse(run.period_start)); Ok(web::Json(runs)) diff --git a/packages/xredis/src/cache.rs b/packages/xredis/src/cache.rs index 5e2aba678b..25654e93ee 100644 --- a/packages/xredis/src/cache.rs +++ b/packages/xredis/src/cache.rs @@ -254,6 +254,48 @@ impl CacheManager { None, false, keys, + None, + |ids| async move { + Ok(closure(ids) + .await? + .into_iter() + .map(|(key, value)| (key, (None::, value))) + .collect()) + }, + ) + .await + } + + pub async fn get_cached_keys_raw_with_expiry( + &self, + provider: &P, + namespace: &str, + keys: &[K], + expiry: i64, + closure: F, + ) -> Result, E> + where + P: ConnectionProvider, + F: FnOnce(Vec) -> Fut, + Fut: Future, E>>, + E: From, + T: Serialize + DeserializeOwned, + K: Display + + Hash + + Eq + + PartialEq + + Clone + + DeserializeOwned + + Serialize + + Debug, + { + self.get_cached_keys_raw_with_slug( + provider, + namespace, + None, + false, + keys, + Some((expiry, expiry)), |ids| async move { Ok(closure(ids) .await? @@ -298,6 +340,7 @@ impl CacheManager { Some(slug_namespace), case_sensitive, keys, + None, closure, ) .await? @@ -313,6 +356,7 @@ impl CacheManager { slug_namespace: Option<&str>, case_sensitive: bool, keys: &[I], + expiry_override: Option<(i64, i64)>, closure: F, ) -> Result, E> where @@ -430,7 +474,8 @@ impl CacheManager { .instrument(info_span!("get_cached_values_closure")) }; - let (default_expiry, actual_expiry) = self.settings.expiries(namespace); + let (default_expiry, actual_expiry) = expiry_override + .unwrap_or_else(|| self.settings.expiries(namespace)); let current_time = Utc::now(); let mut expired_values = HashMap::new(); let mut expired_identities = HashMap::new(); diff --git a/packages/xredis/src/lib.rs b/packages/xredis/src/lib.rs index e84fce15eb..34c8619b3b 100644 --- a/packages/xredis/src/lib.rs +++ b/packages/xredis/src/lib.rs @@ -168,6 +168,34 @@ impl RedisPool { .await } + pub async fn get_cached_keys_raw_with_expiry( + &self, + namespace: &str, + keys: &[K], + expiry: i64, + closure: F, + ) -> Result, E> + where + F: FnOnce(Vec) -> Fut, + Fut: Future, E>>, + E: From, + T: Serialize + DeserializeOwned, + K: Display + + Hash + + Eq + + PartialEq + + Clone + + DeserializeOwned + + Serialize + + Debug, + { + self.cache + .get_cached_keys_raw_with_expiry( + self, namespace, keys, expiry, closure, + ) + .await + } + pub async fn get_cached_keys_with_slug( &self, namespace: &str, @@ -233,6 +261,7 @@ impl RedisPool { slug_namespace, case_sensitive, keys, + None, closure, ) .await From a0c25375052780fd85091ef88bcfabdbf14fcccf Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:08:38 +0000 Subject: [PATCH 6/8] adjust payout report to show actual days --- apps/labrinth/src/models/v3/payout_runs.rs | 18 +-- apps/labrinth/src/queue/payouts/mod.rs | 1 + apps/labrinth/src/routes/internal/payouts.rs | 152 ++++++++++++------- packages/xredis/src/cache.rs | 16 +- packages/xredis/src/lib.rs | 4 +- 5 files changed, 110 insertions(+), 81 deletions(-) diff --git a/apps/labrinth/src/models/v3/payout_runs.rs b/apps/labrinth/src/models/v3/payout_runs.rs index 907616a104..e195f4c251 100644 --- a/apps/labrinth/src/models/v3/payout_runs.rs +++ b/apps/labrinth/src/models/v3/payout_runs.rs @@ -52,9 +52,9 @@ pub enum PayoutRunStatus { #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct PayoutRunReport { - pub revenue: PayoutRunRevenue, - #[serde(with = "rust_decimal::serde::float")] - pub fees_deducted_usd: Decimal, + pub days: Vec, + #[serde(with = "rust_decimal::serde::float")] + pub fees_deducted_usd: Decimal, #[serde(with = "rust_decimal::serde::float")] pub variance_adjustment_usd: Decimal, #[serde(with = "rust_decimal::serde::float")] @@ -71,18 +71,6 @@ pub struct PayoutRunCompletion { pub revenue_usd: Decimal, } -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum PayoutRunRevenue { - Estimated { - days: Vec, - }, - Actual { - #[serde(with = "rust_decimal::serde::float")] - amount_usd: Decimal, - }, -} - #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct DayRevenue { pub date: NaiveDate, diff --git a/apps/labrinth/src/queue/payouts/mod.rs b/apps/labrinth/src/queue/payouts/mod.rs index cdaf456498..f51c4d5916 100644 --- a/apps/labrinth/src/queue/payouts/mod.rs +++ b/apps/labrinth/src/queue/payouts/mod.rs @@ -925,6 +925,7 @@ pub async fn get_cached_aditude_month_estimates( fetch_aditude_month_estimates, ) .await + .wrap_internal_err("failed to fetch cached Aditude month estimates") } async fn fetch_aditude_month_estimates( diff --git a/apps/labrinth/src/routes/internal/payouts.rs b/apps/labrinth/src/routes/internal/payouts.rs index d28c834f88..961109938d 100644 --- a/apps/labrinth/src/routes/internal/payouts.rs +++ b/apps/labrinth/src/routes/internal/payouts.rs @@ -1,16 +1,17 @@ use std::cmp::Reverse; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use actix_web::{HttpRequest, get, web}; use chrono::{Months, Utc}; +use rust_decimal::Decimal; use crate::auth::get_user_from_headers; use crate::database::models::DBUserId; use crate::database::{PgPool, ReadOnlyPgPool}; use crate::models::pats::Scopes; use crate::models::payout_runs::{ - Adjustment, PayoutRun, PayoutRunCompletion, PayoutRunReport, - PayoutRunRevenue, PayoutRunStatus, + Adjustment, DayRevenue, PayoutRun, PayoutRunCompletion, PayoutRunReport, + PayoutRunStatus, }; use crate::queue::payouts::get_cached_aditude_month_estimates; use crate::queue::session::AuthQueue; @@ -19,6 +20,12 @@ use crate::util::error::Context; use crate::util::time::{YearMonth, net_60_payout_available_at}; use xredis::RedisPool; +#[derive(Debug, Clone, Copy)] +enum DayRevenueEstimate { + Raw, + AdjustedToActual { actual_revenue_usd: Decimal }, +} + pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(get); } @@ -76,48 +83,40 @@ pub async fn get( .wrap_internal_err("failed to fetch newest payout value")?; let mut stored_periods = HashSet::with_capacity(stored_runs.len()); - let mut estimate_periods = HashSet::new(); - let mut runs = Vec::with_capacity(stored_runs.len()); - for run in stored_runs { - let period_start = YearMonth::from_day1(run.period_start.date_naive()); - let (status, report) = if run.completed_at.is_some() { - let amount_usd = run - .completed_result + let mut revenue_estimates = HashMap::new(); + let mut runs = Vec::with_capacity(stored_runs.len()); + for run in stored_runs { + let period_start = YearMonth::from_day1(run.period_start.date_naive()); + let (status, revenue_estimate) = if run.completed_at.is_some() { + let amount_usd = run + .completed_result .map(|completion| completion.revenue_usd) .wrap_internal_err( "paid payout run is missing its completion result", )?; - ( - PayoutRunStatus::Paid, - PayoutRunReport { - revenue: PayoutRunRevenue::Actual { amount_usd }, - fees_deducted_usd: todo!(), - variance_adjustment_usd: todo!(), - net_estimated_revenue_usd: todo!(), - creator_net_estimated_revenue_usd: todo!(), - modrinth_net_estimated_revenue_usd: todo!(), - }, - ) - } else { - estimate_periods.insert(period_start); - ( - PayoutRunStatus::Review, - PayoutRunReport { - revenue: PayoutRunRevenue::Estimated { days: Vec::new() }, - fees_deducted_usd: todo!(), - variance_adjustment_usd: todo!(), - net_estimated_revenue_usd: todo!(), - creator_net_estimated_revenue_usd: todo!(), - modrinth_net_estimated_revenue_usd: todo!(), - }, - ) - }; - - stored_periods.insert(period_start); - runs.push(PayoutRun { - period_start, - status, - report, + ( + PayoutRunStatus::Paid, + DayRevenueEstimate::AdjustedToActual { + actual_revenue_usd: amount_usd, + }, + ) + } else { + (PayoutRunStatus::Review, DayRevenueEstimate::Raw) + }; + + stored_periods.insert(period_start); + revenue_estimates.insert(period_start, revenue_estimate); + runs.push(PayoutRun { + period_start, + status, + report: PayoutRunReport { + days: Vec::new(), + fees_deducted_usd: todo!(), + variance_adjustment_usd: todo!(), + net_estimated_revenue_usd: todo!(), + creator_net_estimated_revenue_usd: todo!(), + modrinth_net_estimated_revenue_usd: todo!(), + }, started_at: is_admin.then_some(run.started_at), started_by: is_admin .then_some(run.started_by.map(|id| DBUserId(id).into())) @@ -145,15 +144,13 @@ pub async fn get( PayoutRunStatus::Pending }; - estimate_periods.insert(period); + revenue_estimates.insert(period, DayRevenueEstimate::Raw); runs.push(PayoutRun { period_start: period, status, - report: PayoutRunReport { - revenue: PayoutRunRevenue::Estimated { - days: Vec::new(), - }, + report: PayoutRunReport { + days: Vec::new(), fees_deducted_usd: todo!(), variance_adjustment_usd: todo!(), net_estimated_revenue_usd: todo!(), @@ -181,19 +178,60 @@ pub async fn get( } } - let estimate_periods = estimate_periods.into_iter().collect::>(); - let estimates = - get_cached_aditude_month_estimates(&estimate_periods, &redis).await?; - for run in &mut runs { - if let Some(days) = estimates.get(&run.period_start) { - run.report.net_estimated_revenue_usd = - days.iter().map(|day| day.amount_usd).sum(); - run.report.revenue = - PayoutRunRevenue::Estimated { days: days.clone() }; - } + let estimate_periods = revenue_estimates.keys().copied().collect::>(); + let estimates = + get_cached_aditude_month_estimates(&estimate_periods, &redis).await?; + for run in &mut runs { + if let Some(days) = estimates.get(&run.period_start) { + let days = match revenue_estimates.get(&run.period_start) { + Some(DayRevenueEstimate::Raw) | None => days.clone(), + Some(DayRevenueEstimate::AdjustedToActual { + actual_revenue_usd, + }) => adjust_estimates_to_actual(days, *actual_revenue_usd)?, + }; + run.report.net_estimated_revenue_usd = + days.iter().map(|day| day.amount_usd).sum(); + run.report.days = days; + } } runs.sort_by_key(|run| Reverse(run.period_start)); - Ok(web::Json(runs)) + Ok(web::Json(runs)) +} + +fn adjust_estimates_to_actual( + days: &[DayRevenue], + actual_revenue_usd: Decimal, +) -> Result, ApiError> { + if days.is_empty() { + return Ok(Vec::new()); + } + + let estimated_revenue_usd = + days.iter().map(|day| day.amount_usd).sum::(); + let day_count = u64::try_from(days.len()) + .wrap_internal_err("failed to calculate payout period day count")?; + let mut allocated_revenue_usd = Decimal::ZERO; + let last_day = days.len() - 1; + + Ok(days + .iter() + .enumerate() + .map(|(index, day)| { + let amount_usd = if index == last_day { + actual_revenue_usd - allocated_revenue_usd + } else if estimated_revenue_usd.is_zero() { + actual_revenue_usd / Decimal::from(day_count) + } else { + day.amount_usd * actual_revenue_usd / estimated_revenue_usd + }; + allocated_revenue_usd += amount_usd; + + DayRevenue { + date: day.date, + amount_usd, + } + }) + .collect()) } diff --git a/packages/xredis/src/cache.rs b/packages/xredis/src/cache.rs index 310c8fb7e8..94f945636d 100644 --- a/packages/xredis/src/cache.rs +++ b/packages/xredis/src/cache.rs @@ -260,11 +260,13 @@ impl CacheManager { keys, None, |ids| async move { - Ok(closure(ids) - .await? - .into_iter() - .map(|(key, value)| (key, (None::, value))) - .collect()) + Ok::<_, E>( + closure(ids) + .await? + .into_iter() + .map(|(key, value)| (key, (None::, value))) + .collect(), + ) }, ) .await @@ -277,12 +279,12 @@ impl CacheManager { keys: &[K], expiry: i64, closure: F, - ) -> Result, E> + ) -> Result> where P: ConnectionProvider, F: FnOnce(Vec) -> Fut, Fut: Future, E>>, - E: From, + E: std::error::Error + Send + Sync + 'static, T: Serialize + DeserializeOwned, K: Display + Hash diff --git a/packages/xredis/src/lib.rs b/packages/xredis/src/lib.rs index 32240c22a8..84c60c3af5 100644 --- a/packages/xredis/src/lib.rs +++ b/packages/xredis/src/lib.rs @@ -169,11 +169,11 @@ impl RedisPool { keys: &[K], expiry: i64, closure: F, - ) -> Result, E> + ) -> Result> where F: FnOnce(Vec) -> Fut, Fut: Future, E>>, - E: From, + E: std::error::Error + Send + Sync + 'static, T: Serialize + DeserializeOwned, K: Display + Hash From 30df2ee4dd03a2c4c4821bb31b835fe55ba6acea Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:52:23 +0000 Subject: [PATCH 7/8] fix aditude month query --- apps/labrinth/src/queue/payouts/mod.rs | 84 ++++++++++++++++---- apps/labrinth/src/routes/internal/payouts.rs | 20 ++--- 2 files changed, 77 insertions(+), 27 deletions(-) diff --git a/apps/labrinth/src/queue/payouts/mod.rs b/apps/labrinth/src/queue/payouts/mod.rs index f51c4d5916..e1e3753f43 100644 --- a/apps/labrinth/src/queue/payouts/mod.rs +++ b/apps/labrinth/src/queue/payouts/mod.rs @@ -880,6 +880,24 @@ pub struct AditudeTime { pub seconds: u64, } +#[derive(Deserialize)] +struct AditudeMetricsV2Response { + responses: Vec, +} + +#[derive(Deserialize)] +struct AditudeMetricsV2Table { + rows: Vec, +} + +#[derive(Deserialize)] +struct AditudeRevenueRow { + #[serde(rename = "_TIME")] + time_millis: i64, + #[serde(rename = "REVENUE")] + revenue: Decimal, +} + pub async fn make_aditude_request( metrics: &[&str], range: &str, @@ -910,7 +928,32 @@ pub async fn make_aditude_request( Ok(json) } -const ADITUDE_MONTH_ESTIMATE_CACHE_NAMESPACE: &str = "aditude_month_estimates"; +async fn make_aditude_revenue_request( + start_time: i64, + end_time: i64, +) -> Result { + reqwest::Client::new() + .post("https://cloud.aditude.io/api/public/insights/metrics/v2") + .bearer_auth(&ENV.ADITUDE_API_KEY) + .json(&serde_json::json!({ + "metrics": ["REVENUE"], + "range": "custom", + "startTime": start_time, + "endTime": end_time, + "interval": "1d" + })) + .send() + .await + .wrap_internal_err("failed to request Aditude revenue estimates")? + .error_for_status() + .wrap_internal_err("Aditude revenue estimate request failed")? + .json() + .await + .wrap_internal_err("failed to deserialize Aditude revenue estimates") +} + +const ADITUDE_MONTH_ESTIMATE_CACHE_NAMESPACE: &str = + "aditude_month_estimates_v2"; const ADITUDE_MONTH_ESTIMATE_CACHE_EXPIRY: i64 = 60 * 60 * 24; pub async fn get_cached_aditude_month_estimates( @@ -945,11 +988,19 @@ async fn fetch_aditude_month_estimates( .date() .checked_add_months(Months::new(1)) .wrap_internal_err("failed to calculate payout period end")?; - let range = format!( - "{}/{}", - first_period.date().format("%Y-%m-%d"), - range_end.format("%Y-%m-%d") - ); + let range_start_time = first_period + .date() + .and_hms_opt(0, 0, 0) + .wrap_internal_err("failed to calculate payout period start")? + .and_utc() + .timestamp_millis(); + let range_end_time = range_end + .and_hms_opt(0, 0, 0) + .wrap_internal_err("failed to calculate payout period end")? + .and_utc() + .timestamp_millis() + .checked_sub(1) + .wrap_internal_err("failed to calculate inclusive payout period end")?; let estimates = periods .into_iter() .map(|period| { @@ -984,16 +1035,15 @@ async fn fetch_aditude_month_estimates( }) .collect::, ApiError>>()?; - let response = - make_aditude_request(&["METRIC_REVENUE"], &range, "1d").await?; - for point in response.into_iter().flat_map(|points| points.points_list) { - let Some(revenue) = point.metric.revenue else { - continue; - }; - let timestamp = i64::try_from(point.time.seconds) - .ok() - .and_then(|seconds| DateTime::from_timestamp(seconds, 0)) - .wrap_internal_err("invalid Aditude estimate timestamp")?; + let response = + make_aditude_revenue_request(range_start_time, range_end_time).await?; + for row in response + .responses + .into_iter() + .flat_map(|response| response.rows) + { + let timestamp = DateTime::from_timestamp_millis(row.time_millis) + .wrap_internal_err("invalid Aditude estimate timestamp")?; let date = timestamp.date_naive(); let period = YearMonth::from_day1(date); let Some(mut days) = estimates.get_mut(&period) else { @@ -1005,7 +1055,7 @@ async fn fetch_aditude_month_estimates( ) .wrap_internal_err("invalid Aditude estimate day")?; days[day_index].date = date; - days[day_index].amount_usd += revenue; + days[day_index].amount_usd += row.revenue; } Ok(estimates) diff --git a/apps/labrinth/src/routes/internal/payouts.rs b/apps/labrinth/src/routes/internal/payouts.rs index 961109938d..82fb899be1 100644 --- a/apps/labrinth/src/routes/internal/payouts.rs +++ b/apps/labrinth/src/routes/internal/payouts.rs @@ -111,11 +111,11 @@ pub async fn get( status, report: PayoutRunReport { days: Vec::new(), - fees_deducted_usd: todo!(), - variance_adjustment_usd: todo!(), - net_estimated_revenue_usd: todo!(), - creator_net_estimated_revenue_usd: todo!(), - modrinth_net_estimated_revenue_usd: todo!(), + fees_deducted_usd: Decimal::ZERO, // TODO: calculate deducted fees + variance_adjustment_usd: Decimal::ZERO, // TODO: calculate variance adjustment + net_estimated_revenue_usd: Decimal::ZERO, // TODO: calculate net revenue + creator_net_estimated_revenue_usd: Decimal::ZERO, // TODO: calculate creator share + modrinth_net_estimated_revenue_usd: Decimal::ZERO, // TODO: calculate Modrinth share }, started_at: is_admin.then_some(run.started_at), started_by: is_admin @@ -151,11 +151,11 @@ pub async fn get( status, report: PayoutRunReport { days: Vec::new(), - fees_deducted_usd: todo!(), - variance_adjustment_usd: todo!(), - net_estimated_revenue_usd: todo!(), - creator_net_estimated_revenue_usd: todo!(), - modrinth_net_estimated_revenue_usd: todo!(), + fees_deducted_usd: Decimal::ZERO, // TODO: calculate deducted fees + variance_adjustment_usd: Decimal::ZERO, // TODO: calculate variance adjustment + net_estimated_revenue_usd: Decimal::ZERO, // TODO: calculate net revenue + creator_net_estimated_revenue_usd: Decimal::ZERO, // TODO: calculate creator share + modrinth_net_estimated_revenue_usd: Decimal::ZERO, // TODO: calculate Modrinth share }, started_at: None, started_by: None, From 9a717962fb676511f266fe26b30d296c0c450eb2 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:54:41 +0000 Subject: [PATCH 8/8] raw/net estimated revenue fields --- .../20260814130000_payouts_variance.sql | 8 + apps/labrinth/src/models/v3/payout_runs.rs | 10 +- apps/labrinth/src/queue/payouts/mod.rs | 141 +++++---- apps/labrinth/src/routes/internal/payouts.rs | 267 ++++++++++++------ 4 files changed, 269 insertions(+), 157 deletions(-) create mode 100644 apps/labrinth/migrations/20260814130000_payouts_variance.sql diff --git a/apps/labrinth/migrations/20260814130000_payouts_variance.sql b/apps/labrinth/migrations/20260814130000_payouts_variance.sql new file mode 100644 index 0000000000..fe1dea183a --- /dev/null +++ b/apps/labrinth/migrations/20260814130000_payouts_variance.sql @@ -0,0 +1,8 @@ +CREATE TABLE payouts_variance ( + applied_at TIMESTAMPTZ PRIMARY KEY, + variance NUMERIC(40, 20) NOT NULL + CHECK (variance BETWEEN 0 AND 1) +); + +INSERT INTO payouts_variance (applied_at, variance) +VALUES ('1970-01-01 00:00:00+00', 0.1); diff --git a/apps/labrinth/src/models/v3/payout_runs.rs b/apps/labrinth/src/models/v3/payout_runs.rs index e195f4c251..6ed5c47562 100644 --- a/apps/labrinth/src/models/v3/payout_runs.rs +++ b/apps/labrinth/src/models/v3/payout_runs.rs @@ -50,11 +50,13 @@ pub enum PayoutRunStatus { Paid, } -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct PayoutRunReport { - pub days: Vec, - #[serde(with = "rust_decimal::serde::float")] - pub fees_deducted_usd: Decimal, + pub days: Vec, + #[serde(with = "rust_decimal::serde::float")] + pub raw_estimated_revenue_usd: Decimal, + #[serde(with = "rust_decimal::serde::float")] + pub fees_deducted_usd: Decimal, #[serde(with = "rust_decimal::serde::float")] pub variance_adjustment_usd: Decimal, #[serde(with = "rust_decimal::serde::float")] diff --git a/apps/labrinth/src/queue/payouts/mod.rs b/apps/labrinth/src/queue/payouts/mod.rs index e1e3753f43..0205dc9e08 100644 --- a/apps/labrinth/src/queue/payouts/mod.rs +++ b/apps/labrinth/src/queue/payouts/mod.rs @@ -882,20 +882,28 @@ pub struct AditudeTime { #[derive(Deserialize)] struct AditudeMetricsV2Response { - responses: Vec, + responses: Vec, } #[derive(Deserialize)] struct AditudeMetricsV2Table { - rows: Vec, + rows: Vec, } #[derive(Deserialize)] -struct AditudeRevenueRow { - #[serde(rename = "_TIME")] - time_millis: i64, - #[serde(rename = "REVENUE")] - revenue: Decimal, +struct AditudeMetricRow { + #[serde(rename = "_TIME")] + time_millis: i64, + #[serde(rename = "REVENUE")] + revenue: Option, + #[serde(rename = "IMPRESSIONS")] + impressions: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AditudeDayEstimate { + pub revenue: DayRevenue, + pub impressions: u128, } pub async fn make_aditude_request( @@ -929,37 +937,37 @@ pub async fn make_aditude_request( } async fn make_aditude_revenue_request( - start_time: i64, - end_time: i64, + start_time: i64, + end_time: i64, ) -> Result { - reqwest::Client::new() - .post("https://cloud.aditude.io/api/public/insights/metrics/v2") - .bearer_auth(&ENV.ADITUDE_API_KEY) - .json(&serde_json::json!({ - "metrics": ["REVENUE"], - "range": "custom", - "startTime": start_time, - "endTime": end_time, - "interval": "1d" - })) - .send() - .await - .wrap_internal_err("failed to request Aditude revenue estimates")? - .error_for_status() - .wrap_internal_err("Aditude revenue estimate request failed")? - .json() - .await - .wrap_internal_err("failed to deserialize Aditude revenue estimates") + reqwest::Client::new() + .post("https://cloud.aditude.io/api/public/insights/metrics/v2") + .bearer_auth(&ENV.ADITUDE_API_KEY) + .json(&serde_json::json!({ + "metrics": ["REVENUE", "IMPRESSIONS"], + "range": "custom", + "startTime": start_time, + "endTime": end_time, + "interval": "1d" + })) + .send() + .await + .wrap_internal_err("failed to request Aditude revenue estimates")? + .error_for_status() + .wrap_internal_err("Aditude revenue estimate request failed")? + .json() + .await + .wrap_internal_err("failed to deserialize Aditude revenue estimates") } const ADITUDE_MONTH_ESTIMATE_CACHE_NAMESPACE: &str = - "aditude_month_estimates_v2"; + "aditude_month_estimates_v1"; const ADITUDE_MONTH_ESTIMATE_CACHE_EXPIRY: i64 = 60 * 60 * 24; pub async fn get_cached_aditude_month_estimates( periods: &[YearMonth], redis: &RedisPool, -) -> Result>, ApiError> { +) -> Result>, ApiError> { redis .get_cached_keys_raw_with_expiry( ADITUDE_MONTH_ESTIMATE_CACHE_NAMESPACE, @@ -973,7 +981,7 @@ pub async fn get_cached_aditude_month_estimates( async fn fetch_aditude_month_estimates( periods: Vec, -) -> Result>, ApiError> { +) -> Result>, ApiError> { let first_period = periods .iter() .min() @@ -988,19 +996,19 @@ async fn fetch_aditude_month_estimates( .date() .checked_add_months(Months::new(1)) .wrap_internal_err("failed to calculate payout period end")?; - let range_start_time = first_period - .date() - .and_hms_opt(0, 0, 0) - .wrap_internal_err("failed to calculate payout period start")? - .and_utc() - .timestamp_millis(); - let range_end_time = range_end - .and_hms_opt(0, 0, 0) - .wrap_internal_err("failed to calculate payout period end")? - .and_utc() - .timestamp_millis() - .checked_sub(1) - .wrap_internal_err("failed to calculate inclusive payout period end")?; + let range_start_time = first_period + .date() + .and_hms_opt(0, 0, 0) + .wrap_internal_err("failed to calculate payout period start")? + .and_utc() + .timestamp_millis(); + let range_end_time = range_end + .and_hms_opt(0, 0, 0) + .wrap_internal_err("failed to calculate payout period end")? + .and_utc() + .timestamp_millis() + .checked_sub(1) + .wrap_internal_err("failed to calculate inclusive payout period end")?; let estimates = periods .into_iter() .map(|period| { @@ -1025,9 +1033,12 @@ async fn fetch_aditude_month_estimates( .wrap_internal_err( "failed to calculate payout period day", )?; - Ok(DayRevenue { - date, - amount_usd: Decimal::ZERO, + Ok(AditudeDayEstimate { + revenue: DayRevenue { + date, + amount_usd: Decimal::ZERO, + }, + impressions: 0, }) }) .collect::, ApiError>>()?; @@ -1035,15 +1046,15 @@ async fn fetch_aditude_month_estimates( }) .collect::, ApiError>>()?; - let response = - make_aditude_revenue_request(range_start_time, range_end_time).await?; - for row in response - .responses - .into_iter() - .flat_map(|response| response.rows) - { - let timestamp = DateTime::from_timestamp_millis(row.time_millis) - .wrap_internal_err("invalid Aditude estimate timestamp")?; + let response = + make_aditude_revenue_request(range_start_time, range_end_time).await?; + for row in response + .responses + .into_iter() + .flat_map(|response| response.rows) + { + let timestamp = DateTime::from_timestamp_millis(row.time_millis) + .wrap_internal_err("invalid Aditude estimate timestamp")?; let date = timestamp.date_naive(); let period = YearMonth::from_day1(date); let Some(mut days) = estimates.get_mut(&period) else { @@ -1054,13 +1065,23 @@ async fn fetch_aditude_month_estimates( date.signed_duration_since(period.date()).num_days(), ) .wrap_internal_err("invalid Aditude estimate day")?; - days[day_index].date = date; - days[day_index].amount_usd += row.revenue; + days[day_index].revenue.date = date; + if let Some(revenue) = row.revenue { + days[day_index].revenue.amount_usd += revenue; + } + if let Some(impressions) = row.impressions { + days[day_index].impressions += impressions; + } } Ok(estimates) } +pub fn clean_io_fee_usd(impressions: u128) -> Decimal { + let clean_io_cpm = Decimal::from(8) / Decimal::from(1000); + clean_io_cpm * Decimal::from(impressions) / Decimal::from(1000) +} + pub async fn process_payout( pool: &PgPool, client: &clickhouse::Client, @@ -1303,13 +1324,13 @@ pub async fn process_payout( // Modrinth's share of ad revenue let modrinth_cut = Decimal::from(1) / Decimal::from(4); // Clean.io fee (ad antimalware). Per 1000 impressions. 0.008 CPM - let clean_io_fee = Decimal::from(8) / Decimal::from(1000); + let clean_io_fee = clean_io_fee_usd(aditude_impressions); // Google Ad Manager fee. Per 1000 impressions. 0.015400 CPM let gam_fee = Decimal::from(154) / Decimal::from(10000); let net_revenue = aditude_amount - - ((clean_io_fee + gam_fee) * Decimal::from(aditude_impressions) - / Decimal::from(1000)); + - clean_io_fee + - (gam_fee * Decimal::from(aditude_impressions) / Decimal::from(1000)); let payout = net_revenue * (Decimal::from(1) - modrinth_cut); diff --git a/apps/labrinth/src/routes/internal/payouts.rs b/apps/labrinth/src/routes/internal/payouts.rs index 82fb899be1..07ddc16106 100644 --- a/apps/labrinth/src/routes/internal/payouts.rs +++ b/apps/labrinth/src/routes/internal/payouts.rs @@ -2,7 +2,7 @@ use std::cmp::Reverse; use std::collections::{HashMap, HashSet}; use actix_web::{HttpRequest, get, web}; -use chrono::{Months, Utc}; +use chrono::{DateTime, Months, Utc}; use rust_decimal::Decimal; use crate::auth::get_user_from_headers; @@ -10,10 +10,12 @@ use crate::database::models::DBUserId; use crate::database::{PgPool, ReadOnlyPgPool}; use crate::models::pats::Scopes; use crate::models::payout_runs::{ - Adjustment, DayRevenue, PayoutRun, PayoutRunCompletion, PayoutRunReport, - PayoutRunStatus, + Adjustment, DayRevenue, PayoutRun, PayoutRunCompletion, PayoutRunReport, + PayoutRunStatus, +}; +use crate::queue::payouts::{ + AditudeDayEstimate, clean_io_fee_usd, get_cached_aditude_month_estimates, }; -use crate::queue::payouts::get_cached_aditude_month_estimates; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::error::Context; @@ -22,8 +24,14 @@ use xredis::RedisPool; #[derive(Debug, Clone, Copy)] enum DayRevenueEstimate { - Raw, - AdjustedToActual { actual_revenue_usd: Decimal }, + Raw, + AdjustedToActual { actual_revenue_usd: Decimal }, +} + +#[derive(Debug, Clone, Copy)] +struct PayoutVariance { + applied_at: DateTime, + variance: Decimal, } pub fn config(cfg: &mut web::ServiceConfig) { @@ -82,41 +90,51 @@ pub async fn get( .await .wrap_internal_err("failed to fetch newest payout value")?; + let payout_variances = sqlx::query!( + r#" + SELECT applied_at, variance + FROM payouts_variance + ORDER BY applied_at ASC + "#, + ) + .fetch_all(&***ro_pool) + .await + .wrap_internal_err("failed to fetch payout variances")? + .into_iter() + .map(|row| PayoutVariance { + applied_at: row.applied_at, + variance: row.variance, + }) + .collect::>(); + let mut stored_periods = HashSet::with_capacity(stored_runs.len()); - let mut revenue_estimates = HashMap::new(); - let mut runs = Vec::with_capacity(stored_runs.len()); - for run in stored_runs { - let period_start = YearMonth::from_day1(run.period_start.date_naive()); - let (status, revenue_estimate) = if run.completed_at.is_some() { - let amount_usd = run - .completed_result + let mut revenue_estimates = HashMap::new(); + let mut runs = Vec::with_capacity(stored_runs.len()); + for run in stored_runs { + let period_start = YearMonth::from_day1(run.period_start.date_naive()); + let (status, revenue_estimate) = if run.completed_at.is_some() { + let amount_usd = run + .completed_result .map(|completion| completion.revenue_usd) .wrap_internal_err( "paid payout run is missing its completion result", )?; - ( - PayoutRunStatus::Paid, - DayRevenueEstimate::AdjustedToActual { - actual_revenue_usd: amount_usd, - }, - ) - } else { - (PayoutRunStatus::Review, DayRevenueEstimate::Raw) - }; - - stored_periods.insert(period_start); - revenue_estimates.insert(period_start, revenue_estimate); - runs.push(PayoutRun { - period_start, - status, - report: PayoutRunReport { - days: Vec::new(), - fees_deducted_usd: Decimal::ZERO, // TODO: calculate deducted fees - variance_adjustment_usd: Decimal::ZERO, // TODO: calculate variance adjustment - net_estimated_revenue_usd: Decimal::ZERO, // TODO: calculate net revenue - creator_net_estimated_revenue_usd: Decimal::ZERO, // TODO: calculate creator share - modrinth_net_estimated_revenue_usd: Decimal::ZERO, // TODO: calculate Modrinth share - }, + ( + PayoutRunStatus::Paid, + DayRevenueEstimate::AdjustedToActual { + actual_revenue_usd: amount_usd, + }, + ) + } else { + (PayoutRunStatus::Review, DayRevenueEstimate::Raw) + }; + + stored_periods.insert(period_start); + revenue_estimates.insert(period_start, revenue_estimate); + runs.push(PayoutRun { + period_start, + status, + report: empty_payout_report(), started_at: is_admin.then_some(run.started_at), started_by: is_admin .then_some(run.started_by.map(|id| DBUserId(id).into())) @@ -144,19 +162,12 @@ pub async fn get( PayoutRunStatus::Pending }; - revenue_estimates.insert(period, DayRevenueEstimate::Raw); + revenue_estimates.insert(period, DayRevenueEstimate::Raw); runs.push(PayoutRun { period_start: period, status, - report: PayoutRunReport { - days: Vec::new(), - fees_deducted_usd: Decimal::ZERO, // TODO: calculate deducted fees - variance_adjustment_usd: Decimal::ZERO, // TODO: calculate variance adjustment - net_estimated_revenue_usd: Decimal::ZERO, // TODO: calculate net revenue - creator_net_estimated_revenue_usd: Decimal::ZERO, // TODO: calculate creator share - modrinth_net_estimated_revenue_usd: Decimal::ZERO, // TODO: calculate Modrinth share - }, + report: empty_payout_report(), started_at: None, started_by: None, completed_at: None, @@ -178,60 +189,130 @@ pub async fn get( } } - let estimate_periods = revenue_estimates.keys().copied().collect::>(); - let estimates = - get_cached_aditude_month_estimates(&estimate_periods, &redis).await?; - for run in &mut runs { - if let Some(days) = estimates.get(&run.period_start) { - let days = match revenue_estimates.get(&run.period_start) { - Some(DayRevenueEstimate::Raw) | None => days.clone(), - Some(DayRevenueEstimate::AdjustedToActual { - actual_revenue_usd, - }) => adjust_estimates_to_actual(days, *actual_revenue_usd)?, - }; - run.report.net_estimated_revenue_usd = - days.iter().map(|day| day.amount_usd).sum(); - run.report.days = days; - } + let estimate_periods = + revenue_estimates.keys().copied().collect::>(); + let estimates = + get_cached_aditude_month_estimates(&estimate_periods, &redis).await?; + for run in &mut runs { + let estimates = estimates + .get(&run.period_start) + .wrap_internal_err("missing Aditude payout period estimates")?; + let revenue_estimate = revenue_estimates + .get(&run.period_start) + .copied() + .wrap_internal_err("missing payout period revenue estimate type")?; + run.report = calculate_payout_report( + estimates, + revenue_estimate, + &payout_variances, + )?; } runs.sort_by_key(|run| Reverse(run.period_start)); - Ok(web::Json(runs)) + Ok(web::Json(runs)) +} + +fn empty_payout_report() -> PayoutRunReport { + PayoutRunReport { + days: Vec::new(), + raw_estimated_revenue_usd: Decimal::ZERO, + fees_deducted_usd: Decimal::ZERO, + variance_adjustment_usd: Decimal::ZERO, + net_estimated_revenue_usd: Decimal::ZERO, + creator_net_estimated_revenue_usd: Decimal::ZERO, + modrinth_net_estimated_revenue_usd: Decimal::ZERO, + } +} + +fn calculate_payout_report( + estimates: &[AditudeDayEstimate], + revenue_estimate: DayRevenueEstimate, + payout_variances: &[PayoutVariance], +) -> Result { + let estimated_days = estimates + .iter() + .map(|estimate| estimate.revenue.clone()) + .collect::>(); + let days = match revenue_estimate { + DayRevenueEstimate::Raw => estimated_days, + DayRevenueEstimate::AdjustedToActual { actual_revenue_usd } => { + adjust_estimates_to_actual(&estimated_days, actual_revenue_usd)? + } + }; + + let raw_estimated_revenue_usd = + days.iter().map(|day| day.amount_usd).sum::(); + let fees_deducted_usd = estimates + .iter() + .map(|estimate| clean_io_fee_usd(estimate.impressions)) + .sum::(); + let variance_adjustment_usd = days + .iter() + .zip(estimates) + .map(|(day, estimate)| { + let variance = payout_variances + .iter() + .rev() + .find(|variance| variance.applied_at.date_naive() <= day.date) + .map(|variance| variance.variance) + .wrap_internal_err("missing payout variance for revenue day")?; + let fee_usd = clean_io_fee_usd(estimate.impressions); + Ok((day.amount_usd - fee_usd) * variance) + }) + .collect::, ApiError>>()? + .into_iter() + .sum::(); + let net_estimated_revenue_usd = + raw_estimated_revenue_usd - fees_deducted_usd - variance_adjustment_usd; + let creator_net_estimated_revenue_usd = + net_estimated_revenue_usd * Decimal::new(75, 2); + let modrinth_net_estimated_revenue_usd = + net_estimated_revenue_usd - creator_net_estimated_revenue_usd; + + Ok(PayoutRunReport { + days, + raw_estimated_revenue_usd, + fees_deducted_usd, + variance_adjustment_usd, + net_estimated_revenue_usd, + creator_net_estimated_revenue_usd, + modrinth_net_estimated_revenue_usd, + }) } fn adjust_estimates_to_actual( - days: &[DayRevenue], - actual_revenue_usd: Decimal, + days: &[DayRevenue], + actual_revenue_usd: Decimal, ) -> Result, ApiError> { - if days.is_empty() { - return Ok(Vec::new()); - } - - let estimated_revenue_usd = - days.iter().map(|day| day.amount_usd).sum::(); - let day_count = u64::try_from(days.len()) - .wrap_internal_err("failed to calculate payout period day count")?; - let mut allocated_revenue_usd = Decimal::ZERO; - let last_day = days.len() - 1; - - Ok(days - .iter() - .enumerate() - .map(|(index, day)| { - let amount_usd = if index == last_day { - actual_revenue_usd - allocated_revenue_usd - } else if estimated_revenue_usd.is_zero() { - actual_revenue_usd / Decimal::from(day_count) - } else { - day.amount_usd * actual_revenue_usd / estimated_revenue_usd - }; - allocated_revenue_usd += amount_usd; - - DayRevenue { - date: day.date, - amount_usd, - } - }) - .collect()) + if days.is_empty() { + return Ok(Vec::new()); + } + + let estimated_revenue_usd = + days.iter().map(|day| day.amount_usd).sum::(); + let day_count = u64::try_from(days.len()) + .wrap_internal_err("failed to calculate payout period day count")?; + let mut allocated_revenue_usd = Decimal::ZERO; + let last_day = days.len() - 1; + + Ok(days + .iter() + .enumerate() + .map(|(index, day)| { + let amount_usd = if index == last_day { + actual_revenue_usd - allocated_revenue_usd + } else if estimated_revenue_usd.is_zero() { + actual_revenue_usd / Decimal::from(day_count) + } else { + day.amount_usd * actual_revenue_usd / estimated_revenue_usd + }; + allocated_revenue_usd += amount_usd; + + DayRevenue { + date: day.date, + amount_usd, + } + }) + .collect()) }