diff --git a/apps/labrinth/src/models/v3/payout_runs.rs b/apps/labrinth/src/models/v3/payout_runs.rs index 764f3efa2f..a0ddb7006c 100644 --- a/apps/labrinth/src/models/v3/payout_runs.rs +++ b/apps/labrinth/src/models/v3/payout_runs.rs @@ -40,7 +40,7 @@ pub enum PayoutRunStatus { Pending, /// The ad provider should have issued payouts to us by now, and we will /// soon run the payouts. - Review, + InReview, /// Payouts run is currently being performed. Running, /// Payouts run is complete and payouts have been distributed to users. diff --git a/apps/labrinth/src/queue/payouts/affiliate.rs b/apps/labrinth/src/queue/payouts/affiliate.rs index 913e973749..8a7ca2d57c 100644 --- a/apps/labrinth/src/queue/payouts/affiliate.rs +++ b/apps/labrinth/src/queue/payouts/affiliate.rs @@ -1,11 +1,11 @@ use crate::database::PgPool; use crate::env::ENV; -use chrono::{Datelike, Duration, TimeZone, Utc}; use eyre::{Context, Result, eyre}; use rust_decimal::{Decimal, dec}; use tracing::warn; use crate::database::models::{DBAffiliateCodeId, DBUserId}; +use crate::util::time::{YearMonth, net_60_payout_available_at}; pub async fn process_affiliate_payouts(postgres: &PgPool) -> Result<()> { // process: @@ -91,21 +91,10 @@ pub async fn process_affiliate_payouts(postgres: &PgPool) -> Result<()> { continue; }; - // affiliate payouts are Net 60 from the end of the month - // this is net 60 relative to the time of the charge's last attempt, not from now - let available = { - let year = last_attempt.year(); - let month = last_attempt.month(); - - // get the first day of the next month - let last_day_of_month = if month == 12 { - Utc.with_ymd_and_hms(year + 1, 1, 1, 0, 0, 0).unwrap() - } else { - Utc.with_ymd_and_hms(year, month + 1, 1, 0, 0, 0).unwrap() - }; - - last_day_of_month + Duration::days(59) - }; + let available = net_60_payout_available_at(YearMonth::from_day1( + last_attempt.date_naive(), + )) + .ok_or_else(|| eyre!("failed to calculate affiliate payout date"))?; let revenue_split = row .revenue_split diff --git a/apps/labrinth/src/queue/payouts/mod.rs b/apps/labrinth/src/queue/payouts/mod.rs index c237f6d624..944946e26b 100644 --- a/apps/labrinth/src/queue/payouts/mod.rs +++ b/apps/labrinth/src/queue/payouts/mod.rs @@ -9,12 +9,13 @@ use crate::models::payouts::{ use crate::models::projects::MonetizationStatus; use crate::routes::ApiError; use crate::util::error::Context; +use crate::util::time::{YearMonth, net_60_payout_available_at}; use crate::util::webhook::{ PayoutSourceAlertType, send_slack_payout_source_alert_webhook, }; use arc_swap::ArcSwapOption; use base64::Engine; -use chrono::{DateTime, Datelike, Duration, NaiveTime, TimeZone, Utc}; +use chrono::{DateTime, Duration, NaiveTime, Utc}; use dashmap::DashMap; use eyre::Result; use futures::TryStreamExt; @@ -1132,22 +1133,10 @@ pub async fn process_payout( let payout = net_revenue * (Decimal::from(1) - modrinth_cut); - // Ad payouts are Net 60 from the end of the month - let available = { - let now = Utc::now().date_naive(); - - let year = now.year(); - let month = now.month(); - - // Get the first day of the next month - let last_day_of_month = if month == 12 { - Utc.with_ymd_and_hms(year + 1, 1, 1, 0, 0, 0).unwrap() - } else { - Utc.with_ymd_and_hms(year, month + 1, 1, 0, 0, 0).unwrap() - }; - - last_day_of_month + Duration::days(59) - }; + let available = net_60_payout_available_at(YearMonth::from_day1( + Utc::now().date_naive(), + )) + .wrap_internal_err("failed to calculate creator payout date")?; let ( mut insert_user_ids, diff --git a/apps/labrinth/src/routes/internal/payouts.rs b/apps/labrinth/src/routes/internal/payouts.rs index e0b7d5b241..abd77fa8dc 100644 --- a/apps/labrinth/src/routes/internal/payouts.rs +++ b/apps/labrinth/src/routes/internal/payouts.rs @@ -12,7 +12,7 @@ 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; +use crate::util::time::{YearMonth, net_60_payout_available_at}; use xredis::RedisPool; pub fn config(cfg: &mut web::ServiceConfig) { @@ -100,9 +100,19 @@ pub async fn get( while period <= newest_period { if !stored_periods.contains(&period) { + let status = + if net_60_payout_available_at(period).wrap_internal_err( + "failed to calculate payout review date", + )? <= newest_created + { + PayoutRunStatus::InReview + } else { + PayoutRunStatus::Pending + }; + runs.push(PayoutRun { period_start: period, - status: PayoutRunStatus::Pending, + status, started_at: None, started_by: None, completed_at: None, diff --git a/apps/labrinth/src/util/time.rs b/apps/labrinth/src/util/time.rs index 59c82eed08..7deca2a753 100644 --- a/apps/labrinth/src/util/time.rs +++ b/apps/labrinth/src/util/time.rs @@ -1,6 +1,6 @@ use std::{fmt, str::FromStr}; -use chrono::{Datelike, NaiveDate}; +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)] @@ -28,6 +28,16 @@ impl YearMonth { } } +/// 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)) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] #[error("a year-month must use the first day of its month")] pub struct InvalidYearMonth; @@ -171,4 +181,31 @@ mod tests { 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() + ) + ); + } }