//! Connected account operations: onboarding, balance, product/price creation, //! subscription lifecycle, refunds, and billing portal. use stripe::{IdempotencyKey, RequestStrategy, StripeRequest}; use stripe_billing::billing_portal_session::CreateBillingPortalSession; use stripe_billing::subscription::{ CancelSubscription, ResumeSubscription, UpdateSubscription, UpdateSubscriptionPauseCollection, UpdateSubscriptionPauseCollectionBehavior, }; use stripe_connect::account::{CreateAccount, CreateAccountType, RetrieveAccount}; use stripe_connect::account_link::{CreateAccountLink, CreateAccountLinkType}; use stripe_connect::transfer::CreateTransfer; use stripe_connect::transfer_reversal::CreateIdTransferReversal; use stripe_core::balance::RetrieveForMyAccountBalance; use stripe_core::refund::CreateRefund; use stripe_product::price::{CreatePrice, CreatePriceRecurring, CreatePriceRecurringInterval}; use stripe_product::product::CreateProduct; use super::StripeClient; use crate::currency::SettlementCurrency; use crate::db::StripeAccountId; use crate::error::{AppError, Result}; 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()) })?; // Stripe minted this id; trust its shape rather than re-validating. Ok(StripeAccountId::from_trusted(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::from(account)) } /// 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, currency: SettlementCurrency, ) -> 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()) })?; // The tier's Price is minted in the creator's settlement currency and // never re-denominated afterwards. A creator who later changes their // Stripe currency keeps tiers priced in the old one until they re-price, // which is the honest outcome: the number they typed meant that currency. let price = CreatePrice::new(currency.to_stripe()) .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 line-scoped refund for one transaction on a connected account. /// /// `amount_cents` is refunded against the shared PaymentIntent and the Stripe /// refund is tagged with `mnw_transaction_id` so the `refund.created` webhook /// marks and revokes exactly that transaction. Cart checkouts put every line /// of an order under ONE PaymentIntent, so a PI-wide refund would silently /// reverse the whole order (Run #2 Payments SERIOUS). #[tracing::instrument(skip_all, name = "payments::create_refund_for_transaction")] pub async fn create_refund_for_transaction( &self, payment_intent_id: &str, connected_account_id: &str, amount_cents: i64, transaction_id: crate::db::TransactionId, ) -> Result<()> { let acct = Self::parse_account_id(connected_account_id)?; // Deterministic idempotency key (`refund-{transaction_id}`), mirroring the // platform-credit transfer below: a retry after a crash or transient // failure returns the same refund rather than double-debiting the // creator's connected balance. A transaction is refunded in full exactly // once, so keying on its id is the correct dedup scope. let key = IdempotencyKey::new(format!("refund-{transaction_id}")) .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?; let metadata = std::collections::HashMap::from([( "mnw_transaction_id".to_string(), transaction_id.to_string(), )]); CreateRefund::new() .payment_intent(payment_intent_id.to_string()) .amount(amount_cents) .metadata(metadata) .customize() .account_id(acct) .request_strategy(RequestStrategy::Idempotent(key)) .send(&self.client) .await .map_err(|e| { tracing::error!(payment_intent_id = %payment_intent_id, transaction_id = %transaction_id, error = ?e, "failed to create Stripe line refund"); AppError::Internal(anyhow::anyhow!("Failed to create refund")) })?; Ok(()) } /// Reimburse a creator for a platform-funded credit (the Fan+ renewal credit) /// applied to their sale, so they still net the full pre-discount price and the /// "0% platform fee, creators keep everything" promise holds. This is a platform /// -> connected transfer funded from MNW's own balance (the platform absorbs the /// credit, not the creator). /// /// The idempotency key is deterministic (`platform-credit-{transaction_id}`), so a /// retry after a crash or transient failure returns the same transfer rather than /// paying the creator twice. /// /// Returns the created transfer's Stripe id so the settle path can persist /// it, the reversal path ([`create_platform_credit_reversal`]) needs it to /// claw the funds back if the sale is later refunded. #[tracing::instrument(skip_all, name = "payments::create_platform_credit_transfer")] pub async fn create_platform_credit_transfer( &self, connected_account_id: &str, amount_cents: i64, transaction_id: crate::db::TransactionId, currency: SettlementCurrency, ) -> Result { let acct = Self::parse_account_id(connected_account_id)?; let key = IdempotencyKey::new(format!("platform-credit-{transaction_id}")) .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?; let metadata = std::collections::HashMap::from([ ("mnw_transaction_id".to_string(), transaction_id.to_string()), ("reason".to_string(), "platform_funded_credit".to_string()), ]); // Denominated in the sale's currency, not MNW's. The creator is owed the // amount of a sale that was priced in their currency, so MNW carries any // conversion out of its own balance rather than handing the creator a // number that happens to match in USD. let transfer = CreateTransfer::new(currency.to_stripe(), acct.to_string()) .amount(amount_cents) .description("Fan+ credit reimbursement") .metadata(metadata) .customize() .request_strategy(RequestStrategy::Idempotent(key)) .send(&self.client) .await .map_err(|e| { tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to create platform credit transfer"); AppError::Internal(anyhow::anyhow!("Failed to create transfer")) })?; Ok(transfer.id.to_string()) } /// Reverse a settled platform-funded credit transfer when its sale is /// refunded, pulling the reimbursed amount back from the connected account /// to MNW so the platform isn't left funding a returned item. /// /// The idempotency key is deterministic /// (`platform-credit-reversal-{transaction_id}`), so a retry after a crash /// or transient failure returns the same reversal rather than clawing back /// twice. `transfer_id` is the id captured when the forward transfer settled. #[tracing::instrument(skip_all, name = "payments::create_platform_credit_reversal")] pub async fn create_platform_credit_reversal( &self, transfer_id: &str, amount_cents: i64, transaction_id: crate::db::TransactionId, ) -> Result<()> { let key = IdempotencyKey::new(format!("platform-credit-reversal-{transaction_id}")) .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?; let metadata = std::collections::HashMap::from([ ("mnw_transaction_id".to_string(), transaction_id.to_string()), ( "reason".to_string(), "platform_funded_credit_reversal".to_string(), ), ]); CreateIdTransferReversal::new(transfer_id.to_string()) .amount(amount_cents) .metadata(metadata) .customize() .request_strategy(RequestStrategy::Idempotent(key)) .send(&self.client) .await .map_err(|e| { tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to reverse platform credit transfer"); AppError::Internal(anyhow::anyhow!("Failed to reverse transfer")) })?; 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"); } }