Promote to full subscription, fmt + clippy

This commit is contained in:
fetch 2025-08-07 00:16:36 -04:00
parent 7a39f5853f
commit 09e89724de
No known key found for this signature in database
10 changed files with 278 additions and 70 deletions

View File

@ -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"
}

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -102,5 +102,5 @@
true true
] ]
}, },
"hash": "cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055" "hash": "2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7"
} }

View File

@ -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"
}

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -14,12 +14,14 @@
"Int8", "Int8",
"Varchar", "Varchar",
"Timestamptz", "Timestamptz",
"Varchar" "Varchar",
"Timestamptz",
"Int4"
] ]
}, },
"nullable": [ "nullable": [
false false
] ]
}, },
"hash": "8e5e0eb3ac9e34c944e9e6eedd5143dd7bde0cebee0e8e669d9a784e96c76ade" "hash": "7adff98b270adc4a48e2c8a89a32ca1b83104102190597f4cda05e6f1c1e8f26"
} }

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
@ -8,10 +8,12 @@
"Int4", "Int4",
"Varchar", "Varchar",
"Varchar", "Varchar",
"Timestamptz" "Timestamptz",
"Timestamptz",
"Int4"
] ]
}, },
"nullable": [] "nullable": []
}, },
"hash": "e7937a0d98454477409746b2b3111ba5daab035afed0a044b32bdd4040105efd" "hash": "8d61d1ecc5321e2ac8932ef99de0f77e49cced9c7726ea746392a5fcbe75f2f5"
} }

View File

@ -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"
}

View File

@ -197,7 +197,7 @@ impl DBCharge {
) -> Result<Option<DBCharge>, DatabaseError> { ) -> Result<Option<DBCharge>, DatabaseError> {
let user_subscription_id = user_subscription_id.0; let user_subscription_id = user_subscription_id.0;
let res = select_charges_with_predicate!( 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 user_subscription_id
) )
.fetch_optional(exec) .fetch_optional(exec)

View File

@ -288,6 +288,15 @@ impl DBProductPrice {
.collect::<Result<Vec<_>, serde_json::Error>>()?) .collect::<Result<Vec<_>, serde_json::Error>>()?)
} }
pub async fn get_all_product_prices(
product_id: DBProductId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<DBProductPrice>, 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( pub async fn get_all_public_product_prices(
product_id: DBProductId, product_id: DBProductId,
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,

View File

@ -59,7 +59,7 @@ impl Status {
"pending" => Status::Pending, "pending" => Status::Pending,
"processing" => Status::Processing, "processing" => Status::Processing,
"processed" => Status::Processed, "processed" => Status::Processed,
_ => Default::default(), _ => Status::default(),
} }
} }
} }

View File

