mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 09:28:11 +00:00
wip
This commit is contained in:
1 parent
1e4fca06ee
commit
80f0235c50
39 files changed
+1573
-117
No files matched your search
Generated
+2
@@ -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",
|
||||
|
||||
+4
-4
@@ -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"] }
|
||||
|
||||
+1
-1
@@ -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,
|
||||
+41
@@ -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,
|
||||
|
||||
+2
-2
@@ -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;
|
||||
+99
-104
@@ -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();
|
||||
|
||||
+30
@@ -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,
|
||||
})
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub(crate) mod charge_succeeded_handler;
|
||||
+32
@@ -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,
|
||||
})
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub(crate) mod checkout_session_completed_handler;
|
||||
+25
@@ -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,
|
||||
})
|
||||
}
|
||||
+24
@@ -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,
|
||||
})
|
||||
}
|
||||
+24
@@ -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,
|
||||
})
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
pub(crate) mod customer_created_handler;
|
||||
pub(crate) mod customer_deleted_handler;
|
||||
pub(crate) mod customer_updated_handler;
|
||||
+32
@@ -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
|
||||
}
|
||||
+8
@@ -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";
|
||||
+119
@@ -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)
|
||||
}
|
||||
+116
@@ -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,
|
||||
})
|
||||
}
|
||||
+127
@@ -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,
|
||||
})
|
||||
}
|
||||
+6
@@ -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;
|
||||
+428
@@ -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()));
|
||||
}
|
||||
}
|
||||
+236
@@ -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(())
|
||||
}
|
||||
}*/
|
||||
+31
@@ -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,
|
||||
})
|
||||
}
|
||||
+31
@@ -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,
|
||||
})
|
||||
}
|
||||
+35
@@ -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,
|
||||
})
|
||||
}
|
||||
+4
@@ -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;
|
||||
+9
@@ -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;
|
||||
+1
@@ -0,0 +1 @@
|
||||
pub(crate) mod payment_intent_succeeded_handler;
|
||||
+30
@@ -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,
|
||||
})
|
||||
}
|
||||
+34
@@ -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)
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -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;
|
||||
Reference in new issue
Block a user