//! Connected account operations: onboarding, balance, product/price creation, //! subscription lifecycle, refunds, and billing portal. use stripe::StripeRequest; use stripe_billing::subscription::{ CancelSubscription, ResumeSubscription, UpdateSubscription, UpdateSubscriptionPauseCollection, UpdateSubscriptionPauseCollectionBehavior, }; use stripe_billing::billing_portal_session::CreateBillingPortalSession; use stripe_connect::account::{CreateAccount, CreateAccountType, RetrieveAccount}; use stripe_connect::account_link::{CreateAccountLink, CreateAccountLinkType}; use stripe_core::balance::RetrieveForMyAccountBalance; use stripe_core::refund::CreateRefund; use stripe_product::product::CreateProduct; use stripe_product::price::{CreatePrice, CreatePriceRecurring, CreatePriceRecurringInterval}; use stripe_types::Currency; use crate::error::{AppError, Result}; use super::StripeClient; fn parse_subscription_id(stripe_sub_id: &str) -> Result { stripe_sub_id.parse().map_err(|e| { AppError::Internal(anyhow::anyhow!("Invalid Stripe subscription ID '{}': {}", stripe_sub_id, e)) }) } impl StripeClient { /// Create a Stripe Standard connected account for a creator. #[tracing::instrument(skip_all, name = "payments::create_connect_account")] pub async fn create_connect_account(&self, email: &str) -> Result { let account = CreateAccount::new() .type_(CreateAccountType::Standard) .email(email.to_string()) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, "failed to create Stripe connected account"); AppError::BadRequest("Failed to create Stripe account".to_string()) })?; Ok(account.id.to_string()) } /// Create an Account Link for Stripe Connect onboarding. #[tracing::instrument(skip_all, name = "payments::create_account_link")] pub async fn create_account_link( &self, account_id: &str, return_url: &str, refresh_url: &str, ) -> Result { // CreateAccountLink takes the account id as a plain String, not AccountId. let link = CreateAccountLink::new(account_id.to_string(), CreateAccountLinkType::AccountOnboarding) .return_url(return_url.to_string()) .refresh_url(refresh_url.to_string()) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, "failed to create Stripe account link"); AppError::BadRequest("Failed to create Stripe onboarding link".to_string()) })?; Ok(link.url) } /// Fetch a Stripe Connect account by ID. #[tracing::instrument(skip_all, name = "payments::fetch_account")] pub async fn fetch_account(&self, account_id: &str) -> Result { let account_id = Self::parse_account_id(account_id)?; let account = RetrieveAccount::new(account_id) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, "failed to fetch Stripe account"); AppError::BadRequest("Failed to fetch Stripe account".to_string()) })?; Ok(super::AccountUpdate { account_id: account.id.to_string(), charges_enabled: account.charges_enabled.unwrap_or(false), payouts_enabled: account.payouts_enabled.unwrap_or(false), details_submitted: account.details_submitted.unwrap_or(false), }) } /// Create a Product + monthly recurring Price on a connected account. #[tracing::instrument(skip_all, name = "payments::create_subscription_product_and_price")] pub async fn create_subscription_product_and_price( &self, connected_account_id: &str, tier_name: &str, tier_description: Option<&str>, price_cents: i64, ) -> Result<(String, String)> { if price_cents <= 0 { return Err(AppError::BadRequest("Price must be positive".to_string())); } let acct = Self::parse_account_id(connected_account_id)?; let mut product_req = CreateProduct::new(tier_name.to_string()); if let Some(desc) = tier_description { product_req = product_req.description(desc.to_string()); } let product = product_req .customize() .account_id(acct.clone()) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, "failed to create Stripe product"); AppError::BadRequest("Failed to create subscription product".to_string()) })?; let price = CreatePrice::new(Currency::USD) .product(product.id.to_string()) .unit_amount(price_cents) .recurring(CreatePriceRecurring::new(CreatePriceRecurringInterval::Month)) .customize() .account_id(acct) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, "failed to create Stripe price"); AppError::BadRequest("Failed to create subscription price".to_string()) })?; Ok((product.id.to_string(), price.id.to_string())) } /// Retrieve the balance for a connected account. #[tracing::instrument(skip_all, name = "payments::get_connected_account_balance")] pub async fn get_connected_account_balance(&self, account_id: &str) -> Result { let acct = Self::parse_account_id(account_id)?; RetrieveForMyAccountBalance::new() .customize() .account_id(acct) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, "failed to fetch Stripe balance"); AppError::BadRequest("Failed to fetch Stripe balance".to_string()) }) } /// Pause subscription collection (void invoices) on a connected account. #[tracing::instrument(skip_all, name = "payments::pause_subscription")] pub async fn pause_subscription( &self, stripe_sub_id: &str, connected_account_id: &str, ) -> Result<()> { let acct = Self::parse_account_id(connected_account_id)?; let sub_id = parse_subscription_id(stripe_sub_id)?; UpdateSubscription::new(sub_id) .pause_collection(UpdateSubscriptionPauseCollection::new( UpdateSubscriptionPauseCollectionBehavior::Void, )) .customize() .account_id(acct) .send(&self.client) .await .map_err(|e| { tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to pause Stripe subscription"); AppError::Internal(anyhow::anyhow!("Failed to pause subscription")) })?; Ok(()) } /// Resume a paused subscription on a connected account. /// /// rc.5 exposes `POST /subscriptions/{id}/resume` as the proper way to lift /// a pause; the legacy "clear `pause_collection`" trick is no longer needed. #[tracing::instrument(skip_all, name = "payments::resume_subscription")] pub async fn resume_subscription( &self, stripe_sub_id: &str, connected_account_id: &str, ) -> Result<()> { let acct = Self::parse_account_id(connected_account_id)?; let sub_id = parse_subscription_id(stripe_sub_id)?; ResumeSubscription::new(sub_id) .customize() .account_id(acct) .send(&self.client) .await .map_err(|e| { tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to resume Stripe subscription"); AppError::Internal(anyhow::anyhow!("Failed to resume subscription")) })?; Ok(()) } /// Cancel a subscription on a connected account (permanent). #[tracing::instrument(skip_all, name = "payments::cancel_subscription")] pub async fn cancel_subscription( &self, stripe_sub_id: &str, connected_account_id: &str, ) -> Result<()> { let acct = Self::parse_account_id(connected_account_id)?; let sub_id = parse_subscription_id(stripe_sub_id)?; CancelSubscription::new(sub_id) .customize() .account_id(acct) .send(&self.client) .await .map_err(|e| { tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to cancel Stripe subscription"); AppError::Internal(anyhow::anyhow!("Failed to cancel subscription")) })?; Ok(()) } /// Cancel a platform-level subscription (creator tier, Fan+). #[tracing::instrument(skip_all, name = "payments::cancel_platform_subscription")] pub async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> Result<()> { let sub_id = parse_subscription_id(stripe_sub_id)?; CancelSubscription::new(sub_id) .send(&self.client) .await .map_err(|e| { tracing::error!(stripe_sub_id = %stripe_sub_id, error = ?e, "failed to cancel platform subscription"); AppError::Internal(anyhow::anyhow!("Failed to cancel platform subscription")) })?; Ok(()) } /// Set or clear `cancel_at_period_end` on a platform-level subscription. #[tracing::instrument(skip_all, name = "payments::set_platform_cancel_at_period_end")] pub async fn set_platform_cancel_at_period_end( &self, stripe_sub_id: &str, cancel: bool, ) -> Result<()> { let sub_id = parse_subscription_id(stripe_sub_id)?; UpdateSubscription::new(sub_id) .cancel_at_period_end(cancel) .send(&self.client) .await .map_err(|e| { tracing::error!(stripe_sub_id = %stripe_sub_id, cancel = %cancel, error = ?e, "failed to set platform cancel_at_period_end"); AppError::Internal(anyhow::anyhow!("Failed to update subscription cancellation")) })?; Ok(()) } /// Set or clear `cancel_at_period_end` on a connected-account subscription. #[tracing::instrument(skip_all, name = "payments::set_cancel_at_period_end")] pub async fn set_cancel_at_period_end( &self, stripe_sub_id: &str, connected_account_id: &str, cancel: bool, ) -> Result<()> { let acct = Self::parse_account_id(connected_account_id)?; let sub_id = parse_subscription_id(stripe_sub_id)?; UpdateSubscription::new(sub_id) .cancel_at_period_end(cancel) .customize() .account_id(acct) .send(&self.client) .await .map_err(|e| { tracing::error!(stripe_sub_id = %stripe_sub_id, cancel = %cancel, error = ?e, "failed to set cancel_at_period_end"); AppError::Internal(anyhow::anyhow!("Failed to update subscription cancellation")) })?; Ok(()) } /// Create a Stripe Billing Portal session for a customer. #[tracing::instrument(skip_all, name = "payments::create_billing_portal_session")] pub async fn create_billing_portal_session( &self, stripe_customer_id: &str, return_url: &str, ) -> Result { let session = CreateBillingPortalSession::new() .customer(stripe_customer_id.to_string()) .return_url(return_url.to_string()) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, "failed to create billing portal session"); AppError::Internal(anyhow::anyhow!("Failed to create billing portal session")) })?; Ok(session.url) } /// Issue a full refund for a payment on a connected account. #[tracing::instrument(skip_all, name = "payments::create_refund")] pub async fn create_refund( &self, payment_intent_id: &str, connected_account_id: &str, ) -> Result<()> { let acct = Self::parse_account_id(connected_account_id)?; CreateRefund::new() .payment_intent(payment_intent_id.to_string()) .customize() .account_id(acct) .send(&self.client) .await .map_err(|e| { tracing::error!(payment_intent_id = %payment_intent_id, error = ?e, "failed to create Stripe refund"); AppError::Internal(anyhow::anyhow!("Failed to create refund")) })?; Ok(()) } } #[cfg(test)] mod tests { use super::*; // NOTE: async-stripe's `*Id` types are permissive newtypes — `FromStr` // accepts any non-pathological string without validating the `acct_`/`sub_` // prefix, so there is no error path to assert on normal input. These tests // pin what is actually observable: canonical IDs parse and round-trip, and // both account-id call sites now go through the single `parse_account_id` // (the divergent `parse_account_id_internal` was deleted in Run #14). #[test] fn account_id_parses_and_round_trips() { let acct = StripeClient::parse_account_id("acct_1A2b3C4d5E6f7G").unwrap(); assert_eq!(acct.to_string(), "acct_1A2b3C4d5E6f7G"); } #[test] fn subscription_id_parses_and_round_trips() { let sub = parse_subscription_id("sub_1A2b3C4d5E6f7G8h").unwrap(); assert_eq!(sub.to_string(), "sub_1A2b3C4d5E6f7G8h"); } }