Skip to content
This repository has been archived by the owner on Oct 19, 2024. It is now read-only.

Commit

Permalink
Prorations
Browse files Browse the repository at this point in the history
  • Loading branch information
Geometrically committed Oct 12, 2024
1 parent c88bfbb commit 2ed60c1
Show file tree
Hide file tree
Showing 2 changed files with 170 additions and 13 deletions.
5 changes: 4 additions & 1 deletion src/models/v3/billing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,16 @@ impl PriceDuration {
_ => PriceDuration::Monthly,
}
}

pub fn as_str(&self) -> &'static str {
match self {
PriceDuration::Monthly => "monthly",
PriceDuration::Yearly => "yearly",
}
}

pub fn iterator() -> impl Iterator<Item = PriceDuration> {
vec![PriceDuration::Monthly, PriceDuration::Yearly].into_iter()
}
}

#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)]
Expand Down
178 changes: 166 additions & 12 deletions src/routes/internal/billing.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::auth::{get_user_from_headers, send_email};
use crate::database::models::charge_item::ChargeItem;
use crate::database::models::{
generate_charge_id, generate_user_subscription_id, product_item, user_subscription_item,
};
Expand All @@ -15,6 +16,8 @@ use crate::routes::ApiError;
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
use chrono::Utc;
use log::{info, warn};
use rust_decimal::prelude::ToPrimitive;
use rust_decimal::Decimal;
use serde::Serialize;
use serde_with::serde_derive::Deserialize;
use sqlx::{PgPool, Postgres, Transaction};
Expand Down Expand Up @@ -105,6 +108,7 @@ pub async fn subscriptions(
#[derive(Deserialize)]
pub struct SubscriptionEdit {
pub interval: Option<PriceDuration>,
pub payment_method: Option<String>,
pub cancelled: Option<bool>,
pub product: Option<crate::models::ids::ProductId>,
}
Expand All @@ -117,6 +121,7 @@ pub async fn edit_subscription(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
edit_subscription: web::Json<SubscriptionEdit>,
stripe_client: web::Data<stripe::Client>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
Expand Down Expand Up @@ -187,11 +192,132 @@ pub async fn edit_subscription(
}
}

let intent = if let Some(product_id) = &edit_subscription.product {
let product_price = product_item::ProductPriceItem::get_all_product_prices(
(*product_id).into(),
&mut *transaction,
)
.await?
.into_iter()
.find(|x| x.currency_code == current_price.currency_code)
.ok_or_else(|| {
ApiError::InvalidInput(
"Could not find a valid price for your currency code!".to_string(),
)
})?;

if product_price.id == current_price.id {
return Err(ApiError::InvalidInput(
"You may not change the price of this subscription!".to_string(),
));
}

let interval = open_charge.due - Utc::now();
let duration = PriceDuration::iterator()
.min_by_key(|x| (x.duration().num_seconds() - interval.num_seconds()).abs())
.unwrap_or(PriceDuration::Monthly);

let current_amount = match &current_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())
})?;

let charge_id = generate_charge_id(&mut transaction).await?;
let charge = ChargeItem {
id: charge_id,
user_id: user.id.into(),
price_id: product_price.id,
amount: proration as i64,
currency_code: current_price.currency_code.clone(),
status: ChargeStatus::Processing,
due: Utc::now(),
last_attempt: None,
type_: ChargeType::Proration,
subscription_id: Some(subscription.id),
subscription_interval: Some(duration),
};

let customer_id = get_or_create_customer(
user.id,
user.stripe_customer_id.as_deref(),
user.email.as_deref(),
&stripe_client,
&pool,
&redis,
)
.await?;

let currency = Currency::from_str(&current_price.currency_code.to_lowercase())
.map_err(|_| ApiError::InvalidInput("Invalid currency code".to_string()))?;

let mut intent = CreatePaymentIntent::new(proration as i64, currency);

let mut metadata = HashMap::new();
metadata.insert("modrinth_user_id".to_string(), to_base62(user.id.0));

intent.customer = Some(customer_id);
intent.metadata = Some(metadata);
intent.receipt_email = user.email.as_deref();
intent.setup_future_usage = Some(PaymentIntentSetupFutureUsage::OffSession);

