//! 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. //! //! # Currency //! //! USD throughout, on purpose. This is Make Creative billing a developer for //! SyncKit, not a creator selling to a fan, so there is no settlement currency //! to read: the same rule that keeps the creator tiers and Fan+ in USD. The //! `Currency::USD` literals below are the intent, not a missed conversion. use std::collections::HashMap; use stripe::{IdempotencyKey, RequestStrategy, StripeRequest}; 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}; /// Build a deterministic Stripe idempotency key. Keying the SyncKit /// customer / product / subscription creates on the app id means two racing /// `activate` (or `setup`) requests, which each pass the `billing_status = /// 'draft'` read before either writes, return the *same* live Stripe object /// instead of orphaning a duplicate subscription that would bill with no local /// row. The DB `activate_billing` UPDATE is already /// `WHERE billing_status = 'draft'`-guarded, so the loser gets a Conflict; this /// keeps the Stripe side from leaking a second billable object in that window. fn synckit_idempotency_key(prefix: &str, app_id: SyncAppId) -> Result { IdempotencyKey::new(format!("{prefix}-{app_id}")) .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}"))) } /// 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}" )) }) } /// The Customer body for one SyncKit app. The customer represents the app, /// not the developer's MNW account, because each app is billed independently. /// /// Split from the method that sends it so the request Stripe is handed can be /// read back in a test; every builder below follows the same shape. fn synckit_customer_request( developer_user_id: UserId, email: &str, app_name: &str, ) -> CreateCustomer { 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()); CreateCustomer::new() .email(email.to_string()) .name(format!("SyncKit: {app_name}")) .metadata(metadata) } /// The Product body. Created once per app; inline `price_data` needs a /// product id even though the price itself is never a stored Price object. fn synckit_product_request(app_name: &str) -> CreateProduct { CreateProduct::new(format!("SyncKit: {app_name}")) } /// The monthly developer subscription, priced inline. `synckit_app_id` in the /// metadata is what the webhook dispatcher routes on, so a subscription /// without it arrives as an unrecognised creator-tier event. fn synckit_subscription_request( customer_id: &str, app_id: SyncAppId, product_id: &str, price_cents: i64, ) -> CreateSubscription { let price_data = CreateSubscriptionItemsPriceData { currency: Currency::USD, product: product_id.to_string(), 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()); CreateSubscription::new() .customer(customer_id.to_string()) .items(vec![item]) .metadata(metadata) } /// A re-price of an existing subscription item, reusing its product so /// orphans do not accumulate. `proration` decides whether the developer is /// charged the difference now or at the period boundary. fn reprice_request( sub_id: stripe_shared::SubscriptionId, item_id: &str, product_id: &str, new_price_cents: i64, interval: UpdateSubscriptionItemsPriceDataRecurringInterval, proration: UpdateSubscriptionProrationBehavior, ) -> UpdateSubscription { let new_price_data = UpdateSubscriptionItemsPriceData { currency: Currency::USD, product: product_id.to_string(), recurring: UpdateSubscriptionItemsPriceDataRecurring::new(interval), tax_behavior: None, unit_amount: Some(new_price_cents), unit_amount_decimal: None, }; let item = UpdateSubscriptionItems { id: Some(item_id.to_string()), price_data: Some(new_price_data), ..Default::default() }; UpdateSubscription::new(sub_id) .items(vec![item]) .proration_behavior(proration) } /// The recurring interval an end-user app subscription bills on. fn app_sub_interval( interval: super::SyncBillingInterval, ) -> UpdateSubscriptionItemsPriceDataRecurringInterval { match interval { super::SyncBillingInterval::Monthly => { UpdateSubscriptionItemsPriceDataRecurringInterval::Month } super::SyncBillingInterval::Annual => { UpdateSubscriptionItemsPriceDataRecurringInterval::Year } } } 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, app_id: SyncAppId, email: &str, app_name: &str, ) -> Result { let key = synckit_idempotency_key("synckit-customer", app_id)?; let customer = synckit_customer_request(developer_user_id, email, app_name) .customize() .request_strategy(RequestStrategy::Idempotent(key)) .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_id: SyncAppId, app_name: &str) -> Result { let key = synckit_idempotency_key("synckit-product", app_id)?; let product = synckit_product_request(app_name) .customize() .request_strategy(RequestStrategy::Idempotent(key)) .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_id, app_name).await?; let key = synckit_idempotency_key("synckit-sub", app_id)?; let subscription = synckit_subscription_request( customer_id, app_id, &product_id, price_cents, ) .customize() .request_strategy(RequestStrategy::Idempotent(key)) .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 {subscription_id} has no items" )) })?; // Reuse the existing item's product so we don't accumulate orphans. let product_id = existing_item.price.product.id().to_string(); // 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; reprice_request( sub_id, existing_item.id.as_ref(), &product_id, new_price_cents, UpdateSubscriptionItemsPriceDataRecurringInterval::Month, 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 {subscription_id} has no items" )) })?; let product_id = existing_item.price.product.id().to_string(); let _ = product_name; reprice_request( sub_id, existing_item.id.as_ref(), &product_id, new_price_cents, app_sub_interval(interval), 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 } } #[cfg(test)] mod tests { //! Idempotency keys for SyncKit's Stripe writes. The key is the only thing //! standing between two racing `activate` requests and a duplicate live //! subscription that bills a developer with no local row to cancel it, so //! its determinism is a billing invariant rather than a detail. use super::*; /// The form-encoded body a request would be sent with, decoded into pairs. /// `RequestBuilder` is the last point before the wire a test can read. fn form(req: &impl StripeRequest) -> std::collections::BTreeMap { let built = req.build(); let body = built.body.unwrap_or_default(); url::form_urlencoded::parse(body.as_bytes()) .map(|(k, v)| (k.into_owned(), v.into_owned())) .collect() } fn path_of(req: &impl StripeRequest) -> String { req.build().path } #[test] fn the_same_app_and_prefix_always_produce_the_same_key() { let app = SyncAppId::nil(); let a = synckit_idempotency_key("synckit-sub", app).expect("valid key"); let b = synckit_idempotency_key("synckit-sub", app).expect("valid key"); assert_eq!( format!("{a:?}"), format!("{b:?}"), "two racing activates must reuse one Stripe object, not create two" ); } #[test] fn a_different_operation_on_one_app_gets_a_different_key() { let app = SyncAppId::nil(); let sub = synckit_idempotency_key("synckit-sub", app).expect("valid key"); let cust = synckit_idempotency_key("synckit-cust", app).expect("valid key"); assert_ne!( format!("{sub:?}"), format!("{cust:?}"), "sharing a key across operations would make Stripe replay the wrong response" ); } #[test] fn an_over_long_prefix_is_an_error_rather_than_a_silently_truncated_key() { // Stripe caps idempotency keys at 255 characters. Truncation would make // two distinct operations collide, which is worse than failing loudly. let err = synckit_idempotency_key(&"x".repeat(300), SyncAppId::nil()); assert!( matches!(err, Err(AppError::Internal(_))), "an unusable key must not reach Stripe" ); } #[test] fn subscription_id_parsing_rejects_nothing_at_all() { // Not the contract this function's name and error message imply. // `stripe_shared::SubscriptionId` derives `FromStr` with // `type Err = Infallible`: it wraps the string and always succeeds. The // `AppError::Internal("Invalid Stripe subscription ID")` branch is // unreachable, so an empty or garbage id from our own row is passed // straight to Stripe's cancel / re-price calls. // // Documented rather than asserted-as-correct: adding a real check is a // money-path behaviour change. Filed as a problem against mnw-server. assert!(parse_subscription_id("").is_ok()); assert!(parse_subscription_id("not a sub id").is_ok()); assert!(parse_subscription_id("acct_wrong_type").is_ok()); } // ── what each method puts on the wire ── // // The `send` half of these methods cannot be reached without calling // Stripe; the request half can, and it is where the billing decisions are. // The currency is USD throughout on purpose (see the module header): this // is Make Creative billing a developer, not a creator selling to a fan. #[test] fn a_synckit_customer_is_the_app_rather_than_the_developer() { let req = synckit_customer_request(UserId::nil(), "dev@example.com", "Notes"); assert_eq!(path_of(&req), "/customers"); let f = form(&req); assert_eq!(f.get("email").map(String::as_str), Some("dev@example.com")); assert_eq!( f.get("name").map(String::as_str), Some("SyncKit: Notes"), "one customer per app, so the dashboard has to name the app" ); assert_eq!( f.get("metadata[synckit_app_name]").map(String::as_str), Some("Notes") ); assert_eq!( f.get("metadata[mnw_user_id]").map(String::as_str), Some(UserId::nil().to_string()).as_deref() ); } #[test] fn a_synckit_product_is_named_for_its_app() { let req = synckit_product_request("Notes"); assert_eq!(path_of(&req), "/products"); assert_eq!( form(&req).get("name").map(String::as_str), Some("SyncKit: Notes") ); } #[test] fn a_developer_subscription_prices_inline_and_routes_by_app_id() { let app = SyncAppId::nil(); let req = synckit_subscription_request("cus_123", app, "prod_123", 2500); assert_eq!(path_of(&req), "/subscriptions"); let f = form(&req); assert_eq!(f.get("customer").map(String::as_str), Some("cus_123")); assert_eq!( f.get("items[0][price_data][product]").map(String::as_str), Some("prod_123") ); assert_eq!( f.get("items[0][price_data][unit_amount]") .map(String::as_str), Some("2500") ); assert_eq!( f.get("items[0][price_data][currency]").map(String::as_str), Some("usd") ); assert_eq!( f.get("items[0][price_data][recurring][interval]") .map(String::as_str), Some("month"), "without `recurring` Stripe bills the developer once, not monthly" ); assert_eq!( f.get("metadata[synckit_app_id]").map(String::as_str), Some(app.to_string()).as_deref(), "the webhook dispatcher routes on this; without it the event reads \ as a creator-tier one" ); } #[test] fn a_developer_reprice_prorates_and_reuses_the_existing_item() { let req = reprice_request( "sub_1A2b3C".parse().unwrap(), "si_123", "prod_123", 4000, UpdateSubscriptionItemsPriceDataRecurringInterval::Month, UpdateSubscriptionProrationBehavior::CreateProrations, ); assert_eq!(path_of(&req), "/subscriptions/sub_1A2b3C"); let f = form(&req); assert_eq!( f.get("items[0][id]").map(String::as_str), Some("si_123"), "re-pricing the existing item rather than adding one is what keeps \ the developer on a single charge" ); assert_eq!( f.get("items[0][price_data][product]").map(String::as_str), Some("prod_123") ); assert_eq!( f.get("items[0][price_data][unit_amount]") .map(String::as_str), Some("4000") ); assert_eq!( f.get("proration_behavior").map(String::as_str), Some("create_prorations"), "the developer is credited or charged the difference on the next \ invoice" ); } #[test] fn an_end_user_reprice_waits_for_the_period_boundary() { // The DB queues the cap change to the next cycle, so the price has to // flip at the same moment. Prorating here would charge for storage the // user does not have yet. let req = reprice_request( "sub_1A2b3C".parse().unwrap(), "si_123", "prod_123", 900, app_sub_interval(super::super::SyncBillingInterval::Monthly), UpdateSubscriptionProrationBehavior::None, ); assert_eq!( form(&req).get("proration_behavior").map(String::as_str), Some("none") ); } #[test] fn the_billing_interval_reaches_stripe_as_the_one_the_user_bought() { for (interval, want) in [ (super::super::SyncBillingInterval::Monthly, "month"), (super::super::SyncBillingInterval::Annual, "year"), ] { let req = reprice_request( "sub_1A2b3C".parse().unwrap(), "si_123", "prod_123", 900, app_sub_interval(interval), UpdateSubscriptionProrationBehavior::None, ); assert_eq!( form(&req) .get("items[0][price_data][recurring][interval]") .map(String::as_str), Some(want), "an annual subscriber re-priced monthly is billed twelve times \ over" ); } } #[test] fn a_synckit_cancel_is_immediate_rather_than_at_period_end() { // The developer is paying for resources that stop the moment the app // is canceled, so the request is a DELETE of the subscription and not // an update carrying cancel_at_period_end. let req = CancelSubscription::new( "sub_1A2b3C" .parse::() .unwrap(), ); assert_eq!(path_of(&req), "/subscriptions/sub_1A2b3C"); assert_eq!(format!("{:?}", req.build().method), "Delete"); } }