diff --git a/apps/labrinth/migrations/20260720114640_creator_payouts.sql b/apps/labrinth/migrations/20260720114640_creator_payouts.sql index b52772a06b..82776cfe9c 100644 --- a/apps/labrinth/migrations/20260720114640_creator_payouts.sql +++ b/apps/labrinth/migrations/20260720114640_creator_payouts.sql @@ -1,8 +1,16 @@ CREATE TABLE payouts_runs( - period_start TIMESTAMPTZ PRIMARY KEY, + id BIGINT PRIMARY KEY, + -- timestamp on the 1st of a month at midnight, + -- representing what month this run is for. + -- if a row exists for a month, then a payout run + -- is running/has completed for this month (see + -- `completed_at`). + period_start TIMESTAMPTZ NOT NULL, started_at TIMESTAMPTZ NOT NULL, started_by BIGINT REFERENCES users(id) ON DELETE SET NULL, completed_at TIMESTAMPTZ, + completed_result JSONB, adjustments JSONB NOT NULL ); +CREATE INDEX payouts_runs_period_start ON payouts_runs(period_start); diff --git a/apps/labrinth/src/models/mod.rs b/apps/labrinth/src/models/mod.rs index c39de80557..7a3498ba96 100644 --- a/apps/labrinth/src/models/mod.rs +++ b/apps/labrinth/src/models/mod.rs @@ -14,6 +14,7 @@ pub use v3::oauth_clients; pub use v3::organizations; pub use v3::pack; pub use v3::pats; +pub use v3::payout_runs; pub use v3::payouts; pub use v3::projects; pub use v3::reports; diff --git a/apps/labrinth/src/models/v3/mod.rs b/apps/labrinth/src/models/v3/mod.rs index 8925b7caec..40494dd7a9 100644 --- a/apps/labrinth/src/models/v3/mod.rs +++ b/apps/labrinth/src/models/v3/mod.rs @@ -11,6 +11,7 @@ pub mod oauth_clients; pub mod organizations; pub mod pack; pub mod pats; +pub mod payout_runs; pub mod payouts; pub mod projects; pub mod reports; diff --git a/apps/labrinth/src/models/v3/payout_runs.rs b/apps/labrinth/src/models/v3/payout_runs.rs new file mode 100644 index 0000000000..764f3efa2f --- /dev/null +++ b/apps/labrinth/src/models/v3/payout_runs.rs @@ -0,0 +1,51 @@ +use ariadne::ids::UserId; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::util::time::YearMonth; + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PayoutRun { + /// What period this payout run is for. + /// + /// Payout runs are always for the period of a specific year and month - + /// they are not associated with any specific day. + pub period_start: YearMonth, + /// What state this run is in. + pub status: PayoutRunStatus, + /// When this run started running. + /// + /// Only accessible to admins. + pub started_at: Option>, + /// What user started this run. + /// + /// Only accessible to admins. + pub started_by: Option, + /// When this run completed. + /// + /// Only accessible to admins. + pub completed_at: Option>, + /// What payout adjustments were specified in this run. + /// + /// Only accessible to admins. + pub adjustments: Option>, +} + +#[derive( + Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum PayoutRunStatus { + /// We are still waiting on the ad provider to issue payouts to us. + Pending, + /// The ad provider should have issued payouts to us by now, and we will + /// soon run the payouts. + Review, + /// Payouts run is currently being performed. + Running, + /// Payouts run is complete and payouts have been distributed to users. + Done, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Adjustment {} diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index 8ab03e1d12..2fcb4369e8 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -13,6 +13,7 @@ pub mod medal; pub mod moderation; pub mod mural; pub mod pats; +pub mod payouts; pub mod search; pub mod server_ping; pub mod session; @@ -32,6 +33,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { .configure(session::config) .configure(flows::config) .configure(pats::config) + .configure(payouts::config) .configure(oauth_clients::config) .service(web::scope("/moderation").configure(moderation::config)) .service(web::scope("/affiliate").configure(affiliate::config)) @@ -100,6 +102,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { pats::create_pat, pats::edit_pat, pats::delete_pat, + payouts::get, moderation::get_projects, moderation::get_project_ids, moderation::get_project_meta, diff --git a/apps/labrinth/src/routes/internal/payouts.rs b/apps/labrinth/src/routes/internal/payouts.rs new file mode 100644 index 0000000000..f8ca9d995b --- /dev/null +++ b/apps/labrinth/src/routes/internal/payouts.rs @@ -0,0 +1,130 @@ +use std::cmp::Reverse; +use std::collections::HashSet; + +use actix_web::{HttpRequest, get, web}; +use chrono::{Months, Utc}; + +use crate::auth::get_user_from_headers; +use crate::database::models::DBUserId; +use crate::database::redis::RedisPool; +use crate::database::{PgPool, ReadOnlyPgPool}; +use crate::models::pats::Scopes; +use crate::models::payout_runs::{Adjustment, PayoutRun, PayoutRunStatus}; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::util::error::Context; +use crate::util::time::YearMonth; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(get); +} + +/// List creator payout runs. +#[utoipa::path( + tag = "payouts", + responses((status = OK, body = inline(Vec))) +)] +#[get("/payout-runs")] +pub async fn get( + req: HttpRequest, + pool: web::Data, + ro_pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result>, ApiError> { + let is_admin = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::empty(), + ) + .await + .is_ok_and(|(_, user)| user.role.is_admin()); + + let stored_runs = sqlx::query!( + r#" + SELECT + period_start, + started_at, + started_by, + completed_at, + adjustments AS "adjustments!: sqlx::types::Json>" + FROM payouts_runs + ORDER BY period_start DESC + "#, + ) + .fetch_all(&***ro_pool) + .await + .wrap_internal_err("failed to fetch payout runs")?; + + let newest_created = sqlx::query_scalar!( + r#" + SELECT created + FROM payouts_values + ORDER BY created DESC + LIMIT 1 + "#, + ) + .fetch_optional(&***ro_pool) + .await + .wrap_internal_err("failed to fetch newest payout value")?; + + let mut stored_periods = HashSet::with_capacity(stored_runs.len()); + let mut runs = Vec::with_capacity(stored_runs.len()); + for run in stored_runs { + let period_start = YearMonth::from_day1(run.period_start.date_naive()); + let status = if run.completed_at.is_some() { + PayoutRunStatus::Done + } else { + PayoutRunStatus::Running + }; + + stored_periods.insert(period_start); + runs.push(PayoutRun { + period_start, + status, + started_at: is_admin.then_some(run.started_at), + started_by: is_admin + .then_some(run.started_by.map(|id| DBUserId(id).into())) + .flatten(), + completed_at: is_admin.then_some(run.completed_at).flatten(), + adjustments: is_admin.then_some(run.adjustments.0), + }); + } + + if let Some(newest_created) = newest_created { + let now = Utc::now(); + let newest_period = YearMonth::from_day1(newest_created.date_naive()); + let mut period = YearMonth::from_day1(now.date_naive()); + + while period <= newest_period { + if !stored_periods.contains(&period) { + runs.push(PayoutRun { + period_start: period, + status: PayoutRunStatus::Pending, + started_at: None, + started_by: None, + completed_at: None, + adjustments: None, + }); + } + + if period == newest_period { + break; + } + + let next_month = period + .date() + .checked_add_months(Months::new(1)) + .wrap_internal_err( + "failed to calculate next payout month", + )?; + period = YearMonth::from_day1(next_month); + } + } + + runs.sort_by_key(|run| Reverse(run.period_start)); + + Ok(web::Json(runs)) +} diff --git a/apps/labrinth/src/util/mod.rs b/apps/labrinth/src/util/mod.rs index 8c04d596de..bbdb18b6fb 100644 --- a/apps/labrinth/src/util/mod.rs +++ b/apps/labrinth/src/util/mod.rs @@ -21,5 +21,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 new file mode 100644 index 0000000000..59c82eed08 --- /dev/null +++ b/apps/labrinth/src/util/time.rs @@ -0,0 +1,174 @@ +use std::{fmt, str::FromStr}; + +use chrono::{Datelike, NaiveDate}; +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 new(date: NaiveDate) -> Result { + if date.day() == 1 { + Ok(Self(date)) + } else { + return Err(InvalidYearMonth); + } + } + + pub fn from_year_month(year: i32, month: u32) -> Option { + NaiveDate::from_ymd_opt(year, month, 1).map(Self) + } + + pub fn from_day1(date: NaiveDate) -> Self { + Self(date.with_day(1).expect("every month has a first day")) + } + + pub fn date(self) -> NaiveDate { + self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("a year-month must use the first day of its month")] +pub struct InvalidYearMonth; + +impl TryFrom for YearMonth { + type Error = InvalidYearMonth; + + fn try_from(date: NaiveDate) -> Result { + Self::new(date) + } +} + +impl From for NaiveDate { + fn from(year_month: YearMonth) -> Self { + year_month.0 + } +} + +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()); + } + } +}