//! Stripe payment processing via Connect Direct Charges. //! //! Wraps the Stripe API for one-time purchases and recurring subscriptions //! using the Direct Charges pattern: payments are created directly on the //! creator's connected Stripe account with no `application_fee_amount`, //! enforcing MakeNotWork's 0% platform fee promise. The only deduction //! creators see is Stripe's own processing fee (~3%). //! //! Key responsibilities: //! - Standard connected account creation and onboarding links //! - One-time purchase and subscription Checkout Session creation //! - Webhook signature verification (v1 and v2 thin events) //! - Event extraction helpers for checkout, subscription, invoice, account, //! and refund webhook events //! - Subscription product and price creation on connected accounts mod checkout; mod checkout_metadata; mod connect; pub mod fan_ops; pub mod synckit_app_pricing; pub mod synckit_billing; mod webhooks; pub use checkout::*; pub use checkout_metadata::*; pub use synckit_app_pricing::{ ANNUAL_MULTIPLIER, MAX_CAP_BYTES, MIN_CAP_BYTES, MIN_CHARGE_CENTS, SyncBillingInterval, quote_price_cents, }; pub use synckit_billing::SynckitSubResult; pub use webhooks::*; use std::time::Duration; use crate::config::StripeConfig; use stripe::{Client, ClientBuilder}; /// Per-attempt HTTP timeout for outbound Stripe calls. The async client has no /// timeout by default, so a hung connection would otherwise stall the caller /// indefinitely, on a webhook handler that holds the response open and invites /// Stripe's retry storm. 30s matches async-stripe's own blocking-client default; /// the request strategy still retries a timed-out attempt where permitted. const STRIPE_HTTP_TIMEOUT: Duration = Duration::from_secs(30); /// Stripe client wrapper for payment operations #[derive(Clone)] pub struct StripeClient { pub(crate) client: Client, pub(crate) config: StripeConfig, } impl StripeClient { /// Create a new Stripe client from configuration. /// /// Fallible because the builder validates the client config; a build error is /// an internal invariant violation (the secret key comes from validated /// config), so it is classified `Internal` and surfaces at boot. pub fn new(config: &StripeConfig) -> Result { let client = ClientBuilder::new(&config.secret_key) .timeout(STRIPE_HTTP_TIMEOUT) .build() .map_err(|e| { AppError::Internal(anyhow::anyhow!("failed to build Stripe client: {e}")) })?; Ok(StripeClient { client, config: config.clone(), }) } /// Parse a connected account ID string into an `AccountId`. /// /// Account IDs are read from our own DB (`users.stripe_account_id`), so a /// parse failure is an internal invariant violation rather than bad user /// input, classify it `Internal` and keep the underlying error for ops. pub(crate) fn parse_account_id(account_id: &str) -> Result { account_id.parse().map_err(|e| { AppError::Internal(anyhow::anyhow!( "Invalid Stripe account ID '{account_id}': {e}" )) }) } } use crate::error::{AppError, Result}; /// Simplified checkout result: what handlers need from Stripe sessions. pub struct CheckoutResult { pub id: String, pub url: Option, } /// Simplified balance: what handlers need from Stripe balance. pub struct BalanceSummary { pub available_cents: i64, pub pending_cents: i64, } /// Payment provider abstraction for checkout, connect, and webhook operations. #[async_trait::async_trait] pub trait PaymentProvider: Send + Sync { // Checkout async fn create_checkout_session( &self, params: &CheckoutParams<'_>, ) -> crate::error::Result; async fn create_guest_checkout_session( &self, params: &GuestCheckoutParams<'_>, ) -> crate::error::Result; async fn create_subscription_checkout_session( &self, params: &SubscriptionCheckoutParams<'_>, ) -> crate::error::Result; async fn create_tip_checkout_session( &self, params: &TipCheckoutParams<'_>, ) -> crate::error::Result; async fn create_fan_plus_checkout_session( &self, price_id: &str, user_id: crate::db::UserId, success_url: &str, cancel_url: &str, ) -> crate::error::Result; async fn create_creator_tier_checkout_session( &self, price_id: &str, user_id: crate::db::UserId, tier: &str, success_url: &str, cancel_url: &str, trial_days: Option, ) -> crate::error::Result; async fn create_synckit_app_sub_checkout_session( &self, params: &SynckitAppSubCheckoutParams<'_>, ) -> crate::error::Result; async fn create_cart_checkout_session( &self, params: &CartCheckoutParams<'_>, ) -> crate::error::Result; // Connect async fn create_connect_account( &self, email: &str, ) -> crate::error::Result; async fn create_account_link( &self, account_id: &str, return_url: &str, refresh_url: &str, ) -> crate::error::Result; async fn fetch_account(&self, account_id: &str) -> crate::error::Result; async fn create_subscription_product_and_price( &self, connected_account_id: &str, tier_name: &str, tier_description: Option<&str>, price_cents: i64, ) -> crate::error::Result<(String, String)>; async fn get_balance(&self, account_id: &str) -> crate::error::Result; // Subscription lifecycle async fn pause_subscription( &self, stripe_sub_id: &str, connected_account_id: &str, ) -> crate::error::Result<()>; async fn resume_subscription( &self, stripe_sub_id: &str, connected_account_id: &str, ) -> crate::error::Result<()>; async fn cancel_subscription( &self, stripe_sub_id: &str, connected_account_id: &str, ) -> crate::error::Result<()>; /// Set or clear `cancel_at_period_end` on a fan subscription (for creator pause/resume). async fn set_cancel_at_period_end( &self, stripe_sub_id: &str, connected_account_id: &str, cancel: bool, ) -> crate::error::Result<()>; /// Cancel a platform-level subscription (creator tier, Fan+). Not on a connected account. async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> crate::error::Result<()>; /// Set or clear `cancel_at_period_end` on a platform subscription (Fan+, creator tier). async fn set_platform_cancel_at_period_end( &self, stripe_sub_id: &str, cancel: bool, ) -> crate::error::Result<()>; /// Create a Stripe-hosted billing portal session. Returns the URL to redirect to. async fn create_billing_portal_session( &self, stripe_customer_id: &str, return_url: &str, ) -> crate::error::Result; // Refunds, line-scoped: refunds `amount_cents` of the shared PaymentIntent // and tags the refund with the transaction id so the refund.created webhook // marks/revokes exactly that line (cart orders share one PaymentIntent). async fn create_refund_for_transaction( &self, payment_intent_id: &str, connected_account_id: &str, amount_cents: i64, transaction_id: crate::db::TransactionId, ) -> crate::error::Result<()>; // Platform-funded credit reimbursement, a platform -> connected transfer that // makes the creator whole for a Fan+ credit applied to their sale (MNW funds it). // Deterministic idempotency key keeps replays/retries from double-paying. // Returns the transfer id so it can be reversed if the sale is refunded. async fn create_platform_credit_transfer( &self, connected_account_id: &str, amount_cents: i64, transaction_id: crate::db::TransactionId, ) -> crate::error::Result; // Reverse a settled platform-credit transfer when its sale is refunded, // clawing the reimbursement back from the connected account to MNW. // Deterministic idempotency key keeps replays/retries from clawing back twice. async fn create_platform_credit_reversal( &self, transfer_id: &str, amount_cents: i64, transaction_id: crate::db::TransactionId, ) -> crate::error::Result<()>; // Webhooks fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result; fn verify_webhook_v2( &self, payload: &str, signature: &str, ) -> crate::error::Result; // SyncKit v2 developer billing, one customer + subscription per app, // separate from creator-tier and Fan+ subscriptions. See // `synckit_billing.rs` for the rationale on per-app customers. async fn create_synckit_customer( &self, developer_user_id: crate::db::UserId, app_id: crate::db::SyncAppId, email: &str, app_name: &str, ) -> crate::error::Result; async fn create_synckit_subscription( &self, customer_id: &str, app_id: crate::db::SyncAppId, app_name: &str, price_cents: i64, ) -> crate::error::Result; async fn update_synckit_subscription_price( &self, subscription_id: &str, new_price_cents: i64, app_name: &str, ) -> crate::error::Result<()>; /// Re-price an end-user SyncKit app subscription. Used by the cap-change /// path; takes effect at next billing cycle (no proration). async fn update_synckit_app_sub_price( &self, subscription_id: &str, new_price_cents: i64, interval: SyncBillingInterval, product_name: &str, ) -> crate::error::Result<()>; async fn cancel_synckit_subscription(&self, subscription_id: &str) -> crate::error::Result<()>; async fn create_synckit_billing_portal( &self, customer_id: &str, return_url: &str, ) -> crate::error::Result; } #[async_trait::async_trait] impl PaymentProvider for StripeClient { async fn create_checkout_session( &self, params: &CheckoutParams<'_>, ) -> crate::error::Result { let session = StripeClient::create_checkout_session(self, params).await?; Ok(CheckoutResult { id: session.id.to_string(), url: session.url, }) } async fn create_guest_checkout_session( &self, params: &GuestCheckoutParams<'_>, ) -> crate::error::Result { let session = StripeClient::create_guest_checkout_session(self, params).await?; Ok(CheckoutResult { id: session.id.to_string(), url: session.url, }) } async fn create_subscription_checkout_session( &self, params: &SubscriptionCheckoutParams<'_>, ) -> crate::error::Result { let session = StripeClient::create_subscription_checkout_session(self, params).await?; Ok(CheckoutResult { id: session.id.to_string(), url: session.url, }) } async fn create_tip_checkout_session( &self, params: &TipCheckoutParams<'_>, ) -> crate::error::Result { let session = StripeClient::create_tip_checkout_session(self, params).await?; Ok(CheckoutResult { id: session.id.to_string(), url: session.url, }) } async fn create_fan_plus_checkout_session( &self, price_id: &str, user_id: crate::db::UserId, success_url: &str, cancel_url: &str, ) -> crate::error::Result { let session = StripeClient::create_fan_plus_checkout_session( self, price_id, user_id, success_url, cancel_url, ) .await?; Ok(CheckoutResult { id: session.id.to_string(), url: session.url, }) } async fn create_creator_tier_checkout_session( &self, price_id: &str, user_id: crate::db::UserId, tier: &str, success_url: &str, cancel_url: &str, trial_days: Option, ) -> crate::error::Result { let session = StripeClient::create_creator_tier_checkout_session( self, price_id, user_id, tier, success_url, cancel_url, trial_days, ) .await?; Ok(CheckoutResult { id: session.id.to_string(), url: session.url, }) } async fn create_synckit_app_sub_checkout_session( &self, params: &SynckitAppSubCheckoutParams<'_>, ) -> crate::error::Result { let session = StripeClient::create_synckit_app_sub_checkout_session(self, params).await?; Ok(CheckoutResult { id: session.id.to_string(), url: session.url, }) } async fn create_cart_checkout_session( &self, params: &CartCheckoutParams<'_>, ) -> crate::error::Result { let session = StripeClient::create_cart_checkout_session(self, params).await?; Ok(CheckoutResult { id: session.id.to_string(), url: session.url, }) } async fn create_connect_account( &self, email: &str, ) -> crate::error::Result { StripeClient::create_connect_account(self, email).await } async fn create_account_link( &self, account_id: &str, return_url: &str, refresh_url: &str, ) -> crate::error::Result { StripeClient::create_account_link(self, account_id, return_url, refresh_url).await } async fn fetch_account(&self, account_id: &str) -> crate::error::Result { StripeClient::fetch_account(self, account_id).await } async fn create_subscription_product_and_price( &self, connected_account_id: &str, tier_name: &str, tier_description: Option<&str>, price_cents: i64, ) -> crate::error::Result<(String, String)> { StripeClient::create_subscription_product_and_price( self, connected_account_id, tier_name, tier_description, price_cents, ) .await } async fn get_balance(&self, account_id: &str) -> crate::error::Result { let balance = self.get_connected_account_balance(account_id).await?; let available_cents: i64 = balance .available .iter() .filter(|b| b.currency == stripe_types::Currency::USD) .map(|b| b.amount) .sum(); let pending_cents: i64 = balance .pending .iter() .filter(|b| b.currency == stripe_types::Currency::USD) .map(|b| b.amount) .sum(); Ok(BalanceSummary { available_cents, pending_cents, }) } async fn pause_subscription( &self, stripe_sub_id: &str, connected_account_id: &str, ) -> crate::error::Result<()> { StripeClient::pause_subscription(self, stripe_sub_id, connected_account_id).await } async fn resume_subscription( &self, stripe_sub_id: &str, connected_account_id: &str, ) -> crate::error::Result<()> { StripeClient::resume_subscription(self, stripe_sub_id, connected_account_id).await } async fn cancel_subscription( &self, stripe_sub_id: &str, connected_account_id: &str, ) -> crate::error::Result<()> { StripeClient::cancel_subscription(self, stripe_sub_id, connected_account_id).await } async fn set_cancel_at_period_end( &self, stripe_sub_id: &str, connected_account_id: &str, cancel: bool, ) -> crate::error::Result<()> { StripeClient::set_cancel_at_period_end(self, stripe_sub_id, connected_account_id, cancel) .await } async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> crate::error::Result<()> { StripeClient::cancel_platform_subscription(self, stripe_sub_id).await } async fn set_platform_cancel_at_period_end( &self, stripe_sub_id: &str, cancel: bool, ) -> crate::error::Result<()> { StripeClient::set_platform_cancel_at_period_end(self, stripe_sub_id, cancel).await } async fn create_billing_portal_session( &self, stripe_customer_id: &str, return_url: &str, ) -> crate::error::Result { StripeClient::create_billing_portal_session(self, stripe_customer_id, return_url).await } async fn create_refund_for_transaction( &self, payment_intent_id: &str, connected_account_id: &str, amount_cents: i64, transaction_id: crate::db::TransactionId, ) -> crate::error::Result<()> { StripeClient::create_refund_for_transaction( self, payment_intent_id, connected_account_id, amount_cents, transaction_id, ) .await } async fn create_platform_credit_transfer( &self, connected_account_id: &str, amount_cents: i64, transaction_id: crate::db::TransactionId, ) -> crate::error::Result { StripeClient::create_platform_credit_transfer( self, connected_account_id, amount_cents, transaction_id, ) .await } async fn create_platform_credit_reversal( &self, transfer_id: &str, amount_cents: i64, transaction_id: crate::db::TransactionId, ) -> crate::error::Result<()> { StripeClient::create_platform_credit_reversal( self, transfer_id, amount_cents, transaction_id, ) .await } fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result { StripeClient::verify_webhook(self, payload, signature) } fn verify_webhook_v2( &self, payload: &str, signature: &str, ) -> crate::error::Result { StripeClient::verify_webhook_v2(self, payload, signature) } async fn create_synckit_customer( &self, developer_user_id: crate::db::UserId, app_id: crate::db::SyncAppId, email: &str, app_name: &str, ) -> crate::error::Result { StripeClient::create_synckit_customer(self, developer_user_id, app_id, email, app_name) .await } async fn create_synckit_subscription( &self, customer_id: &str, app_id: crate::db::SyncAppId, app_name: &str, price_cents: i64, ) -> crate::error::Result { StripeClient::create_synckit_subscription(self, customer_id, app_id, app_name, price_cents) .await } async fn update_synckit_subscription_price( &self, subscription_id: &str, new_price_cents: i64, app_name: &str, ) -> crate::error::Result<()> { StripeClient::update_synckit_subscription_price( self, subscription_id, new_price_cents, app_name, ) .await } async fn update_synckit_app_sub_price( &self, subscription_id: &str, new_price_cents: i64, interval: SyncBillingInterval, product_name: &str, ) -> crate::error::Result<()> { StripeClient::update_synckit_app_sub_price( self, subscription_id, new_price_cents, interval, product_name, ) .await } async fn cancel_synckit_subscription(&self, subscription_id: &str) -> crate::error::Result<()> { StripeClient::cancel_synckit_subscription(self, subscription_id).await } async fn create_synckit_billing_portal( &self, customer_id: &str, return_url: &str, ) -> crate::error::Result { StripeClient::create_synckit_billing_portal(self, customer_id, return_url).await } }