From 7c24822a1a0e1d92710c0d5c768022b0552d1698 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:00:13 +0900 Subject: [PATCH] estimation --- apps/labrinth/Cargo.toml | 1 + .../labrinth/src/queue/payout_run/estimate.rs | 113 ++++++++++- apps/labrinth/src/util/mod.rs | 1 + apps/labrinth/src/util/time.rs | 186 ++++++++++++++++++ 4 files changed, 299 insertions(+), 2 deletions(-) diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index fbfe31b0bf..5075bed3f8 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -19,6 +19,7 @@ actix-web = { workspace = true } actix-web-prom = { workspace = true, features = ["process"] } actix-ws = { workspace = true } arc-swap = { workspace = true } +aditude = { workspace = true } argon2 = { workspace = true } ariadne = { workspace = true } async-minecraft-ping = { workspace = true, features = ["srv"] } diff --git a/apps/labrinth/src/queue/payout_run/estimate.rs b/apps/labrinth/src/queue/payout_run/estimate.rs index 2e4893d989..6139037102 100644 --- a/apps/labrinth/src/queue/payout_run/estimate.rs +++ b/apps/labrinth/src/queue/payout_run/estimate.rs @@ -1,5 +1,114 @@ //! Logic for fetching and caching revenue estimations from our ad provider. -pub async fn estimate(aditude: &aditude::Client) { - aditude. +use std::collections::HashMap; + +use chrono::{Datelike, Months}; +use dashmap::DashMap; +use eyre::{Result, eyre}; +use rust_decimal::Decimal; +use xredis::RedisPool; + +use crate::{ + routes::ApiError, + util::{error::Context, time::YearMonth}, +}; + +const REDIS_KEY: &str = "aditude_month_estimate_v1"; + +#[derive(Debug)] +pub struct PeriodEstimate { + pub period: YearMonth, + pub days: Vec, +} + +#[derive(Debug)] +pub struct DayEstimate { + pub day: u32, + pub raw_estimated_revenue_usd: Decimal, + pub impressions: u128, +} + +pub async fn estimate( + aditude: &aditude::Client, + redis: &RedisPool, + periods: &[YearMonth], +) -> Result> { + redis + .get_cached_keys(REDIS_KEY, periods, |periods| async { + fetch_estimates(aditude, redis, &periods) + .await + .map_err(ApiError::Internal) + }) + .await +} + +async fn fetch_estimates( + aditude: &aditude::Client, + redis: &RedisPool, + periods: &[YearMonth], +) -> Result> { + let mut periods = periods.iter(); + let first_period = periods.next().wrap_err("no first period")?; + let last_period = periods.last().unwrap_or(first_period); + + let range_start = first_period + .date() + .and_hms_opt(0, 0, 0) + .wrap_err("calculating payout period start")? + .and_utc(); + let range_end = last_period + .date() + .checked_add_months(Months::new(1)) + .wrap_err("calculating month after payout period end")? + .and_hms_opt(0, 0, 0) + .wrap_err("calculating payout period end")? + .and_utc(); + + let metrics = aditude + .get_metrics_v2(aditude::v2::GetMetrics { + metrics: &[ + aditude::v2::MetricKind::Revenue, + aditude::v2::MetricKind::Impressions, + ], + range: aditude::v2::Range::Custom { + start: range_start, + end: range_end, + }, + interval: aditude::v2::Interval::OneDay, + }) + .await + .wrap_err("fetching metrics from Aditude")?; + + let mut map = HashMap::::new(); + for response in metrics.responses { + for row in response.rows { + let date = row.time.date_naive(); + let period = YearMonth::from_day1(date); + + let period_estimate = map.entry(period).or_insert(PeriodEstimate { + period, + days: Vec::new(), + }); + let days = &mut period_estimate.days; + + let day = date.day(); + days.push(DayEstimate { + day, + raw_estimated_revenue_usd: row + .revenue + .wrap_err_with(|| eyre!("no revenue data for day {day}"))?, + impressions: row.impressions.wrap_err_with(|| { + eyre!("no impressions data for day {day}") + })?, + }); + } + } + + // we have no clue if the Aditude row return order is stable, + // so for safety sort the days here + for period in map.values_mut() { + period.days.sort_unstable_by_key(|day| day.day); + } + + Ok(map.into_iter().collect::>()) } diff --git a/apps/labrinth/src/util/mod.rs b/apps/labrinth/src/util/mod.rs index dc17c4eeb6..809063e494 100644 --- a/apps/labrinth/src/util/mod.rs +++ b/apps/labrinth/src/util/mod.rs @@ -20,5 +20,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 index e69de29bb2..74013687d7 100644 --- a/apps/labrinth/src/util/time.rs +++ b/apps/labrinth/src/util/time.rs @@ -0,0 +1,186 @@ +use std::{fmt, str::FromStr}; + +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)] +pub struct YearMonth(NaiveDate); + +impl YearMonth { + pub fn from_day1(date: NaiveDate) -> Self { + Self(date.with_day(1).expect("every monht has a first day")) + } + + pub fn from_year_month(year: i32, month: u32) -> Option { + NaiveDate::from_ymd_opt(year, month, 1).map(Self) + } + + #[must_use] + pub fn date(self) -> NaiveDate { + self.0 + } +} + +/// 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)) +} + +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()); + } + } + + #[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() + ) + ); + } +}