diff --git a/apps/labrinth/.sqlx/query-1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341.json b/apps/labrinth/.sqlx/query-1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341.json new file mode 100644 index 000000000..37689395e --- /dev/null +++ b/apps/labrinth/.sqlx/query-1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users_redeemals\n SET status = $1\n WHERE\n status = $2\n AND NOW() - last_attempt > INTERVAL '5 minutes'\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341" +} diff --git a/apps/labrinth/.sqlx/query-cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055.json b/apps/labrinth/.sqlx/query-2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7.json similarity index 93% rename from apps/labrinth/.sqlx/query-cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055.json rename to apps/labrinth/.sqlx/query-2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7.json index 47805a8b3..223a79b2d 100644 --- a/apps/labrinth/.sqlx/query-cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055.json +++ b/apps/labrinth/.sqlx/query-2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')", + "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')", "describe": { "columns": [ { @@ -102,5 +102,5 @@ true ] }, - "hash": "cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055" + "hash": "2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7" } diff --git a/apps/labrinth/.sqlx/query-58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286.json b/apps/labrinth/.sqlx/query-58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286.json new file mode 100644 index 000000000..b5166fb69 --- /dev/null +++ b/apps/labrinth/.sqlx/query-58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM users_redeemals WHERE status = $1 LIMIT $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "offer", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "redeemed", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "last_attempt", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "n_attempts", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286" +} diff --git a/apps/labrinth/.sqlx/query-8e5e0eb3ac9e34c944e9e6eedd5143dd7bde0cebee0e8e669d9a784e96c76ade.json b/apps/labrinth/.sqlx/query-7adff98b270adc4a48e2c8a89a32ca1b83104102190597f4cda05e6f1c1e8f26.json similarity index 58% rename from apps/labrinth/.sqlx/query-8e5e0eb3ac9e34c944e9e6eedd5143dd7bde0cebee0e8e669d9a784e96c76ade.json rename to apps/labrinth/.sqlx/query-7adff98b270adc4a48e2c8a89a32ca1b83104102190597f4cda05e6f1c1e8f26.json index 9162be751..4e253b742 100644 --- a/apps/labrinth/.sqlx/query-8e5e0eb3ac9e34c944e9e6eedd5143dd7bde0cebee0e8e669d9a784e96c76ade.json +++ b/apps/labrinth/.sqlx/query-7adff98b270adc4a48e2c8a89a32ca1b83104102190597f4cda05e6f1c1e8f26.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO users_redeemals\n (user_id, offer, redeemed, status)\n VALUES ($1, $2, $3, $4)\n RETURNING id", + "query": "\n INSERT INTO users_redeemals\n (user_id, offer, redeemed, status, last_attempt, n_attempts)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id", "describe": { "columns": [ { @@ -14,12 +14,14 @@ "Int8", "Varchar", "Timestamptz", - "Varchar" + "Varchar", + "Timestamptz", + "Int4" ] }, "nullable": [ false ] }, - "hash": "8e5e0eb3ac9e34c944e9e6eedd5143dd7bde0cebee0e8e669d9a784e96c76ade" + "hash": "7adff98b270adc4a48e2c8a89a32ca1b83104102190597f4cda05e6f1c1e8f26" } diff --git a/apps/labrinth/.sqlx/query-e7937a0d98454477409746b2b3111ba5daab035afed0a044b32bdd4040105efd.json b/apps/labrinth/.sqlx/query-8d61d1ecc5321e2ac8932ef99de0f77e49cced9c7726ea746392a5fcbe75f2f5.json similarity index 56% rename from apps/labrinth/.sqlx/query-e7937a0d98454477409746b2b3111ba5daab035afed0a044b32bdd4040105efd.json rename to apps/labrinth/.sqlx/query-8d61d1ecc5321e2ac8932ef99de0f77e49cced9c7726ea746392a5fcbe75f2f5.json index fd72db120..aa10d147f 100644 --- a/apps/labrinth/.sqlx/query-e7937a0d98454477409746b2b3111ba5daab035afed0a044b32bdd4040105efd.json +++ b/apps/labrinth/.sqlx/query-8d61d1ecc5321e2ac8932ef99de0f77e49cced9c7726ea746392a5fcbe75f2f5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE users_redeemals\n SET\n offer = $2,\n status = $3,\n redeemed = $4\n WHERE id = $1\n ", + "query": "\n UPDATE users_redeemals\n SET\n offer = $2,\n status = $3,\n redeemed = $4,\n last_attempt = $5,\n n_attempts = $6\n WHERE id = $1\n ", "describe": { "columns": [], "parameters": { @@ -8,10 +8,12 @@ "Int4", "Varchar", "Varchar", - "Timestamptz" + "Timestamptz", + "Timestamptz", + "Int4" ] }, "nullable": [] }, - "hash": "e7937a0d98454477409746b2b3111ba5daab035afed0a044b32bdd4040105efd" + "hash": "8d61d1ecc5321e2ac8932ef99de0f77e49cced9c7726ea746392a5fcbe75f2f5" } diff --git a/apps/labrinth/.sqlx/query-e3f6fa7e5ec6dee4fcdff904b3e692dccd55372d9cc827a1d68361fd036bc183.json b/apps/labrinth/.sqlx/query-e3f6fa7e5ec6dee4fcdff904b3e692dccd55372d9cc827a1d68361fd036bc183.json new file mode 100644 index 000000000..93d7dd8fb --- /dev/null +++ b/apps/labrinth/.sqlx/query-e3f6fa7e5ec6dee4fcdff904b3e692dccd55372d9cc827a1d68361fd036bc183.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users_redeemals\n SET\n status = $3,\n last_attempt = $4,\n n_attempts = $5\n WHERE id = $1 AND status = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Text", + "Varchar", + "Timestamptz", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "e3f6fa7e5ec6dee4fcdff904b3e692dccd55372d9cc827a1d68361fd036bc183" +} diff --git a/apps/labrinth/src/database/models/charge_item.rs b/apps/labrinth/src/database/models/charge_item.rs index 33ea33b3d..24cc9b0cd 100644 --- a/apps/labrinth/src/database/models/charge_item.rs +++ b/apps/labrinth/src/database/models/charge_item.rs @@ -197,7 +197,7 @@ impl DBCharge { ) -> 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' OR status = 'failed')", + "WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')", user_subscription_id ) .fetch_optional(exec) diff --git a/apps/labrinth/src/database/models/product_item.rs b/apps/labrinth/src/database/models/product_item.rs index 02f05deb1..f99605398 100644 --- a/apps/labrinth/src/database/models/product_item.rs +++ b/apps/labrinth/src/database/models/product_item.rs @@ -288,6 +288,15 @@ impl DBProductPrice { .collect::, serde_json::Error>>()?) } + pub async fn get_all_product_prices( + product_id: DBProductId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let res = Self::get_all_products_prices(&[product_id], exec).await?; + + Ok(res.remove(&product_id).map(|x| x.1).unwrap_or_default()) + } + pub async fn get_all_public_product_prices( product_id: DBProductId, exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, diff --git a/apps/labrinth/src/database/models/users_redeemals.rs b/apps/labrinth/src/database/models/users_redeemals.rs index 2fb62695f..6dda41594 100644 --- a/apps/labrinth/src/database/models/users_redeemals.rs +++ b/apps/labrinth/src/database/models/users_redeemals.rs @@ -59,7 +59,7 @@ impl Status { "pending" => Status::Pending, "processing" => Status::Processing, "processed" => Status::Processed, - _ => Default::default(), + _ => Status::default(), } } } diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index 8c2158f0a..a01a75f94 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -384,6 +384,7 @@ pub async fn edit_subscription( })?; if let Some(cancelled) = &edit_subscription.cancelled { + // Notably, cannot cancel/uncancel expiring charges. if !matches!( open_charge.status, ChargeStatus::Open @@ -408,21 +409,24 @@ pub async fn edit_subscription( if let Some(interval) = &edit_subscription.interval { 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(), - )); + // For expiring charges, the interval is handled in the Product branch. + if open_charge.status != ChargeStatus::Expiring { + 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(), + )); + } } } } let intent = if let Some(product_id) = &edit_subscription.product { let product_price = - product_item::DBProductPrice::get_all_public_product_prices( + product_item::DBProductPrice::get_all_product_prices( (*product_id).into(), &mut *transaction, ) @@ -443,48 +447,14 @@ pub async fn edit_subscription( )); } - let interval = open_charge.due - Utc::now(); - let duration = PriceDuration::Monthly; + // If the charge is an expiring charge, we need to create a payment + // intent as if the user was subscribing to the product, as opposed + // to a proration. + if open_charge.status == ChargeStatus::Expiring { + // We need a new interval when promoting the charge. + let interval = edit_subscription.interval + .ok_or_else(|| ApiError::InvalidInput("You need to specify an interval when promoting an expiring charge.".to_owned()))?; - let current_amount = match ¤t_price.prices { - Price::OneTime { price } => *price, - Price::Recurring { intervals } => *intervals.get(&duration).ok_or_else(|| { - ApiError::InvalidInput( - "Could not find a valid price for the user's duration".to_string(), - ) - })?, - }; - - let amount = match &product_price.prices { - Price::OneTime { price } => *price, - Price::Recurring { intervals } => *intervals.get(&duration).ok_or_else(|| { - ApiError::InvalidInput( - "Could not find a valid price for the user's duration".to_string(), - ) - })?, - }; - - let complete = Decimal::from(interval.num_seconds()) - / Decimal::from(duration.duration().num_seconds()); - let proration = (Decimal::from(amount - current_amount) * complete) - .floor() - .to_i32() - .ok_or_else(|| { - ApiError::InvalidInput( - "Could not convert proration to i32".to_string(), - ) - })?; - - // First branch: Plan downgrade, update future charge - // Second branch: For small transactions (under 30 cents), we make a loss on the - // proration due to fees. In these situations, just give it to them for free, because - // their next charge will be in a day or two anyway. - if current_amount > amount || proration < 30 { - open_charge.price_id = product_price.id; - open_charge.amount = amount as i64; - - None - } else { let charge_id = generate_charge_id(&mut transaction).await?; let customer_id = get_or_create_customer( @@ -497,6 +467,15 @@ pub async fn edit_subscription( ) .await?; + let new_price_value = match product_price.prices { + Price::OneTime { ref price } => *price, + Price::Recurring { ref intervals } => { + *intervals + .get(&interval) + .ok_or_else(|| ApiError::InvalidInput("Could not find a valid price for the specified duration".to_owned()))? + } + }; + let currency = Currency::from_str( ¤t_price.currency_code.to_lowercase(), ) @@ -505,7 +484,7 @@ pub async fn edit_subscription( })?; let mut intent = - CreatePaymentIntent::new(proration as i64, currency); + CreatePaymentIntent::new(new_price_value as i64, currency); let mut metadata = HashMap::new(); metadata.insert( @@ -526,15 +505,11 @@ pub async fn edit_subscription( ); metadata.insert( "modrinth_subscription_interval".to_string(), - open_charge - .subscription_interval - .unwrap_or(PriceDuration::Monthly) - .as_str() - .to_string(), + interval.as_str().to_string(), ); metadata.insert( "modrinth_charge_type".to_string(), - ChargeType::Proration.as_str().to_string(), + ChargeType::Subscription.as_str().to_string(), ); intent.customer = Some(customer_id); @@ -559,7 +534,133 @@ pub async fn edit_subscription( stripe::PaymentIntent::create(&stripe_client, intent) .await?; - Some((proration, 0, intent)) + Some((new_price_value, 0, intent)) + } else { + // The charge is not an expiring charge, need to prorate. + + let interval = open_charge.due - Utc::now(); + let duration = PriceDuration::Monthly; + + let current_amount = match ¤t_price.prices { + Price::OneTime { price } => *price, + Price::Recurring { intervals } => *intervals.get(&duration).ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for the user's duration".to_string(), + ) + })?, + }; + + let amount = match &product_price.prices { + Price::OneTime { price } => *price, + Price::Recurring { intervals } => *intervals.get(&duration).ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for the user's duration".to_string(), + ) + })?, + }; + + let complete = Decimal::from(interval.num_seconds()) + / Decimal::from(duration.duration().num_seconds()); + let proration = (Decimal::from(amount - current_amount) + * complete) + .floor() + .to_i32() + .ok_or_else(|| { + ApiError::InvalidInput( + "Could not convert proration to i32".to_string(), + ) + })?; + + // First condition: Plan downgrade, update future charge + // Second condition: For small transactions (under 30 cents), we make a loss on the + // proration due to fees. In these situations, just give it to them for free, because + // their next charge will be in a day or two anyway. + if current_amount > amount || proration < 30 { + open_charge.price_id = product_price.id; + open_charge.amount = amount as i64; + + None + } else { + let charge_id = + generate_charge_id(&mut transaction).await?; + + let customer_id = get_or_create_customer( + user.id, + user.stripe_customer_id.as_deref(), + user.email.as_deref(), + &stripe_client, + &pool, + &redis, + ) + .await?; + + let currency = Currency::from_str( + ¤t_price.currency_code.to_lowercase(), + ) + .map_err(|_| { + ApiError::InvalidInput( + "Invalid currency code".to_string(), + ) + })?; + + let mut intent = + CreatePaymentIntent::new(proration as i64, currency); + + let mut metadata = HashMap::new(); + metadata.insert( + "modrinth_user_id".to_string(), + to_base62(user.id.0), + ); + 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(product_price.id.0 as u64), + ); + metadata.insert( + "modrinth_subscription_interval".to_string(), + open_charge + .subscription_interval + .unwrap_or(PriceDuration::Monthly) + .as_str() + .to_string(), + ); + metadata.insert( + "modrinth_charge_type".to_string(), + ChargeType::Proration.as_str().to_string(), + ); + + intent.customer = Some(customer_id); + intent.metadata = Some(metadata); + intent.receipt_email = user.email.as_deref(); + intent.setup_future_usage = + Some(PaymentIntentSetupFutureUsage::OffSession); + + if let Some(payment_method) = + &edit_subscription.payment_method + { + let Ok(payment_method_id) = + PaymentMethodId::from_str(payment_method) + else { + return Err(ApiError::InvalidInput( + "Invalid payment method id".to_string(), + )); + }; + intent.payment_method = Some(payment_method_id); + } + + let intent = + stripe::PaymentIntent::create(&stripe_client, intent) + .await?; + + Some((proration, 0, intent)) + } } } else { None @@ -2292,7 +2393,9 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) { // Try processing it. let pending_redeemals = UserRedeemal::get_pending(&pool, 100).await?; for redeemal in pending_redeemals { - if let Err(error) = try_process_user_redeemal(&pool, &redis, redeemal).await { + if let Err(error) = + try_process_user_redeemal(&pool, &redis, redeemal).await + { warn!(%error, "Failed to process a redeemal.") } } @@ -2408,7 +2511,7 @@ pub async fn try_process_user_redeemal( swap_mb: swap, storage_mb: storage, }, - source: Default::default(), + source: crate::util::archon::Empty::default(), region, }) .await?;