From 360d24f2e0da557a30067b794cbfeb62963a8a73 Mon Sep 17 00:00:00 2001 From: fetch Date: Wed, 6 Aug 2025 03:31:39 -0400 Subject: [PATCH] Create server on redeem --- .../src/database/models/product_item.rs | 169 +++++++++++++----- apps/labrinth/src/models/v3/billing.rs | 10 ++ apps/labrinth/src/routes/internal/billing.rs | 11 +- apps/labrinth/src/routes/internal/medal.rs | 168 +++++++++++++---- 4 files changed, 276 insertions(+), 82 deletions(-) diff --git a/apps/labrinth/src/database/models/product_item.rs b/apps/labrinth/src/database/models/product_item.rs index d1fe68b38..02f05deb1 100644 --- a/apps/labrinth/src/database/models/product_item.rs +++ b/apps/labrinth/src/database/models/product_item.rs @@ -57,6 +57,26 @@ impl DBProduct { Ok(Self::get_many(&[id], exec).await?.into_iter().next()) } + pub async fn get_by_type<'a, E>( + exec: E, + r#type: &str, + ) -> Result, DatabaseError> + where + E: sqlx::PgExecutor<'a>, + { + let maybe_row = select_products_with_predicate!( + "WHERE metadata ->> 'type' = $1", + r#type + ) + .fetch_all(exec) + .await?; + + maybe_row + .into_iter() + .map(|r| r.try_into().map_err(Into::into)) + .collect() + } + pub async fn get_many( ids: &[DBProductId], exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, @@ -104,7 +124,7 @@ impl QueryProductWithPrices { pub async fn list_purchaseable<'a, E>( exec: E, redis: &RedisPool, - ) -> Result, DatabaseError> + ) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, { @@ -154,6 +174,45 @@ impl QueryProductWithPrices { Ok(products) } + + pub async fn list_by_product_type<'a, E>( + exec: E, + r#type: &str, + ) -> Result, DatabaseError> + where + E: sqlx::PgExecutor<'a> + Copy, + { + let all_products = DBProduct::get_by_type(exec, r#type).await?; + let prices = DBProductPrice::get_all_products_prices( + &all_products.iter().map(|x| x.id).collect::>(), + exec, + ) + .await?; + + let products = all_products + .into_iter() + .filter_map(|x| { + Some(QueryProductWithPrices { + id: x.id, + metadata: x.metadata, + prices: prices + .remove(&x.id) + .map(|x| x.1)? + .into_iter() + .map(|x| DBProductPrice { + id: x.id, + product_id: x.product_id, + prices: x.prices, + currency_code: x.currency_code, + }) + .collect(), + unitary: x.unitary, + }) + }) + .collect::>(); + + Ok(products) + } } #[derive(Deserialize, Serialize)] @@ -172,7 +231,11 @@ struct ProductPriceQueryResult { } macro_rules! select_prices_with_predicate { - ($predicate:tt, $param:ident) => { + ($predicate:tt, $param1:ident) => { + select_prices_with_predicate!($predicate, $param1, ) + }; + + ($predicate:tt, $($param:ident,)+) => { sqlx::query_as!( ProductPriceQueryResult, r#" @@ -180,7 +243,7 @@ macro_rules! select_prices_with_predicate { FROM products_prices "# + $predicate, - $param + $($param),+ ) }; } @@ -240,58 +303,68 @@ impl DBProductPrice { pub async fn get_all_public_products_prices( product_ids: &[DBProductId], exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result>, DatabaseError> { + Self::get_all_products_prices_with_visibility( + product_ids, + Some(true), + exec, + ) + .await + } + + pub async fn get_all_products_prices( + product_ids: &[DBProductId], + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result>, DatabaseError> { + Self::get_all_products_prices_with_visibility(product_ids, None, exec) + .await + } + + async fn get_all_products_prices_with_visibility( + product_ids: &[DBProductId], + public_filter: Option, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, ) -> Result>, DatabaseError> { let ids = product_ids.iter().map(|id| id.0).collect_vec(); let ids_ref: &[i64] = &ids; use futures_util::TryStreamExt; - let prices = select_prices_with_predicate!( - "WHERE product_id = ANY($1::bigint[]) - AND public = true", - ids_ref - ) - .fetch(exec) - .try_fold( - DashMap::new(), - |acc: DashMap>, x| { - if let Ok(item) = >::try_into(x) - { - acc.entry(item.product_id).or_default().push(item); - } - async move { Ok(acc) } - }, - ) - .await?; + let predicate = |acc: DashMap>, x| { + if let Ok(item) = >::try_into(x) + { + acc.entry(item.product_id).or_default().push(item); + } + + async move { Ok(acc) } + }; + + let prices = match public_filter { + None => { + select_prices_with_predicate!( + "WHERE product_id = ANY($1::bigint[])", + ids_ref, + ) + .fetch(exec) + .try_fold(DashMap::new(), predicate) + .await? + } + + Some(public) => { + select_prices_with_predicate!( + "WHERE product_id = ANY($1::bigint[]) + AND public = $2", + ids_ref, + public, + ) + .fetch(exec) + .try_fold(DashMap::new(), predicate) + .await? + } + }; Ok(prices) } } - -/// Queries a single price ID for a product by type. -pub async fn unique_price_id_of_product_by_type<'a, E>( - exec: E, - r#type: &str, -) -> Result, DatabaseError> -where - E: sqlx::PgExecutor<'a>, -{ - let maybe_row = sqlx::query!( - " - SELECT - products_prices.id - FROM - products_prices - INNER JOIN - products ON products.metadata ->> 'type' = $1 - LIMIT 1 - ", - r#type, - ) - .fetch_optional(exec) - .await?; - - Ok(maybe_row.map(|x| DBProductPriceId(x.id))) -} diff --git a/apps/labrinth/src/models/v3/billing.rs b/apps/labrinth/src/models/v3/billing.rs index d5f786f73..2250f1d30 100644 --- a/apps/labrinth/src/models/v3/billing.rs +++ b/apps/labrinth/src/models/v3/billing.rs @@ -29,6 +29,7 @@ pub enum ProductMetadata { ram: u32, swap: u32, storage: u32, + region: String, }, } @@ -219,6 +220,10 @@ pub enum ChargeStatus { Succeeded, Failed, Cancelled, + // Expiring charges are charges that aren't expected to be processed + // but can be promoted to a full charge, like for trials/freebies. When + // due, the underlying subscription is unprovisioned. + Expiring, } impl ChargeStatus { @@ -229,6 +234,7 @@ impl ChargeStatus { "failed" => ChargeStatus::Failed, "open" => ChargeStatus::Open, "cancelled" => ChargeStatus::Cancelled, + "expiring" => ChargeStatus::Expiring, _ => ChargeStatus::Failed, } } @@ -240,6 +246,7 @@ impl ChargeStatus { ChargeStatus::Failed => "failed", ChargeStatus::Open => "open", ChargeStatus::Cancelled => "cancelled", + ChargeStatus::Expiring => "expiring", } } } @@ -247,12 +254,14 @@ impl ChargeStatus { #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub enum PaymentPlatform { Stripe, + None, } impl PaymentPlatform { pub fn from_string(string: &str) -> PaymentPlatform { match string { "stripe" => PaymentPlatform::Stripe, + "none" => PaymentPlatform::None, _ => PaymentPlatform::Stripe, } } @@ -260,6 +269,7 @@ impl PaymentPlatform { pub fn as_str(&self) -> &'static str { match self { PaymentPlatform::Stripe => "stripe", + PaymentPlatform::None => "none", } } } diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index c20a8ed85..e9a93ed8f 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -184,7 +184,9 @@ pub async fn refund_charge( ChargeStatus::Open | ChargeStatus::Processing | ChargeStatus::Succeeded => Some(x.amount), - ChargeStatus::Failed | ChargeStatus::Cancelled => None, + ChargeStatus::Failed + | ChargeStatus::Cancelled + | ChargeStatus::Expiring => None, }) .sum::(); @@ -258,6 +260,12 @@ pub async fn refund_charge( )); } } + PaymentPlatform::None => { + return Err(ApiError::InvalidInput( + "This charge was not processed via a payment platform." + .to_owned(), + )); + } } }; @@ -1714,6 +1722,7 @@ pub async fn stripe_webhook( ram: _, swap: _, storage: _, + region: _, } => { todo!( "Promote Medal subscription to Pyro subscription" diff --git a/apps/labrinth/src/routes/internal/medal.rs b/apps/labrinth/src/routes/internal/medal.rs index 26e7f20a9..c8adc28ba 100644 --- a/apps/labrinth/src/routes/internal/medal.rs +++ b/apps/labrinth/src/routes/internal/medal.rs @@ -1,16 +1,23 @@ use actix_web::{HttpResponse, post, web}; use ariadne::ids::UserId; +use ariadne::ids::base62_impl::to_base62; use chrono::Utc; use serde::{Deserialize, Serialize}; use sqlx::PgPool; -use crate::database::models::generate_user_subscription_id; +use crate::database::models::charge_item::DBCharge; use crate::database::models::product_item; use crate::database::models::user_subscription_item::DBUserSubscription; use crate::database::models::users_redeemals::{ Offer, RedeemalLookupFields, Status, UserRedeemal, }; -use crate::models::v3::billing::{PriceDuration, SubscriptionStatus}; +use crate::database::models::{ + generate_charge_id, generate_user_subscription_id, +}; +use crate::models::v3::billing::{ + ChargeStatus, ChargeType, PaymentPlatform, Price, PriceDuration, + ProductMetadata, SubscriptionMetadata, SubscriptionStatus, +}; use crate::routes::ApiError; use crate::util::guards::medal_key_guard; @@ -58,17 +65,15 @@ pub async fn redeem( ) -> Result { // Check the offer hasn't been redeemed yet, then insert into the table. - let mut txn = pool.begin().await?; - let maybe_fields = RedeemalLookupFields::redeemal_status_by_username_and_offer( - &mut *txn, + &**pool, &username, Offer::Medal, ) .await?; - let redeemal = match maybe_fields { + let user_id = match maybe_fields { None => return Err(ApiError::NotFound), Some(fields) => { if fields.redeemal_status.is_some() { @@ -77,51 +82,148 @@ pub async fn redeem( )); } - let mut redeemal = UserRedeemal { - id: 0, - user_id: fields.user_id, - offer: Offer::Medal, - redeemed: Utc::now(), - status: Status::Pending, - }; - - redeemal.insert(&mut *txn).await?; - redeemal + fields.user_id } }; - txn.commit().await?; - - // TODO: Provision server (send archon request) THEN add subscription to DB - - let mut txn = pool.begin().await?; + let client = reqwest::Client::new(); // Find the Medal product price - let maybe_price_id = - product_item::unique_price_id_of_product_by_type(&mut *txn, "medal") - .await?; + let mut medal_products = + product_item::QueryProductWithPrices::list_by_product_type( + &**pool, "medal", + ) + .await?; - let Some(medal_price_id) = maybe_price_id else { + let Some(product_item::QueryProductWithPrices { + id: _product_id, + metadata, + mut prices, + unitary: _, + }) = medal_products.pop() + else { return Ok(HttpResponse::NotImplemented() - .body("Missing price ID for Medal subscription")); + .body("Missing Medal subscription product")); }; + let ProductMetadata::Medal { + cpu, + ram, + swap, + storage, + region, + } = metadata + else { + return Ok(HttpResponse::NotImplemented() + .body("Missing or incorrect metadata for Medal subscription")); + }; + + let Some(medal_price) = prices.pop() else { + return Ok(HttpResponse::NotImplemented() + .body("Missing price for Medal subscription")); + }; + + let (price_duration, price_amount) = match medal_price.prices { + Price::OneTime { price: _ } => { + return Ok(HttpResponse::NotImplemented() + .body("Unexpected metadata for Medal subscription price")); + } + + Price::Recurring { intervals } => { + let Some((price_duration, price_amount)) = + intervals.into_iter().next() + else { + return Ok(HttpResponse::NotImplemented() + .body("Missing price interval for Medal subscription")); + }; + + (price_duration, price_amount) + } + }; + + let price_id = medal_price.id; + + #[derive(Deserialize)] + struct PyroServerResponse { + uuid: uuid::Uuid, + } + + // TODO: archon-client module + let pyro_response = client + .post(format!( + "{}/modrinth/v0/servers/create", + dotenvy::var("ARCHON_URL")?, + )) + .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) + .json(&serde_json::json!({ + "user_id": to_base62(user_id.0 as u64), + "name": format!("{}'s Medal server", username), + "specs": { + "memory_mb": ram, + "cpu": cpu, + "swap_mb": swap, + "storage_mb": storage, + }, + "region": region, + "source": {}, // Don't install anything by default (field is ignored on Archon anyways) + "payment_interval": 1, // Doesn't matter, not used on Archon anymore anyways + })) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + let mut txn = pool.begin().await?; + // Build a subscription using this price ID. let subscription = DBUserSubscription { id: generate_user_subscription_id(&mut txn).await?, - user_id: redeemal.user_id, - price_id: medal_price_id, + user_id, + price_id, interval: PriceDuration::FiveDays, created: Utc::now(), - status: SubscriptionStatus::Unprovisioned, - metadata: None, // TODO: Provision server, then add metadata + status: SubscriptionStatus::Provisioned, + metadata: Some(SubscriptionMetadata::Medal { + id: pyro_response.uuid.to_string(), + }), }; - // TODO: Insert a cancelled charge in 5 days time, `index_subscriptions` will unprovision - // the subscription. - subscription.upsert(&mut txn).await?; + // Insert an expiring charge, `index_subscriptions` will unprovision the + // subscription when expired. + DBCharge { + id: generate_charge_id(&mut txn).await?, + user_id, + price_id, + amount: price_amount.into(), + currency_code: medal_price.currency_code, + status: ChargeStatus::Expiring, + due: Utc::now() + price_duration.duration(), + last_attempt: None, + type_: ChargeType::Subscription, + subscription_id: Some(subscription.id), + subscription_interval: Some(subscription.interval), + payment_platform: PaymentPlatform::None, + payment_platform_id: None, + parent_charge_id: None, + net: None, + } + .upsert(&mut txn) + .await?; + + // Link user to offer redeemal. + let mut redeemal = UserRedeemal { + id: 0, + user_id, + offer: Offer::Medal, + redeemed: Utc::now(), + status: Status::Redeemed, + }; + + redeemal.insert(&mut *txn).await?; + txn.commit().await?; Ok(HttpResponse::Ok().finish())