Promote to full subscription, fmt + clippy
This commit is contained in:
parent
7a39f5853f
commit
09e89724de
15
apps/labrinth/.sqlx/query-1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341.json
generated
Normal file
15
apps/labrinth/.sqlx/query-1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341.json
generated
Normal 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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
59
apps/labrinth/.sqlx/query-58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286.json
generated
Normal file
59
apps/labrinth/.sqlx/query-58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286.json
generated
Normal 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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
18
apps/labrinth/.sqlx/query-e3f6fa7e5ec6dee4fcdff904b3e692dccd55372d9cc827a1d68361fd036bc183.json
generated
Normal file
18
apps/labrinth/.sqlx/query-e3f6fa7e5ec6dee4fcdff904b3e692dccd55372d9cc827a1d68361fd036bc183.json
generated
Normal 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"
|
||||
}
|
||||
@ -197,7 +197,7 @@ impl DBCharge {
|
||||
) -> Result<Option<DBCharge>, 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)
|
||||
|
||||
@ -288,6 +288,15 @@ impl DBProductPrice {
|
||||
.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(
|
||||
product_id: DBProductId,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
|
||||
|
||||
@ -59,7 +59,7 @@ impl Status {
|
||||
"pending" => Status::Pending,
|
||||
"processing" => Status::Processing,
|
||||
"processed" => Status::Processed,
|
||||
_ => Default::default(),
|
||||
_ => Status::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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?;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user