mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 19:46:33 +00:00
Add provisional payout estimates
This commit is contained in:
+1
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
Generated
+34
@@ -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"
|
||||
}
|
||||
Generated
+15
@@ -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"
|
||||
}
|
||||
Generated
-28
@@ -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"
|
||||
}
|
||||
Generated
+17
@@ -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"
|
||||
}
|
||||
Generated
+19
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
Generated
+21
@@ -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,
|
||||
}
|
||||
]
|
||||
],
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user