From 3326c6d9736eea785f1ef497e54da42e25f7bd9a Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:28:50 +0000 Subject: [PATCH] wip: dev-1126-v2 --- Cargo.lock | 16 ++ Cargo.toml | 2 + apps/labrinth/src/queue/mod.rs | 1 + .../labrinth/src/queue/payout_run/estimate.rs | 5 + apps/labrinth/src/queue/payout_run/mod.rs | 65 ++++++++ apps/labrinth/src/util/time.rs | 0 packages/aditude/Cargo.toml | 25 ++++ packages/aditude/README.md | 1 + packages/aditude/examples/aditude.rs | 38 +++++ packages/aditude/src/lib.rs | 82 +++++++++++ packages/aditude/src/mock.rs | 12 ++ packages/aditude/src/v1.rs | 139 ++++++++++++++++++ packages/aditude/src/v2.rs | 114 ++++++++++++++ packages/aditude/tests/metrics.rs | 117 +++++++++++++++ 14 files changed, 617 insertions(+) create mode 100644 apps/labrinth/src/queue/payout_run/estimate.rs create mode 100644 apps/labrinth/src/queue/payout_run/mod.rs create mode 100644 apps/labrinth/src/util/time.rs create mode 100644 packages/aditude/Cargo.toml create mode 100644 packages/aditude/README.md create mode 100644 packages/aditude/examples/aditude.rs create mode 100644 packages/aditude/src/lib.rs create mode 100644 packages/aditude/src/mock.rs create mode 100644 packages/aditude/src/v1.rs create mode 100644 packages/aditude/src/v2.rs create mode 100644 packages/aditude/tests/metrics.rs diff --git a/Cargo.lock b/Cargo.lock index 0772a3a9c1..06935e8be5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -300,6 +300,22 @@ dependencies = [ "gimli", ] +[[package]] +name = "aditude" +version = "0.0.0" +dependencies = [ + "aditude", + "arc-swap", + "chrono", + "dotenvy", + "eyre", + "reqwest 0.12.24", + "rust_decimal", + "secrecy", + "serde", + "tokio", +] + [[package]] name = "adler2" version = "2.0.1" diff --git a/Cargo.toml b/Cargo.toml index ab510f52d7..0283fe8182 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "apps/app-playground", "apps/daedalus_client", "apps/labrinth", + "packages/aditude", "packages/app-lib", "packages/ariadne", "packages/daedalus", @@ -55,6 +56,7 @@ aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [ base64 = "0.22.1" bitflags = "2.9.4" bon = "3.9.3" +aditude = { path = "packages/aditude" } bytemuck = "1.24.0" bytes = "1.10.1" censor = "0.3.0" diff --git a/apps/labrinth/src/queue/mod.rs b/apps/labrinth/src/queue/mod.rs index 0d7dcb273b..a84b48cb68 100644 --- a/apps/labrinth/src/queue/mod.rs +++ b/apps/labrinth/src/queue/mod.rs @@ -3,6 +3,7 @@ pub mod billing; pub mod email; pub mod file_scan; pub mod moderation; +pub mod payout_run; pub mod payouts; pub mod server_ping; pub mod session; diff --git a/apps/labrinth/src/queue/payout_run/estimate.rs b/apps/labrinth/src/queue/payout_run/estimate.rs new file mode 100644 index 0000000000..2e4893d989 --- /dev/null +++ b/apps/labrinth/src/queue/payout_run/estimate.rs @@ -0,0 +1,5 @@ +//! Logic for fetching and caching revenue estimations from our ad provider. + +pub async fn estimate(aditude: &aditude::Client) { + aditude. +} diff --git a/apps/labrinth/src/queue/payout_run/mod.rs b/apps/labrinth/src/queue/payout_run/mod.rs new file mode 100644 index 0000000000..d0c37c5c2b --- /dev/null +++ b/apps/labrinth/src/queue/payout_run/mod.rs @@ -0,0 +1,65 @@ +//! Logic for executing payout runs, including starting a run, and performing +//! the revenue distribution. +//! +//! # Architecture +//! +//! ## Payout run +//! +//! The general flow for a payout run is as follows: +//! - For a given month (say, January) our ad provider gives us an estimate of +//! how much revenue and how many impressions we received for each individual +//! day. The month (payout period) starts in an _open_ state. +//! - After NET 60 has passed (start of March), the January payouts _should_ be +//! available; usually it takes some time for our ad provider to send the +//! money, so this is closer to NET 75. During this period, the month (payout +//! period) is in an _in review_ state. +//! - Once we receive the money from the provider, an admin enters the total +//! amount we've received into the web UI, adds any manual adjustments (for +//! campaigns outside of our ad provider's), and starts a payout run. +//! - The payout run is not immediately executed; there is a period of time in +//! which it can still be cancelled. +//! - Once the payout run is executed, we calculate the exact revenue +//! distribution to all creators, and fill `payouts_values` with those +//! amounts. +//! +//! ## Distribution +//! +//! How revenue is distributed: +//! - While a month is still open/in review: +//! - `raw_estimated_revenue_usd`: how much our ad provider estimates we'll +//! earn for a specific period +//! - We also get a value for this per day +//! - `fees_usd`: how much we pay in fees to Clean.io +//! - Based on number of impressions; we can get a per-day value for this +//! - `variance_usd`: a fixed percentage that we subtract from the raw estimated +//! revenue to account for it being an overestimate +//! - e.g. if variance is 10%, and we estimate that we'll earn $100k, then +//! our ad provider will probably give us closer to $90k - the variance +//! lets us express this difference +//! - `net_estimated_revenue_usd`: raw estimated - fees - variance +//! - `platform_net_estimated_revenue_usd`: net estimated revenue x Modrinth's cut +//! - `creator_net_estimated_revenue_usd`: net estimated revenue x (1 - Modrinth's cut) +//! - After a payout run has been executed: +//! - We save the per-day raw estimated revenue and impressions in the +//! database +//! - `raw_actual_revenue_usd`: how much we got from Aditude, input by an admin +//! - We compute this per-day by: +//! ```text +//! let factor = raw_actual_revenue_usd / raw_estimated_revenue_usd +//! raw_actual_revenue_usd_for_today = raw_estimated_revenue_usd_for_today * factor +//! ``` +//! - (fees stay the same, since they're based on impressions, not revenue) +//! - (variance is ignored, since that's purely an estimation value) +//! - `adjustments_usd`: sum of all manual adjustments input by the admin +//! - `net_actual_revenue_usd`: raw actual revenue - fees + adjustments +//! - `(platform|creator)_net_estimated_revenue_usd`: same logic as estimated, +//! but using the net actual revenue +//! +//! ## Variance +//! +//! We store a table `payouts_variance` with columns: +//! - a timestamp from when this variance value applies (first entry at Unix +//! epoch) +//! - the decimal fraction of variance to apply + +mod estimate; diff --git a/apps/labrinth/src/util/time.rs b/apps/labrinth/src/util/time.rs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/aditude/Cargo.toml b/packages/aditude/Cargo.toml new file mode 100644 index 0000000000..f0c73b6855 --- /dev/null +++ b/packages/aditude/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "aditude" +edition.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +arc-swap = { workspace = true, optional = true } +reqwest = { workspace = true, features = ["json"] } +rust_decimal = { workspace = true } +secrecy = { workspace = true } +serde = { workspace = true, features = ["derive"] } +chrono = { workspace = true, features = ["serde"] } + +[dev-dependencies] +aditude = { path = ".", features = ["mock"] } +dotenvy = { workspace = true } +eyre = { workspace = true } +tokio = { workspace = true, features = ["full"] } + +[features] +mock = ["dep:arc-swap"] + +[lints] +workspace = true diff --git a/packages/aditude/README.md b/packages/aditude/README.md new file mode 100644 index 0000000000..a1857b8397 --- /dev/null +++ b/packages/aditude/README.md @@ -0,0 +1 @@ +Types for the [Aditude API](https://aditude.io). diff --git a/packages/aditude/examples/aditude.rs b/packages/aditude/examples/aditude.rs new file mode 100644 index 0000000000..4ed121a325 --- /dev/null +++ b/packages/aditude/examples/aditude.rs @@ -0,0 +1,38 @@ +//! Example Aditude client. +#![expect(clippy::print_stdout, reason = "this is an example")] + +use aditude::{Client, v1, v2}; +use eyre::{Context, Result}; + +#[tokio::main] +async fn main() -> Result<()> { + _ = dotenvy::dotenv(); + let aditude = Client::new( + dotenvy::var("ADITUDE_API_URL").wrap_err("no API URL")?, + dotenvy::var("ADITUDE_API_KEY").wrap_err("no API key")?, + ); + + let resp = aditude + .get_metrics_v1(v1::GetMetrics { + metrics: &[v1::MetricKind::Impressions, v1::MetricKind::Revenue], + range: v1::Range::Yesterday, + interval: v1::Interval::OneDay, + }) + .await + .wrap_err("failed to get metrics")?; + println!("{resp:#?}"); + + println!("\n---\n"); + + let resp = aditude + .get_metrics_v2(v2::GetMetrics { + metrics: &[v2::MetricKind::Impressions, v2::MetricKind::Revenue], + range: v2::Range::Yesterday, + interval: v2::Interval::OneDay, + }) + .await + .wrap_err("failed to get metrics")?; + println!("{resp:#?}"); + + Ok(()) +} diff --git a/packages/aditude/src/lib.rs b/packages/aditude/src/lib.rs new file mode 100644 index 0000000000..aa45862c20 --- /dev/null +++ b/packages/aditude/src/lib.rs @@ -0,0 +1,82 @@ +#![doc = include_str!("../README.md")] +#![allow(missing_docs, reason = "these are Aditude types")] + +#[cfg(feature = "mock")] +pub mod mock; +pub mod v1; +pub mod v2; + +use std::borrow::Cow; + +use chrono::{DateTime, Duration, Utc}; +use secrecy::SecretString; + +/// [Aditude](https://www.aditude.com/) client. +#[derive(Debug)] +pub struct Client { + http: reqwest::Client, + pub api_url: Cow<'static, str>, + pub api_key: SecretString, + #[cfg(feature = "mock")] + pub mock: arc_swap::ArcSwapOption, +} + +impl Client { + /// Creates a new Aditude client with a [`reqwest::Client`]. + #[must_use] + pub fn from_client( + http: reqwest::Client, + api_url: impl Into>, + api_key: impl Into, + ) -> Self { + Self { + http, + api_url: api_url.into(), + api_key: api_key.into(), + #[cfg(feature = "mock")] + mock: arc_swap::ArcSwapOption::empty(), + } + } + + /// Creates a new Aditude client. + #[must_use] + pub fn new( + api_url: impl Into>, + api_key: impl Into, + ) -> Self { + Self::from_client(reqwest::Client::new(), api_url, api_key) + } + + /// Creates an Aditude client which mocks responses. + #[cfg(feature = "mock")] + #[must_use] + pub fn from_mock(mock: mock::AditudeMock) -> Self { + Self { + http: reqwest::Client::new(), + api_url: "".into(), + api_key: SecretString::from(String::new()), + mock: arc_swap::ArcSwapOption::from_pointee(mock), + } + } + + /// Sets the mock responses that this client will output. + #[cfg(feature = "mock")] + pub fn set_mock(&self, mock: mock::AditudeMock) { + self.mock.store(Some(std::sync::Arc::new(mock))); + } +} + +/// Returns the start and end of what Aditude considers the "Yesterday" time +/// range. +#[must_use] +pub fn yesterday(now: DateTime) -> (DateTime, DateTime) { + let start = DateTime::::from_naive_utc_and_offset( + (now - Duration::days(1)) + .date_naive() + .and_hms_nano_opt(0, 0, 0, 0) + .unwrap_or_default(), + Utc, + ); + let end = start + Duration::days(1); + (start, end) +} diff --git a/packages/aditude/src/mock.rs b/packages/aditude/src/mock.rs new file mode 100644 index 0000000000..a1b86245b1 --- /dev/null +++ b/packages/aditude/src/mock.rs @@ -0,0 +1,12 @@ +//! See [`AditudeMock`]. + +use crate::{v1, v2}; + +/// Mock data returned by [`crate::Aditude`]. +#[derive(Debug, Clone, Default)] +pub struct AditudeMock { + /// Response for [`crate::Aditude::get_metrics_v1`]. + pub metrics_v1_response: Option>, + /// Response for [`crate::Aditude::get_metrics_v2`]. + pub metrics_v2_response: Option, +} diff --git a/packages/aditude/src/v1.rs b/packages/aditude/src/v1.rs new file mode 100644 index 0000000000..3ec3683c4d --- /dev/null +++ b/packages/aditude/src/v1.rs @@ -0,0 +1,139 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use secrecy::ExposeSecret; +use serde::{Deserialize, Serialize}; + +use crate::Client; + +impl Client { + /// Fetches insights metrics from the API. + /// + /// # Errors + /// + /// Errors if the request could not be completed. + pub async fn get_metrics_v1( + &self, + req: GetMetrics<'_>, + ) -> reqwest::Result> { + #[derive(Debug, Clone, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct Body<'a> { + pub metrics: &'a [MetricKind], + pub range: &'static str, + pub interval: Interval, + #[serde(with = "chrono::serde::ts_milliseconds_option")] + pub start_time: Option>, + #[serde(with = "chrono::serde::ts_milliseconds_option")] + pub end_time: Option>, + } + + #[cfg(feature = "mock")] + if let Some(mock) = &*self.mock.load() { + return Ok(mock.get_metrics_v1()); + } + + let body = Body { + metrics: req.metrics, + range: match req.range { + Range::Yesterday => "Yesterday", + Range::Custom { .. } => "custom", + }, + interval: req.interval, + start_time: if let Range::Custom { start, .. } = req.range { + Some(start) + } else { + None + }, + end_time: if let Range::Custom { end, .. } = req.range { + Some(end) + } else { + None + }, + }; + + self.http + .post(format!("{}/public/insights/metrics", self.api_url)) + .bearer_auth(self.api_key.expose_secret()) + .json(&body) + .send() + .await? + .error_for_status()? + .json::>() + .await + } +} + +#[derive(Debug, Clone)] +pub struct GetMetrics<'a> { + pub metrics: &'a [MetricKind], + pub range: Range, + pub interval: Interval, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Range { + #[serde(rename = "Yesterday")] + Yesterday, + #[serde(rename = "custom", rename_all = "camelCase")] + Custom { + #[serde(with = "chrono::serde::ts_milliseconds")] + start: DateTime, + #[serde(with = "chrono::serde::ts_milliseconds")] + end: DateTime, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Interval { + #[serde(rename = "1d")] + OneDay, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MetricKind { + #[serde(rename = "METRIC_IMPRESSIONS")] + Impressions, + #[serde(rename = "METRIC_REVENUE")] + Revenue, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MetricsResponse { + pub points_list: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Point { + pub metric: Metric, + pub time: Time, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Metric { + #[serde(with = "rust_decimal::serde::float_option", default)] + pub revenue: Option, + pub impressions: Option, + #[serde(with = "rust_decimal::serde::float_option", default)] + pub cpm: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Time { + pub seconds: u64, + pub nanos: u64, +} + +#[cfg(feature = "mock")] +const _: () = { + use crate::{mock::AditudeMock, v1}; + + impl AditudeMock { + pub(crate) fn get_metrics_v1(&self) -> Vec { + self.metrics_v1_response + .as_ref() + .expect("missing mock `metrics_v1_response`") + .clone() + } + } +}; diff --git a/packages/aditude/src/v2.rs b/packages/aditude/src/v2.rs new file mode 100644 index 0000000000..f7ce50285e --- /dev/null +++ b/packages/aditude/src/v2.rs @@ -0,0 +1,114 @@ +pub use crate::v1::{Interval, Range}; +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use secrecy::ExposeSecret; +use serde::{Deserialize, Serialize}; + +use crate::Client; + +impl Client { + /// Fetches insights metrics from the API. + /// + /// # Errors + /// + /// Errors if the request could not be completed. + pub async fn get_metrics_v2( + &self, + req: GetMetrics<'_>, + ) -> reqwest::Result { + #[derive(Debug, Clone, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct Body<'a> { + pub metrics: &'a [MetricKind], + pub range: &'static str, + pub interval: Interval, + #[serde(with = "chrono::serde::ts_milliseconds_option")] + pub start_time: Option>, + #[serde(with = "chrono::serde::ts_milliseconds_option")] + pub end_time: Option>, + } + + #[cfg(feature = "mock")] + if let Some(mock) = &*self.mock.load() { + return Ok(mock.get_metrics_v2()); + } + + let body = Body { + metrics: req.metrics, + range: match req.range { + Range::Yesterday => "Yesterday", + Range::Custom { .. } => "custom", + }, + interval: req.interval, + start_time: if let Range::Custom { start, .. } = req.range { + Some(start) + } else { + None + }, + end_time: if let Range::Custom { end, .. } = req.range { + Some(end) + } else { + None + }, + }; + + self.http + .post(format!("{}/public/insights/metrics/v2", self.api_url)) + .bearer_auth(self.api_key.expose_secret()) + .json(&body) + .send() + .await? + .error_for_status()? + .json::() + .await + } +} + +#[derive(Debug, Clone)] +pub struct GetMetrics<'a> { + pub metrics: &'a [MetricKind], + pub range: Range, + pub interval: Interval, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MetricKind { + #[serde(rename = "IMPRESSIONS")] + Impressions, + #[serde(rename = "REVENUE")] + Revenue, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Metrics { + pub responses: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Response { + pub rows: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Row { + #[serde(rename = "IMPRESSIONS")] + pub impressions: Option, + #[serde(rename = "REVENUE")] + pub revenue: Option, + #[serde(rename = "_TIME", with = "chrono::serde::ts_milliseconds")] + pub time: DateTime, +} + +#[cfg(feature = "mock")] +const _: () = { + use crate::{mock::AditudeMock, v2}; + + impl AditudeMock { + pub(crate) fn get_metrics_v2(&self) -> v2::Metrics { + self.metrics_v2_response + .as_ref() + .expect("missing mock `metrics_v2_response`") + .clone() + } + } +}; diff --git a/packages/aditude/tests/metrics.rs b/packages/aditude/tests/metrics.rs new file mode 100644 index 0000000000..bcb02aba03 --- /dev/null +++ b/packages/aditude/tests/metrics.rs @@ -0,0 +1,117 @@ +#![expect(missing_docs, reason = "test crate")] + +use aditude::{Client, mock::AditudeMock, v1, v2}; +use chrono::{DateTime, Utc}; +use rust_decimal::dec; + +#[tokio::test] +async fn mock_metrics() { + let metrics_v1_response = vec![v1::MetricsResponse { + points_list: vec![v1::Point { + metric: v1::Metric { + revenue: Some(dec!(1.23)), + impressions: Some(456), + cpm: Some(dec!(7.89)), + }, + time: v1::Time { + seconds: 123, + nanos: 0, + }, + }], + }]; + + let epoch = DateTime::UNIX_EPOCH; + let metrics_v2_response = v2::Metrics { + responses: vec![ + v2::Response { + rows: vec![v2::Row { + impressions: Some(123), + revenue: None, + time: epoch, + }], + }, + v2::Response { + rows: vec![v2::Row { + impressions: None, + revenue: Some(dec!(45.6)), + time: epoch, + }], + }, + ], + }; + + let aditude = Client::from_mock(AditudeMock { + metrics_v1_response: Some(metrics_v1_response.clone()), + metrics_v2_response: Some(metrics_v2_response.clone()), + }); + assert_eq!( + metrics_v1_response, + aditude + .get_metrics_v1(v1::GetMetrics { + metrics: &[], + range: v1::Range::Yesterday, + interval: v1::Interval::OneDay, + }) + .await + .unwrap() + ); + assert_eq!( + metrics_v2_response, + aditude + .get_metrics_v2(v2::GetMetrics { + metrics: &[], + range: v2::Range::Yesterday, + interval: v2::Interval::OneDay, + }) + .await + .unwrap() + ); +} + +#[tokio::test] +async fn test_yesterday() { + _ = dotenvy::dotenv(); + let (Ok(url), Ok(key)) = ( + dotenvy::var("ADITUDE_API_URL"), + dotenvy::var("ADITUDE_API_KEY"), + ) else { + return; + }; + if url.trim().is_empty() + || key.trim().is_empty() + || url == "none" + || key == "none" + { + return; + } + + let aditude = Client::new(url, key); + + let real_yesterday = aditude + .get_metrics_v2(v2::GetMetrics { + metrics: &[v2::MetricKind::Impressions, v2::MetricKind::Revenue], + range: v2::Range::Yesterday, + interval: v2::Interval::OneDay, + }) + .await + .unwrap(); + + // Aditude defines the time range "Yesterday" according to this logic, I think. + // We need to make sure it stays defined like this. + let now = Utc::now(); + let (start_of_yesterday, end_of_yesterday) = aditude::yesterday(now); + + let our_yesterday = aditude + .get_metrics_v2(v2::GetMetrics { + metrics: &[v2::MetricKind::Impressions, v2::MetricKind::Revenue], + range: v2::Range::Custom { + start: start_of_yesterday, + end: end_of_yesterday, + }, + interval: v2::Interval::OneDay, + }) + .await + .unwrap(); + + assert_eq!(real_yesterday, our_yesterday); +}