diff --git a/.sqlx/query-3cbc34bc326595fc9d070494613fca57628eed279f720565fab55c8d10decd88.json b/.sqlx/query-3cbc34bc326595fc9d070494613fca57628eed279f720565fab55c8d10decd88.json deleted file mode 100644 index 14f57491..00000000 --- a/.sqlx/query-3cbc34bc326595fc9d070494613fca57628eed279f720565fab55c8d10decd88.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n us.id, us.user_id, us.price_id, us.interval, us.created, us.status, us.metadata\n FROM users_subscriptions us\n \n INNER JOIN charges c\n ON c.subscription_id = us.id\n AND (\n (c.status = 'cancelled' AND c.due < $1) OR\n (c.status = 'failed' AND c.last_attempt < $1 - INTERVAL '2 days')\n )\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "user_id", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "price_id", - "type_info": "Int8" - }, - { - "ordinal": 3, - "name": "interval", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "created", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "status", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "metadata", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "Timestamptz" - ] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - true - ] - }, - "hash": "3cbc34bc326595fc9d070494613fca57628eed279f720565fab55c8d10decd88" -} diff --git a/.sqlx/query-a87c913916adf9177f8f38369975d5fc644d989293ccb42c1e06ec54dc2571f8.json b/.sqlx/query-a87c913916adf9177f8f38369975d5fc644d989293ccb42c1e06ec54dc2571f8.json new file mode 100644 index 00000000..66986fd9 --- /dev/null +++ b/.sqlx/query-a87c913916adf9177f8f38369975d5fc644d989293ccb42c1e06ec54dc2571f8.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, price_id, amount, currency_code, status, due, last_attempt, charge_type, subscription_id, subscription_interval\n FROM charges\n WHERE (status = 'cancelled' AND due < $1) OR (status = 'failed' AND last_attempt < $1 - INTERVAL '2 days')", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "price_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "amount", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "currency_code", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "due", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_attempt", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "charge_type", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "subscription_id", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "subscription_interval", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "a87c913916adf9177f8f38369975d5fc644d989293ccb42c1e06ec54dc2571f8" +} diff --git a/src/database/models/charge_item.rs b/src/database/models/charge_item.rs index af0ed0bf..4ea19bf0 100644 --- a/src/database/models/charge_item.rs +++ b/src/database/models/charge_item.rs @@ -162,6 +162,22 @@ impl ChargeItem { .collect::, serde_json::Error>>()?) } + pub async fn get_unprovision( + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let now = Utc::now(); + + let res = + select_charges_with_predicate!("WHERE (status = 'cancelled' AND due < $1) OR (status = 'failed' AND last_attempt < $1 - INTERVAL '2 days')", now) + .fetch_all(exec) + .await?; + + Ok(res + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) + } + pub async fn remove( id: ChargeId, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, diff --git a/src/database/models/user_subscription_item.rs b/src/database/models/user_subscription_item.rs index 8de0fd0e..d83546c8 100644 --- a/src/database/models/user_subscription_item.rs +++ b/src/database/models/user_subscription_item.rs @@ -95,30 +95,6 @@ impl UserSubscriptionItem { .collect::, serde_json::Error>>()?) } - pub async fn get_all_unprovision( - exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, - ) -> Result, DatabaseError> { - let now = Utc::now(); - let results = select_user_subscriptions_with_predicate!( - " - INNER JOIN charges c - ON c.subscription_id = us.id - AND ( - (c.status = 'cancelled' AND c.due < $1) OR - (c.status = 'failed' AND c.last_attempt < $1 - INTERVAL '2 days') - ) - ", - now - ) - .fetch_all(exec) - .await?; - - Ok(results - .into_iter() - .map(|r| r.try_into()) - .collect::, serde_json::Error>>()?) - } - pub async fn upsert( &self, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, diff --git a/src/models/v3/billing.rs b/src/models/v3/billing.rs index 0cc1dadf..4c9d3b77 100644 --- a/src/models/v3/billing.rs +++ b/src/models/v3/billing.rs @@ -21,7 +21,12 @@ pub struct Product { #[serde(tag = "type", rename_all = "kebab-case")] pub enum ProductMetadata { Midas, - Pyro { ram: u32 }, + Pyro { + cpu: u32, + ram: u32, + swap: u32, + storage: u32, + }, } #[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] @@ -70,13 +75,16 @@ impl PriceDuration { _ => PriceDuration::Monthly, } } - pub fn as_str(&self) -> &'static str { match self { PriceDuration::Monthly => "monthly", PriceDuration::Yearly => "yearly", } } + + pub fn iterator() -> impl Iterator { + vec![PriceDuration::Monthly, PriceDuration::Yearly].into_iter() + } } #[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] diff --git a/src/routes/internal/billing.rs b/src/routes/internal/billing.rs index b017f5bd..b0c544d3 100644 --- a/src/routes/internal/billing.rs +++ b/src/routes/internal/billing.rs @@ -1,4 +1,5 @@ use crate::auth::{get_user_from_headers, send_email}; +use crate::database::models::charge_item::ChargeItem; use crate::database::models::{ generate_charge_id, generate_user_subscription_id, product_item, user_subscription_item, }; @@ -15,6 +16,8 @@ use crate::routes::ApiError; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::Utc; use log::{info, warn}; +use rust_decimal::prelude::ToPrimitive; +use rust_decimal::Decimal; use serde::Serialize; use serde_with::serde_derive::Deserialize; use sqlx::{PgPool, Postgres, Transaction}; @@ -105,6 +108,7 @@ pub async fn subscriptions( #[derive(Deserialize)] pub struct SubscriptionEdit { pub interval: Option, + pub payment_method: Option, pub cancelled: Option, pub product: Option, } @@ -117,6 +121,7 @@ pub async fn edit_subscription( redis: web::Data, session_queue: web::Data, edit_subscription: web::Json, + stripe_client: web::Data, ) -> Result { let user = get_user_from_headers( &req, @@ -187,11 +192,139 @@ pub async fn edit_subscription( } } + let intent = if let Some(product_id) = &edit_subscription.product { + let product_price = product_item::ProductPriceItem::get_all_product_prices( + (*product_id).into(), + &mut *transaction, + ) + .await? + .into_iter() + .find(|x| x.currency_code == current_price.currency_code) + .ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for your currency code!".to_string(), + ) + })?; + + if product_price.id == current_price.id { + return Err(ApiError::InvalidInput( + "You may not change the price of this subscription!".to_string(), + )); + } + + let interval = open_charge.due - Utc::now(); + let duration = PriceDuration::iterator() + .min_by_key(|x| (x.duration().num_seconds() - interval.num_seconds()).abs()) + .unwrap_or(PriceDuration::Monthly); + + let current_amount = match ¤t_price.prices { + Price::OneTime { price } => *price, + Price::Recurring { intervals } => *intervals.get(&duration).ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for the user's duration".to_string(), + ) + })?, + }; + + let amount = match &product_price.prices { + Price::OneTime { price } => *price, + Price::Recurring { intervals } => *intervals.get(&duration).ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for the user's duration".to_string(), + ) + })?, + }; + + let complete = Decimal::from(interval.num_seconds()) + / Decimal::from(duration.duration().num_seconds()); + let proration = (Decimal::from(amount - current_amount) * complete) + .floor() + .to_i32() + .ok_or_else(|| { + ApiError::InvalidInput("Could not convert proration to i32".to_string()) + })?; + + // TODO: Add downgrading plans + if proration <= 0 { + return Err(ApiError::InvalidInput( + "You may not downgrade plans!".to_string(), + )); + } + + let charge_id = generate_charge_id(&mut transaction).await?; + let charge = ChargeItem { + id: charge_id, + user_id: user.id.into(), + price_id: product_price.id, + amount: proration as i64, + currency_code: current_price.currency_code.clone(), + status: ChargeStatus::Processing, + due: Utc::now(), + last_attempt: None, + type_: ChargeType::Proration, + subscription_id: Some(subscription.id), + subscription_interval: Some(duration), + }; + + let customer_id = get_or_create_customer( + user.id, + user.stripe_customer_id.as_deref(), + user.email.as_deref(), + &stripe_client, + &pool, + &redis, + ) + .await?; + + let currency = Currency::from_str(¤t_price.currency_code.to_lowercase()) + .map_err(|_| ApiError::InvalidInput("Invalid currency code".to_string()))?; + + let mut intent = CreatePaymentIntent::new(proration as i64, currency); + + let mut metadata = HashMap::new(); + metadata.insert("modrinth_user_id".to_string(), to_base62(user.id.0)); + + intent.customer = Some(customer_id); + intent.metadata = Some(metadata); + intent.receipt_email = user.email.as_deref(); + intent.setup_future_usage = Some(PaymentIntentSetupFutureUsage::OffSession); + + if let Some(payment_method) = &edit_subscription.payment_method { + let payment_method_id = if let Ok(id) = PaymentMethodId::from_str(&payment_method) { + id + } else { + return Err(ApiError::InvalidInput( + "Invalid payment method id".to_string(), + )); + }; + intent.payment_method = Some(payment_method_id); + } + + charge.upsert(&mut transaction).await?; + + Some(( + proration, + 0, + stripe::PaymentIntent::create(&stripe_client, intent).await?, + )) + } else { + None + }; + open_charge.upsert(&mut transaction).await?; transaction.commit().await?; - Ok(HttpResponse::NoContent().body("")) + if let Some((amount, tax, payment_intent)) = intent { + Ok(HttpResponse::Ok().json(serde_json::json!({ + "payment_intent_id": payment_intent.id, + "client_secret": payment_intent.client_secret, + "tax": tax, + "total": amount + }))) + } else { + Ok(HttpResponse::NoContent().body("")) + } } else { Err(ApiError::NotFound) } @@ -971,9 +1104,17 @@ pub async fn stripe_webhook( break 'metadata; }; - if let Some(interval) = charge.subscription_interval { - subscription.interval = interval; + match charge.type_ { + ChargeType::OneTime | ChargeType::Subscription => { + if let Some(interval) = charge.subscription_interval { + subscription.interval = interval; + } + } + ChargeType::Proration => { + subscription.price_id = charge.price_id; + } } + subscription.upsert(transaction).await?; (charge, price, product, Some(subscription)) @@ -1037,11 +1178,7 @@ pub async fn stripe_webhook( price_id, interval, created: Utc::now(), - status: if charge_status == ChargeStatus::Succeeded { - SubscriptionStatus::Provisioned - } else { - SubscriptionStatus::Unprovisioned - }, + status: SubscriptionStatus::Unprovisioned, metadata: None, }; @@ -1056,7 +1193,7 @@ pub async fn stripe_webhook( } }; - let charge = crate::database::models::charge_item::ChargeItem { + let charge = ChargeItem { id: charge_id, user_id, price_id, @@ -1101,7 +1238,7 @@ pub async fn stripe_webhook( if let EventObject::PaymentIntent(payment_intent) = event.data.object { let mut transaction = pool.begin().await?; - let metadata = get_payment_intent_metadata( + let mut metadata = get_payment_intent_metadata( payment_intent.metadata, &pool, &redis, @@ -1127,78 +1264,144 @@ pub async fn stripe_webhook( .execute(&mut *transaction) .await?; } - ProductMetadata::Pyro { ram } => { + ProductMetadata::Pyro { + ram, + cpu, + swap, + storage, + } => { if let Some(ref subscription) = metadata.user_subscription_item { let client = reqwest::Client::new(); if let Some(SubscriptionMetadata::Pyro { id }) = &subscription.metadata { - let res = client + client .post(format!( - "https://archon.pyro.host/v0/servers/{}/unsuspend", + "https://archon.pyro.host/modrinth/v0/servers/{}/unsuspend", id )) .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) .send() - .await; + .await? + .error_for_status()?; - if let Err(e) = res { - warn!("Error unsuspending pyro server: {:?}", e); - } - } else if let Some(PaymentRequestMetadata::Pyro { - server_name, - source, - }) = &metadata.payment_metadata - { - let server_name = server_name.clone().unwrap_or_else(|| { + // TODO: Send plan upgrade request for proration + } else { + let (server_name, source) = if let Some( + PaymentRequestMetadata::Pyro { + ref server_name, + ref source, + }, + ) = metadata.payment_metadata + { + (server_name.clone(), source.clone()) + } else { + // Create a server with the latest version of Minecraft + let minecraft_versions = crate::database::models::legacy_loader_fields::MinecraftGameVersion::list( + Some("release"), + None, + &**pool, + &redis, + ).await?; + + ( + None, + serde_json::json!({ + "loader": "Vanilla", + "game_version": minecraft_versions.first().map(|x| x.version.clone()), + "loader_version": "" + }), + ) + }; + + let server_name = server_name.unwrap_or_else(|| { format!("{}'s server", metadata.user_item.username) }); + #[derive(Deserialize)] + struct PyroServerResponse { + uuid: String, + } + let res = client - .post("https://archon.pyro.host/v0/servers/create") + .post("https://archon.pyro.host/modrinth/v0/servers/create") .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) .json(&serde_json::json!({ "user_id": to_base62(metadata.user_item.id.0 as u64), "name": server_name, "specs": { - "ram": ram, - "cpu": std::cmp::max(2, (ram / 1024) / 2), - "swap": ram / 4, + "memory_mb": ram, + "cpu": cpu, + "swap_mb": swap, + "storage_mb": storage, }, "source": source, })) .send() - .await; - - if let Err(e) = res { - warn!("Error creating pyro server: {:?}", e); + .await? + .error_for_status()? + .json::() + .await?; + + if let Some(ref mut subscription) = + metadata.user_subscription_item + { + subscription.metadata = + Some(SubscriptionMetadata::Pyro { id: res.uuid }); } } } } } - if let Some(subscription) = metadata.user_subscription_item { - if metadata.charge_item.status != ChargeStatus::Cancelled { + if let Some(mut subscription) = metadata.user_subscription_item { + let open_charge = + ChargeItem::get_open_subscription(subscription.id, &mut *transaction) + .await?; + + let new_price = match metadata.product_price_item.prices { + Price::OneTime { price } => price, + Price::Recurring { intervals } => { + *intervals.get(&subscription.interval).ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for the user's country" + .to_string(), + ) + })? + } + }; + + if let Some(mut charge) = open_charge { + charge.price_id = metadata.product_price_item.id; + charge.amount = new_price as i64; + + charge.upsert(&mut transaction).await?; + } else if metadata.charge_item.status != ChargeStatus::Cancelled { let charge_id = generate_charge_id(&mut transaction).await?; - let charge = crate::database::models::charge_item::ChargeItem { + ChargeItem { id: charge_id, user_id: metadata.user_item.id, price_id: metadata.product_price_item.id, - amount: metadata.charge_item.amount, + amount: new_price as i64, currency_code: metadata.product_price_item.currency_code, status: ChargeStatus::Open, - due: Utc::now() + subscription.interval.duration(), + due: if subscription.status == SubscriptionStatus::Unprovisioned { + Utc::now() + subscription.interval.duration() + } else { + metadata.charge_item.due + subscription.interval.duration() + }, last_attempt: None, type_: ChargeType::Subscription, subscription_id: Some(subscription.id), subscription_interval: Some(subscription.interval), - }; - let err = charge.upsert(&mut transaction).await; + } + .upsert(&mut transaction) + .await?; + }; - err?; - } + subscription.status = SubscriptionStatus::Provisioned; + subscription.upsert(&mut transaction).await?; } transaction.commit().await?; @@ -1346,8 +1549,18 @@ pub async fn subscription_task(pool: PgPool, redis: RedisPool) { let mut clear_cache_users = Vec::new(); // If an active subscription has a canceled charge OR a failed charge more than two days ago, it should be cancelled - let all_subscriptions = - user_subscription_item::UserSubscriptionItem::get_all_unprovision(&pool).await?; + let all_charges = ChargeItem::get_unprovision(&pool).await?; + + let mut all_subscriptions = user_subscription_item::UserSubscriptionItem::get_many( + &all_charges + .iter() + .filter_map(|x| x.subscription_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + ) + .await?; let subscription_prices = product_item::ProductPriceItem::get_many( &all_subscriptions .iter() @@ -1380,7 +1593,20 @@ pub async fn subscription_task(pool: PgPool, redis: RedisPool) { ) .await?; - for mut subscription in all_subscriptions { + for charge in all_charges { + let subscription = if let Some(subscription) = all_subscriptions + .iter_mut() + .find(|x| Some(x.id) == charge.subscription_id) + { + subscription + } else { + continue; + }; + + if subscription.status == SubscriptionStatus::Unprovisioned { + continue; + } + let product_price = if let Some(product_price) = subscription_prices .iter() .find(|x| x.id == subscription.price_id) @@ -1427,12 +1653,16 @@ pub async fn subscription_task(pool: PgPool, redis: RedisPool) { if let Some(SubscriptionMetadata::Pyro { id }) = &subscription.metadata { let res = reqwest::Client::new() .post(format!( - "https://archon.pyro.host/v0/servers/{}/suspend", + "https://archon.pyro.host/modrinth/v0/servers/{}/suspend", id )) .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) .json(&serde_json::json!({ - "reason": "cancelled" + "reason": if charge.status == ChargeStatus::Cancelled { + "cancelled" + } else { + "paymentfailed" + } })) .send() .await;