estimation

This commit is contained in:
aecsocket
2026-08-17 17:00:13 +09:00
parent 3326c6d973
commit 7c24822a1a
4 changed files with 299 additions and 2 deletions
+1
View File
@@ -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"] }
+111 -2
View File
@@ -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<DayEstimate>,
}
#[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<Vec<PeriodEstimate>> {
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<DashMap<YearMonth, PeriodEstimate>> {
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::<YearMonth, PeriodEstimate>::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::<DashMap<_, _>>())
}
+1
View File
@@ -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;
+186
View File
@@ -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<Self> {
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<DateTime<Utc>> {
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<Self, Self::Err> {
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for YearMonth {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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::schema::Schema> {
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::<YearMonth>(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()
)
);
}
}