//! 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; /// The MNW event vocabulary the webhook dispatcher reasons in. pub mod mnw_event; pub mod refund; pub mod synckit_app_pricing; pub mod synckit_billing; mod webhooks; pub use checkout::*; pub use checkout_metadata::*; pub use mnw_event::*; 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); /// A connected-account id as a payment provider minted it. /// /// The `PaymentProvider` trait deals in this rather than in /// [`crate::db::StripeAccountId`], which carries Stripe's `acct_` shape in its /// validator: a non-Stripe provider has no legal value to return there. The /// wrapper is deliberately opaque, holding whatever the provider handed back /// with no format claim of its own. Vendor validation happens where the value /// meets a vendor-shaped column, at the call site. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ProviderAccountId(String); impl ProviderAccountId { /// Wrap what a provider returned. No validation: the provider minted it. pub fn from_provider(id: String) -> Self { Self(id) } pub fn as_str(&self) -> &str { &self.0 } /// Consume the wrapper, returning the inner `String`. pub fn into_inner(self) -> String { self.0 } } impl std::fmt::Display for ProviderAccountId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } /// 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 { // `build()` constructs the rustls connector, which reads the // process-wide provider and panics if none is installed. Doing it here // rather than relying on a caller means no construction order can get // this wrong; the call is idempotent. crate::crypto::install_default_crypto_provider(); 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, } /// Sum the entries whose currency is `want`, ignoring the rest. /// /// Split out of `get_balance` so the filter has a test. A connected account's /// Stripe balance carries one entry per currency it holds, and the sum comes /// back as a bare `i64` with no currency attached to contradict it, so summing /// the wrong entries reports another currency's money as this one's and looks /// like a plausible number rather than an error. fn sum_in_currency<'a, C>(entries: impl IntoIterator, want: &C) -> i64 where C: PartialEq + 'a, { entries .into_iter() .filter(|(currency, _)| *currency == want) .map(|(_, amount)| amount) .sum() } /// 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, currency: crate::currency::SettlementCurrency, ) -> crate::error::Result<(String, String)>; /// Balance in the account's own settlement currency. A connected account can /// hold several currencies at once; summing across them would be adding /// pounds to euros. async fn get_balance( &self, account_id: &str, currency: crate::currency::SettlementCurrency, ) -> 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, currency: crate::currency::SettlementCurrency, ) -> 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; } #[cfg(test)] pub(crate) mod test_provider { //! 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, 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 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 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 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) } 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_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 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) } 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) } 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) } 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) } 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 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 { let account = StripeClient::create_connect_account(self, email).await?; Ok(ProviderAccountId::from_provider(account.into_inner())) } 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, currency: crate::currency::SettlementCurrency, ) -> crate::error::Result<(String, String)> { StripeClient::create_subscription_product_and_price( self, connected_account_id, tier_name, tier_description, price_cents, currency, ) .await } async fn get_balance( &self, account_id: &str, currency: crate::currency::SettlementCurrency, ) -> crate::error::Result { let balance = self.get_connected_account_balance(account_id).await?; let want = currency.to_stripe(); let available_cents = sum_in_currency( balance.available.iter().map(|b| (&b.currency, b.amount)), &want, ); let pending_cents = sum_in_currency( balance.pending.iter().map(|b| (&b.currency, b.amount)), &want, ); 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, currency: crate::currency::SettlementCurrency, ) -> crate::error::Result { StripeClient::create_platform_credit_transfer( self, connected_account_id, amount_cents, transaction_id, currency, ) .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 } } #[cfg(test)] mod tests { //! Stripe id parsing at the boundary between our database and Stripe's API. //! These ids come out of our own rows, so a parse failure means our data is //! wrong, and the classification matters: `Internal` pages us, `BadRequest` //! would blame the creator for our own corrupted column. use super::*; #[test] fn account_id_parsing_rejects_nothing_at_all() { // Same dead guard as `parse_subscription_id`. `stripe_shared::AccountId` // derives `FromStr` with `type Err = Infallible`, so every value parses // and the `Invalid Stripe account ID` branch cannot be reached. The doc // comment above reasons carefully about classifying the failure as // `Internal` rather than `BadRequest`; there is no failure to classify. // // The consequence is not academic: an empty `users.stripe_account_id` // becomes an empty connected-account header on a live charge instead of // an error we can see. assert!(StripeClient::parse_account_id("acct_1A2b3C4d5E6f7G8h").is_ok()); for anything in ["", "cus_123", "not an id", "acct_"] { assert!( StripeClient::parse_account_id(anything).is_ok(), "{anything:?} parses today; if this now fails, the guard became real \ and the test should assert the new contract" ); } } // ── sum_in_currency, the filter behind `get_balance` ── use crate::currency::SettlementCurrency; /// The entries a connected account holding three currencies would carry. fn mixed() -> Vec<(stripe_types::Currency, i64)> { vec![ (SettlementCurrency::Usd.to_stripe(), 1_000), (SettlementCurrency::Gbp.to_stripe(), 2_500), (SettlementCurrency::Usd.to_stripe(), 250), (SettlementCurrency::Eur.to_stripe(), 9_999), ] } #[test] fn sums_every_entry_in_the_wanted_currency() { let entries = mixed(); let total = sum_in_currency( entries.iter().map(|(c, a)| (c, *a)), &SettlementCurrency::Usd.to_stripe(), ); assert_eq!(total, 1_250, "both USD entries, and only those"); } #[test] fn ignores_every_entry_in_another_currency() { let entries = mixed(); for currency in SettlementCurrency::ALL { let total = sum_in_currency(entries.iter().map(|(c, a)| (c, *a)), ¤cy.to_stripe()); let expected = match currency { SettlementCurrency::Usd => 1_250, SettlementCurrency::Gbp => 2_500, SettlementCurrency::Eur => 9_999, _ => 0, }; assert_eq!( total, expected, "{currency} must see its own money and nobody else's" ); } } #[test] fn a_currency_the_account_does_not_hold_is_zero_rather_than_everything() { let entries = mixed(); let total = sum_in_currency( entries.iter().map(|(c, a)| (c, *a)), &SettlementCurrency::Nzd.to_stripe(), ); assert_eq!( total, 0, "an inverted filter would report 13,749 NZD cents the account never held" ); } }