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/.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" +} diff --git a/apps/labrinth/migrations/20251024182919_subscription_affiliations.sql b/apps/labrinth/migrations/20251024182919_subscription_affiliations.sql new file mode 100644 index 0000000000..e8edc64694 --- /dev/null +++ b/apps/labrinth/migrations/20251024182919_subscription_affiliations.sql @@ -0,0 +1,19 @@ +CREATE TABLE users_subscriptions_affiliations ( + 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, + UNIQUE (subscription_id) +); + +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) +); + +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/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..dccd793023 --- /dev/null +++ b/apps/labrinth/src/database/models/users_subscriptions_affiliations.rs @@ -0,0 +1,86 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::database::models::{ + DBAffiliateCodeId, DBChargeId, DBUserSubscriptionId, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DBUsersSubscriptionsAffiliations { + pub subscription_id: DBUserSubscriptionId, + pub affiliate_code: DBAffiliateCodeId, + pub deactivated_at: Option>, +} + +impl DBUsersSubscriptionsAffiliations { + pub async fn insert<'a, E>(&self, exec: E) -> sqlx::Result<()> + where + E: sqlx::PgExecutor<'a>, + { + sqlx::query_scalar!( + " + INSERT INTO users_subscriptions_affiliations + (subscription_id, affiliate_code, deactivated_at) + VALUES ($1, $2, $3) + ", + self.subscription_id.0, + self.affiliate_code.0, + self.deactivated_at, + ) + .fetch_one(exec) + .await?; + Ok(()) + } + + 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 deactivated_at = NOW() + WHERE subscription_id = $1", + subscription_id.0, + ) + .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/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..b0473e7490 --- /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 + AND usa.deactivated_at IS NULL + -- ...which have an affiliate code... + INNER JOIN affiliate_codes ac + ON usa.affiliate_code = ac.id + -- ...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/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index 6e6c180ec4..2b7fff1e10 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; @@ -613,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 @@ -632,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 @@ -1328,7 +1341,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 +1887,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 +2076,22 @@ 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 { + subscription_id: subscription.id, + affiliate_code: DBAffiliateCodeId::from( + affiliate_code, + ), + deactivated_at: None, + } + .insert(&mut *transaction) + .await?; + } }; subscription.status = SubscriptionStatus::Provisioned;