From 73a63a6891c3da68b7a89620e197edf476dfe1a8 Mon Sep 17 00:00:00 2001 From: Jai A Date: Tue, 1 Oct 2024 18:29:39 -0700 Subject: [PATCH 1/7] Move charges to DB + fix subscription recurring payments --- migrations/20240923163452_charges-fix.sql | 12 + src/database/models/charge_item.rs | 131 +++++ src/database/models/ids.rs | 23 + src/database/models/mod.rs | 1 + src/database/models/user_subscription_item.rs | 11 - src/models/v3/billing.rs | 50 ++ src/models/v3/ids.rs | 4 +- src/routes/internal/billing.rs | 450 ++++++++++-------- 8 files changed, 467 insertions(+), 215 deletions(-) create mode 100644 migrations/20240923163452_charges-fix.sql create mode 100644 src/database/models/charge_item.rs diff --git a/migrations/20240923163452_charges-fix.sql b/migrations/20240923163452_charges-fix.sql new file mode 100644 index 00000000..378bd488 --- /dev/null +++ b/migrations/20240923163452_charges-fix.sql @@ -0,0 +1,12 @@ +CREATE TABLE charges ( + id bigint PRIMARY KEY, + user_id bigint REFERENCES users NOT NULL, + price_id bigint REFERENCES products_prices NOT NULL, + amount bigint NOT NULL, + currency_code text NOT NULL, + subscription_id bigint REFERENCES users_subscriptions NULL, + interval text NULL, + status varchar(255) NOT NULL, + due timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + last_attempt timestamptz NOT NULL +); \ No newline at end of file diff --git a/src/database/models/charge_item.rs b/src/database/models/charge_item.rs new file mode 100644 index 00000000..af535a7a --- /dev/null +++ b/src/database/models/charge_item.rs @@ -0,0 +1,131 @@ +use crate::database::models::{ + ChargeId, DatabaseError, ProductPriceId, UserId, UserSubscriptionId, +}; +use crate::models::billing::{ChargeStatus, PriceDuration}; +use chrono::{DateTime, Utc}; +use std::convert::TryFrom; + +pub struct ChargeItem { + pub id: ChargeId, + pub user_id: UserId, + pub price_id: ProductPriceId, + pub amount: i64, + pub currency_code: String, + pub subscription_id: Option, + pub interval: Option, + pub status: ChargeStatus, + pub due: DateTime, + pub last_attempt: Option>, +} + +struct ChargeResult { + id: i64, + user_id: i64, + price_id: i64, + amount: i64, + currency_code: String, + subscription_id: Option, + interval: Option, + status: String, + due: DateTime, + last_attempt: Option>, +} + +impl TryFrom for ChargeItem { + type Error = serde_json::Error; + + fn try_from(r: ChargeResult) -> Result { + Ok(ChargeItem { + id: ChargeId(r.id), + user_id: UserId(r.user_id), + price_id: ProductPriceId(r.price_id), + amount: r.amount, + currency_code: r.currency_code, + subscription_id: r.subscription_id.map(UserSubscriptionId), + interval: r.interval.map(|x| serde_json::from_str(&x)).transpose()?, + status: serde_json::from_str(&r.status)?, + due: r.due, + last_attempt: r.last_attempt, + }) + } +} + +macro_rules! select_charges_with_predicate { + ($predicate:tt, $param:ident) => { + sqlx::query_as!( + ChargeResult, + r#" + SELECT id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt + FROM charges + "# + + $predicate, + $param + ) + }; +} + +impl ChargeItem { + pub async fn insert( + &self, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result { + sqlx::query!( + r#" + INSERT INTO charges (id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + "#, + self.id.0, + self.user_id.0, + self.price_id.0, + self.amount, + self.currency_code, + self.subscription_id.map(|x| x.0), + self.interval.map(|x| x.as_str()), + self.status.as_str(), + self.due, + self.last_attempt, + ) + .execute(exec) + .await?; + + Ok(self.id) + } + + pub async fn get( + id: ChargeId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let res = select_charges_with_predicate!("WHERE id = $1", id) + .fetch_optional(exec) + .await?; + + Ok(res.map(|r| r.try_into())) + } + + pub async fn get_from_user( + user_id: UserId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let res = select_charges_with_predicate!("WHERE user_id = $1", user_id) + .fetch_all(exec) + .await?; + + Ok(res + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) + } + + pub async fn get_chargeable( + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let res = select_charges_with_predicate!("WHERE (status = 'open' AND due < NOW()) OR (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')") + .fetch_all(exec) + .await?; + + Ok(res + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) + } +} diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index fd85a64c..be380924 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -256,6 +256,14 @@ generate_ids!( UserSubscriptionId ); +generate_ids!( + pub generate_charge_id, + ChargeId, + 8, + "SELECT EXISTS(SELECT 1 FROM charges WHERE id=$1)", + ChargeId +); + #[derive(Copy, Clone, Debug, PartialEq, Eq, Type, Hash, Serialize, Deserialize)] #[sqlx(transparent)] pub struct UserId(pub i64); @@ -386,6 +394,10 @@ pub struct ProductPriceId(pub i64); #[sqlx(transparent)] pub struct UserSubscriptionId(pub i64); +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash)] +#[sqlx(transparent)] +pub struct ChargeId(pub i64); + use crate::models::ids; impl From for ProjectId { @@ -571,3 +583,14 @@ impl From for ids::UserSubscriptionId { ids::UserSubscriptionId(id.0 as u64) } } + +impl From for ChargeId { + fn from(id: ids::ChargeId) -> Self { + ChargeId(id.0 as i64) + } +} +impl From for ids::ChargeId { + fn from(id: ChargeId) -> Self { + ids::ChargeId(id.0 as u64) + } +} diff --git a/src/database/models/mod.rs b/src/database/models/mod.rs index 51dafed6..dabcfdda 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -1,6 +1,7 @@ use thiserror::Error; pub mod categories; +pub mod charge_item; pub mod collection_item; pub mod flow_item; pub mod ids; diff --git a/src/database/models/user_subscription_item.rs b/src/database/models/user_subscription_item.rs index b13d319f..fb5af314 100644 --- a/src/database/models/user_subscription_item.rs +++ b/src/database/models/user_subscription_item.rs @@ -89,17 +89,6 @@ impl UserSubscriptionItem { Ok(results.into_iter().map(|r| r.into()).collect()) } - pub async fn get_all_expired( - exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, - ) -> Result, DatabaseError> { - let now = Utc::now(); - let results = select_user_subscriptions_with_predicate!("WHERE expires < $1", now) - .fetch_all(exec) - .await?; - - Ok(results.into_iter().map(|r| r.into()).collect()) - } - 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 c78583da..c1e3d703 100644 --- a/src/models/v3/billing.rs +++ b/src/models/v3/billing.rs @@ -134,3 +134,53 @@ impl SubscriptionStatus { } } } + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct ChargeId(pub u64); + +#[derive(Serialize, Deserialize)] +pub struct Charge { + pub id: ChargeId, + pub user_id: UserId, + pub price_id: ProductPriceId, + pub amount: i64, + pub currency_code: String, + pub subscription_id: Option, + pub interval: Option, + pub status: ChargeStatus, + pub due: DateTime, + pub last_attempt: Option>, +} + +#[derive(Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum ChargeStatus { + // Open charges are for the next billing interval + Open, + Processing, + Succeeded, + Failed, +} + +impl ChargeStatus { + pub fn from_string(string: &str) -> ChargeStatus { + match string { + "processing" => ChargeStatus::Processing, + "succeeded" => ChargeStatus::Succeeded, + "failed" => ChargeStatus::Failed, + "open" => ChargeStatus::Open, + _ => ChargeStatus::Failed, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + ChargeStatus::Processing => "processing", + ChargeStatus::Succeeded => "succeeded", + ChargeStatus::Failed => "failed", + ChargeStatus::Open => "open", + } + } +} diff --git a/src/models/v3/ids.rs b/src/models/v3/ids.rs index 839d6587..3af87437 100644 --- a/src/models/v3/ids.rs +++ b/src/models/v3/ids.rs @@ -13,8 +13,7 @@ pub use super::teams::TeamId; pub use super::threads::ThreadId; pub use super::threads::ThreadMessageId; pub use super::users::UserId; -pub use crate::models::billing::UserSubscriptionId; -pub use crate::models::v3::billing::{ProductId, ProductPriceId}; +pub use crate::models::billing::{ChargeId, ProductId, ProductPriceId, UserSubscriptionId}; use thiserror::Error; /// Generates a random 64 bit integer that is exactly `n` characters @@ -137,6 +136,7 @@ base62_id_impl!(PayoutId, PayoutId); base62_id_impl!(ProductId, ProductId); base62_id_impl!(ProductPriceId, ProductPriceId); base62_id_impl!(UserSubscriptionId, UserSubscriptionId); +base62_id_impl!(ChargeId, ChargeId); pub mod base62_impl { use serde::de::{self, Deserializer, Visitor}; diff --git a/src/routes/internal/billing.rs b/src/routes/internal/billing.rs index a63442dc..424d0a1f 100644 --- a/src/routes/internal/billing.rs +++ b/src/routes/internal/billing.rs @@ -1,11 +1,11 @@ use crate::auth::{get_user_from_headers, send_email}; use crate::database::models::{ - generate_user_subscription_id, product_item, user_subscription_item, + generate_charge_id, generate_user_subscription_id, product_item, user_subscription_item, }; use crate::database::redis::RedisPool; use crate::models::billing::{ - Price, PriceDuration, Product, ProductMetadata, ProductPrice, SubscriptionStatus, - UserSubscription, + Charge, ChargeStatus, Price, PriceDuration, Product, ProductMetadata, ProductPrice, + SubscriptionStatus, UserSubscription, }; use crate::models::ids::base62_impl::{parse_base62, to_base62}; use crate::models::pats::Scopes; @@ -140,6 +140,8 @@ pub async fn cancel_subscription( ) .execute(&mut *transaction) .await?; + + // TODO: delete open charges for this subscription } else { subscription.status = SubscriptionStatus::Cancelled; subscription.upsert(&mut transaction).await?; @@ -191,7 +193,6 @@ pub async fn charges( pool: web::Data, redis: web::Data, session_queue: web::Data, - stripe_client: web::Data, ) -> Result { let user = get_user_from_headers( &req, @@ -203,25 +204,27 @@ pub async fn charges( .await? .1; - if let Some(customer_id) = user - .stripe_customer_id - .as_ref() - .and_then(|x| stripe::CustomerId::from_str(x).ok()) - { - let charges = stripe::Charge::list( - &stripe_client, - &ListCharges { - customer: Some(customer_id), - limit: Some(100), - ..Default::default() - }, - ) - .await?; + let charges = + crate::database::models::charge_item::ChargeItem::get_from_user(user.id.into(), &**pool) + .await?; - Ok(HttpResponse::Ok().json(charges.data)) - } else { - Ok(HttpResponse::NoContent().finish()) - } + Ok(HttpResponse::Ok().json( + charges + .into_iter() + .map(|x| Charge { + id: x.id.into(), + user_id: x.user_id.into(), + price_id: x.price_id.into(), + amount: x.amount, + currency_code: x.currency_code, + subscription_id: x.subscription_id.map(|x| x.into()), + interval: x.interval, + status: x.status, + due: x.due, + last_attempt: x.last_attempt, + }) + .collect::>(), + )) } #[post("payment_method")] @@ -466,15 +469,85 @@ pub enum PaymentRequestType { ConfirmationToken { token: String }, } +#[derive(Deserialize)] +pub enum ChargeRequestType { + Existing { + id: crate::models::ids::ChargeId, + }, + New { + product_id: crate::models::ids::ProductId, + interval: Option, + }, +} + #[derive(Deserialize)] pub struct PaymentRequest { - pub product_id: crate::models::ids::ProductId, - pub interval: Option, #[serde(flatten)] pub type_: PaymentRequestType, + pub charge: ChargeRequestType, pub existing_payment_intent: Option, } +fn infer_currency_code(country: &str) -> String { + match country { + "US" => "USD", + "GB" => "GBP", + "EU" => "EUR", + "AT" => "EUR", + "BE" => "EUR", + "CY" => "EUR", + "EE" => "EUR", + "FI" => "EUR", + "FR" => "EUR", + "DE" => "EUR", + "GR" => "EUR", + "IE" => "EUR", + "IT" => "EUR", + "LV" => "EUR", + "LT" => "EUR", + "LU" => "EUR", + "MT" => "EUR", + "NL" => "EUR", + "PT" => "EUR", + "SK" => "EUR", + "SI" => "EUR", + "RU" => "RUB", + "BR" => "BRL", + "JP" => "JPY", + "ID" => "IDR", + "MY" => "MYR", + "PH" => "PHP", + "TH" => "THB", + "VN" => "VND", + "KR" => "KRW", + "TR" => "TRY", + "UA" => "UAH", + "MX" => "MXN", + "CA" => "CAD", + "NZ" => "NZD", + "NO" => "NOK", + "PL" => "PLN", + "CH" => "CHF", + "LI" => "CHF", + "IN" => "INR", + "CL" => "CLP", + "PE" => "PEN", + "CO" => "COP", + "ZA" => "ZAR", + "HK" => "HKD", + "AR" => "ARS", + "KZ" => "KZT", + "UY" => "UYU", + "CN" => "CNY", + "AU" => "AUD", + "TW" => "TWD", + "SA" => "SAR", + "QA" => "QAR", + _ => "USD", + } + .to_string() +} + #[post("payment")] pub async fn initiate_payment( req: HttpRequest, @@ -494,12 +567,6 @@ pub async fn initiate_payment( .await? .1; - let product = product_item::ProductItem::get(payment_request.product_id.into(), &**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("Specified product could not be found!".to_string()) - })?; - let (user_country, payment_method) = match &payment_request.type_ { PaymentRequestType::PaymentMethod { id } => { let payment_method_id = stripe::PaymentMethodId::from_str(id) @@ -551,93 +618,112 @@ pub async fn initiate_payment( }; let country = user_country.as_deref().unwrap_or("US"); - let recommended_currency_code = match country { - "US" => "USD", - "GB" => "GBP", - "EU" => "EUR", - "AT" => "EUR", - "BE" => "EUR", - "CY" => "EUR", - "EE" => "EUR", - "FI" => "EUR", - "FR" => "EUR", - "DE" => "EUR", - "GR" => "EUR", - "IE" => "EUR", - "IT" => "EUR", - "LV" => "EUR", - "LT" => "EUR", - "LU" => "EUR", - "MT" => "EUR", - "NL" => "EUR", - "PT" => "EUR", - "SK" => "EUR", - "SI" => "EUR", - "RU" => "RUB", - "BR" => "BRL", - "JP" => "JPY", - "ID" => "IDR", - "MY" => "MYR", - "PH" => "PHP", - "TH" => "THB", - "VN" => "VND", - "KR" => "KRW", - "TR" => "TRY", - "UA" => "UAH", - "MX" => "MXN", - "CA" => "CAD", - "NZ" => "NZD", - "NO" => "NOK", - "PL" => "PLN", - "CH" => "CHF", - "LI" => "CHF", - "IN" => "INR", - "CL" => "CLP", - "PE" => "PEN", - "CO" => "COP", - "ZA" => "ZAR", - "HK" => "HKD", - "AR" => "ARS", - "KZ" => "KZT", - "UY" => "UYU", - "CN" => "CNY", - "AU" => "AUD", - "TW" => "TWD", - "SA" => "SAR", - "QA" => "QAR", - _ => "USD", - }; - - let mut product_prices = - product_item::ProductPriceItem::get_all_product_prices(product.id, &**pool).await?; - - let price_item = if let Some(pos) = product_prices - .iter() - .position(|x| x.currency_code == recommended_currency_code) - { - product_prices.remove(pos) - } else if let Some(pos) = product_prices.iter().position(|x| x.currency_code == "USD") { - product_prices.remove(pos) - } else { - return Err(ApiError::InvalidInput( - "Could not find a valid price for the user's country".to_string(), - )); - }; + let recommended_currency_code = infer_currency_code(country); + + let (price, currency_code, interval, price_id, charge_id) = match payment_request.charge { + ChargeRequestType::Existing { id } => { + let charge = crate::database::models::charge_item::ChargeItem::get(id.into(), &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("Specified charge could not be found!".to_string()) + })?; + + ( + charge.amount, + charge.currency_code, + charge.interval, + charge.price_id, + Some(id), + ) + } + ChargeRequestType::New { + product_id, + interval, + } => { + let product = product_item::ProductItem::get(product_id.into(), &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("Specified product could not be found!".to_string()) + })?; + + let mut product_prices = + product_item::ProductPriceItem::get_all_product_prices(product.id, &**pool).await?; + + let price_item = if let Some(pos) = product_prices + .iter() + .position(|x| x.currency_code == recommended_currency_code) + { + product_prices.remove(pos) + } else if let Some(pos) = product_prices.iter().position(|x| x.currency_code == "USD") { + product_prices.remove(pos) + } else { + return Err(ApiError::InvalidInput( + "Could not find a valid price for the user's country".to_string(), + )); + }; + + let price = match price_item.prices { + Price::OneTime { price } => price, + Price::Recurring { ref intervals } => { + let interval = interval.ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid interval for the user's country".to_string(), + ) + })?; + + *intervals.get(&interval).ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for the user's country".to_string(), + ) + })? + } + }; + + if let Price::Recurring { .. } = price_item.prices { + if product.unitary { + let user_subscriptions = + user_subscription_item::UserSubscriptionItem::get_all_user( + user.id.into(), + &**pool, + ) + .await?; + + let user_products = product_item::ProductPriceItem::get_many( + &user_subscriptions + .iter() + .map(|x| x.price_id) + .collect::>(), + &**pool, + ) + .await?; - let price = match price_item.prices { - Price::OneTime { price } => price, - Price::Recurring { ref intervals } => { - let interval = payment_request.interval.ok_or_else(|| { - ApiError::InvalidInput( - "Could not find a valid interval for the user's country".to_string(), - ) - })?; + if let Some(product) = user_products + .into_iter() + .find(|x| x.product_id == product.id) + { + if let Some(subscription) = user_subscriptions + .into_iter() + .find(|x| x.price_id == product.id) + { + return Err(ApiError::InvalidInput(if !(subscription.status == SubscriptionStatus::Cancelled + || subscription.status == SubscriptionStatus::PaymentFailed) + { + "You are already subscribed to this product!" + } else { + "You are already subscribed to this product, but the payment failed!" + }.to_string())); + } + } + } + } - *intervals.get(&interval).ok_or_else(|| { - ApiError::InvalidInput( - "Could not find a valid price for the user's country".to_string(), - ) - })? + ( + price as i64, + price_item.currency_code, + interval, + price_item.id, + None, + ) } }; @@ -650,31 +736,17 @@ pub async fn initiate_payment( &redis, ) .await?; - let stripe_currency = Currency::from_str(&price_item.currency_code.to_lowercase()) + let stripe_currency = Currency::from_str(¤cy_code.to_lowercase()) .map_err(|_| ApiError::InvalidInput("Invalid currency code".to_string()))?; if let Some(payment_intent_id) = &payment_request.existing_payment_intent { let mut update_payment_intent = stripe::UpdatePaymentIntent { - amount: Some(price as i64), + amount: Some(price), currency: Some(stripe_currency), customer: Some(customer), ..Default::default() }; - let mut metadata = HashMap::new(); - metadata.insert("modrinth_user_id".to_string(), to_base62(user.id.0)); - metadata.insert( - "modrinth_price_id".to_string(), - to_base62(price_item.id.0 as u64), - ); - if let Some(interval) = payment_request.interval { - metadata.insert( - "modrinth_subscription_interval".to_string(), - interval.as_str().to_string(), - ); - } - update_payment_intent.metadata = Some(metadata); - if let PaymentRequestType::PaymentMethod { .. } = payment_request.type_ { update_payment_intent.payment_method = Some(payment_method.id.clone()); } @@ -689,68 +761,20 @@ pub async fn initiate_payment( "payment_method": payment_method, }))) } else { - let mut intent = CreatePaymentIntent::new(price as i64, stripe_currency); + let mut intent = CreatePaymentIntent::new(price, stripe_currency); - let mut transaction = pool.begin().await?; let mut metadata = HashMap::new(); metadata.insert("modrinth_user_id".to_string(), to_base62(user.id.0)); - metadata.insert( - "modrinth_price_id".to_string(), - to_base62(price_item.id.0 as u64), - ); - - if let Price::Recurring { .. } = price_item.prices { - if product.unitary { - let user_subscriptions = - user_subscription_item::UserSubscriptionItem::get_all_user( - user.id.into(), - &**pool, - ) - .await?; - - let user_products = product_item::ProductPriceItem::get_many( - &user_subscriptions - .iter() - .map(|x| x.price_id) - .collect::>(), - &**pool, - ) - .await?; - - if let Some(product) = user_products - .into_iter() - .find(|x| x.product_id == product.id) - { - if let Some(subscription) = user_subscriptions - .into_iter() - .find(|x| x.price_id == product.id) - { - if subscription.status == SubscriptionStatus::Cancelled - || subscription.status == SubscriptionStatus::PaymentFailed - { - metadata.insert( - "modrinth_subscription_id".to_string(), - to_base62(subscription.id.0 as u64), - ); - } else { - return Err(ApiError::InvalidInput( - "You are already subscribed to this product!".to_string(), - )); - } - } - } - } - - if !metadata.contains_key("modrinth_subscription_id") { - let user_subscription_id = generate_user_subscription_id(&mut transaction).await?; - metadata.insert( - "modrinth_subscription_id".to_string(), - to_base62(user_subscription_id.0 as u64), - ); - } + if let Some(charge_id) = charge_id { + metadata.insert("modrinth_charge_id".to_string(), to_base62(charge_id.0)); + } else { + metadata.insert( + "modrinth_price_id".to_string(), + to_base62(price_id.0 as u64), + ); - if let Some(interval) = payment_request.interval { + if let Some(interval) = interval { metadata.insert( "modrinth_subscription_interval".to_string(), interval.as_str().to_string(), @@ -766,14 +790,12 @@ pub async fn initiate_payment( }); intent.receipt_email = user.email.as_deref(); intent.setup_future_usage = Some(PaymentIntentSetupFutureUsage::OffSession); - intent.payment_method_types = Some(vec!["card".to_string(), "cashapp".to_string()]); if let PaymentRequestType::PaymentMethod { .. } = payment_request.type_ { intent.payment_method = Some(payment_method.id.clone()); } let payment_intent = stripe::PaymentIntent::create(&stripe_client, intent).await?; - transaction.commit().await?; Ok(HttpResponse::Ok().json(serde_json::json!({ "payment_intent_id": payment_intent.id, @@ -814,6 +836,7 @@ pub async fn stripe_webhook( user_subscription: Option, product: product_item::ProductItem, product_price: product_item::ProductPriceItem, + charge_item: Option, } async fn get_payment_intent_metadata( @@ -1115,16 +1138,29 @@ async fn get_or_create_customer( } pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) { + // Check for open charges which are open AND last charge hasn't already been attempted + // CHeck for open charges which are failed ANd last attempt > 2 days ago (and unprovision) + // if subscription is cancelled and expired, unprovision and remove // if subscription is payment failed and last attempt is > 2 days ago, try again to charge and unprovision // if subscription is active and expired, attempt to charge and set as processing loop { info!("Indexing billing queue"); let res = async { - let expired = - user_subscription_item::UserSubscriptionItem::get_all_expired(&pool).await?; + let charges_to_do = crate::database::models::charge_item::ChargeItem::get_chargeable(&pool).await?; + + let subscription_items = user_subscription_item::UserSubscriptionItem::get_many( + &charges_to_do + .iter() + .flat_map(|x| x.subscription_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + ) + .await?; let subscription_prices = product_item::ProductPriceItem::get_many( - &expired + &charges_to_do .iter() .map(|x| x.price_id) .collect::>() @@ -1144,7 +1180,7 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) ) .await?; let users = crate::database::models::User::get_many_ids( - &expired + &charges_to_do .iter() .map(|x| x.user_id) .collect::>() @@ -1158,13 +1194,13 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) let mut transaction = pool.begin().await?; let mut clear_cache_users = Vec::new(); - for mut subscription in expired { - let user = users.iter().find(|x| x.id == subscription.user_id); + for mut charge in charges_to_do { + let user = users.iter().find(|x| x.id == charge.user_id); if let Some(user) = user { let product_price = subscription_prices .iter() - .find(|x| x.id == subscription.price_id); + .find(|x| x.id == charge.price_id); if let Some(product_price) = product_price { let product = subscription_products @@ -1238,11 +1274,21 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) ) .await?; - let mut intent = CreatePaymentIntent::new( - *price as i64, - Currency::from_str(&product_price.currency_code) - .unwrap_or(Currency::USD), - ); + let currency = match Currency::from_str( + &product_price.currency_code.to_lowercase(), + ) { + Ok(x) => x, + Err(_) => { + warn!( + "Could not find currency for {}", + product_price.currency_code + ); + continue; + } + }; + + let mut intent = + CreatePaymentIntent::new(*price as i64, currency); let mut metadata = HashMap::new(); metadata.insert( From 4d10a4326e0dd62212dec80adc46d1406dae9c51 Mon Sep 17 00:00:00 2001 From: Jai A Date: Sat, 5 Oct 2024 17:38:09 -0700 Subject: [PATCH 2/7] Finish most + pyro integration --- .env | 4 +- ...4feaad606fb3aa80a749a36b323d77b01931a.json | 23 + ...d07db828f2a8b81cb6d9e640b94d8d12eb28f.json | 76 ++ ...192c73c1d4e8bdb43b0755607f1182d24c46d.json | 21 + ...59012beddbf0de01a78e63101ef265f096a03.json | 15 + ...b6aab6d500d836b01094076cb751c3c349e3.json} | 16 +- ...5f13c7934e3019675c04d9d3f240eb590bdc4.json | 22 + ...9174f196a50cd74c9243ddd57d6f4f3d0b062.json | 21 - ...596db21e1daaa74f53381b47847fc9229a993.json | 76 ++ ...70122465388c2a9b1837aa572d69baa38fe1.json} | 16 +- ...2dc03483c1e578dd5ea39119fcf5ad58d8250.json | 15 - ...41e3504bab316de4e30ea263ceb3ffcbc210.json} | 36 +- migrations/20240923163452_charges-fix.sql | 7 +- src/database/models/charge_item.rs | 21 +- src/database/models/user_subscription_item.rs | 39 +- src/lib.rs | 2 + src/models/v3/billing.rs | 20 +- src/routes/internal/billing.rs | 672 ++++++++++++------ 18 files changed, 777 insertions(+), 325 deletions(-) create mode 100644 .sqlx/query-375262713edeccbdf9770a180bf4feaad606fb3aa80a749a36b323d77b01931a.json create mode 100644 .sqlx/query-3c2665e46fe6a66cba9382426bed07db828f2a8b81cb6d9e640b94d8d12eb28f.json create mode 100644 .sqlx/query-75d10a180e66d5741318da652b7192c73c1d4e8bdb43b0755607f1182d24c46d.json create mode 100644 .sqlx/query-7aae2ea58ac43d7597a8075692359012beddbf0de01a78e63101ef265f096a03.json rename .sqlx/{query-07afad3b85ed64acbe9584570fdec92f923abf17439f0011e2b46797cec0ad97.json => query-80673778f6fea2dc776e722376d6b6aab6d500d836b01094076cb751c3c349e3.json} (76%) create mode 100644 .sqlx/query-95b52480a3fc7257a95e1cbc0e05f13c7934e3019675c04d9d3f240eb590bdc4.json delete mode 100644 .sqlx/query-e82ba8bafb4e45b8a8a100c639a9174f196a50cd74c9243ddd57d6f4f3d0b062.json create mode 100644 .sqlx/query-f1c1442c72e7b9761e1786e8ac8596db21e1daaa74f53381b47847fc9229a993.json rename .sqlx/{query-d6d3c29ff2aa3b311a19225cefcd5b8844fbe5bedf44ffe24f31b12e5bc5f868.json => query-f3a021c0eeb5c02592d7dddc6c5f70122465388c2a9b1837aa572d69baa38fe1.json} (77%) delete mode 100644 .sqlx/query-f643ba5f92e5f76cc2f9d2016f52dc03483c1e578dd5ea39119fcf5ad58d8250.json rename .sqlx/{query-61a87b00baaba022ab32eedf177d5b9dc6d5b7568cf1df15fac6c9e85acfa448.json => query-f6cb97b547c80e1f47467f33bbc641e3504bab316de4e30ea263ceb3ffcbc210.json} (59%) diff --git a/.env b/.env index d24a0d20..07dc02ee 100644 --- a/.env +++ b/.env @@ -103,4 +103,6 @@ FLAME_ANVIL_URL=none STRIPE_API_KEY=none STRIPE_WEBHOOK_SECRET=none -ADITUDE_API_KEY=none \ No newline at end of file +ADITUDE_API_KEY=none + +PYRO_API_KEY=none \ No newline at end of file diff --git a/.sqlx/query-375262713edeccbdf9770a180bf4feaad606fb3aa80a749a36b323d77b01931a.json b/.sqlx/query-375262713edeccbdf9770a180bf4feaad606fb3aa80a749a36b323d77b01931a.json new file mode 100644 index 00000000..3f7a136b --- /dev/null +++ b/.sqlx/query-375262713edeccbdf9770a180bf4feaad606fb3aa80a749a36b323d77b01931a.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Int8", + "Text", + "Int8", + "Text", + "Varchar", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "375262713edeccbdf9770a180bf4feaad606fb3aa80a749a36b323d77b01931a" +} diff --git a/.sqlx/query-3c2665e46fe6a66cba9382426bed07db828f2a8b81cb6d9e640b94d8d12eb28f.json b/.sqlx/query-3c2665e46fe6a66cba9382426bed07db828f2a8b81cb6d9e640b94d8d12eb28f.json new file mode 100644 index 00000000..889b0639 --- /dev/null +++ b/.sqlx/query-3c2665e46fe6a66cba9382426bed07db828f2a8b81cb6d9e640b94d8d12eb28f.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt\n FROM charges\n WHERE (status = 'open' 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": "subscription_id", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "interval", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "due", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "last_attempt", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + false, + false, + false + ] + }, + "hash": "3c2665e46fe6a66cba9382426bed07db828f2a8b81cb6d9e640b94d8d12eb28f" +} diff --git a/.sqlx/query-75d10a180e66d5741318da652b7192c73c1d4e8bdb43b0755607f1182d24c46d.json b/.sqlx/query-75d10a180e66d5741318da652b7192c73c1d4e8bdb43b0755607f1182d24c46d.json new file mode 100644 index 00000000..1cd31c4e --- /dev/null +++ b/.sqlx/query-75d10a180e66d5741318da652b7192c73c1d4e8bdb43b0755607f1182d24c46d.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users_subscriptions (\n id, user_id, price_id, interval, created, expires, status, metadata\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8\n )\n ON CONFLICT (id)\n DO UPDATE\n SET interval = EXCLUDED.interval,\n expires = EXCLUDED.expires,\n status = EXCLUDED.status,\n price_id = EXCLUDED.price_id,\n metadata = EXCLUDED.metadata\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Text", + "Timestamptz", + "Timestamptz", + "Varchar", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "75d10a180e66d5741318da652b7192c73c1d4e8bdb43b0755607f1182d24c46d" +} diff --git a/.sqlx/query-7aae2ea58ac43d7597a8075692359012beddbf0de01a78e63101ef265f096a03.json b/.sqlx/query-7aae2ea58ac43d7597a8075692359012beddbf0de01a78e63101ef265f096a03.json new file mode 100644 index 00000000..7b6d4f03 --- /dev/null +++ b/.sqlx/query-7aae2ea58ac43d7597a8075692359012beddbf0de01a78e63101ef265f096a03.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET badges = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "7aae2ea58ac43d7597a8075692359012beddbf0de01a78e63101ef265f096a03" +} diff --git a/.sqlx/query-07afad3b85ed64acbe9584570fdec92f923abf17439f0011e2b46797cec0ad97.json b/.sqlx/query-80673778f6fea2dc776e722376d6b6aab6d500d836b01094076cb751c3c349e3.json similarity index 76% rename from .sqlx/query-07afad3b85ed64acbe9584570fdec92f923abf17439f0011e2b46797cec0ad97.json rename to .sqlx/query-80673778f6fea2dc776e722376d6b6aab6d500d836b01094076cb751c3c349e3.json index bc77da8c..b87784f2 100644 --- a/.sqlx/query-07afad3b85ed64acbe9584570fdec92f923abf17439f0011e2b46797cec0ad97.json +++ b/.sqlx/query-80673778f6fea2dc776e722376d6b6aab6d500d836b01094076cb751c3c349e3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, interval, created, expires, last_charge, status\n FROM users_subscriptions\n WHERE id = ANY($1::bigint[])", + "query": "\n SELECT\n id, user_id, price_id, interval, created, expires, status, metadata\n FROM users_subscriptions\n WHERE id = ANY($1::bigint[])", "describe": { "columns": [ { @@ -35,13 +35,13 @@ }, { "ordinal": 6, - "name": "last_charge", - "type_info": "Timestamptz" + "name": "status", + "type_info": "Varchar" }, { "ordinal": 7, - "name": "status", - "type_info": "Varchar" + "name": "metadata", + "type_info": "Jsonb" } ], "parameters": { @@ -56,9 +56,9 @@ false, false, false, - true, - false + false, + true ] }, - "hash": "07afad3b85ed64acbe9584570fdec92f923abf17439f0011e2b46797cec0ad97" + "hash": "80673778f6fea2dc776e722376d6b6aab6d500d836b01094076cb751c3c349e3" } diff --git a/.sqlx/query-95b52480a3fc7257a95e1cbc0e05f13c7934e3019675c04d9d3f240eb590bdc4.json b/.sqlx/query-95b52480a3fc7257a95e1cbc0e05f13c7934e3019675c04d9d3f240eb590bdc4.json new file mode 100644 index 00000000..baa4b0c8 --- /dev/null +++ b/.sqlx/query-95b52480a3fc7257a95e1cbc0e05f13c7934e3019675c04d9d3f240eb590bdc4.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM charges WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "95b52480a3fc7257a95e1cbc0e05f13c7934e3019675c04d9d3f240eb590bdc4" +} diff --git a/.sqlx/query-e82ba8bafb4e45b8a8a100c639a9174f196a50cd74c9243ddd57d6f4f3d0b062.json b/.sqlx/query-e82ba8bafb4e45b8a8a100c639a9174f196a50cd74c9243ddd57d6f4f3d0b062.json deleted file mode 100644 index 6546eb3d..00000000 --- a/.sqlx/query-e82ba8bafb4e45b8a8a100c639a9174f196a50cd74c9243ddd57d6f4f3d0b062.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO users_subscriptions (\n id, user_id, price_id, interval, created, expires, last_charge, status\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8\n )\n ON CONFLICT (id)\n DO UPDATE\n SET interval = EXCLUDED.interval,\n expires = EXCLUDED.expires,\n last_charge = EXCLUDED.last_charge,\n status = EXCLUDED.status,\n price_id = EXCLUDED.price_id\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Int8", - "Text", - "Timestamptz", - "Timestamptz", - "Timestamptz", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "e82ba8bafb4e45b8a8a100c639a9174f196a50cd74c9243ddd57d6f4f3d0b062" -} diff --git a/.sqlx/query-f1c1442c72e7b9761e1786e8ac8596db21e1daaa74f53381b47847fc9229a993.json b/.sqlx/query-f1c1442c72e7b9761e1786e8ac8596db21e1daaa74f53381b47847fc9229a993.json new file mode 100644 index 00000000..dda71d1c --- /dev/null +++ b/.sqlx/query-f1c1442c72e7b9761e1786e8ac8596db21e1daaa74f53381b47847fc9229a993.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt\n FROM charges\n WHERE user_id = $1", + "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": "subscription_id", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "interval", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "due", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "last_attempt", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + false, + false, + false + ] + }, + "hash": "f1c1442c72e7b9761e1786e8ac8596db21e1daaa74f53381b47847fc9229a993" +} diff --git a/.sqlx/query-d6d3c29ff2aa3b311a19225cefcd5b8844fbe5bedf44ffe24f31b12e5bc5f868.json b/.sqlx/query-f3a021c0eeb5c02592d7dddc6c5f70122465388c2a9b1837aa572d69baa38fe1.json similarity index 77% rename from .sqlx/query-d6d3c29ff2aa3b311a19225cefcd5b8844fbe5bedf44ffe24f31b12e5bc5f868.json rename to .sqlx/query-f3a021c0eeb5c02592d7dddc6c5f70122465388c2a9b1837aa572d69baa38fe1.json index df277ea6..49f25866 100644 --- a/.sqlx/query-d6d3c29ff2aa3b311a19225cefcd5b8844fbe5bedf44ffe24f31b12e5bc5f868.json +++ b/.sqlx/query-f3a021c0eeb5c02592d7dddc6c5f70122465388c2a9b1837aa572d69baa38fe1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, interval, created, expires, last_charge, status\n FROM users_subscriptions\n WHERE user_id = $1", + "query": "\n SELECT\n id, user_id, price_id, interval, created, expires, status, metadata\n FROM users_subscriptions\n WHERE user_id = $1", "describe": { "columns": [ { @@ -35,13 +35,13 @@ }, { "ordinal": 6, - "name": "last_charge", - "type_info": "Timestamptz" + "name": "status", + "type_info": "Varchar" }, { "ordinal": 7, - "name": "status", - "type_info": "Varchar" + "name": "metadata", + "type_info": "Jsonb" } ], "parameters": { @@ -56,9 +56,9 @@ false, false, false, - true, - false + false, + true ] }, - "hash": "d6d3c29ff2aa3b311a19225cefcd5b8844fbe5bedf44ffe24f31b12e5bc5f868" + "hash": "f3a021c0eeb5c02592d7dddc6c5f70122465388c2a9b1837aa572d69baa38fe1" } diff --git a/.sqlx/query-f643ba5f92e5f76cc2f9d2016f52dc03483c1e578dd5ea39119fcf5ad58d8250.json b/.sqlx/query-f643ba5f92e5f76cc2f9d2016f52dc03483c1e578dd5ea39119fcf5ad58d8250.json deleted file mode 100644 index 75db1e6c..00000000 --- a/.sqlx/query-f643ba5f92e5f76cc2f9d2016f52dc03483c1e578dd5ea39119fcf5ad58d8250.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE users\n SET badges = $1\n WHERE (id = $2)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "f643ba5f92e5f76cc2f9d2016f52dc03483c1e578dd5ea39119fcf5ad58d8250" -} diff --git a/.sqlx/query-61a87b00baaba022ab32eedf177d5b9dc6d5b7568cf1df15fac6c9e85acfa448.json b/.sqlx/query-f6cb97b547c80e1f47467f33bbc641e3504bab316de4e30ea263ceb3ffcbc210.json similarity index 59% rename from .sqlx/query-61a87b00baaba022ab32eedf177d5b9dc6d5b7568cf1df15fac6c9e85acfa448.json rename to .sqlx/query-f6cb97b547c80e1f47467f33bbc641e3504bab316de4e30ea263ceb3ffcbc210.json index a91ed2eb..2dda14a0 100644 --- a/.sqlx/query-61a87b00baaba022ab32eedf177d5b9dc6d5b7568cf1df15fac6c9e85acfa448.json +++ b/.sqlx/query-f6cb97b547c80e1f47467f33bbc641e3504bab316de4e30ea263ceb3ffcbc210.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, interval, created, expires, last_charge, status\n FROM users_subscriptions\n WHERE expires < $1", + "query": "\n SELECT id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt\n FROM charges\n WHERE id = $1", "describe": { "columns": [ { @@ -20,33 +20,43 @@ }, { "ordinal": 3, - "name": "interval", - "type_info": "Text" + "name": "amount", + "type_info": "Int8" }, { "ordinal": 4, - "name": "created", - "type_info": "Timestamptz" + "name": "currency_code", + "type_info": "Text" }, { "ordinal": 5, - "name": "expires", - "type_info": "Timestamptz" + "name": "subscription_id", + "type_info": "Int8" }, { "ordinal": 6, - "name": "last_charge", - "type_info": "Timestamptz" + "name": "interval", + "type_info": "Text" }, { "ordinal": 7, "name": "status", "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "due", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "last_attempt", + "type_info": "Timestamptz" } ], "parameters": { "Left": [ - "Timestamptz" + "Int8" ] }, "nullable": [ @@ -55,10 +65,12 @@ false, false, false, - false, true, + true, + false, + false, false ] }, - "hash": "61a87b00baaba022ab32eedf177d5b9dc6d5b7568cf1df15fac6c9e85acfa448" + "hash": "f6cb97b547c80e1f47467f33bbc641e3504bab316de4e30ea263ceb3ffcbc210" } diff --git a/migrations/20240923163452_charges-fix.sql b/migrations/20240923163452_charges-fix.sql index 378bd488..2df73e6a 100644 --- a/migrations/20240923163452_charges-fix.sql +++ b/migrations/20240923163452_charges-fix.sql @@ -4,9 +4,12 @@ CREATE TABLE charges ( price_id bigint REFERENCES products_prices NOT NULL, amount bigint NOT NULL, currency_code text NOT NULL, - subscription_id bigint REFERENCES users_subscriptions NULL, + subscription_id bigint NULL, interval text NULL, status varchar(255) NOT NULL, due timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, last_attempt timestamptz NOT NULL -); \ No newline at end of file +); + +ALTER TABLE users_subscriptions DROP COLUMN last_charge; +ALTER TABLE users_subscriptions ADD COLUMN metadata jsonb NULL; \ No newline at end of file diff --git a/src/database/models/charge_item.rs b/src/database/models/charge_item.rs index af535a7a..1320c605 100644 --- a/src/database/models/charge_item.rs +++ b/src/database/models/charge_item.rs @@ -3,7 +3,7 @@ use crate::database::models::{ }; use crate::models::billing::{ChargeStatus, PriceDuration}; use chrono::{DateTime, Utc}; -use std::convert::TryFrom; +use std::convert::{TryFrom, TryInto}; pub struct ChargeItem { pub id: ChargeId, @@ -65,14 +65,19 @@ macro_rules! select_charges_with_predicate { } impl ChargeItem { - pub async fn insert( + pub async fn upsert( &self, - exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result { sqlx::query!( r#" INSERT INTO charges (id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) + DO UPDATE + SET status = EXCLUDED.status, + last_attempt = EXCLUDED.last_attempt, + due = EXCLUDED.due "#, self.id.0, self.user_id.0, @@ -85,7 +90,7 @@ impl ChargeItem { self.due, self.last_attempt, ) - .execute(exec) + .execute(&mut **transaction) .await?; Ok(self.id) @@ -95,17 +100,19 @@ impl ChargeItem { id: ChargeId, exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, ) -> Result, DatabaseError> { + let id = id.0; let res = select_charges_with_predicate!("WHERE id = $1", id) .fetch_optional(exec) .await?; - Ok(res.map(|r| r.try_into())) + Ok(res.and_then(|r| r.try_into().ok())) } pub async fn get_from_user( user_id: UserId, exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, ) -> Result, DatabaseError> { + let user_id = user_id.0; let res = select_charges_with_predicate!("WHERE user_id = $1", user_id) .fetch_all(exec) .await?; @@ -119,7 +126,9 @@ impl ChargeItem { pub async fn get_chargeable( exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, ) -> Result, DatabaseError> { - let res = select_charges_with_predicate!("WHERE (status = 'open' AND due < NOW()) OR (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')") + let now = Utc::now(); + + let res = select_charges_with_predicate!("WHERE (status = 'open' AND due < $1) OR (status = 'failed' AND last_attempt < $1 - INTERVAL '2 days')", now) .fetch_all(exec) .await?; diff --git a/src/database/models/user_subscription_item.rs b/src/database/models/user_subscription_item.rs index fb5af314..d851893a 100644 --- a/src/database/models/user_subscription_item.rs +++ b/src/database/models/user_subscription_item.rs @@ -1,7 +1,8 @@ use crate::database::models::{DatabaseError, ProductPriceId, UserId, UserSubscriptionId}; -use crate::models::billing::{PriceDuration, SubscriptionStatus}; +use crate::models::billing::{PriceDuration, SubscriptionMetadata, SubscriptionStatus}; use chrono::{DateTime, Utc}; use itertools::Itertools; +use std::convert::{TryFrom, TryInto}; pub struct UserSubscriptionItem { pub id: UserSubscriptionId, @@ -10,8 +11,8 @@ pub struct UserSubscriptionItem { pub interval: PriceDuration, pub created: DateTime, pub expires: DateTime, - pub last_charge: Option>, pub status: SubscriptionStatus, + pub metadata: Option, } struct UserSubscriptionResult { @@ -21,8 +22,8 @@ struct UserSubscriptionResult { interval: String, pub created: DateTime, pub expires: DateTime, - pub last_charge: Option>, pub status: String, + pub metadata: serde_json::Value, } macro_rules! select_user_subscriptions_with_predicate { @@ -31,7 +32,7 @@ macro_rules! select_user_subscriptions_with_predicate { UserSubscriptionResult, r#" SELECT - id, user_id, price_id, interval, created, expires, last_charge, status + id, user_id, price_id, interval, created, expires, status, metadata FROM users_subscriptions "# + $predicate, @@ -40,18 +41,20 @@ macro_rules! select_user_subscriptions_with_predicate { }; } -impl From for UserSubscriptionItem { - fn from(r: UserSubscriptionResult) -> Self { - UserSubscriptionItem { +impl TryFrom for UserSubscriptionItem { + type Error = serde_json::Error; + + fn try_from(r: UserSubscriptionResult) -> Result { + Ok(UserSubscriptionItem { id: UserSubscriptionId(r.id), user_id: UserId(r.user_id), price_id: ProductPriceId(r.price_id), interval: PriceDuration::from_string(&r.interval), created: r.created, expires: r.expires, - last_charge: r.last_charge, status: SubscriptionStatus::from_string(&r.status), - } + metadata: serde_json::from_value(r.metadata)?, + }) } } @@ -74,7 +77,10 @@ impl UserSubscriptionItem { .fetch_all(exec) .await?; - Ok(results.into_iter().map(|r| r.into()).collect()) + Ok(results + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) } pub async fn get_all_user( @@ -86,7 +92,10 @@ impl UserSubscriptionItem { .fetch_all(exec) .await?; - Ok(results.into_iter().map(|r| r.into()).collect()) + Ok(results + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) } pub async fn upsert( @@ -96,7 +105,7 @@ impl UserSubscriptionItem { sqlx::query!( " INSERT INTO users_subscriptions ( - id, user_id, price_id, interval, created, expires, last_charge, status + id, user_id, price_id, interval, created, expires, status, metadata ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8 @@ -105,9 +114,9 @@ impl UserSubscriptionItem { DO UPDATE SET interval = EXCLUDED.interval, expires = EXCLUDED.expires, - last_charge = EXCLUDED.last_charge, status = EXCLUDED.status, - price_id = EXCLUDED.price_id + price_id = EXCLUDED.price_id, + metadata = EXCLUDED.metadata ", self.id.0, self.user_id.0, @@ -115,8 +124,8 @@ impl UserSubscriptionItem { self.interval.as_str(), self.created, self.expires, - self.last_charge, self.status.as_str(), + serde_json::to_value(&self.metadata)?, ) .execute(&mut **transaction) .await?; diff --git a/src/lib.rs b/src/lib.rs index 29b5af1c..bde9760b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -456,5 +456,7 @@ pub fn check_env_vars() -> bool { failed |= check_var::("ADITUDE_API_KEY"); + failed |= check_var::("PYRO_API_KEY"); + failed } diff --git a/src/models/v3/billing.rs b/src/models/v3/billing.rs index c1e3d703..30d2917f 100644 --- a/src/models/v3/billing.rs +++ b/src/models/v3/billing.rs @@ -21,6 +21,7 @@ pub struct Product { #[serde(tag = "type", rename_all = "kebab-case")] pub enum ProductMetadata { Midas, + Pyro { ram: u32 }, } #[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] @@ -55,6 +56,13 @@ pub enum PriceDuration { } impl PriceDuration { + pub fn duration(&self) -> chrono::Duration { + match self { + PriceDuration::Monthly => chrono::Duration::days(30), + PriceDuration::Yearly => chrono::Duration::days(365), + } + } + pub fn from_string(string: &str) -> PriceDuration { match string { "monthly" => PriceDuration::Monthly, @@ -85,7 +93,7 @@ pub struct UserSubscription { pub status: SubscriptionStatus, pub created: DateTime, pub expires: DateTime, - pub last_charge: Option>, + pub metadata: Option, } impl From @@ -100,7 +108,7 @@ impl From status: x.status, created: x.created, expires: x.expires, - last_charge: x.last_charge, + metadata: x.metadata, } } } @@ -135,6 +143,12 @@ impl SubscriptionStatus { } } +#[derive(Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum SubscriptionMetadata { + Pyro { id: String }, +} + #[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] #[serde(from = "Base62Id")] #[serde(into = "Base62Id")] @@ -154,7 +168,7 @@ pub struct Charge { pub last_attempt: Option>, } -#[derive(Serialize, Deserialize, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "kebab-case")] pub enum ChargeStatus { // Open charges are for the next billing interval diff --git a/src/routes/internal/billing.rs b/src/routes/internal/billing.rs index 424d0a1f..6b7265e4 100644 --- a/src/routes/internal/billing.rs +++ b/src/routes/internal/billing.rs @@ -5,7 +5,7 @@ use crate::database::models::{ use crate::database::redis::RedisPool; use crate::models::billing::{ Charge, ChargeStatus, Price, PriceDuration, Product, ProductMetadata, ProductPrice, - SubscriptionStatus, UserSubscription, + SubscriptionMetadata, SubscriptionStatus, UserSubscription, }; use crate::models::ids::base62_impl::{parse_base62, to_base62}; use crate::models::pats::Scopes; @@ -15,15 +15,16 @@ use crate::routes::ApiError; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::{Duration, Utc}; use log::{info, warn}; +use serde::Serialize; use serde_with::serde_derive::Deserialize; -use sqlx::PgPool; +use sqlx::{PgPool, Postgres, Transaction}; use std::collections::{HashMap, HashSet}; use std::str::FromStr; use stripe::{ CreateCustomer, CreatePaymentIntent, CreatePaymentIntentAutomaticPaymentMethods, CreateSetupIntent, CreateSetupIntentAutomaticPaymentMethods, CreateSetupIntentAutomaticPaymentMethodsAllowRedirects, Currency, CustomerId, - CustomerInvoiceSettings, CustomerPaymentMethodRetrieval, EventObject, EventType, ListCharges, + CustomerInvoiceSettings, CustomerPaymentMethodRetrieval, EventObject, EventType, PaymentIntentOffSession, PaymentIntentSetupFutureUsage, PaymentMethodId, SetupIntent, UpdateCustomer, Webhook, }; @@ -34,7 +35,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { .service(products) .service(subscriptions) .service(user_customer) - .service(cancel_subscription) + .service(edit_subscription) .service(payment_methods) .service(add_payment_method_flow) .service(edit_payment_method) @@ -101,13 +102,21 @@ pub async fn subscriptions( Ok(HttpResponse::Ok().json(subscriptions)) } -#[delete("subscription/{id}")] -pub async fn cancel_subscription( +#[derive(Deserialize)] +pub struct SubscriptionEdit { + pub interval: Option, + pub status: Option, + pub product: Option, +} + +#[patch("subscription/{id}")] +pub async fn edit_subscription( req: HttpRequest, info: web::Path<(crate::models::ids::UserSubscriptionId,)>, pool: web::Data, redis: web::Data, session_queue: web::Data, + edit_subscription: web::Json, ) -> Result { let user = get_user_from_headers( &req, @@ -128,6 +137,11 @@ pub async fn cancel_subscription( return Err(ApiError::NotFound); } + // cancel: set status to cancelled + delete all open charges + // uncancel: set status to active + create new open charge + // change interval: set interval + update existing open charge + //.change plan: update existing open charge + let mut transaction = pool.begin().await?; if subscription.expires < Utc::now() { @@ -480,12 +494,22 @@ pub enum ChargeRequestType { }, } +#[derive(Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PaymentRequestMetadata { + Pyro { + server_name: Option, + source: serde_json::Value, + }, +} + #[derive(Deserialize)] pub struct PaymentRequest { #[serde(flatten)] pub type_: PaymentRequestType, pub charge: ChargeRequestType, pub existing_payment_intent: Option, + pub metadata: Option, } fn infer_currency_code(country: &str) -> String { @@ -697,22 +721,14 @@ pub async fn initiate_payment( ) .await?; - if let Some(product) = user_products + if user_products .into_iter() .find(|x| x.product_id == product.id) + .is_some() { - if let Some(subscription) = user_subscriptions - .into_iter() - .find(|x| x.price_id == product.id) - { - return Err(ApiError::InvalidInput(if !(subscription.status == SubscriptionStatus::Cancelled - || subscription.status == SubscriptionStatus::PaymentFailed) - { - "You are already subscribed to this product!" - } else { - "You are already subscribed to this product, but the payment failed!" - }.to_string())); - } + return Err(ApiError::InvalidInput( + "You are already subscribed to this product!".to_string(), + )); } } } @@ -755,7 +771,7 @@ pub async fn initiate_payment( .await?; Ok(HttpResponse::Ok().json(serde_json::json!({ - "price_id": to_base62(price_item.id.0 as u64), + "price_id": to_base62(price_id.0 as u64), "tax": 0, "total": price, "payment_method": payment_method, @@ -766,9 +782,29 @@ pub async fn initiate_payment( let mut metadata = HashMap::new(); metadata.insert("modrinth_user_id".to_string(), to_base62(user.id.0)); + if let Some(payment_metadata) = &payment_request.metadata { + metadata.insert( + "modrinth_payment_metadata".to_string(), + serde_json::to_string(&payment_metadata)?, + ); + } + if let Some(charge_id) = charge_id { metadata.insert("modrinth_charge_id".to_string(), to_base62(charge_id.0)); } else { + let mut transaction = pool.begin().await?; + let charge_id = generate_charge_id(&mut transaction).await?; + let subscription_id = generate_user_subscription_id(&mut transaction).await?; + + metadata.insert( + "modrinth_charge_id".to_string(), + to_base62(charge_id.0 as u64), + ); + metadata.insert( + "modrinth_subscription_id".to_string(), + to_base62(subscription_id.0 as u64), + ); + metadata.insert( "modrinth_price_id".to_string(), to_base62(price_id.0 as u64), @@ -800,7 +836,7 @@ pub async fn initiate_payment( Ok(HttpResponse::Ok().json(serde_json::json!({ "payment_intent_id": payment_intent.id, "client_secret": payment_intent.client_secret, - "price_id": to_base62(price_item.id.0 as u64), + "price_id": to_base62(price_id.0 as u64), "tax": 0, "total": price, "payment_method": payment_method, @@ -828,79 +864,199 @@ pub async fn stripe_webhook( &dotenvy::var("STRIPE_WEBHOOK_SECRET")?, ) { struct PaymentIntentMetadata { - user: crate::database::models::User, - user_subscription_data: Option<( - crate::database::models::ids::UserSubscriptionId, - PriceDuration, - )>, - user_subscription: Option, - product: product_item::ProductItem, - product_price: product_item::ProductPriceItem, - charge_item: Option, + pub user_item: crate::database::models::user_item::User, + pub product_price_item: product_item::ProductPriceItem, + pub product_item: product_item::ProductItem, + pub charge_item: crate::database::models::charge_item::ChargeItem, + pub user_subscription_item: Option, + pub payment_metadata: Option, } async fn get_payment_intent_metadata( metadata: HashMap, pool: &PgPool, redis: &RedisPool, + charge_status: ChargeStatus, + subscription_status: SubscriptionStatus, + transaction: &mut Transaction<'_, Postgres>, ) -> Result { - if let Some(user_id) = metadata - .get("modrinth_user_id") - .and_then(|x| parse_base62(x).ok()) - .map(|x| crate::database::models::ids::UserId(x as i64)) - { - let user = - crate::database::models::user_item::User::get_id(user_id, pool, redis).await?; + 'metadata: { + let user_id = if let Some(user_id) = metadata + .get("modrinth_user_id") + .and_then(|x| parse_base62(x).ok()) + .map(|x| crate::database::models::ids::UserId(x as i64)) + { + user_id + } else { + break 'metadata; + }; + + let user = if let Some(user) = + crate::database::models::user_item::User::get_id(user_id, pool, redis).await? + { + user + } else { + break 'metadata; + }; + + let payment_metadata = metadata + .get("modrinth_payment_metadata") + .and_then(|x| serde_json::from_str(x).ok()); + + let charge_id = if let Some(charge_id) = metadata + .get("modrinth_charge_id") + .and_then(|x| parse_base62(x).ok()) + .map(|x| crate::database::models::ids::ChargeId(x as i64)) + { + charge_id + } else { + break 'metadata; + }; + + let (charge, price, product, subscription) = if let Some(mut charge) = + crate::database::models::charge_item::ChargeItem::get(charge_id, pool).await? + { + let price = if let Some(price) = + product_item::ProductPriceItem::get(charge.price_id, pool).await? + { + price + } else { + break 'metadata; + }; - if let Some(user) = user { - let (user_subscription_data, user_subscription) = if let Some(subscription_id) = - metadata - .get("modrinth_subscription_id") - .and_then(|x| parse_base62(x).ok()) - .map(|x| crate::database::models::ids::UserSubscriptionId(x as i64)) + let product = if let Some(product) = + product_item::ProductItem::get(price.product_id, pool).await? { - if let Some(interval) = metadata - .get("modrinth_subscription_interval") - .map(|x| PriceDuration::from_string(x)) - { - let subscription = user_subscription_item::UserSubscriptionItem::get( - subscription_id, - pool, - ) - .await?; + product + } else { + break 'metadata; + }; - (Some((subscription_id, interval)), subscription) + charge.status = charge_status; + charge.last_attempt = Some(Utc::now()); + charge.upsert(transaction).await?; + + if let Some(subscription_id) = charge.subscription_id { + let mut subscription = if let Some(subscription) = + user_subscription_item::UserSubscriptionItem::get(subscription_id, pool) + .await? + { + subscription } else { - (None, None) + break 'metadata; + }; + + if charge_status == ChargeStatus::Succeeded { + subscription.expires = Utc::now() + subscription.interval.duration(); } - } else { - (None, None) - }; + subscription.status = subscription_status; + subscription.upsert(transaction).await?; - if let Some(price_id) = metadata + (charge, price, product, Some(subscription)) + } else { + (charge, price, product, None) + } + } else { + let price_id = if let Some(price_id) = metadata .get("modrinth_price_id") .and_then(|x| parse_base62(x).ok()) .map(|x| crate::database::models::ids::ProductPriceId(x as i64)) { - let price = product_item::ProductPriceItem::get(price_id, pool).await?; + price_id + } else { + break 'metadata; + }; - if let Some(product_price) = price { - let product = - product_item::ProductItem::get(product_price.product_id, pool) - .await?; + let price = if let Some(price) = + product_item::ProductPriceItem::get(price_id, pool).await? + { + price + } else { + break 'metadata; + }; - if let Some(product) = product { - return Ok(PaymentIntentMetadata { - user, - user_subscription_data, - user_subscription, - product, - product_price, - }); + let product = if let Some(product) = + product_item::ProductItem::get(price.product_id, pool).await? + { + product + } else { + break 'metadata; + }; + + let (amount, subscription) = match &price.prices { + Price::OneTime { price } => (*price, None), + Price::Recurring { intervals } => { + let interval = if let Some(interval) = metadata + .get("modrinth_subscription_interval") + .map(|x| PriceDuration::from_string(x)) + { + interval + } else { + break 'metadata; + }; + + if let Some(price) = intervals.get(&interval) { + let subscription_id = if let Some(subscription_id) = metadata + .get("modrinth_subscription_id") + .and_then(|x| parse_base62(x).ok()) + .map(|x| { + crate::database::models::ids::UserSubscriptionId(x as i64) + }) { + subscription_id + } else { + break 'metadata; + }; + + let subscription = user_subscription_item::UserSubscriptionItem { + id: subscription_id, + user_id, + price_id, + interval, + created: Utc::now(), + expires: Utc::now() + interval.duration(), + status: subscription_status, + metadata: None, + }; + + if charge_status != ChargeStatus::Failed { + subscription.upsert(transaction).await?; + } + + (*price, Some(subscription)) + } else { + break 'metadata; } } + }; + + let charge = crate::database::models::charge_item::ChargeItem { + id: charge_id, + user_id, + price_id, + amount: amount as i64, + currency_code: price.currency_code.clone(), + subscription_id: subscription.as_ref().map(|x| x.id), + interval: subscription.as_ref().map(|x| x.interval), + status: charge_status, + due: Utc::now(), + last_attempt: Some(Utc::now()), + }; + + if charge_status != ChargeStatus::Failed { + charge.upsert(transaction).await?; } - } + + (charge, price, product, subscription) + }; + + return Ok(PaymentIntentMetadata { + user_item: user, + product_price_item: price, + product_item: product, + charge_item: charge, + user_subscription_item: subscription, + payment_metadata, + }); } Err(ApiError::InvalidInput( @@ -911,43 +1067,22 @@ pub async fn stripe_webhook( match event.type_ { EventType::PaymentIntentSucceeded => { if let EventObject::PaymentIntent(payment_intent) = event.data.object { - let metadata = - get_payment_intent_metadata(payment_intent.metadata, &pool, &redis).await?; - let mut transaction = pool.begin().await?; - if let Some((subscription_id, interval)) = metadata.user_subscription_data { - let duration = match interval { - PriceDuration::Monthly => Duration::days(30), - PriceDuration::Yearly => Duration::days(365), - }; - - if let Some(mut user_subscription) = metadata.user_subscription { - user_subscription.expires += duration; - user_subscription.status = SubscriptionStatus::Active; - user_subscription.interval = interval; - user_subscription.price_id = metadata.product_price.id; - user_subscription.upsert(&mut transaction).await?; - } else { - user_subscription_item::UserSubscriptionItem { - id: subscription_id, - user_id: metadata.user.id, - price_id: metadata.product_price.id, - interval, - created: Utc::now(), - expires: Utc::now() + duration, - last_charge: None, - status: SubscriptionStatus::Active, - } - .upsert(&mut transaction) - .await?; - } - } + let metadata = get_payment_intent_metadata( + payment_intent.metadata, + &pool, + &redis, + ChargeStatus::Succeeded, + SubscriptionStatus::Active, + &mut transaction, + ) + .await?; // Provision subscription - match metadata.product.metadata { + match metadata.product_item.metadata { ProductMetadata::Midas => { - let badges = metadata.user.badges | Badges::MIDAS; + let badges = metadata.user_item.badges | Badges::MIDAS; sqlx::query!( " @@ -956,16 +1091,86 @@ pub async fn stripe_webhook( WHERE (id = $2) ", badges.bits() as i64, - metadata.user.id as crate::database::models::ids::UserId, + metadata.user_item.id as crate::database::models::ids::UserId, ) .execute(&mut *transaction) .await?; } + ProductMetadata::Pyro { ram } => { + 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 + .post(format!( + "https://archon.pyro.host/v0/servers/{}/unsuspend", + id + )) + .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) + .send() + .await; + + 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(|| { + format!("{}'s server", metadata.user_item.username) + }); + + let res = client + .post("https://archon.pyro.host/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, + }, + "source": source, + })) + .send() + .await; + + if let Err(e) = res { + warn!("Error creating pyro server: {:?}", e); + } + } + } + } + } + } + + if let Some(subscription) = metadata.user_subscription_item { + let charge_id = generate_charge_id(&mut transaction).await?; + let charge = crate::database::models::charge_item::ChargeItem { + id: charge_id, + user_id: metadata.user_item.id, + price_id: metadata.product_price_item.id, + amount: metadata.charge_item.amount, + currency_code: metadata.product_price_item.currency_code, + subscription_id: Some(subscription.id), + interval: Some(subscription.interval), + status: ChargeStatus::Open, + due: subscription.expires, + last_attempt: None, + }; + charge.upsert(&mut transaction).await?; } transaction.commit().await?; crate::database::models::user_item::User::clear_caches( - &[(metadata.user.id, None)], + &[(metadata.user_item.id, None)], &redis, ) .await?; @@ -973,83 +1178,47 @@ pub async fn stripe_webhook( } EventType::PaymentIntentProcessing => { if let EventObject::PaymentIntent(payment_intent) = event.data.object { - let metadata = - get_payment_intent_metadata(payment_intent.metadata, &pool, &redis).await?; - let mut transaction = pool.begin().await?; - - if let Some((subscription_id, interval)) = metadata.user_subscription_data { - if let Some(mut user_subscription) = metadata.user_subscription { - user_subscription.status = SubscriptionStatus::PaymentProcessing; - user_subscription.interval = interval; - user_subscription.price_id = metadata.product_price.id; - user_subscription.upsert(&mut transaction).await?; - } else { - user_subscription_item::UserSubscriptionItem { - id: subscription_id, - user_id: metadata.user.id, - price_id: metadata.product_price.id, - interval, - created: Utc::now(), - expires: Utc::now(), - last_charge: None, - status: SubscriptionStatus::PaymentProcessing, - } - .upsert(&mut transaction) - .await?; - } - } - + get_payment_intent_metadata( + payment_intent.metadata, + &pool, + &redis, + ChargeStatus::Processing, + SubscriptionStatus::PaymentProcessing, + &mut transaction, + ) + .await?; transaction.commit().await?; } } EventType::PaymentIntentPaymentFailed => { if let EventObject::PaymentIntent(payment_intent) = event.data.object { - let metadata = - get_payment_intent_metadata(payment_intent.metadata, &pool, &redis).await?; - let mut transaction = pool.begin().await?; - let price = match metadata.product_price.prices { - Price::OneTime { price } => Some(price), - Price::Recurring { intervals } => { - if let Some((_subscription_id, interval)) = - metadata.user_subscription_data - { - if let Some(mut user_subscription) = metadata.user_subscription { - user_subscription.last_charge = Some(Utc::now()); - user_subscription.status = SubscriptionStatus::PaymentFailed; - user_subscription.price_id = metadata.product_price.id; - user_subscription.interval = interval; - user_subscription.upsert(&mut transaction).await?; - - intervals.get(&interval).copied() - } else { - // We don't create a new subscription for a failed payment, so we return None here so no email is sent - None - } - } else { - None - } - } - }; + let metadata = get_payment_intent_metadata( + payment_intent.metadata, + &pool, + &redis, + ChargeStatus::Failed, + SubscriptionStatus::PaymentFailed, + &mut transaction, + ) + .await?; - if let Some(price) = price { - if let Some(email) = metadata.user.email { - let money = rusty_money::Money::from_minor( - price as i64, - rusty_money::iso::find(&metadata.product_price.currency_code) - .unwrap_or(rusty_money::iso::USD), - ); - - let _ = send_email( - email, - "Payment Failed for Modrinth", - &format!("Our attempt to collect payment for {money} from the payment card on file was unsuccessful."), - "Please visit the following link below to update your payment method or contact your card provider. If the button does not work, you can copy the link and paste it into your browser.", - Some(("Update billing settings", &format!("{}/{}", dotenvy::var("SITE_URL")?, dotenvy::var("SITE_BILLING_PATH")?))), - ); - } + if let Some(email) = metadata.user_item.email { + let money = rusty_money::Money::from_minor( + metadata.charge_item.amount, + rusty_money::iso::find(&metadata.charge_item.currency_code) + .unwrap_or(rusty_money::iso::USD), + ); + + let _ = send_email( + email, + "Payment Failed for Modrinth", + &format!("Our attempt to collect payment for {money} from the payment card on file was unsuccessful."), + "Please visit the following link below to update your payment method or contact your card provider. If the button does not work, you can copy the link and paste it into your browser.", + Some(("Update billing settings", &format!("{}/{}", dotenvy::var("SITE_URL")?, dotenvy::var("SITE_BILLING_PATH")?))), + ); } transaction.commit().await?; @@ -1140,16 +1309,15 @@ async fn get_or_create_customer( pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) { // Check for open charges which are open AND last charge hasn't already been attempted // CHeck for open charges which are failed ANd last attempt > 2 days ago (and unprovision) + // If charge's subscription is cancelled and expired, unprovision and remove - // if subscription is cancelled and expired, unprovision and remove - // if subscription is payment failed and last attempt is > 2 days ago, try again to charge and unprovision - // if subscription is active and expired, attempt to charge and set as processing loop { info!("Indexing billing queue"); let res = async { - let charges_to_do = crate::database::models::charge_item::ChargeItem::get_chargeable(&pool).await?; + let charges_to_do = + crate::database::models::charge_item::ChargeItem::get_chargeable(&pool).await?; - let subscription_items = user_subscription_item::UserSubscriptionItem::get_many( + let mut subscription_items = user_subscription_item::UserSubscriptionItem::get_many( &charges_to_do .iter() .flat_map(|x| x.subscription_id) @@ -1194,13 +1362,12 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) let mut transaction = pool.begin().await?; let mut clear_cache_users = Vec::new(); - for mut charge in charges_to_do { + for charge in charges_to_do { let user = users.iter().find(|x| x.id == charge.user_id); if let Some(user) = user { - let product_price = subscription_prices - .iter() - .find(|x| x.id == charge.price_id); + let product_price = + subscription_prices.iter().find(|x| x.id == charge.price_id); if let Some(product_price) = product_price { let product = subscription_products @@ -1208,55 +1375,92 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) .find(|x| x.id == product_price.product_id); if let Some(product) = product { + let mut subscription = charge + .subscription_id + .and_then(|x| subscription_items.iter_mut().find(|y| y.id == x)); + let price = match &product_price.prices { Price::OneTime { price } => Some(price), Price::Recurring { intervals } => { - intervals.get(&subscription.interval) + if let Some(ref subscription) = subscription { + intervals.get(&subscription.interval) + } else { + warn!( + "Could not find subscription for charge {:?}", + charge.id + ); + continue; + } } }; if let Some(price) = price { - let cancelled = - subscription.status == SubscriptionStatus::Cancelled; - let payment_failed = subscription - .last_charge - .map(|y| { - subscription.status == SubscriptionStatus::PaymentFailed - && Utc::now() - y > Duration::days(2) - }) - .unwrap_or(false); - let active = subscription.status == SubscriptionStatus::Active; - - // Unprovision subscription - if cancelled || payment_failed { - match product.metadata { - ProductMetadata::Midas => { - let badges = user.badges - Badges::MIDAS; - - sqlx::query!( - " - UPDATE users - SET badges = $1 - WHERE (id = $2) - ", - badges.bits() as i64, - user.id as crate::database::models::ids::UserId, - ) - .execute(&mut *transaction) - .await?; + let should_charge = if let Some(ref subscription) = subscription { + let cancelled = + subscription.status == SubscriptionStatus::Cancelled; + let payment_failed = charge + .last_attempt + .map(|y| { + subscription.status == SubscriptionStatus::PaymentFailed + && Utc::now() - y > Duration::days(2) + }) + .unwrap_or(false); + let active = subscription.status == SubscriptionStatus::Active; + + // Unprovision subscription + if cancelled || payment_failed { + match product.metadata { + ProductMetadata::Midas => { + let badges = user.badges - Badges::MIDAS; + + sqlx::query!( + " + UPDATE users + SET badges = $1 + WHERE (id = $2) + ", + badges.bits() as i64, + user.id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + ProductMetadata::Pyro { .. } => { + if let Some(SubscriptionMetadata::Pyro { id }) = &subscription.metadata { + let res = reqwest::Client::new() + .post(format!("https://archon.pyro.host/v0/servers/{}/suspend", id)) + .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) + .json(&serde_json::json!({ + "reason": if cancelled { "cancelled" } else { "paymentfailed" } + })) + .send() + .await; + + if let Err(e) = res { + warn!("Error suspending pyro server: {:?}", e); + } + } + } } + + clear_cache_users.push(user.id); } - clear_cache_users.push(user.id); - } + if cancelled { + user_subscription_item::UserSubscriptionItem::remove( + subscription.id, + &mut transaction, + ) + .await?; + false + } else { + payment_failed || active + } + } else { + true + }; - if cancelled { - user_subscription_item::UserSubscriptionItem::remove( - subscription.id, - &mut transaction, - ) - .await?; - } else if payment_failed || active { + if should_charge { let customer_id = get_or_create_customer( user.id.into(), user.stripe_customer_id.as_deref(), @@ -1296,16 +1500,8 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) to_base62(user.id.0 as u64), ); metadata.insert( - "modrinth_price_id".to_string(), - to_base62(product_price.id.0 as u64), - ); - metadata.insert( - "modrinth_subscription_id".to_string(), - to_base62(subscription.id.0 as u64), - ); - metadata.insert( - "modrinth_subscription_interval".to_string(), - subscription.interval.as_str().to_string(), + "modrinth_charge_id".to_string(), + to_base62(charge.id.0 as u64), ); intent.metadata = Some(metadata); @@ -1320,14 +1516,22 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) intent.off_session = Some(PaymentIntentOffSession::Exists(true)); - subscription.status = SubscriptionStatus::PaymentProcessing; + if let Some(ref mut subscription) = subscription { + subscription.status = + SubscriptionStatus::PaymentProcessing; + } + stripe::PaymentIntent::create(&stripe_client, intent) .await?; } else { - subscription.status = SubscriptionStatus::PaymentFailed; + if let Some(ref mut subscription) = subscription { + subscription.status = SubscriptionStatus::PaymentFailed; + } } - subscription.upsert(&mut transaction).await?; + if let Some(ref subscription) = subscription { + subscription.upsert(&mut transaction).await?; + } } } } From b9ce67c84fbd97e3b283174c24035b2d44edb747 Mon Sep 17 00:00:00 2001 From: Jai A Date: Wed, 9 Oct 2024 20:23:16 -0700 Subject: [PATCH 3/7] Finish billing --- migrations/20240923163452_charges-fix.sql | 10 +- src/database/models/charge_item.rs | 71 ++- src/database/models/user_subscription_item.rs | 58 +- src/lib.rs | 27 +- src/models/v3/billing.rs | 60 +- src/routes/internal/billing.rs | 565 ++++++++++-------- 6 files changed, 467 insertions(+), 324 deletions(-) diff --git a/migrations/20240923163452_charges-fix.sql b/migrations/20240923163452_charges-fix.sql index 2df73e6a..c494528e 100644 --- a/migrations/20240923163452_charges-fix.sql +++ b/migrations/20240923163452_charges-fix.sql @@ -4,12 +4,14 @@ CREATE TABLE charges ( price_id bigint REFERENCES products_prices NOT NULL, amount bigint NOT NULL, currency_code text NOT NULL, - subscription_id bigint NULL, - interval text NULL, status varchar(255) NOT NULL, due timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, - last_attempt timestamptz NOT NULL + last_attempt timestamptz NULL, + charge_type text NOT NULL, + subscription_id bigint NULL, + subscription_interval text NULL ); ALTER TABLE users_subscriptions DROP COLUMN last_charge; -ALTER TABLE users_subscriptions ADD COLUMN metadata jsonb NULL; \ No newline at end of file +ALTER TABLE users_subscriptions ADD COLUMN metadata jsonb NULL; +ALTER TABLE users_subscriptions DROP COLUMN expires; \ No newline at end of file diff --git a/src/database/models/charge_item.rs b/src/database/models/charge_item.rs index 1320c605..55482df9 100644 --- a/src/database/models/charge_item.rs +++ b/src/database/models/charge_item.rs @@ -1,7 +1,7 @@ use crate::database::models::{ ChargeId, DatabaseError, ProductPriceId, UserId, UserSubscriptionId, }; -use crate::models::billing::{ChargeStatus, PriceDuration}; +use crate::models::billing::{ChargeStatus, ChargeType, PriceDuration}; use chrono::{DateTime, Utc}; use std::convert::{TryFrom, TryInto}; @@ -11,11 +11,13 @@ pub struct ChargeItem { pub price_id: ProductPriceId, pub amount: i64, pub currency_code: String, - pub subscription_id: Option, - pub interval: Option, pub status: ChargeStatus, pub due: DateTime, pub last_attempt: Option>, + + pub type_: ChargeType, + pub subscription_id: Option, + pub subscription_interval: Option, } struct ChargeResult { @@ -24,11 +26,12 @@ struct ChargeResult { price_id: i64, amount: i64, currency_code: String, - subscription_id: Option, - interval: Option, status: String, due: DateTime, last_attempt: Option>, + charge_type: String, + subscription_id: Option, + subscription_interval: Option, } impl TryFrom for ChargeItem { @@ -41,11 +44,14 @@ impl TryFrom for ChargeItem { price_id: ProductPriceId(r.price_id), amount: r.amount, currency_code: r.currency_code, - subscription_id: r.subscription_id.map(UserSubscriptionId), - interval: r.interval.map(|x| serde_json::from_str(&x)).transpose()?, - status: serde_json::from_str(&r.status)?, + status: ChargeStatus::from_string(&r.status), due: r.due, last_attempt: r.last_attempt, + type_: ChargeType::from_string(&r.charge_type), + subscription_id: r.subscription_id.map(UserSubscriptionId), + subscription_interval: r + .subscription_interval + .map(|x| PriceDuration::from_string(&*x)), }) } } @@ -55,7 +61,7 @@ macro_rules! select_charges_with_predicate { sqlx::query_as!( ChargeResult, r#" - SELECT id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt + SELECT id, user_id, price_id, amount, currency_code, status, due, last_attempt, charge_type, subscription_id, subscription_interval FROM charges "# + $predicate, @@ -71,24 +77,27 @@ impl ChargeItem { ) -> Result { sqlx::query!( r#" - INSERT INTO charges (id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, last_attempt = EXCLUDED.last_attempt, - due = EXCLUDED.due + due = EXCLUDED.due, + subscription_id = EXCLUDED.subscription_id, + subscription_interval = EXCLUDED.subscription_interval "#, self.id.0, self.user_id.0, self.price_id.0, self.amount, self.currency_code, - self.subscription_id.map(|x| x.0), - self.interval.map(|x| x.as_str()), + self.type_.as_str(), self.status.as_str(), self.due, self.last_attempt, + self.subscription_id.map(|x| x.0), + self.subscription_interval.map(|x| x.as_str()), ) .execute(&mut **transaction) .await?; @@ -113,7 +122,7 @@ impl ChargeItem { exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, ) -> Result, DatabaseError> { let user_id = user_id.0; - let res = select_charges_with_predicate!("WHERE user_id = $1", user_id) + let res = select_charges_with_predicate!("WHERE user_id = $1 ORDER BY due DESC", user_id) .fetch_all(exec) .await?; @@ -123,6 +132,21 @@ impl ChargeItem { .collect::, serde_json::Error>>()?) } + pub async fn get_open_subscription( + user_subscription_id: UserSubscriptionId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let user_subscription_id = user_subscription_id.0; + let res = select_charges_with_predicate!( + "WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')", + user_subscription_id + ) + .fetch_optional(exec) + .await?; + + Ok(res.and_then(|r| r.try_into().ok())) + } + pub async fn get_chargeable( exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, ) -> Result, DatabaseError> { @@ -137,4 +161,21 @@ impl ChargeItem { .map(|r| r.try_into()) .collect::, serde_json::Error>>()?) } + + pub async fn remove( + id: ChargeId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + DELETE FROM charges + WHERE id = $1 + ", + id.0 as i64 + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } } diff --git a/src/database/models/user_subscription_item.rs b/src/database/models/user_subscription_item.rs index d851893a..8de0fd0e 100644 --- a/src/database/models/user_subscription_item.rs +++ b/src/database/models/user_subscription_item.rs @@ -10,7 +10,6 @@ pub struct UserSubscriptionItem { pub price_id: ProductPriceId, pub interval: PriceDuration, pub created: DateTime, - pub expires: DateTime, pub status: SubscriptionStatus, pub metadata: Option, } @@ -21,7 +20,6 @@ struct UserSubscriptionResult { price_id: i64, interval: String, pub created: DateTime, - pub expires: DateTime, pub status: String, pub metadata: serde_json::Value, } @@ -32,8 +30,8 @@ macro_rules! select_user_subscriptions_with_predicate { UserSubscriptionResult, r#" SELECT - id, user_id, price_id, interval, created, expires, status, metadata - FROM users_subscriptions + us.id, us.user_id, us.price_id, us.interval, us.created, us.status, us.metadata + FROM users_subscriptions us "# + $predicate, $param @@ -51,7 +49,6 @@ impl TryFrom for UserSubscriptionItem { price_id: ProductPriceId(r.price_id), interval: PriceDuration::from_string(&r.interval), created: r.created, - expires: r.expires, status: SubscriptionStatus::from_string(&r.status), metadata: serde_json::from_value(r.metadata)?, }) @@ -73,7 +70,7 @@ impl UserSubscriptionItem { let ids = ids.iter().map(|id| id.0).collect_vec(); let ids_ref: &[i64] = &ids; let results = - select_user_subscriptions_with_predicate!("WHERE id = ANY($1::bigint[])", ids_ref) + select_user_subscriptions_with_predicate!("WHERE us.id = ANY($1::bigint[])", ids_ref) .fetch_all(exec) .await?; @@ -88,7 +85,7 @@ impl UserSubscriptionItem { exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, ) -> Result, DatabaseError> { let user_id = user_id.0; - let results = select_user_subscriptions_with_predicate!("WHERE user_id = $1", user_id) + let results = select_user_subscriptions_with_predicate!("WHERE us.user_id = $1", user_id) .fetch_all(exec) .await?; @@ -98,6 +95,30 @@ 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>, @@ -105,15 +126,14 @@ impl UserSubscriptionItem { sqlx::query!( " INSERT INTO users_subscriptions ( - id, user_id, price_id, interval, created, expires, status, metadata + id, user_id, price_id, interval, created, status, metadata ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8 + $1, $2, $3, $4, $5, $6, $7 ) ON CONFLICT (id) DO UPDATE SET interval = EXCLUDED.interval, - expires = EXCLUDED.expires, status = EXCLUDED.status, price_id = EXCLUDED.price_id, metadata = EXCLUDED.metadata @@ -123,7 +143,6 @@ impl UserSubscriptionItem { self.price_id.0, self.interval.as_str(), self.created, - self.expires, self.status.as_str(), serde_json::to_value(&self.metadata)?, ) @@ -132,21 +151,4 @@ impl UserSubscriptionItem { Ok(()) } - - pub async fn remove( - id: UserSubscriptionId, - transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, - ) -> Result<(), DatabaseError> { - sqlx::query!( - " - DELETE FROM users_subscriptions - WHERE id = $1 - ", - id.0 as i64 - ) - .execute(&mut **transaction) - .await?; - - Ok(()) - } } diff --git a/src/lib.rs b/src/lib.rs index bde9760b..8d4bcc06 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -259,15 +259,24 @@ pub fn app_setup( } let stripe_client = stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap()); - // { - // let pool_ref = pool.clone(); - // let redis_ref = redis_pool.clone(); - // let stripe_client_ref = stripe_client.clone(); - // - // actix_rt::spawn(async move { - // routes::internal::billing::task(stripe_client_ref, pool_ref, redis_ref).await; - // }); - // } + { + let pool_ref = pool.clone(); + let redis_ref = redis_pool.clone(); + let stripe_client_ref = stripe_client.clone(); + + actix_rt::spawn(async move { + routes::internal::billing::task(stripe_client_ref, pool_ref, redis_ref).await; + }); + } + + { + let pool_ref = pool.clone(); + let redis_ref = redis_pool.clone(); + + actix_rt::spawn(async move { + routes::internal::billing::subscription_task(pool_ref, redis_ref).await; + }); + } let ip_salt = Pepper { pepper: models::ids::Base62Id(models::ids::random_base62(11)).to_string(), diff --git a/src/models/v3/billing.rs b/src/models/v3/billing.rs index 30d2917f..0cc1dadf 100644 --- a/src/models/v3/billing.rs +++ b/src/models/v3/billing.rs @@ -92,7 +92,6 @@ pub struct UserSubscription { pub interval: PriceDuration, pub status: SubscriptionStatus, pub created: DateTime, - pub expires: DateTime, pub metadata: Option, } @@ -107,38 +106,31 @@ impl From interval: x.interval, status: x.status, created: x.created, - expires: x.expires, metadata: x.metadata, } } } -#[derive(Serialize, Deserialize, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] #[serde(rename_all = "kebab-case")] pub enum SubscriptionStatus { - Active, - PaymentProcessing, - PaymentFailed, - Cancelled, + Provisioned, + Unprovisioned, } impl SubscriptionStatus { pub fn from_string(string: &str) -> SubscriptionStatus { match string { - "active" => SubscriptionStatus::Active, - "payment-processing" => SubscriptionStatus::PaymentProcessing, - "payment-failed" => SubscriptionStatus::PaymentFailed, - "cancelled" => SubscriptionStatus::Cancelled, - _ => SubscriptionStatus::Cancelled, + "provisioned" => SubscriptionStatus::Provisioned, + "unprovisioned" => SubscriptionStatus::Unprovisioned, + _ => SubscriptionStatus::Provisioned, } } pub fn as_str(&self) -> &'static str { match self { - SubscriptionStatus::Active => "active", - SubscriptionStatus::PaymentProcessing => "payment-processing", - SubscriptionStatus::PaymentFailed => "payment-failed", - SubscriptionStatus::Cancelled => "cancelled", + SubscriptionStatus::Provisioned => "provisioned", + SubscriptionStatus::Unprovisioned => "unprovisioned", } } } @@ -161,11 +153,40 @@ pub struct Charge { pub price_id: ProductPriceId, pub amount: i64, pub currency_code: String, - pub subscription_id: Option, - pub interval: Option, pub status: ChargeStatus, pub due: DateTime, pub last_attempt: Option>, + #[serde(flatten)] + pub type_: ChargeType, + pub subscription_id: Option, + pub subscription_interval: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum ChargeType { + OneTime, + Subscription, + Proration, +} + +impl ChargeType { + pub fn as_str(&self) -> &'static str { + match self { + ChargeType::OneTime => "one-time", + ChargeType::Subscription { .. } => "subscription", + ChargeType::Proration { .. } => "proration", + } + } + + pub fn from_string(string: &str) -> ChargeType { + match string { + "one-time" => ChargeType::OneTime, + "subscription" => ChargeType::Subscription, + "proration" => ChargeType::Proration, + _ => ChargeType::OneTime, + } + } } #[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] @@ -176,6 +197,7 @@ pub enum ChargeStatus { Processing, Succeeded, Failed, + Cancelled, } impl ChargeStatus { @@ -185,6 +207,7 @@ impl ChargeStatus { "succeeded" => ChargeStatus::Succeeded, "failed" => ChargeStatus::Failed, "open" => ChargeStatus::Open, + "cancelled" => ChargeStatus::Cancelled, _ => ChargeStatus::Failed, } } @@ -195,6 +218,7 @@ impl ChargeStatus { ChargeStatus::Succeeded => "succeeded", ChargeStatus::Failed => "failed", ChargeStatus::Open => "open", + ChargeStatus::Cancelled => "cancelled", } } } diff --git a/src/routes/internal/billing.rs b/src/routes/internal/billing.rs index 6b7265e4..50ba0bea 100644 --- a/src/routes/internal/billing.rs +++ b/src/routes/internal/billing.rs @@ -4,7 +4,7 @@ use crate::database::models::{ }; use crate::database::redis::RedisPool; use crate::models::billing::{ - Charge, ChargeStatus, Price, PriceDuration, Product, ProductMetadata, ProductPrice, + Charge, ChargeStatus, ChargeType, Price, PriceDuration, Product, ProductMetadata, ProductPrice, SubscriptionMetadata, SubscriptionStatus, UserSubscription, }; use crate::models::ids::base62_impl::{parse_base62, to_base62}; @@ -13,7 +13,7 @@ use crate::models::users::Badges; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; -use chrono::{Duration, Utc}; +use chrono::Utc; use log::{info, warn}; use serde::Serialize; use serde_with::serde_derive::Deserialize; @@ -21,8 +21,8 @@ use sqlx::{PgPool, Postgres, Transaction}; use std::collections::{HashMap, HashSet}; use std::str::FromStr; use stripe::{ - CreateCustomer, CreatePaymentIntent, CreatePaymentIntentAutomaticPaymentMethods, - CreateSetupIntent, CreateSetupIntentAutomaticPaymentMethods, + CreateCustomer, CreatePaymentIntent, CreateSetupIntent, + CreateSetupIntentAutomaticPaymentMethods, CreateSetupIntentAutomaticPaymentMethodsAllowRedirects, Currency, CustomerId, CustomerInvoiceSettings, CustomerPaymentMethodRetrieval, EventObject, EventType, PaymentIntentOffSession, PaymentIntentSetupFutureUsage, PaymentMethodId, SetupIntent, @@ -105,7 +105,7 @@ pub async fn subscriptions( #[derive(Deserialize)] pub struct SubscriptionEdit { pub interval: Option, - pub status: Option, + pub cancelled: Option, pub product: Option, } @@ -130,35 +130,64 @@ pub async fn edit_subscription( let (id,) = info.into_inner(); - if let Some(mut subscription) = + if let Some(subscription) = user_subscription_item::UserSubscriptionItem::get(id.into(), &**pool).await? { if subscription.user_id != user.id.into() && !user.role.is_admin() { return Err(ApiError::NotFound); } - // cancel: set status to cancelled + delete all open charges - // uncancel: set status to active + create new open charge - // change interval: set interval + update existing open charge - //.change plan: update existing open charge - let mut transaction = pool.begin().await?; - if subscription.expires < Utc::now() { - sqlx::query!( - " - DELETE FROM users_subscriptions - WHERE id = $1 - ", - subscription.id.0 as i64 + let mut open_charge = + crate::database::models::charge_item::ChargeItem::get_open_subscription( + subscription.id, + &mut *transaction, ) - .execute(&mut *transaction) - .await?; + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "Could not find open charge for this subscription".to_string(), + ) + })?; - // TODO: delete open charges for this subscription - } else { - subscription.status = SubscriptionStatus::Cancelled; - subscription.upsert(&mut transaction).await?; + let current_price = + product_item::ProductPriceItem::get(subscription.price_id, &mut *transaction) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("Could not find current product price".to_string()) + })?; + + if let Some(cancelled) = &edit_subscription.cancelled { + if open_charge.status != ChargeStatus::Open + || open_charge.status != ChargeStatus::Cancelled + { + return Err(ApiError::InvalidInput( + "You may not change the status of this subscription!".to_string(), + )); + } + + if *cancelled { + open_charge.status = ChargeStatus::Cancelled; + } else { + open_charge.status = ChargeStatus::Open; + } + } + + if let Some(interval) = &edit_subscription.interval { + match ¤t_price.prices { + Price::Recurring { intervals } => { + if let Some(price) = intervals.get(interval) { + open_charge.subscription_interval = Some(*interval); + open_charge.amount = *price as i64; + } else { + return Err(ApiError::InvalidInput( + "Interval is not valid for this subscription!".to_string(), + )); + } + } + _ => {} + }; } transaction.commit().await?; @@ -231,11 +260,12 @@ pub async fn charges( price_id: x.price_id.into(), amount: x.amount, currency_code: x.currency_code, - subscription_id: x.subscription_id.map(|x| x.into()), - interval: x.interval, status: x.status, due: x.due, last_attempt: x.last_attempt, + type_: x.type_, + subscription_id: x.subscription_id.map(|x| x.into()), + subscription_interval: x.subscription_interval, }) .collect::>(), )) @@ -404,7 +434,7 @@ pub async fn remove_payment_method( if user_subscriptions .iter() - .any(|x| x.status != SubscriptionStatus::Cancelled) + .any(|x| x.status != SubscriptionStatus::Unprovisioned) { let customer = stripe::Customer::retrieve(&stripe_client, &customer, &[]).await?; @@ -484,6 +514,7 @@ pub enum PaymentRequestType { } #[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] pub enum ChargeRequestType { Existing { id: crate::models::ids::ChargeId, @@ -655,7 +686,11 @@ pub async fn initiate_payment( ( charge.amount, charge.currency_code, - charge.interval, + if let Some(interval) = charge.subscription_interval { + Some(interval) + } else { + None + }, charge.price_id, Some(id), ) @@ -715,6 +750,7 @@ pub async fn initiate_payment( let user_products = product_item::ProductPriceItem::get_many( &user_subscriptions .iter() + .filter(|x| x.status == SubscriptionStatus::Provisioned) .map(|x| x.price_id) .collect::>(), &**pool, @@ -820,10 +856,6 @@ pub async fn initiate_payment( intent.customer = Some(customer); intent.metadata = Some(metadata); - intent.automatic_payment_methods = Some(CreatePaymentIntentAutomaticPaymentMethods { - allow_redirects: None, - enabled: false, - }); intent.receipt_email = user.email.as_deref(); intent.setup_future_usage = Some(PaymentIntentSetupFutureUsage::OffSession); @@ -877,7 +909,6 @@ pub async fn stripe_webhook( pool: &PgPool, redis: &RedisPool, charge_status: ChargeStatus, - subscription_status: SubscriptionStatus, transaction: &mut Transaction<'_, Postgres>, ) -> Result { 'metadata: { @@ -938,18 +969,20 @@ pub async fn stripe_webhook( if let Some(subscription_id) = charge.subscription_id { let mut subscription = if let Some(subscription) = - user_subscription_item::UserSubscriptionItem::get(subscription_id, pool) - .await? + user_subscription_item::UserSubscriptionItem::get( + subscription_id.into(), + pool, + ) + .await? { subscription } else { break 'metadata; }; - if charge_status == ChargeStatus::Succeeded { - subscription.expires = Utc::now() + subscription.interval.duration(); + if let Some(interval) = charge.subscription_interval { + subscription.interval = interval; } - subscription.status = subscription_status; subscription.upsert(transaction).await?; (charge, price, product, Some(subscription)) @@ -1013,8 +1046,11 @@ pub async fn stripe_webhook( price_id, interval, created: Utc::now(), - expires: Utc::now() + interval.duration(), - status: subscription_status, + status: if charge_status == ChargeStatus::Succeeded { + SubscriptionStatus::Provisioned + } else { + SubscriptionStatus::Unprovisioned + }, metadata: None, }; @@ -1035,11 +1071,16 @@ pub async fn stripe_webhook( price_id, amount: amount as i64, currency_code: price.currency_code.clone(), - subscription_id: subscription.as_ref().map(|x| x.id), - interval: subscription.as_ref().map(|x| x.interval), status: charge_status, due: Utc::now(), last_attempt: Some(Utc::now()), + type_: if subscription.is_some() { + ChargeType::Subscription + } else { + ChargeType::OneTime + }, + subscription_id: subscription.as_ref().map(|x| x.id), + subscription_interval: subscription.as_ref().map(|x| x.interval), }; if charge_status != ChargeStatus::Failed { @@ -1074,7 +1115,6 @@ pub async fn stripe_webhook( &pool, &redis, ChargeStatus::Succeeded, - SubscriptionStatus::Active, &mut transaction, ) .await?; @@ -1152,20 +1192,25 @@ pub async fn stripe_webhook( } if let Some(subscription) = metadata.user_subscription_item { - let charge_id = generate_charge_id(&mut transaction).await?; - let charge = crate::database::models::charge_item::ChargeItem { - id: charge_id, - user_id: metadata.user_item.id, - price_id: metadata.product_price_item.id, - amount: metadata.charge_item.amount, - currency_code: metadata.product_price_item.currency_code, - subscription_id: Some(subscription.id), - interval: Some(subscription.interval), - status: ChargeStatus::Open, - due: subscription.expires, - last_attempt: None, - }; - charge.upsert(&mut transaction).await?; + if metadata.charge_item.status != ChargeStatus::Cancelled { + let charge_id = generate_charge_id(&mut transaction).await?; + let charge = crate::database::models::charge_item::ChargeItem { + id: charge_id, + user_id: metadata.user_item.id, + price_id: metadata.product_price_item.id, + amount: metadata.charge_item.amount, + currency_code: metadata.product_price_item.currency_code, + status: ChargeStatus::Open, + due: Utc::now() + 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; + + err?; + } } transaction.commit().await?; @@ -1184,7 +1229,6 @@ pub async fn stripe_webhook( &pool, &redis, ChargeStatus::Processing, - SubscriptionStatus::PaymentProcessing, &mut transaction, ) .await?; @@ -1200,7 +1244,6 @@ pub async fn stripe_webhook( &pool, &redis, ChargeStatus::Failed, - SubscriptionStatus::PaymentFailed, &mut transaction, ) .await?; @@ -1306,29 +1349,19 @@ async fn get_or_create_customer( } } -pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) { - // Check for open charges which are open AND last charge hasn't already been attempted - // CHeck for open charges which are failed ANd last attempt > 2 days ago (and unprovision) - // If charge's subscription is cancelled and expired, unprovision and remove - +pub async fn subscription_task(pool: PgPool, redis: RedisPool) { loop { - info!("Indexing billing queue"); + info!("Indexing subscriptions"); + let res = async { - let charges_to_do = - crate::database::models::charge_item::ChargeItem::get_chargeable(&pool).await?; + let mut transaction = pool.begin().await?; + let mut clear_cache_users = Vec::new(); - let mut subscription_items = user_subscription_item::UserSubscriptionItem::get_many( - &charges_to_do - .iter() - .flat_map(|x| x.subscription_id) - .collect::>() - .into_iter() - .collect::>(), - &pool, - ) - .await?; + // 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 subscription_prices = product_item::ProductPriceItem::get_many( - &charges_to_do + &all_subscriptions .iter() .map(|x| x.price_id) .collect::>() @@ -1348,7 +1381,7 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) ) .await?; let users = crate::database::models::User::get_many_ids( - &charges_to_do + &all_subscriptions .iter() .map(|x| x.user_id) .collect::>() @@ -1359,184 +1392,81 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) ) .await?; - let mut transaction = pool.begin().await?; - let mut clear_cache_users = Vec::new(); - - for charge in charges_to_do { - let user = users.iter().find(|x| x.id == charge.user_id); - - if let Some(user) = user { - let product_price = - subscription_prices.iter().find(|x| x.id == charge.price_id); - - if let Some(product_price) = product_price { - let product = subscription_products - .iter() - .find(|x| x.id == product_price.product_id); - - if let Some(product) = product { - let mut subscription = charge - .subscription_id - .and_then(|x| subscription_items.iter_mut().find(|y| y.id == x)); - - let price = match &product_price.prices { - Price::OneTime { price } => Some(price), - Price::Recurring { intervals } => { - if let Some(ref subscription) = subscription { - intervals.get(&subscription.interval) - } else { - warn!( - "Could not find subscription for charge {:?}", - charge.id - ); - continue; - } - } - }; - - if let Some(price) = price { - let should_charge = if let Some(ref subscription) = subscription { - let cancelled = - subscription.status == SubscriptionStatus::Cancelled; - let payment_failed = charge - .last_attempt - .map(|y| { - subscription.status == SubscriptionStatus::PaymentFailed - && Utc::now() - y > Duration::days(2) - }) - .unwrap_or(false); - let active = subscription.status == SubscriptionStatus::Active; - - // Unprovision subscription - if cancelled || payment_failed { - match product.metadata { - ProductMetadata::Midas => { - let badges = user.badges - Badges::MIDAS; - - sqlx::query!( - " - UPDATE users - SET badges = $1 - WHERE (id = $2) - ", - badges.bits() as i64, - user.id as crate::database::models::ids::UserId, - ) - .execute(&mut *transaction) - .await?; - } - ProductMetadata::Pyro { .. } => { - if let Some(SubscriptionMetadata::Pyro { id }) = &subscription.metadata { - let res = reqwest::Client::new() - .post(format!("https://archon.pyro.host/v0/servers/{}/suspend", id)) - .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) - .json(&serde_json::json!({ - "reason": if cancelled { "cancelled" } else { "paymentfailed" } - })) - .send() - .await; - - if let Err(e) = res { - warn!("Error suspending pyro server: {:?}", e); - } - } - } - } - - clear_cache_users.push(user.id); - } + for mut subscription in all_subscriptions { + let product_price = if let Some(product_price) = subscription_prices + .iter() + .find(|x| x.id == subscription.price_id) + { + product_price + } else { + continue; + }; - if cancelled { - user_subscription_item::UserSubscriptionItem::remove( - subscription.id, - &mut transaction, - ) - .await?; - false - } else { - payment_failed || active - } - } else { - true - }; + let product = if let Some(product) = subscription_products + .iter() + .find(|x| x.id == product_price.product_id) + { + product + } else { + continue; + }; - if should_charge { - let customer_id = get_or_create_customer( - user.id.into(), - user.stripe_customer_id.as_deref(), - user.email.as_deref(), - &stripe_client, - &pool, - &redis, - ) - .await?; - - let customer = stripe::Customer::retrieve( - &stripe_client, - &customer_id, - &[], - ) - .await?; - - let currency = match Currency::from_str( - &product_price.currency_code.to_lowercase(), - ) { - Ok(x) => x, - Err(_) => { - warn!( - "Could not find currency for {}", - product_price.currency_code - ); - continue; - } - }; - - let mut intent = - CreatePaymentIntent::new(*price as i64, currency); - - let mut metadata = HashMap::new(); - metadata.insert( - "modrinth_user_id".to_string(), - to_base62(user.id.0 as u64), - ); - metadata.insert( - "modrinth_charge_id".to_string(), - to_base62(charge.id.0 as u64), - ); - - intent.metadata = Some(metadata); - intent.customer = Some(customer_id); - - if let Some(payment_method) = customer - .invoice_settings - .and_then(|x| x.default_payment_method.map(|x| x.id())) - { - intent.payment_method = Some(payment_method); - intent.confirm = Some(true); - intent.off_session = - Some(PaymentIntentOffSession::Exists(true)); - - if let Some(ref mut subscription) = subscription { - subscription.status = - SubscriptionStatus::PaymentProcessing; - } + let user = if let Some(user) = users.iter().find(|x| x.id == subscription.user_id) { + user + } else { + continue; + }; - stripe::PaymentIntent::create(&stripe_client, intent) - .await?; - } else { - if let Some(ref mut subscription) = subscription { - subscription.status = SubscriptionStatus::PaymentFailed; - } - } + let unprovisioned = match product.metadata { + ProductMetadata::Midas => { + let badges = user.badges - Badges::MIDAS; + + sqlx::query!( + " + UPDATE users + SET badges = $1 + WHERE (id = $2) + ", + badges.bits() as i64, + user.id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; - if let Some(ref subscription) = subscription { - subscription.upsert(&mut transaction).await?; - } - } + true + } + ProductMetadata::Pyro { .. } => { + if let Some(SubscriptionMetadata::Pyro { id }) = &subscription.metadata { + let res = reqwest::Client::new() + .post(format!( + "https://archon.pyro.host/v0/servers/{}/suspend", + id + )) + .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) + .json(&serde_json::json!({ + "reason": "cancelled" + })) + .send() + .await; + + if let Err(e) = res { + warn!("Error suspending pyro server: {:?}", e); + false + } else { + true } + } else { + true } } + }; + + if unprovisioned { + subscription.status = SubscriptionStatus::Unprovisioned; + subscription.upsert(&mut transaction).await?; } + + clear_cache_users.push(user.id); } crate::database::models::User::clear_caches( @@ -1549,6 +1479,141 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) .await?; transaction.commit().await?; + Ok::<(), ApiError>(()) + }; + + if let Err(e) = res.await { + warn!("Error indexing billing queue: {:?}", e); + } + + info!("Done indexing billing queue"); + + tokio::time::sleep(std::time::Duration::from_secs(60 * 1)).await; + } +} + +pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) { + loop { + info!("Indexing billing queue"); + let res = async { + // If a charge is open and due or has been attempted more than two days ago, it should be processed + let charges_to_do = + crate::database::models::charge_item::ChargeItem::get_chargeable(&pool).await?; + + let prices = product_item::ProductPriceItem::get_many( + &charges_to_do + .iter() + .map(|x| x.price_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + ) + .await?; + + let users = crate::database::models::User::get_many_ids( + &charges_to_do + .iter() + .map(|x| x.user_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + &redis, + ) + .await?; + + let mut transaction = pool.begin().await?; + + for mut charge in charges_to_do { + let product_price = + if let Some(price) = prices.iter().find(|x| x.id == charge.price_id) { + price + } else { + continue; + }; + + let user = if let Some(user) = users.iter().find(|x| x.id == charge.user_id) { + user + } else { + continue; + }; + + let price = match &product_price.prices { + Price::OneTime { price } => Some(price), + Price::Recurring { intervals } => { + if let Some(ref interval) = charge.subscription_interval { + intervals.get(interval) + } else { + warn!("Could not find subscription for charge {:?}", charge.id); + continue; + } + } + }; + + if let Some(price) = price { + let customer_id = get_or_create_customer( + user.id.into(), + user.stripe_customer_id.as_deref(), + user.email.as_deref(), + &stripe_client, + &pool, + &redis, + ) + .await?; + + let customer = + stripe::Customer::retrieve(&stripe_client, &customer_id, &[]).await?; + + let currency = + match Currency::from_str(&product_price.currency_code.to_lowercase()) { + Ok(x) => x, + Err(_) => { + warn!( + "Could not find currency for {}", + product_price.currency_code + ); + continue; + } + }; + + let mut intent = CreatePaymentIntent::new(*price as i64, currency); + + let mut metadata = HashMap::new(); + metadata.insert( + "modrinth_user_id".to_string(), + to_base62(charge.user_id.0 as u64), + ); + metadata.insert( + "modrinth_charge_id".to_string(), + to_base62(charge.id.0 as u64), + ); + + intent.metadata = Some(metadata); + intent.customer = Some(customer.id); + + if let Some(payment_method) = customer + .invoice_settings + .and_then(|x| x.default_payment_method.map(|x| x.id())) + { + intent.payment_method = Some(payment_method); + intent.confirm = Some(true); + intent.off_session = Some(PaymentIntentOffSession::Exists(true)); + + charge.status = ChargeStatus::Processing; + + stripe::PaymentIntent::create(&stripe_client, intent).await?; + } else { + charge.status = ChargeStatus::Failed; + charge.last_attempt = Some(Utc::now()); + } + + charge.upsert(&mut transaction).await?; + } + } + + transaction.commit().await?; + Ok::<(), ApiError>(()) } .await; @@ -1559,6 +1624,6 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) info!("Done indexing billing queue"); - tokio::time::sleep(std::time::Duration::from_secs(60 * 5)).await; + tokio::time::sleep(std::time::Duration::from_secs(60 * 1)).await; } } From 195e404f94aeb7730b68c7af43e8180d341adea0 Mon Sep 17 00:00:00 2001 From: Jai A Date: Wed, 9 Oct 2024 20:25:17 -0700 Subject: [PATCH 4/7] Run prepare --- ...24a6e422e4041deb6f640ab5159d55ba2789c.json | 82 +++++++++++++++++++ ...4feaad606fb3aa80a749a36b323d77b01931a.json | 23 ------ ...fca57628eed279f720565fab55c8d10decd88.json | 58 +++++++++++++ ...8154053ae4e90e319d34a940fb73e33a69d4.json} | 36 ++++---- ...aca81b445a7f5a44e52a0526a1b57bd7a8c9d.json | 24 ++++++ ...192c73c1d4e8bdb43b0755607f1182d24c46d.json | 21 ----- ...59012beddbf0de01a78e63101ef265f096a03.json | 15 ---- ...9b3b077930d8e9e09dca76fde8983413adc6.json} | 36 ++++---- ...663df0ac672d465d78445e48f321fc47e09cb.json | 14 ---- ...d91caafb733a5217b832ab9dcf7fde60d49dd.json | 20 +++++ ...4f39c64ad8815ff0ec0a98903fee0b4167c7.json} | 12 +-- ...90762fd7068cab7822a5b60545b44e6ba775.json} | 12 +-- ...6566d53049176d72073c22a04b43adea18326.json | 14 ---- ...44e88f58fd2e1cc3e6da8d90708cbf242f761.json | 14 ++++ ...2800a0a43dfef4a37a5725403d33ccb20d908.json | 15 ++++ ...b6b27d8dbe2ddaf211ba37a026eab3bb6926.json} | 38 +++++---- src/routes/internal/billing.rs | 4 +- 17 files changed, 285 insertions(+), 153 deletions(-) create mode 100644 .sqlx/query-285cdd452fff85480dde02119d224a6e422e4041deb6f640ab5159d55ba2789c.json delete mode 100644 .sqlx/query-375262713edeccbdf9770a180bf4feaad606fb3aa80a749a36b323d77b01931a.json create mode 100644 .sqlx/query-3cbc34bc326595fc9d070494613fca57628eed279f720565fab55c8d10decd88.json rename .sqlx/{query-f1c1442c72e7b9761e1786e8ac8596db21e1daaa74f53381b47847fc9229a993.json => query-457493bd11254ba1c9f81c47f15e8154053ae4e90e319d34a940fb73e33a69d4.json} (75%) create mode 100644 .sqlx/query-56d7b62fc05c77f228e46dbfe4eaca81b445a7f5a44e52a0526a1b57bd7a8c9d.json delete mode 100644 .sqlx/query-75d10a180e66d5741318da652b7192c73c1d4e8bdb43b0755607f1182d24c46d.json delete mode 100644 .sqlx/query-7aae2ea58ac43d7597a8075692359012beddbf0de01a78e63101ef265f096a03.json rename .sqlx/{query-f6cb97b547c80e1f47467f33bbc641e3504bab316de4e30ea263ceb3ffcbc210.json => query-86ee460c74f0052a4945ab4df9829b3b077930d8e9e09dca76fde8983413adc6.json} (76%) delete mode 100644 .sqlx/query-88d135700420321a3896f9262bb663df0ac672d465d78445e48f321fc47e09cb.json create mode 100644 .sqlx/query-8bcf4589c429ab0abf2460f658fd91caafb733a5217b832ab9dcf7fde60d49dd.json rename .sqlx/{query-80673778f6fea2dc776e722376d6b6aab6d500d836b01094076cb751c3c349e3.json => query-a25ee30b6968dc98b66b1beac5124f39c64ad8815ff0ec0a98903fee0b4167c7.json} (70%) rename .sqlx/{query-f3a021c0eeb5c02592d7dddc6c5f70122465388c2a9b1837aa572d69baa38fe1.json => query-af1a10f0fa88c7893cff3a451fd890762fd7068cab7822a5b60545b44e6ba775.json} (70%) delete mode 100644 .sqlx/query-b64651865cf9c1fbebed7f188da6566d53049176d72073c22a04b43adea18326.json create mode 100644 .sqlx/query-bc2a2166718a2d2b23e57cde6b144e88f58fd2e1cc3e6da8d90708cbf242f761.json create mode 100644 .sqlx/query-bd26a27ce80ca796ae19bc709c92800a0a43dfef4a37a5725403d33ccb20d908.json rename .sqlx/{query-3c2665e46fe6a66cba9382426bed07db828f2a8b81cb6d9e640b94d8d12eb28f.json => query-e68e27fcb3e85233be06e7435aaeb6b27d8dbe2ddaf211ba37a026eab3bb6926.json} (73%) diff --git a/.sqlx/query-285cdd452fff85480dde02119d224a6e422e4041deb6f640ab5159d55ba2789c.json b/.sqlx/query-285cdd452fff85480dde02119d224a6e422e4041deb6f640ab5159d55ba2789c.json new file mode 100644 index 00000000..72f8988c --- /dev/null +++ b/.sqlx/query-285cdd452fff85480dde02119d224a6e422e4041deb6f640ab5159d55ba2789c.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 = 'open' 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": "285cdd452fff85480dde02119d224a6e422e4041deb6f640ab5159d55ba2789c" +} diff --git a/.sqlx/query-375262713edeccbdf9770a180bf4feaad606fb3aa80a749a36b323d77b01931a.json b/.sqlx/query-375262713edeccbdf9770a180bf4feaad606fb3aa80a749a36b323d77b01931a.json deleted file mode 100644 index 3f7a136b..00000000 --- a/.sqlx/query-375262713edeccbdf9770a180bf4feaad606fb3aa80a749a36b323d77b01931a.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Int8", - "Int8", - "Text", - "Int8", - "Text", - "Varchar", - "Timestamptz", - "Timestamptz" - ] - }, - "nullable": [] - }, - "hash": "375262713edeccbdf9770a180bf4feaad606fb3aa80a749a36b323d77b01931a" -} diff --git a/.sqlx/query-3cbc34bc326595fc9d070494613fca57628eed279f720565fab55c8d10decd88.json b/.sqlx/query-3cbc34bc326595fc9d070494613fca57628eed279f720565fab55c8d10decd88.json new file mode 100644 index 00000000..14f57491 --- /dev/null +++ b/.sqlx/query-3cbc34bc326595fc9d070494613fca57628eed279f720565fab55c8d10decd88.json @@ -0,0 +1,58 @@ +{ + "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-f1c1442c72e7b9761e1786e8ac8596db21e1daaa74f53381b47847fc9229a993.json b/.sqlx/query-457493bd11254ba1c9f81c47f15e8154053ae4e90e319d34a940fb73e33a69d4.json similarity index 75% rename from .sqlx/query-f1c1442c72e7b9761e1786e8ac8596db21e1daaa74f53381b47847fc9229a993.json rename to .sqlx/query-457493bd11254ba1c9f81c47f15e8154053ae4e90e319d34a940fb73e33a69d4.json index dda71d1c..33d196a9 100644 --- a/.sqlx/query-f1c1442c72e7b9761e1786e8ac8596db21e1daaa74f53381b47847fc9229a993.json +++ b/.sqlx/query-457493bd11254ba1c9f81c47f15e8154053ae4e90e319d34a940fb73e33a69d4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt\n FROM charges\n WHERE user_id = $1", + "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 user_id = $1 ORDER BY due DESC", "describe": { "columns": [ { @@ -30,28 +30,33 @@ }, { "ordinal": 5, - "name": "subscription_id", - "type_info": "Int8" + "name": "status", + "type_info": "Varchar" }, { "ordinal": 6, - "name": "interval", - "type_info": "Text" + "name": "due", + "type_info": "Timestamptz" }, { "ordinal": 7, - "name": "status", - "type_info": "Varchar" + "name": "last_attempt", + "type_info": "Timestamptz" }, { "ordinal": 8, - "name": "due", - "type_info": "Timestamptz" + "name": "charge_type", + "type_info": "Text" }, { "ordinal": 9, - "name": "last_attempt", - "type_info": "Timestamptz" + "name": "subscription_id", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "subscription_interval", + "type_info": "Text" } ], "parameters": { @@ -65,12 +70,13 @@ false, false, false, - true, - true, false, false, - false + true, + false, + true, + true ] }, - "hash": "f1c1442c72e7b9761e1786e8ac8596db21e1daaa74f53381b47847fc9229a993" + "hash": "457493bd11254ba1c9f81c47f15e8154053ae4e90e319d34a940fb73e33a69d4" } diff --git a/.sqlx/query-56d7b62fc05c77f228e46dbfe4eaca81b445a7f5a44e52a0526a1b57bd7a8c9d.json b/.sqlx/query-56d7b62fc05c77f228e46dbfe4eaca81b445a7f5a44e52a0526a1b57bd7a8c9d.json new file mode 100644 index 00000000..53bd4798 --- /dev/null +++ b/.sqlx/query-56d7b62fc05c77f228e46dbfe4eaca81b445a7f5a44e52a0526a1b57bd7a8c9d.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due,\n subscription_id = EXCLUDED.subscription_id,\n subscription_interval = EXCLUDED.subscription_interval\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Int8", + "Text", + "Text", + "Varchar", + "Timestamptz", + "Timestamptz", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "56d7b62fc05c77f228e46dbfe4eaca81b445a7f5a44e52a0526a1b57bd7a8c9d" +} diff --git a/.sqlx/query-75d10a180e66d5741318da652b7192c73c1d4e8bdb43b0755607f1182d24c46d.json b/.sqlx/query-75d10a180e66d5741318da652b7192c73c1d4e8bdb43b0755607f1182d24c46d.json deleted file mode 100644 index 1cd31c4e..00000000 --- a/.sqlx/query-75d10a180e66d5741318da652b7192c73c1d4e8bdb43b0755607f1182d24c46d.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO users_subscriptions (\n id, user_id, price_id, interval, created, expires, status, metadata\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8\n )\n ON CONFLICT (id)\n DO UPDATE\n SET interval = EXCLUDED.interval,\n expires = EXCLUDED.expires,\n status = EXCLUDED.status,\n price_id = EXCLUDED.price_id,\n metadata = EXCLUDED.metadata\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Int8", - "Text", - "Timestamptz", - "Timestamptz", - "Varchar", - "Jsonb" - ] - }, - "nullable": [] - }, - "hash": "75d10a180e66d5741318da652b7192c73c1d4e8bdb43b0755607f1182d24c46d" -} diff --git a/.sqlx/query-7aae2ea58ac43d7597a8075692359012beddbf0de01a78e63101ef265f096a03.json b/.sqlx/query-7aae2ea58ac43d7597a8075692359012beddbf0de01a78e63101ef265f096a03.json deleted file mode 100644 index 7b6d4f03..00000000 --- a/.sqlx/query-7aae2ea58ac43d7597a8075692359012beddbf0de01a78e63101ef265f096a03.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE users\n SET badges = $1\n WHERE (id = $2)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "7aae2ea58ac43d7597a8075692359012beddbf0de01a78e63101ef265f096a03" -} diff --git a/.sqlx/query-f6cb97b547c80e1f47467f33bbc641e3504bab316de4e30ea263ceb3ffcbc210.json b/.sqlx/query-86ee460c74f0052a4945ab4df9829b3b077930d8e9e09dca76fde8983413adc6.json similarity index 76% rename from .sqlx/query-f6cb97b547c80e1f47467f33bbc641e3504bab316de4e30ea263ceb3ffcbc210.json rename to .sqlx/query-86ee460c74f0052a4945ab4df9829b3b077930d8e9e09dca76fde8983413adc6.json index 2dda14a0..e146de6b 100644 --- a/.sqlx/query-f6cb97b547c80e1f47467f33bbc641e3504bab316de4e30ea263ceb3ffcbc210.json +++ b/.sqlx/query-86ee460c74f0052a4945ab4df9829b3b077930d8e9e09dca76fde8983413adc6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt\n FROM charges\n WHERE id = $1", + "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 id = $1", "describe": { "columns": [ { @@ -30,28 +30,33 @@ }, { "ordinal": 5, - "name": "subscription_id", - "type_info": "Int8" + "name": "status", + "type_info": "Varchar" }, { "ordinal": 6, - "name": "interval", - "type_info": "Text" + "name": "due", + "type_info": "Timestamptz" }, { "ordinal": 7, - "name": "status", - "type_info": "Varchar" + "name": "last_attempt", + "type_info": "Timestamptz" }, { "ordinal": 8, - "name": "due", - "type_info": "Timestamptz" + "name": "charge_type", + "type_info": "Text" }, { "ordinal": 9, - "name": "last_attempt", - "type_info": "Timestamptz" + "name": "subscription_id", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "subscription_interval", + "type_info": "Text" } ], "parameters": { @@ -65,12 +70,13 @@ false, false, false, - true, - true, false, false, - false + true, + false, + true, + true ] }, - "hash": "f6cb97b547c80e1f47467f33bbc641e3504bab316de4e30ea263ceb3ffcbc210" + "hash": "86ee460c74f0052a4945ab4df9829b3b077930d8e9e09dca76fde8983413adc6" } diff --git a/.sqlx/query-88d135700420321a3896f9262bb663df0ac672d465d78445e48f321fc47e09cb.json b/.sqlx/query-88d135700420321a3896f9262bb663df0ac672d465d78445e48f321fc47e09cb.json deleted file mode 100644 index 7058e994..00000000 --- a/.sqlx/query-88d135700420321a3896f9262bb663df0ac672d465d78445e48f321fc47e09cb.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM users_subscriptions\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "88d135700420321a3896f9262bb663df0ac672d465d78445e48f321fc47e09cb" -} diff --git a/.sqlx/query-8bcf4589c429ab0abf2460f658fd91caafb733a5217b832ab9dcf7fde60d49dd.json b/.sqlx/query-8bcf4589c429ab0abf2460f658fd91caafb733a5217b832ab9dcf7fde60d49dd.json new file mode 100644 index 00000000..36befffd --- /dev/null +++ b/.sqlx/query-8bcf4589c429ab0abf2460f658fd91caafb733a5217b832ab9dcf7fde60d49dd.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users_subscriptions (\n id, user_id, price_id, interval, created, status, metadata\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n ON CONFLICT (id)\n DO UPDATE\n SET interval = EXCLUDED.interval,\n status = EXCLUDED.status,\n price_id = EXCLUDED.price_id,\n metadata = EXCLUDED.metadata\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Text", + "Timestamptz", + "Varchar", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "8bcf4589c429ab0abf2460f658fd91caafb733a5217b832ab9dcf7fde60d49dd" +} diff --git a/.sqlx/query-80673778f6fea2dc776e722376d6b6aab6d500d836b01094076cb751c3c349e3.json b/.sqlx/query-a25ee30b6968dc98b66b1beac5124f39c64ad8815ff0ec0a98903fee0b4167c7.json similarity index 70% rename from .sqlx/query-80673778f6fea2dc776e722376d6b6aab6d500d836b01094076cb751c3c349e3.json rename to .sqlx/query-a25ee30b6968dc98b66b1beac5124f39c64ad8815ff0ec0a98903fee0b4167c7.json index b87784f2..179313d3 100644 --- a/.sqlx/query-80673778f6fea2dc776e722376d6b6aab6d500d836b01094076cb751c3c349e3.json +++ b/.sqlx/query-a25ee30b6968dc98b66b1beac5124f39c64ad8815ff0ec0a98903fee0b4167c7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, interval, created, expires, status, metadata\n FROM users_subscriptions\n WHERE id = ANY($1::bigint[])", + "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 WHERE us.id = ANY($1::bigint[])", "describe": { "columns": [ { @@ -30,16 +30,11 @@ }, { "ordinal": 5, - "name": "expires", - "type_info": "Timestamptz" - }, - { - "ordinal": 6, "name": "status", "type_info": "Varchar" }, { - "ordinal": 7, + "ordinal": 6, "name": "metadata", "type_info": "Jsonb" } @@ -56,9 +51,8 @@ false, false, false, - false, true ] }, - "hash": "80673778f6fea2dc776e722376d6b6aab6d500d836b01094076cb751c3c349e3" + "hash": "a25ee30b6968dc98b66b1beac5124f39c64ad8815ff0ec0a98903fee0b4167c7" } diff --git a/.sqlx/query-f3a021c0eeb5c02592d7dddc6c5f70122465388c2a9b1837aa572d69baa38fe1.json b/.sqlx/query-af1a10f0fa88c7893cff3a451fd890762fd7068cab7822a5b60545b44e6ba775.json similarity index 70% rename from .sqlx/query-f3a021c0eeb5c02592d7dddc6c5f70122465388c2a9b1837aa572d69baa38fe1.json rename to .sqlx/query-af1a10f0fa88c7893cff3a451fd890762fd7068cab7822a5b60545b44e6ba775.json index 49f25866..84837d7a 100644 --- a/.sqlx/query-f3a021c0eeb5c02592d7dddc6c5f70122465388c2a9b1837aa572d69baa38fe1.json +++ b/.sqlx/query-af1a10f0fa88c7893cff3a451fd890762fd7068cab7822a5b60545b44e6ba775.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, interval, created, expires, status, metadata\n FROM users_subscriptions\n WHERE user_id = $1", + "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 WHERE us.user_id = $1", "describe": { "columns": [ { @@ -30,16 +30,11 @@ }, { "ordinal": 5, - "name": "expires", - "type_info": "Timestamptz" - }, - { - "ordinal": 6, "name": "status", "type_info": "Varchar" }, { - "ordinal": 7, + "ordinal": 6, "name": "metadata", "type_info": "Jsonb" } @@ -56,9 +51,8 @@ false, false, false, - false, true ] }, - "hash": "f3a021c0eeb5c02592d7dddc6c5f70122465388c2a9b1837aa572d69baa38fe1" + "hash": "af1a10f0fa88c7893cff3a451fd890762fd7068cab7822a5b60545b44e6ba775" } diff --git a/.sqlx/query-b64651865cf9c1fbebed7f188da6566d53049176d72073c22a04b43adea18326.json b/.sqlx/query-b64651865cf9c1fbebed7f188da6566d53049176d72073c22a04b43adea18326.json deleted file mode 100644 index 6b762f83..00000000 --- a/.sqlx/query-b64651865cf9c1fbebed7f188da6566d53049176d72073c22a04b43adea18326.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM users_subscriptions\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "b64651865cf9c1fbebed7f188da6566d53049176d72073c22a04b43adea18326" -} diff --git a/.sqlx/query-bc2a2166718a2d2b23e57cde6b144e88f58fd2e1cc3e6da8d90708cbf242f761.json b/.sqlx/query-bc2a2166718a2d2b23e57cde6b144e88f58fd2e1cc3e6da8d90708cbf242f761.json new file mode 100644 index 00000000..0c464981 --- /dev/null +++ b/.sqlx/query-bc2a2166718a2d2b23e57cde6b144e88f58fd2e1cc3e6da8d90708cbf242f761.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM charges\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "bc2a2166718a2d2b23e57cde6b144e88f58fd2e1cc3e6da8d90708cbf242f761" +} diff --git a/.sqlx/query-bd26a27ce80ca796ae19bc709c92800a0a43dfef4a37a5725403d33ccb20d908.json b/.sqlx/query-bd26a27ce80ca796ae19bc709c92800a0a43dfef4a37a5725403d33ccb20d908.json new file mode 100644 index 00000000..c0c2cbe9 --- /dev/null +++ b/.sqlx/query-bd26a27ce80ca796ae19bc709c92800a0a43dfef4a37a5725403d33ccb20d908.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET badges = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "bd26a27ce80ca796ae19bc709c92800a0a43dfef4a37a5725403d33ccb20d908" +} diff --git a/.sqlx/query-3c2665e46fe6a66cba9382426bed07db828f2a8b81cb6d9e640b94d8d12eb28f.json b/.sqlx/query-e68e27fcb3e85233be06e7435aaeb6b27d8dbe2ddaf211ba37a026eab3bb6926.json similarity index 73% rename from .sqlx/query-3c2665e46fe6a66cba9382426bed07db828f2a8b81cb6d9e640b94d8d12eb28f.json rename to .sqlx/query-e68e27fcb3e85233be06e7435aaeb6b27d8dbe2ddaf211ba37a026eab3bb6926.json index 889b0639..5f6fbb75 100644 --- a/.sqlx/query-3c2665e46fe6a66cba9382426bed07db828f2a8b81cb6d9e640b94d8d12eb28f.json +++ b/.sqlx/query-e68e27fcb3e85233be06e7435aaeb6b27d8dbe2ddaf211ba37a026eab3bb6926.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, user_id, price_id, amount, currency_code, subscription_id, interval, status, due, last_attempt\n FROM charges\n WHERE (status = 'open' AND due < $1) OR (status = 'failed' AND last_attempt < $1 - INTERVAL '2 days')", + "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 subscription_id = $1 AND (status = 'open' OR status = 'cancelled')", "describe": { "columns": [ { @@ -30,33 +30,38 @@ }, { "ordinal": 5, - "name": "subscription_id", - "type_info": "Int8" + "name": "status", + "type_info": "Varchar" }, { "ordinal": 6, - "name": "interval", - "type_info": "Text" + "name": "due", + "type_info": "Timestamptz" }, { "ordinal": 7, - "name": "status", - "type_info": "Varchar" + "name": "last_attempt", + "type_info": "Timestamptz" }, { "ordinal": 8, - "name": "due", - "type_info": "Timestamptz" + "name": "charge_type", + "type_info": "Text" }, { "ordinal": 9, - "name": "last_attempt", - "type_info": "Timestamptz" + "name": "subscription_id", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "subscription_interval", + "type_info": "Text" } ], "parameters": { "Left": [ - "Timestamptz" + "Int8" ] }, "nullable": [ @@ -65,12 +70,13 @@ false, false, false, - true, - true, false, false, - false + true, + false, + true, + true ] }, - "hash": "3c2665e46fe6a66cba9382426bed07db828f2a8b81cb6d9e640b94d8d12eb28f" + "hash": "e68e27fcb3e85233be06e7435aaeb6b27d8dbe2ddaf211ba37a026eab3bb6926" } diff --git a/src/routes/internal/billing.rs b/src/routes/internal/billing.rs index 50ba0bea..24f131a4 100644 --- a/src/routes/internal/billing.rs +++ b/src/routes/internal/billing.rs @@ -1488,7 +1488,7 @@ pub async fn subscription_task(pool: PgPool, redis: RedisPool) { info!("Done indexing billing queue"); - tokio::time::sleep(std::time::Duration::from_secs(60 * 1)).await; + tokio::time::sleep(std::time::Duration::from_secs(60 * 5)).await; } } @@ -1624,6 +1624,6 @@ pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) info!("Done indexing billing queue"); - tokio::time::sleep(std::time::Duration::from_secs(60 * 1)).await; + tokio::time::sleep(std::time::Duration::from_secs(60 * 5)).await; } } From 8900a1dba70db5a068ce0bb4c5c26e774e8cf6ab Mon Sep 17 00:00:00 2001 From: Jai A Date: Wed, 9 Oct 2024 20:46:41 -0700 Subject: [PATCH 5/7] Fix intervals --- src/routes/internal/billing.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/routes/internal/billing.rs b/src/routes/internal/billing.rs index 24f131a4..15b9ec0f 100644 --- a/src/routes/internal/billing.rs +++ b/src/routes/internal/billing.rs @@ -160,7 +160,7 @@ pub async fn edit_subscription( if let Some(cancelled) = &edit_subscription.cancelled { if open_charge.status != ChargeStatus::Open - || open_charge.status != ChargeStatus::Cancelled + && open_charge.status != ChargeStatus::Cancelled { return Err(ApiError::InvalidInput( "You may not change the status of this subscription!".to_string(), @@ -190,6 +190,8 @@ pub async fn edit_subscription( }; } + open_charge.upsert(&mut transaction).await?; + transaction.commit().await?; Ok(HttpResponse::NoContent().body("")) From 9b068d4fc9c12e41224c7558a11c7f51f71e30b5 Mon Sep 17 00:00:00 2001 From: Jai A Date: Wed, 9 Oct 2024 21:01:14 -0700 Subject: [PATCH 6/7] Fix clippy --- src/database/models/charge_item.rs | 2 +- src/file_hosting/backblaze/delete.rs | 2 +- src/queue/payouts.rs | 2 +- src/routes/internal/billing.rs | 74 +++++++++++----------------- src/routes/internal/flows.rs | 6 +-- src/routes/internal/gdpr.rs | 22 ++++----- src/routes/v2/version_creation.rs | 13 ++--- src/routes/v3/payouts.rs | 4 +- 8 files changed, 54 insertions(+), 71 deletions(-) diff --git a/src/database/models/charge_item.rs b/src/database/models/charge_item.rs index 55482df9..af0ed0bf 100644 --- a/src/database/models/charge_item.rs +++ b/src/database/models/charge_item.rs @@ -51,7 +51,7 @@ impl TryFrom for ChargeItem { subscription_id: r.subscription_id.map(UserSubscriptionId), subscription_interval: r .subscription_interval - .map(|x| PriceDuration::from_string(&*x)), + .map(|x| PriceDuration::from_string(&x)), }) } } diff --git a/src/file_hosting/backblaze/delete.rs b/src/file_hosting/backblaze/delete.rs index 190288e6..87e24ac3 100644 --- a/src/file_hosting/backblaze/delete.rs +++ b/src/file_hosting/backblaze/delete.rs @@ -15,7 +15,7 @@ pub async fn delete_file_version( file_name: &str, ) -> Result { let response = reqwest::Client::new() - .post(&format!( + .post(format!( "{}/b2api/v2/b2_delete_file_version", authorization_data.api_url )) diff --git a/src/queue/payouts.rs b/src/queue/payouts.rs index e7188c7f..6d1d0c1c 100644 --- a/src/queue/payouts.rs +++ b/src/queue/payouts.rs @@ -74,7 +74,7 @@ impl PayoutsQueue { } let credential: PaypalCredential = client - .post(&format!("{}oauth2/token", dotenvy::var("PAYPAL_API_URL")?)) + .post(format!("{}oauth2/token", dotenvy::var("PAYPAL_API_URL")?)) .header("Accept", "application/json") .header("Accept-Language", "en_US") .header("Authorization", formatted_key) diff --git a/src/routes/internal/billing.rs b/src/routes/internal/billing.rs index 15b9ec0f..b017f5bd 100644 --- a/src/routes/internal/billing.rs +++ b/src/routes/internal/billing.rs @@ -175,19 +175,16 @@ pub async fn edit_subscription( } if let Some(interval) = &edit_subscription.interval { - match ¤t_price.prices { - Price::Recurring { intervals } => { - if let Some(price) = intervals.get(interval) { - open_charge.subscription_interval = Some(*interval); - open_charge.amount = *price as i64; - } else { - return Err(ApiError::InvalidInput( - "Interval is not valid for this subscription!".to_string(), - )); - } + if let Price::Recurring { intervals } = ¤t_price.prices { + if let Some(price) = intervals.get(interval) { + open_charge.subscription_interval = Some(*interval); + open_charge.amount = *price as i64; + } else { + return Err(ApiError::InvalidInput( + "Interval is not valid for this subscription!".to_string(), + )); } - _ => {} - }; + } } open_charge.upsert(&mut transaction).await?; @@ -688,11 +685,7 @@ pub async fn initiate_payment( ( charge.amount, charge.currency_code, - if let Some(interval) = charge.subscription_interval { - Some(interval) - } else { - None - }, + charge.subscription_interval, charge.price_id, Some(id), ) @@ -761,8 +754,7 @@ pub async fn initiate_payment( if user_products .into_iter() - .find(|x| x.product_id == product.id) - .is_some() + .any(|x| x.product_id == product.id) { return Err(ApiError::InvalidInput( "You are already subscribed to this product!".to_string(), @@ -971,11 +963,8 @@ pub async fn stripe_webhook( if let Some(subscription_id) = charge.subscription_id { let mut subscription = if let Some(subscription) = - user_subscription_item::UserSubscriptionItem::get( - subscription_id.into(), - pool, - ) - .await? + user_subscription_item::UserSubscriptionItem::get(subscription_id, pool) + .await? { subscription } else { @@ -1157,21 +1146,19 @@ pub async fn stripe_webhook( 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(|| { - format!("{}'s server", metadata.user_item.username) - }); - - let res = client - .post("https://archon.pyro.host/v0/servers/create") - .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) - .json(&serde_json::json!({ + } else if let Some(PaymentRequestMetadata::Pyro { + server_name, + source, + }) = &metadata.payment_metadata + { + let server_name = server_name.clone().unwrap_or_else(|| { + format!("{}'s server", metadata.user_item.username) + }); + + let res = client + .post("https://archon.pyro.host/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": { @@ -1181,12 +1168,11 @@ pub async fn stripe_webhook( }, "source": source, })) - .send() - .await; + .send() + .await; - if let Err(e) = res { - warn!("Error creating pyro server: {:?}", e); - } + if let Err(e) = res { + warn!("Error creating pyro server: {:?}", e); } } } diff --git a/src/routes/internal/flows.rs b/src/routes/internal/flows.rs index 094e40ff..ef7d395e 100644 --- a/src/routes/internal/flows.rs +++ b/src/routes/internal/flows.rs @@ -510,7 +510,7 @@ impl AuthProvider { map.insert("grant_type", "authorization_code"); let token: AccessToken = reqwest::Client::new() - .post(&format!("{api_url}oauth2/token")) + .post(format!("{api_url}oauth2/token")) .header(reqwest::header::ACCEPT, "application/json") .header( AUTHORIZATION, @@ -766,7 +766,7 @@ impl AuthProvider { let api_url = dotenvy::var("PAYPAL_API_URL")?; let paypal_user: PayPalUser = reqwest::Client::new() - .get(&format!( + .get(format!( "{api_url}identity/openidconnect/userinfo?schema=openid" )) .header(reqwest::header::USER_AGENT, "Modrinth") @@ -1393,7 +1393,7 @@ pub async fn sign_up_beehiiv(email: &str) -> Result<(), AuthenticationError> { let client = reqwest::Client::new(); client - .post(&format!( + .post(format!( "https://api.beehiiv.com/v2/publications/{id}/subscriptions" )) .header(AUTHORIZATION, format!("Bearer {}", api_key)) diff --git a/src/routes/internal/gdpr.rs b/src/routes/internal/gdpr.rs index 8d7f51da..e07855e5 100644 --- a/src/routes/internal/gdpr.rs +++ b/src/routes/internal/gdpr.rs @@ -21,7 +21,7 @@ pub async fn export( &req, &**pool, &redis, - &*session_queue, + &session_queue, Some(&[Scopes::SESSION_ACCESS]), ) .await? @@ -34,19 +34,19 @@ pub async fn export( crate::database::models::Collection::get_many(&collection_ids, &**pool, &redis) .await? .into_iter() - .map(|x| crate::models::collections::Collection::from(x)) + .map(crate::models::collections::Collection::from) .collect::>(); let follows = crate::database::models::User::get_follows(user_id, &**pool) .await? .into_iter() - .map(|x| crate::models::ids::ProjectId::from(x)) + .map(crate::models::ids::ProjectId::from) .collect::>(); let projects = crate::database::models::User::get_projects(user_id, &**pool, &redis) .await? .into_iter() - .map(|x| crate::models::ids::ProjectId::from(x)) + .map(crate::models::ids::ProjectId::from) .collect::>(); let org_ids = crate::database::models::User::get_organizations(user_id, &**pool).await?; @@ -64,7 +64,7 @@ pub async fn export( ) .await? .into_iter() - .map(|x| crate::models::notifications::Notification::from(x)) + .map(crate::models::notifications::Notification::from) .collect::>(); let oauth_clients = @@ -73,7 +73,7 @@ pub async fn export( ) .await? .into_iter() - .map(|x| crate::models::oauth_clients::OAuthClient::from(x)) + .map(crate::models::oauth_clients::OAuthClient::from) .collect::>(); let oauth_authorizations = crate::database::models::oauth_client_authorization_item::OAuthClientAuthorization::get_all_for_user( @@ -81,7 +81,7 @@ pub async fn export( ) .await? .into_iter() - .map(|x| crate::models::oauth_clients::OAuthClientAuthorization::from(x)) + .map(crate::models::oauth_clients::OAuthClientAuthorization::from) .collect::>(); let pat_ids = crate::database::models::pat_item::PersonalAccessToken::get_user_pats( @@ -102,7 +102,7 @@ pub async fn export( let payouts = crate::database::models::payout_item::Payout::get_many(&payout_ids, &**pool) .await? .into_iter() - .map(|x| crate::models::payouts::Payout::from(x)) + .map(crate::models::payouts::Payout::from) .collect::>(); let report_ids = @@ -110,7 +110,7 @@ pub async fn export( let reports = crate::database::models::report_item::Report::get_many(&report_ids, &**pool) .await? .into_iter() - .map(|x| crate::models::reports::Report::from(x)) + .map(crate::models::reports::Report::from) .collect::>(); let message_ids = sqlx::query!( @@ -146,7 +146,7 @@ pub async fn export( crate::database::models::image_item::Image::get_many(&uploaded_images_ids, &**pool, &redis) .await? .into_iter() - .map(|x| crate::models::images::Image::from(x)) + .map(crate::models::images::Image::from) .collect::>(); let subscriptions = @@ -155,7 +155,7 @@ pub async fn export( ) .await? .into_iter() - .map(|x| crate::models::billing::UserSubscription::from(x)) + .map(crate::models::billing::UserSubscription::from) .collect::>(); Ok(HttpResponse::Ok().json(serde_json::json!({ diff --git a/src/routes/v2/version_creation.rs b/src/routes/v2/version_creation.rs index f9422de2..cd4335a1 100644 --- a/src/routes/v2/version_creation.rs +++ b/src/routes/v2/version_creation.rs @@ -106,14 +106,11 @@ pub async fn version_create( // Get all possible side-types for loaders given- we will use these to check if we need to convert/apply singleplayer, etc. let loaders = match v3::tags::loader_list(client.clone(), redis.clone()).await { - Ok(loader_response) => match v2_reroute::extract_ok_json::< - Vec, - >(loader_response) - .await - { - Ok(loaders) => loaders, - Err(_) => vec![], - }, + Ok(loader_response) => { + (v2_reroute::extract_ok_json::>(loader_response) + .await) + .unwrap_or_default() + } Err(_) => vec![], }; diff --git a/src/routes/v3/payouts.rs b/src/routes/v3/payouts.rs index f2d1d6fb..f97844d2 100644 --- a/src/routes/v3/payouts.rs +++ b/src/routes/v3/payouts.rs @@ -352,7 +352,7 @@ pub async fn create_payout( .fetch_optional(&mut *transaction) .await?; - let balance = get_user_balance(user.id.into(), &**pool).await?; + let balance = get_user_balance(user.id, &pool).await?; if balance.available < body.amount || body.amount < Decimal::ZERO { return Err(ApiError::InvalidInput( "You do not have enough funds to make this payout!".to_string(), @@ -734,7 +734,7 @@ pub async fn get_balance( .await? .1; - let balance = get_user_balance(user.id.into(), &**pool).await?; + let balance = get_user_balance(user.id.into(), &pool).await?; Ok(HttpResponse::Ok().json(balance)) } From 61bc0d933da427799bda6c08082ec0a94dc7e769 Mon Sep 17 00:00:00 2001 From: Jai A Date: Wed, 9 Oct 2024 21:04:52 -0700 Subject: [PATCH 7/7] Remove unused test --- tests/project.rs | 88 ------------------------------------------------ 1 file changed, 88 deletions(-) diff --git a/tests/project.rs b/tests/project.rs index 99c68c3b..ed096507 100644 --- a/tests/project.rs +++ b/tests/project.rs @@ -579,94 +579,6 @@ pub async fn test_bulk_edit_links() { .await; } -#[actix_rt::test] -async fn delete_project_with_report() { - with_test_environment(None, |test_env: TestEnvironment| async move { - let api = &test_env.api; - let alpha_project_id: &str = &test_env.dummy.project_alpha.project_id; - let beta_project_id: &str = &test_env.dummy.project_beta.project_id; - - // Create a report for the project - let resp = api - .create_report( - "copyright", - alpha_project_id, - CommonItemType::Project, - "Hey! This is my project, copied without permission!", - ENEMY_USER_PAT, // Enemy makes a report - ) - .await; - assert_status!(&resp, StatusCode::OK); - let value = test::read_body_json::(resp).await; - let alpha_report_id = value["id"].as_str().unwrap(); - - // Confirm existence - let resp = api - .get_report( - alpha_report_id, - ENEMY_USER_PAT, // Enemy makes a report - ) - .await; - assert_status!(&resp, StatusCode::OK); - - // Do the same for beta - let resp = api - .create_report( - "copyright", - beta_project_id, - CommonItemType::Project, - "Hey! This is my project, copied without permission!", - ENEMY_USER_PAT, // Enemy makes a report - ) - .await; - assert_status!(&resp, StatusCode::OK); - let value = test::read_body_json::(resp).await; - let beta_report_id = value["id"].as_str().unwrap(); - - // Delete the project - let resp = api.remove_project(alpha_project_id, USER_USER_PAT).await; - assert_status!(&resp, StatusCode::NO_CONTENT); - - // Confirm that the project is gone from the cache - let mut redis_pool = test_env.db.redis_pool.connect().await.unwrap(); - assert_eq!( - redis_pool - .get(PROJECTS_SLUGS_NAMESPACE, "demo") - .await - .unwrap() - .and_then(|x| x.parse::().ok()), - None - ); - assert_eq!( - redis_pool - .get(PROJECTS_SLUGS_NAMESPACE, alpha_project_id) - .await - .unwrap() - .and_then(|x| x.parse::().ok()), - None - ); - - // Report for alpha no longer exists - let resp = api - .get_report( - alpha_report_id, - ENEMY_USER_PAT, // Enemy makes a report - ) - .await; - assert_status!(&resp, StatusCode::NOT_FOUND); - - // Confirm that report for beta still exists - let resp = api - .get_report( - beta_report_id, - ENEMY_USER_PAT, // Enemy makes a report - ) - .await; - assert_status!(&resp, StatusCode::OK); - }) - .await; -} - #[actix_rt::test] async fn permissions_patch_project_v3() { with_test_environment(Some(8), |test_env: TestEnvironment| async move {