wip: dev-1126-v2

This commit is contained in:
aecsocket
2026-08-17 07:28:50 +00:00
parent 248c7bb217
commit 3326c6d973
14 changed files with 617 additions and 0 deletions
Generated
+16
View File
@@ -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"
+2
View File
@@ -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"
+1
View File
@@ -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;
@@ -0,0 +1,5 @@
//! Logic for fetching and caching revenue estimations from our ad provider.
pub async fn estimate(aditude: &aditude::Client) {
aditude.
}
+65
View File
@@ -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;
View File
+25
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
Types for the [Aditude API](https://aditude.io).
+38
View File
@@ -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(())
}
+82
View File
@@ -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<mock::AditudeMock>,
}
impl Client {
/// Creates a new Aditude client with a [`reqwest::Client`].
#[must_use]
pub fn from_client(
http: reqwest::Client,
api_url: impl Into<Cow<'static, str>>,
api_key: impl Into<SecretString>,
) -> 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<Cow<'static, str>>,
api_key: impl Into<SecretString>,
) -> 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<Utc>) -> (DateTime<Utc>, DateTime<Utc>) {
let start = DateTime::<Utc>::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)
}
+12
View File
@@ -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<Vec<v1::MetricsResponse>>,
/// Response for [`crate::Aditude::get_metrics_v2`].
pub metrics_v2_response: Option<v2::Metrics>,
}
+139
View File
@@ -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<Vec<MetricsResponse>> {
#[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<DateTime<Utc>>,
#[serde(with = "chrono::serde::ts_milliseconds_option")]
pub end_time: Option<DateTime<Utc>>,
}
#[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::<Vec<MetricsResponse>>()
.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<Utc>,
#[serde(with = "chrono::serde::ts_milliseconds")]
end: DateTime<Utc>,
},
}
#[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<Point>,
}
#[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<Decimal>,
pub impressions: Option<u128>,
#[serde(with = "rust_decimal::serde::float_option", default)]
pub cpm: Option<Decimal>,
}
#[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<v1::MetricsResponse> {
self.metrics_v1_response
.as_ref()
.expect("missing mock `metrics_v1_response`")
.clone()
}
}
};
+114
View File
@@ -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<Metrics> {
#[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<DateTime<Utc>>,
#[serde(with = "chrono::serde::ts_milliseconds_option")]
pub end_time: Option<DateTime<Utc>>,
}
#[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::<Metrics>()
.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<Response>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Response {
pub rows: Vec<Row>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Row {
#[serde(rename = "IMPRESSIONS")]
pub impressions: Option<u128>,
#[serde(rename = "REVENUE")]
pub revenue: Option<Decimal>,
#[serde(rename = "_TIME", with = "chrono::serde::ts_milliseconds")]
pub time: DateTime<Utc>,
}
#[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()
}
}
};
+117
View File
@@ -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);
}