@ -384,6 +384,7 @@ pub async fn edit_subscription(
})?; })?;
if let Some(cancelled) = &edit_subscription.cancelled { if let Some(cancelled) = &edit_subscription.cancelled {
// Notably, cannot cancel/uncancel expiring charges.
if !matches!( if !matches!(
open_charge.status, open_charge.status,
ChargeStatus::Open ChargeStatus::Open
@ -408,6 +409,8 @@ pub async fn edit_subscription(
if let Some(interval) = &edit_subscription.interval { if let Some(interval) = &edit_subscription.interval {
if let Price::Recurring { intervals } = &current_price.prices { if let Price::Recurring { intervals } = &current_price.prices {
// For expiring charges, the interval is handled in the Product branch.
if open_charge.status != ChargeStatus::Expiring {
if let Some(price) = intervals.get(interval) { if let Some(price) = intervals.get(interval) {
open_charge.subscription_interval = Some(*interval); open_charge.subscription_interval = Some(*interval);
open_charge.amount = *price as i64; open_charge.amount = *price as i64;
@ -419,10 +422,11 @@ pub async fn edit_subscription(
} }
} }
} }
}
let intent = if let Some(product_id) = &edit_subscription.product { let intent = if let Some(product_id) = &edit_subscription.product {
let product_price = let product_price =
product_item::DBProductPrice::get_all_public_product_prices( product_item::DBProductPrice::get_all_product_prices(
(*product_id).into(), (*product_id).into(),
&mut *transaction, &mut *transaction,
) )
@ -443,6 +447,97 @@ pub async fn edit_subscription(
)); ));
} }
// 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 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 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(
&current_price.currency_code.to_lowercase(),
)
.map_err(|_| {
ApiError::InvalidInput("Invalid currency code".to_string())
})?;
let mut intent =
CreatePaymentIntent::new(new_price_value 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(),
interval.as_str().to_string(),
);
metadata.insert(
"modrinth_charge_type".to_string(),
ChargeType::Subscription.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((new_price_value, 0, intent))
} else {
// The charge is not an expiring charge, need to prorate.
let interval = open_charge.due - Utc::now(); let interval = open_charge.due - Utc::now();
let duration = PriceDuration::Monthly; let duration = PriceDuration::Monthly;
@ -466,7 +561,8 @@ pub async fn edit_subscription(
let complete = Decimal::from(interval.num_seconds()) let complete = Decimal::from(interval.num_seconds())
/ Decimal::from(duration.duration().num_seconds()); / Decimal::from(duration.duration().num_seconds());
let proration = (Decimal::from(amount - current_amount) * complete) let proration = (Decimal::from(amount - current_amount)
* complete)
.floor() .floor()
.to_i32() .to_i32()
.ok_or_else(|| { .ok_or_else(|| {
@ -475,8 +571,8 @@ pub async fn edit_subscription(
) )
})?; })?;
// First branch: Plan downgrade, update future charge // First condition: Plan downgrade, update future charge
// Second branch: For small transactions (under 30 cents), we make a loss on the // 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 // 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. // their next charge will be in a day or two anyway.
if current_amount > amount || proration < 30 { if current_amount > amount || proration < 30 {
@ -485,7 +581,8 @@ pub async fn edit_subscription(
None None
} else { } else {
let charge_id = generate_charge_id(&mut transaction).await?; let charge_id =
generate_charge_id(&mut transaction).await?;
let customer_id = get_or_create_customer( let customer_id = get_or_create_customer(
user.id, user.id,
@ -501,7 +598,9 @@ pub async fn edit_subscription(
&current_price.currency_code.to_lowercase(), &current_price.currency_code.to_lowercase(),
) )
.map_err(|_| { .map_err(|_| {
ApiError::InvalidInput("Invalid currency code".to_string()) ApiError::InvalidInput(
"Invalid currency code".to_string(),
)
})?; })?;
let mut intent = let mut intent =
@ -543,7 +642,8 @@ pub async fn edit_subscription(
intent.setup_future_usage = intent.setup_future_usage =
Some(PaymentIntentSetupFutureUsage::OffSession); Some(PaymentIntentSetupFutureUsage::OffSession);
if let Some(payment_method) = &edit_subscription.payment_method if let Some(payment_method) =
&edit_subscription.payment_method
{ {
let Ok(payment_method_id) = let Ok(payment_method_id) =
PaymentMethodId::from_str(payment_method) PaymentMethodId::from_str(payment_method)
@ -561,6 +661,7 @@ pub async fn edit_subscription(
Some((proration, 0, intent)) Some((proration, 0, intent))
} }
}
} else { } else {
None None
}; };
@ -2292,7 +2393,9 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) {
// Try processing it. // Try processing it.
let pending_redeemals = UserRedeemal::get_pending(&pool, 100).await?; let pending_redeemals = UserRedeemal::get_pending(&pool, 100).await?;
for redeemal in pending_redeemals { 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.") warn!(%error, "Failed to process a redeemal.")
} }
} }
@ -2408,7 +2511,7 @@ pub async fn try_process_user_redeemal(
swap_mb: swap, swap_mb: swap,
storage_mb: storage, storage_mb: storage,
}, },
source: Default::default(), source: crate::util::archon::Empty::default(),
region, region,
}) })
.await?; .await?;