max / makenotwork
21 files changed,
+1154 insertions,
-90 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.8.1" | |
| 3 | + | version = "0.8.7" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 |
| @@ -1506,7 +1506,7 @@ | |||
| 1506 | 1506 | ||
| 1507 | 1507 | [[package]] | |
| 1508 | 1508 | name = "synckit-client" | |
| 1509 | - | version = "0.3.1" | |
| 1509 | + | version = "0.4.0" | |
| 1510 | 1510 | dependencies = [ | |
| 1511 | 1511 | "argon2", | |
| 1512 | 1512 | "base64", |
| @@ -956,6 +956,7 @@ | |||
| 956 | 956 | FanPlus, | |
| 957 | 957 | CreatorTier, | |
| 958 | 958 | Cart, | |
| 959 | + | SynckitAppSub, | |
| 959 | 960 | } | |
| 960 | 961 | ||
| 961 | 962 | impl_str_enum!(CheckoutType { | |
| @@ -965,6 +966,7 @@ | |||
| 965 | 966 | FanPlus => "fan_plus", | |
| 966 | 967 | CreatorTier => "creator_tier", | |
| 967 | 968 | Cart => "cart", | |
| 969 | + | SynckitAppSub => "synckit_app_sub", | |
| 968 | 970 | }); | |
| 969 | 971 | ||
| 970 | 972 | impl ModerationActionType { |
| @@ -8,14 +8,15 @@ | |||
| 8 | 8 | use stripe::StripeRequest; | |
| 9 | 9 | use stripe_checkout::checkout_session::{ | |
| 10 | 10 | CreateCheckoutSession, CreateCheckoutSessionAutomaticTax, CreateCheckoutSessionLineItems, | |
| 11 | - | CreateCheckoutSessionLineItemsPriceData, | |
| 11 | + | CreateCheckoutSessionLineItemsPriceData, CreateCheckoutSessionLineItemsPriceDataRecurring, | |
| 12 | + | CreateCheckoutSessionLineItemsPriceDataRecurringInterval, | |
| 12 | 13 | CreateCheckoutSessionSubscriptionData, ProductData, | |
| 13 | 14 | }; | |
| 14 | 15 | use stripe_shared::CheckoutSessionMode; | |
| 15 | 16 | use stripe_types::Currency; | |
| 16 | 17 | ||
| 17 | 18 | use crate::constants; | |
| 18 | - | use crate::db::{Cents, CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, UserId}; | |
| 19 | + | use crate::db::{Cents, CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, SyncAppId, UserId}; | |
| 19 | 20 | use crate::error::{AppError, Result}; | |
| 20 | 21 | use super::StripeClient; | |
| 21 | 22 | ||
| @@ -122,6 +123,27 @@ | |||
| 122 | 123 | } | |
| 123 | 124 | } | |
| 124 | 125 | ||
| 126 | + | /// Build an inline recurring line item — used by SyncKit app subscriptions | |
| 127 | + | /// so we don't have to pre-provision Stripe Products and Prices for every | |
| 128 | + | /// (app, tier, interval) combination. | |
| 129 | + | fn build_inline_recurring_line_item( | |
| 130 | + | product_name: &str, | |
| 131 | + | amount_cents: i64, | |
| 132 | + | interval: CreateCheckoutSessionLineItemsPriceDataRecurringInterval, | |
| 133 | + | ) -> CreateCheckoutSessionLineItems { | |
| 134 | + | CreateCheckoutSessionLineItems { | |
| 135 | + | price_data: Some(CreateCheckoutSessionLineItemsPriceData { | |
| 136 | + | currency: Currency::USD, | |
| 137 | + | product_data: Some(ProductData::new(product_name.to_string())), | |
| 138 | + | unit_amount: Some(amount_cents), | |
| 139 | + | recurring: Some(CreateCheckoutSessionLineItemsPriceDataRecurring::new(interval)), | |
| 140 | + | ..CreateCheckoutSessionLineItemsPriceData::new(Currency::USD) | |
| 141 | + | }), | |
| 142 | + | quantity: Some(1), | |
| 143 | + | ..CreateCheckoutSessionLineItems::new() | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 125 | 147 | fn automatic_tax(enable: bool) -> Option<CreateCheckoutSessionAutomaticTax> { | |
| 126 | 148 | if enable { | |
| 127 | 149 | Some(CreateCheckoutSessionAutomaticTax::new(true)) | |
| @@ -371,4 +393,41 @@ | |||
| 371 | 393 | self.send_on_platform(builder, "creator_tier_checkout").await | |
| 372 | 394 | } | |
| 373 | 395 | ||
| 396 | + | /// Build a Checkout Session for an end-user subscribing to an app's cloud | |
| 397 | + | /// sync (SyncKit). Runs on MNW's own Stripe account. Uses inline | |
| 398 | + | /// `price_data` so no Stripe Products/Prices need to be pre-configured — | |
| 399 | + | /// the tier name and cents come from the `sync_app_tiers` row. | |
| 400 | + | #[tracing::instrument(skip_all, name = "payments::create_synckit_app_sub_checkout_session")] | |
| 401 | + | pub async fn create_synckit_app_sub_checkout_session( | |
| 402 | + | &self, | |
| 403 | + | product_name: &str, | |
| 404 | + | amount_cents: i64, | |
| 405 | + | interval: CreateCheckoutSessionLineItemsPriceDataRecurringInterval, | |
| 406 | + | user_id: UserId, | |
| 407 | + | app_id: SyncAppId, | |
| 408 | + | tier: &str, | |
| 409 | + | storage_limit_bytes: Option<i64>, | |
| 410 | + | success_url: &str, | |
| 411 | + | cancel_url: &str, | |
| 412 | + | ) -> Result<stripe_shared::CheckoutSession> { | |
| 413 | + | let mut metadata = HashMap::new(); | |
| 414 | + | metadata.insert("checkout_type".to_string(), CheckoutType::SynckitAppSub.to_string()); | |
| 415 | + | metadata.insert("user_id".to_string(), user_id.to_string()); | |
| 416 | + | metadata.insert("app_id".to_string(), app_id.to_string()); | |
| 417 | + | metadata.insert("tier".to_string(), tier.to_string()); | |
| 418 | + | if let Some(bytes) = storage_limit_bytes { | |
| 419 | + | metadata.insert("storage_limit_bytes".to_string(), bytes.to_string()); | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | let line_item = build_inline_recurring_line_item(product_name, amount_cents, interval); | |
| 423 | + | ||
| 424 | + | let builder = CreateCheckoutSession::new() | |
| 425 | + | .mode(CheckoutSessionMode::Subscription) | |
| 426 | + | .success_url(success_url.to_string()) | |
| 427 | + | .cancel_url(cancel_url.to_string()) | |
| 428 | + | .line_items(vec![line_item]) | |
| 429 | + | .metadata(metadata); | |
| 430 | + | ||
| 431 | + | self.send_on_platform(builder, "synckit_app_sub_checkout").await | |
| 432 | + | } | |
| 374 | 433 | } |
| @@ -7,7 +7,7 @@ | |||
| 7 | 7 | ||
| 8 | 8 | use std::collections::HashMap; | |
| 9 | 9 | ||
| 10 | - | use crate::db::{CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, UserId}; | |
| 10 | + | use crate::db::{CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, SyncAppId, UserId}; | |
| 11 | 11 | use crate::error::{AppError, Result}; | |
| 12 | 12 | ||
| 13 | 13 | /// Convenience alias for the metadata pulled off a Stripe `CheckoutSession`. | |
| @@ -176,6 +176,31 @@ | |||
| 176 | 176 | get_checkout_type(meta) == Some(CheckoutType::Cart) | |
| 177 | 177 | } | |
| 178 | 178 | ||
| 179 | + | pub fn is_synckit_app_sub_checkout(meta: Option<&CheckoutMetaMap>) -> bool { | |
| 180 | + | get_checkout_type(meta) == Some(CheckoutType::SynckitAppSub) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | /// Parsed metadata for an end-user subscribing to an app's cloud sync. | |
| 184 | + | #[derive(Debug)] | |
| 185 | + | pub struct SynckitAppSubCheckoutMetadata { | |
| 186 | + | pub user_id: UserId, | |
| 187 | + | pub app_id: SyncAppId, | |
| 188 | + | pub tier: String, | |
| 189 | + | pub storage_limit_bytes: Option<i64>, | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | impl SynckitAppSubCheckoutMetadata { | |
| 193 | + | pub fn from_metadata(meta: Option<&CheckoutMetaMap>) -> Result<Self> { | |
| 194 | + | let user_id: UserId = parse_uuid_to(require(meta, "user_id")?, "user_id")?; | |
| 195 | + | let app_id: SyncAppId = parse_uuid_to(require(meta, "app_id")?, "app_id")?; | |
| 196 | + | let tier = require(meta, "tier")?.clone(); | |
| 197 | + | let storage_limit_bytes = meta | |
| 198 | + | .and_then(|m| m.get("storage_limit_bytes")) | |
| 199 | + | .and_then(|v| v.parse::<i64>().ok()); | |
| 200 | + | Ok(SynckitAppSubCheckoutMetadata { user_id, app_id, tier, storage_limit_bytes }) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 179 | 204 | #[cfg(test)] | |
| 180 | 205 | mod tests { | |
| 181 | 206 | use super::*; |
| @@ -17,11 +17,13 @@ | |||
| 17 | 17 | mod checkout; | |
| 18 | 18 | mod checkout_metadata; | |
| 19 | 19 | mod connect; | |
| 20 | + | pub mod synckit_app_pricing; | |
| 20 | 21 | pub mod synckit_billing; | |
| 21 | 22 | mod webhooks; | |
| 22 | 23 | ||
| 23 | 24 | pub use checkout::*; | |
| 24 | 25 | pub use checkout_metadata::*; | |
| 26 | + | pub use synckit_app_pricing::{quote_price_cents, SyncBillingInterval, ANNUAL_MULTIPLIER, MAX_CAP_BYTES, MIN_CAP_BYTES, MIN_CHARGE_CENTS}; | |
| 25 | 27 | pub use synckit_billing::SynckitSubResult; | |
| 26 | 28 | pub use webhooks::*; | |
| 27 | 29 | ||
| @@ -77,6 +79,7 @@ | |||
| 77 | 79 | async fn create_tip_checkout_session(&self, params: &TipCheckoutParams<'_>) -> crate::error::Result<CheckoutResult>; | |
| 78 | 80 | 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<CheckoutResult>; | |
| 79 | 81 | async fn create_creator_tier_checkout_session(&self, price_id: &str, user_id: crate::db::UserId, tier: &str, success_url: &str, cancel_url: &str) -> crate::error::Result<CheckoutResult>; | |
| 82 | + | async fn create_synckit_app_sub_checkout_session(&self, product_name: &str, amount_cents: i64, interval: &str, user_id: crate::db::UserId, app_id: crate::db::SyncAppId, tier: &str, storage_limit_bytes: Option<i64>, success_url: &str, cancel_url: &str) -> crate::error::Result<CheckoutResult>; | |
| 80 | 83 | async fn create_cart_checkout_session(&self, params: &CartCheckoutParams<'_>) -> crate::error::Result<CheckoutResult>; | |
| 81 | 84 | ||
| 82 | 85 | // Connect | |
| @@ -112,6 +115,9 @@ | |||
| 112 | 115 | async fn create_synckit_customer(&self, developer_user_id: crate::db::UserId, email: &str, app_name: &str) -> crate::error::Result<String>; | |
| 113 | 116 | async fn create_synckit_subscription(&self, customer_id: &str, app_id: crate::db::SyncAppId, app_name: &str, price_cents: i64) -> crate::error::Result<SynckitSubResult>; | |
| 114 | 117 | async fn update_synckit_subscription_price(&self, subscription_id: &str, new_price_cents: i64, app_name: &str) -> crate::error::Result<()>; | |
| 118 | + | /// Re-price an end-user SyncKit app subscription. Used by the cap-change | |
| 119 | + | /// path; takes effect at next billing cycle (no proration). | |
| 120 | + | async fn update_synckit_app_sub_price(&self, subscription_id: &str, new_price_cents: i64, interval: SyncBillingInterval, product_name: &str) -> crate::error::Result<()>; | |
| 115 | 121 | async fn cancel_synckit_subscription(&self, subscription_id: &str) -> crate::error::Result<()>; | |
| 116 | 122 | async fn create_synckit_billing_portal(&self, customer_id: &str, return_url: &str) -> crate::error::Result<String>; | |
| 117 | 123 | } | |
| @@ -148,6 +154,17 @@ | |||
| 148 | 154 | Ok(CheckoutResult { id: session.id.to_string(), url: session.url }) | |
| 149 | 155 | } | |
| 150 | 156 | ||
| 157 | + | async fn create_synckit_app_sub_checkout_session(&self, product_name: &str, amount_cents: i64, interval: &str, user_id: crate::db::UserId, app_id: crate::db::SyncAppId, tier: &str, storage_limit_bytes: Option<i64>, success_url: &str, cancel_url: &str) -> crate::error::Result<CheckoutResult> { | |
| 158 | + | use stripe_checkout::checkout_session::CreateCheckoutSessionLineItemsPriceDataRecurringInterval as Recurring; | |
| 159 | + | let interval = match interval { | |
| 160 | + | "monthly" => Recurring::Month, | |
| 161 | + | "annual" => Recurring::Year, | |
| 162 | + | other => return Err(crate::error::AppError::BadRequest(format!("Invalid interval '{other}'"))), | |
| 163 | + | }; | |
| 164 | + | let session = StripeClient::create_synckit_app_sub_checkout_session(self, product_name, amount_cents, interval, user_id, app_id, tier, storage_limit_bytes, success_url, cancel_url).await?; | |
| 165 | + | Ok(CheckoutResult { id: session.id.to_string(), url: session.url }) | |
| 166 | + | } | |
| 167 | + | ||
| 151 | 168 | async fn create_cart_checkout_session(&self, params: &CartCheckoutParams<'_>) -> crate::error::Result<CheckoutResult> { | |
| 152 | 169 | let session = StripeClient::create_cart_checkout_session(self, params).await?; | |
| 153 | 170 | Ok(CheckoutResult { id: session.id.to_string(), url: session.url }) | |
| @@ -238,6 +255,10 @@ | |||
| 238 | 255 | StripeClient::update_synckit_subscription_price(self, subscription_id, new_price_cents, app_name).await | |
| 239 | 256 | } | |
| 240 | 257 | ||
| 258 | + | async fn update_synckit_app_sub_price(&self, subscription_id: &str, new_price_cents: i64, interval: SyncBillingInterval, product_name: &str) -> crate::error::Result<()> { | |
| 259 | + | StripeClient::update_synckit_app_sub_price(self, subscription_id, new_price_cents, interval, product_name).await | |
| 260 | + | } | |
| 261 | + | ||
| 241 | 262 | async fn cancel_synckit_subscription(&self, subscription_id: &str) -> crate::error::Result<()> { | |
| 242 | 263 | StripeClient::cancel_synckit_subscription(self, subscription_id).await | |
| 243 | 264 | } |
| @@ -17,7 +17,9 @@ | |||
| 17 | 17 | CancelSubscription, CreateSubscription, CreateSubscriptionItems, | |
| 18 | 18 | CreateSubscriptionItemsPriceData, CreateSubscriptionItemsPriceDataRecurring, | |
| 19 | 19 | CreateSubscriptionItemsPriceDataRecurringInterval, RetrieveSubscription, UpdateSubscription, | |
| 20 | - | UpdateSubscriptionItems, UpdateSubscriptionItemsPriceData, UpdateSubscriptionProrationBehavior, | |
| 20 | + | UpdateSubscriptionItems, UpdateSubscriptionItemsPriceData, | |
| 21 | + | UpdateSubscriptionItemsPriceDataRecurring, UpdateSubscriptionItemsPriceDataRecurringInterval, | |
| 22 | + | UpdateSubscriptionProrationBehavior, | |
| 21 | 23 | }; | |
| 22 | 24 | use stripe_core::customer::CreateCustomer; | |
| 23 | 25 | use stripe_product::product::CreateProduct; | |
| @@ -221,6 +223,81 @@ | |||
| 221 | 223 | Ok(()) | |
| 222 | 224 | } | |
| 223 | 225 | ||
| 226 | + | /// Re-price an end-user SyncKit app subscription (the per-user subs that | |
| 227 | + | /// run on MNW's own Stripe account, distinct from the developer-billing | |
| 228 | + | /// subs above). Used by the storage-cap change path: when a user queues a | |
| 229 | + | /// new cap, we update Stripe to charge the new price *at the next billing | |
| 230 | + | /// cycle* — `proration_behavior=None` — so the cap and the price flip | |
| 231 | + | /// together at the period boundary, matching the DB pending-cap semantics. | |
| 232 | + | #[tracing::instrument(skip_all, name = "payments::update_synckit_app_sub_price")] | |
| 233 | + | pub async fn update_synckit_app_sub_price( | |
| 234 | + | &self, | |
| 235 | + | subscription_id: &str, | |
| 236 | + | new_price_cents: i64, | |
| 237 | + | interval: super::SyncBillingInterval, | |
| 238 | + | product_name: &str, | |
| 239 | + | ) -> Result<()> { | |
| 240 | + | if new_price_cents <= 0 { | |
| 241 | + | return Err(AppError::BadRequest( | |
| 242 | + | "Subscription price must be positive".to_string(), | |
| 243 | + | )); | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | let sub_id = parse_subscription_id(subscription_id)?; | |
| 247 | + | ||
| 248 | + | let existing = RetrieveSubscription::new(sub_id.clone()) | |
| 249 | + | .send(&self.client) | |
| 250 | + | .await | |
| 251 | + | .map_err(|e| { | |
| 252 | + | tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to retrieve app sub"); | |
| 253 | + | AppError::Internal(anyhow::anyhow!("Failed to retrieve Stripe subscription")) | |
| 254 | + | })?; | |
| 255 | + | ||
| 256 | + | let existing_item = existing.items.data.first().ok_or_else(|| { | |
| 257 | + | AppError::Internal(anyhow::anyhow!( | |
| 258 | + | "Stripe subscription {} has no items", | |
| 259 | + | subscription_id | |
| 260 | + | )) | |
| 261 | + | })?; | |
| 262 | + | ||
| 263 | + | let product_id = existing_item.price.product.id().to_string(); | |
| 264 | + | let _ = product_name; | |
| 265 | + | ||
| 266 | + | let recurring_interval = match interval { | |
| 267 | + | super::SyncBillingInterval::Monthly => { | |
| 268 | + | UpdateSubscriptionItemsPriceDataRecurringInterval::Month | |
| 269 | + | } | |
| 270 | + | super::SyncBillingInterval::Annual => { | |
| 271 | + | UpdateSubscriptionItemsPriceDataRecurringInterval::Year | |
| 272 | + | } | |
| 273 | + | }; | |
| 274 | + | ||
| 275 | + | let new_price_data = UpdateSubscriptionItemsPriceData { | |
| 276 | + | currency: Currency::USD, | |
| 277 | + | product: product_id, | |
| 278 | + | recurring: UpdateSubscriptionItemsPriceDataRecurring::new(recurring_interval), | |
| 279 | + | tax_behavior: None, | |
| 280 | + | unit_amount: Some(new_price_cents), | |
| 281 | + | unit_amount_decimal: None, | |
| 282 | + | }; | |
| 283 | + | ||
| 284 | + | let mut item = UpdateSubscriptionItems::default(); | |
| 285 | + | item.id = Some(existing_item.id.to_string()); | |
| 286 | + | item.price_data = Some(new_price_data); | |
| 287 | + | ||
| 288 | + | UpdateSubscription::new(sub_id) | |
| 289 | + | .items(vec![item]) | |
| 290 | + | .proration_behavior(UpdateSubscriptionProrationBehavior::None) | |
| 291 | + | .send(&self.client) | |
| 292 | + | .await | |
| 293 | + | .map_err(|e| { | |
| 294 | + | tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to re-price app sub"); | |
| 295 | + | AppError::Internal(anyhow::anyhow!("Failed to update Stripe subscription")) | |
| 296 | + | })?; | |
| 297 | + | ||
| 298 | + | Ok(()) | |
| 299 | + | } | |
| 300 | + | ||
| 224 | 301 | /// Cancel a SyncKit subscription immediately. | |
| 225 | 302 | /// | |
| 226 | 303 | /// We cancel immediately (rather than at_period_end=true) because the |