diff --git a/Cargo.lock b/Cargo.lock index a65cc6b072..b44929b14e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -869,6 +869,7 @@ dependencies = [ "dotenv", "http_server_common", "log", + "mockall", "once_cell", "regex", "reusable_types", @@ -876,6 +877,7 @@ dependencies = [ "serde_derive", "serde_json", "sqlx", + "tokio 1.13.0", "toml", ] @@ -1467,6 +1469,12 @@ dependencies = [ "syn", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.9.0" @@ -1536,6 +1544,12 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "dtoa" version = "0.4.8" @@ -1629,6 +1643,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1660,6 +1683,12 @@ dependencies = [ "percent-encoding 2.1.0", ] +[[package]] +name = "fragile" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" + [[package]] name = "fuchsia-cprng" version = "0.1.1" @@ -2362,6 +2391,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47be2f14c678be2fdcab04ab1171db51b2762ce6f0a8ee87c8dd4a04ed216135" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "0.4.7" @@ -2776,6 +2814,33 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "mockall" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e4a1c770583dac7ab5e2f6c139153b783a53a1bbee9729613f193e59828326" +dependencies = [ + "cfg-if 1.0.0", + "downcast", + "fragile", + "lazy_static", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832663583d5fa284ca8810bf7015e46c9fff9622d3cf34bd1eea5003fec06dd0" +dependencies = [ + "cfg-if 1.0.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "mysqlclient-sys" version = "0.2.4" @@ -2846,6 +2911,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "ntapi" version = "0.3.6" @@ -3216,6 +3287,36 @@ version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" +[[package]] +name = "predicates" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5aab5be6e4732b473071984b3164dbbfb7a3674d30ea5ff44410b6bcd960c3c" +dependencies = [ + "difflib", + "float-cmp", + "itertools", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da1c2388b1513e1b605fcec39a95e0a9e8ef088f71443ef37099fa9ae6673fcb" + +[[package]] +name = "predicates-tree" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d86de6de25020a36c6d3643a86d9a6a9f552107c0559c60ea03551b5e16c032" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro-hack" version = "0.5.19" @@ -4428,6 +4529,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termtree" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507e9898683b6c43a9aa55b64259b721b52ba226e0f3779137e50ad114a4c90b" + [[package]] name = "textwrap" version = "0.11.0" diff --git a/crates/component_subsystem/billing/Cargo.toml b/crates/component_subsystem/billing/Cargo.toml index 6870aae8c4..580767805d 100644 --- a/crates/component_subsystem/billing/Cargo.toml +++ b/crates/component_subsystem/billing/Cargo.toml @@ -54,3 +54,9 @@ async-trait = "0.1.58" # "rustls" doesn't appear to work with IP addresses, per: # https://github.com/launchbadge/sqlx/issues/1095#issuecomment-798050828 sqlx = { version = "0.5", features = [ "mysql", "runtime-actix-native-tls", "offline", "chrono" ] } + +[dev-dependencies] +mockall = "0.11.3" + +# NB: Tokio version matches `async-stripe` transitive dep; features are for testing +tokio = { version = "1.13.0", features = ["rt", "macros"] } diff --git a/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/mod.rs b/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/mod.rs index 3a65acaa34..e714cc72d1 100644 --- a/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/mod.rs +++ b/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/mod.rs @@ -1,4 +1,5 @@ pub mod stripe_checkout_success_handler; +pub mod stripe_create_checkout_session_error; pub mod stripe_create_checkout_session_json_handler; pub mod stripe_create_checkout_session_redirect_handler; pub mod stripe_create_checkout_session_shared; diff --git a/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/stripe_create_checkout_session_error.rs b/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/stripe_create_checkout_session_error.rs new file mode 100644 index 0000000000..f26c840348 --- /dev/null +++ b/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/stripe_create_checkout_session_error.rs @@ -0,0 +1,36 @@ +use actix_http::StatusCode; +use actix_web::{HttpResponse, ResponseError}; +use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use std::fmt; + +#[derive(Debug, Serialize, Eq, PartialEq)] +pub enum CreateCheckoutSessionError { + BadRequest, + PlanNotFound, + InvalidSession, + ServerError, + StripeError, +} + +impl ResponseError for CreateCheckoutSessionError { + fn status_code(&self) -> StatusCode { + match *self { + CreateCheckoutSessionError::BadRequest => StatusCode::BAD_REQUEST, + CreateCheckoutSessionError::PlanNotFound => StatusCode::NOT_FOUND, + CreateCheckoutSessionError::InvalidSession => StatusCode::UNAUTHORIZED, + CreateCheckoutSessionError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + CreateCheckoutSessionError::StripeError => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + serialize_as_json_error(self) + } +} + +// NB: Not using derive_more::Display since Clion doesn't understand it. +impl fmt::Display for CreateCheckoutSessionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} diff --git a/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/stripe_create_checkout_session_json_handler.rs b/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/stripe_create_checkout_session_json_handler.rs index 5958224c6b..dd4fcffe39 100644 --- a/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/stripe_create_checkout_session_json_handler.rs +++ b/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/stripe_create_checkout_session_json_handler.rs @@ -4,8 +4,11 @@ use actix_web::http::{header, StatusCode}; use actix_web::web::{Path, Query}; use actix_web::{web, HttpResponse, HttpRequest}; use chrono::{DateTime, Utc}; +use crate::stripe::http_endpoints::checkout::stripe_create_checkout_session_error::CreateCheckoutSessionError; use crate::stripe::http_endpoints::checkout::stripe_create_checkout_session_shared::stripe_create_checkout_session_shared; use crate::stripe::stripe_config::StripeConfig; +use crate::stripe::traits::internal_product_to_stripe_lookup::InternalProductToStripeLookup; +use crate::stripe::traits::internal_user_lookup::{InternalUserLookup, UserMetadata}; use http_server_common::request::get_request_header_optional::get_request_header_optional; use http_server_common::response::serialize_as_json_error::serialize_as_json_error; use log::{error, warn}; @@ -13,13 +16,14 @@ use sqlx::MySqlPool; use std::collections::HashMap; use std::fmt; use stripe::{CheckoutSession, CheckoutSessionMode, CreateCheckoutSession, CreateCheckoutSessionLineItems}; -use crate::stripe::traits::internal_user_lookup::UserMetadata; // =============== Request =============== #[derive(Deserialize)] pub struct CreateCheckoutSessionRequest { - price_key: Option, + /// The (non-Stripe) internal identifier for the product or subscription. + /// This will be translated into a Stripe identifier. + internal_plan_key: Option, } // =============== Success Response =============== @@ -30,52 +34,25 @@ pub struct CreateCheckoutSessionSuccessResponse { pub stripe_checkout_redirect_url: String, } -// =============== Error Response =============== - -#[derive(Debug, Serialize)] -pub enum CreateCheckoutSessionError { - ServerError, -} - -impl ResponseError for CreateCheckoutSessionError { - fn status_code(&self) -> StatusCode { - match *self { - CreateCheckoutSessionError::ServerError=> StatusCode::INTERNAL_SERVER_ERROR, - } - } - - fn error_response(&self) -> HttpResponse { - serialize_as_json_error(self) - } -} - -// NB: Not using derive_more::Display since Clion doesn't understand it. -impl fmt::Display for CreateCheckoutSessionError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{:?}", self) - } -} - pub async fn stripe_create_checkout_session_json_handler( - _http_request: HttpRequest, - _mysql_pool: web::Data, + http_request: HttpRequest, request: Query, stripe_config: web::Data, + stripe_client: web::Data, + internal_product_to_stripe_lookup: web::Data, + internal_user_lookup: web::Data, ) -> Result { - let price_key = request.price_key.as_deref().unwrap_or("unknown"); - - let user_metadata = UserMetadata::default(); + let maybe_internal_product_key = request.internal_plan_key.as_deref(); let url = stripe_create_checkout_session_shared( + maybe_internal_product_key, + &http_request, &stripe_config, - price_key, - Some(&user_metadata)) - .await - .map_err(|err| { - error!("Error creating Stripe checkout session: {:?}", err); - CreateCheckoutSessionError::ServerError - })?; + &stripe_client, + internal_product_to_stripe_lookup.get_ref(), + internal_user_lookup.get_ref(), + ).await?; let response = CreateCheckoutSessionSuccessResponse { success: true, diff --git a/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/stripe_create_checkout_session_redirect_handler.rs b/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/stripe_create_checkout_session_redirect_handler.rs index 0d5580639e..77fac39a3a 100644 --- a/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/stripe_create_checkout_session_redirect_handler.rs +++ b/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/stripe_create_checkout_session_redirect_handler.rs @@ -4,88 +4,44 @@ use actix_web::http::{header, StatusCode}; use actix_web::web::{Path, Query}; use actix_web::{web, HttpResponse, HttpRequest}; use chrono::{DateTime, Utc}; +use crate::stripe::http_endpoints::checkout::stripe_create_checkout_session_error::CreateCheckoutSessionError; use crate::stripe::http_endpoints::checkout::stripe_create_checkout_session_shared::stripe_create_checkout_session_shared; use crate::stripe::stripe_config::StripeConfig; +use crate::stripe::traits::internal_product_to_stripe_lookup::InternalProductToStripeLookup; +use crate::stripe::traits::internal_user_lookup::InternalUserLookup; use http_server_common::response::serialize_as_json_error::serialize_as_json_error; use log::{error, warn}; use sqlx::MySqlPool; use std::collections::HashMap; use std::fmt; use stripe::{CheckoutSession, CheckoutSessionMode, CreateCheckoutSession, CreateCheckoutSessionLineItems}; -use crate::stripe::traits::internal_user_lookup::InternalUserLookup; // =============== Request =============== #[derive(Deserialize)] pub struct CreateCheckoutSessionRequest { - price_key: Option, -} - -// =============== Error Response =============== - -#[derive(Debug, Serialize)] -pub enum CreateCheckoutSessionError { - NotAuthorizedError, - ServerError, -} - -impl ResponseError for CreateCheckoutSessionError { - fn status_code(&self) -> StatusCode { - match *self { - CreateCheckoutSessionError::NotAuthorizedError => StatusCode::UNAUTHORIZED, - CreateCheckoutSessionError::ServerError => StatusCode::INTERNAL_SERVER_ERROR, - } - } - - fn error_response(&self) -> HttpResponse { - serialize_as_json_error(self) - } -} - -// NB: Not using derive_more::Display since Clion doesn't understand it. -impl fmt::Display for CreateCheckoutSessionError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{:?}", self) - } + product: Option, } pub async fn stripe_create_checkout_session_redirect_handler( http_request: HttpRequest, request: Query, stripe_config: web::Data, - mysql_pool: web::Data, + stripe_client: web::Data, + internal_product_to_stripe_lookup: web::Data, internal_user_lookup: web::Data, ) -> Result { - let price_key = request.price_key.as_deref().unwrap_or("unknown"); - - let maybe_user_metadata = internal_user_lookup - .lookup_user_from_http_request(&http_request, &mysql_pool) - .await - .map_err(|err| { - error!("Error looking up user: {:?}", err); - CreateCheckoutSessionError::ServerError // NB: This was probably *our* fault. - })?; - - // NB: Our integration relies on an internal user token being present. - let user_metadata = match maybe_user_metadata { - None => return Err(CreateCheckoutSessionError::NotAuthorizedError), - Some(user_metadata) => user_metadata, - }; - - if user_metadata.user_token.is_none() { - return Err(CreateCheckoutSessionError::NotAuthorizedError); - } + let maybe_internal_product_key = request.product.as_deref(); let url = stripe_create_checkout_session_shared( + maybe_internal_product_key, + &http_request, &stripe_config, - price_key, - Some(&user_metadata)) - .await - .map_err(|err| { - error!("Error creating Stripe checkout session: {:?}", err); - CreateCheckoutSessionError::ServerError - })?; + &stripe_client, + internal_product_to_stripe_lookup.get_ref(), + internal_user_lookup.get_ref(), + ).await?; Ok(HttpResponse::Found() .append_header((header::LOCATION, url)) diff --git a/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/stripe_create_checkout_session_shared.rs b/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/stripe_create_checkout_session_shared.rs index 9eea54dd92..fa262b3663 100644 --- a/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/stripe_create_checkout_session_shared.rs +++ b/crates/component_subsystem/billing/src/stripe/http_endpoints/checkout/stripe_create_checkout_session_shared.rs @@ -1,49 +1,65 @@ +use actix_web::{HttpRequest, web}; use anyhow::anyhow; use container_common::anyhow_result::AnyhowResult; use crate::stripe::helpers::common_metadata_keys::{METADATA_EMAIL, METADATA_USER_TOKEN, METADATA_USERNAME}; +use crate::stripe::http_endpoints::checkout::stripe_create_checkout_session_error::CreateCheckoutSessionError; use crate::stripe::stripe_config::StripeConfig; -use crate::stripe::traits::internal_user_lookup::UserMetadata; +use crate::stripe::traits::internal_product_to_stripe_lookup::{InternalProductToStripeLookup, StripeProduct}; +use crate::stripe::traits::internal_user_lookup::{InternalUserLookup, UserMetadata}; use log::error; +use sqlx::MySqlPool; use std::collections::HashMap; use stripe::{CheckoutSession, CheckoutSessionMode, CreateCheckoutSession, CreateCheckoutSessionAutomaticTax, CreateCheckoutSessionLineItems, CreateCheckoutSessionPaymentIntentData, CreateCheckoutSessionSubscriptionData}; -// NB: These are "test" product IDs. -// TODO: Pass these in via request; validate via a dynamically dispatched trait callable that can do -// whatever the calling codebase needs -pub const PRODUCT_FAKEYOU_BASIC_ID : &'static str = "prod_MMxi2J5y69VPbO"; -pub const PRODUCT_FAKEYOU_BASIC_PRICE_ID : &'static str = "price_1LeDnKEU5se17MekVr1iYYNf"; - -pub const PRODUCT_ONE_TIME_PURCHASE_ID : &'static str = "prod_MPQ6nWJ4k6lJmw"; -pub const PRODUCT_ONE_TIME_PURCHASE_PRICE_ID : &'static str = "price_1LgbG9EU5se17MekZIw95gEO"; - /// Create a checkout session and return the URL /// If anything fails, treat it as a 500 server error. pub async fn stripe_create_checkout_session_shared( + maybe_internal_product_key: Option<&str>, + http_request: &HttpRequest, stripe_config: &StripeConfig, - price_key: &str, - maybe_user_metadata: Option<&UserMetadata>, -) -> AnyhowResult { + stripe_client: &stripe::Client, + internal_product_to_stripe_lookup: &dyn InternalProductToStripeLookup, + internal_user_lookup: &dyn InternalUserLookup, - let stripe_client = { - let api_secret = stripe_config.secrets.secret_key - .as_deref() - .ok_or(anyhow!("API key not configured"))?; - stripe::Client::new(api_secret) +) -> Result { + let internal_product_key = match maybe_internal_product_key { + None => return Err(CreateCheckoutSessionError::BadRequest), + Some(internal_product_key) => internal_product_key, }; + let stripe_product = internal_product_to_stripe_lookup + .lookup_stripe_product_from_internal_product_key(internal_product_key) + .map_err(|err| { + error!("Error looking up product: {:?}", err); + CreateCheckoutSessionError::ServerError // NB: This was probably *our* fault. + })? + .ok_or(CreateCheckoutSessionError::PlanNotFound)?; // Non-existing product + + let maybe_user_metadata = internal_user_lookup + .lookup_user_from_http_request(http_request) + .await + .map_err(|err| { + error!("Error looking up user: {:?}", err); + CreateCheckoutSessionError::ServerError // NB: This was probably *our* fault. + })?; + + // NB: Our integration relies on an internal user token being present. + let user_metadata = match maybe_user_metadata { + None => return Err(CreateCheckoutSessionError::InvalidSession), + Some(user_metadata) => user_metadata, + }; + + if user_metadata.user_token.is_none() { + return Err(CreateCheckoutSessionError::InvalidSession); + } + let success_url = stripe_config.checkout.success_url .as_deref() - .ok_or(anyhow!("Checkout Success URL not configured"))?; + .ok_or(CreateCheckoutSessionError::ServerError)?; let cancel_url = stripe_config.checkout.cancel_url .as_deref() - .ok_or(anyhow!("Checkout Cancel URL not configured"))?; - - let (price_id, is_subscription) = match price_key { - "subscription" => (PRODUCT_FAKEYOU_BASIC_PRICE_ID, true), - "one-time" => (PRODUCT_ONE_TIME_PURCHASE_PRICE_ID, false), - _ => return Err(anyhow!("wrong price key!")), - }; + .ok_or(CreateCheckoutSessionError::ServerError)?; let checkout_session = { let mut params = CreateCheckoutSession::new( @@ -79,16 +95,14 @@ pub async fn stripe_create_checkout_session_shared( let mut metadata = HashMap::new(); - if let Some(user_metadata) = maybe_user_metadata { - if let Some(user_token) = user_metadata.user_token.as_deref() { - metadata.insert(METADATA_USER_TOKEN.to_string(), user_token.to_string()); - } - if let Some(username) = user_metadata.username.as_deref() { - metadata.insert(METADATA_USERNAME.to_string(), username.to_string()); - } - if let Some(user_email) = user_metadata.user_email.as_deref() { - metadata.insert(METADATA_EMAIL.to_string(), user_email.to_string()); - } + if let Some(user_token) = user_metadata.user_token.as_deref() { + metadata.insert(METADATA_USER_TOKEN.to_string(), user_token.to_string()); + } + if let Some(username) = user_metadata.username.as_deref() { + metadata.insert(METADATA_USERNAME.to_string(), username.to_string()); + } + if let Some(user_email) = user_metadata.user_email.as_deref() { + metadata.insert(METADATA_EMAIL.to_string(), user_email.to_string()); } // NB: This metadata attaches to Stripe's Checkout Session object. @@ -96,7 +110,7 @@ pub async fn stripe_create_checkout_session_shared( // objects. (TODO: Confirm this.) params.metadata = Some(metadata.clone()); - if is_subscription { + if stripe_product.is_subscription_product { // Subscription mode: Use Stripe Billing to set up fixed-price subscriptions. params.mode = Some(CheckoutSessionMode::Subscription); @@ -125,7 +139,7 @@ pub async fn stripe_create_checkout_session_shared( params.line_items = Some(vec![ CreateCheckoutSessionLineItems { - price: Some(price_id.to_string()), + price: Some(stripe_product.stripe_price_id.to_string()), quantity: Some(1), ..Default::default() } @@ -135,9 +149,78 @@ pub async fn stripe_create_checkout_session_shared( .await .map_err(|e| { error!("Error: {:?}", e); - anyhow!("error creating checkout session against Stripe") + CreateCheckoutSessionError::StripeError })? }; - checkout_session.url.ok_or(anyhow!("checkout session does not contain a URL")) + checkout_session.url.ok_or(CreateCheckoutSessionError::ServerError) +} + +#[cfg(test)] +mod tests { + use actix_web::error::UrlencodedError::ContentType; + use crate::stripe::http_endpoints::checkout::stripe_create_checkout_session_shared::stripe_create_checkout_session_shared; + use crate::stripe::stripe_config::{StripeCheckout, StripeConfig, StripeSecrets}; + use crate::stripe::traits::internal_product_to_stripe_lookup::{MockInternalProductToStripeLookup, StripeProduct}; + use crate::stripe::traits::internal_user_lookup::{MockInternalUserLookup, UserMetadata}; + use mockall::predicate::*; + use tokio; + use crate::stripe::http_endpoints::checkout::stripe_create_checkout_session_error::CreateCheckoutSessionError; + + #[tokio::test] + async fn test_success_case() { + let http_request = actix_web::test::TestRequest::default() + .insert_header(actix_web::http::header::ContentType::json()) + .to_http_request(); + + let maybe_internal_product_key = Some("TEST_FAKEYOU_PRODUCT"); + + let stripe_config = StripeConfig { + checkout: StripeCheckout { + success_url: Some("http://example.com/success".to_string()), + cancel_url: Some("http://example.com/cancel".to_string()), + }, + secrets: StripeSecrets { + publishable_key: None, + secret_key: Some("sk_test_12345".to_string()), // NB: Expected key format + secret_webhook_signing_key: Some("fake_test_signing".to_string()), + } + }; + + // TODO: Mock this somehow? We can't really test this library unless we can get inside it. + // Note that this might also fail in CI if the client tries to actually talk to Stripe.com. + let mut stripe_client= stripe::Client::new("sk_test_12345"); + + let mut internal_product_to_stripe_lookup_mock = MockInternalProductToStripeLookup::new(); + + internal_product_to_stripe_lookup_mock.expect_lookup_stripe_product_from_internal_product_key() + .with(eq("TEST_FAKEYOU_PRODUCT")) + .returning(|_| Ok(Some(StripeProduct { + stripe_product_id: "TEST_PRODUCT_ID".to_string(), + stripe_price_id: "TEST_PRICE_ID".to_string(), + is_subscription_product: true, + }))); + + let mut internal_user_lookup_mock = MockInternalUserLookup::new(); + + internal_user_lookup_mock.expect_lookup_user_from_http_request() + .returning(|_| Ok(Some(UserMetadata { + user_token: Some("U:USER".to_string()), + username: Some("vegito".to_string()), + user_email: Some("vegito@fakeyou.com".to_string()), + }))); + + let result = stripe_create_checkout_session_shared( + maybe_internal_product_key, + &http_request, + &stripe_config, + &stripe_client, + &internal_product_to_stripe_lookup_mock, + &internal_user_lookup_mock, + ).await; + + // TODO: Sort of throwing my hands up over testing this. + // There's no convenient way to test which arguments get sent. + assert_eq!(result, Err(CreateCheckoutSessionError::StripeError)); + } } diff --git a/crates/component_subsystem/billing/src/stripe/traits/internal_product_to_stripe_lookup.rs b/crates/component_subsystem/billing/src/stripe/traits/internal_product_to_stripe_lookup.rs index 77a08e61a1..a332580d58 100644 --- a/crates/component_subsystem/billing/src/stripe/traits/internal_product_to_stripe_lookup.rs +++ b/crates/component_subsystem/billing/src/stripe/traits/internal_product_to_stripe_lookup.rs @@ -1,3 +1,6 @@ +#[cfg(test)] +use mockall::{automock, mock, predicate::*}; + use std::error::Error; use std::fmt::{Display, Formatter}; @@ -31,6 +34,7 @@ pub struct StripeProduct { } /// Allows external systems to map internal product keys to stripe information. +#[cfg_attr(test, automock)] pub trait InternalProductToStripeLookup { /// Look up stripe product information from an internal system identifier. fn lookup_stripe_product_from_internal_product_key(&self, internal_product_key: &str) diff --git a/crates/component_subsystem/billing/src/stripe/traits/internal_user_lookup.rs b/crates/component_subsystem/billing/src/stripe/traits/internal_user_lookup.rs index 61cf1744a6..a123082fc6 100644 --- a/crates/component_subsystem/billing/src/stripe/traits/internal_user_lookup.rs +++ b/crates/component_subsystem/billing/src/stripe/traits/internal_user_lookup.rs @@ -1,3 +1,6 @@ +#[cfg(test)] +use mockall::{automock, mock, predicate::*}; + use actix_web::HttpRequest; use async_trait::async_trait; use sqlx::MySqlPool; @@ -47,10 +50,11 @@ pub struct UserMetadata { /// Allows us to inject a user lookup from the HTTP request's session info and database backend, /// then translate these into the pieces we need for the billing component. +#[cfg_attr(test, automock)] #[async_trait(?Send)] // NB: Marking async_trait as not needing Sync/Send. Hopefully this doesn't blow up on us. pub trait InternalUserLookup { /// Lookup a user's session details from an HTTP request, then return the /// relevant pieces for the Stripe integration. - async fn lookup_user_from_http_request(&self, http_request: &HttpRequest, mysql_pool: &MySqlPool) -> Result, InternalUserLookupError>; + async fn lookup_user_from_http_request(&self, http_request: &HttpRequest) -> Result, InternalUserLookupError>; } diff --git a/crates/service/storyteller_web/src/billing/internal_product_to_stripe_lookup_impl.rs b/crates/service/storyteller_web/src/billing/internal_product_to_stripe_lookup_impl.rs new file mode 100644 index 0000000000..f6146ed62f --- /dev/null +++ b/crates/service/storyteller_web/src/billing/internal_product_to_stripe_lookup_impl.rs @@ -0,0 +1,29 @@ +use billing_component::stripe::traits::internal_product_to_stripe_lookup::{InternalProductToStripeLookup, StripeProduct, StripeProductLookupError}; +use crate::configs::plans::plan_list::ALL_PLANS_BY_SLUG; + +/// A simple Actix injectable action +#[derive(Clone, Copy)] +pub struct InternalProductToStripeLookupImpl {} + +impl InternalProductToStripeLookup for InternalProductToStripeLookupImpl { + fn lookup_stripe_product_from_internal_product_key(&self, internal_product_key: &str) -> Result, StripeProductLookupError> { + Ok(ALL_PLANS_BY_SLUG.get(internal_product_key) + .and_then(|plan| { + let stripe_product_id = match plan.stripe_product_id() { + None => return None, + Some(stripe_product_id) => stripe_product_id.to_string(), + }; + + let stripe_price_id = match plan.stripe_price_id() { + None => return None, + Some(stripe_price_id) => stripe_price_id.to_string(), + }; + + Some(StripeProduct { + stripe_product_id, + stripe_price_id, + is_subscription_product: true, // NB: For now all plans are subscription plans. + }) + })) + } +} diff --git a/crates/service/storyteller_web/src/billing/mod.rs b/crates/service/storyteller_web/src/billing/mod.rs index 8e594f66d2..c037329014 100644 --- a/crates/service/storyteller_web/src/billing/mod.rs +++ b/crates/service/storyteller_web/src/billing/mod.rs @@ -1,2 +1,3 @@ +pub mod internal_product_to_stripe_lookup_impl; pub mod stripe_internal_subscription_product_lookup_impl; -pub mod stripe_internal_user_lookup; \ No newline at end of file +pub mod stripe_internal_user_lookup_impl; \ No newline at end of file diff --git a/crates/service/storyteller_web/src/billing/stripe_internal_user_lookup.rs b/crates/service/storyteller_web/src/billing/stripe_internal_user_lookup_impl.rs similarity index 86% rename from crates/service/storyteller_web/src/billing/stripe_internal_user_lookup.rs rename to crates/service/storyteller_web/src/billing/stripe_internal_user_lookup_impl.rs index adb8d846f8..a5cad378ce 100644 --- a/crates/service/storyteller_web/src/billing/stripe_internal_user_lookup.rs +++ b/crates/service/storyteller_web/src/billing/stripe_internal_user_lookup_impl.rs @@ -10,12 +10,14 @@ use users_component::utils::session_checker::SessionChecker; #[derive(Clone)] pub struct StripeInternalUserLookupImpl { session_checker: SessionChecker, + mysql_pool: MySqlPool, } impl StripeInternalUserLookupImpl { - pub fn new(session_checker: SessionChecker) -> Self { + pub fn new(session_checker: SessionChecker, mysql_pool: MySqlPool) -> Self { Self { session_checker, + mysql_pool, } } } @@ -23,8 +25,8 @@ impl StripeInternalUserLookupImpl { // NB: Marking async_trait as not needing Sync/Send. Hopefully this doesn't blow up on us. #[async_trait(?Send)] impl InternalUserLookup for StripeInternalUserLookupImpl { - async fn lookup_user_from_http_request(&self, http_request: &HttpRequest, mysql_pool: &MySqlPool) -> Result, InternalUserLookupError> { - let mut mysql_connection = mysql_pool.acquire() + async fn lookup_user_from_http_request(&self, http_request: &HttpRequest) -> Result, InternalUserLookupError> { + let mut mysql_connection = self.mysql_pool.acquire() .await .map_err(|e| { warn!("Could not acquire DB pool: {:?}", e); diff --git a/crates/service/storyteller_web/src/main.rs b/crates/service/storyteller_web/src/main.rs index e161dff52b..a2c2a6e303 100644 --- a/crates/service/storyteller_web/src/main.rs +++ b/crates/service/storyteller_web/src/main.rs @@ -52,7 +52,7 @@ use crate::configs::static_api_tokens::{StaticApiTokenConfig, StaticApiTokens, S use crate::http_server::middleware::ip_filter_middleware::IpFilter; use crate::http_server::web_utils::redis_rate_limiter::RedisRateLimiter; use crate::routes::add_routes; -use crate::server_state::{ServerState, EnvConfig, TwitchOauthSecrets, TwitchOauth, RedisRateLimiters, InMemoryCaches}; +use crate::server_state::{ServerState, EnvConfig, TwitchOauthSecrets, TwitchOauth, RedisRateLimiters, InMemoryCaches, StripeSettings}; use crate::threads::db_health_checker_thread::db_health_check_status::HealthCheckStatus; use crate::threads::db_health_checker_thread::db_health_checker_thread::db_health_checker_thread; use crate::threads::ip_banlist_set::IpBanlistSet; @@ -72,14 +72,17 @@ use sqlx::MySqlPool; use sqlx::mysql::MySqlPoolOptions; use std::sync::Arc; use std::time::Duration; +use anyhow::anyhow; use storage_buckets_common::bucket_client::BucketClient; use tokio::runtime::Runtime; +use billing_component::stripe::traits::internal_product_to_stripe_lookup::InternalProductToStripeLookup; use billing_component::stripe::traits::internal_subscription_product_lookup::InternalSubscriptionProductLookup; use billing_component::stripe::traits::internal_user_lookup::InternalUserLookup; use twitch_common::twitch_secrets::TwitchSecrets; use users_component::utils::session_checker::SessionChecker; use users_component::utils::session_cookie_manager::SessionCookieManager; -use crate::billing::stripe_internal_user_lookup::StripeInternalUserLookupImpl; +use crate::billing::internal_product_to_stripe_lookup_impl::InternalProductToStripeLookupImpl; +use crate::billing::stripe_internal_user_lookup_impl::StripeInternalUserLookupImpl; // TODO TODO TODO TODO // TODO TODO TODO TODO @@ -328,6 +331,16 @@ async fn main() -> AnyhowResult<()> { }, }; + let stripe_client = { + let api_secret = stripe_configs + .secrets + .secret_key + .clone() + .ok_or(anyhow!("stripe secret key not configured"))?; + + stripe::Client::new(api_secret) + }; + let server_state = ServerState { env_config: EnvConfig { num_workers, @@ -337,7 +350,10 @@ async fn main() -> AnyhowResult<()> { cookie_http_only, website_homepage_redirect, }, - stripe_configs, + stripe: StripeSettings { + config: stripe_configs, + client: stripe_client, + }, hostname: server_hostname, health_check_status, mysql_pool: pool, @@ -406,7 +422,11 @@ pub async fn serve(server_state: ServerState) -> AnyhowResult<()> // NB: Dynamic dispatch needs to be wrapped with Arc. let product_lookup : Arc = Arc::new(StripeInternalSubscriptionProductLookupImpl {}); - let user_lookup : Arc = Arc::new(StripeInternalUserLookupImpl::new(server_state_arc.session_checker.clone())); + let stripe_lookup : Arc = Arc::new(InternalProductToStripeLookupImpl{}); + let user_lookup : Arc = Arc::new(StripeInternalUserLookupImpl::new( + server_state_arc.session_checker.clone(), + server_state_arc.mysql_pool.clone(), + )); // NB: app_data being clone()'d below should all be safe (dependencies included) let app = App::new() @@ -414,8 +434,10 @@ pub async fn serve(server_state: ServerState) -> AnyhowResult<()> .app_data(web::Data::new(server_state_arc.mysql_pool.clone())) .app_data(web::Data::new(server_state_arc.session_checker.clone())) .app_data(web::Data::new(server_state_arc.cookie_manager.clone())) - .app_data(web::Data::new(server_state_arc.stripe_configs.clone())) + .app_data(web::Data::new(server_state_arc.stripe.clone().config.clone())) + .app_data(web::Data::new(server_state_arc.stripe.clone().client.clone())) .app_data(web::Data::from(product_lookup)) // NB: Data::from(Arc) for dynamic dispatch + .app_data(web::Data::from(stripe_lookup)) // NB: Data::from(Arc) for dynamic dispatch .app_data(web::Data::from(user_lookup)) // NB: Data::from(Arc) for dynamic dispatch .app_data(server_state_arc.clone()) .wrap(build_common_cors_config()) diff --git a/crates/service/storyteller_web/src/server_state.rs b/crates/service/storyteller_web/src/server_state.rs index e9a9e76ac5..09b2c20740 100644 --- a/crates/service/storyteller_web/src/server_state.rs +++ b/crates/service/storyteller_web/src/server_state.rs @@ -22,7 +22,7 @@ pub struct ServerState { /// Configuration from ENV vars. pub env_config: EnvConfig, - pub stripe_configs: StripeConfig, + pub stripe: StripeSettings, pub hostname: String, @@ -120,3 +120,9 @@ pub struct InMemoryCaches { /// vector clock. pub tts_queue_length: SingleItemTtlCache, } + +#[derive(Clone)] +pub struct StripeSettings { + pub config: StripeConfig, + pub client: stripe::Client, +} \ No newline at end of file