Skip to main content

max / makenotwork

server: synckit-app-sub end-user billing (first-party apps) Restores per-app end-user SyncKit subscriptions for first-party apps (GO, BB, AF) on top of the developer-pays-MNW base model. Migration 098 created the table; migration 117 dropped it for the developer-side rewrite; 120 brings it back with a new pending_storage_limit_bytes column for cap changes that take effect at the next billing cycle. Pricing model is now formula-driven (payments::synckit_app_pricing) — no tier table, the user picks any cap and the server quotes a price. CheckoutType::SynckitAppSub variant for the new checkout flow. Stripe webhook handlers wire the create / cap-change / cancel lifecycle. synckit-client SDK gains the AppPricing / PriceQuote / BillingInterval types for client-side rendering of the cap-slider UX. Cargo.toml: version bump 0.8.1 → 0.8.7.
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-24 21:07 UTC
Commit: 39c687617d1443ad49803e4c133fed9a8764f40d
Parent: 80cc342
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