//! Stripe test helpers, webhook signature computation and mock payment provider. use super::faults::Faults; use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use makenotwork::error::{AppError, Result}; use makenotwork::payments::{ AccountUpdate, BalanceSummary, CheckoutParams, CheckoutResult, PaymentProvider, SubscriptionCheckoutParams, TipCheckoutParams, }; #[allow(dead_code)] type HmacSha256 = Hmac; /// Known test webhook secret used by the test harness `with_stripe()` builder. #[allow(dead_code)] pub(crate) const TEST_WEBHOOK_SECRET: &str = "whsec_test_secret"; /// Known test webhook secret for v2 thin events. #[allow(dead_code)] pub(crate) const TEST_WEBHOOK_SECRET_V2: &str = "whsec_test_secret_v2"; /// Compute a valid `Stripe-Signature` header value for the given payload. /// /// Mirrors Stripe's signing scheme: /// signed_payload = "{timestamp}.{payload}" /// signature = HMAC-SHA256(secret, signed_payload) /// header = "t={timestamp},v1={hex(signature)}" #[allow(dead_code)] pub(crate) fn sign_webhook_payload(payload: &str, secret: &str) -> String { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); sign_webhook_payload_with_timestamp(payload, secret, timestamp) } /// Like [`sign_webhook_payload`] but with an explicit timestamp (seconds since epoch). #[allow(dead_code)] pub(crate) fn sign_webhook_payload_with_timestamp( payload: &str, secret: &str, timestamp: u64, ) -> String { let signed_payload = format!("{timestamp}.{payload}"); let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length"); mac.update(signed_payload.as_bytes()); let result = mac.finalize(); let hex_sig = hex::encode(result.into_bytes()); format!("t={timestamp},v1={hex_sig}") } /// Record of a checkout session created by the mock. #[derive(Debug, Clone)] #[allow(dead_code)] pub(crate) struct MockCheckout { pub id: String, pub url: String, } /// Mock payment provider for integration tests. /// /// Returns predictable fake data for all operations. Records checkout /// creations so tests can assert on them. Webhook verification uses the /// test webhook secrets defined above. pub(crate) struct MockPaymentProvider { checkouts: Mutex>, next_checkout_id: Mutex, /// `trial_days` passed to each creator-tier checkout, in call order. Lets /// the comp-code test assert the trial was actually threaded to Stripe. creator_tier_trial_days: Mutex>>, /// Line-scoped refunds requested, in call order. Lets tests assert a cart /// line refund hits Stripe for only that line's amount + transaction id. refunds: Mutex>, /// Platform-funded credit transfers requested, in call order. Lets the Fan+ /// reimbursement tests assert the creator was made whole. transfers: Mutex>, /// Platform-credit reversals requested, in call order. Lets refund tests /// assert a settled credit was clawed back. reversals: Mutex>, /// Injected Stripe failures. Empty by default. Operations are named for the /// trait method, so a rule reads as the Stripe call it breaks. faults: Faults, } /// A line-scoped refund captured by the mock. #[derive(Debug, Clone)] #[allow(dead_code)] pub(crate) struct MockRefund { pub payment_intent_id: String, pub amount_cents: i64, pub transaction_id: makenotwork::db::TransactionId, } /// A platform-funded credit transfer captured by the mock. #[derive(Debug, Clone)] #[allow(dead_code)] pub(crate) struct MockTransfer { pub connected_account_id: String, pub amount_cents: i64, pub transaction_id: makenotwork::db::TransactionId, } /// A platform-credit reversal captured by the mock. #[derive(Debug, Clone)] #[allow(dead_code)] pub(crate) struct MockReversal { pub transfer_id: String, pub amount_cents: i64, pub transaction_id: makenotwork::db::TransactionId, } #[allow(dead_code)] impl MockPaymentProvider { pub(crate) fn new() -> Self { MockPaymentProvider { checkouts: Mutex::new(Vec::new()), next_checkout_id: Mutex::new(1), creator_tier_trial_days: Mutex::new(Vec::new()), refunds: Mutex::new(Vec::new()), transfers: Mutex::new(Vec::new()), reversals: Mutex::new(Vec::new()), faults: Faults::new(), } } /// The failure policy. Install rules on it to reach the compensation paths, /// `db/pending_refunds.rs` above all, that a provider which never fails /// leaves unobserved. pub(crate) fn faults(&self) -> &Faults { &self.faults } /// All line-scoped refunds requested so far. pub(crate) fn refunds(&self) -> Vec { self.refunds.lock().unwrap().clone() } /// All platform-funded credit transfers requested so far. pub(crate) fn transfers(&self) -> Vec { self.transfers.lock().unwrap().clone() } /// All platform-credit reversals requested so far. pub(crate) fn reversals(&self) -> Vec { self.reversals.lock().unwrap().clone() } /// Return all checkouts created so far. pub(crate) fn checkouts(&self) -> Vec { self.checkouts.lock().unwrap().clone() } /// `trial_days` recorded for each creator-tier checkout, in call order. pub(crate) fn creator_tier_trial_days(&self) -> Vec> { self.creator_tier_trial_days.lock().unwrap().clone() } fn next_session(&self) -> CheckoutResult { let mut counter = self.next_checkout_id.lock().unwrap(); let id = format!("cs_test_{}", *counter); let url = format!("https://checkout.stripe.com/test/{id}"); *counter += 1; self.checkouts.lock().unwrap().push(MockCheckout { id: id.clone(), url: url.clone(), }); CheckoutResult { id, url: Some(url) } } } #[async_trait::async_trait] impl PaymentProvider for MockPaymentProvider { async fn create_checkout_session( &self, _params: &CheckoutParams<'_>, ) -> Result { self.faults.check("create_checkout_session")?; Ok(self.next_session()) } async fn create_guest_checkout_session( &self, _params: &makenotwork::payments::GuestCheckoutParams<'_>, ) -> Result { self.faults.check("create_guest_checkout_session")?; Ok(self.next_session()) } async fn create_subscription_checkout_session( &self, _params: &SubscriptionCheckoutParams<'_>, ) -> Result { self.faults.check("create_subscription_checkout_session")?; Ok(self.next_session()) } async fn create_tip_checkout_session( &self, _params: &TipCheckoutParams<'_>, ) -> Result { self.faults.check("create_tip_checkout_session")?; Ok(self.next_session()) } async fn create_fan_plus_checkout_session( &self, _price_id: &str, _user_id: makenotwork::db::UserId, _success_url: &str, _cancel_url: &str, ) -> Result { self.faults.check("create_fan_plus_checkout_session")?; Ok(self.next_session()) } async fn create_creator_tier_checkout_session( &self, _price_id: &str, _user_id: makenotwork::db::UserId, _tier: &str, _success_url: &str, _cancel_url: &str, trial_days: Option, ) -> Result { self.faults.check("create_creator_tier_checkout_session")?; self.creator_tier_trial_days .lock() .unwrap() .push(trial_days); Ok(self.next_session()) } async fn create_cart_checkout_session( &self, _params: &makenotwork::payments::CartCheckoutParams<'_>, ) -> Result { self.faults.check("create_cart_checkout_session")?; Ok(self.next_session()) } async fn create_connect_account( &self, _email: &str, ) -> Result { self.faults.check("create_connect_account")?; Ok(makenotwork::db::StripeAccountId::from_trusted( "acct_test_mock".to_string(), )) } async fn create_account_link( &self, _account_id: &str, _return_url: &str, _refresh_url: &str, ) -> Result { self.faults.check("create_account_link")?; Ok("https://connect.stripe.com/test/onboarding".to_string()) } async fn fetch_account(&self, account_id: &str) -> Result { self.faults.check("fetch_account")?; Ok(AccountUpdate { account_id: account_id.to_string(), charges_enabled: true, payouts_enabled: true, details_submitted: true, settlement_currency: Some(makenotwork::currency::SettlementCurrency::Usd), }) } async fn create_subscription_product_and_price( &self, _connected_account_id: &str, _tier_name: &str, _tier_description: Option<&str>, _price_cents: i64, _currency: makenotwork::currency::SettlementCurrency, ) -> Result<(String, String)> { self.faults.check("create_subscription_product_and_price")?; Ok(("prod_test_mock".to_string(), "price_test_mock".to_string())) } async fn get_balance( &self, _account_id: &str, _currency: makenotwork::currency::SettlementCurrency, ) -> Result { self.faults.check("get_balance")?; Ok(BalanceSummary { available_cents: 0, pending_cents: 0, }) } fn verify_webhook( &self, payload: &str, signature: &str, ) -> Result { self.faults.check("verify_webhook")?; makenotwork::payments::verify_signature(payload, signature, TEST_WEBHOOK_SECRET) .map_err(AppError::BadRequest)?; makenotwork::payments::UntypedEvent::from_payload(payload) } fn verify_webhook_v2(&self, payload: &str, signature: &str) -> Result { self.faults.check("verify_webhook_v2")?; makenotwork::payments::verify_signature(payload, signature, TEST_WEBHOOK_SECRET_V2) .map_err(AppError::BadRequest)?; serde_json::from_str(payload) .map_err(|e| AppError::BadRequest(format!("Invalid payload: {e}"))) } async fn pause_subscription( &self, _stripe_sub_id: &str, _connected_account_id: &str, ) -> Result<()> { self.faults.check("pause_subscription")?; Ok(()) } async fn resume_subscription( &self, _stripe_sub_id: &str, _connected_account_id: &str, ) -> Result<()> { self.faults.check("resume_subscription")?; Ok(()) } async fn cancel_subscription( &self, _stripe_sub_id: &str, _connected_account_id: &str, ) -> Result<()> { self.faults.check("cancel_subscription")?; Ok(()) } async fn set_cancel_at_period_end( &self, _stripe_sub_id: &str, _connected_account_id: &str, _cancel: bool, ) -> Result<()> { self.faults.check("set_cancel_at_period_end")?; Ok(()) } async fn cancel_platform_subscription(&self, _stripe_sub_id: &str) -> Result<()> { self.faults.check("cancel_platform_subscription")?; Ok(()) } async fn set_platform_cancel_at_period_end( &self, _stripe_sub_id: &str, _cancel: bool, ) -> Result<()> { self.faults.check("set_platform_cancel_at_period_end")?; Ok(()) } async fn create_billing_portal_session( &self, _stripe_customer_id: &str, return_url: &str, ) -> Result { self.faults.check("create_billing_portal_session")?; // Echo a deterministic URL so tests can assert the redirect target. Ok(format!( "https://billing.stripe.test/portal?return={}", urlencoding::encode(return_url) )) } async fn create_refund_for_transaction( &self, payment_intent_id: &str, _connected_account_id: &str, amount_cents: i64, transaction_id: makenotwork::db::TransactionId, ) -> Result<()> { self.faults.check("create_refund_for_transaction")?; self.refunds.lock().unwrap().push(MockRefund { payment_intent_id: payment_intent_id.to_string(), amount_cents, transaction_id, }); Ok(()) } async fn create_platform_credit_transfer( &self, connected_account_id: &str, amount_cents: i64, transaction_id: makenotwork::db::TransactionId, _currency: makenotwork::currency::SettlementCurrency, ) -> Result { self.faults.check("create_platform_credit_transfer")?; self.transfers.lock().unwrap().push(MockTransfer { connected_account_id: connected_account_id.to_string(), amount_cents, transaction_id, }); // Deterministic dummy transfer id, the reversal path stores and reuses it. Ok(format!("tr_mock_{transaction_id}")) } async fn create_platform_credit_reversal( &self, transfer_id: &str, amount_cents: i64, transaction_id: makenotwork::db::TransactionId, ) -> Result<()> { self.faults.check("create_platform_credit_reversal")?; self.reversals.lock().unwrap().push(MockReversal { transfer_id: transfer_id.to_string(), amount_cents, transaction_id, }); Ok(()) } async fn create_synckit_customer( &self, _developer_user_id: makenotwork::db::UserId, _app_id: makenotwork::db::SyncAppId, _email: &str, _app_name: &str, ) -> Result { self.faults.check("create_synckit_customer")?; // Deterministic dummy; tests assert on shape, not content. Ok("cus_test_synckit".to_string()) } async fn create_synckit_subscription( &self, _customer_id: &str, app_id: makenotwork::db::SyncAppId, _app_name: &str, _price_cents: i64, ) -> Result { self.faults.check("create_synckit_subscription")?; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() as i64; Ok(makenotwork::payments::SynckitSubResult { subscription_id: format!("sub_test_{app_id}"), current_period_start: now, current_period_end: now + 30 * 24 * 60 * 60, }) } async fn update_synckit_subscription_price( &self, _subscription_id: &str, _new_price_cents: i64, _app_name: &str, ) -> Result<()> { self.faults.check("update_synckit_subscription_price")?; Ok(()) } async fn update_synckit_app_sub_price( &self, _subscription_id: &str, _new_price_cents: i64, _interval: makenotwork::payments::SyncBillingInterval, _product_name: &str, ) -> Result<()> { self.faults.check("update_synckit_app_sub_price")?; Ok(()) } async fn create_synckit_app_sub_checkout_session( &self, _params: &makenotwork::payments::SynckitAppSubCheckoutParams<'_>, ) -> Result { self.faults .check("create_synckit_app_sub_checkout_session")?; Ok(self.next_session()) } async fn cancel_synckit_subscription(&self, _subscription_id: &str) -> Result<()> { self.faults.check("cancel_synckit_subscription")?; Ok(()) } async fn create_synckit_billing_portal( &self, _customer_id: &str, return_url: &str, ) -> Result { self.faults.check("create_synckit_billing_portal")?; Ok(format!( "https://billing.stripe.test/portal?return={}", urlencoding::encode(return_url) )) } }