//! Stripe wiring for SyncKit v2 developer billing. //! //! One Stripe Customer is created per sync app (not per developer's MNW //! account), because each app is billed independently. The subscription's //! metadata carries `synckit_app_id` so the webhook dispatcher can route //! events to the SyncKit billing path. //! //! Prices are created inline on the subscription via `price_data` rather than //! by pre-creating Stripe Price objects; this keeps the dashboard tidy and //! lets us re-price freely on every knob change. We do create a Stripe //! `Product` per app once (the SDK requires a product id even for inline //! price_data); the product is reused for subsequent re-prices. use std::collections::HashMap; use stripe_billing::subscription::{ CancelSubscription, CreateSubscription, CreateSubscriptionItems, CreateSubscriptionItemsPriceData, CreateSubscriptionItemsPriceDataRecurring, CreateSubscriptionItemsPriceDataRecurringInterval, RetrieveSubscription, UpdateSubscription, UpdateSubscriptionItems, UpdateSubscriptionItemsPriceData, UpdateSubscriptionItemsPriceDataRecurring, UpdateSubscriptionItemsPriceDataRecurringInterval, UpdateSubscriptionProrationBehavior, }; use stripe_core::customer::CreateCustomer; use stripe_product::product::CreateProduct; use stripe_types::Currency; use super::StripeClient; use crate::db::{SyncAppId, UserId}; use crate::error::{AppError, Result}; /// Result of creating a SyncKit subscription. Carries enough information for /// the route handler to stamp the local `sync_apps` row in one go. pub struct SynckitSubResult { pub subscription_id: String, pub current_period_start: i64, pub current_period_end: i64, } fn parse_subscription_id(id: &str) -> Result { id.parse().map_err(|e| { AppError::Internal(anyhow::anyhow!( "Invalid Stripe subscription ID '{}': {}", id, e )) }) } impl StripeClient { /// Create a Stripe Customer for a SyncKit app. The customer represents /// one app, not the developer's MNW account, because each app is billed /// independently. Metadata pins the customer to both developer and app /// for audit-trail visibility in the Stripe dashboard. #[tracing::instrument(skip_all, name = "payments::create_synckit_customer")] pub async fn create_synckit_customer( &self, developer_user_id: UserId, email: &str, app_name: &str, ) -> Result { let mut metadata = HashMap::new(); metadata.insert("mnw_user_id".to_string(), developer_user_id.to_string()); metadata.insert("synckit_app_name".to_string(), app_name.to_string()); let customer = CreateCustomer::new() .email(email.to_string()) .name(format!("SyncKit — {app_name}")) .metadata(metadata) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, "failed to create SyncKit Stripe customer"); AppError::Internal(anyhow::anyhow!("Failed to create Stripe customer")) })?; Ok(customer.id.to_string()) } /// Create a Stripe Product for a SyncKit app. Called once during billing /// activation; the same product is reused on re-price. async fn create_synckit_product(&self, app_name: &str) -> Result { let product = CreateProduct::new(format!("SyncKit — {app_name}")) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, "failed to create SyncKit Stripe product"); AppError::Internal(anyhow::anyhow!("Failed to create Stripe product")) })?; Ok(product.id.to_string()) } /// Create a monthly recurring subscription for a SyncKit app. The price /// is created inline via `price_data` (`unit_amount = price_cents`, /// `interval = month`, `currency = usd`). Metadata `synckit_app_id` lets /// the webhook dispatcher distinguish these from creator-tier / Fan+ /// subscriptions. #[tracing::instrument(skip_all, name = "payments::create_synckit_subscription")] pub async fn create_synckit_subscription( &self, customer_id: &str, app_id: SyncAppId, app_name: &str, price_cents: i64, ) -> Result { if price_cents <= 0 { return Err(AppError::BadRequest( "Subscription price must be positive".to_string(), )); } // We need a Product id to use inline price_data; create one per app. let product_id = self.create_synckit_product(app_name).await?; let price_data = CreateSubscriptionItemsPriceData { currency: Currency::USD, product: product_id, recurring: CreateSubscriptionItemsPriceDataRecurring::new( CreateSubscriptionItemsPriceDataRecurringInterval::Month, ), tax_behavior: None, unit_amount: Some(price_cents), unit_amount_decimal: None, }; let mut item = CreateSubscriptionItems::new(); item.price_data = Some(price_data); let mut metadata = HashMap::new(); metadata.insert("synckit_app_id".to_string(), app_id.to_string()); let subscription = CreateSubscription::new() .customer(customer_id.to_string()) .items(vec![item]) .metadata(metadata) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, app_id = %app_id, "failed to create SyncKit subscription"); AppError::Internal(anyhow::anyhow!("Failed to create Stripe subscription")) })?; let first_item = subscription.items.data.first().ok_or_else(|| { AppError::Internal(anyhow::anyhow!("Stripe subscription has no items")) })?; Ok(SynckitSubResult { subscription_id: subscription.id.to_string(), current_period_start: first_item.current_period_start, current_period_end: first_item.current_period_end, }) } /// Re-price a SyncKit subscription. Fetches the existing subscription to /// learn its item id, then attaches a new inline `price_data` with the /// new amount. Prorations are turned on (`create_prorations`) so the /// developer is credited / charged the difference on the next invoice. #[tracing::instrument(skip_all, name = "payments::update_synckit_subscription_price")] pub async fn update_synckit_subscription_price( &self, subscription_id: &str, new_price_cents: i64, app_name: &str, ) -> Result<()> { if new_price_cents <= 0 { return Err(AppError::BadRequest( "Subscription price must be positive".to_string(), )); } let sub_id = parse_subscription_id(subscription_id)?; // Need the existing subscription item id to update its price. let existing = RetrieveSubscription::new(sub_id.clone()) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to retrieve SyncKit subscription"); AppError::Internal(anyhow::anyhow!("Failed to retrieve Stripe subscription")) })?; let existing_item = existing.items.data.first().ok_or_else(|| { AppError::Internal(anyhow::anyhow!( "Stripe subscription {} has no items", subscription_id )) })?; // Reuse the existing item's product so we don't accumulate orphans. let product_id = existing_item.price.product.id().to_string(); let new_price_data = UpdateSubscriptionItemsPriceData { currency: Currency::USD, product: product_id, recurring: stripe_billing::subscription::UpdateSubscriptionItemsPriceDataRecurring::new( stripe_billing::subscription::UpdateSubscriptionItemsPriceDataRecurringInterval::Month, ), tax_behavior: None, unit_amount: Some(new_price_cents), unit_amount_decimal: None, }; let item = UpdateSubscriptionItems { id: Some(existing_item.id.to_string()), price_data: Some(new_price_data), ..Default::default() }; // The product name (which surfaces on the Stripe dashboard for this // product) is set once at create-time. Re-naming is a separate Stripe // call we currently don't need — record the param so future re-naming // hooks have it without changing the trait signature. let _ = app_name; UpdateSubscription::new(sub_id) .items(vec![item]) .proration_behavior(UpdateSubscriptionProrationBehavior::CreateProrations) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to update SyncKit subscription price"); AppError::Internal(anyhow::anyhow!("Failed to update Stripe subscription")) })?; Ok(()) } /// Re-price an end-user SyncKit app subscription (the per-user subs that /// run on MNW's own Stripe account, distinct from the developer-billing /// subs above). Used by the storage-cap change path: when a user queues a /// new cap, we update Stripe to charge the new price *at the next billing /// cycle* — `proration_behavior=None` — so the cap and the price flip /// together at the period boundary, matching the DB pending-cap semantics. #[tracing::instrument(skip_all, name = "payments::update_synckit_app_sub_price")] pub async fn update_synckit_app_sub_price( &self, subscription_id: &str, new_price_cents: i64, interval: super::SyncBillingInterval, product_name: &str, ) -> Result<()> { if new_price_cents <= 0 { return Err(AppError::BadRequest( "Subscription price must be positive".to_string(), )); } let sub_id = parse_subscription_id(subscription_id)?; let existing = RetrieveSubscription::new(sub_id.clone()) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to retrieve app sub"); AppError::Internal(anyhow::anyhow!("Failed to retrieve Stripe subscription")) })?; let existing_item = existing.items.data.first().ok_or_else(|| { AppError::Internal(anyhow::anyhow!( "Stripe subscription {} has no items", subscription_id )) })?; let product_id = existing_item.price.product.id().to_string(); let _ = product_name; let recurring_interval = match interval { super::SyncBillingInterval::Monthly => { UpdateSubscriptionItemsPriceDataRecurringInterval::Month } super::SyncBillingInterval::Annual => { UpdateSubscriptionItemsPriceDataRecurringInterval::Year } }; let new_price_data = UpdateSubscriptionItemsPriceData { currency: Currency::USD, product: product_id, recurring: UpdateSubscriptionItemsPriceDataRecurring::new(recurring_interval), tax_behavior: None, unit_amount: Some(new_price_cents), unit_amount_decimal: None, }; let item = UpdateSubscriptionItems { id: Some(existing_item.id.to_string()), price_data: Some(new_price_data), ..Default::default() }; UpdateSubscription::new(sub_id) .items(vec![item]) .proration_behavior(UpdateSubscriptionProrationBehavior::None) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to re-price app sub"); AppError::Internal(anyhow::anyhow!("Failed to update Stripe subscription")) })?; Ok(()) } /// Cancel a SyncKit subscription immediately. /// /// We cancel immediately (rather than at_period_end=true) because the /// developer is paying for cloud resources we'll stop providing the /// moment the app is canceled. Holding the subscription open for a few /// extra weeks would let the developer keep billing accruing against a /// dead app, worse for everyone. #[tracing::instrument(skip_all, name = "payments::cancel_synckit_subscription")] pub async fn cancel_synckit_subscription(&self, subscription_id: &str) -> Result<()> { let sub_id = parse_subscription_id(subscription_id)?; CancelSubscription::new(sub_id) .send(&self.client) .await .map_err(|e| { tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to cancel SyncKit subscription"); AppError::Internal(anyhow::anyhow!("Failed to cancel Stripe subscription")) })?; Ok(()) } /// Open a Stripe billing portal session for the SyncKit app's customer. /// Reuses the platform-level billing portal pattern. #[tracing::instrument(skip_all, name = "payments::create_synckit_billing_portal")] pub async fn create_synckit_billing_portal( &self, customer_id: &str, return_url: &str, ) -> Result { // Identical to the creator-tier / Fan+ billing portal path — kept as // a separate method so the trait surface mirrors the SyncKit domain. self.create_billing_portal_session(customer_id, return_url).await } }