diff --git a/Cargo.toml b/Cargo.toml index 7b59cec4..71d238ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -114,10 +114,11 @@ jemallocator = {version = "0.5.4", optional = true} async-stripe = { version = "0.37.3", features = ["runtime-tokio-hyper-rustls"] } rusty-money = "0.4.1" +json-patch = "*" [dev-dependencies] actix-http = "3.4.0" -json-patch = "*" + [profile.dev] opt-level = 0 # Minimal optimization, speeds up compilation lto = false # Disables Link Time Optimization diff --git a/migrations/20240702213250_subscriptions.sql b/migrations/20240702213250_subscriptions.sql index 34bc0be2..edad3ef8 100644 --- a/migrations/20240702213250_subscriptions.sql +++ b/migrations/20240702213250_subscriptions.sql @@ -9,16 +9,15 @@ CREATE TABLE products ( CREATE TABLE products_prices ( id bigint PRIMARY KEY, product_id bigint REFERENCES products NOT NULL, - interval jsonb NOT NULL, - -- price in smallest currency unit (cents for USD) - price int not null, - currency_code text not null + currency_code text not null, + prices jsonb NOT NULL ); CREATE TABLE users_subscriptions ( id bigint PRIMARY KEY, user_id bigint REFERENCES users NOT NULL, price_id bigint REFERENCES products_prices NOT NULL, + interval text NOT NULL, created timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, expires timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, last_charge timestamptz NULL, diff --git a/src/database/models/product_item.rs b/src/database/models/product_item.rs index 3ffc9f15..28bc74c7 100644 --- a/src/database/models/product_item.rs +++ b/src/database/models/product_item.rs @@ -1,6 +1,6 @@ use crate::database::models::{product_item, DatabaseError, ProductId, ProductPriceId}; use crate::database::redis::RedisPool; -use crate::models::billing::{PriceInterval, ProductMetadata}; +use crate::models::billing::{Price, ProductMetadata}; use dashmap::DashMap; use itertools::Itertools; use serde::{Deserialize, Serialize}; @@ -129,8 +129,7 @@ impl QueryProduct { .map(|x| ProductPriceItem { id: x.id, product_id: x.product_id, - interval: x.interval, - price: x.price, + prices: x.prices, currency_code: x.currency_code, }) .collect(), @@ -150,16 +149,14 @@ impl QueryProduct { pub struct ProductPriceItem { pub id: ProductPriceId, pub product_id: ProductId, - pub interval: PriceInterval, - pub price: i32, + pub prices: Price, pub currency_code: String, } struct ProductPriceResult { id: i64, product_id: i64, - interval: serde_json::Value, - price: i32, + prices: serde_json::Value, currency_code: String, } @@ -168,7 +165,7 @@ macro_rules! select_prices_with_predicate { sqlx::query_as!( ProductPriceResult, r#" - SELECT id, product_id, interval, price, currency_code + SELECT id, product_id, prices, currency_code FROM products_prices "# + $predicate, @@ -184,8 +181,7 @@ impl TryFrom for ProductPriceItem { Ok(ProductPriceItem { id: ProductPriceId(r.id), product_id: ProductId(r.product_id), - interval: serde_json::from_value(r.interval)?, - price: r.price, + prices: serde_json::from_value(r.prices)?, currency_code: r.currency_code, }) } diff --git a/src/database/models/user_subscription_item.rs b/src/database/models/user_subscription_item.rs index f5bf6090..0b7da1ed 100644 --- a/src/database/models/user_subscription_item.rs +++ b/src/database/models/user_subscription_item.rs @@ -1,5 +1,5 @@ use crate::database::models::{DatabaseError, ProductPriceId, UserId, UserSubscriptionId}; -use crate::models::billing::SubscriptionStatus; +use crate::models::billing::{PriceDuration, SubscriptionStatus}; use chrono::{DateTime, Utc}; use itertools::Itertools; @@ -7,6 +7,7 @@ pub struct UserSubscriptionItem { pub id: UserSubscriptionId, pub user_id: UserId, pub price_id: ProductPriceId, + pub interval: PriceDuration, pub created: DateTime, pub expires: DateTime, pub last_charge: Option>, @@ -17,6 +18,7 @@ struct UserSubscriptionResult { id: i64, user_id: i64, price_id: i64, + interval: String, pub created: DateTime, pub expires: DateTime, pub last_charge: Option>, @@ -29,7 +31,7 @@ macro_rules! select_user_subscriptions_with_predicate { UserSubscriptionResult, r#" SELECT - id, user_id, price_id, created, expires, last_charge, status + id, user_id, price_id, interval, created, expires, last_charge, status FROM users_subscriptions "# + $predicate, @@ -44,6 +46,7 @@ impl From for 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, @@ -104,20 +107,22 @@ impl UserSubscriptionItem { sqlx::query!( " INSERT INTO users_subscriptions ( - id, user_id, price_id, created, expires, last_charge, status + id, user_id, price_id, interval, created, expires, last_charge, status ) VALUES ( - $1, $2, $3, $4, $5, $6, $7 + $1, $2, $3, $4, $5, $6, $7, $8 ) ON CONFLICT (id) DO UPDATE - SET expires = EXCLUDED.expires, + SET interval = EXCLUDED.interval, + expires = EXCLUDED.expires, last_charge = EXCLUDED.last_charge, status = EXCLUDED.status ", self.id.0, self.user_id.0, self.price_id.0, + self.interval.as_str(), self.created, self.expires, self.last_charge, diff --git a/src/lib.rs b/src/lib.rs index 6de882ad..d3d508f9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -266,9 +266,9 @@ pub fn app_setup( 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; - }); + // actix_rt::spawn(async move { + // routes::internal::billing::task(stripe_client_ref, pool_ref, redis_ref).await; + // }); } let ip_salt = Pepper { diff --git a/src/models/v3/billing.rs b/src/models/v3/billing.rs index c5d782a8..c90c6a7f 100644 --- a/src/models/v3/billing.rs +++ b/src/models/v3/billing.rs @@ -2,6 +2,7 @@ use crate::models::ids::Base62Id; use crate::models::ids::UserId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; #[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] #[serde(from = "Base62Id")] @@ -31,30 +32,43 @@ pub struct ProductPriceId(pub u64); pub struct ProductPrice { pub id: ProductPriceId, pub product_id: ProductId, - pub interval: PriceInterval, - pub price: i32, + pub prices: Price, pub currency_code: String, } #[derive(Serialize, Deserialize)] #[serde(tag = "type", rename_all = "kebab-case")] -pub enum PriceInterval { - OneTime, - /// For recurring payments. amount: 1 and duration: 'week' would result in a recurring payment - /// every week +pub enum Price { + OneTime { + price: i32, + }, Recurring { - amount: usize, - duration: PriceDuration, + intervals: HashMap, }, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Hash, Eq, PartialEq, Debug, Copy, Clone)] #[serde(rename_all = "kebab-case")] pub enum PriceDuration { - Day, - Week, - Month, - Year, + Monthly, + Yearly, +} + +impl PriceDuration { + pub fn from_string(string: &str) -> PriceDuration { + match string { + "monthly" => PriceDuration::Monthly, + "yearly" => PriceDuration::Yearly, + _ => PriceDuration::Monthly, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + PriceDuration::Monthly => "monthly", + PriceDuration::Yearly => "yearly", + } + } } #[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] @@ -67,6 +81,7 @@ pub struct UserSubscription { pub id: UserSubscriptionId, pub user_id: UserId, pub price_id: ProductPriceId, + pub interval: PriceDuration, pub status: SubscriptionStatus, pub created: DateTime, pub expires: DateTime, diff --git a/src/routes/internal/billing.rs b/src/routes/internal/billing.rs index c98589ce..e5891ddd 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::{ - PriceDuration, PriceInterval, Product, ProductMetadata, ProductPrice, SubscriptionStatus, + Price, PriceDuration, Product, ProductMetadata, ProductPrice, SubscriptionStatus, UserSubscription, }; use crate::models::ids::base62_impl::{parse_base62, to_base62}; @@ -25,8 +25,8 @@ use stripe::{ CreateSetupIntentAutomaticPaymentMethods, CreateSetupIntentAutomaticPaymentMethodsAllowRedirects, Currency, CustomerId, CustomerInvoiceSettings, CustomerPaymentMethodRetrieval, EventObject, EventType, ListCharges, - PaymentIntentOffSession, PaymentIntentStatus, PaymentMethodId, SetupIntent, UpdateCustomer, - Webhook, + PaymentIntentOffSession, PaymentIntentSetupFutureUsage, PaymentIntentStatus, PaymentMethodId, + SetupIntent, UpdateCustomer, Webhook, }; pub fn config(cfg: &mut web::ServiceConfig) { @@ -64,9 +64,8 @@ pub async fn products( .map(|x| ProductPrice { id: x.id.into(), product_id: x.product_id.into(), - interval: x.interval, - price: x.price, currency_code: x.currency_code, + prices: x.prices, }) .collect(), unitary: x.unitary, @@ -101,6 +100,7 @@ pub async fn subscriptions( id: x.id.into(), user_id: x.user_id.into(), price_id: x.price_id.into(), + interval: x.interval, status: x.status, created: x.created, expires: x.expires, @@ -457,13 +457,22 @@ pub async fn payment_methods( } } +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PaymentRequestType { + PaymentMethod { id: String }, + ConfirmationToken { token: String }, +} + #[derive(Deserialize)] pub struct PaymentRequest { - pub price_id: crate::models::ids::ProductPriceId, - pub payment_method_id: Option, + pub product_id: crate::models::ids::ProductId, + pub interval: Option, + #[serde(flatten)] + pub type_: PaymentRequestType, + pub existing_payment_intent: Option, } -// TODO: Change to use confirmation tokens once async_stripe supports api #[post("payment")] pub async fn initiate_payment( req: HttpRequest, @@ -483,19 +492,149 @@ pub async fn initiate_payment( .await? .1; - let price = product_item::ProductPriceItem::get(payment_request.price_id.into(), &**pool) - .await? - .ok_or_else(|| { - ApiError::InvalidInput("Specified product price could not be found!".to_string()) - })?; - - let product = product_item::ProductItem::get(price.product_id, &**pool) + 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 customer_id = get_or_create_customer( + let (user_country, payment_method) = match &payment_request.type_ { + PaymentRequestType::PaymentMethod { id } => { + let payment_method_id = stripe::PaymentMethodId::from_str(&id) + .map_err(|_| ApiError::InvalidInput("Invalid payment method id".to_string()))?; + + let payment_method = + stripe::PaymentMethod::retrieve(&stripe_client, &payment_method_id, &[]).await?; + + let country = payment_method + .billing_details + .address + .as_ref() + .and_then(|x| x.country.clone()); + + (country, payment_method) + } + PaymentRequestType::ConfirmationToken { token } => { + #[derive(Deserialize)] + struct ConfirmationToken { + payment_method_preview: Option, + } + + let mut confirmation: serde_json::Value = stripe_client + .get(&format!("confirmation_tokens/{token}")) + .await?; + + // We patch the JSONs to support the PaymentMethod struct + let p: json_patch::Patch = serde_json::from_value(serde_json::json!([ + { "op": "add", "path": "/payment_method_preview/id", "value": "pm_1PirTdJygY5LJFfKmPIaM1N1" }, + { "op": "add", "path": "/payment_method_preview/created", "value": 1723183475 }, + { "op": "add", "path": "/payment_method_preview/livemode", "value": false } + ])).unwrap(); + json_patch::patch(&mut confirmation, &*p).unwrap(); + + let confirmation: ConfirmationToken = serde_json::from_value(confirmation)?; + + let payment_method = confirmation.payment_method_preview.ok_or_else(|| { + ApiError::InvalidInput("Confirmation token is missing payment method!".to_string()) + })?; + + let country = payment_method + .billing_details + .address + .as_ref() + .and_then(|x| x.country.clone()); + + (country, payment_method) + } + }; + + 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", + _ => "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 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(), + ) + })?; + + *intervals.get(&interval).ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for the user's country".to_string(), + ) + })? + } + }; + + let customer = get_or_create_customer( user.id, user.stripe_customer_id.as_deref(), user.email.as_deref(), @@ -504,87 +643,125 @@ pub async fn initiate_payment( &redis, ) .await?; + let stripe_currency = Currency::from_str(&price_item.currency_code.to_lowercase()) + .map_err(|_| ApiError::InvalidInput("Invalid currency code".to_string()))?; - let mut intent = CreatePaymentIntent::new( - price.price as i64, - Currency::from_str(&price.currency_code).unwrap_or(Currency::USD), - ); + if let Some(payment_intent_id) = &payment_request.existing_payment_intent { + let mut update_payment_intent = stripe::UpdatePaymentIntent { + amount: Some(price as i64), + 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(user.id.0)); + if let PaymentRequestType::PaymentMethod { .. } = payment_request.type_ { + update_payment_intent.payment_method = Some(payment_method.id.clone()); + } - if product.unitary { - let user_subscriptions = - user_subscription_item::UserSubscriptionItem::get_all_user(user.id.into(), &**pool) - .await?; + stripe::PaymentIntent::update(&stripe_client, payment_intent_id, update_payment_intent) + .await?; - let user_products = product_item::ProductPriceItem::get_many( - &user_subscriptions - .iter() - .map(|x| x.price_id) - .collect::>(), - &**pool, - ) - .await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ + "price_id": to_base62(price_item.id.0 as u64), + "tax": 0, + "total": price, + "payment_method": payment_method, + }))) + } else { + let mut intent = CreatePaymentIntent::new(price as i64, stripe_currency); - 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 + 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) { - metadata.insert("modrinth_subscription_id".to_string(), to_base62(user.id.0)); - } else { - return Err(ApiError::InvalidInput( - "You are already subscribed to this product!".to_string(), - )); + 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 let PriceInterval::Recurring { .. } = price.interval { - if !metadata.contains_key("modrinth_subscription_id") { - let mut transaction = pool.begin().await?; - let user_subscription_id = generate_user_subscription_id(&mut transaction).await?; - } - } - intent.metadata = Some(metadata); + if !metadata.contains_key("modrinth_subscription_id") { + let user_subscription_id = generate_user_subscription_id(&mut transaction).await?; - intent.automatic_payment_methods = Some(CreatePaymentIntentAutomaticPaymentMethods { - allow_redirects: Some(CreatePaymentIntentAutomaticPaymentMethodsAllowRedirects::Never), - enabled: true, - }); + metadata.insert( + "modrinth_subscription_id".to_string(), + to_base62(user_subscription_id.0 as u64), + ); + } - if let Some(payment_method) = payment_request - .payment_method_id - .clone() - .and_then(|x| stripe::PaymentMethodId::from_str(&x).ok()) - { - intent.payment_method = Some(payment_method); - intent.confirm = Some(false); - intent.off_session = Some(PaymentIntentOffSession::Exists(true)) - } + if let Some(interval) = payment_request.interval { + metadata.insert( + "modrinth_subscription_interval".to_string(), + interval.as_str().to_string(), + ); + } + } - intent.receipt_email = user.email.as_deref(); + intent.customer = Some(customer); + intent.metadata = Some(metadata); + intent.automatic_payment_methods = Some(CreatePaymentIntentAutomaticPaymentMethods { + allow_redirects: None, + enabled: true, + }); + intent.receipt_email = user.email.as_deref(); + intent.setup_future_usage = Some(PaymentIntentSetupFutureUsage::OffSession); + + 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?; + let payment_intent = stripe::PaymentIntent::create(&stripe_client, intent).await?; + transaction.commit().await?; - if payment_intent.status == PaymentIntentStatus::Succeeded { - return Ok(HttpResponse::NoContent().finish()); + 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), + "tax": 0, + "total": price, + "payment_method": payment_method, + }))) } - - Ok(HttpResponse::Ok().json(serde_json::json!({ - "status": payment_intent.status, - "client_secret": payment_intent.client_secret - }))) } #[post("_stripe")] @@ -608,7 +785,10 @@ pub async fn stripe_webhook( ) { struct PaymentIntentMetadata { user: crate::database::models::User, - user_subscription_id: Option, + user_subscription_data: Option<( + crate::database::models::ids::UserSubscriptionId, + PriceDuration, + )>, user_subscription: Option, product: product_item::ProductItem, product_price: product_item::ProductPriceItem, @@ -628,19 +808,26 @@ pub async fn stripe_webhook( crate::database::models::user_item::User::get_id(user_id, pool, &redis).await?; if let Some(user) = user { - let (user_subscription_id, user_subscription) = if let Some(subscription_id) = + 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 subscription = user_subscription_item::UserSubscriptionItem::get( - subscription_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?; - (Some(subscription_id), subscription) + (Some((subscription_id, interval)), subscription) + } else { + (None, None) + } } else { (None, None) }; @@ -660,7 +847,7 @@ pub async fn stripe_webhook( if let Some(product) = product { return Ok(PaymentIntentMetadata { user, - user_subscription_id, + user_subscription_data, user_subscription, product, product_price, @@ -684,34 +871,30 @@ pub async fn stripe_webhook( let mut transaction = pool.begin().await?; - if let PriceInterval::Recurring { amount, duration } = - metadata.product_price.interval - { - if let Some(subscription_id) = metadata.user_subscription_id { - let duration = match duration { - PriceDuration::Day => Duration::days(amount as i64), - PriceDuration::Week => Duration::days((amount * 7) as i64), - PriceDuration::Month => Duration::days((amount * 30) as i64), - PriceDuration::Year => Duration::days((amount * 365) as i64), - }; - - if let Some(mut user_subscription) = metadata.user_subscription { - user_subscription.expires = Utc::now() + duration; - user_subscription.status = SubscriptionStatus::Active; - 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, - created: Utc::now(), - expires: Utc::now() + duration, - last_charge: None, - status: SubscriptionStatus::Active, - } - .upsert(&mut transaction) - .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 = Utc::now() + duration; + user_subscription.status = SubscriptionStatus::Active; + user_subscription.interval = interval; + 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?; } } @@ -750,24 +933,24 @@ pub async fn stripe_webhook( let mut transaction = pool.begin().await?; - if let PriceInterval::Recurring { .. } = metadata.product_price.interval { - if let Some(subscription_id) = metadata.user_subscription_id { - if let Some(mut user_subscription) = metadata.user_subscription { - user_subscription.status = SubscriptionStatus::PaymentProcessing; - 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, - created: Utc::now(), - expires: Utc::now(), - last_charge: None, - status: SubscriptionStatus::PaymentProcessing, - } - .upsert(&mut transaction) - .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.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?; } } @@ -781,76 +964,82 @@ pub async fn stripe_webhook( let mut transaction = pool.begin().await?; - if let PriceInterval::Recurring { .. } = metadata.product_price.interval { - if let Some(subscription_id) = metadata.user_subscription_id { - if let Some(mut user_subscription) = metadata.user_subscription { - user_subscription.last_charge = Some(Utc::now()); - user_subscription.status = SubscriptionStatus::PaymentFailed; - 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, - created: Utc::now(), - expires: Utc::now(), - last_charge: Some(Utc::now()), - status: SubscriptionStatus::PaymentFailed, + 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.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: Some(Utc::now()), + status: SubscriptionStatus::PaymentFailed, + } + .upsert(&mut transaction) + .await?; } - .upsert(&mut transaction) - .await?; + + intervals.get(&interval).copied() + } else { + None } } - } + }; - if let Some(email) = metadata.user.email { - let money = rusty_money::Money::from_minor( - metadata.product_price.price as i64, - rusty_money::iso::find(&metadata.product_price.currency_code) - .unwrap_or(rusty_money::iso::USD), - ); - - send_email( - email, - "[Action Required] 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(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), + ); + + send_email( + email, + "[Action Required] 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?; } } - EventType::SetupIntentSucceeded => { - if let EventObject::SetupIntent(setup_intent) = event.data.object { - if let Some(customer_id) = setup_intent.customer.map(|x| x.id()) { - if let Some(payment_method_id) = setup_intent.payment_method.map(|x| x.id()) + EventType::PaymentMethodAttached => { + if let EventObject::PaymentMethod(payment_method) = event.data.object { + if let Some(customer_id) = payment_method.customer.map(|x| x.id()) { + let customer = + stripe::Customer::retrieve(&stripe_client, &customer_id, &[]).await?; + + if !customer + .invoice_settings + .map(|x| x.default_payment_method.is_some()) + .unwrap_or(false) { - let customer = - stripe::Customer::retrieve(&stripe_client, &customer_id, &[]) - .await?; - - if !customer - .invoice_settings - .map(|x| x.default_payment_method.is_some()) - .unwrap_or(false) - { - stripe::Customer::update( - &stripe_client, - &customer_id, - UpdateCustomer { - invoice_settings: Some(CustomerInvoiceSettings { - default_payment_method: Some( - payment_method_id.to_string(), - ), - ..Default::default() - }), + stripe::Customer::update( + &stripe_client, + &customer_id, + UpdateCustomer { + invoice_settings: Some(CustomerInvoiceSettings { + default_payment_method: Some(payment_method.id.to_string()), ..Default::default() - }, - ) - .await?; - } + }), + ..Default::default() + }, + ) + .await?; } } } @@ -910,178 +1099,178 @@ async fn get_or_create_customer( } } -pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) { - // if subscription is cancelled and expired, unprovision - // 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 subscription_prices = product_item::ProductPriceItem::get_many( - &expired - .iter() - .map(|x| x.price_id) - .collect::>() - .into_iter() - .collect::>(), - &pool, - ) - .await?; - let subscription_products = product_item::ProductItem::get_many( - &subscription_prices - .iter() - .map(|x| x.product_id) - .collect::>() - .into_iter() - .collect::>(), - &pool, - ) - .await?; - let users = crate::database::models::User::get_many_ids( - &expired - .iter() - .map(|x| x.user_id) - .collect::>() - .into_iter() - .collect::>(), - &pool, - &redis, - ) - .await?; - - 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); - - if let Some(user) = user { - let product_price = subscription_prices - .iter() - .find(|x| x.id == subscription.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 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?; - } - } - - clear_cache_users.push(user.id); - } - - if payment_failed || active { - 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 mut intent = CreatePaymentIntent::new( - product_price.price as i64, - Currency::from_str(&product_price.currency_code) - .unwrap_or(Currency::USD), - ); - - let mut metadata = HashMap::new(); - metadata.insert( - "modrinth_user_id".to_string(), - 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), - ); - - 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(false); - intent.off_session = - Some(PaymentIntentOffSession::Exists(true)); - - stripe::PaymentIntent::create(&stripe_client, intent).await?; - - subscription.status = SubscriptionStatus::PaymentProcessing; - } else { - subscription.status = SubscriptionStatus::PaymentFailed; - } - - subscription.upsert(&mut transaction).await?; - } - } - } - } - } - - crate::database::models::User::clear_caches( - &clear_cache_users - .into_iter() - .map(|x| (x, None)) - .collect::>(), - &redis, - ) - .await?; - transaction.commit().await?; - - Ok::<(), ApiError>(()) - } - .await; - - if let Err(e) = res { - warn!("Error indexing billing queue: {:?}", e); - } - - info!("Done indexing billing queue"); - - tokio::time::sleep(std::time::Duration::from_secs(60 * 5)).await; - } -} +// pub async fn task(stripe_client: stripe::Client, pool: PgPool, redis: RedisPool) { +// // if subscription is cancelled and expired, unprovision +// // 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 subscription_prices = product_item::ProductPriceItem::get_many( +// &expired +// .iter() +// .map(|x| x.price_id) +// .collect::>() +// .into_iter() +// .collect::>(), +// &pool, +// ) +// .await?; +// let subscription_products = product_item::ProductItem::get_many( +// &subscription_prices +// .iter() +// .map(|x| x.product_id) +// .collect::>() +// .into_iter() +// .collect::>(), +// &pool, +// ) +// .await?; +// let users = crate::database::models::User::get_many_ids( +// &expired +// .iter() +// .map(|x| x.user_id) +// .collect::>() +// .into_iter() +// .collect::>(), +// &pool, +// &redis, +// ) +// .await?; +// +// 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); +// +// if let Some(user) = user { +// let product_price = subscription_prices +// .iter() +// .find(|x| x.id == subscription.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 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?; +// } +// } +// +// clear_cache_users.push(user.id); +// } +// +// if payment_failed || active { +// 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 mut intent = CreatePaymentIntent::new( +// product_price.price as i64, +// Currency::from_str(&product_price.currency_code) +// .unwrap_or(Currency::USD), +// ); +// +// let mut metadata = HashMap::new(); +// metadata.insert( +// "modrinth_user_id".to_string(), +// 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), +// ); +// +// 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(false); +// intent.off_session = +// Some(PaymentIntentOffSession::Exists(true)); +// +// stripe::PaymentIntent::create(&stripe_client, intent).await?; +// +// subscription.status = SubscriptionStatus::PaymentProcessing; +// } else { +// subscription.status = SubscriptionStatus::PaymentFailed; +// } +// +// subscription.upsert(&mut transaction).await?; +// } +// } +// } +// } +// } +// +// crate::database::models::User::clear_caches( +// &clear_cache_users +// .into_iter() +// .map(|x| (x, None)) +// .collect::>(), +// &redis, +// ) +// .await?; +// transaction.commit().await?; +// +// Ok::<(), ApiError>(()) +// } +// .await; +// +// if let Err(e) = res { +// warn!("Error indexing billing queue: {:?}", e); +// } +// +// info!("Done indexing billing queue"); +// +// tokio::time::sleep(std::time::Duration::from_secs(60 * 5)).await; +// } +// }