From 9e88a91095a75bae642b0bcf6434e50a5208ab23 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sat, 25 Oct 2025 10:36:38 +0100 Subject: [PATCH 1/5] wip: affiliate payouts again --- ...251024182919_subscription_affiliations.sql | 15 ++++ apps/labrinth/src/database/models/mod.rs | 1 + .../users_subscriptions_affiliations.rs | 90 +++++++++++++++++++ apps/labrinth/src/routes/internal/billing.rs | 44 +++++++-- 4 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 apps/labrinth/migrations/20251024182919_subscription_affiliations.sql create mode 100644 apps/labrinth/src/database/models/users_subscriptions_affiliations.rs diff --git a/apps/labrinth/migrations/20251024182919_subscription_affiliations.sql b/apps/labrinth/migrations/20251024182919_subscription_affiliations.sql new file mode 100644 index 0000000000..e5d58a7064 --- /dev/null +++ b/apps/labrinth/migrations/20251024182919_subscription_affiliations.sql @@ -0,0 +1,15 @@ +CREATE TABLE users_subscriptions_affiliations ( + id BIGSERIAL NOT NULL PRIMARY KEY, + subscription_id BIGINT NOT NULL REFERENCES users_subscriptions(id), + affiliate_code BIGINT NOT NULL REFERENCES affiliate_codes(id), + deactivated_at TIMESTAMPTZ +); + +CREATE TABLE users_subscriptions_affiliations_payouts( + id BIGSERIAL PRIMARY KEY, + charge_id BIGINT NOT NULL REFERENCES charges(id), + subscription_id BIGINT NOT NULL REFERENCES users_subscriptions(id), + affiliate_code BIGINT NOT NULL REFERENCES affiliate_codes(id), + payout_value_id BIGSERIAL NOT NULL REFERENCES payouts_values(id), + UNIQUE (charge_id) +); diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 0e5f31cdf8..e3f42b518a 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -35,6 +35,7 @@ pub mod user_subscription_item; pub mod users_compliance; pub mod users_notifications_preferences_item; pub mod users_redeemals; +pub mod users_subscriptions_affiliations; pub mod users_subscriptions_credits; pub mod version_item; diff --git a/apps/labrinth/src/database/models/users_subscriptions_affiliations.rs b/apps/labrinth/src/database/models/users_subscriptions_affiliations.rs new file mode 100644 index 0000000000..bcc6300087 --- /dev/null +++ b/apps/labrinth/src/database/models/users_subscriptions_affiliations.rs @@ -0,0 +1,90 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::database::models::{ + DBAffiliateCodeId, DBChargeId, DBUserSubscriptionId, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DBUsersSubscriptionsAffiliations { + pub id: i64, + pub subscription_id: DBUserSubscriptionId, + pub affiliate_code: DBAffiliateCodeId, + pub deactivated_at: Option>, +} + +impl DBUsersSubscriptionsAffiliations { + pub async fn insert<'a, E>(&mut self, exec: E) -> sqlx::Result<()> + where + E: sqlx::PgExecutor<'a>, + { + let id = sqlx::query_scalar!( + " + INSERT INTO users_subscriptions_affiliations + (subscription_id, affiliate_code, deactivated_at) + VALUES ($1, $2, $3) + RETURNING id + ", + self.subscription_id.0, + self.affiliate_code.0, + self.deactivated_at, + ) + .fetch_one(exec) + .await?; + + self.id = id; + Ok(()) + } + + pub async fn update<'a, E>(&mut self, exec: E) -> sqlx::Result<()> + where + E: sqlx::PgExecutor<'a>, + { + sqlx::query!( + "UPDATE users_subscriptions_affiliations + SET subscription_id = $1, affiliate_code = $2, deactivated_at = $3 + WHERE id = $4", + self.subscription_id.0, + self.affiliate_code.0, + self.deactivated_at, + self.id + ) + .execute(exec) + .await?; + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DBUsersSubscriptionsAffiliationsPayouts { + pub id: i64, + pub charge_id: DBChargeId, + pub subscription_id: DBUserSubscriptionId, + pub affiliate_code: DBAffiliateCodeId, + pub payout_value_id: i64, +} + +impl DBUsersSubscriptionsAffiliationsPayouts { + pub async fn insert<'a, E>(&mut self, exec: E) -> sqlx::Result<()> + where + E: sqlx::PgExecutor<'a>, + { + let id = sqlx::query_scalar!( + " + INSERT INTO users_subscriptions_affiliations_payouts + (charge_id, subscription_id, affiliate_code, payout_value_id) + VALUES ($1, $2, $3, $4) + RETURNING id + ", + self.charge_id.0, + self.subscription_id.0, + self.affiliate_code.0, + self.payout_value_id, + ) + .fetch_one(exec) + .await?; + + self.id = id; + Ok(()) + } +} diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index 6e6c180ec4..d417b6e1f1 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -4,9 +4,11 @@ use crate::database::models::charge_item::DBCharge; use crate::database::models::ids::DBUserSubscriptionId; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::products_tax_identifier_item::product_info_by_product_price_id; +use crate::database::models::users_subscriptions_affiliations::DBUsersSubscriptionsAffiliations; use crate::database::models::users_subscriptions_credits::DBUserSubscriptionCredit; use crate::database::models::{ - charge_item, generate_charge_id, product_item, user_subscription_item, + DBAffiliateCodeId, charge_item, generate_charge_id, product_item, + user_subscription_item, }; use crate::database::redis::RedisPool; use crate::models::billing::{ @@ -14,6 +16,7 @@ use crate::models::billing::{ Product, ProductMetadata, ProductPrice, SubscriptionMetadata, SubscriptionStatus, UserSubscription, }; +use crate::models::ids::AffiliateCodeId; use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; use crate::models::users::Badges; @@ -1328,7 +1331,15 @@ pub enum ChargeRequestType { #[derive(Deserialize, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] -pub enum PaymentRequestMetadata { +pub struct PaymentRequestMetadata { + #[serde(flatten)] + pub kind: PaymentRequestMetadataKind, + pub affiliate_code: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PaymentRequestMetadataKind { Pyro { server_name: Option, server_region: Option, @@ -1866,12 +1877,12 @@ pub async fn stripe_webhook( } else { let (server_name, server_region, source) = if let Some( - PaymentRequestMetadata::Pyro { - ref server_name, - ref server_region, - ref source, + PaymentRequestMetadataKind::Pyro { + server_name, + server_region, + source, }, - ) = metadata.payment_metadata + ) = metadata.payment_metadata.as_ref().map(|m| &m.kind) { ( server_name.clone(), @@ -2055,6 +2066,25 @@ pub async fn stripe_webhook( } .upsert(&mut transaction) .await?; + + if let Some(affiliate_code) = metadata + .payment_metadata + .as_ref() + .and_then(|m| m.affiliate_code) + { + DBUsersSubscriptionsAffiliations { + id: 0, + subscription_id: subscription.id, + affiliate_code: DBAffiliateCodeId::from( + affiliate_code, + ), + deactivated_at: None, + } + .insert(&mut *transaction) + .await?; + } + + // TODO affiliate code }; subscription.status = SubscriptionStatus::Provisioned; From d9108f280e4b3a2a9518c429811002b963664a15 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Tue, 28 Oct 2025 14:06:49 +0000 Subject: [PATCH 2/5] Implement affiliate payout queue --- apps/labrinth/.env.docker-compose | 2 + apps/labrinth/.env.local | 2 + ...251024182919_subscription_affiliations.sql | 6 +- apps/labrinth/src/background_task.rs | 12 +- apps/labrinth/src/lib.rs | 2 + apps/labrinth/src/queue/payouts.rs | 3 + apps/labrinth/src/queue/payouts/affiliate.rs | 197 +++ apps/labrinth/tests/affiliate_payouts.rs | 1065 +++++++++++++++++ 8 files changed, 1285 insertions(+), 4 deletions(-) create mode 100644 apps/labrinth/src/queue/payouts/affiliate.rs create mode 100644 apps/labrinth/tests/affiliate_payouts.rs diff --git a/apps/labrinth/.env.docker-compose b/apps/labrinth/.env.docker-compose index d272a7e435..d1f227011e 100644 --- a/apps/labrinth/.env.docker-compose +++ b/apps/labrinth/.env.docker-compose @@ -146,3 +146,5 @@ GOTENBERG_URL=http://labrinth-gotenberg:13000 GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg ARCHON_URL=none + +DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1 diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local index 45372c45f3..18437e323a 100644 --- a/apps/labrinth/.env.local +++ b/apps/labrinth/.env.local @@ -147,3 +147,5 @@ GOTENBERG_URL=http://localhost:13000 GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg ARCHON_URL=none + +DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1 diff --git a/apps/labrinth/migrations/20251024182919_subscription_affiliations.sql b/apps/labrinth/migrations/20251024182919_subscription_affiliations.sql index e5d58a7064..45fbb43f96 100644 --- a/apps/labrinth/migrations/20251024182919_subscription_affiliations.sql +++ b/apps/labrinth/migrations/20251024182919_subscription_affiliations.sql @@ -1,7 +1,8 @@ CREATE TABLE users_subscriptions_affiliations ( - id BIGSERIAL NOT NULL PRIMARY KEY, + id BIGSERIAL NOT NULL PRIMARY KEY, subscription_id BIGINT NOT NULL REFERENCES users_subscriptions(id), affiliate_code BIGINT NOT NULL REFERENCES affiliate_codes(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, deactivated_at TIMESTAMPTZ ); @@ -13,3 +14,6 @@ CREATE TABLE users_subscriptions_affiliations_payouts( payout_value_id BIGSERIAL NOT NULL REFERENCES payouts_values(id), UNIQUE (charge_id) ); + +ALTER TABLE payouts_values +ADD COLUMN affiliate_code_source BIGINT; diff --git a/apps/labrinth/src/background_task.rs b/apps/labrinth/src/background_task.rs index b79671eaac..cefd7d375d 100644 --- a/apps/labrinth/src/background_task.rs +++ b/apps/labrinth/src/background_task.rs @@ -3,7 +3,8 @@ use crate::queue::billing::{index_billing, index_subscriptions}; use crate::queue::email::EmailQueue; use crate::queue::payouts::{ PayoutsQueue, index_payouts_notifications, - insert_bank_balances_and_webhook, process_payout, + insert_bank_balances_and_webhook, process_affiliate_payouts, + process_payout, }; use crate::search::indexing::index_projects; use crate::util::anrok; @@ -179,12 +180,17 @@ pub async fn payouts( info!("Started running payouts"); let result = process_payout(&pool, &clickhouse).await; if let Err(e) = result { - warn!("Payouts run failed: {:?}", e); + warn!("Payouts run failed: {e:#?}"); } let result = index_payouts_notifications(&pool, &redis_pool).await; if let Err(e) = result { - warn!("Payouts notifications indexing failed: {:?}", e); + warn!("Payouts notifications indexing failed: {e:#?}"); + } + + let result = process_affiliate_payouts(&pool).await; + if let Err(e) = result { + warn!("Affiliate payouts run failed: {e:#?}"); } info!("Done running payouts"); diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index 2dff703111..96dc4cff89 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -526,5 +526,7 @@ pub fn check_env_vars() -> bool { failed |= check_var::("ARCHON_URL"); + failed |= check_var::("DEFAULT_AFFILIATE_REVENUE_SPLIT"); + failed } diff --git a/apps/labrinth/src/queue/payouts.rs b/apps/labrinth/src/queue/payouts.rs index 88feaaea28..a46db62e7e 100644 --- a/apps/labrinth/src/queue/payouts.rs +++ b/apps/labrinth/src/queue/payouts.rs @@ -26,6 +26,9 @@ use std::collections::HashMap; use tokio::sync::RwLock; use tracing::{error, info}; +mod affiliate; +pub use affiliate::process_affiliate_payouts; + pub struct PayoutsQueue { credential: RwLock>, payout_options: RwLock>, diff --git a/apps/labrinth/src/queue/payouts/affiliate.rs b/apps/labrinth/src/queue/payouts/affiliate.rs new file mode 100644 index 0000000000..9bdf1b10e0 --- /dev/null +++ b/apps/labrinth/src/queue/payouts/affiliate.rs @@ -0,0 +1,197 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Datelike, Duration, TimeZone, Utc}; +use eyre::{Context, Result, eyre}; +use rust_decimal::Decimal; +use sqlx::PgPool; +use tracing::warn; + +use crate::database::models::{DBAffiliateCodeId, DBUserId}; + +pub async fn process_affiliate_payouts(postgres: &PgPool) -> Result<()> { + /// Data for an (affiliate user, affiliate code) pair. + #[derive(Debug, Default)] + struct AffiliatePayoutInfo { + /// How much the affiliate will earn from this code. + amount: Decimal, + /// Which (charge, subscription) pairs will be linked to this payout. + charge_subscription_ids: Vec<(i64, i64)>, + } + + // process: + // - get any subscriptions which are in `users_subscriptions_affiliations` + // - for those subscriptions, get any charges which are not in `users_subscriptions_affiliations_payouts` + // - for each of those charges, + // - get the subscription's `affiliate_code` + // - get the affiliate user of that code + // - add a payout for that affiliate user, proportional to the net of the charge + // - add a record of this into `users_subscriptions_affiliations_payouts` + + let mut txn = postgres + .begin() + .await + .wrap_err("failed to begin transaction")?; + + let rows = sqlx::query!( + r#" + SELECT + c.id as charge_id, + c.subscription_id AS "subscription_id!", + c.net as charge_net, + c.currency_code, + usa.affiliate_code, + ac.affiliate as affiliate_user_id, + ac.revenue_split + -- get any charges... + FROM charges c + -- ...which have a subscription... + INNER JOIN users_subscriptions_affiliations usa + ON c.subscription_id = usa.subscription_id + AND c.subscription_id IS NOT NULL + -- ...which have an affiliate code... + INNER JOIN affiliate_codes ac + ON usa.affiliate_code = ac.id + AND usa.deactivated_at IS NULL + -- ...and where no payout to an affiliate has been made for this charge yet + LEFT JOIN users_subscriptions_affiliations_payouts usap + ON c.id = usap.charge_id + WHERE + c.status = 'succeeded' + AND c.net > 0 + AND usap.id IS NULL + "# + ) + .fetch_all(&mut *txn) + .await + .wrap_err("failed to fetch charges awaiting affiliate payout")?; + + let default_affiliate_revenue_split = + dotenvy::var("DEFAULT_AFFILIATE_REVENUE_SPLIT") + .wrap_err("no env var `DEFAULT_AFFILIATE_REVENUE_SPLIT`")? + .parse::() + .wrap_err("`DEFAULT_AFFILIATE_REVENUE_SPLIT` is not a decimal")?; + + let now = Utc::now(); + let start: DateTime = DateTime::from_naive_utc_and_offset( + (now - Duration::days(1)) + .date_naive() + .and_hms_nano_opt(0, 0, 0, 0) + .unwrap_or_default(), + Utc, + ); + + // affiliate payouts are Net 60 from the end of the month + let available = { + let now = Utc::now().date_naive(); + + let year = now.year(); + let month = now.month(); + + // get the first day of the next month + let last_day_of_month = if month == 12 { + Utc.with_ymd_and_hms(year + 1, 1, 1, 0, 0, 0).unwrap() + } else { + Utc.with_ymd_and_hms(year, month + 1, 1, 0, 0, 0).unwrap() + }; + + last_day_of_month + Duration::days(59) + }; + + // collect the rev from each affiliate and their code, and sum up values + let mut payouts = + HashMap::<(DBUserId, DBAffiliateCodeId), AffiliatePayoutInfo>::new(); + + for row in rows { + let Some(net) = row.charge_net else { + warn!( + "Charge {} has no net amount; cannot calculate affiliate payout", + row.charge_id + ); + continue; + }; + let net = Decimal::new(net, 2); + + let revenue_split = row + .revenue_split + .and_then(Decimal::from_f64_retain) + .unwrap_or(default_affiliate_revenue_split); + if !(Decimal::from(0)..=Decimal::from(1)).contains(&revenue_split) { + warn!( + "Charge {} has revenue split {} which is out of range", + row.charge_id, revenue_split + ); + continue; + } + + let affiliate_cut = net * revenue_split; + let affiliate_user_id = DBUserId(row.affiliate_user_id); + let affiliate_code_id = DBAffiliateCodeId(row.affiliate_code); + + let payout_info = payouts + .entry((affiliate_user_id, affiliate_code_id)) + .or_default(); + // a portion of this charge will be added as a payout to the affiliate... + payout_info.amount += affiliate_cut; + payout_info + .charge_subscription_ids + .push((row.charge_id, row.subscription_id)); + } + + for ((affiliate_id, affiliate_code_id), payout_info) in payouts { + let payout_value_id = sqlx::query!( + " + INSERT INTO payouts_values + (user_id, amount, created, + date_available, affiliate_code_source) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + ", + affiliate_id.0, + payout_info.amount, + start, + available, + affiliate_code_id.0, + ) + .fetch_one(&mut *txn) + .await + .wrap_err_with(|| eyre!("failed to insert payout value for ({affiliate_id:?}, {affiliate_code_id:?})"))? + .id; + + let ( + mut insert_usap_charges, + mut insert_usap_subscriptions, + mut insert_usap_affiliate_codes, + mut insert_usap_payout_values, + ) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + + for (charge_id, subscription_id) in payout_info.charge_subscription_ids + { + insert_usap_charges.push(charge_id); + insert_usap_subscriptions.push(subscription_id); + insert_usap_affiliate_codes.push(affiliate_code_id.0); + insert_usap_payout_values.push(payout_value_id); + } + + sqlx::query!( + " + INSERT INTO users_subscriptions_affiliations_payouts + (charge_id, subscription_id, + affiliate_code, payout_value_id) + SELECT * FROM UNNEST($1::bigint[], $2::bigint[], $3::bigint[], $4::bigint[]) + ", + &insert_usap_charges[..], + &insert_usap_subscriptions[..], + &insert_usap_affiliate_codes[..], + &insert_usap_payout_values[..], + ) + .execute(&mut *txn) + .await + .wrap_err("failed to associate charges with affiliate payouts")?; + } + + txn.commit() + .await + .wrap_err("failed to commit transaction")?; + + Ok(()) +} diff --git a/apps/labrinth/tests/affiliate_payouts.rs b/apps/labrinth/tests/affiliate_payouts.rs new file mode 100644 index 0000000000..6608f7a773 --- /dev/null +++ b/apps/labrinth/tests/affiliate_payouts.rs @@ -0,0 +1,1065 @@ +use ariadne::ids::base62_impl::parse_base62; +use chrono::{DateTime, Duration, Utc}; +use chrono::{Datelike, TimeZone}; +use common::permissions::PermissionsTest; +use common::permissions::PermissionsTestContext; +use common::{ + api_v3::ApiV3, + database::*, + environment::{TestEnvironment, with_test_environment}, +}; +use itertools::Itertools; +use labrinth::database::models::charge_item::DBCharge; +use labrinth::database::models::{ + DBAffiliateCode, DBAffiliateCodeId, DBChargeId, DBProductPriceId, DBUserId, + DBUserSubscriptionId, +}; +use labrinth::models::billing::{ChargeStatus, ChargeType, PaymentPlatform}; +use labrinth::models::teams::ProjectPermissions; +use labrinth::queue::payouts::{self, process_affiliate_payouts}; +use rust_decimal::{Decimal, prelude::ToPrimitive}; +use std::collections::HashMap; + +pub mod common; + +#[actix_rt::test] +pub async fn affiliate_payout_basic() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let pool = &test_env.db.pool; + + // Setup test data + let affiliate_user_id = DBUserId(USER_USER_ID_PARSED); + let customer_user_id = DBUserId(FRIEND_USER_ID_PARSED); + let affiliate_code_id = DBAffiliateCodeId(1001); + let subscription_id = DBUserSubscriptionId(2001); + + // Create affiliate code + let affiliate_code = DBAffiliateCode { + id: affiliate_code_id, + created_at: Utc::now(), + created_by: affiliate_user_id, + affiliate: affiliate_user_id, + source_name: "test_code".to_string(), + }; + affiliate_code.insert(pool).await.unwrap(); + + // Create subscription affiliation + let mut affiliation = DBUsersSubscriptionsAffiliations { + id: 0, + subscription_id, + affiliate_code: affiliate_code_id, + deactivated_at: None, + }; + affiliation.insert(pool).await.unwrap(); + + // Create a successful charge with net amount + let charge_id = DBChargeId(3001); + let charge = DBCharge { + id: charge_id, + user_id: customer_user_id, + price_id: DBProductPriceId(1001), + amount: 1000, // $10.00 + currency_code: "USD".to_string(), + status: ChargeStatus::Succeeded, + due: Utc::now(), + last_attempt: Some(Utc::now()), + type_: ChargeType::Subscription, + subscription_id: Some(subscription_id), + subscription_interval: None, + payment_platform: PaymentPlatform::Stripe, + payment_platform_id: Some("ch_test123".to_string()), + parent_charge_id: None, + tax_amount: 50, + tax_platform_id: None, + tax_last_updated: None, + tax_transaction_version: None, + tax_platform_accounting_time: None, + net: Some(1000), // $10.00 net + tax_drift_loss: None, + }; + + let mut txn = pool.begin().await.unwrap(); + charge.upsert(&mut txn).await.unwrap(); + txn.commit().await.unwrap(); + + // Process affiliate payouts + process_affiliate_payouts(pool).await.unwrap(); + + // Verify payout was created + let payout_records = sqlx::query!( + "SELECT user_id, amount, created, date_available, affiliate_code_source + FROM payouts_values WHERE affiliate_code_source IS NOT NULL" + ) + .fetch_all(pool) + .await + .unwrap(); + + assert_eq!(payout_records.len(), 1); + let payout = &payout_records[0]; + assert_eq!(payout.user_id, affiliate_user_id.0); + assert_eq!(payout.amount, Some(100)); // $10.00 * 0.1 = $1.00, but stored as cents + assert_eq!(payout.affiliate_code_source, Some(affiliate_code_id.0)); + + // Verify charge-payout association was created + let association = sqlx::query!( + "SELECT charge_id, subscription_id, affiliate_code, payout_value_id + FROM users_subscriptions_affiliations_payouts" + ) + .fetch_one(pool) + .await + .unwrap(); + + assert_eq!(association.charge_id, charge_id.0); + assert_eq!(association.subscription_id, subscription_id.0); + assert_eq!(association.affiliate_code, affiliate_code_id.0); + }, + ) + .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 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(), + ) + .await + }; + + PermissionsTest::new(&test_env) + .with_failure_codes(vec![200, 401]) + .with_200_json_checks( + // On failure, should have 0 projects returned + |value: &serde_json::Value| { + let value = value.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(); + assert_eq!(value.len(), 1); + }, + ) + .simple_project_permissions_test(view_analytics, req_gen) + .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; + }, + ) + .await; +} + +#[actix_rt::test] +pub async fn affiliate_payout_custom_revenue_split() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let pool = &test_env.db.pool; + + // Setup test data with custom revenue split (25%) + let affiliate_user_id = DBUserId(USER_USER_ID_PARSED); + let customer_user_id = DBUserId(FRIEND_USER_ID_PARSED); + let affiliate_code_id = DBAffiliateCodeId(1002); + let subscription_id = DBUserSubscriptionId(2002); + + // Create affiliate code with custom revenue split + let affiliate_code = DBAffiliateCode { + id: affiliate_code_id, + created_at: Utc::now(), + created_by: affiliate_user_id, + affiliate: affiliate_user_id, + source_name: "test_code_custom".to_string(), + }; + affiliate_code.insert(pool).await.unwrap(); + + // Update the affiliate code to have a custom revenue split + sqlx::query!( + "UPDATE affiliate_codes SET revenue_split = $1 WHERE id = $2", + 0.25f64, + affiliate_code_id.0 + ) + .execute(pool) + .await + .unwrap(); + + // Create subscription affiliation + let mut affiliation = DBUsersSubscriptionsAffiliations { + id: 0, + subscription_id, + affiliate_code: affiliate_code_id, + deactivated_at: None, + }; + affiliation.insert(pool).await.unwrap(); + + // Create a successful charge + let charge_id = DBChargeId(3002); + let charge = DBCharge { + id: charge_id, + user_id: customer_user_id, + price_id: 1002, + amount: 2000, // $20.00 + currency_code: "USD".to_string(), + status: ChargeStatus::Succeeded, + due: Utc::now(), + last_attempt: Some(Utc::now()), + type_: ChargeType::Subscription, + subscription_id: Some(subscription_id), + subscription_interval: None, + payment_platform: PaymentPlatform::Stripe, + payment_platform_id: Some("ch_test456".to_string()), + parent_charge_id: None, + tax_amount: 100, + tax_platform_id: None, + tax_last_updated: None, + tax_transaction_version: None, + tax_platform_accounting_time: None, + net: Some(2000), // $20.00 net + tax_drift_loss: None, + }; + + let mut txn = pool.begin().await.unwrap(); + charge.upsert(&mut txn).await.unwrap(); + txn.commit().await.unwrap(); + + // Process affiliate payouts + process_affiliate_payouts(pool).await.unwrap(); + + // Verify payout with custom split + let payout = sqlx::query!( + "SELECT amount FROM payouts_values WHERE affiliate_code_source = $1", + affiliate_code_id.0 + ) + .fetch_one(pool) + .await + .unwrap(); + + assert_eq!(payout.amount, Decimal::from(500)); // $20.00 * 0.25 = $5.00, stored as cents + }, + ) + .await; +} + +#[actix_rt::test] +pub async fn affiliate_payout_multiple_charges_same_code() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let pool = &test_env.db.pool; + + let affiliate_user_id = DBUserId(USER_USER_ID_PARSED); + let customer_user_id = DBUserId(FRIEND_USER_ID_PARSED); + let affiliate_code_id = DBAffiliateCodeId(1003); + let subscription_id = DBUserSubscriptionId(2003); + + // Setup affiliate code and affiliation + let affiliate_code = DBAffiliateCode { + id: affiliate_code_id, + created_at: Utc::now(), + created_by: affiliate_user_id, + affiliate: affiliate_user_id, + source_name: "test_multi".to_string(), + }; + affiliate_code.insert(pool).await.unwrap(); + + let mut affiliation = DBUsersSubscriptionsAffiliations { + id: 0, + subscription_id, + affiliate_code: affiliate_code_id, + deactivated_at: None, + }; + affiliation.insert(pool).await.unwrap(); + + // Create multiple charges for the same subscription + let charges = vec![ + (DBChargeId(3003), 1000), // $10.00 + (DBChargeId(3004), 1500), // $15.00 + (DBChargeId(3005), 2000), // $20.00 + ]; + + let mut txn = pool.begin().await.unwrap(); + for (charge_id, net_amount) in &charges { + let charge = DBCharge { + id: *charge_id, + user_id: customer_user_id, + price_id: 1003, + amount: *net_amount, + currency_code: "USD".to_string(), + status: ChargeStatus::Succeeded, + due: Utc::now(), + last_attempt: Some(Utc::now()), + type_: ChargeType::Subscription, + subscription_id: Some(subscription_id), + subscription_interval: None, + payment_platform: PaymentPlatform::Stripe, + payment_platform_id: Some(format!("ch_test{}", charge_id.0)), + parent_charge_id: None, + tax_amount: 50, + tax_platform_id: None, + tax_last_updated: None, + tax_transaction_version: None, + tax_platform_accounting_time: None, + net: Some(*net_amount), + tax_drift_loss: None, + }; + charge.upsert(&mut txn).await.unwrap(); + } + txn.commit().await.unwrap(); + + // Process affiliate payouts + process_affiliate_payouts(pool).await.unwrap(); + + // Should create a single payout aggregating all charges + let payout_records = sqlx::query!( + "SELECT user_id, amount FROM payouts_values WHERE affiliate_code_source = $1", + affiliate_code_id.0 + ) + .fetch_all(pool) + .await + .unwrap(); + + assert_eq!(payout_records.len(), 1); + let total_expected = Decimal::from(1000 + 1500 + 2000) * Decimal::from_f64_retain(0.1).unwrap() / Decimal::new(1, 0); + assert_eq!(payout_records[0].amount, Some(total_expected.to_i64().unwrap())); + + // Should create 3 charge-payout associations + let associations = sqlx::query!( + "SELECT COUNT(*) as count FROM users_subscriptions_affiliations_payouts WHERE affiliate_code = $1", + affiliate_code_id.0 + ) + .fetch_one(pool) + .await + .unwrap(); + + assert_eq!(associations.count.unwrap(), 3); + }, + ) + .await; +} + +#[actix_rt::test] +pub async fn affiliate_payout_deactivated_affiliation() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let pool = &test_env.db.pool; + + let affiliate_user_id = DBUserId(USER_USER_ID_PARSED); + let customer_user_id = DBUserId(FRIEND_USER_ID_PARSED); + let affiliate_code_id = DBAffiliateCodeId(1004); + let subscription_id = DBUserSubscriptionId(2004); + + // Setup affiliate code + let affiliate_code = DBAffiliateCode { + id: affiliate_code_id, + created_at: Utc::now(), + created_by: affiliate_user_id, + affiliate: affiliate_user_id, + source_name: "test_deactivated".to_string(), + }; + affiliate_code.insert(pool).await.unwrap(); + + // Create DEACTIVATED subscription affiliation + let mut affiliation = DBUsersSubscriptionsAffiliations { + id: 0, + subscription_id, + affiliate_code: affiliate_code_id, + deactivated_at: Some(Utc::now() - Duration::days(1)), // Deactivated yesterday + }; + affiliation.insert(pool).await.unwrap(); + + // Create a successful charge + let charge_id = DBChargeId(3006); + let charge = DBCharge { + id: charge_id, + user_id: customer_user_id, + price_id: 1004, + amount: 1000, + currency_code: "USD".to_string(), + status: ChargeStatus::Succeeded, + due: Utc::now(), + last_attempt: Some(Utc::now()), + type_: ChargeType::Subscription, + subscription_id: Some(subscription_id), + subscription_interval: None, + payment_platform: PaymentPlatform::Stripe, + payment_platform_id: Some("ch_test789".to_string()), + parent_charge_id: None, + tax_amount: 50, + tax_platform_id: None, + tax_last_updated: None, + tax_transaction_version: None, + tax_platform_accounting_time: None, + net: Some(1000), + tax_drift_loss: None, + }; + + let mut txn = pool.begin().await.unwrap(); + charge.upsert(&mut txn).await.unwrap(); + txn.commit().await.unwrap(); + + // Process affiliate payouts + process_affiliate_payouts(pool).await.unwrap(); + + // Should NOT create any payouts for deactivated affiliations + let payout_records = sqlx::query!( + "SELECT COUNT(*) as count FROM payouts_values WHERE affiliate_code_source = $1", + affiliate_code_id.0 + ) + .fetch_one(pool) + .await + .unwrap(); + + assert_eq!(payout_records.count.unwrap(), 0); + }, + ) + .await; +} + +#[actix_rt::test] +pub async fn affiliate_payout_edge_cases() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let pool = &test_env.db.pool; + + let affiliate_user_id = DBUserId(USER_USER_ID_PARSED); + let customer_user_id = DBUserId(FRIEND_USER_ID_PARSED); + let affiliate_code_id = DBAffiliateCodeId(1005); + let subscription_id = DBUserSubscriptionId(2005); + + // Setup affiliate code + let affiliate_code = DBAffiliateCode { + id: affiliate_code_id, + created_at: Utc::now(), + created_by: affiliate_user_id, + affiliate: affiliate_user_id, + source_name: "test_edge_cases".to_string(), + }; + affiliate_code.insert(pool).await.unwrap(); + + // Set an invalid revenue split (out of range) + sqlx::query!( + "UPDATE affiliate_codes SET revenue_split = $1 WHERE id = $2", + 1.5f64, // Invalid: > 1.0 + affiliate_code_id.0 + ) + .execute(pool) + .await + .unwrap(); + + let mut affiliation = DBUsersSubscriptionsAffiliations { + id: 0, + subscription_id, + affiliate_code: affiliate_code_id, + deactivated_at: None, + }; + affiliation.insert(pool).await.unwrap(); + + // Create charges with edge case scenarios + let charges = vec![ + (DBChargeId(3007), Some(1000), ChargeStatus::Succeeded), // Normal case + (DBChargeId(3008), Some(0), ChargeStatus::Succeeded), // Zero net + (DBChargeId(3009), Some(1000), ChargeStatus::Failed), // Failed charge + (DBChargeId(3010), None, ChargeStatus::Succeeded), // No net amount + ]; + + let mut txn = pool.begin().await.unwrap(); + for (charge_id, net_amount, status) in charges { + let charge = DBCharge { + id: charge_id, + user_id: customer_user_id, + price_id: 1005, + amount: net_amount.unwrap_or(0), + currency_code: "USD".to_string(), + status, + due: Utc::now(), + last_attempt: Some(Utc::now()), + type_: ChargeType::Subscription, + subscription_id: Some(subscription_id), + subscription_interval: None, + payment_platform: PaymentPlatform::Stripe, + payment_platform_id: Some(format!("ch_test{}", charge_id.0)), + parent_charge_id: None, + tax_amount: 0, + tax_platform_id: None, + tax_last_updated: None, + tax_transaction_version: None, + tax_platform_accounting_time: None, + net: net_amount, + tax_drift_loss: None, + }; + charge.upsert(&mut txn).await.unwrap(); + } + txn.commit().await.unwrap(); + + // Process affiliate payouts + process_affiliate_payouts(pool).await.unwrap(); + + // Should only create payout for the valid charge (3007) + // But since the revenue split is invalid, even that should be skipped + let payout_records = sqlx::query!( + "SELECT COUNT(*) as count FROM payouts_values WHERE affiliate_code_source = $1", + affiliate_code_id.0 + ) + .fetch_one(pool) + .await + .unwrap(); + + assert_eq!(payout_records.count.unwrap(), 0); + }, + ) + .await; +} + +#[actix_rt::test] +pub async fn affiliate_payout_idempotent() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let pool = &test_env.db.pool; + + let affiliate_user_id = DBUserId(USER_USER_ID_PARSED); + let customer_user_id = DBUserId(FRIEND_USER_ID_PARSED); + let affiliate_code_id = DBAffiliateCodeId(1006); + let subscription_id = DBUserSubscriptionId(2006); + + // Setup affiliate code and affiliation + let affiliate_code = DBAffiliateCode { + id: affiliate_code_id, + created_at: Utc::now(), + created_by: affiliate_user_id, + affiliate: affiliate_user_id, + source_name: "test_idempotent".to_string(), + }; + affiliate_code.insert(pool).await.unwrap(); + + let mut affiliation = DBUsersSubscriptionsAffiliations { + id: 0, + subscription_id, + affiliate_code: affiliate_code_id, + deactivated_at: None, + }; + affiliation.insert(pool).await.unwrap(); + + // Create a successful charge + let charge_id = DBChargeId(3011); + let charge = DBCharge { + id: charge_id, + user_id: customer_user_id, + price_id: 1006, + amount: 1000, + currency_code: "USD".to_string(), + status: ChargeStatus::Succeeded, + due: Utc::now(), + last_attempt: Some(Utc::now()), + type_: ChargeType::Subscription, + subscription_id: Some(subscription_id), + subscription_interval: None, + payment_platform: PaymentPlatform::Stripe, + payment_platform_id: Some("ch_test_idempotent".to_string()), + parent_charge_id: None, + tax_amount: 50, + tax_platform_id: None, + tax_last_updated: None, + tax_transaction_version: None, + tax_platform_accounting_time: None, + net: Some(1000), + tax_drift_loss: None, + }; + + let mut txn = pool.begin().await.unwrap(); + charge.upsert(&mut txn).await.unwrap(); + txn.commit().await.unwrap(); + + // Process affiliate payouts first time + process_affiliate_payouts(pool).await.unwrap(); + + let first_payout_count = sqlx::query!( + "SELECT COUNT(*) as count FROM payouts_values WHERE affiliate_code_source = $1", + affiliate_code_id.0 + ) + .fetch_one(pool) + .await + .unwrap() + .count + .unwrap(); + + let first_association_count = sqlx::query!( + "SELECT COUNT(*) as count FROM users_subscriptions_affiliations_payouts WHERE charge_id = $1", + charge_id.0 + ) + .fetch_one(pool) + .await + .unwrap() + .count + .unwrap(); + + assert_eq!(first_payout_count, 1); + assert_eq!(first_association_count, 1); + + // Process affiliate payouts second time (should be idempotent) + process_affiliate_payouts(pool).await.unwrap(); + + let second_payout_count = sqlx::query!( + "SELECT COUNT(*) as count FROM payouts_values WHERE affiliate_code_source = $1", + affiliate_code_id.0 + ) + .fetch_one(pool) + .await + .unwrap() + .count + .unwrap(); + + let second_association_count = sqlx::query!( + "SELECT COUNT(*) as count FROM users_subscriptions_affiliations_payouts WHERE charge_id = $1", + charge_id.0 + ) + .fetch_one(pool) + .await + .unwrap() + .count + .unwrap(); + + // Should not create duplicate payouts + assert_eq!(second_payout_count, 1); + assert_eq!(second_association_count, 1); + }, + ) + .await; +} + +#[actix_rt::test] +pub async fn affiliate_payout_availability_date() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let pool = &test_env.db.pool; + + let affiliate_user_id = DBUserId(USER_USER_ID_PARSED); + let customer_user_id = DBUserId(FRIEND_USER_ID_PARSED); + let affiliate_code_id = DBAffiliateCodeId(1007); + let subscription_id = DBUserSubscriptionId(2007); + + // Setup affiliate code and affiliation + let affiliate_code = DBAffiliateCode { + id: affiliate_code_id, + created_at: Utc::now(), + created_by: affiliate_user_id, + affiliate: affiliate_user_id, + source_name: "test_availability".to_string(), + }; + affiliate_code.insert(pool).await.unwrap(); + + let mut affiliation = DBUsersSubscriptionsAffiliations { + id: 0, + subscription_id, + affiliate_code: affiliate_code_id, + deactivated_at: None, + }; + affiliation.insert(pool).await.unwrap(); + + // Create a successful charge + let charge_id = DBChargeId(3012); + let charge = DBCharge { + id: charge_id, + user_id: customer_user_id, + price_id: 1007, + amount: 1000, + currency_code: "USD".to_string(), + status: ChargeStatus::Succeeded, + due: Utc::now(), + last_attempt: Some(Utc::now()), + type_: ChargeType::Subscription, + subscription_id: Some(subscription_id), + subscription_interval: None, + payment_platform: PaymentPlatform::Stripe, + payment_platform_id: Some("ch_test_availability".to_string()), + parent_charge_id: None, + tax_amount: 50, + tax_platform_id: None, + tax_last_updated: None, + tax_transaction_version: None, + tax_platform_accounting_time: None, + net: Some(1000), + tax_drift_loss: None, + }; + + let mut txn = pool.begin().await.unwrap(); + charge.upsert(&mut txn).await.unwrap(); + txn.commit().await.unwrap(); + + // Process affiliate payouts + process_affiliate_payouts(pool).await.unwrap(); + + // Verify availability date is set correctly (Net 60) + let payout = sqlx::query!( + "SELECT created, date_available FROM payouts_values WHERE affiliate_code_source = $1", + affiliate_code_id.0 + ) + .fetch_one(pool) + .await + .unwrap(); + + let now = Utc::now(); + let expected_available = { + let year = now.year(); + let month = now.month(); + + // First day of next month + 59 days (Net 60) + let first_day_next_month = if month == 12 { + Utc.with_ymd_and_hms(year + 1, 1, 1, 0, 0, 0).unwrap() + } else { + Utc.with_ymd_and_hms(year, month + 1, 1, 0, 0, 0).unwrap() + }; + + first_day_next_month + Duration::days(59) + }; + + // Check that availability date is approximately correct (within 1 minute) + let availability_diff = (payout.date_available - expected_available).abs(); + assert!(availability_diff.num_minutes() < 1); + + // Check that created date is yesterday (start of the day) + let expected_created = (now - Duration::days(1)).date_naive().and_hms_opt(0, 0, 0).unwrap(); + let actual_created = payout.created.naive_utc(); + let created_diff = (actual_created - expected_created).abs(); + assert!(created_diff.num_seconds() < 1); + }, + ) + .await; +} + +#[actix_rt::test] +pub async fn affiliate_payout_multiple_codes_same_affiliate() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let pool = &test_env.db.pool; + + let affiliate_user_id = DBUserId(USER_USER_ID_PARSED); + let customer_user_id = DBUserId(FRIEND_USER_ID_PARSED); + let affiliate_code_id_1 = DBAffiliateCodeId(1008); + let affiliate_code_id_2 = DBAffiliateCodeId(1009); + let subscription_id_1 = DBUserSubscriptionId(2008); + let subscription_id_2 = DBUserSubscriptionId(2009); + + // Create two affiliate codes for the same affiliate user + let affiliate_code_1 = DBAffiliateCode { + id: affiliate_code_id_1, + created_at: Utc::now(), + created_by: affiliate_user_id, + affiliate: affiliate_user_id, + source_name: "test_code_1".to_string(), + }; + affiliate_code_1.insert(pool).await.unwrap(); + + let affiliate_code_2 = DBAffiliateCode { + id: affiliate_code_id_2, + created_at: Utc::now(), + created_by: affiliate_user_id, + affiliate: affiliate_user_id, + source_name: "test_code_2".to_string(), + }; + affiliate_code_2.insert(pool).await.unwrap(); + + // Create affiliations for both codes + let mut affiliation_1 = DBUsersSubscriptionsAffiliations { + id: 0, + subscription_id: subscription_id_1, + affiliate_code: affiliate_code_id_1, + deactivated_at: None, + }; + affiliation_1.insert(pool).await.unwrap(); + + let mut affiliation_2 = DBUsersSubscriptionsAffiliations { + id: 0, + subscription_id: subscription_id_2, + affiliate_code: affiliate_code_id_2, + deactivated_at: None, + }; + affiliation_2.insert(pool).await.unwrap(); + + // Create charges for both subscriptions + let charge_1_id = DBChargeId(3013); + let charge_1 = DBCharge { + id: charge_1_id, + user_id: customer_user_id, + price_id: 1008, + amount: 1000, // $10.00 + currency_code: "USD".to_string(), + status: ChargeStatus::Succeeded, + due: Utc::now(), + last_attempt: Some(Utc::now()), + type_: ChargeType::Subscription, + subscription_id: Some(subscription_id_1), + subscription_interval: None, + payment_platform: PaymentPlatform::Stripe, + payment_platform_id: Some("ch_test_multi1".to_string()), + parent_charge_id: None, + tax_amount: 50, + tax_platform_id: None, + tax_last_updated: None, + tax_transaction_version: None, + tax_platform_accounting_time: None, + net: Some(1000), + tax_drift_loss: None, + }; + + let charge_2_id = DBChargeId(3014); + let charge_2 = DBCharge { + id: charge_2_id, + user_id: customer_user_id, + price_id: 1009, + amount: 2000, // $20.00 + currency_code: "USD".to_string(), + status: ChargeStatus::Succeeded, + due: Utc::now(), + last_attempt: Some(Utc::now()), + type_: ChargeType::Subscription, + subscription_id: Some(subscription_id_2), + subscription_interval: None, + payment_platform: PaymentPlatform::Stripe, + payment_platform_id: Some("ch_test_multi2".to_string()), + parent_charge_id: None, + tax_amount: 100, + tax_platform_id: None, + tax_last_updated: None, + tax_transaction_version: None, + tax_platform_accounting_time: None, + net: Some(2000), + tax_drift_loss: None, + }; + + let mut txn = pool.begin().await.unwrap(); + charge_1.upsert(&mut txn).await.unwrap(); + charge_2.upsert(&mut txn).await.unwrap(); + txn.commit().await.unwrap(); + + // Process affiliate payouts + process_affiliate_payouts(pool).await.unwrap(); + + // Should create separate payouts for each code + let payout_records = sqlx::query!( + "SELECT affiliate_code_source, amount FROM payouts_values WHERE user_id = $1 ORDER BY affiliate_code_source", + affiliate_user_id.0 + ) + .fetch_all(pool) + .await + .unwrap(); + + assert_eq!(payout_records.len(), 2); + + // Verify amounts for each code + let payouts_by_code: HashMap = payout_records + .into_iter() + .map(|r| (r.affiliate_code_source.unwrap(), r.amount)) + .collect(); + + assert_eq!(payouts_by_code.get(&1008).unwrap().unwrap(), 100); // $10.00 * 0.1 = $1.00 + assert_eq!(payouts_by_code.get(&1009).unwrap().unwrap(), 200); // $20.00 * 0.1 = $2.00 + }, + ) + .await; +} + +#[actix_rt::test] +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(); + + let pool = test_env.db.pool.clone(); + + // Generate sample revenue data- directly insert into sql + let ( + mut insert_user_ids, + mut insert_project_ids, + mut insert_payouts, + mut insert_starts, + mut insert_availables, + ) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); + + // Note: these go from most recent to least recent + let money_time_pairs: [(f64, DateTime); 10] = [ + (50.0, Utc::now() - Duration::minutes(5)), + (50.1, Utc::now() - Duration::minutes(10)), + (101.0, Utc::now() - Duration::days(1)), + (200.0, Utc::now() - Duration::days(2)), + (311.0, Utc::now() - Duration::days(3)), + (400.0, Utc::now() - Duration::days(4)), + (526.0, Utc::now() - Duration::days(5)), + (633.0, Utc::now() - Duration::days(6)), + (800.0, Utc::now() - Duration::days(14)), + (800.0, Utc::now() - Duration::days(800)), + ]; + + let project_id = parse_base62(&alpha_project_id).unwrap() as i64; + for (money, time) in &money_time_pairs { + insert_user_ids.push(USER_USER_ID_PARSED); + insert_project_ids.push(project_id); + insert_payouts.push(Decimal::from_f64_retain(*money).unwrap()); + insert_starts.push(*time); + insert_availables.push(*time); + } + + let mut transaction = pool.begin().await.unwrap(); + payouts::insert_payouts( + insert_user_ids, + insert_project_ids, + insert_payouts, + insert_starts, + insert_availables, + &mut transaction, + ) + .await + .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, + ) + .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); + } + + // 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); + } + }, + ) + .await; +} From ebf3e7e6ca7eb7cf8cd0d3ad85d191c7dc7d6073 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 30 Oct 2025 10:52:26 +0000 Subject: [PATCH 3/5] Deactivate subscription affiliations on cancellation --- ...251024182919_subscription_affiliations.sql | 4 ++-- .../users_subscriptions_affiliations.rs | 22 ++++++++----------- apps/labrinth/src/queue/payouts/affiliate.rs | 2 +- apps/labrinth/src/routes/internal/billing.rs | 13 ++++++++--- 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/apps/labrinth/migrations/20251024182919_subscription_affiliations.sql b/apps/labrinth/migrations/20251024182919_subscription_affiliations.sql index 45fbb43f96..e8edc64694 100644 --- a/apps/labrinth/migrations/20251024182919_subscription_affiliations.sql +++ b/apps/labrinth/migrations/20251024182919_subscription_affiliations.sql @@ -1,9 +1,9 @@ CREATE TABLE users_subscriptions_affiliations ( - id BIGSERIAL NOT NULL PRIMARY KEY, subscription_id BIGINT NOT NULL REFERENCES users_subscriptions(id), affiliate_code BIGINT NOT NULL REFERENCES affiliate_codes(id), created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - deactivated_at TIMESTAMPTZ + deactivated_at TIMESTAMPTZ, + UNIQUE (subscription_id) ); CREATE TABLE users_subscriptions_affiliations_payouts( diff --git a/apps/labrinth/src/database/models/users_subscriptions_affiliations.rs b/apps/labrinth/src/database/models/users_subscriptions_affiliations.rs index bcc6300087..dccd793023 100644 --- a/apps/labrinth/src/database/models/users_subscriptions_affiliations.rs +++ b/apps/labrinth/src/database/models/users_subscriptions_affiliations.rs @@ -7,23 +7,21 @@ use crate::database::models::{ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DBUsersSubscriptionsAffiliations { - pub id: i64, pub subscription_id: DBUserSubscriptionId, pub affiliate_code: DBAffiliateCodeId, pub deactivated_at: Option>, } impl DBUsersSubscriptionsAffiliations { - pub async fn insert<'a, E>(&mut self, exec: E) -> sqlx::Result<()> + pub async fn insert<'a, E>(&self, exec: E) -> sqlx::Result<()> where E: sqlx::PgExecutor<'a>, { - let id = sqlx::query_scalar!( + sqlx::query_scalar!( " INSERT INTO users_subscriptions_affiliations (subscription_id, affiliate_code, deactivated_at) VALUES ($1, $2, $3) - RETURNING id ", self.subscription_id.0, self.affiliate_code.0, @@ -31,23 +29,21 @@ impl DBUsersSubscriptionsAffiliations { ) .fetch_one(exec) .await?; - - self.id = id; Ok(()) } - pub async fn update<'a, E>(&mut self, exec: E) -> sqlx::Result<()> + pub async fn deactivate<'a, E>( + subscription_id: DBUserSubscriptionId, + exec: E, + ) -> sqlx::Result<()> where E: sqlx::PgExecutor<'a>, { sqlx::query!( "UPDATE users_subscriptions_affiliations - SET subscription_id = $1, affiliate_code = $2, deactivated_at = $3 - WHERE id = $4", - self.subscription_id.0, - self.affiliate_code.0, - self.deactivated_at, - self.id + SET deactivated_at = NOW() + WHERE subscription_id = $1", + subscription_id.0, ) .execute(exec) .await?; diff --git a/apps/labrinth/src/queue/payouts/affiliate.rs b/apps/labrinth/src/queue/payouts/affiliate.rs index 9bdf1b10e0..b0473e7490 100644 --- a/apps/labrinth/src/queue/payouts/affiliate.rs +++ b/apps/labrinth/src/queue/payouts/affiliate.rs @@ -48,10 +48,10 @@ pub async fn process_affiliate_payouts(postgres: &PgPool) -> Result<()> { INNER JOIN users_subscriptions_affiliations usa ON c.subscription_id = usa.subscription_id AND c.subscription_id IS NOT NULL + AND usa.deactivated_at IS NULL -- ...which have an affiliate code... INNER JOIN affiliate_codes ac ON usa.affiliate_code = ac.id - AND usa.deactivated_at IS NULL -- ...and where no payout to an affiliate has been made for this charge yet LEFT JOIN users_subscriptions_affiliations_payouts usap ON c.id = usap.charge_id diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index d417b6e1f1..2b7fff1e10 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -616,6 +616,11 @@ pub async fn edit_subscription( .. } if open_charge.status == ChargeStatus::Failed => { if cancelled { + DBUsersSubscriptionsAffiliations::deactivate( + subscription.id, + &mut *transaction, + ) + .await?; open_charge.status = ChargeStatus::Cancelled; } else { // Forces another resubscription attempt @@ -635,6 +640,11 @@ pub async fn edit_subscription( ) => { open_charge.status = if cancelled { + DBUsersSubscriptionsAffiliations::deactivate( + subscription.id, + &mut *transaction, + ) + .await?; ChargeStatus::Cancelled } else { ChargeStatus::Open @@ -2073,7 +2083,6 @@ pub async fn stripe_webhook( .and_then(|m| m.affiliate_code) { DBUsersSubscriptionsAffiliations { - id: 0, subscription_id: subscription.id, affiliate_code: DBAffiliateCodeId::from( affiliate_code, @@ -2083,8 +2092,6 @@ pub async fn stripe_webhook( .insert(&mut *transaction) .await?; } - - // TODO affiliate code }; subscription.status = SubscriptionStatus::Provisioned; From 4aafbd8fb2fa07faadced1b8ef71b924ac46473a Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 30 Oct 2025 19:17:22 +0000 Subject: [PATCH 4/5] Remove a test that never compiled in the first place --- apps/labrinth/tests/affiliate_payouts.rs | 1065 ---------------------- 1 file changed, 1065 deletions(-) delete mode 100644 apps/labrinth/tests/affiliate_payouts.rs diff --git a/apps/labrinth/tests/affiliate_payouts.rs b/apps/labrinth/tests/affiliate_payouts.rs deleted file mode 100644 index 6608f7a773..0000000000 --- a/apps/labrinth/tests/affiliate_payouts.rs +++ /dev/null @@ -1,1065 +0,0 @@ -use ariadne::ids::base62_impl::parse_base62; -use chrono::{DateTime, Duration, Utc}; -use chrono::{Datelike, TimeZone}; -use common::permissions::PermissionsTest; -use common::permissions::PermissionsTestContext; -use common::{ - api_v3::ApiV3, - database::*, - environment::{TestEnvironment, with_test_environment}, -}; -use itertools::Itertools; -use labrinth::database::models::charge_item::DBCharge; -use labrinth::database::models::{ - DBAffiliateCode, DBAffiliateCodeId, DBChargeId, DBProductPriceId, DBUserId, - DBUserSubscriptionId, -}; -use labrinth::models::billing::{ChargeStatus, ChargeType, PaymentPlatform}; -use labrinth::models::teams::ProjectPermissions; -use labrinth::queue::payouts::{self, process_affiliate_payouts}; -use rust_decimal::{Decimal, prelude::ToPrimitive}; -use std::collections::HashMap; - -pub mod common; - -#[actix_rt::test] -pub async fn affiliate_payout_basic() { - with_test_environment( - None, - |test_env: TestEnvironment| async move { - let pool = &test_env.db.pool; - - // Setup test data - let affiliate_user_id = DBUserId(USER_USER_ID_PARSED); - let customer_user_id = DBUserId(FRIEND_USER_ID_PARSED); - let affiliate_code_id = DBAffiliateCodeId(1001); - let subscription_id = DBUserSubscriptionId(2001); - - // Create affiliate code - let affiliate_code = DBAffiliateCode { - id: affiliate_code_id, - created_at: Utc::now(), - created_by: affiliate_user_id, - affiliate: affiliate_user_id, - source_name: "test_code".to_string(), - }; - affiliate_code.insert(pool).await.unwrap(); - - // Create subscription affiliation - let mut affiliation = DBUsersSubscriptionsAffiliations { - id: 0, - subscription_id, - affiliate_code: affiliate_code_id, - deactivated_at: None, - }; - affiliation.insert(pool).await.unwrap(); - - // Create a successful charge with net amount - let charge_id = DBChargeId(3001); - let charge = DBCharge { - id: charge_id, - user_id: customer_user_id, - price_id: DBProductPriceId(1001), - amount: 1000, // $10.00 - currency_code: "USD".to_string(), - status: ChargeStatus::Succeeded, - due: Utc::now(), - last_attempt: Some(Utc::now()), - type_: ChargeType::Subscription, - subscription_id: Some(subscription_id), - subscription_interval: None, - payment_platform: PaymentPlatform::Stripe, - payment_platform_id: Some("ch_test123".to_string()), - parent_charge_id: None, - tax_amount: 50, - tax_platform_id: None, - tax_last_updated: None, - tax_transaction_version: None, - tax_platform_accounting_time: None, - net: Some(1000), // $10.00 net - tax_drift_loss: None, - }; - - let mut txn = pool.begin().await.unwrap(); - charge.upsert(&mut txn).await.unwrap(); - txn.commit().await.unwrap(); - - // Process affiliate payouts - process_affiliate_payouts(pool).await.unwrap(); - - // Verify payout was created - let payout_records = sqlx::query!( - "SELECT user_id, amount, created, date_available, affiliate_code_source - FROM payouts_values WHERE affiliate_code_source IS NOT NULL" - ) - .fetch_all(pool) - .await - .unwrap(); - - assert_eq!(payout_records.len(), 1); - let payout = &payout_records[0]; - assert_eq!(payout.user_id, affiliate_user_id.0); - assert_eq!(payout.amount, Some(100)); // $10.00 * 0.1 = $1.00, but stored as cents - assert_eq!(payout.affiliate_code_source, Some(affiliate_code_id.0)); - - // Verify charge-payout association was created - let association = sqlx::query!( - "SELECT charge_id, subscription_id, affiliate_code, payout_value_id - FROM users_subscriptions_affiliations_payouts" - ) - .fetch_one(pool) - .await - .unwrap(); - - assert_eq!(association.charge_id, charge_id.0); - assert_eq!(association.subscription_id, subscription_id.0); - assert_eq!(association.affiliate_code, affiliate_code_id.0); - }, - ) - .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 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(), - ) - .await - }; - - PermissionsTest::new(&test_env) - .with_failure_codes(vec![200, 401]) - .with_200_json_checks( - // On failure, should have 0 projects returned - |value: &serde_json::Value| { - let value = value.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(); - assert_eq!(value.len(), 1); - }, - ) - .simple_project_permissions_test(view_analytics, req_gen) - .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; - }, - ) - .await; -} - -#[actix_rt::test] -pub async fn affiliate_payout_custom_revenue_split() { - with_test_environment( - None, - |test_env: TestEnvironment| async move { - let pool = &test_env.db.pool; - - // Setup test data with custom revenue split (25%) - let affiliate_user_id = DBUserId(USER_USER_ID_PARSED); - let customer_user_id = DBUserId(FRIEND_USER_ID_PARSED); - let affiliate_code_id = DBAffiliateCodeId(1002); - let subscription_id = DBUserSubscriptionId(2002); - - // Create affiliate code with custom revenue split - let affiliate_code = DBAffiliateCode { - id: affiliate_code_id, - created_at: Utc::now(), - created_by: affiliate_user_id, - affiliate: affiliate_user_id, - source_name: "test_code_custom".to_string(), - }; - affiliate_code.insert(pool).await.unwrap(); - - // Update the affiliate code to have a custom revenue split - sqlx::query!( - "UPDATE affiliate_codes SET revenue_split = $1 WHERE id = $2", - 0.25f64, - affiliate_code_id.0 - ) - .execute(pool) - .await - .unwrap(); - - // Create subscription affiliation - let mut affiliation = DBUsersSubscriptionsAffiliations { - id: 0, - subscription_id, - affiliate_code: affiliate_code_id, - deactivated_at: None, - }; - affiliation.insert(pool).await.unwrap(); - - // Create a successful charge - let charge_id = DBChargeId(3002); - let charge = DBCharge { - id: charge_id, - user_id: customer_user_id, - price_id: 1002, - amount: 2000, // $20.00 - currency_code: "USD".to_string(), - status: ChargeStatus::Succeeded, - due: Utc::now(), - last_attempt: Some(Utc::now()), - type_: ChargeType::Subscription, - subscription_id: Some(subscription_id), - subscription_interval: None, - payment_platform: PaymentPlatform::Stripe, - payment_platform_id: Some("ch_test456".to_string()), - parent_charge_id: None, - tax_amount: 100, - tax_platform_id: None, - tax_last_updated: None, - tax_transaction_version: None, - tax_platform_accounting_time: None, - net: Some(2000), // $20.00 net - tax_drift_loss: None, - }; - - let mut txn = pool.begin().await.unwrap(); - charge.upsert(&mut txn).await.unwrap(); - txn.commit().await.unwrap(); - - // Process affiliate payouts - process_affiliate_payouts(pool).await.unwrap(); - - // Verify payout with custom split - let payout = sqlx::query!( - "SELECT amount FROM payouts_values WHERE affiliate_code_source = $1", - affiliate_code_id.0 - ) - .fetch_one(pool) - .await - .unwrap(); - - assert_eq!(payout.amount, Decimal::from(500)); // $20.00 * 0.25 = $5.00, stored as cents - }, - ) - .await; -} - -#[actix_rt::test] -pub async fn affiliate_payout_multiple_charges_same_code() { - with_test_environment( - None, - |test_env: TestEnvironment| async move { - let pool = &test_env.db.pool; - - let affiliate_user_id = DBUserId(USER_USER_ID_PARSED); - let customer_user_id = DBUserId(FRIEND_USER_ID_PARSED); - let affiliate_code_id = DBAffiliateCodeId(1003); - let subscription_id = DBUserSubscriptionId(2003); - - // Setup affiliate code and affiliation - let affiliate_code = DBAffiliateCode { - id: affiliate_code_id, - created_at: Utc::now(), - created_by: affiliate_user_id, - affiliate: affiliate_user_id, - source_name: "test_multi".to_string(), - }; - affiliate_code.insert(pool).await.unwrap(); - - let mut affiliation = DBUsersSubscriptionsAffiliations { - id: 0, - subscription_id, - affiliate_code: affiliate_code_id, - deactivated_at: None, - }; - affiliation.insert(pool).await.unwrap(); - - // Create multiple charges for the same subscription - let charges = vec![ - (DBChargeId(3003), 1000), // $10.00 - (DBChargeId(3004), 1500), // $15.00 - (DBChargeId(3005), 2000), // $20.00 - ]; - - let mut txn = pool.begin().await.unwrap(); - for (charge_id, net_amount) in &charges { - let charge = DBCharge { - id: *charge_id, - user_id: customer_user_id, - price_id: 1003, - amount: *net_amount, - currency_code: "USD".to_string(), - status: ChargeStatus::Succeeded, - due: Utc::now(), - last_attempt: Some(Utc::now()), - type_: ChargeType::Subscription, - subscription_id: Some(subscription_id), - subscription_interval: None, - payment_platform: PaymentPlatform::Stripe, - payment_platform_id: Some(format!("ch_test{}", charge_id.0)), - parent_charge_id: None, - tax_amount: 50, - tax_platform_id: None, - tax_last_updated: None, - tax_transaction_version: None, - tax_platform_accounting_time: None, - net: Some(*net_amount), - tax_drift_loss: None, - }; - charge.upsert(&mut txn).await.unwrap(); - } - txn.commit().await.unwrap(); - - // Process affiliate payouts - process_affiliate_payouts(pool).await.unwrap(); - - // Should create a single payout aggregating all charges - let payout_records = sqlx::query!( - "SELECT user_id, amount FROM payouts_values WHERE affiliate_code_source = $1", - affiliate_code_id.0 - ) - .fetch_all(pool) - .await - .unwrap(); - - assert_eq!(payout_records.len(), 1); - let total_expected = Decimal::from(1000 + 1500 + 2000) * Decimal::from_f64_retain(0.1).unwrap() / Decimal::new(1, 0); - assert_eq!(payout_records[0].amount, Some(total_expected.to_i64().unwrap())); - - // Should create 3 charge-payout associations - let associations = sqlx::query!( - "SELECT COUNT(*) as count FROM users_subscriptions_affiliations_payouts WHERE affiliate_code = $1", - affiliate_code_id.0 - ) - .fetch_one(pool) - .await - .unwrap(); - - assert_eq!(associations.count.unwrap(), 3); - }, - ) - .await; -} - -#[actix_rt::test] -pub async fn affiliate_payout_deactivated_affiliation() { - with_test_environment( - None, - |test_env: TestEnvironment| async move { - let pool = &test_env.db.pool; - - let affiliate_user_id = DBUserId(USER_USER_ID_PARSED); - let customer_user_id = DBUserId(FRIEND_USER_ID_PARSED); - let affiliate_code_id = DBAffiliateCodeId(1004); - let subscription_id = DBUserSubscriptionId(2004); - - // Setup affiliate code - let affiliate_code = DBAffiliateCode { - id: affiliate_code_id, - created_at: Utc::now(), - created_by: affiliate_user_id, - affiliate: affiliate_user_id, - source_name: "test_deactivated".to_string(), - }; - affiliate_code.insert(pool).await.unwrap(); - - // Create DEACTIVATED subscription affiliation - let mut affiliation = DBUsersSubscriptionsAffiliations { - id: 0, - subscription_id, - affiliate_code: affiliate_code_id, - deactivated_at: Some(Utc::now() - Duration::days(1)), // Deactivated yesterday - }; - affiliation.insert(pool).await.unwrap(); - - // Create a successful charge - let charge_id = DBChargeId(3006); - let charge = DBCharge { - id: charge_id, - user_id: customer_user_id, - price_id: 1004, - amount: 1000, - currency_code: "USD".to_string(), - status: ChargeStatus::Succeeded, - due: Utc::now(), - last_attempt: Some(Utc::now()), - type_: ChargeType::Subscription, - subscription_id: Some(subscription_id), - subscription_interval: None, - payment_platform: PaymentPlatform::Stripe, - payment_platform_id: Some("ch_test789".to_string()), - parent_charge_id: None, - tax_amount: 50, - tax_platform_id: None, - tax_last_updated: None, - tax_transaction_version: None, - tax_platform_accounting_time: None, - net: Some(1000), - tax_drift_loss: None, - }; - - let mut txn = pool.begin().await.unwrap(); - charge.upsert(&mut txn).await.unwrap(); - txn.commit().await.unwrap(); - - // Process affiliate payouts - process_affiliate_payouts(pool).await.unwrap(); - - // Should NOT create any payouts for deactivated affiliations - let payout_records = sqlx::query!( - "SELECT COUNT(*) as count FROM payouts_values WHERE affiliate_code_source = $1", - affiliate_code_id.0 - ) - .fetch_one(pool) - .await - .unwrap(); - - assert_eq!(payout_records.count.unwrap(), 0); - }, - ) - .await; -} - -#[actix_rt::test] -pub async fn affiliate_payout_edge_cases() { - with_test_environment( - None, - |test_env: TestEnvironment| async move { - let pool = &test_env.db.pool; - - let affiliate_user_id = DBUserId(USER_USER_ID_PARSED); - let customer_user_id = DBUserId(FRIEND_USER_ID_PARSED); - let affiliate_code_id = DBAffiliateCodeId(1005); - let subscription_id = DBUserSubscriptionId(2005); - - // Setup affiliate code - let affiliate_code = DBAffiliateCode { - id: affiliate_code_id, - created_at: Utc::now(), - created_by: affiliate_user_id, - affiliate: affiliate_user_id, - source_name: "test_edge_cases".to_string(), - }; - affiliate_code.insert(pool).await.unwrap(); - - // Set an invalid revenue split (out of range) - sqlx::query!( - "UPDATE affiliate_codes SET revenue_split = $1 WHERE id = $2", - 1.5f64, // Invalid: > 1.0 - affiliate_code_id.0 - ) - .execute(pool) - .await - .unwrap(); - - let mut affiliation = DBUsersSubscriptionsAffiliations { - id: 0, - subscription_id, - affiliate_code: affiliate_code_id, - deactivated_at: None, - }; - affiliation.insert(pool).await.unwrap(); - - // Create charges with edge case scenarios - let charges = vec![ - (DBChargeId(3007), Some(1000), ChargeStatus::Succeeded), // Normal case - (DBChargeId(3008), Some(0), ChargeStatus::Succeeded), // Zero net - (DBChargeId(3009), Some(1000), ChargeStatus::Failed), // Failed charge - (DBChargeId(3010), None, ChargeStatus::Succeeded), // No net amount - ]; - - let mut txn = pool.begin().await.unwrap(); - for (charge_id, net_amount, status) in charges { - let charge = DBCharge { - id: charge_id, - user_id: customer_user_id, - price_id: 1005, - amount: net_amount.unwrap_or(0), - currency_code: "USD".to_string(), - status, - due: Utc::now(), - last_attempt: Some(Utc::now()), - type_: ChargeType::Subscription, - subscription_id: Some(subscription_id), - subscription_interval: None, - payment_platform: PaymentPlatform::Stripe, - payment_platform_id: Some(format!("ch_test{}", charge_id.0)), - parent_charge_id: None, - tax_amount: 0, - tax_platform_id: None, - tax_last_updated: None, - tax_transaction_version: None, - tax_platform_accounting_time: None, - net: net_amount, - tax_drift_loss: None, - }; - charge.upsert(&mut txn).await.unwrap(); - } - txn.commit().await.unwrap(); - - // Process affiliate payouts - process_affiliate_payouts(pool).await.unwrap(); - - // Should only create payout for the valid charge (3007) - // But since the revenue split is invalid, even that should be skipped - let payout_records = sqlx::query!( - "SELECT COUNT(*) as count FROM payouts_values WHERE affiliate_code_source = $1", - affiliate_code_id.0 - ) - .fetch_one(pool) - .await - .unwrap(); - - assert_eq!(payout_records.count.unwrap(), 0); - }, - ) - .await; -} - -#[actix_rt::test] -pub async fn affiliate_payout_idempotent() { - with_test_environment( - None, - |test_env: TestEnvironment| async move { - let pool = &test_env.db.pool; - - let affiliate_user_id = DBUserId(USER_USER_ID_PARSED); - let customer_user_id = DBUserId(FRIEND_USER_ID_PARSED); - let affiliate_code_id = DBAffiliateCodeId(1006); - let subscription_id = DBUserSubscriptionId(2006); - - // Setup affiliate code and affiliation - let affiliate_code = DBAffiliateCode { - id: affiliate_code_id, - created_at: Utc::now(), - created_by: affiliate_user_id, - affiliate: affiliate_user_id, - source_name: "test_idempotent".to_string(), - }; - affiliate_code.insert(pool).await.unwrap(); - - let mut affiliation = DBUsersSubscriptionsAffiliations { - id: 0, - subscription_id, - affiliate_code: affiliate_code_id, - deactivated_at: None, - }; - affiliation.insert(pool).await.unwrap(); - - // Create a successful charge - let charge_id = DBChargeId(3011); - let charge = DBCharge { - id: charge_id, - user_id: customer_user_id, - price_id: 1006, - amount: 1000, - currency_code: "USD".to_string(), - status: ChargeStatus::Succeeded, - due: Utc::now(), - last_attempt: Some(Utc::now()), - type_: ChargeType::Subscription, - subscription_id: Some(subscription_id), - subscription_interval: None, - payment_platform: PaymentPlatform::Stripe, - payment_platform_id: Some("ch_test_idempotent".to_string()), - parent_charge_id: None, - tax_amount: 50, - tax_platform_id: None, - tax_last_updated: None, - tax_transaction_version: None, - tax_platform_accounting_time: None, - net: Some(1000), - tax_drift_loss: None, - }; - - let mut txn = pool.begin().await.unwrap(); - charge.upsert(&mut txn).await.unwrap(); - txn.commit().await.unwrap(); - - // Process affiliate payouts first time - process_affiliate_payouts(pool).await.unwrap(); - - let first_payout_count = sqlx::query!( - "SELECT COUNT(*) as count FROM payouts_values WHERE affiliate_code_source = $1", - affiliate_code_id.0 - ) - .fetch_one(pool) - .await - .unwrap() - .count - .unwrap(); - - let first_association_count = sqlx::query!( - "SELECT COUNT(*) as count FROM users_subscriptions_affiliations_payouts WHERE charge_id = $1", - charge_id.0 - ) - .fetch_one(pool) - .await - .unwrap() - .count - .unwrap(); - - assert_eq!(first_payout_count, 1); - assert_eq!(first_association_count, 1); - - // Process affiliate payouts second time (should be idempotent) - process_affiliate_payouts(pool).await.unwrap(); - - let second_payout_count = sqlx::query!( - "SELECT COUNT(*) as count FROM payouts_values WHERE affiliate_code_source = $1", - affiliate_code_id.0 - ) - .fetch_one(pool) - .await - .unwrap() - .count - .unwrap(); - - let second_association_count = sqlx::query!( - "SELECT COUNT(*) as count FROM users_subscriptions_affiliations_payouts WHERE charge_id = $1", - charge_id.0 - ) - .fetch_one(pool) - .await - .unwrap() - .count - .unwrap(); - - // Should not create duplicate payouts - assert_eq!(second_payout_count, 1); - assert_eq!(second_association_count, 1); - }, - ) - .await; -} - -#[actix_rt::test] -pub async fn affiliate_payout_availability_date() { - with_test_environment( - None, - |test_env: TestEnvironment| async move { - let pool = &test_env.db.pool; - - let affiliate_user_id = DBUserId(USER_USER_ID_PARSED); - let customer_user_id = DBUserId(FRIEND_USER_ID_PARSED); - let affiliate_code_id = DBAffiliateCodeId(1007); - let subscription_id = DBUserSubscriptionId(2007); - - // Setup affiliate code and affiliation - let affiliate_code = DBAffiliateCode { - id: affiliate_code_id, - created_at: Utc::now(), - created_by: affiliate_user_id, - affiliate: affiliate_user_id, - source_name: "test_availability".to_string(), - }; - affiliate_code.insert(pool).await.unwrap(); - - let mut affiliation = DBUsersSubscriptionsAffiliations { - id: 0, - subscription_id, - affiliate_code: affiliate_code_id, - deactivated_at: None, - }; - affiliation.insert(pool).await.unwrap(); - - // Create a successful charge - let charge_id = DBChargeId(3012); - let charge = DBCharge { - id: charge_id, - user_id: customer_user_id, - price_id: 1007, - amount: 1000, - currency_code: "USD".to_string(), - status: ChargeStatus::Succeeded, - due: Utc::now(), - last_attempt: Some(Utc::now()), - type_: ChargeType::Subscription, - subscription_id: Some(subscription_id), - subscription_interval: None, - payment_platform: PaymentPlatform::Stripe, - payment_platform_id: Some("ch_test_availability".to_string()), - parent_charge_id: None, - tax_amount: 50, - tax_platform_id: None, - tax_last_updated: None, - tax_transaction_version: None, - tax_platform_accounting_time: None, - net: Some(1000), - tax_drift_loss: None, - }; - - let mut txn = pool.begin().await.unwrap(); - charge.upsert(&mut txn).await.unwrap(); - txn.commit().await.unwrap(); - - // Process affiliate payouts - process_affiliate_payouts(pool).await.unwrap(); - - // Verify availability date is set correctly (Net 60) - let payout = sqlx::query!( - "SELECT created, date_available FROM payouts_values WHERE affiliate_code_source = $1", - affiliate_code_id.0 - ) - .fetch_one(pool) - .await - .unwrap(); - - let now = Utc::now(); - let expected_available = { - let year = now.year(); - let month = now.month(); - - // First day of next month + 59 days (Net 60) - let first_day_next_month = if month == 12 { - Utc.with_ymd_and_hms(year + 1, 1, 1, 0, 0, 0).unwrap() - } else { - Utc.with_ymd_and_hms(year, month + 1, 1, 0, 0, 0).unwrap() - }; - - first_day_next_month + Duration::days(59) - }; - - // Check that availability date is approximately correct (within 1 minute) - let availability_diff = (payout.date_available - expected_available).abs(); - assert!(availability_diff.num_minutes() < 1); - - // Check that created date is yesterday (start of the day) - let expected_created = (now - Duration::days(1)).date_naive().and_hms_opt(0, 0, 0).unwrap(); - let actual_created = payout.created.naive_utc(); - let created_diff = (actual_created - expected_created).abs(); - assert!(created_diff.num_seconds() < 1); - }, - ) - .await; -} - -#[actix_rt::test] -pub async fn affiliate_payout_multiple_codes_same_affiliate() { - with_test_environment( - None, - |test_env: TestEnvironment| async move { - let pool = &test_env.db.pool; - - let affiliate_user_id = DBUserId(USER_USER_ID_PARSED); - let customer_user_id = DBUserId(FRIEND_USER_ID_PARSED); - let affiliate_code_id_1 = DBAffiliateCodeId(1008); - let affiliate_code_id_2 = DBAffiliateCodeId(1009); - let subscription_id_1 = DBUserSubscriptionId(2008); - let subscription_id_2 = DBUserSubscriptionId(2009); - - // Create two affiliate codes for the same affiliate user - let affiliate_code_1 = DBAffiliateCode { - id: affiliate_code_id_1, - created_at: Utc::now(), - created_by: affiliate_user_id, - affiliate: affiliate_user_id, - source_name: "test_code_1".to_string(), - }; - affiliate_code_1.insert(pool).await.unwrap(); - - let affiliate_code_2 = DBAffiliateCode { - id: affiliate_code_id_2, - created_at: Utc::now(), - created_by: affiliate_user_id, - affiliate: affiliate_user_id, - source_name: "test_code_2".to_string(), - }; - affiliate_code_2.insert(pool).await.unwrap(); - - // Create affiliations for both codes - let mut affiliation_1 = DBUsersSubscriptionsAffiliations { - id: 0, - subscription_id: subscription_id_1, - affiliate_code: affiliate_code_id_1, - deactivated_at: None, - }; - affiliation_1.insert(pool).await.unwrap(); - - let mut affiliation_2 = DBUsersSubscriptionsAffiliations { - id: 0, - subscription_id: subscription_id_2, - affiliate_code: affiliate_code_id_2, - deactivated_at: None, - }; - affiliation_2.insert(pool).await.unwrap(); - - // Create charges for both subscriptions - let charge_1_id = DBChargeId(3013); - let charge_1 = DBCharge { - id: charge_1_id, - user_id: customer_user_id, - price_id: 1008, - amount: 1000, // $10.00 - currency_code: "USD".to_string(), - status: ChargeStatus::Succeeded, - due: Utc::now(), - last_attempt: Some(Utc::now()), - type_: ChargeType::Subscription, - subscription_id: Some(subscription_id_1), - subscription_interval: None, - payment_platform: PaymentPlatform::Stripe, - payment_platform_id: Some("ch_test_multi1".to_string()), - parent_charge_id: None, - tax_amount: 50, - tax_platform_id: None, - tax_last_updated: None, - tax_transaction_version: None, - tax_platform_accounting_time: None, - net: Some(1000), - tax_drift_loss: None, - }; - - let charge_2_id = DBChargeId(3014); - let charge_2 = DBCharge { - id: charge_2_id, - user_id: customer_user_id, - price_id: 1009, - amount: 2000, // $20.00 - currency_code: "USD".to_string(), - status: ChargeStatus::Succeeded, - due: Utc::now(), - last_attempt: Some(Utc::now()), - type_: ChargeType::Subscription, - subscription_id: Some(subscription_id_2), - subscription_interval: None, - payment_platform: PaymentPlatform::Stripe, - payment_platform_id: Some("ch_test_multi2".to_string()), - parent_charge_id: None, - tax_amount: 100, - tax_platform_id: None, - tax_last_updated: None, - tax_transaction_version: None, - tax_platform_accounting_time: None, - net: Some(2000), - tax_drift_loss: None, - }; - - let mut txn = pool.begin().await.unwrap(); - charge_1.upsert(&mut txn).await.unwrap(); - charge_2.upsert(&mut txn).await.unwrap(); - txn.commit().await.unwrap(); - - // Process affiliate payouts - process_affiliate_payouts(pool).await.unwrap(); - - // Should create separate payouts for each code - let payout_records = sqlx::query!( - "SELECT affiliate_code_source, amount FROM payouts_values WHERE user_id = $1 ORDER BY affiliate_code_source", - affiliate_user_id.0 - ) - .fetch_all(pool) - .await - .unwrap(); - - assert_eq!(payout_records.len(), 2); - - // Verify amounts for each code - let payouts_by_code: HashMap = payout_records - .into_iter() - .map(|r| (r.affiliate_code_source.unwrap(), r.amount)) - .collect(); - - assert_eq!(payouts_by_code.get(&1008).unwrap().unwrap(), 100); // $10.00 * 0.1 = $1.00 - assert_eq!(payouts_by_code.get(&1009).unwrap().unwrap(), 200); // $20.00 * 0.1 = $2.00 - }, - ) - .await; -} - -#[actix_rt::test] -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(); - - let pool = test_env.db.pool.clone(); - - // Generate sample revenue data- directly insert into sql - let ( - mut insert_user_ids, - mut insert_project_ids, - mut insert_payouts, - mut insert_starts, - mut insert_availables, - ) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); - - // Note: these go from most recent to least recent - let money_time_pairs: [(f64, DateTime); 10] = [ - (50.0, Utc::now() - Duration::minutes(5)), - (50.1, Utc::now() - Duration::minutes(10)), - (101.0, Utc::now() - Duration::days(1)), - (200.0, Utc::now() - Duration::days(2)), - (311.0, Utc::now() - Duration::days(3)), - (400.0, Utc::now() - Duration::days(4)), - (526.0, Utc::now() - Duration::days(5)), - (633.0, Utc::now() - Duration::days(6)), - (800.0, Utc::now() - Duration::days(14)), - (800.0, Utc::now() - Duration::days(800)), - ]; - - let project_id = parse_base62(&alpha_project_id).unwrap() as i64; - for (money, time) in &money_time_pairs { - insert_user_ids.push(USER_USER_ID_PARSED); - insert_project_ids.push(project_id); - insert_payouts.push(Decimal::from_f64_retain(*money).unwrap()); - insert_starts.push(*time); - insert_availables.push(*time); - } - - let mut transaction = pool.begin().await.unwrap(); - payouts::insert_payouts( - insert_user_ids, - insert_project_ids, - insert_payouts, - insert_starts, - insert_availables, - &mut transaction, - ) - .await - .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, - ) - .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); - } - - // 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); - } - }, - ) - .await; -} From e38e8de843288bf4827015f02b365b2bf9fbc9b3 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Fri, 31 Oct 2025 10:24:39 +0000 Subject: [PATCH 5/5] Update sqlx cache --- ...b8e3b466c7d6fd7a6efd3984a3cbc87f996bc.json | 26 +++++++++ ...c24a660eb1c3b625e90c68bd64d8a7519adff.json | 56 +++++++++++++++++++ ...6f56a5e220c21aa9d0fc0c03f57bee864fb63.json | 16 ++++++ ...5e7ddaf51f952783f68eda706505a825f25a5.json | 25 +++++++++ ...c4cac65a3b85be430690f92170a45f5d73d8c.json | 17 ++++++ ...9c3b8116b282e0edb2d3c71b8a98e6353ce82.json | 14 +++++ 6 files changed, 154 insertions(+) create mode 100644 apps/labrinth/.sqlx/query-08310363d63462bf1d07f950f09b8e3b466c7d6fd7a6efd3984a3cbc87f996bc.json create mode 100644 apps/labrinth/.sqlx/query-1188e75b9d7da4d49188aa791acc24a660eb1c3b625e90c68bd64d8a7519adff.json create mode 100644 apps/labrinth/.sqlx/query-64844433bb6c7e5a48890ec42786f56a5e220c21aa9d0fc0c03f57bee864fb63.json create mode 100644 apps/labrinth/.sqlx/query-82a8120805e27f9134ccaa02ea25e7ddaf51f952783f68eda706505a825f25a5.json create mode 100644 apps/labrinth/.sqlx/query-88729318c63f197e6043e85313ec4cac65a3b85be430690f92170a45f5d73d8c.json create mode 100644 apps/labrinth/.sqlx/query-abdda73294ec06970af162132e49c3b8116b282e0edb2d3c71b8a98e6353ce82.json diff --git a/apps/labrinth/.sqlx/query-08310363d63462bf1d07f950f09b8e3b466c7d6fd7a6efd3984a3cbc87f996bc.json b/apps/labrinth/.sqlx/query-08310363d63462bf1d07f950f09b8e3b466c7d6fd7a6efd3984a3cbc87f996bc.json new file mode 100644 index 0000000000..4fa58d0230 --- /dev/null +++ b/apps/labrinth/.sqlx/query-08310363d63462bf1d07f950f09b8e3b466c7d6fd7a6efd3984a3cbc87f996bc.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO payouts_values\n (user_id, amount, created,\n date_available, affiliate_code_source)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Numeric", + "Timestamptz", + "Timestamptz", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "08310363d63462bf1d07f950f09b8e3b466c7d6fd7a6efd3984a3cbc87f996bc" +} diff --git a/apps/labrinth/.sqlx/query-1188e75b9d7da4d49188aa791acc24a660eb1c3b625e90c68bd64d8a7519adff.json b/apps/labrinth/.sqlx/query-1188e75b9d7da4d49188aa791acc24a660eb1c3b625e90c68bd64d8a7519adff.json new file mode 100644 index 0000000000..908750d055 --- /dev/null +++ b/apps/labrinth/.sqlx/query-1188e75b9d7da4d49188aa791acc24a660eb1c3b625e90c68bd64d8a7519adff.json @@ -0,0 +1,56 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n c.id as charge_id,\n c.subscription_id AS \"subscription_id!\",\n c.net as charge_net,\n c.currency_code,\n usa.affiliate_code,\n ac.affiliate as affiliate_user_id,\n ac.revenue_split\n -- get any charges...\n FROM charges c\n -- ...which have a subscription...\n INNER JOIN users_subscriptions_affiliations usa\n ON c.subscription_id = usa.subscription_id\n AND c.subscription_id IS NOT NULL\n AND usa.deactivated_at IS NULL\n -- ...which have an affiliate code...\n INNER JOIN affiliate_codes ac\n ON usa.affiliate_code = ac.id\n -- ...and where no payout to an affiliate has been made for this charge yet\n LEFT JOIN users_subscriptions_affiliations_payouts usap\n ON c.id = usap.charge_id\n WHERE\n c.status = 'succeeded'\n AND c.net > 0\n AND usap.id IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "charge_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "subscription_id!", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "charge_net", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "currency_code", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "affiliate_code", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "affiliate_user_id", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "revenue_split", + "type_info": "Float8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + true, + true, + false, + false, + false, + true + ] + }, + "hash": "1188e75b9d7da4d49188aa791acc24a660eb1c3b625e90c68bd64d8a7519adff" +} diff --git a/apps/labrinth/.sqlx/query-64844433bb6c7e5a48890ec42786f56a5e220c21aa9d0fc0c03f57bee864fb63.json b/apps/labrinth/.sqlx/query-64844433bb6c7e5a48890ec42786f56a5e220c21aa9d0fc0c03f57bee864fb63.json new file mode 100644 index 0000000000..172159dc93 --- /dev/null +++ b/apps/labrinth/.sqlx/query-64844433bb6c7e5a48890ec42786f56a5e220c21aa9d0fc0c03f57bee864fb63.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users_subscriptions_affiliations\n (subscription_id, affiliate_code, deactivated_at)\n VALUES ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "64844433bb6c7e5a48890ec42786f56a5e220c21aa9d0fc0c03f57bee864fb63" +} diff --git a/apps/labrinth/.sqlx/query-82a8120805e27f9134ccaa02ea25e7ddaf51f952783f68eda706505a825f25a5.json b/apps/labrinth/.sqlx/query-82a8120805e27f9134ccaa02ea25e7ddaf51f952783f68eda706505a825f25a5.json new file mode 100644 index 0000000000..6ba94641a1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-82a8120805e27f9134ccaa02ea25e7ddaf51f952783f68eda706505a825f25a5.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users_subscriptions_affiliations_payouts\n (charge_id, subscription_id, affiliate_code, payout_value_id)\n VALUES ($1, $2, $3, $4)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "82a8120805e27f9134ccaa02ea25e7ddaf51f952783f68eda706505a825f25a5" +} diff --git a/apps/labrinth/.sqlx/query-88729318c63f197e6043e85313ec4cac65a3b85be430690f92170a45f5d73d8c.json b/apps/labrinth/.sqlx/query-88729318c63f197e6043e85313ec4cac65a3b85be430690f92170a45f5d73d8c.json new file mode 100644 index 0000000000..63d6735b9c --- /dev/null +++ b/apps/labrinth/.sqlx/query-88729318c63f197e6043e85313ec4cac65a3b85be430690f92170a45f5d73d8c.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users_subscriptions_affiliations_payouts\n (charge_id, subscription_id,\n affiliate_code, payout_value_id)\n SELECT * FROM UNNEST($1::bigint[], $2::bigint[], $3::bigint[], $4::bigint[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "Int8Array", + "Int8Array", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "88729318c63f197e6043e85313ec4cac65a3b85be430690f92170a45f5d73d8c" +} diff --git a/apps/labrinth/.sqlx/query-abdda73294ec06970af162132e49c3b8116b282e0edb2d3c71b8a98e6353ce82.json b/apps/labrinth/.sqlx/query-abdda73294ec06970af162132e49c3b8116b282e0edb2d3c71b8a98e6353ce82.json new file mode 100644 index 0000000000..c0de4c972b --- /dev/null +++ b/apps/labrinth/.sqlx/query-abdda73294ec06970af162132e49c3b8116b282e0edb2d3c71b8a98e6353ce82.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users_subscriptions_affiliations\n SET deactivated_at = NOW()\n WHERE subscription_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "abdda73294ec06970af162132e49c3b8116b282e0edb2d3c71b8a98e6353ce82" +}