From 9e07617173d3579efa503dd5e8094da1c43040d9 Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:13:09 +0900 Subject: [PATCH] wip: estimated payout period info --- Cargo.lock | 1 + .../labrinth/src/queue/payout_run/estimate.rs | 26 ++-- apps/labrinth/src/queue/payout_run/mod.rs | 2 + apps/labrinth/src/routes/internal/mod.rs | 1 + .../src/routes/internal/payout_runs.rs | 116 +++++++++++++++++- apps/labrinth/src/util/time.rs | 11 +- scripts/seed-labrinth.sh | 7 ++ 7 files changed, 143 insertions(+), 21 deletions(-) create mode 100755 scripts/seed-labrinth.sh diff --git a/Cargo.lock b/Cargo.lock index 06935e8be5..39f5e354f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5479,6 +5479,7 @@ dependencies = [ "actix-web", "actix-web-prom", "actix-ws", + "aditude", "arc-swap", "argon2", "ariadne", diff --git a/apps/labrinth/src/queue/payout_run/estimate.rs b/apps/labrinth/src/queue/payout_run/estimate.rs index adda4c3ea4..3665cfad8b 100644 --- a/apps/labrinth/src/queue/payout_run/estimate.rs +++ b/apps/labrinth/src/queue/payout_run/estimate.rs @@ -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> { - 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> { - 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::>()) } diff --git a/apps/labrinth/src/queue/payout_run/mod.rs b/apps/labrinth/src/queue/payout_run/mod.rs index 9e6c5b5b83..9fc0a634d4 100644 --- a/apps/labrinth/src/queue/payout_run/mod.rs +++ b/apps/labrinth/src/queue/payout_run/mod.rs @@ -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); diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index 2018d9ba1f..89b60974e4 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -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) diff --git a/apps/labrinth/src/routes/internal/payout_runs.rs b/apps/labrinth/src/routes/internal/payout_runs.rs index 826d93279d..96a2240eeb 100644 --- a/apps/labrinth/src/routes/internal/payout_runs.rs +++ b/apps/labrinth/src/routes/internal/payout_runs.rs @@ -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, pub adjustments: Vec, } +/// 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, } -#[get("/")] -pub async fn get_runs() -> Result, ApiError> {} +#[get("")] +pub async fn get_runs( + pool: web::Data, + redis: web::Data, + aditude: web::Data, +) -> Result, 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::>(); + + Ok(web::Json(PayoutRuns { periods })) +} diff --git a/apps/labrinth/src/util/time.rs b/apps/labrinth/src/util/time.rs index 74013687d7..06f5ef1726 100644 --- a/apps/labrinth/src/util/time.rs +++ b/apps/labrinth/src/util/time.rs @@ -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 { @@ -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""#); } diff --git a/scripts/seed-labrinth.sh b/scripts/seed-labrinth.sh new file mode 100755 index 0000000000..85a0eb7346 --- /dev/null +++ b/scripts/seed-labrinth.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source .env + +psql "$DATABASE_URL" < fixtures/labrinth-seed-data-202508052143.sql