diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-utils.ts b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-utils.ts index e09e5b615d..e12d839b39 100644 --- a/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-utils.ts +++ b/apps/frontend/src/components/analytics-dashboard/analytics-chart/analytics-chart-utils.ts @@ -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 } } diff --git a/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-row-builder.ts b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-row-builder.ts index a4fe46007c..b99269c243 100644 --- a/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-row-builder.ts +++ b/apps/frontend/src/components/analytics-dashboard/analytics-table/analytics-table-row-builder.ts @@ -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 } diff --git a/apps/frontend/src/providers/analytics/analytics-data-utils.ts b/apps/frontend/src/providers/analytics/analytics-data-utils.ts index d415eb8a57..b104420b39 100644 --- a/apps/frontend/src/providers/analytics/analytics-data-utils.ts +++ b/apps/frontend/src/providers/analytics/analytics-data-utils.ts @@ -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 } diff --git a/apps/labrinth/.sqlx/query-01ac9da2281dd65c1b4a3eb5a2b941ba117decea2e953f400bbcabb1ad873a3f.json b/apps/labrinth/.sqlx/query-01ac9da2281dd65c1b4a3eb5a2b941ba117decea2e953f400bbcabb1ad873a3f.json new file mode 100644 index 0000000000..5c42f5cf07 --- /dev/null +++ b/apps/labrinth/.sqlx/query-01ac9da2281dd65c1b4a3eb5a2b941ba117decea2e953f400bbcabb1ad873a3f.json @@ -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" +} diff --git a/apps/labrinth/.sqlx/query-1bd41cf314799ee2d7ded70463f8c32acf008386040415fe472994341b64eb81.json b/apps/labrinth/.sqlx/query-1bd41cf314799ee2d7ded70463f8c32acf008386040415fe472994341b64eb81.json new file mode 100644 index 0000000000..9410905c32 --- /dev/null +++ b/apps/labrinth/.sqlx/query-1bd41cf314799ee2d7ded70463f8c32acf008386040415fe472994341b64eb81.json @@ -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" +} diff --git a/apps/labrinth/.sqlx/query-58fbda9daed27c5c466849a944ab3df193679cef17e8588ac3be79978b60bdab.json b/apps/labrinth/.sqlx/query-58fbda9daed27c5c466849a944ab3df193679cef17e8588ac3be79978b60bdab.json deleted file mode 100644 index 22e0f6006a..0000000000 --- a/apps/labrinth/.sqlx/query-58fbda9daed27c5c466849a944ab3df193679cef17e8588ac3be79978b60bdab.json +++ /dev/null @@ -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" -} diff --git a/apps/labrinth/.sqlx/query-657cc50d96f6a6d46aba09a6957175e698fd9f221bbcfb45c13d15b5f24498b2.json b/apps/labrinth/.sqlx/query-657cc50d96f6a6d46aba09a6957175e698fd9f221bbcfb45c13d15b5f24498b2.json new file mode 100644 index 0000000000..9cd8c99623 --- /dev/null +++ b/apps/labrinth/.sqlx/query-657cc50d96f6a6d46aba09a6957175e698fd9f221bbcfb45c13d15b5f24498b2.json @@ -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" +} diff --git a/apps/labrinth/.sqlx/query-9f831b44a53edd68073c4e695f65bc1a380a30bfd19d26a4b2a4b61fbccee86b.json b/apps/labrinth/.sqlx/query-9f831b44a53edd68073c4e695f65bc1a380a30bfd19d26a4b2a4b61fbccee86b.json new file mode 100644 index 0000000000..ce36ec902c --- /dev/null +++ b/apps/labrinth/.sqlx/query-9f831b44a53edd68073c4e695f65bc1a380a30bfd19d26a4b2a4b61fbccee86b.json @@ -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" +} diff --git a/apps/labrinth/.sqlx/query-c2e40b00e2764be89a162feb1f1cc927e873e51a0d3b6e3e142efa3ba9e0e76f.json b/apps/labrinth/.sqlx/query-b290fce93574e1cadb76a01a74720bd45b980294b84853eda559f16da49c5cc3.json similarity index 56% rename from apps/labrinth/.sqlx/query-c2e40b00e2764be89a162feb1f1cc927e873e51a0d3b6e3e142efa3ba9e0e76f.json rename to apps/labrinth/.sqlx/query-b290fce93574e1cadb76a01a74720bd45b980294b84853eda559f16da49c5cc3.json index 9addf403d0..520cf4110c 100644 --- a/apps/labrinth/.sqlx/query-c2e40b00e2764be89a162feb1f1cc927e873e51a0d3b6e3e142efa3ba9e0e76f.json +++ b/apps/labrinth/.sqlx/query-b290fce93574e1cadb76a01a74720bd45b980294b84853eda559f16da49c5cc3.json @@ -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" } diff --git a/apps/labrinth/.sqlx/query-b2a1ba3ea43ffc1c99fd8147d810396ef978c33ff68730c06c70edb15e8f3833.json b/apps/labrinth/.sqlx/query-b2a1ba3ea43ffc1c99fd8147d810396ef978c33ff68730c06c70edb15e8f3833.json new file mode 100644 index 0000000000..cb7f1c186e --- /dev/null +++ b/apps/labrinth/.sqlx/query-b2a1ba3ea43ffc1c99fd8147d810396ef978c33ff68730c06c70edb15e8f3833.json @@ -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" +} diff --git a/apps/labrinth/migrations/20260821120000_payout-estimates.sql b/apps/labrinth/migrations/20260821120000_payout-estimates.sql new file mode 100644 index 0000000000..8fb7289479 --- /dev/null +++ b/apps/labrinth/migrations/20260821120000_payout-estimates.sql @@ -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); diff --git a/apps/labrinth/src/routes/v3/analytics_get/metrics/affiliate_code_revenue.rs b/apps/labrinth/src/routes/v3/analytics_get/metrics/affiliate_code_revenue.rs index 791dd8a3dd..f0fa3b451c 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/metrics/affiliate_code_revenue.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/metrics/affiliate_code_revenue.rs @@ -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, } diff --git a/apps/labrinth/src/routes/v3/analytics_get/metrics/mod.rs b/apps/labrinth/src/routes/v3/analytics_get/metrics/mod.rs index 91f590033c..efd84f50ec 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/metrics/mod.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/metrics/mod.rs @@ -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}; diff --git a/apps/labrinth/src/routes/v3/analytics_get/metrics/project_revenue.rs b/apps/labrinth/src/routes/v3/analytics_get/metrics/project_revenue.rs index c03de3cf8c..f517b5a34c 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/metrics/project_revenue.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/metrics/project_revenue.rs @@ -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, + /// 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, }), }), diff --git a/apps/labrinth/src/routes/v3/analytics_get/mod.rs b/apps/labrinth/src/routes/v3/analytics_get/mod.rs index 6dc980a1c7..7c6b60d2ca 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/mod.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/mod.rs @@ -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, } ] ], diff --git a/apps/labrinth/src/routes/v3/payouts.rs b/apps/labrinth/src/routes/v3/payouts.rs index 5a002b45b8..6153ae2f0e 100644 --- a/apps/labrinth/src/routes/v3/payouts.rs +++ b/apps/labrinth/src/routes/v3/payouts.rs @@ -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, Decimal>, } @@ -1114,27 +1123,46 @@ async fn get_user_balance( pool: &PgPool, ) -> Result { 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(), }) } diff --git a/apps/labrinth/tests/analytics.rs b/apps/labrinth/tests/analytics.rs index 217f0e7b15..9925b599a2 100644 --- a/apps/labrinth/tests/analytics.rs +++ b/apps/labrinth/tests/analytics.rs @@ -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| 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, Vec) = - 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, Vec) = - 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::>(); + + let actual_revenue = revenue_metrics + .iter() + .filter(|metric| metric["revenue_kind"] == "actual") + .map(|metric| metric["revenue"].as_f64().unwrap()) + .sum::(); + let estimated_revenue = revenue_metrics + .iter() + .filter(|metric| metric["revenue_kind"] == "estimated") + .map(|metric| metric["revenue"].as_f64().unwrap()) + .sum::(); + + 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) -> Vec { - 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| 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; }, diff --git a/apps/labrinth/tests/payout_estimates.rs b/apps/labrinth/tests/payout_estimates.rs new file mode 100644 index 0000000000..c1c0d5f6b1 --- /dev/null +++ b/apps/labrinth/tests/payout_estimates.rs @@ -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, Decimal>, +} + +async fn get_balance(test_env: &TestEnvironment) -> 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| 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 = + test::read_body_json(history_response).await; + let available_entries = history + .iter() + .filter(|item| item["type"] == "payout_available") + .collect::>(); + assert!(available_entries.iter().any(|item| { + serde_json::from_value::>(item["created"].clone()) + .unwrap() + == finalized_available_at + && serde_json::from_value::(item["amount"].clone()) + .unwrap() + == Decimal::from(100) + })); + assert!(available_entries.iter().all(|item| { + serde_json::from_value::>(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; +} diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 071e5b5edc..2daba88827 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -700,9 +700,12 @@ export namespace Labrinth { seconds: number } + export type ProjectRevenueKind = 'estimated' | 'actual' + export type ProjectRevenue = { user_id?: string - revenue: string + revenue_kind: ProjectRevenueKind + revenue: number } export type AffiliateCodeAnalytics = { @@ -723,7 +726,7 @@ export namespace Labrinth { } export type AffiliateCodeRevenue = { - revenue: string + revenue: number } } }