This commit is contained in:
aecsocket
2026-08-04 14:17:50 +01:00
parent 6549d047dc
commit 5c4c30e514
8 changed files with 370 additions and 1 deletions
@@ -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);
+1
View File
@@ -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;
+1
View File
@@ -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;
@@ -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<DateTime<Utc>>,
/// What user started this run.
///
/// Only accessible to admins.
pub started_by: Option<UserId>,
/// When this run completed.
///
/// Only accessible to admins.
pub completed_at: Option<DateTime<Utc>>,
/// What payout adjustments were specified in this run.
///
/// Only accessible to admins.
pub adjustments: Option<Vec<Adjustment>>,
}
#[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 {}
+3
View File
@@ -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,
@@ -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<PayoutRun>)))
)]
#[get("/payout-runs")]
pub async fn get(
req: HttpRequest,
pool: web::Data<PgPool>,
ro_pool: web::Data<ReadOnlyPgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<Vec<PayoutRun>>, 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<Vec<Adjustment>>"
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))
}
+1
View File
@@ -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;
+174
View File
@@ -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<Self, InvalidYearMonth> {
if date.day() == 1 {
Ok(Self(date))
} else {
return Err(InvalidYearMonth);
}
}
pub fn from_year_month(year: i32, month: u32) -> Option<Self> {
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<NaiveDate> for YearMonth {
type Error = InvalidYearMonth;
fn try_from(date: NaiveDate) -> Result<Self, Self::Error> {
Self::new(date)
}
}
impl From<YearMonth> 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<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());
}
}
}