//! Scripted payment providers, for tests that need one that behaves on cue. //! //! `pub(crate)` rather than private: `payments/fan_ops.rs` builds a //! `ScriptedProvider` too, and a fixture two modules share is a module, not a //! copy in each. //! A crate-visible [`PaymentProvider`] double for lib tests. //! //! The integration suite already has `MockPaymentProvider` //! (`tests/harness/stripe.rs`), which is richer: it captures checkout //! sessions and signs webhooks. It lives in a separate test binary, so a //! `--lib` test cannot reach it, and this is deliberately the smaller //! thing. It answers the subscription-lifecycle calls and panics on //! everything else, which is enough to test the code that fans those out //! without a database, a router or a Stripe key. //! //! Implement a method here when a lib test needs it. Growing this toward //! the harness's copy would give the crate two mocks to keep in agreement, //! which is the imitation-oracle failure wiki `testing-posture` describes. use std::collections::HashSet; use std::sync::Mutex; use super::*; /// Records every subscription op it is asked for, and fails the ones whose /// subscription id was listed as failing. #[derive(Default)] pub(crate) struct ScriptedProvider { failing: HashSet, calls: Mutex>, } impl ScriptedProvider { /// Every call succeeds. pub(crate) fn healthy() -> Self { Self::default() } /// Every call succeeds except those naming one of `sub_ids`. pub(crate) fn failing(sub_ids: impl IntoIterator) -> Self { Self { failing: sub_ids.into_iter().map(str::to_owned).collect(), calls: Mutex::new(Vec::new()), } } /// `(op, subscription id)` in the order they were applied. pub(crate) fn calls(&self) -> Vec<(&'static str, String)> { self.calls .lock() .expect("no test panics while holding this") .clone() } fn record(&self, op: &'static str, sub_id: &str) -> crate::error::Result<()> { self.calls .lock() .expect("no test panics while holding this") .push((op, sub_id.to_string())); if self.failing.contains(sub_id) { return Err(crate::error::AppError::BadRequest(format!( "scripted failure for {sub_id}" ))); } Ok(()) } } /// The methods no lib test drives yet. A call is a bug in the test, not a /// condition to handle, so it panics rather than returning an error the /// code under test would quietly count as a Stripe failure. macro_rules! unused { ($($name:ident),+ $(,)?) => { $( #[allow(unused_variables)] fn $name(&self) -> ! { unimplemented!( "ScriptedProvider::{} is not implemented; add it if a lib test needs it", stringify!($name) ) } )+ }; } impl ScriptedProvider { unused!( create_checkout_session, create_guest_checkout_session, create_subscription_checkout_session, create_tip_checkout_session, create_fan_plus_checkout_session, create_creator_tier_checkout_session, create_synckit_app_sub_checkout_session, create_cart_checkout_session, create_connect_account, create_account_link, fetch_account, create_subscription_product_and_price, get_balance, cancel_platform_subscription, set_platform_cancel_at_period_end, create_billing_portal_session, create_refund_for_transaction, create_platform_credit_transfer, create_platform_credit_reversal, verify_webhook, verify_webhook_v2, normalize_webhook, create_synckit_customer, create_synckit_subscription, update_synckit_subscription_price, update_synckit_app_sub_price, cancel_synckit_subscription, create_synckit_billing_portal, ); } #[async_trait::async_trait] impl PaymentProvider for ScriptedProvider { // ── what the fan-out drives ── async fn pause_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> { self.record("pause", sub) } async fn resume_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> { self.record("resume", sub) } async fn cancel_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> { self.record("cancel", sub) } async fn set_cancel_at_period_end( &self, sub: &str, _account: &str, cancel: bool, ) -> crate::error::Result<()> { self.record( if cancel { "set_cancel_at_period_end" } else { "clear_cancel_at_period_end" }, sub, ) } // ── everything else ── async fn create_checkout_session( &self, _params: &CheckoutParams<'_>, ) -> crate::error::Result { ScriptedProvider::create_checkout_session(self) } async fn create_guest_checkout_session( &self, _params: &GuestCheckoutParams<'_>, ) -> crate::error::Result { ScriptedProvider::create_guest_checkout_session(self) } async fn create_subscription_checkout_session( &self, _params: &SubscriptionCheckoutParams<'_>, ) -> crate::error::Result { ScriptedProvider::create_subscription_checkout_session(self) } async fn create_tip_checkout_session( &self, _params: &TipCheckoutParams<'_>, ) -> crate::error::Result { ScriptedProvider::create_tip_checkout_session(self) } 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 { ScriptedProvider::create_fan_plus_checkout_session(self) } 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 { ScriptedProvider::create_creator_tier_checkout_session(self) } async fn create_synckit_app_sub_checkout_session( &self, _params: &SynckitAppSubCheckoutParams<'_>, ) -> crate::error::Result { ScriptedProvider::create_synckit_app_sub_checkout_session(self) } async fn create_cart_checkout_session( &self, _params: &CartCheckoutParams<'_>, ) -> crate::error::Result { ScriptedProvider::create_cart_checkout_session(self) } async fn create_connect_account( &self, _email: &str, ) -> crate::error::Result { ScriptedProvider::create_connect_account(self) } async fn get_balance( &self, _account_id: &str, _currency: crate::currency::SettlementCurrency, ) -> crate::error::Result { ScriptedProvider::get_balance(self) } async fn cancel_platform_subscription(&self, _sub: &str) -> crate::error::Result<()> { ScriptedProvider::cancel_platform_subscription(self) } async fn set_platform_cancel_at_period_end( &self, _sub: &str, _cancel: bool, ) -> crate::error::Result<()> { ScriptedProvider::set_platform_cancel_at_period_end(self) } fn verify_webhook( &self, _payload: &str, _signature: &str, ) -> crate::error::Result { ScriptedProvider::verify_webhook(self) } fn verify_webhook_v2( &self, _payload: &str, _signature: &str, ) -> crate::error::Result { ScriptedProvider::verify_webhook_v2(self) } fn normalize_webhook(&self, _event: UntypedEvent) -> crate::error::Result { ScriptedProvider::normalize_webhook(self) } async fn update_synckit_subscription_price( &self, _subscription_id: &str, _new_price_cents: i64, _app_name: &str, ) -> crate::error::Result<()> { ScriptedProvider::update_synckit_subscription_price(self) } async fn update_synckit_app_sub_price( &self, _subscription_id: &str, _new_price_cents: i64, _interval: SyncBillingInterval, _product_name: &str, ) -> crate::error::Result<()> { ScriptedProvider::update_synckit_app_sub_price(self) } async fn cancel_synckit_subscription(&self, _sub: &str) -> crate::error::Result<()> { ScriptedProvider::cancel_synckit_subscription(self) } } // ── The capability extensions, every one of them a panic: no lib test // drives an extension yet, and `ScriptedProvider` implements them so the // double stays wirable wherever a full provider is expected. #[async_trait::async_trait] impl HostedPortal for ScriptedProvider { async fn create_billing_portal_session( &self, _customer_id: &str, _return_url: &str, ) -> crate::error::Result { ScriptedProvider::create_billing_portal_session(self) } async fn create_synckit_billing_portal( &self, _customer_id: &str, _return_url: &str, ) -> crate::error::Result { ScriptedProvider::create_synckit_billing_portal(self) } } #[async_trait::async_trait] impl ConnectOnboarding for ScriptedProvider { async fn create_account_link( &self, _account_id: &str, _return_url: &str, _refresh_url: &str, ) -> crate::error::Result { ScriptedProvider::create_account_link(self) } async fn fetch_account(&self, _account_id: &str) -> crate::error::Result { ScriptedProvider::fetch_account(self) } } #[async_trait::async_trait] impl Catalogue for ScriptedProvider { async fn create_subscription_product_and_price( &self, _connected_account_id: &str, _tier_name: &str, _tier_description: Option<&str>, _price_cents: i64, _currency: crate::currency::SettlementCurrency, ) -> crate::error::Result<(String, String)> { ScriptedProvider::create_subscription_product_and_price(self) } } #[async_trait::async_trait] impl Refundable for ScriptedProvider { 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<()> { ScriptedProvider::create_refund_for_transaction(self) } } #[async_trait::async_trait] impl PlatformTransfers for ScriptedProvider { async fn create_platform_credit_transfer( &self, _connected_account_id: &str, _amount_cents: i64, _transaction_id: crate::db::TransactionId, _currency: crate::currency::SettlementCurrency, ) -> crate::error::Result { ScriptedProvider::create_platform_credit_transfer(self) } async fn create_platform_credit_reversal( &self, _transfer_id: &str, _amount_cents: i64, _transaction_id: crate::db::TransactionId, ) -> crate::error::Result<()> { ScriptedProvider::create_platform_credit_reversal(self) } } #[async_trait::async_trait] impl CustodialCustomers for ScriptedProvider { 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 { ScriptedProvider::create_synckit_customer(self) } async fn create_synckit_subscription( &self, _customer_id: &str, _app_id: crate::db::SyncAppId, _app_name: &str, _price_cents: i64, ) -> crate::error::Result { ScriptedProvider::create_synckit_subscription(self) } }