This commit is contained in:
Brandon Thomas committed 2025-09-16 22:56:38 -04:00
1 parent 780ee74b95
commit d4ec659568
6 files changed
+48 -165

No files matched your search

@@ -94,7 +94,7 @@ pub async fn stripe_artcraft_create_credits_pack_checkout_handler(
.map(|sub| sub.stripe_customer_id.as_str())
.map(|customer_id| CustomerId::from_str(customer_id).ok()) // NB: Infallible parse
.flatten();
if maybe_existing_stripe_customer_id.is_none() {
let result = find_user_stripe_customer_link_using_connection(
&user_metadata.user_token_typed,
@@ -114,32 +114,6 @@ pub async fn stripe_artcraft_create_credits_pack_checkout_handler(
let cancel_url = stripe_config.checkout_cancel_url.clone();
let checkout_session = {
// `client_reference_id`
// Stripe Docs:
// A unique string to reference the Checkout Session.
// This can be a customer ID, a cart ID, or similar, and can be used to reconcile the session
// with your internal systems.
//
// Our Notes:
// This gets reported back in the Checkout Session (and related webhooks) as
// `client_reference_id`. Passing the same ID on multiple checkouts does not unify or
// cross-correlate customers and only seems to be metadata for the checkout session itself.
// This is probably only useful for tracking checkout session engagement.
//params.client_reference_id = Some("SOME_INTERNAL_ID");
// `customer_email`
// Stripe Docs:
// If provided, this value will be used when the Customer object is created. If not provided,
// customers will be asked to enter their email address. Use this parameter to prefill
// customer data if you already have an email on file. To access information about the
// customer once a session is complete, use the customer field.
//
// Our Notes:
// This does not look up previous customers with the same email and will not unify or
// cross-correlate customers. By default the field will be un-editable in the checkout flow
// if this is specified.
//params.customer_email = Some("email@example.com");
let mut metadata = HashMap::new();
metadata.insert(STRIPE_ARTCRAFT_METADATA_USER_TOKEN.to_string(), user_metadata.user_token.to_string());
@@ -208,6 +182,9 @@ pub async fn stripe_artcraft_create_credits_pack_checkout_handler(
} else {
// If we don't have an existing customer_id, we want to create a new customer.
// If we don't do that, the "customer" winds up as a "Guest" customer.
// The only danger in forcefully creating a new customer is that we might create
// duplicates (which Stripe won't merge) if we can't catch and consolidate across
// all events.
checkout_builder = checkout_builder
.customer_creation(CreateCheckoutSessionCustomerCreation::Always);
}
@@ -1,34 +0,0 @@
use crate::endpoints::webhook::common::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::endpoints::webhook::common::webhook_event_log_summary::WebhookEventLogSummary;
use crate::utils::expand_ids::expand_customer_id::expand_customer_id;
use crate::utils::metadata::get_metadata_user_token::get_metadata_user_token;
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<WebhookEventLogSummary, 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
.as_ref()
.map(|metadata| get_metadata_user_token(metadata))
.flatten();
Ok(WebhookEventLogSummary {
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 @@
pub(crate) mod checkout_session_completed_handler;
@@ -1,7 +1,8 @@
use crate::endpoints::webhook::common::enriched_webhook_event::EnrichedWebhookEvent;
use crate::endpoints::webhook::common::stripe_artcraft_webhook_error::StripeArtcraftWebhookError;
use crate::endpoints::webhook::common::webhook_event_log_summary::WebhookEventLogSummary;
use crate::endpoints::webhook::webhook_event_enrichment::checkout_session::checkout_session_completed_handler::checkout_session_completed_handler;
use crate::endpoints::webhook::webhook_event_enrichment::customer::customer_created_handler::customer_created_handler;
use crate::endpoints::webhook::webhook_event_enrichment::customer::customer_updated_handler::customer_updated_handler;
use crate::endpoints::webhook::webhook_event_enrichment::customer_subscription::customer_subscription_created_handler::customer_subscription_created_handler;
use crate::endpoints::webhook::webhook_event_enrichment::customer_subscription::customer_subscription_deleted_handler::customer_subscription_deleted_handler;
use crate::endpoints::webhook::webhook_event_enrichment::customer_subscription::customer_subscription_updated_handler::customer_subscription_updated_handler;
@@ -16,20 +17,6 @@ use sqlx::pool::PoolConnection;
use sqlx::{MySql, Transaction};
use stripe::Client;
use stripe_webhook::{Event, EventObject};
use crate::endpoints::webhook::webhook_event_enrichment::customer::customer_created_handler::customer_created_handler;
use crate::endpoints::webhook::webhook_event_enrichment::customer::customer_updated_handler::customer_updated_handler;
/*
You usually need these:
1. Subscription events: to track contract state (active, canceled, past_due, trialing, etc.).
2. Invoice events: to track actual money flow and handle edge cases like failed payments, dunning, or refunds.
Minimal Set to Cover Payment Lifecycle
- invoice.paid → confirm credits or entitlements
- invoice.payment_failed → trigger dunning or downgrade access
- customer.subscription.deleted → stop service when canceled
- customer.subscription.updated → react to upgrades, downgrades, trial end
- (Optionally) checkout.session.completed → provision on signup
*/
pub async fn handle_webhook_event_enrichment(
stripe_event_descriptor: &StripeEventDescriptor,
@@ -64,6 +51,7 @@ pub async fn handle_webhook_event_enrichment(
// We'll ignore any payment intents from subscription invoices and let other event handlers
// fulfill subscription states.
info!("Event {}, data: {:?}", stripe_event_descriptor, payment_intent);
return payment_intent_succeeded_extractor(
&payment_intent,
server_environment,
@@ -71,6 +59,33 @@ pub async fn handle_webhook_event_enrichment(
).await;
}
// =============== INVOICES ===============
EventObject::InvoicePaid(invoice) => {
// Invoices are for subscriptions, not one-off charges and purchases.
// This is the *required* event that enables the subscription!
// These are fired on an interval - whatever the billing cadence is.
info!("Event: {}, data: {:?}", stripe_event_descriptor, invoice);
return invoice_paid_handler(
&stripe_event_descriptor,
&invoice,
server_environment,
stripe_client
).await;
}
EventObject::InvoicePaymentFailed(_invoice) => {
// TODO: Halt service.
// When we detect invoice payment failures, we need to disable subscription services.
// info!("Event: {:?}, data: {:?}", stripe_event_descriptor, invoice);
}
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
}
// =============== CUSTOMER SUBSCRIPTIONS ===============
EventObject::CustomerSubscriptionCreated(subscription) => {
@@ -106,91 +121,6 @@ pub async fn handle_webhook_event_enrichment(
).await;
}
// =============== INVOICES ===============
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
}
EventObject::InvoicePaid(invoice) => {
// Invoices are for subscriptions, not one-off charges and purchases.
// This is the *required* event that enables the subscription!
// These are fired on an interval - whatever the billing cadence is.
info!("Event: {}, data: {:?}", stripe_event_descriptor, invoice);
return invoice_paid_handler(
&stripe_event_descriptor,
&invoice,
server_environment,
stripe_client
).await;
}
/*EventObject::InvoicePaymentFailed(invoice) => {
// TODO: Halt service.
// TODO: in wallet schema, hold a 'lifetime_total_credits_used' - no, that wastes space. `credits_used` then SUM() for reports
// When we detect invoice payment failures, we need to disable
// subscription services.
info!("Event: {:?}, data: {:?}", stripe_event_descriptor, invoice);
webhook_summary = invoice_payment_failed_handler(&invoice)?;
}
*/
// =============== CUSTOMERS ===============
// EventObject::CustomerCreated(customer) => {
// // We use this simply to create a (totally optional) User <-> Customer link.
// // We already get that link from subscriptions, but this will help us associate
// // one-off payments and payment cards with a user.
// info!("Event: {}, data: {:?}", stripe_event_descriptor, customer);
// return customer_created_handler(customer);
// }
// EventObject::CustomerUpdated(customer) => {
// // We use this simply to create a (totally optional) User <-> Customer link.
// // We already get that link from subscriptions, but this will help us associate
// // one-off payments and payment cards with a user.
// info!("Event: {}, data: {:?}", stripe_event_descriptor, customer);
// return customer_updated_handler(customer);
// }
// =============== CHECKOUT SESSIONS ===============
// TODO: Provision the subscription here.
EventObject::CheckoutSessionCompleted(checkout_session) => {
info!("Event: {}, data: {:?}", webhook_payload.type_, checkout_session);
// TODO: DO NOT USE TO PROVISION SERVICE - USE `invoice.paid`.
//
// Checkout session completion is ideal for provisioning the service after checkout,
// but will not be used for monthly subscription renewals, which are handled by
// invoice.paid events.
//
// - `payment_status = {paid, unpaid, no_payment_required}` will let us know
// if the funds are in our account. NOTE (2): this will not be `paid` for free
// trials, but `invoice.paid` events will fire for free trials. (2): Async
// methods like ACH will have the status `unpaid` until the payment
// clears.
//
// - `subscription` will contain the ID of the subscription in subscription mode.
//
// - `metadata` - user token, etc.
//
// 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.
//
// Still need to ask about this. - does stripe say we can provision service here?
// Do we know if this happens for upgrades/downgrades/renewals?
// What about one-off payments?
/* TODO: Commented out for now
webhook_summary = checkout_session_completed_handler(checkout_session)?;
*/
}
// =============== Ignored ===============
_ => {
@@ -7,6 +7,18 @@ use stripe_webhook::{Event, EventObject};
pub fn ignore_known_unwanted_events(webhook_payload: &Event) -> Option<WebhookEventLogSummary> {
match webhook_payload.data.object {
EventObject::CheckoutSessionCompleted(_) => {
// We don't use this to provision service - we use `invoice.paid` instead.
//
// Checkout Session Completed `payment_status = {paid, unpaid, no_payment_required}`
// will let us know if the funds are in our account under SOME circumstances.
//
// NOTE however that:
// (1) this will not be `paid` for free trials, whereas `invoice.paid` events will fire for free trials.
// (2) async methods like ACH will have the status `unpaid` until the payment clears.
//
// Thus, we really don't want to use this event to provision service.
}
EventObject::ChargeSucceeded(_) => {
// charge.succeeded - building block event and also used for older legacy integrations.
// Use `invoice.paid` as billing success signal for subscriptions.
@@ -20,8 +32,8 @@ pub fn ignore_known_unwanted_events(webhook_payload: &Event) -> Option<WebhookEv
EventObject::CustomerDeleted(_) |
EventObject::CustomerUpdated(_) => {
// We don't need to know about Stripe customer object metadata changes,
// eg. stripe email change. Unfortunately, we also can't directly associate
// metadata with customers in a simple API call to create a checkout or portal
// eg. stripe email change. Unfortunately, we also can't directly associate
// metadata with customers in a simple API call to create a checkout or portal
// session, so these objects are kind of useless.
}
EventObject::PaymentIntentCreated(_) => {
@@ -1,7 +1,6 @@
mod ignore_known_unwanted_events;
pub (crate) mod handle_webhook_event_enrichment;
pub(crate) mod checkout_session;
pub(crate) mod customer_subscription;
pub(crate) mod invoice;
pub(crate) mod payment_intent;