diff --git a/apps/labrinth/.sqlx/query-055f71ec6d193c5f5259cafc989a04f3f791205e6c2df15c5bf9d5afcf71a76a.json b/apps/labrinth/.sqlx/query-055f71ec6d193c5f5259cafc989a04f3f791205e6c2df15c5bf9d5afcf71a76a.json index cf686ee65a..086322f4a6 100644 --- a/apps/labrinth/.sqlx/query-055f71ec6d193c5f5259cafc989a04f3f791205e6c2df15c5bf9d5afcf71a76a.json +++ b/apps/labrinth/.sqlx/query-055f71ec6d193c5f5259cafc989a04f3f791205e6c2df15c5bf9d5afcf71a76a.json @@ -76,10 +76,12 @@ "name": "delphi_severity", "kind": { "Enum": [ + "hidden", "low", "medium", "high", - "severe" + "severe", + "malware" ] } } diff --git a/apps/labrinth/.sqlx/query-1551b022217df05490a01e715b3584d26550d01a1917d1af64fe236d63dd5005.json b/apps/labrinth/.sqlx/query-1551b022217df05490a01e715b3584d26550d01a1917d1af64fe236d63dd5005.json index db45e607d0..434d224cc7 100644 --- a/apps/labrinth/.sqlx/query-1551b022217df05490a01e715b3584d26550d01a1917d1af64fe236d63dd5005.json +++ b/apps/labrinth/.sqlx/query-1551b022217df05490a01e715b3584d26550d01a1917d1af64fe236d63dd5005.json @@ -76,10 +76,12 @@ "name": "delphi_severity", "kind": { "Enum": [ + "hidden", "low", "medium", "high", - "severe" + "severe", + "malware" ] } } diff --git a/apps/labrinth/.sqlx/query-154d32137068456f7c0739271790d46bbe78053eda81fb2c227d95262cb4140a.json b/apps/labrinth/.sqlx/query-3962c046a8877433f2e5afd6064c0dfb11c7093d60093e8265d8cc5937c2c02c.json similarity index 52% rename from apps/labrinth/.sqlx/query-154d32137068456f7c0739271790d46bbe78053eda81fb2c227d95262cb4140a.json rename to apps/labrinth/.sqlx/query-3962c046a8877433f2e5afd6064c0dfb11c7093d60093e8265d8cc5937c2c02c.json index e911aa561a..2c8bb1eeaa 100644 --- a/apps/labrinth/.sqlx/query-154d32137068456f7c0739271790d46bbe78053eda81fb2c227d95262cb4140a.json +++ b/apps/labrinth/.sqlx/query-3962c046a8877433f2e5afd6064c0dfb11c7093d60093e8265d8cc5937c2c02c.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT MAX(created)\n FROM payouts_values\n ", + "query": "\n SELECT MAX(date)\n FROM payout_period_days\n ", "describe": { "columns": [ { "ordinal": 0, "name": "max", - "type_info": "Timestamptz" + "type_info": "Date" } ], "parameters": { @@ -16,5 +16,5 @@ null ] }, - "hash": "154d32137068456f7c0739271790d46bbe78053eda81fb2c227d95262cb4140a" + "hash": "3962c046a8877433f2e5afd6064c0dfb11c7093d60093e8265d8cc5937c2c02c" } diff --git a/apps/labrinth/.sqlx/query-63a9a89443566baa880ddbe05f741e2a83b50d89f5954ab8d951a090e702de73.json b/apps/labrinth/.sqlx/query-63a9a89443566baa880ddbe05f741e2a83b50d89f5954ab8d951a090e702de73.json index b9c6d3543c..86b2c5a1d5 100644 --- a/apps/labrinth/.sqlx/query-63a9a89443566baa880ddbe05f741e2a83b50d89f5954ab8d951a090e702de73.json +++ b/apps/labrinth/.sqlx/query-63a9a89443566baa880ddbe05f741e2a83b50d89f5954ab8d951a090e702de73.json @@ -41,10 +41,12 @@ "name": "delphi_severity", "kind": { "Enum": [ + "hidden", "low", "medium", "high", - "severe" + "severe", + "malware" ] } } diff --git a/apps/labrinth/.sqlx/query-805f9e937806491b75494627147259e3cc4cb82ba8f8645f748ad77b5abb81c8.json b/apps/labrinth/.sqlx/query-805f9e937806491b75494627147259e3cc4cb82ba8f8645f748ad77b5abb81c8.json new file mode 100644 index 0000000000..ec60f6e8a7 --- /dev/null +++ b/apps/labrinth/.sqlx/query-805f9e937806491b75494627147259e3cc4cb82ba8f8645f748ad77b5abb81c8.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT LEAST(\n COALESCE(\n (SELECT MAX(created)::date FROM payouts_values),\n $1\n ),\n COALESCE(\n (SELECT MIN(period) FROM payout_periods),\n $1\n ),\n $1\n ) AS \"first_period!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "first_period!", + "type_info": "Date" + } + ], + "parameters": { + "Left": [ + "Date" + ] + }, + "nullable": [ + null + ] + }, + "hash": "805f9e937806491b75494627147259e3cc4cb82ba8f8645f748ad77b5abb81c8" +} diff --git a/apps/labrinth/.sqlx/query-8d37c8c2633b7d601c7ba74d63608ff40af3b6b56f43540ec3798ef30e5c0b9d.json b/apps/labrinth/.sqlx/query-8d37c8c2633b7d601c7ba74d63608ff40af3b6b56f43540ec3798ef30e5c0b9d.json index 475dba1b98..cf1d9b23c6 100644 --- a/apps/labrinth/.sqlx/query-8d37c8c2633b7d601c7ba74d63608ff40af3b6b56f43540ec3798ef30e5c0b9d.json +++ b/apps/labrinth/.sqlx/query-8d37c8c2633b7d601c7ba74d63608ff40af3b6b56f43540ec3798ef30e5c0b9d.json @@ -26,10 +26,12 @@ "name": "delphi_severity", "kind": { "Enum": [ + "hidden", "low", "medium", "high", - "severe" + "severe", + "malware" ] } } diff --git a/apps/labrinth/.sqlx/query-9369f0659c5fbd08463923a9b2bba49f4963315fd7667c6db96e6153e54a2fd2.json b/apps/labrinth/.sqlx/query-9369f0659c5fbd08463923a9b2bba49f4963315fd7667c6db96e6153e54a2fd2.json index 8ca4b69491..f3609a305c 100644 --- a/apps/labrinth/.sqlx/query-9369f0659c5fbd08463923a9b2bba49f4963315fd7667c6db96e6153e54a2fd2.json +++ b/apps/labrinth/.sqlx/query-9369f0659c5fbd08463923a9b2bba49f4963315fd7667c6db96e6153e54a2fd2.json @@ -22,10 +22,12 @@ "name": "delphi_severity", "kind": { "Enum": [ + "hidden", "low", "medium", "high", - "severe" + "severe", + "malware" ] } } diff --git a/apps/labrinth/.sqlx/query-f2054ae7dcc89b21ed6b2f04526de1e7cddd68ac956143bef994104280a8dc07.json b/apps/labrinth/.sqlx/query-f2054ae7dcc89b21ed6b2f04526de1e7cddd68ac956143bef994104280a8dc07.json index 8cbe94abd5..1e2ab737e9 100644 --- a/apps/labrinth/.sqlx/query-f2054ae7dcc89b21ed6b2f04526de1e7cddd68ac956143bef994104280a8dc07.json +++ b/apps/labrinth/.sqlx/query-f2054ae7dcc89b21ed6b2f04526de1e7cddd68ac956143bef994104280a8dc07.json @@ -19,10 +19,12 @@ "name": "delphi_severity", "kind": { "Enum": [ + "hidden", "low", "medium", "high", - "severe" + "severe", + "malware" ] } } diff --git a/apps/labrinth/src/queue/payout_run/estimate.rs b/apps/labrinth/src/queue/payout_run/estimate.rs index 250ae86edd..48c5cfe72c 100644 --- a/apps/labrinth/src/queue/payout_run/estimate.rs +++ b/apps/labrinth/src/queue/payout_run/estimate.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet}; -use chrono::{DateTime, Days, NaiveDate, Utc}; +use chrono::{DateTime, Days, Months, NaiveDate, Utc}; use dashmap::DashMap; use eyre::Result; use rust_decimal::Decimal; @@ -29,6 +29,39 @@ pub struct DayEstimate { pub impressions: u128, } +/// Validate that an estimate contains exactly one complete entry for every +/// calendar day in its payout period. +pub(crate) fn validate_complete_period_estimate( + estimate: &PeriodEstimate, +) -> Result<()> { + let period_end = estimate + .period + .date() + .checked_add_months(Months::new(1)) + .wrap_err("calculating payout period end")?; + let mut expected_date = estimate.period.date(); + + for day in &estimate.days { + if day.date != expected_date { + return Err(eyre::eyre!( + "expected Aditude estimate for `{expected_date}`, found `{}`", + day.date, + )); + } + expected_date = expected_date + .succ_opt() + .wrap_err("calculating next payout estimate date")?; + } + + if expected_date != period_end { + return Err(eyre::eyre!( + "missing Aditude estimate for `{expected_date}`", + )); + } + + Ok(()) +} + /// Get per-month and per-day estimated ad provider info for an inclusive date /// range. /// @@ -193,4 +226,27 @@ mod tests { assert_eq!(estimate.days[0].raw_estimated_revenue_usd, dec!(12.34)); assert_eq!(estimate.days[0].impressions, 5678); } + + #[test] + fn validates_every_day_in_period() { + let period = + YearMonth::from_day1(NaiveDate::from_ymd_opt(2026, 2, 1).unwrap()); + let mut date = period.date(); + let period_end = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(); + let mut days = Vec::new(); + while date < period_end { + days.push(DayEstimate { + date, + raw_estimated_revenue_usd: dec!(1), + impressions: 1, + }); + date = date.succ_opt().unwrap(); + } + let mut estimate = PeriodEstimate { period, days }; + + validate_complete_period_estimate(&estimate).unwrap(); + + estimate.days.remove(10); + assert!(validate_complete_period_estimate(&estimate).is_err()); + } } diff --git a/apps/labrinth/src/routes/internal/payout_runs/admin.rs b/apps/labrinth/src/routes/internal/payout_runs/admin.rs index 123bd42801..6bdeb65add 100644 --- a/apps/labrinth/src/routes/internal/payout_runs/admin.rs +++ b/apps/labrinth/src/routes/internal/payout_runs/admin.rs @@ -12,12 +12,15 @@ use crate::{ database::{ PgPool, models::{ - DBUserId, generate_payout_run_id, + DBUserId, DatabaseError, generate_payout_run_id, payout_run_item::{DBPayoutRun, PayoutRunStatus}, }, }, models::{ids::PayoutRunId, pats::Scopes}, - queue::{payout_run::estimate, session::AuthQueue}, + queue::{ + payout_run::{estimate, validate_complete_period_estimate}, + session::AuthQueue, + }, routes::ApiError, util::{ error::Context, @@ -57,6 +60,7 @@ pub struct StartPayoutRunResponse { (status = BAD_REQUEST, description = "Invalid payout run input"), (status = UNAUTHORIZED, description = "Invalid authentication or TOTP code"), (status = CONFLICT, description = "Payout period is unavailable or another run is active"), + (status = FAILED_DEPENDENCY, description = "Aditude returned an incomplete period"), ), security(("bearer_auth" = ["SESSION_ACCESS"])), )] @@ -151,6 +155,8 @@ pub async fn start_run( let estimate = estimates .pop() .wrap_internal_err("missing requested payout estimate")?; + validate_complete_period_estimate(&estimate) + .wrap_failed_dependency_err("validating payout estimate")?; let payload = PayoutRunPayload { raw_actual_revenue_usd: body.raw_actual_revenue_usd, adjustments: body.adjustments.clone(), @@ -272,9 +278,16 @@ pub async fn start_run( cancelled_by: None, error: None, }; - run.upsert(&mut transaction) - .await - .wrap_internal_err("creating scheduled payout run")?; + let create_run_result = run.upsert(&mut transaction).await; + if let Err(DatabaseError::Database(sqlx::Error::Database(error))) = + &create_run_result + && error.constraint() == Some("payout_runs_single_active") + { + return Err(ApiError::Conflict(eyre::eyre!( + "another payout run is already scheduled or running", + ))); + } + create_run_result.wrap_internal_err("creating scheduled payout run")?; transaction .commit()