if let Some(payment_method) = &edit_subscription.payment_method {
let payment_method_id = if let Ok(id) = PaymentMethodId::from_str(&payment_method) {

Check warning on line 286 in src/routes/internal/billing.rs

View workflow job for this annotation

GitHub Actions / clippy

this expression creates a reference which is immediately dereferenced by the compiler

warning: this expression creates a reference which is immediately dereferenced by the compiler --> src/routes/internal/billing.rs:286:83 | 286 | let payment_method_id = if let Ok(id) = PaymentMethodId::from_str(&payment_method) { | ^^^^^^^^^^^^^^^ help: change this to: `payment_method` | = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#needless_borrow = note: `#[warn(clippy::needless_borrow)]` on by default
id
} else {
return Err(ApiError::InvalidInput(
"Invalid payment method id".to_string(),
));
};
intent.payment_method = Some(payment_method_id);
}

charge.upsert(&mut transaction).await?;

Some((
proration,
0,
stripe::PaymentIntent::create(&stripe_client, intent).await?,
))
} else {
None
};

open_charge.upsert(&mut transaction).await?;

transaction.commit().await?;

Ok(HttpResponse::NoContent().body(""))
if let Some((amount, tax, payment_intent)) = intent {
Ok(HttpResponse::Ok().json(serde_json::json!({
"payment_intent_id": payment_intent.id,
"client_secret": payment_intent.client_secret,
"tax": tax,
"total": amount
})))
} else {
Ok(HttpResponse::NoContent().body(""))
}
} else {
Err(ApiError::NotFound)
}
Expand Down Expand Up @@ -971,9 +1097,17 @@ pub async fn stripe_webhook(
break 'metadata;
};

if let Some(interval) = charge.subscription_interval {
subscription.interval = interval;
match charge.type_ {
ChargeType::OneTime | ChargeType::Subscription => {
if let Some(interval) = charge.subscription_interval {
subscription.interval = interval;
}
}
ChargeType::Proration => {
subscription.price_id = charge.price_id;
}
}

subscription.upsert(transaction).await?;

(charge, price, product, Some(subscription))
Expand Down Expand Up @@ -1056,7 +1190,7 @@ pub async fn stripe_webhook(
}
};

let charge = crate::database::models::charge_item::ChargeItem {
let charge = ChargeItem {
id: charge_id,
user_id,
price_id,
Expand Down Expand Up @@ -1180,25 +1314,45 @@ pub async fn stripe_webhook(
}

if let Some(subscription) = metadata.user_subscription_item {
if metadata.charge_item.status != ChargeStatus::Cancelled {
let open_charge =
ChargeItem::get_open_subscription(subscription.id, &mut *transaction)
.await?;

let new_price = match metadata.product_price_item.prices {
Price::OneTime { price } => price,
Price::Recurring { intervals } => {
*intervals.get(&subscription.interval).ok_or_else(|| {
ApiError::InvalidInput(
"Could not find a valid price for the user's country"
.to_string(),
)
})?
}
};

if let Some(mut charge) = open_charge {
charge.price_id = metadata.product_price_item.id;
charge.amount = new_price as i64;

charge.upsert(&mut transaction).await?;
} else if metadata.charge_item.status != ChargeStatus::Cancelled {
let charge_id = generate_charge_id(&mut transaction).await?;
let charge = crate::database::models::charge_item::ChargeItem {
ChargeItem {
id: charge_id,
user_id: metadata.user_item.id,
price_id: metadata.product_price_item.id,
amount: metadata.charge_item.amount,
amount: new_price as i64,
currency_code: metadata.product_price_item.currency_code,
status: ChargeStatus::Open,
due: Utc::now() + subscription.interval.duration(),
last_attempt: None,
type_: ChargeType::Subscription,
subscription_id: Some(subscription.id),
subscription_interval: Some(subscription.interval),
};
let err = charge.upsert(&mut transaction).await;

err?;
}
}
.upsert(&mut transaction)
.await?;
};
}

transaction.commit().await?;
Expand Down

0 comments on commit 2ed60c1

Please sign in to comment.