//! 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; /// 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<()>; // Webhooks fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result; fn verify_webhook_v2( &self, payload: &str, signature: &str, ) -> crate::error::Result; /// Turn a verified envelope into the MNW vocabulary. /// /// Base trait rather than an extension: a provider that sends webhooks must /// be able to say what its events mean, so this is not a capability any /// provider can lack. /// /// Takes the whole envelope rather than `(type, object)` because the retry /// worker calls it alone — a stored payload was verified once already and /// has no signature to re-check, so it has no `verify_webhook` result to /// destructure. The live path composes the two. fn normalize_webhook(&self, event: UntypedEvent) -> crate::error::Result; // SyncKit subscription re-pricing and cancellation. Creating the customer // and the subscription needs [`CustodialCustomers`]; changing the price of // one that already exists is an ordinary subscription edit, so it stays // here. 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<()>; } // ── Capability extensions ───────────────────────────────────────────────── // // Split out of `PaymentProvider` on 2026-08-28 (`9a452f48`, option (a)). Each // one is a capability a rail can genuinely lack, and the grouping is by the // *reason* it lacks it rather than by the shape of the method. A provider that // cannot host a portal does not implement [`HostedPortal`], so the compiler // says so at the wiring rather than the route saying so at runtime with an // `Err(Unsupported)` nobody can plan around. /// Provider-hosted billing pages the customer is redirected to. /// /// One trait for both entry points because they are one capability: the /// SyncKit portal call delegates straight to the general one /// (`StripeClient::create_synckit_billing_portal`), so a rail that can serve /// either can serve both. #[async_trait::async_trait] pub trait HostedPortal: Send + Sync { /// Create a provider-hosted billing portal session. Returns the URL to /// redirect to. async fn create_billing_portal_session( &self, customer_id: &str, return_url: &str, ) -> crate::error::Result; /// The same portal for a SyncKit developer's per-app customer. async fn create_synckit_billing_portal( &self, customer_id: &str, return_url: &str, ) -> crate::error::Result; } /// Provider-hosted onboarding for connected accounts, plus reading one back. /// /// A rail that onboards sellers out of band (a form we host, a contract, a bank /// enrolment) mints an account id without ever having a link to send anyone to, /// and has no account object of the provider's shape to fetch. #[async_trait::async_trait] pub trait ConnectOnboarding: Send + Sync { 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; } /// A product/price catalogue held on the provider's side. /// /// A rail that prices at charge time has nothing to create here: the amount is /// an argument, not a stored object with an id. #[async_trait::async_trait] pub trait Catalogue: Send + Sync { 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)>; } /// Refunds initiated through the provider. /// /// Line-scoped: refunds `amount_cents` of the shared PaymentIntent and tags the /// refund with the transaction id so the `refund.created` webhook marks and /// revokes exactly that line (cart orders share one PaymentIntent). #[async_trait::async_trait] pub trait Refundable: Send + Sync { 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 transfers into a connected account, and their reversal. /// /// This is money moving from MNW's own balance to a creator's, which only a /// rail that holds a platform balance can do. It makes the creator whole for a /// Fan+ credit applied to their sale. Deterministic idempotency keys keep /// replays and retries from double-paying or double-clawing. #[async_trait::async_trait] pub trait PlatformTransfers: Send + Sync { /// Reimburse a creator for a platform-funded credit. 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. async fn create_platform_credit_reversal( &self, transfer_id: &str, amount_cents: i64, transaction_id: crate::db::TransactionId, ) -> crate::error::Result<()>; } /// Customer records the provider holds on our behalf, and subscriptions billed /// against them. /// /// SyncKit v2 developer billing keeps one customer and one subscription per /// app, separate from creator-tier and Fan+ subscriptions; see /// `synckit_billing.rs` for the rationale on per-app customers. A rail that /// does not custody customers (charging a token per transaction, say) has /// nowhere to put one. #[async_trait::async_trait] pub trait CustodialCustomers: Send + Sync { 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; } /// A provider that implements the base trait and every capability, which is /// what a full-service rail like Stripe is. /// /// Wiring convenience only: [`PaymentCapabilities::all`] takes one `Arc` and /// hands back a handle per capability, so a deployment names its provider once. pub trait FullPaymentProvider: PaymentProvider + HostedPortal + ConnectOnboarding + Catalogue + Refundable + PlatformTransfers + CustodialCustomers + 'static { } impl FullPaymentProvider for T where T: PaymentProvider + HostedPortal + ConnectOnboarding + Catalogue + Refundable + PlatformTransfers + CustodialCustomers + 'static { } /// Typed handles to whichever capabilities the wired provider implements. /// /// One field per extension trait, grouped the way `AppStorage` groups the /// buckets: the alternative was six more fields on each of `AppState`, /// `Billing` and `AppStateParts` plus their two propagation sites, which is /// eighteen declarations for six capabilities. Grouping keeps the reference /// typed (`state.payment_caps.require_hosted_portal()?` is one unwrap and no /// `Any`) without that. /// /// `None` means the deployment's provider cannot do it, which is the same /// answer as "no provider is configured at all" from a route's point of view. #[derive(Clone, Default)] pub struct PaymentCapabilities { pub hosted_portal: Option>, pub connect_onboarding: Option>, pub catalogue: Option>, pub refundable: Option>, pub platform_transfers: Option>, pub custodial_customers: Option>, } impl PaymentCapabilities { /// Every capability, all backed by the one provider value. pub fn all(provider: std::sync::Arc

) -> Self { Self { hosted_portal: Some(provider.clone()), connect_onboarding: Some(provider.clone()), catalogue: Some(provider.clone()), refundable: Some(provider.clone()), platform_transfers: Some(provider.clone()), custodial_customers: Some(provider), } } } impl PaymentCapabilities { fn missing(what: &str) -> crate::error::AppError { crate::error::AppError::ServiceUnavailable(format!( "The configured payment provider does not support {what}" )) } /// The hosted-portal capability, or a 503 if the provider has none. pub fn require_hosted_portal(&self) -> crate::error::Result<&std::sync::Arc> { self.hosted_portal .as_ref() .ok_or_else(|| Self::missing("hosted billing portals")) } /// The Connect-onboarding capability, or a 503 if the provider has none. pub fn require_connect_onboarding( &self, ) -> crate::error::Result<&std::sync::Arc> { self.connect_onboarding .as_ref() .ok_or_else(|| Self::missing("hosted account onboarding")) } /// The catalogue capability, or a 503 if the provider has none. pub fn require_catalogue(&self) -> crate::error::Result<&std::sync::Arc> { self.catalogue .as_ref() .ok_or_else(|| Self::missing("a hosted product catalogue")) } /// The refund capability, or a 503 if the provider has none. pub fn require_refundable(&self) -> crate::error::Result<&std::sync::Arc> { self.refundable .as_ref() .ok_or_else(|| Self::missing("refunds")) } /// The platform-transfer capability, or a 503 if the provider has none. pub fn require_platform_transfers( &self, ) -> crate::error::Result<&std::sync::Arc> { self.platform_transfers .as_ref() .ok_or_else(|| Self::missing("platform transfers")) } /// The custodial-customer capability, or a 503 if the provider has none. pub fn require_custodial_customers( &self, ) -> crate::error::Result<&std::sync::Arc> { self.custodial_customers .as_ref() .ok_or_else(|| Self::missing("provider-held customer records")) } } #[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, 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) } } } #[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 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 } 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) } fn normalize_webhook(&self, event: UntypedEvent) -> crate::error::Result { StripeClient::normalize_webhook(self, event) } 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_trait::async_trait] impl HostedPortal for StripeClient { 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_synckit_billing_portal( &self, customer_id: &str, return_url: &str, ) -> crate::error::Result { StripeClient::create_synckit_billing_portal(self, customer_id, return_url).await } } #[async_trait::async_trait] impl ConnectOnboarding for StripeClient { 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_trait::async_trait] impl Catalogue for StripeClient { 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_trait::async_trait] impl Refundable for StripeClient { 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_trait::async_trait] impl PlatformTransfers for StripeClient { 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 } } #[async_trait::async_trait] impl CustodialCustomers for StripeClient { 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 } } #[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" ); } }