adjustments on the net rev pool

This commit is contained in:
aecsocket
2026-08-26 15:08:56 +01:00
parent 9b85f3bcdf
commit d513c6c756
22 changed files with 203 additions and 111 deletions
@@ -5,13 +5,13 @@ use rust_decimal::Decimal;
use sqlx::types::Json;
use super::DatabaseError;
use crate::queue::payout_run::{Adjustment, PayoutRunPayload};
use crate::queue::payout_run::{PayoutRunPayload, RevenueAdjustment};
#[derive(Debug, Clone)]
pub struct DBPayoutPeriod {
pub period: NaiveDate,
pub raw_actual_aditude_revenue_usd: Decimal,
pub adjustments: Vec<Adjustment>,
pub revenue_adjustments: Vec<RevenueAdjustment>,
pub active_run_payload: Option<PayoutRunPayload>,
pub active_run_execute_at: Option<DateTime<Utc>>,
pub days: Vec<DBPayoutPeriodDay>,
@@ -39,7 +39,7 @@ impl DBPayoutPeriod {
SELECT
payout_periods.period,
payout_periods.raw_actual_aditude_revenue_usd,
payout_periods.adjustments AS "adjustments: Json<Vec<Adjustment>>",
payout_periods.revenue_adjustments AS "revenue_adjustments: Json<Vec<RevenueAdjustment>>",
active_run.payload AS "active_run_payload: Json<PayoutRunPayload>",
active_run.execute_at AS active_run_execute_at,
EXISTS (
@@ -68,7 +68,7 @@ impl DBPayoutPeriod {
period: row.period,
raw_actual_aditude_revenue_usd: row
.raw_actual_aditude_revenue_usd,
adjustments: row.adjustments.0,
revenue_adjustments: row.revenue_adjustments.0,
active_run_payload: row
.active_run_payload
.map(|payload| payload.0),
+111 -17
View File
@@ -14,8 +14,9 @@
//! money, so this is closer to NET 75. During this period, the month (payout
//! period) is in an _in review_ state.
//! - Once we receive the money from the provider, an admin enters the total
//! amount we've received into the web UI, adds any manual adjustments (for
//! campaigns outside of our ad provider's), and starts a payout run.
//! amount we've received into the web UI, adds any manual revenue
//! adjustments (for campaigns outside of our ad provider's), and starts a
//! payout run.
//! - The payout run is not immediately executed; there is a period of time in
//! which it can still be cancelled.
//! - Once the payout run is executed, we calculate the exact revenue
@@ -50,11 +51,12 @@
//! ```
//! - (fees stay the same, since they're based on impressions, not revenue)
//! - (variance is ignored, since that's purely an estimation value)
//! - `actual.net_revenue_usd`: raw actual revenue - fees
//! - Revenue adjustments are split evenly across the days in the period.
//! - `actual.net_revenue_usd`: raw actual revenue - fees + revenue adjustment
//! - `actual.(platform|creator)_net_revenue_usd`: same logic as estimated,
//! but using the net actual revenue
//! - Manual adjustments are stored separately on the payout period and applied
//! on top of its actual distribution.
//! - Manual revenue adjustments are stored on the payout period and applied to
//! its actual distribution before the platform/creator split.
//!
//! ## Variance
//!
@@ -63,10 +65,12 @@
//! epoch date)
//! - the decimal fraction of variance to apply
use chrono::NaiveDate;
use chrono::{Months, NaiveDate};
use rust_decimal::{Decimal, dec};
use serde::{Deserialize, Serialize};
use crate::util::time::YearMonth;
mod estimate;
pub use estimate::*;
@@ -77,13 +81,13 @@ pub struct PayoutRunPayload {
/// Actual raw revenue received from the ad provider for the period.
#[serde(with = "rust_decimal::serde::float")]
pub raw_actual_revenue_usd: Decimal,
/// Manual adjustments to apply on top of actual revenue.
pub adjustments: Vec<Adjustment>,
/// Manual revenue adjustments to apply on top of actual revenue.
pub revenue_adjustments: Vec<RevenueAdjustment>,
}
/// Manual admin-input adjustment to a payout period.
/// Manual admin-input revenue adjustment to a payout period.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct Adjustment {
pub struct RevenueAdjustment {
/// Total value of the adjustment.
#[serde(with = "rust_decimal::serde::float")]
pub amount_usd: Decimal,
@@ -113,8 +117,14 @@ pub struct DayDistribution {
/// For non-estimates (actual revenue values), this is zero.
#[serde(with = "rust_decimal::serde::float")]
pub variance_usd: Decimal,
/// Revenue added after fees and variance, before the platform/creator
/// split.
///
/// This is zero for estimated revenue.
#[serde(with = "rust_decimal::serde::float")]
pub revenue_adjustment_usd: Decimal,
/// Total net revenue that we earned;
/// `raw_revenue - fees - variance`.
/// `raw_revenue - fees - variance + revenue_adjustment`.
#[serde(with = "rust_decimal::serde::float")]
pub net_revenue_usd: Decimal,
/// How much of the net revenue goes to the platform.
@@ -153,6 +163,7 @@ pub fn distribution_for_day(
date: NaiveDate,
raw_revenue_usd: Decimal,
impressions: u128,
revenue_adjustment_usd: Decimal,
variances: &PayoutVariances,
) -> DayDistribution {
let fees_usd = {
@@ -168,16 +179,17 @@ pub fn distribution_for_day(
.unwrap_or(variances.default_frac);
let variance_usd = raw_revenue_usd * variance_frac;
let net_estimated_revenue_usd = raw_revenue_usd - fees_usd - variance_usd;
let net_revenue_usd =
raw_revenue_usd - fees_usd - variance_usd + revenue_adjustment_usd;
DayDistribution {
raw_revenue_usd,
fees_usd,
variance_usd,
net_revenue_usd: net_estimated_revenue_usd,
platform_net_revenue_usd: net_estimated_revenue_usd
* PLATFORM_REVENUE_SPLIT,
creator_net_revenue_usd: net_estimated_revenue_usd
revenue_adjustment_usd,
net_revenue_usd,
platform_net_revenue_usd: net_revenue_usd * PLATFORM_REVENUE_SPLIT,
creator_net_revenue_usd: net_revenue_usd
* (dec!(1) - PLATFORM_REVENUE_SPLIT),
}
}
@@ -186,6 +198,9 @@ pub fn distribution_for_day(
#[derive(Debug, Clone, Copy)]
pub struct ActualDistributionFlow {
share: Decimal,
period: YearMonth,
daily_revenue_adjustment_usd: Decimal,
final_day_revenue_adjustment_usd: Decimal,
}
/// Start a flow for computing the actual revenue distribution of a payout
@@ -202,12 +217,17 @@ pub struct ActualDistributionFlow {
/// ```
///
/// If the period's estimated revenue is zero, the share is `1`.
/// Revenue adjustments are split evenly across every calendar day. Any
/// decimal rounding remainder is placed on the final day so the daily values
/// sum exactly to the period adjustment.
///
/// We use a type-state-ish pattern here to ensure that the same flow is used
/// for each day in a period.
pub fn compute_actual_distribution_flow(
period: YearMonth,
raw_estimated_revenue_usd: Decimal,
raw_actual_revenue_usd: Decimal,
revenue_adjustment_usd: Decimal,
) -> ActualDistributionFlow {
let share = if raw_estimated_revenue_usd.is_zero() {
Decimal::ONE
@@ -215,7 +235,21 @@ pub fn compute_actual_distribution_flow(
raw_actual_revenue_usd / raw_estimated_revenue_usd
};
ActualDistributionFlow { share }
let next_period = period
.date()
.checked_add_months(Months::new(1))
.expect("a payout period must have a following month");
let day_count = Decimal::from((next_period - period.date()).num_days());
let daily_revenue_adjustment_usd = revenue_adjustment_usd / day_count;
let final_day_revenue_adjustment_usd = revenue_adjustment_usd
- daily_revenue_adjustment_usd * (day_count - Decimal::ONE);
ActualDistributionFlow {
share,
period,
daily_revenue_adjustment_usd,
final_day_revenue_adjustment_usd,
}
}
impl ActualDistributionFlow {
@@ -226,12 +260,72 @@ impl ActualDistributionFlow {
raw_estimated_revenue_usd: Decimal,
impressions: u128,
) -> DayDistribution {
debug_assert_eq!(YearMonth::from_day1(date), self.period);
let next_period = self
.period
.date()
.checked_add_months(Months::new(1))
.expect("a payout period must have a following month");
let revenue_adjustment_usd = if date.succ_opt() == Some(next_period) {
self.final_day_revenue_adjustment_usd
} else {
self.daily_revenue_adjustment_usd
};
distribution_for_day(
date,
raw_estimated_revenue_usd * self.share,
impressions,
revenue_adjustment_usd,
// actual rev distribution always has no variance
&PayoutVariances::ZERO,
)
}
}
#[cfg(test)]
mod tests {
use chrono::Days;
use super::*;
#[test]
fn splits_revenue_adjustment_across_period_exactly() {
let period = YearMonth::from_year_month(2026, 4).unwrap();
let adjustment = dec!(500);
let flow = compute_actual_distribution_flow(
period,
dec!(30),
dec!(30),
adjustment,
);
let mut date = period.date();
let mut distributions = Vec::new();
for _ in 0..30 {
distributions.push(flow.distribution_for_day(date, dec!(1), 0));
date = date.checked_add_days(Days::new(1)).unwrap();
}
assert_eq!(
distributions
.iter()
.map(|day| day.revenue_adjustment_usd)
.sum::<Decimal>(),
adjustment,
);
assert_eq!(
distributions
.iter()
.map(|day| day.net_revenue_usd)
.sum::<Decimal>(),
dec!(530),
);
assert!(
distributions[..29]
.iter()
.all(|day| day.revenue_adjustment_usd
== distributions[0].revenue_adjustment_usd),
);
}
}
@@ -4,7 +4,7 @@ use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
use super::{Adjustment, PayoutRunDay, PayoutRunPayload};
use super::{PayoutRunDay, PayoutRunPayload, RevenueAdjustment};
use crate::{
auth::{
AuthenticationError, get_user_from_headers, two_factor::verify_2fa_code,
@@ -45,7 +45,7 @@ pub struct StartPayoutRun {
pub ignore_totp: bool,
#[serde(with = "rust_decimal::serde::float")]
pub raw_actual_revenue_usd: Decimal,
pub adjustments: Vec<Adjustment>,
pub revenue_adjustments: Vec<RevenueAdjustment>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
@@ -58,10 +58,11 @@ pub struct StartPayoutRunResponse {
pub struct CalculatePayoutRunResponse {
pub period: YearMonth,
pub days: Vec<PayoutRunDay>,
/// Sum of all adjustments that would be applied on top of actual revenue.
/// Sum of all revenue adjustments that would be applied on top of actual
/// revenue.
#[serde(with = "rust_decimal::serde::float")]
pub total_adjustments: Decimal,
pub adjustments: Vec<Adjustment>,
pub total_revenue_adjustment_usd: Decimal,
pub revenue_adjustments: Vec<RevenueAdjustment>,
}
/// Calculate a payout run without scheduling it.
@@ -148,9 +149,16 @@ pub async fn calculate_run(
.iter()
.map(|day| day.raw_estimated_revenue_usd)
.sum();
let total_revenue_adjustment_usd = body
.revenue_adjustments
.iter()
.map(|adjustment| adjustment.amount_usd)
.sum();
let actual_flow = compute_actual_distribution_flow(
body.period,
total_estimated_revenue_usd,
body.raw_actual_revenue_usd,
total_revenue_adjustment_usd,
);
let days = estimate
.days
@@ -161,6 +169,7 @@ pub async fn calculate_run(
day.date,
day.raw_estimated_revenue_usd,
day.impressions,
Decimal::ZERO,
&variances,
),
actual: Some(actual_flow.distribution_for_day(
@@ -170,17 +179,12 @@ pub async fn calculate_run(
)),
})
.collect();
let total_adjustments = body
.adjustments
.iter()
.map(|adjustment| adjustment.amount_usd)
.sum();
Ok(web::Json(CalculatePayoutRunResponse {
period: body.period,
days,
total_adjustments,
adjustments: body.adjustments,
total_revenue_adjustment_usd,
revenue_adjustments: body.revenue_adjustments,
}))
}
@@ -290,7 +294,7 @@ pub async fn start_run(
.wrap_failed_dependency_err("validating payout estimate")?;
let payload = PayoutRunPayload {
raw_actual_revenue_usd: body.raw_actual_revenue_usd,
adjustments: body.adjustments.clone(),
revenue_adjustments: body.revenue_adjustments.clone(),
};
let mut transaction = pool
@@ -303,7 +307,7 @@ pub async fn start_run(
INSERT INTO payout_periods (
period,
raw_actual_aditude_revenue_usd,
adjustments
revenue_adjustments
)
VALUES ($1, $2, '[]'::jsonb)
ON CONFLICT (period) DO UPDATE SET
@@ -6,7 +6,7 @@ use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use xredis::RedisPool;
use super::Adjustment;
use super::RevenueAdjustment;
use crate::{
auth::get_user_from_headers,
database::{
@@ -43,14 +43,15 @@ pub struct PayoutRunPeriod {
/// When the active payout run is scheduled to begin executing.
pub runs_at: Option<DateTime<Utc>>,
pub days: Vec<PayoutRunDay>,
/// Sum of all adjustments applied on top of actual revenue.
/// Sum of all revenue adjustments applied on top of actual revenue.
#[serde(with = "rust_decimal::serde::float")]
pub total_adjustments: Decimal,
/// Individual adjustments, including their admin-provided descriptions.
pub total_revenue_adjustment_usd: Decimal,
/// Individual revenue adjustments, including their admin-provided
/// descriptions.
///
/// Only visible to admins.
#[serde(skip_serializing_if = "Option::is_none")]
pub adjustments: Option<Vec<Adjustment>>,
pub revenue_adjustments: Option<Vec<RevenueAdjustment>>,
}
/// Has revenue been distributed for a specific payout period month yet?
@@ -200,25 +201,28 @@ pub async fn get_runs(
.get(&requested_period.date())
.filter(|period| !period.days.is_empty())
{
let period_adjustments =
let period_revenue_adjustments =
if let Some(payload) = &period.active_run_payload {
&payload.adjustments
&payload.revenue_adjustments
} else {
&period.adjustments
&period.revenue_adjustments
};
let total_adjustments = period_adjustments
let total_revenue_adjustment_usd = period_revenue_adjustments
.iter()
.map(|adjustment| adjustment.amount_usd)
.sum();
let adjustments = is_admin.then(|| period_adjustments.clone());
let revenue_adjustments =
is_admin.then(|| period_revenue_adjustments.clone());
let total_estimated_revenue_usd = period
.days
.iter()
.map(|day| day.raw_estimated_aditude_revenue_usd)
.sum();
let actual_flow = compute_actual_distribution_flow(
requested_period,
total_estimated_revenue_usd,
period.raw_actual_aditude_revenue_usd,
total_revenue_adjustment_usd,
);
let days = period
.days
@@ -235,6 +239,7 @@ pub async fn get_runs(
day.date,
day.raw_estimated_aditude_revenue_usd,
impressions,
Decimal::ZERO,
&variances,
),
actual: Some(actual_flow.distribution_for_day(
@@ -258,8 +263,8 @@ pub async fn get_runs(
status,
runs_at: period.active_run_execute_at,
days,
total_adjustments,
adjustments,
total_revenue_adjustment_usd,
revenue_adjustments,
})
} else {
let estimate = live_estimates
@@ -282,6 +287,7 @@ pub async fn get_runs(
day.date,
day.raw_estimated_revenue_usd,
day.impressions,
Decimal::ZERO,
&variances,
),
actual: None,
@@ -293,8 +299,8 @@ pub async fn get_runs(
status,
runs_at: None,
days,
total_adjustments: Decimal::ZERO,
adjustments: is_admin.then(Vec::new),
total_revenue_adjustment_usd: Decimal::ZERO,
revenue_adjustments: is_admin.then(Vec::new),
})
}
})
@@ -1,7 +1,7 @@
mod admin;
mod fetch;
pub use crate::queue::payout_run::{Adjustment, PayoutRunPayload};
pub use crate::queue::payout_run::{PayoutRunPayload, RevenueAdjustment};
pub use admin::*;
pub use fetch::*;