This commit is contained in:
Brandon Thomas committed 2025-09-12 01:00:03 -04:00
1 parent 1e4fca06ee
commit 80f0235c50
39 files changed
+1573 -117

No files matched your search

Generated
+2
View File
@@ -1340,6 +1340,8 @@ dependencies = [
"async-stripe 1.0.0-alpha.2",
"async-stripe-checkout",
"async-stripe-core",
"async-stripe-shared",
"async-stripe-types",
"async-stripe-webhook",
"chrono",
"component_traits",
@@ -1,7 +1,7 @@
use anyhow::anyhow;
use chrono::NaiveDateTime;
use sqlx::MySqlPool;
use enums::common::subscription_namespace::SubscriptionNamespace;
use errors::AnyhowResult;
use reusable_types::stripe::stripe_recurring_interval::StripeRecurringInterval;
use reusable_types::stripe::stripe_subscription_status::StripeSubscriptionStatus;
@@ -17,8 +17,8 @@ pub struct UpsertUserSubscription<'a> {
/// Internal user token
pub user_token: &'a str,
/// The platform key, eg. "fakeyou", "storyteller_stream", "symphonia", etc.
pub subscription_namespace: &'a str,
/// The platform key, eg. "artcraft", "fakeyou", "storyteller_stream", "symphonia", etc.
pub subscription_namespace: SubscriptionNamespace,
/// The name of the product the user is subscribing to within the category.
pub subscription_product_slug: &'a str,
@@ -112,7 +112,7 @@ ON DUPLICATE KEY UPDATE
// Insert
token,
self.user_token,
self.subscription_namespace,
self.subscription_namespace.to_str(),
self.subscription_product_slug,
self.stripe_subscription_id,
@@ -46,9 +46,11 @@ sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio", "tls-rustls",
# Money
#async-stripe = { version = "1.0.0-alpha.2", features = ["runtime-tokio-hyper"] }
async-stripe = { version = "1.0.0-alpha.2", features = [] }
async-stripe-core = { version = "1.0.0-alpha.2", features = ["customer"] }
async-stripe-webhook = { version = "1.0.0-alpha.2", features = [] }
async-stripe-checkout = { version = "1.0.0-alpha.2", features = ["checkout_session"] }
async-stripe-core = { version = "1.0.0-alpha.2", features = ["customer"] }
async-stripe-shared= { version = "1.0.0-alpha.2", features = [] }
async-stripe-types = { version = "1.0.0-alpha.2", features = [] }
async-stripe-webhook = { version = "1.0.0-alpha.2", features = [] }
#sqlx = { version = "0.7.4", features = [ "mysql", "runtime-tokio", "tls-rustls", "chrono" ] }
#utoipa = { version = "4", features = ["chrono","actix_extras"] }
@@ -3,7 +3,7 @@ use crate::configs::stripe_artcraft_product_info_list::{ARTCRAFT_BASIC_PRODUCTIO
use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug;
use reusable_types::server_environment::ServerEnvironment;
pub fn get_artcraft_product_by_slug(slug: ArtcraftSubscriptionSlug, env: ServerEnvironment) -> StripeArtcraftProductInfo {
pub fn get_artcraft_product_by_slug_and_env(slug: ArtcraftSubscriptionSlug, env: ServerEnvironment) -> StripeArtcraftProductInfo {
match (env, slug) {
// Development sandbox
(ServerEnvironment::Development, ArtcraftSubscriptionSlug::ArtcraftBasic) => ARTCRAFT_BASIC_SANDBOX,
@@ -0,0 +1,41 @@
use crate::configs::stripe_artcraft_product_info::StripeArtcraftProductInfo;
use crate::configs::stripe_artcraft_product_info_list::{ARTCRAFT_BASIC_PRODUCTION, ARTCRAFT_BASIC_SANDBOX, ARTCRAFT_MAX_PRODUCTION, ARTCRAFT_MAX_SANDBOX, ARTCRAFT_PRO_PRODUCTION, ARTCRAFT_PRO_SANDBOX};
use once_cell::sync::Lazy;
use reusable_types::server_environment::ServerEnvironment;
use std::collections::HashMap;
static PRODUCTION_PRODUCTS_BY_STRIPE_ID : Lazy<HashMap<String, StripeArtcraftProductInfo>> = Lazy::new(|| {
let mut plans = HashMap::new();
fn add_plan(plans: &mut HashMap<String, StripeArtcraftProductInfo>, product: &StripeArtcraftProductInfo) {
plans.insert(product.product_id.to_string(), product.clone());
}
add_plan(&mut plans, &ARTCRAFT_BASIC_PRODUCTION);
add_plan(&mut plans, &ARTCRAFT_PRO_PRODUCTION);
add_plan(&mut plans, &ARTCRAFT_MAX_PRODUCTION);
plans
});
static SANDBOX_PRODUCTS_BY_STRIPE_ID : Lazy<HashMap<String, StripeArtcraftProductInfo>> = Lazy::new(|| {
let mut plans = HashMap::new();
fn add_plan(plans: &mut HashMap<String, StripeArtcraftProductInfo>, product: &StripeArtcraftProductInfo) {
plans.insert(product.product_id.to_string(), product.clone());
}
add_plan(&mut plans, &ARTCRAFT_BASIC_SANDBOX);
add_plan(&mut plans, &ARTCRAFT_PRO_SANDBOX);
add_plan(&mut plans, &ARTCRAFT_MAX_SANDBOX);
plans
});
pub fn get_artcraft_product_by_stripe_id_and_env(stripe_id: &str, env: ServerEnvironment) -> Option<&StripeArtcraftProductInfo> {
match env {
ServerEnvironment::Development => SANDBOX_PRODUCTS_BY_STRIPE_ID.get(stripe_id),
ServerEnvironment::Production => PRODUCTION_PRODUCTS_BY_STRIPE_ID.get(stripe_id),
}
}
@@ -1,5 +1,5 @@
pub mod get_artcraft_product_by_slug;
pub mod get_artcraft_product_by_slug_and_env;
pub mod get_artcraft_product_by_stripe_id_and_env;
pub mod stripe_artcraft_metadata_keys;
pub mod stripe_artcraft_product_info;
pub mod stripe_artcraft_product_info_list;
@@ -1,5 +1,6 @@
use enums::common::artcraft_subscription_slug::ArtcraftSubscriptionSlug;
#[derive(Debug, Clone)]
pub struct StripeArtcraftProductInfo {
pub slug: ArtcraftSubscriptionSlug,
pub product_id: &'static str,
@@ -1,4 +1,4 @@
use crate::configs::get_artcraft_product_by_slug::get_artcraft_product_by_slug;
use crate::configs::get_artcraft_product_by_slug_and_env::get_artcraft_product_by_slug_and_env;
use crate::configs::stripe_artcraft_metadata_keys::{STRIPE_ARTCRAFT_METADATA_EMAIL, STRIPE_ARTCRAFT_METADATA_USERNAME, STRIPE_ARTCRAFT_METADATA_USER_TOKEN};
use crate::utils::artcraft_stripe_config::ArtcraftStripeConfigWithClient;
use crate::utils::common_web_error::CommonWebError;
@@ -51,7 +51,7 @@ pub async fn stripe_artcraft_create_checkout_session_handler(
Some(cadence) => cadence,
};
let plan = get_artcraft_product_by_slug(slug, **server_environment);
let plan = get_artcraft_product_by_slug_and_env(slug, **server_environment);
let price_id = match cadence {
PlanBillingCadence::Monthly => plan.monthly_price_id.clone(),
@@ -1,2 +1,2 @@
pub mod stripe_artcraft_webhook_handler;
pub mod stripe_artcraft_webhook_summary;
pub mod webhook_event_handlers;
@@ -1,20 +1,32 @@
use actix_web::web::{Bytes, Data, Json};
use actix_web::{web, HttpRequest, HttpResponse};
use crate::endpoints::webhook::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary;
use crate::endpoints::webhook::webhook_event_handlers::charge::charge_succeeded_handler::charge_succeeded_handler;
use crate::endpoints::webhook::webhook_event_handlers::checkout_session::checkout_session_completed_handler::checkout_session_completed_handler;
use crate::endpoints::webhook::webhook_event_handlers::customer::customer_created_handler::customer_created_handler;
use crate::endpoints::webhook::webhook_event_handlers::customer::customer_deleted_handler::customer_deleted_handler;
use crate::endpoints::webhook::webhook_event_handlers::customer::customer_updated_handler::customer_updated_handler;
use crate::endpoints::webhook::webhook_event_handlers::customer_subscription::customer_subscription_created_handler::customer_subscription_created_handler;
use crate::endpoints::webhook::webhook_event_handlers::invoice::invoice_paid_handler::invoice_paid_handler;
use crate::endpoints::webhook::webhook_event_handlers::invoice::invoice_payment_failed::invoice_payment_failed_handler;
use crate::endpoints::webhook::webhook_event_handlers::invoice::invoice_payment_succeeded_handler::invoice_payment_succeeded_handler;
use crate::endpoints::webhook::webhook_event_handlers::invoice::invoice_updated_handler::invoice_updated_handler;
use crate::endpoints::webhook::webhook_event_handlers::payment_intent::payment_intent_succeeded_handler::payment_intent_succeeded_handler;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary;
use crate::utils::artcraft_stripe_config::ArtcraftStripeConfigWithClient;
use crate::utils::common_web_error::CommonWebError;
use crate::utils::verify_stripe_webhook_ip_address::verify_stripe_webhook_ip_address;
use chrono::NaiveDateTime;
use http_server_common::request::get_request_header_optional::get_request_header_optional;
use log::{error, info, warn};
use mysql_queries::queries::billing::stripe::get_stripe_webhook_event_log_by_id::{get_stripe_webhook_event_log_by_id, get_stripe_webhook_event_log_by_id_with_connection};
use mysql_queries::queries::billing::stripe::insert_stripe_webhook_event_log::InsertStripeWebhookEventLog;
use reusable_types::server_environment::ServerEnvironment;
use serde_derive::Serialize;
use sqlx::pool::PoolConnection;
use sqlx::{MySql, MySqlConnection, MySqlPool};
use std::sync::Arc;
use stripe_webhook::{Event, Webhook};
use stripe_webhook::{Event, EventObject, Webhook};
#[derive(Serialize)]
pub struct StripeArtcraftWebhookSuccessResponse {
@@ -33,17 +45,17 @@ pub struct StripeArtcraftWebhookSuccessResponse {
// )]
pub async fn stripe_artcraft_webhook_handler(
http_request: HttpRequest,
//server_state: Data<Arc<ServerState>>,
server_environment: Data<ServerEnvironment>,
stripe_config: Data<ArtcraftStripeConfigWithClient>,
request_body_bytes: Bytes,
mysql_pool: Data<MySqlPool>,
) -> Result<Json<StripeArtcraftWebhookSuccessResponse>, CommonWebError>
) -> Result<Json<StripeArtcraftWebhookSuccessResponse>, StripeArtcraftWebhookError>
{
verify_stripe_webhook_ip_address(&http_request)
.map_err(|e| {
let reason = format!("Improper client IP address. Error: {:?}", e);
error!("{}", &reason);
CommonWebError::BadInputWithSimpleMessage("bad request".to_string())
StripeArtcraftWebhookError::BadRequest("bad request".to_string())
})?;
let secret_signing_key = &stripe_config.secret_webhook_signing_key;
@@ -56,7 +68,7 @@ pub async fn stripe_artcraft_webhook_handler(
.map_err(|err| {
let reason = format!("Could not decode request body to UTF-8: {:?}", err);
error!("{}", &reason);
CommonWebError::BadInputWithSimpleMessage(reason)
StripeArtcraftWebhookError::BadRequest(reason)
})?;
let webhook_payload = Webhook::construct_event(&webhook_payload, &stripe_signature, secret_signing_key)
@@ -64,7 +76,7 @@ pub async fn stripe_artcraft_webhook_handler(
let reason = format!("Could not construct Stripe webhook event: {:?}", e);
error!("{}", &reason);
println!("{:?}", webhook_payload);
CommonWebError::BadInputWithSimpleMessage(reason)
StripeArtcraftWebhookError::BadRequest(reason)
})?;
// Events can be re-sent, so we need to make handling them idempotent.
@@ -73,13 +85,13 @@ pub async fn stripe_artcraft_webhook_handler(
let stripe_event_created_at = NaiveDateTime::from_timestamp(webhook_payload.created, 0);
let stripe_event_type = webhook_payload.type_.as_str().to_string();
//let stripe_event_type = serde_json::to_string(&webhook_payload.type_)
// .map(|s| s.replace("\"", ""))
// .map_err(|err| {
// let reason = format!("Could not serialize webhook type: {:?}", err);
// error!("{}", &reason);
// CommonWebError::BadInputWithSimpleMessage(reason)
// StripeArtcraftWebhookError::BadInputWithSimpleMessage(reason)
// })?;
// NB: Whether this was test data or live data
@@ -94,14 +106,18 @@ pub async fn stripe_artcraft_webhook_handler(
let mut mysql_connection = mysql_pool
.acquire()
.await?;
.await
.map_err(|err| {
error!("Could not acquire mysql connection: {:?}", err);
StripeArtcraftWebhookError::ServerError
})?;
let maybe_previously_played_event = get_stripe_webhook_event_log_by_id_with_connection(&stripe_event_id, &mut mysql_connection)
.await
.map_err(|err| {
let reason = format!("Could not query previous event by ID ({}): {:?}", &stripe_event_id, err);
error!("{}", &reason);
CommonWebError::ServerError
Err(StripeArtcraftWebhookError::ServerError)
})?;
if let Some(event) = maybe_previously_played_event {
@@ -117,7 +133,12 @@ pub async fn stripe_artcraft_webhook_handler(
}
}
let webhook_summary = handle_webhook_payload(&mut mysql_connection, webhook_payload, &stripe_event_type).await?;
let webhook_summary = handle_webhook_payload(
&mut mysql_connection,
**server_environment,
webhook_payload,
&stripe_event_type
).await?;
let query = InsertStripeWebhookEventLog {
stripe_event_id,
@@ -136,7 +157,7 @@ pub async fn stripe_artcraft_webhook_handler(
.map_err(|err| {
let reason = format!("Could not insert Stripe webhook event log: {:?}", err);
error!("{}", &reason);
CommonWebError::ServerError
StripeArtcraftWebhookError::ServerError
})?;
Ok(Json(StripeArtcraftWebhookSuccessResponse {
@@ -146,9 +167,10 @@ pub async fn stripe_artcraft_webhook_handler(
async fn handle_webhook_payload(
mysql_connection: &mut PoolConnection<MySql>,
server_environment: ServerEnvironment,
webhook_payload: Event,
stripe_event_type: &String
) -> Result<StripeArtcraftWebhookSummary, CommonWebError> {
) -> Result<StripeArtcraftWebhookSummary, StripeArtcraftWebhookError> {
let mut unhandled_event_type = false;
let mut webhook_summary = StripeArtcraftWebhookSummary {
@@ -159,119 +181,96 @@ async fn handle_webhook_payload(
should_ignore_retry: false,
};
// NB:
// - "Webhook endpoints might occasionally receive the same event more than once."
// - "Stripe does not guarantee delivery of events in the order in which they are generated."
/*
match webhook_payload.type_ {
match webhook_payload.data.object {
// =============== CHECKOUT SESSIONS ===============
EventType::CheckoutSessionCompleted => {
if let EventObject::CheckoutSession(checkout_session) = webhook_payload.data.object {
webhook_summary = checkout_session_completed_handler(checkout_session)?;
}
EventObject::CheckoutSessionCompleted(checkout_session) => {
webhook_summary = checkout_session_completed_handler(checkout_session)?;
}
// EventType::CheckoutSessionExpired => {}
// EventType::CheckoutSessionAsyncPaymentFailed => {}
// EventType::CheckoutSessionAsyncPaymentSucceeded => {}
// EventObject::CheckoutSessionAsyncPaymentFailed(_) => {}
// EventObject::CheckoutSessionAsyncPaymentSucceeded(_) => {}
// EventObject::CheckoutSessionExpired(_) => {}
// =============== CUSTOMERS ===============
EventType::CustomerCreated => {
if let EventObject::Customer(customer) = webhook_payload.data.object {
webhook_summary = customer_created_handler(&customer)?;
}
EventObject::CustomerCreated(customer) => {
webhook_summary = customer_created_handler(&customer)?;
}
EventType::CustomerUpdated => {
if let EventObject::Customer(customer) = webhook_payload.data.object {
webhook_summary = customer_updated_handler(&customer)?;
}
EventObject::CustomerUpdated(customer) => {
webhook_summary = customer_updated_handler(&customer)?;
}
EventType::CustomerDeleted => {
if let EventObject::Customer(customer) = webhook_payload.data.object {
webhook_summary = customer_deleted_handler(&customer)?;
}
EventObject::CustomerDeleted(customer) => {
webhook_summary = customer_deleted_handler(&customer)?;
}
// =============== CUSTOMER SUBSCRIPTIONS ===============
EventType::CustomerSubscriptionCreated => {
if let EventObject::Subscription(subscription) = webhook_payload.data.object {
webhook_summary = customer_subscription_created_handler(
&subscription,
internal_subscription_product_lookup.get_ref(),
&mysql_pool).await?;
}
EventObject::CustomerSubscriptionCreated(subscription) => {
webhook_summary = customer_subscription_created_handler(
&subscription,
server_environment,
&mysql_pool).await?;
}
EventType::CustomerSubscriptionUpdated => {
if let EventObject::Subscription(subscription) = webhook_payload.data.object {
webhook_summary = customer_subscription_updated_handler(
&subscription,
internal_subscription_product_lookup.get_ref(),
&mysql_pool).await?;
}
EventObject::CustomerSubscriptionUpdated(subscription) => {
webhook_summary = customer_subscription_updated_handler(
&subscription,
server_environment,
&mysql_pool).await?;
}
EventType::CustomerSubscriptionDeleted => {
if let EventObject::Subscription(subscription) = webhook_payload.data.object {
webhook_summary = customer_subscription_deleted_handler(
&subscription,
internal_subscription_product_lookup.get_ref(),
&mysql_pool).await?;
}
EventObject::CustomerSubscriptionDeleted(subscription) => {
webhook_summary = customer_subscription_deleted_handler(
&subscription,
internal_subscription_product_lookup.get_ref(),
&mysql_pool).await?;
}
// EventType::CustomerSubscriptionPendingUpdateApplied => {}
// EventType::CustomerSubscriptionPendingUpdateExpired => {}
// EventType::CustomerSubscriptionTrialWillEnd => {}
// EventObject::CustomerSubscriptionPendingUpdateApplied(_) => {}
// EventObject::CustomerSubscriptionPendingUpdateExpired(_) => {}
// EventObject::CustomerSubscriptionTrialWillEnd(_) => {}
// =============== INVOICES ===============
EventType::InvoiceCreated => {
EventObject::InvoiceCreated(_invoice) => {
// TODO: We need to respond to this so we don't hold payments up by 72 hours!
// See: https://stripe.com/docs/billing/subscriptions/webhooks
}
EventType::InvoiceUpdated => {
if let EventObject::Invoice(invoice) = webhook_payload.data.object {
webhook_summary = invoice_updated_handler(&invoice)?;
}
EventObject::InvoiceUpdated(invoice) => {
webhook_summary = invoice_updated_handler(&invoice)?;
}
EventType::InvoicePaid => {
if let EventObject::Invoice(invoice) = webhook_payload.data.object {
webhook_summary = invoice_paid_handler(&invoice)?;
}
EventObject::InvoicePaid(invoice) => {
webhook_summary = invoice_paid_handler(&invoice)?;
}
EventType::InvoicePaymentSucceeded => {
if let EventObject::Invoice(invoice) = webhook_payload.data.object {
webhook_summary = invoice_payment_succeeded_handler(&invoice)?;
}
EventObject::InvoicePaymentSucceeded(invoice) => {
webhook_summary = invoice_payment_succeeded_handler(&invoice)?;
}
EventType::InvoicePaymentFailed => {
if let EventObject::Invoice(invoice) = webhook_payload.data.object {
webhook_summary = invoice_payment_failed_handler(&invoice)?;
}
EventObject::InvoicePaymentFailed(invoice) => {
webhook_summary = invoice_payment_failed_handler(&invoice)?;
}
// EventType::InvoiceDeleted => {}
// EventType::InvoiceFinalizationFailed => {}
// EventType::InvoiceFinalized => {}
// EventType::InvoiceItemCreated => {}
// EventType::InvoiceItemDeleted => {}
// EventType::InvoiceItemUpdated => {}
// EventType::InvoiceMarkedUncollectible => {}
// EventType::InvoicePaymentActionRequired => {}
// EventType::InvoiceSent => {}
// EventType::InvoiceUpcoming => {}
// EventType::InvoiceUpdated => {}
// EventType::InvoiceVoided => {}
// EventObject::InvoiceDeleted(_) => {}
// EventObject::InvoiceFinalizationFailed(_) => {}
// EventObject::InvoiceFinalized(_) => {}
// EventObject::InvoiceItemCreated(_) => {}
// EventObject::InvoiceItemDeleted(_) => {}
// EventObject::InvoiceItemUpdated(_) => {}
// EventObject::InvoiceMarkedUncollectible(_) => {}
// EventObject::InvoicePaymentActionRequired(_) => {}
// EventObject::InvoiceSent(_) => {}
// EventObject::InvoiceUpcoming(_) => {}
// EventObject::InvoiceUpdated(_) => {}
// EventObject::InvoiceVoided(_) => {}
// =============== PAYMENT INTENTS ===============
EventType::PaymentIntentSucceeded => {
if let EventObject::PaymentIntent(payment_intent) = webhook_payload.data.object {
webhook_summary = payment_intent_succeeded_handler(&payment_intent)?;
}
EventObject::PaymentIntentSucceeded(payment_intent) => {
webhook_summary = payment_intent_succeeded_handler(&payment_intent)?;
}
// EventType::PaymentIntentAmountCapturableUpdated => {}
@@ -285,16 +284,13 @@ async fn handle_webhook_payload(
// =============== CHARGES ===============
EventType::ChargeSucceeded => {
if let EventObject::Charge(charge) = webhook_payload.data.object {
webhook_summary = charge_succeeded_handler(&charge)?;
}
EventObject::ChargeSucceeded(charge) => {
webhook_summary = charge_succeeded_handler(&charge)?;
}
// =============== Ignored ===============
// Ignoring these types:
// Ignoring these types (NOTE: This is from the old async-stripe version):
// Account* (6),
// ApplicationFee* (2),
// BalanceAvailable (1),
@@ -342,12 +338,11 @@ async fn handle_webhook_payload(
// TestHelpersTestClock* (5),
// Topup* (5),
// Transfer* (5),
_ => {
unhandled_event_type = true;
},
}
*/
// To play with the payload contents as JSON:
// let json = serde_json::ser::to_string(&event_payload).unwrap();
@@ -0,0 +1,30 @@
use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary;
use crate::utils::expand_customer_id::expand_customer_id;
use stripe_shared::Charge;
// Handle event type: 'charge.succeeded'
pub fn charge_succeeded_handler(charge: &Charge) -> Result<StripeArtcraftWebhookSummary, StripeArtcraftWebhookError> {
let charge_id = charge.id.to_string();
let charge_status = charge.status;
// NB: We'll need this to send them to the "customer portal", which is how they can modify
// or cancel their subscriptions.
let maybe_stripe_customer_id = charge.customer
.as_ref()
.map(|c| expand_customer_id(c));
// NB: Our internal user token.
let maybe_user_token = charge.metadata.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN)
.map(|t| t.to_string());
Ok(StripeArtcraftWebhookSummary {
maybe_user_token,
maybe_event_entity_id: Some(charge_id),
maybe_stripe_customer_id,
action_was_taken: false,
should_ignore_retry: false,
})
}
@@ -0,0 +1 @@
pub(crate) mod charge_succeeded_handler;
@@ -0,0 +1,32 @@
use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary;
use crate::utils::expand_customer_id::expand_customer_id;
use stripe_checkout::CheckoutSession;
// After the subscription signup succeeds, the customer returns to your website at the success_url,
// which initiates a checkout.session.completed webhooks. When you receive a checkout.session.completed
// event, you can provision the subscription. Continue to provision each month (if billing monthly) as
// you receive invoice.paid events. If you receive an invoice.payment_failed event, notify your customer
// and send them to the customer portal to update their payment method.
pub fn checkout_session_completed_handler(checkout_session: CheckoutSession) -> Result<StripeArtcraftWebhookSummary, StripeArtcraftWebhookError> {
let stripe_checkout_id = checkout_session.id.to_string();
// NB: We'll need this to send them to the "customer portal", which is how they can modify or cancel
// their subscriptions.
let maybe_stripe_customer_id = checkout_session.customer
.as_ref()
.map(|c| expand_customer_id(c));
// NB: Our internal user token.
let maybe_user_token = checkout_session.metadata
.and_then(|m| m.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN).map(|t| t.to_string()));
Ok(StripeArtcraftWebhookSummary {
maybe_user_token,
maybe_event_entity_id: Some(stripe_checkout_id),
maybe_stripe_customer_id,
action_was_taken: false,
should_ignore_retry: false,
})
}
@@ -0,0 +1 @@
pub(crate) mod checkout_session_completed_handler;
@@ -0,0 +1,25 @@
use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary;
use stripe_core::Customer;
// Handle event type: 'customer.created'
pub fn customer_created_handler(customer: &Customer) -> Result<StripeArtcraftWebhookSummary, StripeArtcraftWebhookError> {
// NB: We'll need this to send them to the "customer portal", which is how they can modify
// or cancel their subscriptions.
let customer_id = customer.id.to_string();
// NB: Our internal user token.
let maybe_user_token = customer.metadata
.as_ref()
.and_then(|m| m.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN).map(|t| t.to_string()));
Ok(StripeArtcraftWebhookSummary {
maybe_user_token,
maybe_event_entity_id: Some(customer_id.clone()),
maybe_stripe_customer_id: Some(customer_id.clone()),
action_was_taken: false,
should_ignore_retry: false,
})
}
@@ -0,0 +1,24 @@
use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary;
use stripe_core::Customer;
// Handle event type: 'customer.deleted'
pub fn customer_deleted_handler(customer: &Customer) -> Result<StripeArtcraftWebhookSummary, StripeArtcraftWebhookError> {
// NB: We'll need this to send them to the "customer portal", which is how they can modify
// or cancel their subscriptions.
let customer_id = customer.id.to_string();
// NB: Our internal user token.
let maybe_user_token = customer.metadata
.as_ref()
.and_then(|m| m.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN).map(|t| t.to_string()));
Ok(StripeArtcraftWebhookSummary {
maybe_user_token,
maybe_event_entity_id: Some(customer_id.clone()),
maybe_stripe_customer_id: Some(customer_id.clone()),
action_was_taken: false,
should_ignore_retry: false,
})
}
@@ -0,0 +1,24 @@
use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary;
use stripe_core::Customer;
// Handle event type: 'customer.updated'
pub fn customer_updated_handler(customer: &Customer) -> Result<StripeArtcraftWebhookSummary, StripeArtcraftWebhookError> {
// NB: We'll need this to send them to the "customer portal", which is how they can modify
// or cancel their subscriptions.
let customer_id = customer.id.to_string();
// NB: Our internal user token.
let maybe_user_token = customer.metadata
.as_ref()
.and_then(|m| m.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN).map(|t| t.to_string()));
Ok(StripeArtcraftWebhookSummary {
maybe_user_token,
maybe_event_entity_id: Some(customer_id.clone()),
maybe_stripe_customer_id: Some(customer_id.clone()),
action_was_taken: false,
should_ignore_retry: false,
})
}
@@ -0,0 +1,3 @@
pub(crate) mod customer_created_handler;
pub(crate) mod customer_deleted_handler;
pub(crate) mod customer_updated_handler;
@@ -0,0 +1,32 @@
use std::ops::{Add, Sub};
use chrono::{Duration, NaiveDateTime};
use once_cell::sync::Lazy;
use crate::endpoints::webhook::webhook_event_handlers::customer_subscription::subscription_event_extractor::SubscriptionSummary;
use reusable_types::stripe::stripe_subscription_status::StripeSubscriptionStatus;
// Push back the expiration date.
static SUBSCRIPTION_GRACE_DAYS : Lazy<Duration> = Lazy::new(|| Duration::days(2));
// Set canceled subscriptions back in time over a year, which is the maximum billing period + grace.
static BACKDATE_TERMINATION_DAYS : Lazy<Duration> = Lazy::new(|| Duration::days(370));
#[inline]
pub fn calculate_subscription_end_date(subscription_summary: &SubscriptionSummary) -> NaiveDateTime {
if subscription_summary.stripe_subscription_status == StripeSubscriptionStatus::Canceled {
// If it's cancelled, the user is done and the service should be removed.
// NB: Subscriptions set to cancel in the future (from the admin UI) are still "active".
return subscription_summary.maybe_canceled_at
.unwrap_or(subscription_summary.current_billing_period_end)
.sub(*BACKDATE_TERMINATION_DAYS); // Just to be sure.
}
let mut subscription_end = subscription_summary.current_billing_period_end;
if !subscription_summary.cancel_at_period_end {
subscription_end = subscription_end.add(*SUBSCRIPTION_GRACE_DAYS);
}
subscription_end
}
@@ -0,0 +1,8 @@
/// Subscription categories are used to namespace subscriptions.
/// Integrations should always provide a default even if this isn't used.
pub const UNKNOWN_SUBSCRIPTION_NAMESPACE: &str = "unknown";
/// The integrated system has internal unique string key identifiers for
/// each subscription plan.
pub const UNKNOWN_SUBSCRIPTION_PRODUCT_SLUG: &str = "unknown";
@@ -0,0 +1,119 @@
use crate::configs::get_artcraft_product_by_slug_and_env::get_artcraft_product_by_slug_and_env;
use crate::configs::get_artcraft_product_by_stripe_id_and_env::get_artcraft_product_by_stripe_id_and_env;
use crate::endpoints::webhook::webhook_event_handlers::customer_subscription::calculate_subscription_end_date::calculate_subscription_end_date;
use crate::endpoints::webhook::webhook_event_handlers::customer_subscription::subscription_event_extractor::subscription_summary_extractor;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary;
use enums::common::subscription_namespace::SubscriptionNamespace;
use log::error;
use mysql_queries::queries::users::user::update::update_user_record_with_new_stripe_customer_id::update_user_record_with_new_stripe_customer_id;
use mysql_queries::queries::users::user_subscriptions::get_user_subscription_by_stripe_subscription_id::get_user_subscription_by_stripe_subscription_id;
use mysql_queries::queries::users::user_subscriptions::upsert_user_subscription_by_stripe_id::UpsertUserSubscription;
use reusable_types::server_environment::ServerEnvironment;
use sqlx::MySqlPool;
use stripe_shared::Subscription;
/// Handle event type: 'customer.subscription.created'
/// Sent when the subscription is created. The subscription status may be incomplete if customer
/// authentication is required to complete the payment or if you set payment_behavior to
/// default_incomplete. For more details, read about subscription payment behavior.
pub async fn customer_subscription_created_handler(
subscription: &Subscription,
server_environment: ServerEnvironment,
//internal_subscription_product_lookup: &dyn InternalSubscriptionProductLookup,
mysql_pool: &MySqlPool,
) -> Result<StripeArtcraftWebhookSummary, StripeArtcraftWebhookError> {
let summary = subscription_summary_extractor(subscription)
.map_err(|err| {
let reason = format!("Error extracting subscription from 'customer.subscription.created' payload: {:?}", err);
error!("{}", reason);
StripeArtcraftWebhookError::ServerError(reason) // NB: This was probably *our* fault.
})?;
let mut result = StripeArtcraftWebhookSummary {
maybe_user_token: summary.user_token.clone(),
maybe_event_entity_id: Some(summary.stripe_subscription_id.clone()),
maybe_stripe_customer_id: Some(summary.stripe_customer_id.clone()),
action_was_taken: false,
should_ignore_retry: false,
};
// NB: It's possible to receive events out of order.
// We won't want to play a `create` event on top.
let maybe_existing_subscription = get_user_subscription_by_stripe_subscription_id(&summary.stripe_subscription_id, &mysql_pool)
.await
.map_err(|err| {
let reason = format!("Mysql error: {:?}", err);
error!("{}", reason);
StripeArtcraftWebhookError::ServerError(reason)
})?;
if maybe_existing_subscription.is_some() {
result.should_ignore_retry = true;
return Ok(result);
}
let maybe_product = get_artcraft_product_by_stripe_id_and_env(
&summary.stripe_product_id, server_environment);
let product = match maybe_product {
None => {
error!("No matching product for stripe product ID: {}", &summary.stripe_product_id);
result.should_ignore_retry = true;
return Ok(result);
}
Some(product) => product,
};
if let Some(user_token) = summary.user_token.as_deref() {
// TODO: record cancel_at (future_cancel_at), canceled_at, ended_at (if subscription ended, when it ended), start_date
let upsert = UpsertUserSubscription {
stripe_subscription_id: &summary.stripe_subscription_id,
user_token,
subscription_namespace: SubscriptionNamespace::Artcraft,
subscription_product_slug: &product.slug.to_str(),
maybe_stripe_customer_id: Some(&summary.stripe_customer_id),
maybe_stripe_product_id: Some(&summary.stripe_product_id),
maybe_stripe_price_id: Some(&summary.stripe_price_id),
maybe_stripe_recurring_interval: Some(summary.subscription_interval),
maybe_stripe_subscription_status: Some(summary.stripe_subscription_status),
maybe_stripe_is_production: Some(summary.stripe_is_production),
subscription_start_at: summary.subscription_start_date,
current_billing_period_start_at: summary.current_billing_period_start,
current_billing_period_end_at: summary.current_billing_period_end,
subscription_expires_at: calculate_subscription_end_date(&summary),
maybe_cancel_at: summary.maybe_cancel_at,
maybe_canceled_at: summary.maybe_canceled_at,
};
let _r = upsert.upsert(mysql_pool)
.await
.map_err(|err| {
let reason = format!("Mysql error: {:?}", err);
error!("{}", reason);
StripeArtcraftWebhookError::ServerError(reason)
})?;
// TODO: Should we care if a user accidentally gets two stripe customer IDs and this
// overwrites one of them?
update_user_record_with_new_stripe_customer_id(
mysql_pool,
user_token,
Some(&summary.stripe_customer_id))
.await
.map_err(|err| {
let reason = format!("Mysql error: {:?}", err);
error!("{}", reason);
StripeArtcraftWebhookError::ServerError(reason)
})?;
result.action_was_taken = true;
}
result.should_ignore_retry = true;
Ok(result)
}
@@ -0,0 +1,116 @@
use log::error;
use sqlx::MySqlPool;
use stripe::Subscription;
use mysql_queries::queries::users::user_subscriptions::get_user_subscription_by_stripe_subscription_id::get_user_subscription_by_stripe_subscription_id;
use mysql_queries::queries::users::user_subscriptions::upsert_user_subscription_by_stripe_id::UpsertUserSubscription;
use reusable_types::stripe::stripe_subscription_status::StripeSubscriptionStatus;
use crate::stripe::http_endpoints::webhook::webhook_event_handlers::customer_subscription::calculate_subscription_end_date::calculate_subscription_end_date;
use crate::stripe::http_endpoints::webhook::webhook_event_handlers::customer_subscription::common::{UNKNOWN_SUBSCRIPTION_NAMESPACE, UNKNOWN_SUBSCRIPTION_PRODUCT_SLUG};
use crate::stripe::http_endpoints::webhook::webhook_event_handlers::customer_subscription::subscription_event_extractor::subscription_summary_extractor;
use crate::stripe::http_endpoints::webhook::webhook_event_handlers::stripe_webhook_error::StripeWebhookError;
use crate::stripe::http_endpoints::webhook::webhook_event_handlers::stripe_webhook_summary::StripeWebhookSummary;
use crate::stripe::traits::internal_subscription_product_lookup::InternalSubscriptionProductLookup;
/// Handle event type: 'customer.subscription.deleted'
/// Sent when a customer’s subscription ends.
pub async fn customer_subscription_deleted_handler(
subscription: &Subscription,
internal_subscription_product_lookup: &dyn InternalSubscriptionProductLookup,
mysql_pool: &MySqlPool,
) -> Result<StripeWebhookSummary, StripeWebhookError> {
let summary = subscription_summary_extractor(subscription)
.map_err(|err| {
let reason = format!("Error extracting subscription from 'customer.subscription.deleted' payload: {:?}", err);
error!("{}", reason);
StripeWebhookError::ServerError(reason) // NB: This was probably *our* fault.
})?;
let mut should_process_update = true;
let mut action_was_taken = false;
let mut should_ignore_retry = false;
let maybe_internal_subscription_product =
internal_subscription_product_lookup.lookup_internal_product_from_stripe_product_id(&summary.stripe_product_id)
.map_err(|err| {
let reason = format!("Error mapping to internal product: {:?}", err);
error!("{}", reason);
StripeWebhookError::ServerError(reason) // NB: This was probably *our* fault.
})?;
let mut subscription_namespace = UNKNOWN_SUBSCRIPTION_NAMESPACE;
let mut subscription_product_slug = UNKNOWN_SUBSCRIPTION_PRODUCT_SLUG;
if let Some(ref internal_product) = maybe_internal_subscription_product {
subscription_namespace = &internal_product.subscription_category;
subscription_product_slug = &internal_product.subscription_product_key;
}
// NB: It's possible to receive events out of order.
let maybe_existing_subscription = get_user_subscription_by_stripe_subscription_id(&summary.stripe_subscription_id, &mysql_pool)
.await
.map_err(|err| {
let reason = format!("Mysql error: {:?}", err);
error!("{}", reason);
StripeWebhookError::ServerError(reason)
})?;
if let Some(existing_subscription) = maybe_existing_subscription {
match existing_subscription.maybe_stripe_subscription_status {
Some(StripeSubscriptionStatus::Canceled) => {
// NB: The stored subscription already had a terminal status and the subscription cannot be updated any further.
should_process_update = false;
should_ignore_retry = true;
}
_ => {}
}
}
// NB: Even if we haven't received a record before, we should still be able to "tombstone" it
// once we detect the deletion.
if should_process_update {
if let Some(user_token) = summary.user_token.as_deref() {
let upsert = UpsertUserSubscription {
stripe_subscription_id: &summary.stripe_subscription_id,
user_token,
subscription_namespace,
subscription_product_slug,
maybe_stripe_customer_id: Some(&summary.stripe_customer_id),
maybe_stripe_product_id: Some(&summary.stripe_product_id),
maybe_stripe_price_id: Some(&summary.stripe_price_id),
maybe_stripe_recurring_interval: Some(summary.subscription_interval),
maybe_stripe_subscription_status: Some(summary.stripe_subscription_status),
maybe_stripe_is_production: Some(summary.stripe_is_production),
subscription_start_at: summary.subscription_start_date,
current_billing_period_start_at: summary.current_billing_period_start,
current_billing_period_end_at: summary.current_billing_period_end,
subscription_expires_at: calculate_subscription_end_date(&summary),
maybe_cancel_at: summary.maybe_cancel_at,
maybe_canceled_at: summary.maybe_canceled_at,
};
let _r = upsert.upsert(mysql_pool)
.await
.map_err(|err| {
let reason = format!("Mysql error: {:?}", err);
error!("{}", reason);
StripeWebhookError::ServerError(reason)
})?;
action_was_taken = true;
}
should_ignore_retry = true;
}
Ok(StripeWebhookSummary {
maybe_user_token: summary.user_token,
maybe_event_entity_id: Some(summary.stripe_subscription_id),
maybe_stripe_customer_id: Some(summary.stripe_customer_id),
action_was_taken,
should_ignore_retry,
})
}
@@ -0,0 +1,127 @@
use crate::endpoints::webhook::webhook_event_handlers::customer_subscription::calculate_subscription_end_date::calculate_subscription_end_date;
use crate::endpoints::webhook::webhook_event_handlers::customer_subscription::subscription_event_extractor::subscription_summary_extractor;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary;
use log::{error, info};
use mysql_queries::queries::users::user::update::update_user_record_with_new_stripe_customer_id::update_user_record_with_new_stripe_customer_id;
use mysql_queries::queries::users::user_subscriptions::get_user_subscription_by_stripe_subscription_id::get_user_subscription_by_stripe_subscription_id;
use mysql_queries::queries::users::user_subscriptions::upsert_user_subscription_by_stripe_id::UpsertUserSubscription;
use reusable_types::stripe::stripe_subscription_status::StripeSubscriptionStatus;
use sqlx::MySqlPool;
use stripe_shared::Subscription;
/// Handle event type: 'customer.subscription.updated'
pub async fn customer_subscription_updated_handler(
subscription: &Subscription,
internal_subscription_product_lookup: &dyn InternalSubscriptionProductLookup,
mysql_pool: &MySqlPool,
) -> Result<StripeArtcraftWebhookSummary, StripeArtcraftWebhookError> {
let summary = subscription_summary_extractor(subscription)
.map_err(|err| {
let reason = format!("Error extracting subscription from 'customer.subscription.updated' payload: {:?}", err);
error!("{}", reason);
StripeArtcraftWebhookError::ServerError(reason) // NB: This was probably *our* fault.
})?;
let mut should_process_update = true;
let mut action_was_taken = false;
let mut should_ignore_retry = false;
let maybe_internal_subscription_product =
internal_subscription_product_lookup.lookup_internal_product_from_stripe_product_id(&summary.stripe_product_id)
.map_err(|err| {
let reason = format!("Error mapping to internal product: {:?}", err);
error!("{}", reason);
StripeArtcraftWebhookError::ServerError(reason) // NB: This was probably *our* fault.
})?;
let mut subscription_namespace = UNKNOWN_SUBSCRIPTION_NAMESPACE;
let mut subscription_product_slug = UNKNOWN_SUBSCRIPTION_PRODUCT_SLUG;
if let Some(ref internal_product) = maybe_internal_subscription_product {
subscription_namespace = &internal_product.subscription_category;
subscription_product_slug = &internal_product.subscription_product_key;
}
// NB: It's possible to receive events out of order.
let maybe_existing_subscription = get_user_subscription_by_stripe_subscription_id(&summary.stripe_subscription_id, &mysql_pool)
.await
.map_err(|err| {
let reason = format!("Mysql error: {:?}", err);
error!("{}", reason);
StripeArtcraftWebhookError::ServerError(reason)
})?;
if let Some(existing_subscription) = maybe_existing_subscription {
match existing_subscription.maybe_stripe_subscription_status {
Some(StripeSubscriptionStatus::Canceled) => {
// NB: The stored subscription already had a terminal status and the subscription cannot be updated any further.
should_process_update = false;
should_ignore_retry = true;
}
_ => {}
}
}
if should_process_update {
if let Some(user_token) = summary.user_token.as_deref() {
info!("Upserting subscription");
let upsert = UpsertUserSubscription {
stripe_subscription_id: &summary.stripe_subscription_id,
user_token: &user_token,
subscription_namespace,
subscription_product_slug,
maybe_stripe_customer_id: Some(&summary.stripe_customer_id),
maybe_stripe_product_id: Some(&summary.stripe_product_id),
maybe_stripe_price_id: Some(&summary.stripe_price_id),
maybe_stripe_recurring_interval: Some(summary.subscription_interval),
maybe_stripe_subscription_status: Some(summary.stripe_subscription_status),
maybe_stripe_is_production: Some(summary.stripe_is_production),
subscription_start_at: summary.subscription_start_date,
current_billing_period_start_at: summary.current_billing_period_start,
current_billing_period_end_at: summary.current_billing_period_end,
subscription_expires_at: calculate_subscription_end_date(&summary),
maybe_cancel_at: summary.maybe_cancel_at,
maybe_canceled_at: summary.maybe_canceled_at,
};
let _r = upsert.upsert(mysql_pool)
.await
.map_err(|err| {
let reason = format!("Mysql error: {:?}", err);
error!("{}", reason);
StripeArtcraftWebhookError::ServerError(reason)
})?;
info!("Updating user record with stripe customer ID");
// TODO: Should we care if a user accidentally gets two stripe customer IDs and this
// overwrites one of them?
update_user_record_with_new_stripe_customer_id(
mysql_pool,
user_token,
Some(&summary.stripe_customer_id))
.await
.map_err(|err| {
let reason = format!("Mysql error: {:?}", err);
error!("{}", reason);
StripeArtcraftWebhookError::ServerError(reason)
})?;
action_was_taken = true;
}
}
should_ignore_retry = true;
Ok(StripeArtcraftWebhookSummary {
maybe_user_token: summary.user_token,
maybe_event_entity_id: Some(summary.stripe_subscription_id),
maybe_stripe_customer_id: Some(summary.stripe_customer_id),
action_was_taken,
should_ignore_retry,
})
}
@@ -0,0 +1,6 @@
pub mod calculate_subscription_end_date;
pub mod common;
pub mod customer_subscription_created_handler;
pub mod customer_subscription_deleted_handler;
pub mod customer_subscription_updated_handler;
pub mod subscription_event_extractor;
@@ -0,0 +1,428 @@
use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN;
use crate::utils::expand_customer_id::expand_customer_id;
use crate::utils::expand_product_id::expand_product_id;
use anyhow::anyhow;
use chrono::NaiveDateTime;
use errors::AnyhowResult;
use log::error;
use reusable_types::stripe::stripe_recurring_interval::StripeRecurringInterval;
use reusable_types::stripe::stripe_subscription_status::StripeSubscriptionStatus;
use stripe_shared::{Subscription, SubscriptionStatus};
#[derive(Clone, Debug)]
pub struct SubscriptionSummary {
/// Our own internal user token.
pub user_token: Option<String>,
/// Stripe production flag.
pub stripe_is_production: bool,
pub stripe_subscription_id: String,
pub stripe_customer_id: String,
pub stripe_product_id: String,
pub stripe_price_id: String,
/// The state of the subscription: active, cancelled, and other states.
pub stripe_subscription_status: StripeSubscriptionStatus,
/// We really only care about this when subscriptions are scheduled to be terminated.
/// Do we let the user continue receiving service in a grace period, or cut it off immediately
/// at the scheduled service end date?
/// (This avoids adding extra service days as a grace period.)
pub cancel_at_period_end: bool,
pub subscription_is_active: bool,
pub subscription_interval: StripeRecurringInterval,
/// When the subscription was "created" in Stripe (including any backdating)
pub subscription_start_date: NaiveDateTime,
// The updated billing period for the subscription
pub current_billing_period_start: NaiveDateTime,
pub current_billing_period_end: NaiveDateTime,
// When the subscription will be (or was) canceled
pub maybe_cancel_at: Option<NaiveDateTime>,
pub maybe_canceled_at: Option<NaiveDateTime>,
}
/// Extract only the subscription details we care about
/// This should be unit testable against raw webhook JSON.
pub fn subscription_summary_extractor(subscription: &Subscription) -> AnyhowResult<SubscriptionSummary> {
let subscription_id = subscription.id.to_string();
// NB: Our internal user token.
let maybe_user_token = subscription.metadata.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN)
.map(|t| t.to_string());
error!("SUBSCRIPTION EVENT USER TOKEN: {:?}", maybe_user_token);
if subscription.items.data.len() != 1 {
return Err(anyhow!("Too many items in subscription {} : {}",
subscription_id, subscription.items.data.len()));
}
let item = match subscription.items.data.first() {
None => return Err(anyhow!("Could not get first item in subscription {}", subscription_id)),
Some(line_item) => line_item,
};
let price = match &item.price {
None => return Err(anyhow!("Could not get item price in subscription {}", subscription_id)),
Some(price) => price,
};
let product = match &price.product {
None => return Err(anyhow!("Could not get product in subscription {}", subscription_id)),
Some(product) => product,
};
let recurring = match &price.recurring {
None => return Err(anyhow!("Could not get interval in subscription {}", subscription_id)),
Some(recurring) => recurring,
};
let start_date = NaiveDateTime::from_timestamp(subscription.start_date, 0);
let period_start = NaiveDateTime::from_timestamp(subscription.current_period_start, 0);
let period_end = NaiveDateTime::from_timestamp(subscription.current_period_end, 0);
let maybe_cancel_at = subscription.cancel_at.map(|t| NaiveDateTime::from_timestamp(t, 0));
let maybe_canceled_at = subscription.canceled_at.map(|t| NaiveDateTime::from_timestamp(t, 0));
Ok(SubscriptionSummary {
user_token: maybe_user_token,
stripe_subscription_id: subscription_id,
stripe_is_production: subscription.livemode,
stripe_customer_id: expand_customer_id(&subscription.customer),
stripe_subscription_status: subscription_status_to_reusable_type(subscription.status),
cancel_at_period_end: subscription.cancel_at_period_end,
stripe_product_id: expand_product_id(product),
stripe_price_id: price.id.to_string(),
subscription_is_active: subscription.status == SubscriptionStatus::Active,
subscription_interval: recurring_interval_to_reusable_type(recurring.interval),
subscription_start_date: start_date,
current_billing_period_start: period_start,
current_billing_period_end: period_end,
maybe_cancel_at,
maybe_canceled_at,
})
}
#[cfg(test)]
mod tests {
use stripe::Subscription;
use reusable_types::stripe::stripe_recurring_interval::StripeRecurringInterval;
use reusable_types::stripe::stripe_subscription_status::StripeSubscriptionStatus;
use crate::stripe::http_endpoints::webhook::webhook_event_handlers::customer_subscription::subscription_event_extractor::subscription_summary_extractor;
#[test]
fn test_subscription_summary_extractor_on_create_event() {
// NB: Actual raw test data from Stripe to our webhook
let json = r#"
{"id":"sub_1LhA3MEU5se17MekeWvmTNyk","automatic_tax":{"enabled":false},
"billing_cycle_anchor":1662979188,"cancel_at_period_end":false,
"collection_method":"charge_automatically","created":1662979188,"current_period_end":1665571188,
"current_period_start":1662979188,"customer":"cus_MQ03py0gWUh0Ox","default_tax_rates":[],
"items":{"data":[{"id":"si_MQ03hadVNfRnaS","created":1662979188,"deleted":false,"metadata":{},
"price":{"id":"price_1LeDnKEU5se17MekVr1iYYNf","active":true,"billing_scheme":"per_unit",
"created":1662278586,"currency":"usd","deleted":false,"livemode":false,"metadata":{},
"product":"prod_MMxi2J5y69VPbO","recurring":{"interval":"month","interval_count":1,
"usage_type":"licensed"},"tax_behavior":"exclusive","type":"recurring","unit_amount":700,
"unit_amount_decimal":"700"},"quantity":1,"subscription":"sub_1LhA3MEU5se17MekeWvmTNyk",
"tax_rates":[]}],"has_more":false,"total_count":1,
"url":"/v1/subscription_items?subscription=sub_1LhA3MEU5se17MekeWvmTNyk"},
"latest_invoice":"in_1LhA3MEU5se17MekUqOhZ9cu","livemode":false,
"metadata":{"email":"email@email.com","username":"username","user_token":"U:TOKEN"},
"payment_settings":{"save_default_payment_method":"off"},"start_date":1662979188,
"status":"incomplete"}
"#;
let subscription = serde_json::from_str::<Subscription>(json).unwrap();
let summary= subscription_summary_extractor(&subscription).unwrap();
assert_eq!(summary.user_token, Some("U:TOKEN".to_string()));
assert_eq!(summary.stripe_subscription_id, "sub_1LhA3MEU5se17MekeWvmTNyk".to_string());
assert_eq!(summary.stripe_subscription_status, StripeSubscriptionStatus::Incomplete);
assert_eq!(summary.cancel_at_period_end, false);
assert_eq!(summary.subscription_is_active, false);
assert_eq!(summary.stripe_customer_id, "cus_MQ03py0gWUh0Ox".to_string());
assert_eq!(summary.stripe_product_id, "prod_MMxi2J5y69VPbO".to_string());
assert_eq!(summary.stripe_price_id, "price_1LeDnKEU5se17MekVr1iYYNf".to_string());
assert_eq!(summary.subscription_interval, StripeRecurringInterval::Month);
assert_eq!(summary.stripe_is_production, false);
assert_eq!(summary.subscription_start_date.to_string(), "2022-09-12 10:39:48".to_string());
assert_eq!(summary.current_billing_period_start.to_string(), "2022-09-12 10:39:48".to_string());
assert_eq!(summary.current_billing_period_end.to_string(), "2022-10-12 10:39:48".to_string());
assert_eq!(summary.maybe_cancel_at, None);
assert_eq!(summary.maybe_canceled_at, None);
}
#[test]
fn test_subscription_summary_extractor_on_update_event() {
// NB: Actual raw test data from Stripe to our webhook
let json = r#"
{"id":"sub_1Lh1wvEU5se17Mekx72OzAzs","automatic_tax":{"enabled":false},
"billing_cycle_anchor":1662948037,
"cancel_at_period_end":false,"collection_method":"charge_automatically","created":1662948037,
"current_period_end":1665540037,"current_period_start":1662948037,
"customer":"cus_MPrgIen5Wh6QKG",
"default_payment_method":"pm_1Lh1wtEU5se17MekpirKtMJm","default_tax_rates":[],
"items":{"data":[{"id":"si_MPrgTAV333Nq7c","created":1662948037,"deleted":false,"metadata":{},
"price":{"id":"price_1LeDnKEU5se17MekVr1iYYNf","active":true,"billing_scheme":"per_unit",
"created":1662278586,"currency":"usd","deleted":false,"livemode":false,"metadata":{},
"product":"prod_MMxi2J5y69VPbO","recurring":{"interval":"month","interval_count":1,
"usage_type":"licensed"},"tax_behavior":"exclusive","type":"recurring","unit_amount":700,
"unit_amount_decimal":"700"},"quantity":1,"subscription":"sub_1Lh1wvEU5se17Mekx72OzAzs",
"tax_rates":[]}],"has_more":false,"total_count":1,
"url":"/v1/subscription_items?subscription=sub_1Lh1wvEU5se17Mekx72OzAzs"},
"latest_invoice":"in_1Lh1wvEU5se17MekU99NMi1W","livemode":false,
"metadata":{"username":"echelon","user_token":"U:token","email":"email@address.com"},
"payment_settings":{"save_default_payment_method":"off"},
"start_date":1662948037,"status":"active"}
"#;
let subscription = serde_json::from_str::<Subscription>(json).unwrap();
let summary= subscription_summary_extractor(&subscription).unwrap();
assert_eq!(summary.user_token, Some("U:token".to_string()));
assert_eq!(summary.stripe_subscription_id, "sub_1Lh1wvEU5se17Mekx72OzAzs".to_string());
assert_eq!(summary.stripe_subscription_status, StripeSubscriptionStatus::Active);
assert_eq!(summary.cancel_at_period_end, false);
assert_eq!(summary.subscription_is_active, true);
assert_eq!(summary.stripe_customer_id, "cus_MPrgIen5Wh6QKG".to_string());
assert_eq!(summary.stripe_product_id, "prod_MMxi2J5y69VPbO".to_string());
assert_eq!(summary.stripe_price_id, "price_1LeDnKEU5se17MekVr1iYYNf".to_string());
assert_eq!(summary.subscription_interval, StripeRecurringInterval::Month);
assert_eq!(summary.stripe_is_production, false);
assert_eq!(summary.subscription_start_date.to_string(), "2022-09-12 02:00:37".to_string());
assert_eq!(summary.current_billing_period_start.to_string(), "2022-09-12 02:00:37".to_string());
assert_eq!(summary.current_billing_period_end.to_string(), "2022-10-12 02:00:37".to_string());
assert_eq!(summary.maybe_cancel_at, None);
assert_eq!(summary.maybe_canceled_at, None);
}
#[test]
fn test_subscription_summary_extractor_on_control_panel_immediate_delete_event() {
// NB: Actual raw test data from Stripe to our webhook
let json = r#"
{"id":"sub_1LhA3MEU5se17MekeWvmTNyk","automatic_tax":{"enabled":false},
"billing_cycle_anchor":1662979188,"cancel_at_period_end":false,"canceled_at":1662979740,
"collection_method":"charge_automatically","created":1662979188,"current_period_end":1665571188,
"current_period_start":1662979188,"customer":"cus_MQ03py0gWUh0Ox",
"default_payment_method":"pm_1LhA3LEU5se17MekViNvoYAx","default_tax_rates":[],
"ended_at":1662979740,"items":{"data":[{"id":"si_MQ03hadVNfRnaS","created":1662979188,
"deleted":false,"metadata":{},"price":{"id":"price_1LeDnKEU5se17MekVr1iYYNf","active":true,
"billing_scheme":"per_unit","created":1662278586,"currency":"usd","deleted":false,
"livemode":false,"metadata":{},"product":"prod_MMxi2J5y69VPbO",
"recurring":{"interval":"month","interval_count":1,"usage_type":"licensed"},
"tax_behavior":"exclusive","type":"recurring","unit_amount":700,"unit_amount_decimal":"700"},
"quantity":1,"subscription":"sub_1LhA3MEU5se17MekeWvmTNyk","tax_rates":[]}],"has_more":false,
"total_count":1,
"url":"/v1/subscription_items?subscription=sub_1LhA3MEU5se17MekeWvmTNyk"},
"latest_invoice":"in_1LhA3MEU5se17MekUqOhZ9cu","livemode":false,
"metadata":{"email":"email@email.com","username":"username","user_token":"U:TOKEN"},
"payment_settings":{"save_default_payment_method":"off"},"start_date":1662979188,
"status":"canceled"}
"#;
let subscription = serde_json::from_str::<Subscription>(json).unwrap();
let summary= subscription_summary_extractor(&subscription).unwrap();
assert_eq!(summary.user_token, Some("U:TOKEN".to_string()));
assert_eq!(summary.stripe_subscription_id, "sub_1LhA3MEU5se17MekeWvmTNyk".to_string());
assert_eq!(summary.stripe_subscription_status, StripeSubscriptionStatus::Canceled);
assert_eq!(summary.cancel_at_period_end, false);
assert_eq!(summary.subscription_is_active, false);
assert_eq!(summary.stripe_customer_id, "cus_MQ03py0gWUh0Ox".to_string());
assert_eq!(summary.stripe_product_id, "prod_MMxi2J5y69VPbO".to_string());
assert_eq!(summary.stripe_price_id, "price_1LeDnKEU5se17MekVr1iYYNf".to_string());
assert_eq!(summary.subscription_interval, StripeRecurringInterval::Month);
assert_eq!(summary.stripe_is_production, false);
assert_eq!(summary.subscription_start_date.to_string(), "2022-09-12 10:39:48".to_string());
assert_eq!(summary.current_billing_period_start.to_string(), "2022-09-12 10:39:48".to_string());
assert_eq!(summary.current_billing_period_end.to_string(), "2022-10-12 10:39:48".to_string());
assert_eq!(summary.maybe_cancel_at, None);
assert_eq!(summary.maybe_canceled_at.map(|t| t.to_string()), Some("2022-09-12 10:49:00".to_string()));
}
#[test]
fn test_subscription_summary_extractor_on_control_panel_cancel_at_end_of_billing_period() {
// NB: This test data was taken from the Stripe control panel
let json = r#"
{
"id": "sub_1Lj3PKEU5se17MekHrr1isFH",
"object": "subscription",
"application": null,
"application_fee_percent": null,
"automatic_tax": {
"enabled": false
},
"billing_cycle_anchor": 1663430298,
"billing_thresholds": null,
"cancel_at": 1666022298,
"cancel_at_period_end": true,
"canceled_at": 1663575681,
"collection_method": "charge_automatically",
"created": 1663430298,
"currency": "usd",
"current_period_end": 1666022298,
"current_period_start": 1663430298,
"customer": "cus_MRxJT3KvFcAN6Z",
"days_until_due": null,
"default_payment_method": "pm_1Lj3PJEU5se17MekuWoSWsXu",
"default_source": null,
"default_tax_rates": [
],
"description": null,
"discount": null,
"ended_at": null,
"items": {
"object": "list",
"data": [
{
"id": "si_MRxJErFpEt6eYS",
"object": "subscription_item",
"billing_thresholds": null,
"created": 1663430298,
"metadata": {
},
"plan": {
"id": "price_1LeDnKEU5se17MekVr1iYYNf",
"object": "plan",
"active": true,
"aggregate_usage": null,
"amount": 700,
"amount_decimal": "700",
"billing_scheme": "per_unit",
"created": 1662278586,
"currency": "usd",
"interval": "month",
"interval_count": 1,
"livemode": false,
"metadata": {
},
"nickname": null,
"product": "prod_MMxi2J5y69VPbO",
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"price": {
"id": "price_1LeDnKEU5se17MekVr1iYYNf",
"object": "price",
"active": true,
"billing_scheme": "per_unit",
"created": 1662278586,
"currency": "usd",
"custom_unit_amount": null,
"livemode": false,
"lookup_key": null,
"metadata": {
},
"nickname": null,
"product": "prod_MMxi2J5y69VPbO",
"recurring": {
"aggregate_usage": null,
"interval": "month",
"interval_count": 1,
"trial_period_days": null,
"usage_type": "licensed"
},
"tax_behavior": "exclusive",
"tiers_mode": null,
"transform_quantity": null,
"type": "recurring",
"unit_amount": 700,
"unit_amount_decimal": "700"
},
"quantity": 1,
"subscription": "sub_1Lj3PKEU5se17MekHrr1isFH",
"tax_rates": [
]
}
],
"has_more": false,
"total_count": 1,
"url": "/v1/subscription_items?subscription=sub_1Lj3PKEU5se17MekHrr1isFH"
},
"latest_invoice": "in_1Lj3PKEU5se17MekkCjCtx0X",
"livemode": false,
"metadata": {
"email": "e@mail.com",
"user_token": "U:USER_TOKEN_VALUE",
"username": "rust_test"
},
"next_pending_invoice_item_invoice": null,
"pause_collection": null,
"payment_settings": {
"payment_method_options": null,
"payment_method_types": null,
"save_default_payment_method": "off"
},
"pending_invoice_item_interval": null,
"pending_setup_intent": null,
"pending_update": null,
"plan": {
"id": "price_1LeDnKEU5se17MekVr1iYYNf",
"object": "plan",
"active": true,
"aggregate_usage": null,
"amount": 700,
"amount_decimal": "700",
"billing_scheme": "per_unit",
"created": 1662278586,
"currency": "usd",
"interval": "month",
"interval_count": 1,
"livemode": false,
"metadata": {
},
"nickname": null,
"product": "prod_MMxi2J5y69VPbO",
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"quantity": 1,
"schedule": null,
"start_date": 1663430298,
"status": "active",
"test_clock": null,
"transfer_data": null,
"trial_end": null,
"trial_start": null
}
"#;
let subscription = serde_json::from_str::<Subscription>(json).unwrap();
let summary= subscription_summary_extractor(&subscription).unwrap();
// NB: It was scheduled to cancel at the end of the billing period and remains active for now
// These are the fields we don't expect to be impacted as the subscription remains active.
assert_eq!(summary.user_token, Some("U:USER_TOKEN_VALUE".to_string()));
assert_eq!(summary.stripe_subscription_id, "sub_1Lj3PKEU5se17MekHrr1isFH".to_string());
assert_eq!(summary.stripe_customer_id, "cus_MRxJT3KvFcAN6Z".to_string());
assert_eq!(summary.stripe_product_id, "prod_MMxi2J5y69VPbO".to_string());
assert_eq!(summary.stripe_price_id, "price_1LeDnKEU5se17MekVr1iYYNf".to_string());
assert_eq!(summary.subscription_interval, StripeRecurringInterval::Month);
assert_eq!(summary.stripe_is_production, false);
assert_eq!(summary.subscription_start_date.to_string(), "2022-09-17 15:58:18".to_string());
assert_eq!(summary.current_billing_period_start.to_string(), "2022-09-17 15:58:18".to_string());
assert_eq!(summary.current_billing_period_end.to_string(), "2022-10-17 15:58:18".to_string());
// NB: It was scheduled to cancel at the end of the billing period and remains active for now
// These are the updated fields of interest pertinent to this kind of update.
assert_eq!(summary.stripe_subscription_status, StripeSubscriptionStatus::Active);
assert_eq!(summary.cancel_at_period_end, true);
assert_eq!(summary.subscription_is_active, true);
assert_eq!(summary.maybe_cancel_at.map(|t| t.to_string()), Some("2022-10-17 15:58:18".to_string()));
assert_eq!(summary.maybe_canceled_at.map(|t| t.to_string()), Some("2022-09-19 08:21:21".to_string()));
}
}
@@ -0,0 +1,236 @@
use std::any::Any;
use chrono::{DateTime, Utc};
use log::error;
use stripe_shared::{Invoice, InvoiceStatus};
use errors::AnyhowResult;
use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary;
use crate::utils::expand_customer_id::expand_customer_id;
use crate::utils::expand_product_id::expand_product_id;
use crate::utils::expand_subscription_id::expand_subscription_id;
/// If we determine a subscription needs update, this is the struct we produce.
/// This should be easily unit testable.
pub struct SubscriptionDetails {
// Internal user token (not Stripe)
pub user_token: Option<String>,
pub stripe_is_production: bool,
pub stripe_invoice_id: String,
pub stripe_customer_id: Option<String>,
pub stripe_subscription_id: Option<String>,
pub stripe_product_id: Option<String>,
pub stripe_price_id: Option<String>,
pub subscription_is_active: bool,
pub billed_at: DateTime<Utc>,
/// Tell the update handler how many days in the future to set the plan.
pub subscription_interval: SubscriptionInterval,
/// Calculated from interval
pub subscription_expires_at: DateTime<Utc>,
}
/// If we determine a product was paid for, this is the struct we produce.
/// This should be easily unit testable.
pub struct ProductDetails {
pub user_token: Option<String>,
pub stripe_customer_id: Option<String>,
pub stripe_product_id: Option<String>,
}
/// For now we only handle one singular item at a time.
/// (Technically this can handle many products and subscriptions at once.)
pub enum InvoicePaidDetails {
Subscription(SubscriptionDetails),
Product(ProductDetails),
}
// Handle event type: 'invoice.paid'
// // https://stripe.com/docs/billing/subscriptions/webhooks :
//
// Sent when the invoice is successfully paid. You can provision access to your product when you
// receive this event and the subscription `status` is `active`.
//
// https://stripe.com/docs/billing/subscriptions/webhooks#active-subscriptions :
//
// 1. A few days prior to renewal, your site receives an invoice.upcoming event at the webhook
// endpoint. You can listen for this event to add extra invoice items to the upcoming invoice.
// 2. Your site receives an invoice.paid event.
// 3. Your webhook endpoint finds the customer the payment was made for.
// 4. Your webhook endpoint updates the customer’s access expiration date in your database to the
// appropriate date in the future (plus a day or two for leeway).
//
pub fn invoice_paid_handler(invoice: &Invoice) -> Result<StripeArtcraftWebhookSummary, StripeArtcraftWebhookError> {
let maybe_invoice_details = invoice_paid_extractor(invoice)
.map_err(|err| {
let reason = format!("Error extracting invoice details from 'invoice.paid' payload: {:?}", err);
error!("{}", reason);
StripeArtcraftWebhookError::ServerError(reason)
})?;
// TODO
Ok(StripeArtcraftWebhookSummary {
maybe_user_token: None,
maybe_event_entity_id: None,
maybe_stripe_customer_id: None,
action_was_taken: false,
should_ignore_retry: false,
})
}
fn invoice_paid_extractor(invoice: &Invoice) -> AnyhowResult<Option<InvoicePaidDetails>> {
match invoice.status {
Some(InvoiceStatus::Paid) => {}
_ => {
return Ok(None);
},
}
let invoice_id = invoice.id.to_string();
let is_production= invoice.livemode;
let paid_status = invoice.status;
let maybe_stripe_customer_id = invoice.customer
.as_ref()
.map(|c| expand_customer_id(c));
let maybe_stripe_subscription_id = invoice.subscription
.as_ref()
.map(|s| expand_subscription_id(s));
// TODO: We only handle a single line item for now.
let line_item = match invoice.lines.data.first() {
None => return Ok(None),
Some(line_item) => line_item,
};
//let paid_details = match line_item.type_ {
// InvoiceLineItemType::InvoiceItem => {
// return Ok(None); // TODO: Handle one-time payments.
// }
// InvoiceLineItemType::Subscription => {
// let maybe_subscription_id = line_item.subscription
// .as_ref().map(|s| expand_subscription_id(s));
// let maybe_product_id = line_item.price
// .as_ref()
// .and_then(|price| price.product.as_ref())
// .map(|product| expand_product_id(product));
// let maybe_price_id = line_item.price
// .as_ref()
// .map(|price| price.id.to_string());
// // NB: Internal user token (non-stripe)
// let maybe_user_token = line_item.metadata.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN)
// .map(|d| d.to_string());
// InvoicePaidDetails::Subscription(SubscriptionDetails {
// user_token: maybe_user_token,
// stripe_is_production: is_production,
// stripe_invoice_id: invoice_id,
// stripe_customer_id: maybe_stripe_customer_id,
// stripe_subscription_id: maybe_subscription_id,
// stripe_product_id: maybe_product_id,
// stripe_price_id: maybe_price_id,
// subscription_is_active: false,
// billed_at: Utc::now(),
// subscription_interval: Default::default(),
// subscription_expires_at: Utc::now(),
// })
// }
//};
/*
let maybe_subscription_id = line_item.subscription
.as_ref().map(|s| expand_subscription_id(s));
let maybe_product_id = line_item.price
.as_ref()
.and_then(|price| price.product.as_ref())
.map(|product| expand_product_id(product));
let maybe_price_id = line_item.price
.as_ref()
.map(|price| price.id.to_string());
// NB: Internal user token (non-stripe)
let maybe_user_token = line_item.metadata.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN)
.map(|d| d.to_string());
let paid_details = InvoicePaidDetails::Subscription(SubscriptionDetails {
user_token: maybe_user_token,
stripe_is_production: is_production,
stripe_invoice_id: invoice_id,
stripe_customer_id: maybe_stripe_customer_id,
stripe_subscription_id: maybe_subscription_id,
stripe_product_id: maybe_product_id,
stripe_price_id: maybe_price_id,
subscription_is_active: false,
billed_at: Utc::now(),
subscription_interval: Default::default(),
subscription_expires_at: Utc::now(),
});
Ok(Some(paid_details))
*/
Ok(None) // TODO
}
/*#[cfg(test)]
mod tests {
use anyhow::bail;
use stripe::Invoice;
use errors::AnyhowResult;
use crate::stripe::http_endpoints::webhook::webhook_event_handlers::invoice::invoice_paid_handler::invoice_paid_extractor;
use crate::stripe::http_endpoints::webhook::webhook_event_handlers::invoice::invoice_paid_handler::InvoicePaidDetails;
#[test]
fn test_invoice_paid_extractor() -> AnyhowResult<()> {
// NB: Not the prettiest, but this is a dev mode capture of a subscription "invoice.paid" event:
let invoice = r#"
{"id":"in_1Lh1R6EU5se17Mekksuvq20H","account_country":"US","account_name":"Storyteller","amount_due":700,"amount_paid":700,"amount_remaining":0,
"attempt_count":1,"attempted":true,"auto_advance":false,"automatic_tax":{"enabled":false},"billing_reason":"subscription_create","charge":"ch_3Lh1R7EU5se17Mek1StoPTgz",
"collection_method":"charge_automatically","created":1662946064,"currency":"usd","customer":"cus_MPr9UxT6b4SR5v","customer_address":{"country":"US",
"postal_code":"44444"},"customer_email":"foo@bar.com","customer_name":"Name","customer_tax_exempt":"none","customer_tax_ids":[],
"default_tax_rates":[],"deleted":false,"discounts":[],"ending_balance":0,
"hosted_invoice_url":"https://invoice.stripe.com/i/acct_1KZvWXEU5se17Mek/test_YWNjdF8xS1p2V1hFVTVzZTE3TWVrLF9NUHI5YjVkM0FrWExVQTVUQ1J1bFNaRWhqcHFlTU9VLDUzNDg2ODY402005IZTB0v6?s=ap",
"invoice_pdf":"https://pay.stripe.com/invoice/acct_1KZvWXEU5se17Mek/test_YWNjdF8xS1p2V1hFVTVzZTE3TWVrLF9NUHI5YjVkM0FrWExVQTVUQ1J1bFNaRWhqcHFlTU9VLDUzNDg2ODY402005IZTB0v6/pdf?s=ap",
"lines":{"data":[{"id":"il_1Lh1R6EU5se17MekXq9iJCZi",
"amount":700,"currency":"usd","description":"1 × FakeYou basic (at $7.00 / month)","discount_amounts":[],"discountable":true,"discounts":[],"livemode":false,
"metadata":{"username":"_username_","user_token":"U:TOKEN","email":"user@name.com"},"period":{"end":1665538064,"start":1662946064},"price":{"id":"price_1LeDnKEU5se17MekVr1iYYNf",
"active":true,"billing_scheme":"per_unit","created":1662278586,"currency":"usd","deleted":false,"livemode":false,"metadata":{},"product":"prod_MMxi2J5y69VPbO",
"recurring":{"interval":"month","interval_count":1,"usage_type":"licensed"},"tax_behavior":"exclusive","type":"recurring","unit_amount":700,"unit_amount_decimal":"700"},
"proration":false,"proration_details":{},"quantity":1,"subscription":"sub_1Lh1R6EU5se17Mekq9Ahj0gi",
"subscription_item":"si_MPr9vVB80UAGwp","tax_amounts":[],"tax_rates":[],"type":"subscription"}],"has_more":false,"total_count":1,
"url":"/v1/invoices/in_1Lh1R6EU5se17Mekksuvq20H/lines"},
"livemode":false,"metadata":{},"number":"4F52B2F7-0001","paid":true,"paid_out_of_band":false,"payment_intent":"pi_3Lh1R7EU5se17Mek1Bby1wL8",
"payment_settings":{},"period_end":1662946064,"period_start":1662946064,"post_payment_credit_notes_amount":0,
"pre_payment_credit_notes_amount":0,"starting_balance":0,"status":"paid",
"status_transitions":{"finalized_at":1662946064,"paid_at":1662946066},"subscription":"sub_1Lh1R6EU5se17Mekq9Ahj0gi",
"subtotal":700,"total":700,"total_discount_amounts":[],"total_tax_amounts":[]}
"#;
let invoice = serde_json::from_str::<Invoice>(invoice).unwrap();
let details = invoice_paid_extractor(&invoice).unwrap().unwrap();
match details {
InvoicePaidDetails::Product(_) => { bail!("This is supposed to be a subscription!"); }
InvoicePaidDetails::Subscription(subscription) => {
assert_eq!(subscription.stripe_invoice_id, "in_1Lh1R6EU5se17Mekksuvq20H".to_string());
assert_eq!(subscription.user_token, Some("U:TOKEN".to_string()));
}
}
Ok(())
}
}*/
@@ -0,0 +1,31 @@
use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary;
use crate::utils::expand_customer_id::expand_customer_id;
use stripe_shared::Invoice;
// Handle event type: 'invoice.payment_failed'
pub fn invoice_payment_failed_handler(invoice: &Invoice) -> Result<StripeArtcraftWebhookSummary, StripeArtcraftWebhookError> {
let invoice_id = invoice.id.to_string();
let paid_status = invoice.status;
// NB: We'll need this to send them to the "customer portal", which is how they can modify
// or cancel their subscriptions.
let maybe_stripe_customer_id = invoice.customer
.as_ref()
.map(|c| expand_customer_id(c));
// NB: Our internal user token.
let maybe_user_token = invoice.metadata
.as_ref()
.and_then(|m| m.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN).map(|t| t.to_string()));
Ok(StripeArtcraftWebhookSummary {
maybe_user_token,
maybe_event_entity_id: Some(invoice_id),
maybe_stripe_customer_id,
action_was_taken: false,
should_ignore_retry: false,
})
}
@@ -0,0 +1,31 @@
use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary;
use crate::utils::expand_customer_id::expand_customer_id;
use stripe_shared::Invoice;
// Handle event type: 'invoice.payment_succeeded'
pub fn invoice_payment_succeeded_handler(invoice: &Invoice) -> Result<StripeArtcraftWebhookSummary, StripeArtcraftWebhookError> {
let invoice_id = invoice.id.to_string();
let paid_status = invoice.status;
// NB: We'll need this to send them to the "customer portal", which is how they can modify
// or cancel their subscriptions.
let maybe_stripe_customer_id = invoice.customer
.as_ref()
.map(|c| expand_customer_id(c));
// NB: Our internal user token.
let maybe_user_token = invoice.metadata
.as_ref()
.and_then(|m| m.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN).map(|t| t.to_string()));
Ok(StripeArtcraftWebhookSummary {
maybe_user_token,
maybe_event_entity_id: Some(invoice_id),
maybe_stripe_customer_id,
action_was_taken: false,
should_ignore_retry: false,
})
}
@@ -0,0 +1,35 @@
use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary;
use crate::utils::expand_customer_id::expand_customer_id;
// Handle event type: 'invoice.updated'
// Sent when a payment succeeds or fails.
// If payment is successful the `paid` attribute is set to true and the `status` is `paid`.
// If payment fails, `paid` is set to false and the `status` remains `open`.
// Payment failures also trigger a invoice.payment_failed event.
pub fn invoice_updated_handler(invoice: &Invoice) -> Result<StripeArtcraftWebhookSummary, StripeArtcraftWebhookError> {
let invoice_id = invoice.id.to_string();
let is_paid = invoice.paid;
let invoice_status = invoice.status;
// NB: We'll need this to send them to the "customer portal", which is how they can modify
// or cancel their subscriptions.
let maybe_stripe_customer_id = invoice.customer
.as_ref()
.map(|c| expand_customer_id(c));
// NB: Our internal user token.
let maybe_user_token = invoice.metadata
.as_ref()
.and_then(|m| m.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN).map(|t| t.to_string()));
Ok(StripeArtcraftWebhookSummary {
maybe_user_token,
maybe_event_entity_id: Some(invoice_id),
maybe_stripe_customer_id,
action_was_taken: false,
should_ignore_retry: false,
})
}
@@ -0,0 +1,4 @@
pub(crate) mod invoice_paid_handler;
pub(crate) mod invoice_payment_failed;
pub(crate) mod invoice_payment_succeeded_handler;
pub(crate) mod invoice_updated_handler;
@@ -0,0 +1,9 @@
pub mod stripe_artcraft_webhook_error;
pub(crate) mod charge;
pub(crate) mod checkout_session;
pub(crate) mod customer;
pub(crate) mod customer_subscription;
pub(crate) mod invoice;
pub(crate) mod payment_intent;
pub(crate) mod stripe_artcraft_webhook_summary;
@@ -0,0 +1 @@
pub(crate) mod payment_intent_succeeded_handler;
@@ -0,0 +1,30 @@
use crate::configs::stripe_artcraft_metadata_keys::STRIPE_ARTCRAFT_METADATA_USER_TOKEN;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::endpoints::webhook::webhook_event_handlers::stripe_artcraft_webhook_summary::StripeArtcraftWebhookSummary;
use crate::utils::expand_customer_id::expand_customer_id;
use stripe_shared::PaymentIntent;
// Handle event type: 'payment_intent.succeeded'
pub fn payment_intent_succeeded_handler(payment_intent: &PaymentIntent) -> Result<StripeArtcraftWebhookSummary, StripeArtcraftWebhookError> {
let payment_intent_id = payment_intent.id.to_string();
let payment_intent_status = payment_intent.status;
// NB: We'll need this to send them to the "customer portal", which is how they can modify
// or cancel their subscriptions.
let maybe_stripe_customer_id = payment_intent.customer
.as_ref()
.map(|c| expand_customer_id(c));
// NB: Our internal user token.
let maybe_user_token = payment_intent.metadata.get(STRIPE_ARTCRAFT_METADATA_USER_TOKEN)
.map(|t| t.to_string());
Ok(StripeArtcraftWebhookSummary {
maybe_user_token,
maybe_event_entity_id: Some(payment_intent_id),
maybe_stripe_customer_id,
action_was_taken: false,
should_ignore_retry: false,
})
}
@@ -0,0 +1,34 @@
//! Returned by the webhook endpoint, but also dispatched event handler functions.
use std::fmt;
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, ResponseError};
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
use serde_derive::Serialize;
#[derive(Debug, Serialize)]
pub enum StripeArtcraftWebhookError {
BadRequest(String),
ServerError(String),
}
impl ResponseError for StripeArtcraftWebhookError {
fn status_code(&self) -> StatusCode {
match self {
StripeArtcraftWebhookError::BadRequest(String) => StatusCode::BAD_REQUEST,
StripeArtcraftWebhookError::ServerError(String) => 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 StripeArtcraftWebhookError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
@@ -0,0 +1,9 @@
use stripe_core::Customer;
use stripe_types::Expandable;
pub fn expand_customer_id(expandable_customer: &Expandable<Customer>) -> String {
match expandable_customer {
Expandable::Id(id) => id.to_string(),
Expandable::Object(customer) => customer.id.to_string(),
}
}
@@ -0,0 +1,8 @@
use stripe_types::Expandable;
pub fn expand_product_id(expandable_product: &Expandable<Product>) -> String {
match expandable_product {
Expandable::Id(id) => id.to_string(),
Expandable::Object(product) => product.id.to_string(),
}
}
@@ -0,0 +1,8 @@
use stripe_types::Expandable;
pub fn expand_subscription_id(expandable_subscription: &Expandable<Subscription>) -> String {
match expandable_subscription {
Expandable::Id(id) => id.to_string(),
Expandable::Object(subscription) => subscription.id.to_string(),
}
}
@@ -1,3 +1,6 @@
pub mod artcraft_stripe_config;
pub mod common_web_error;
pub mod verify_stripe_webhook_ip_address;
pub mod expand_customer_id;
pub mod expand_product_id;
pub mod expand_subscription_id;
pub mod verify_stripe_webhook_ip_address;