mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 11:36:05 +00:00
238 lines
8.7 KiB
Rust
238 lines
8.7 KiB
Rust
//! Logic for executing payout runs, including starting a run, and performing
|
|
//! the revenue distribution.
|
|
//!
|
|
//! # Architecture
|
|
//!
|
|
//! ## Payout run
|
|
//!
|
|
//! The general flow for a payout run is as follows:
|
|
//! - For a given month (say, January) our ad provider gives us an estimate of
|
|
//! how much revenue and how many impressions we received for each individual
|
|
//! day. The month (payout period) starts in an _open_ state.
|
|
//! - After NET 60 has passed (start of March), the January payouts _should_ be
|
|
//! available; usually it takes some time for our ad provider to send the
|
|
//! 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.
|
|
//! - 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
|
|
//! distribution to all creators, and fill `payouts_values` with those
|
|
//! amounts.
|
|
//!
|
|
//! ## Distribution
|
|
//!
|
|
//! How revenue is distributed:
|
|
//! - While a month is still open/in review:
|
|
//! - `estimated.raw_revenue_usd`: how much our ad provider estimates we'll
|
|
//! earn for a specific period
|
|
//! - We also get a value for this per day
|
|
//! - `fees_usd`: how much we pay in fees to Clean.io
|
|
//! - Based on number of impressions; we can get a per-day value for this
|
|
//! - `variance_usd`: a fixed percentage that we subtract from the raw estimated
|
|
//! revenue to account for it being an overestimate
|
|
//! - e.g. if variance is 10%, and we estimate that we'll earn $100k, then
|
|
//! our ad provider will probably give us closer to $90k - the variance
|
|
//! lets us express this difference
|
|
//! - `net_revenue_usd`: raw estimated - fees - variance
|
|
//! - `platform_net_revenue_usd`: net estimated revenue x Modrinth's cut
|
|
//! - `creator_net_revenue_usd`: net estimated revenue x (1 - Modrinth's cut)
|
|
//! - After a payout run has been executed:
|
|
//! - We save the per-day raw estimated revenue and impressions in the
|
|
//! database
|
|
//! - `actual.raw_revenue_usd`: how much we got from Aditude, input by an admin
|
|
//! - We compute this per-day by:
|
|
//! ```text
|
|
//! let factor = actual.raw_revenue_usd / raw_estimated_revenue_usd
|
|
//! raw_revenue_usd_for_today = raw_estimated_revenue_usd_for_today * factor
|
|
//! ```
|
|
//! - (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
|
|
//! - `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.
|
|
//!
|
|
//! ## Variance
|
|
//!
|
|
//! We store a table `payouts_variance` with columns:
|
|
//! - a date from when this variance value applies (first entry on the Unix
|
|
//! epoch date)
|
|
//! - the decimal fraction of variance to apply
|
|
|
|
use chrono::NaiveDate;
|
|
use rust_decimal::{Decimal, dec};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
mod estimate;
|
|
|
|
pub use estimate::*;
|
|
|
|
/// Inputs supplied by an admin when scheduling a payout run.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
|
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 admin-input adjustment to a payout period.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct Adjustment {
|
|
/// Total value of the adjustment.
|
|
#[serde(with = "rust_decimal::serde::float")]
|
|
pub amount_usd: Decimal,
|
|
/// Why this adjustment was applied.
|
|
///
|
|
/// Only visible to admins.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
/// Fraction defining much of the net revenue goes to the platform.
|
|
const PLATFORM_REVENUE_SPLIT: Decimal = dec!(0.25);
|
|
|
|
/// How input revenue is distributed for a specific day.
|
|
///
|
|
/// This may refer to either estimated or actual revenue.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct DayDistribution {
|
|
/// Amount of revenue input into the algorithm.
|
|
#[serde(with = "rust_decimal::serde::float")]
|
|
pub raw_revenue_usd: Decimal,
|
|
/// Operational fees to subtract.
|
|
#[serde(with = "rust_decimal::serde::float")]
|
|
pub fees_usd: Decimal,
|
|
/// Estimation variance to subtract.
|
|
///
|
|
/// For non-estimates (actual revenue values), this is zero.
|
|
#[serde(with = "rust_decimal::serde::float")]
|
|
pub variance_usd: Decimal,
|
|
/// Total net revenue that we earned;
|
|
/// `raw_revenue - fees - variance`.
|
|
#[serde(with = "rust_decimal::serde::float")]
|
|
pub net_revenue_usd: Decimal,
|
|
/// How much of the net revenue goes to the platform.
|
|
#[serde(with = "rust_decimal::serde::float")]
|
|
pub platform_net_revenue_usd: Decimal,
|
|
/// How much of the net revenue goes to creators.
|
|
#[serde(with = "rust_decimal::serde::float")]
|
|
pub creator_net_revenue_usd: Decimal,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct PayoutVariance {
|
|
pub starts_at: NaiveDate,
|
|
pub frac: Decimal,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct PayoutVariances {
|
|
pub fracs: Vec<PayoutVariance>,
|
|
pub default_frac: Decimal,
|
|
}
|
|
|
|
impl PayoutVariances {
|
|
pub const ZERO: Self = Self {
|
|
fracs: Vec::new(),
|
|
default_frac: Decimal::ZERO,
|
|
};
|
|
}
|
|
|
|
/// Compute the [`DayDistribution`] for an input revenue and impressions amount.
|
|
///
|
|
/// This logic may be used on both estimated and actual revenue. For actual
|
|
/// revenue, [`PayoutVariances::ZERO`] should be used, since variance only
|
|
/// applies to estimated days.
|
|
pub fn distribution_for_day(
|
|
date: NaiveDate,
|
|
raw_revenue_usd: Decimal,
|
|
impressions: u128,
|
|
variances: &PayoutVariances,
|
|
) -> DayDistribution {
|
|
let fees_usd = {
|
|
let clean_io_cpm = Decimal::from(8) / Decimal::from(1000);
|
|
clean_io_cpm * Decimal::from(impressions) / Decimal::from(1000)
|
|
};
|
|
let variance_frac = variances
|
|
.fracs
|
|
.iter()
|
|
.rev()
|
|
.find(|v| v.starts_at <= date)
|
|
.map(|v| v.frac)
|
|
.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;
|
|
|
|
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
|
|
* (dec!(1) - PLATFORM_REVENUE_SPLIT),
|
|
}
|
|
}
|
|
|
|
/// Precomputed allocation used to distribute actual period revenue by day.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct ActualDistributionFlow {
|
|
share: Decimal,
|
|
}
|
|
|
|
/// Start a flow for computing the actual revenue distribution of a payout
|
|
/// period.
|
|
///
|
|
/// Our ad provider gives us per-day estimates for how much money we earned,
|
|
/// but only provides money in a lump sum per month. Therefore, it is up to us
|
|
/// to figure out how much each day contributed to the lump-sum amount.
|
|
///
|
|
/// We do this using the following formula:
|
|
/// ```text
|
|
/// let share = raw_actual_revenue / raw_estimated_revenue
|
|
/// actual_day_revenue[day] = share * estimated_day_revenue[day]
|
|
/// ```
|
|
///
|
|
/// If the period's estimated revenue is zero, the share is `1`.
|
|
///
|
|
/// 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(
|
|
raw_estimated_revenue_usd: Decimal,
|
|
raw_actual_revenue_usd: Decimal,
|
|
) -> ActualDistributionFlow {
|
|
let share = if raw_estimated_revenue_usd.is_zero() {
|
|
Decimal::ONE
|
|
} else {
|
|
raw_actual_revenue_usd / raw_estimated_revenue_usd
|
|
};
|
|
|
|
ActualDistributionFlow { share }
|
|
}
|
|
|
|
impl ActualDistributionFlow {
|
|
/// Compute the actual distribution for a stored day in this period.
|
|
pub fn distribution_for_day(
|
|
&self,
|
|
date: NaiveDate,
|
|
raw_estimated_revenue_usd: Decimal,
|
|
impressions: u128,
|
|
) -> DayDistribution {
|
|
distribution_for_day(
|
|
date,
|
|
raw_estimated_revenue_usd * self.share,
|
|
impressions,
|
|
// actual rev distribution always has no variance
|
|
&PayoutVariances::ZERO,
|
|
)
|
|
}
|
|
}
|