Add provisional payout estimates

This commit is contained in:
aecsocket
2026-08-25 15:11:11 +01:00
parent d999624702
commit 8cfd8ee660
19 changed files with 555 additions and 187 deletions
@@ -379,7 +379,7 @@ export function getMetricValue(
return point.metric_kind === 'playtime' ? point.seconds : 0
case 'revenue': {
if (point.metric_kind !== 'revenue') return 0
const value = Number.parseFloat(point.revenue)
const value = point.revenue
return Number.isFinite(value) ? value : 0
}
}
@@ -337,7 +337,7 @@ function addAnalyticsMetricToTableRow(
row.playtime += point.seconds
break
case 'revenue': {
const parsed = Number.parseFloat(point.revenue)
const parsed = point.revenue
row.revenue += Number.isFinite(parsed) ? parsed : 0
break
}
@@ -519,7 +519,7 @@ export function computeTotals(
totals.playtime += dataPoint.seconds
break
case 'revenue': {
const value = Number.parseFloat(dataPoint.revenue)
const value = dataPoint.revenue
totals.revenue += Number.isFinite(value) ? value : 0
break
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n date_available AS \"date_available!\",\n SUM(amount) FILTER (WHERE NOT provisional) finalized_sum,\n SUM(amount) FILTER (WHERE provisional) estimate_sum\n FROM (\n SELECT date_available, amount, FALSE provisional\n FROM payouts_values\n WHERE user_id = $1\n\n UNION ALL\n\n SELECT date_available, amount, TRUE provisional\n FROM payout_estimates\n WHERE user_id = $1\n ) payout_amounts\n GROUP BY date_available\n ORDER BY date_available DESC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "date_available!",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "finalized_sum",
"type_info": "Numeric"
},
{
"ordinal": 2,
"name": "estimate_sum",
"type_info": "Numeric"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null,
null,
null
]
},
"hash": "01ac9da2281dd65c1b4a3eb5a2b941ba117decea2e953f400bbcabb1ad873a3f"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO payout_estimates (\n period,\n user_id,\n mod_id,\n amount,\n created,\n date_available\n )\n VALUES ('2000-01-01', $1, $2, 1, NOW(), NOW())\n ON CONFLICT DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "1bd41cf314799ee2d7ded70463f8c32acf008386040415fe472994341b64eb81"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT date_available, SUM(amount) sum\n FROM payouts_values\n WHERE user_id = $1\n GROUP BY date_available\n ORDER BY date_available DESC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "date_available",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "sum",
"type_info": "Numeric"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
null
]
},
"hash": "58fbda9daed27c5c466849a944ab3df193679cef17e8588ac3be79978b60bdab"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO payout_estimates (\n period,\n user_id,\n mod_id,\n amount,\n created,\n date_available\n )\n VALUES ('2000-01-01', $1, $2, 75, $3, $4)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Timestamptz",
"Timestamptz"
]
},
"nullable": []
},
"hash": "657cc50d96f6a6d46aba09a6957175e698fd9f221bbcfb45c13d15b5f24498b2"
}
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO payouts_values (\n user_id,\n mod_id,\n amount,\n created,\n date_available\n )\n VALUES\n ($1, $2, 100, $3, $4),\n ($1, $2, 20, $5, $6)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Timestamptz",
"Timestamptz",
"Timestamptz",
"Timestamptz"
]
},
"nullable": []
},
"hash": "9f831b44a53edd68073c4e695f65bc1a380a30bfd19d26a4b2a4b61fbccee86b"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n WIDTH_BUCKET(\n EXTRACT(EPOCH FROM created)::bigint,\n EXTRACT(EPOCH FROM $1::timestamp with time zone AT TIME ZONE 'UTC')::bigint,\n EXTRACT(EPOCH FROM $2::timestamp with time zone AT TIME ZONE 'UTC')::bigint,\n $3::integer\n ) AS \"bucket?\",\n mod_id AS \"mod_id?\",\n CASE\n WHEN $5 AND ($6 OR mod_id = ANY($7)) THEN user_id\n ELSE 0\n END AS \"user_id?\",\n SUM(amount) AS \"amount_sum?\"\n FROM payouts_values\n WHERE\n -- only project revenue is counted here\n -- for affiliate code revenue, see `affiliate_code_revenue`\n payouts_values.mod_id IS NOT NULL\n AND payouts_values.mod_id = ANY($4)\n AND created >= $1\n AND created < $2\n GROUP BY 1, 2, 3\n ",
"query": "\n SELECT\n WIDTH_BUCKET(\n EXTRACT(EPOCH FROM created)::bigint,\n EXTRACT(EPOCH FROM $1::timestamp with time zone AT TIME ZONE 'UTC')::bigint,\n EXTRACT(EPOCH FROM $2::timestamp with time zone AT TIME ZONE 'UTC')::bigint,\n $3::integer\n ) AS \"bucket?\",\n mod_id AS \"mod_id?\",\n CASE\n WHEN $5 AND ($6 OR mod_id = ANY($7)) THEN user_id\n ELSE 0\n END AS \"user_id?\",\n estimated AS \"estimated!\",\n SUM(amount) AS \"amount_sum?\"\n FROM (\n SELECT user_id, mod_id, amount, created, FALSE AS estimated\n FROM payouts_values\n WHERE mod_id IS NOT NULL\n\n UNION ALL\n\n SELECT user_id, mod_id, amount, created, TRUE AS estimated\n FROM payout_estimates\n ) creator_revenue\n WHERE\n -- only project revenue is counted here\n -- for affiliate code revenue, see `affiliate_code_revenue`\n creator_revenue.mod_id = ANY($4)\n AND created >= $1\n AND created < $2\n GROUP BY 1, 2, 3, 4\n ",
"describe": {
"columns": [
{
@@ -20,6 +20,11 @@
},
{
"ordinal": 3,
"name": "estimated!",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "amount_sum?",
"type_info": "Numeric"
}
@@ -37,10 +42,11 @@
},
"nullable": [
null,
true,
null,
null,
null,
null
]
},
"hash": "c2e40b00e2764be89a162feb1f1cc927e873e51a0d3b6e3e142efa3ba9e0e76f"
"hash": "b290fce93574e1cadb76a01a74720bd45b980294b84853eda559f16da49c5cc3"
}
@@ -0,0 +1,21 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO payout_estimates (\n period,\n user_id,\n mod_id,\n amount,\n created,\n date_available\n )\n VALUES\n ($1, $2, $3, 30, $4, $5),\n ($6, $2, $3, 40, $7, $8)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Date",
"Int8",
"Int8",
"Timestamptz",
"Timestamptz",
"Date",
"Timestamptz",
"Timestamptz"
]
},
"nullable": []
},
"hash": "b2a1ba3ea43ffc1c99fd8147d810396ef978c33ff68730c06c70edb15e8f3833"
}
@@ -0,0 +1,21 @@
CREATE TABLE payout_estimates (
period DATE NOT NULL
CHECK (EXTRACT(DAY FROM period) = 1),
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mod_id BIGINT NOT NULL REFERENCES mods(id) ON DELETE CASCADE,
amount NUMERIC(40, 20) NOT NULL
CHECK (amount >= 0),
created TIMESTAMPTZ NOT NULL,
date_available TIMESTAMPTZ NOT NULL,
calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (period, user_id, mod_id)
);
CREATE INDEX payout_estimates_user_id
ON payout_estimates (user_id);
CREATE INDEX payout_estimates_mod_id
ON payout_estimates (mod_id);
CREATE INDEX payout_estimates_created
ON payout_estimates (created);
@@ -40,6 +40,8 @@ pub struct AffiliateCodeRevenueFilters {
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AffiliateCodeRevenue {
/// Total revenue for this bucket.
#[serde(with = "rust_decimal::serde::float")]
#[schema(value_type = f64)]
pub revenue: Decimal,
}
@@ -40,6 +40,7 @@ pub use project_playtime::{
pub(crate) use project_revenue::fetch as fetch_project_revenue;
pub use project_revenue::{
ProjectRevenue, ProjectRevenueField, ProjectRevenueFilters,
ProjectRevenueKind,
};
pub(crate) use project_views::fetch as fetch_project_views;
pub use project_views::{ProjectViews, ProjectViewsField, ProjectViewsFilters};
@@ -39,13 +39,29 @@ pub enum ProjectRevenueField {
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProjectRevenueFilters {}
/// Whether project revenue is provisional or finalized.
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum ProjectRevenueKind {
/// Provisional revenue which can change before finalization.
Estimated,
/// Finalized creator revenue.
Actual,
}
/// [`super::ReturnMetrics::project_revenue`].
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProjectRevenue {
/// User these metrics are for.
#[serde(skip_serializing_if = "Option::is_none")]
pub user_id: Option<UserId>,
/// Whether this revenue is estimated or actual.
pub revenue_kind: ProjectRevenueKind,
/// Total revenue for this bucket.
#[serde(with = "rust_decimal::serde::float")]
#[schema(value_type = f64)]
pub(crate) revenue: Decimal,
}
@@ -76,16 +92,25 @@ pub(crate) async fn fetch(
WHEN $5 AND ($6 OR mod_id = ANY($7)) THEN user_id
ELSE 0
END AS "user_id?",
estimated AS "estimated!",
SUM(amount) AS "amount_sum?"
FROM payouts_values
FROM (
SELECT user_id, mod_id, amount, created, FALSE AS estimated
FROM payouts_values
WHERE mod_id IS NOT NULL
UNION ALL
SELECT user_id, mod_id, amount, created, TRUE AS estimated
FROM payout_estimates
) creator_revenue
WHERE
-- only project revenue is counted here
-- for affiliate code revenue, see `affiliate_code_revenue`
payouts_values.mod_id IS NOT NULL
AND payouts_values.mod_id = ANY($4)
creator_revenue.mod_id = ANY($4)
AND created >= $1
AND created < $2
GROUP BY 1, 2, 3
GROUP BY 1, 2, 3, 4
"#,
req.time_range.start,
req.time_range.end,
@@ -126,6 +151,11 @@ pub(crate) async fn fetch(
.filter(|id| bucket_by_user_id && *id != 0)
.map(DBUserId)
.map(UserId::from),
revenue_kind: if row.estimated {
ProjectRevenueKind::Estimated
} else {
ProjectRevenueKind::Actual
},
revenue,
}),
}),
@@ -1072,6 +1072,7 @@ mod tests {
source_project: test_project_3,
metrics: ProjectMetrics::Revenue(ProjectRevenue {
user_id: None,
revenue_kind: ProjectRevenueKind::Actual,
revenue: Decimal::new(20000, 2),
}),
})]),
@@ -1100,7 +1101,8 @@ mod tests {
{
"source_project": test_project_3.to_string(),
"metric_kind": "revenue",
"revenue": "200.00",
"revenue_kind": "actual",
"revenue": 200.0,
}
]
],
+46 -12
View File
@@ -1027,10 +1027,19 @@ pub async fn payment_methods(
#[derive(Serialize, utoipa::ToSchema)]
pub struct UserBalance {
/// Finalized revenue currently available to withdraw.
pub available: Decimal,
pub withdrawn_lifetime: Decimal,
pub withdrawn_ytd: Decimal,
/// Finalized revenue not yet available and provisional creator estimates.
///
/// Provisional estimates remain pending regardless of their estimated
/// availability date and may change before finalization.
pub pending: Decimal,
/// Revenue grouped by its finalized or estimated availability date.
///
/// These groups may include provisional creator estimates that can change
/// before finalization.
pub dates: HashMap<DateTime<Utc>, Decimal>,
}
@@ -1114,27 +1123,46 @@ async fn get_user_balance(
pool: &PgPool,
) -> Result<UserBalance, ApiError> {
let payouts = sqlx::query!(
"
SELECT date_available, SUM(amount) sum
FROM payouts_values
WHERE user_id = $1
r#"
SELECT
date_available AS "date_available!",
SUM(amount) FILTER (WHERE NOT provisional) finalized_sum,
SUM(amount) FILTER (WHERE provisional) estimate_sum
FROM (
SELECT date_available, amount, FALSE provisional
FROM payouts_values
WHERE user_id = $1
UNION ALL
SELECT date_available, amount, TRUE provisional
FROM payout_estimates
WHERE user_id = $1
) payout_amounts
GROUP BY date_available
ORDER BY date_available DESC
",
"#,
user_id.0
)
.fetch_all(pool)
.await
.wrap_internal_err("fetching payouts from database")?;
let now = Utc::now();
let available = payouts
.iter()
.filter(|x| x.date_available <= Utc::now())
.fold(Decimal::ZERO, |acc, x| acc + x.sum.unwrap_or(Decimal::ZERO));
let pending = payouts
.iter()
.filter(|x| x.date_available > Utc::now())
.fold(Decimal::ZERO, |acc, x| acc + x.sum.unwrap_or(Decimal::ZERO));
.filter(|x| x.date_available <= now)
.fold(Decimal::ZERO, |acc, x| {
acc + x.finalized_sum.unwrap_or(Decimal::ZERO)
});
let pending = payouts.iter().fold(Decimal::ZERO, |acc, x| {
let finalized = if x.date_available > now {
x.finalized_sum.unwrap_or(Decimal::ZERO)
} else {
Decimal::ZERO
};
acc + finalized + x.estimate_sum.unwrap_or(Decimal::ZERO)
});
let withdrawn = sqlx::query!(
"
@@ -1168,7 +1196,13 @@ async fn get_user_balance(
pending,
dates: payouts
.iter()
.map(|x| (x.date_available, x.sum.unwrap_or(Decimal::ZERO)))
.map(|x| {
(
x.date_available,
x.finalized_sum.unwrap_or(Decimal::ZERO)
+ x.estimate_sum.unwrap_or(Decimal::ZERO),
)
})
.collect(),
})
}
+123 -133
View File
@@ -1,16 +1,19 @@
use actix_http::StatusCode;
use actix_web::test;
use ariadne::ids::base62_impl::parse_base62;
use chrono::{DateTime, Duration, Utc};
use common::permissions::PermissionsTest;
use common::permissions::PermissionsTestContext;
use common::{
api_common::Api,
api_v3::ApiV3,
database::*,
environment::{TestEnvironment, with_test_environment},
};
use itertools::Itertools;
use labrinth::models::teams::ProjectPermissions;
use labrinth::queue::payouts;
use rust_decimal::{Decimal, prelude::ToPrimitive};
use rust_decimal::Decimal;
use serde_json::json;
pub mod common;
@@ -19,8 +22,6 @@ pub async fn analytics_revenue() {
with_test_environment(
None,
|test_env: TestEnvironment<ApiV3>| async move {
let api = &test_env.api;
let alpha_project_id =
test_env.dummy.project_alpha.project_id.clone();
@@ -71,116 +72,143 @@ pub async fn analytics_revenue() {
.unwrap();
transaction.commit().await.unwrap();
let day = 86400;
// Test analytics endpoint with default values
// - all time points in the last 2 weeks
// - 1 day resolution
let analytics = api
.get_analytics_revenue_deserialized(
vec![&alpha_project_id],
false,
None,
None,
None,
USER_USER_PAT,
sqlx::query!(
r#"
INSERT INTO payout_estimates (
period,
user_id,
mod_id,
amount,
created,
date_available
)
.await;
assert_eq!(analytics.len(), 1); // 1 project
let project_analytics = &analytics[&alpha_project_id];
assert_eq!(project_analytics.len(), 8); // 1 days cut off, and 2 points take place on the same day. note that the day exactly 14 days ago is included
// sorted_by_key, values in the order of smallest to largest key
let (sorted_keys, sorted_by_key): (Vec<i64>, Vec<Decimal>) =
project_analytics
.iter()
.sorted_by_key(|(k, _)| *k)
.rev()
.unzip();
assert_eq!(
vec![100.1, 101.0, 200.0, 311.0, 400.0, 526.0, 633.0, 800.0],
to_f64_vec_rounded_up(sorted_by_key)
);
// Ensure that the keys are in multiples of 1 day
for k in sorted_keys {
assert_eq!(k % day, 0);
}
VALUES ('2000-01-01', $1, $2, 75, $3, $4)
"#,
USER_USER_ID_PARSED,
project_id,
money_time_pairs[3].1,
Utc::now() + Duration::days(60),
)
.execute(&pool)
.await
.unwrap();
// Test analytics with last 900 days to include all data
// keep resolution at default
let analytics = api
.get_analytics_revenue_deserialized(
vec![&alpha_project_id],
false,
Some(Utc::now() - Duration::days(801)),
None,
None,
USER_USER_PAT,
)
.await;
let project_analytics = &analytics[&alpha_project_id];
assert_eq!(project_analytics.len(), 9); // and 2 points take place on the same day
let (sorted_keys, sorted_by_key): (Vec<i64>, Vec<Decimal>) =
project_analytics
.iter()
.sorted_by_key(|(k, _)| *k)
.rev()
.unzip();
assert_eq!(
vec![
100.1, 101.0, 200.0, 311.0, 400.0, 526.0, 633.0, 800.0,
800.0
],
to_f64_vec_rounded_up(sorted_by_key)
);
for k in sorted_keys {
assert_eq!(k % day, 0);
}
let modern_request = test::TestRequest::post()
.uri("/v3/analytics")
.append_header(("Authorization", USER_USER_PAT.unwrap()))
.set_json(json!({
"time_range": {
"start": Utc::now() - Duration::days(801),
"end": Utc::now() + Duration::days(1),
"resolution": { "slices": 802 }
},
"return_metrics": {
"project_revenue": {
"bucket_by": [],
"filter_by": {}
}
},
"project_ids": [alpha_project_id]
}))
.to_request();
let modern_response = test_env.call(modern_request).await;
assert_status!(&modern_response, StatusCode::OK);
let modern_analytics: serde_json::Value =
test::read_body_json(modern_response).await;
let revenue_metrics = modern_analytics["metrics"]
.as_array()
.unwrap()
.iter()
.flat_map(|slice| slice.as_array().unwrap())
.filter(|metric| metric["metric_kind"] == "revenue")
.collect::<Vec<_>>();
let actual_revenue = revenue_metrics
.iter()
.filter(|metric| metric["revenue_kind"] == "actual")
.map(|metric| metric["revenue"].as_f64().unwrap())
.sum::<f64>();
let estimated_revenue = revenue_metrics
.iter()
.filter(|metric| metric["revenue_kind"] == "estimated")
.map(|metric| metric["revenue"].as_f64().unwrap())
.sum::<f64>();
assert!((actual_revenue - 3871.1).abs() < 1e-9);
assert_eq!(estimated_revenue, 75.0);
let shared_time_slice = modern_analytics["metrics"]
.as_array()
.unwrap()
.iter()
.map(|slice| slice.as_array().unwrap())
.find(|slice| {
slice
.iter()
.any(|metric| metric["revenue_kind"] == "actual")
&& slice
.iter()
.any(|metric| metric["revenue_kind"] == "estimated")
});
assert!(shared_time_slice.is_some());
},
)
.await;
}
fn to_f64_rounded_up(d: Decimal) -> f64 {
d.round_dp_with_strategy(
1,
rust_decimal::RoundingStrategy::MidpointAwayFromZero,
)
.to_f64()
.unwrap()
}
fn to_f64_vec_rounded_up(d: Vec<Decimal>) -> Vec<f64> {
d.into_iter().map(to_f64_rounded_up).collect_vec()
}
#[actix_rt::test]
pub async fn permissions_analytics_revenue() {
with_test_environment(
None,
|test_env: TestEnvironment<ApiV3>| async move {
let alpha_project_id =
test_env.dummy.project_alpha.project_id.clone();
let alpha_version_id =
test_env.dummy.project_alpha.version_id.clone();
let alpha_team_id = test_env.dummy.project_alpha.team_id.clone();
let api = &test_env.api;
let pool = &test_env.db.pool;
let view_analytics = ProjectPermissions::VIEW_ANALYTICS;
// first, do check with a project
let req_gen = |ctx: PermissionsTestContext| async move {
let project_id = ctx.project_id.unwrap();
let ids_or_slugs = vec![project_id.as_str()];
api.get_analytics_revenue(
ids_or_slugs,
false,
None,
None,
Some(5),
ctx.test_pat.as_deref(),
let project_id_i64 = parse_base62(&project_id).unwrap() as i64;
sqlx::query!(
r#"
INSERT INTO payout_estimates (
period,
user_id,
mod_id,
amount,
created,
date_available
)
VALUES ('2000-01-01', $1, $2, 1, NOW(), NOW())
ON CONFLICT DO NOTHING
"#,
USER_USER_ID_PARSED,
project_id_i64,
)
.execute(pool)
.await
.unwrap();
let mut request = test::TestRequest::post()
.uri("/v3/analytics")
.set_json(json!({
"time_range": {
"start": Utc::now() - Duration::days(1),
"end": Utc::now() + Duration::days(1),
"resolution": { "slices": 5 }
},
"return_metrics": {
"project_revenue": {
"bucket_by": [],
"filter_by": {}
}
},
"project_ids": [project_id]
}));
if let Some(pat) = ctx.test_pat {
request = request.append_header(("Authorization", pat));
}
api.call(request.to_request()).await
};
PermissionsTest::new(&test_env)
@@ -188,12 +216,12 @@ pub async fn permissions_analytics_revenue() {
.with_200_json_checks(
// On failure, should have 0 projects returned
|value: &serde_json::Value| {
let value = value.as_object().unwrap();
let value = value["projects"].as_object().unwrap();
assert_eq!(value.len(), 0);
},
// On success, should have 1 project returned
|value: &serde_json::Value| {
let value = value.as_object().unwrap();
let value = value["projects"].as_object().unwrap();
assert_eq!(value.len(), 1);
},
)
@@ -201,44 +229,6 @@ pub async fn permissions_analytics_revenue() {
.await
.unwrap();
// Now with a version
// Need to use alpha
let req_gen = |ctx: PermissionsTestContext| {
let alpha_version_id = alpha_version_id.clone();
async move {
let ids_or_slugs = vec![alpha_version_id.as_str()];
api.get_analytics_revenue(
ids_or_slugs,
true,
None,
None,
Some(5),
ctx.test_pat.as_deref(),
)
.await
}
};
PermissionsTest::new(&test_env)
.with_failure_codes(vec![200, 401])
.with_existing_project(&alpha_project_id, &alpha_team_id)
.with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true)
.with_200_json_checks(
// On failure, should have 0 versions returned
|value: &serde_json::Value| {
let value = value.as_object().unwrap();
assert_eq!(value.len(), 0);
},
// On success, should have 1 versions returned
|value: &serde_json::Value| {
let value = value.as_object().unwrap();
assert_eq!(value.len(), 0);
},
)
.simple_project_permissions_test(view_analytics, req_gen)
.await
.unwrap();
// Cleanup test db
test_env.cleanup().await;
},
+201
View File
@@ -0,0 +1,201 @@
use actix_http::StatusCode;
use actix_web::test;
use ariadne::ids::base62_impl::parse_base62;
use chrono::{DateTime, Duration, NaiveDate, TimeZone, Utc};
use rust_decimal::Decimal;
use serde::Deserialize;
use serde_json::json;
use common::{
api_v3::ApiV3,
database::{USER_USER_ID_PARSED, USER_USER_PAT},
environment::{TestEnvironment, with_test_environment},
};
pub mod common;
#[derive(Debug, Deserialize)]
struct TestBalance {
available: Decimal,
withdrawn_lifetime: Decimal,
withdrawn_ytd: Decimal,
pending: Decimal,
dates: std::collections::HashMap<DateTime<Utc>, Decimal>,
}
async fn get_balance(test_env: &TestEnvironment<ApiV3>) -> TestBalance {
let request = test::TestRequest::get()
.uri("/v3/payout/balance")
.append_header(("Authorization", USER_USER_PAT.unwrap()))
.to_request();
let response = test_env.call(request).await;
assert_status!(&response, StatusCode::OK);
test::read_body_json(response).await
}
#[actix_rt::test]
async fn provisional_estimates_only_affect_pending_balance() {
with_test_environment(
None,
|test_env: TestEnvironment<ApiV3>| async move {
let pool = &test_env.db.pool;
let project_id =
parse_base62(&test_env.dummy.project_alpha.project_id).unwrap()
as i64;
let now = Utc.timestamp_opt(Utc::now().timestamp(), 0).unwrap();
let finalized_available_at = now - Duration::days(30);
let estimate_past_available_at = now - Duration::days(20);
let shared_future_available_at = now + Duration::days(20);
let initial_balance = get_balance(&test_env).await;
sqlx::query!(
r#"
INSERT INTO payouts_values (
user_id,
mod_id,
amount,
created,
date_available
)
VALUES
($1, $2, 100, $3, $4),
($1, $2, 20, $5, $6)
"#,
USER_USER_ID_PARSED,
project_id,
now - Duration::days(60),
finalized_available_at,
now - Duration::days(10),
shared_future_available_at,
)
.execute(pool)
.await
.unwrap();
sqlx::query!(
r#"
INSERT INTO payout_estimates (
period,
user_id,
mod_id,
amount,
created,
date_available
)
VALUES
($1, $2, $3, 30, $4, $5),
($6, $2, $3, 40, $7, $8)
"#,
NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(),
USER_USER_ID_PARSED,
project_id,
now - Duration::days(50),
estimate_past_available_at,
NaiveDate::from_ymd_opt(2000, 2, 1).unwrap(),
now - Duration::days(10),
shared_future_available_at,
)
.execute(pool)
.await
.unwrap();
let balance = get_balance(&test_env).await;
assert_eq!(
balance.available,
initial_balance.available + Decimal::from(100),
);
assert_eq!(
balance.pending,
initial_balance.pending + Decimal::from(90),
);
assert_eq!(
balance.withdrawn_lifetime,
initial_balance.withdrawn_lifetime,
);
assert_eq!(balance.withdrawn_ytd, initial_balance.withdrawn_ytd);
assert_eq!(
balance.dates[&finalized_available_at]
- initial_balance
.dates
.get(&finalized_available_at)
.copied()
.unwrap_or_default(),
Decimal::from(100),
);
assert_eq!(
balance.dates[&estimate_past_available_at]
- initial_balance
.dates
.get(&estimate_past_available_at)
.copied()
.unwrap_or_default(),
Decimal::from(30),
);
assert_eq!(
balance.dates[&shared_future_available_at]
- initial_balance
.dates
.get(&shared_future_available_at)
.copied()
.unwrap_or_default(),
Decimal::from(60),
);
let history_request = test::TestRequest::get()
.uri("/v3/payout/history")
.append_header(("Authorization", USER_USER_PAT.unwrap()))
.to_request();
let history_response = test_env.call(history_request).await;
assert_status!(&history_response, StatusCode::OK);
let history: Vec<serde_json::Value> =
test::read_body_json(history_response).await;
let available_entries = history
.iter()
.filter(|item| item["type"] == "payout_available")
.collect::<Vec<_>>();
assert!(available_entries.iter().any(|item| {
serde_json::from_value::<DateTime<Utc>>(item["created"].clone())
.unwrap()
== finalized_available_at
&& serde_json::from_value::<Decimal>(item["amount"].clone())
.unwrap()
== Decimal::from(100)
}));
assert!(available_entries.iter().all(|item| {
serde_json::from_value::<DateTime<Utc>>(item["created"].clone())
.unwrap()
!= estimate_past_available_at
}));
let affiliate_request = test::TestRequest::post()
.uri("/v3/analytics")
.append_header(("Authorization", USER_USER_PAT.unwrap()))
.set_json(json!({
"time_range": {
"start": now - Duration::days(365),
"end": now + Duration::days(365),
"resolution": { "slices": 2 }
},
"return_metrics": {
"affiliate_code_revenue": {
"bucket_by": [],
"filter_by": {}
}
}
}))
.to_request();
let affiliate_response = test_env.call(affiliate_request).await;
assert_status!(&affiliate_response, StatusCode::OK);
let affiliate_analytics: serde_json::Value =
test::read_body_json(affiliate_response).await;
assert!(
affiliate_analytics["metrics"]
.as_array()
.unwrap()
.iter()
.all(|slice| slice.as_array().unwrap().is_empty())
);
},
)
.await;
}