wip: estimated payout period info

This commit is contained in:
aecsocket
2026-08-17 18:13:14 +09:00
parent 369cd7aa91
commit 9e07617173
7 changed files with 143 additions and 21 deletions
+18 -8
View File
@@ -14,7 +14,7 @@ use crate::{
util::{error::Context, time::YearMonth},
};
const REDIS_KEY: &str = "aditude_month_estimate_v1";
const REDIS_KEY: &str = "aditude_month_estimate:v1";
#[derive(Debug, Serialize, Deserialize)]
pub struct PeriodEstimate {
@@ -35,23 +35,24 @@ pub async fn estimate(
redis: &RedisPool,
periods: &[YearMonth],
) -> Result<Vec<PeriodEstimate>> {
redis
let mut periods = redis
.get_cached_keys(REDIS_KEY, periods, |periods| async move {
fetch_estimates(aditude, redis, &periods)
fetch_estimates(aditude, &periods)
.await
.map_err(ApiError::Internal)
})
.await
.await?;
periods.sort_unstable_by_key(|p| p.period);
Ok(periods)
}
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 mut periods_iter = periods.iter();
let first_period = periods_iter.next().wrap_err("no first period")?;
let last_period = periods_iter.last().unwrap_or(first_period);
let range_start = first_period
.date()
@@ -112,5 +113,14 @@ async fn fetch_estimates(
period.days.sort_unstable_by_key(|day| day.date);
}
// for any `periods` for which we don't have data yet,
// give them an empty estimate dataset
for &period in periods {
map.entry(period).or_insert(PeriodEstimate {
period,
days: Vec::new(),
});
}
Ok(map.into_iter().collect::<DashMap<_, _>>())
}
@@ -68,6 +68,8 @@ use serde::{Deserialize, Serialize};
mod estimate;
pub use estimate::*;
/// Fraction defining much of the net revenue goes to the platform.
const PLATFORM_REVENUE_SPLIT: Decimal = dec!(0.25);
+1
View File
@@ -46,6 +46,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.service(web::scope("/campaign").configure(campaign::config))
.service(web::scope("/search-management").configure(search::config))
.service(web::scope("/globals").configure(globals::config))
.service(web::scope("/payout-runs").configure(payout_runs::config))
.service(web::scope("/server-ping").configure(server_ping::config))
.service(web::scope("/attribution").configure(attribution::config))
.configure(billing::config)
@@ -1,6 +1,24 @@
use actix_web::{get, web};
use chrono::{Months, NaiveDate, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
use crate::{queue::payout_run::DayDistribution, routes::ApiError};
use crate::{
database::PgPool,
queue::payout_run::{
DayDistribution, PayoutVariances, distribution_for_day, estimate,
},
routes::ApiError,
util::{
error::Context,
time::{YearMonth, net_60_payout_available_at},
},
};
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get_runs);
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PayoutRuns {
@@ -10,11 +28,27 @@ pub struct PayoutRuns {
#[derive(Debug, Serialize, Deserialize)]
pub struct PayoutRunPeriod {
pub period: YearMonth,
pub status: PayoutRunStatus,
pub status: PayoutPeriodStatus,
pub days: Vec<PayoutRunDay>,
pub adjustments: Vec<PayoutRunAdjustment>,
}
/// Has revenue been distributed for a specific payout period month yet?
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PayoutPeriodStatus {
/// We are still waiting on the NET 60 cycle to complete for this month;
/// revenue has not been received by the platform yet.
Open,
/// Revenue should have been received for the platform by now; waiting for
/// an admin to manually execute the payout run.
InReview,
/// Payout run is currently executing.
Running,
/// Payout run has been paid out to creators.
Paid,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PayoutRunDay {
pub date: NaiveDate,
@@ -33,5 +67,79 @@ pub struct PayoutRunAdjustment {
pub description: Option<String>,
}
#[get("/")]
pub async fn get_runs() -> Result<web::Json<PayoutRuns>, ApiError> {}
#[get("")]
pub async fn get_runs(
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
aditude: web::Data<aditude::Client>,
) -> Result<web::Json<PayoutRuns>, ApiError> {
let now = Utc::now();
let latest_payout_value = sqlx::query_scalar!(
r#"
SELECT MAX(created)
FROM payouts_values
"#,
)
.fetch_one(&**pool)
.await
.wrap_internal_err("fetching latest payout value")?
.unwrap_or(now)
.max(now);
let current_period = YearMonth::from_day1(now.date_naive());
let mut period = YearMonth::from_day1(latest_payout_value.date_naive());
let mut requested_periods = Vec::new();
while period <= current_period {
requested_periods.push(period);
period = YearMonth::from_day1(
period
.date()
.checked_add_months(Months::new(1))
.wrap_internal_err("calculating next payout period")?,
);
}
let estimates =
estimate(aditude.get_ref(), redis.get_ref(), &requested_periods)
.await
.wrap_internal_err("fetching payout estimates")?;
let periods = estimates
.into_iter()
.map(|estimate| {
let status = if let Some(available_at) =
net_60_payout_available_at(estimate.period)
&& now >= available_at
{
PayoutPeriodStatus::InReview
} else {
PayoutPeriodStatus::Open
};
let days = estimate
.days
.into_iter()
.map(|day| PayoutRunDay {
date: day.date,
estimated: distribution_for_day(
day.date,
day.raw_estimated_revenue_usd,
day.impressions,
Decimal::ZERO,
&PayoutVariances::ZERO,
),
actual: None,
})
.collect();
PayoutRunPeriod {
period: estimate.period,
status,
days,
adjustments: Vec::new(),
}
})
.collect::<Vec<_>>();
Ok(web::Json(PayoutRuns { periods }))
}
+2 -9
View File
@@ -8,7 +8,7 @@ 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"))
Self(date.with_day(1).expect("every month has a first day"))
}
pub fn from_year_month(year: i32, month: u32) -> Option<Self> {
@@ -92,13 +92,6 @@ impl utoipa::ToSchema for YearMonth {}
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();
@@ -123,7 +116,7 @@ mod tests {
#[test]
fn serializes_as_year_and_month() {
let date = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
let year_month = YearMonth::new(date).unwrap();
let year_month = YearMonth::from_day1(date);
assert_eq!(serde_json::to_string(&year_month).unwrap(), r#""2026-07""#);
}