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, +1153 insertions, -89 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
@@ -0,0 +1,33 @@
1 + -- End-user SyncKit subscriptions, reintroduced for first-party apps.
2 + --
3 + -- Migration 098 created `app_sync_subscriptions` and migration 117 dropped it
4 + -- in favor of a developer-pays-MNW billing model. Per-app end-user billing
5 + -- was supposed to live "outside SyncKit" for first-party apps (GO/BB/AF),
6 + -- but we ended up reusing the SyncKit auth/server plumbing for it. This
7 + -- migration restores the table with the original shape plus a new
8 + -- `pending_storage_limit_bytes` column for cap changes that take effect at
9 + -- the next billing cycle (no mid-cycle proration surprises).
10 + --
11 + -- The pricing model is now formula-driven (see `payments::synckit_app_pricing`)
12 + -- — no tier table, the user picks any cap and the server quotes a price.
13 + CREATE TABLE app_sync_subscriptions (
14 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
15 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
16 + app_id UUID NOT NULL REFERENCES sync_apps(id) ON DELETE CASCADE,
17 + stripe_subscription_id TEXT NOT NULL UNIQUE,
18 + stripe_customer_id TEXT NOT NULL,
19 + -- Billing interval ("monthly" or "annual"). Reuses the legacy `tier`
20 + -- column name so existing SDK code that reads `sub.tier` keeps working.
21 + tier TEXT NOT NULL DEFAULT 'monthly',
22 + status TEXT NOT NULL DEFAULT 'active',
23 + storage_limit_bytes BIGINT,
24 + -- Queued cap change applied at the next billing cycle.
25 + pending_storage_limit_bytes BIGINT,
26 + current_period_start TIMESTAMPTZ,
27 + current_period_end TIMESTAMPTZ,
28 + canceled_at TIMESTAMPTZ,
29 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
30 + );
31 +
32 + CREATE UNIQUE INDEX idx_app_sync_subs_user_app ON app_sync_subscriptions(user_id, app_id);
33 + CREATE INDEX idx_app_sync_subs_stripe ON app_sync_subscriptions(stripe_subscription_id);
@@ -956,6 +956,7 @@ pub enum CheckoutType {
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 @@ impl_str_enum!(CheckoutType {
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 {
@@ -10,6 +10,7 @@ mod devices;
10 10 mod keys;
11 11 mod log;
12 12 mod rotation;
13 + mod subscriptions;
13 14
14 15 pub use apps::*;
15 16 pub use blobs::*;
@@ -17,3 +18,4 @@ pub use devices::*;
17 18 pub use keys::*;
18 19 pub use log::*;
19 20 pub use rotation::*;
21 + pub use subscriptions::*;
@@ -0,0 +1,178 @@
1 + use chrono::{DateTime, Utc};
2 + use sqlx::PgPool;
3 +
4 + use crate::db::{SyncAppId, UserId};
5 + use crate::error::Result;
6 +
7 + /// Parameters for inserting a new app sync subscription from a webhook event.
8 + pub struct NewAppSyncSubscription<'a> {
9 + pub user_id: UserId,
10 + pub app_id: SyncAppId,
11 + pub stripe_subscription_id: &'a str,
12 + pub stripe_customer_id: &'a str,
13 + /// Billing interval ("monthly" / "annual"). Persisted in the `tier` column
14 + /// (kept lowercase for that legacy name) so cap-change handlers know which
15 + /// interval the user is on without round-tripping to Stripe.
16 + pub interval: &'a str,
17 + pub storage_limit_bytes: i64,
18 + }
19 +
20 + /// End-user subscription to an app's cloud sync (rows in `app_sync_subscriptions`).
21 + #[derive(Debug, sqlx::FromRow)]
22 + pub struct DbAppSyncSubscription {
23 + pub stripe_subscription_id: String,
24 + pub interval: String,
25 + pub status: String,
26 + pub storage_limit_bytes: Option<i64>,
27 + pub pending_storage_limit_bytes: Option<i64>,
28 + pub current_period_end: Option<DateTime<Utc>>,
29 + }
30 +
31 + /// Look up the active subscription a user has on an app, if any.
32 + /// Returns `None` if the user has never subscribed or the subscription was deleted.
33 + #[tracing::instrument(skip_all)]
34 + pub async fn get_user_app_subscription(
35 + pool: &PgPool,
36 + user_id: UserId,
37 + app_id: SyncAppId,
38 + ) -> Result<Option<DbAppSyncSubscription>> {
39 + let row = sqlx::query_as::<_, DbAppSyncSubscription>(
40 + r#"
41 + SELECT stripe_subscription_id,
42 + tier AS interval,
43 + status,
44 + storage_limit_bytes,
45 + pending_storage_limit_bytes,
46 + current_period_end
47 + FROM app_sync_subscriptions
48 + WHERE user_id = $1 AND app_id = $2
49 + "#,
50 + )
51 + .bind(user_id)
52 + .bind(app_id)
53 + .fetch_optional(pool)
54 + .await?;
55 + Ok(row)
56 + }
57 +
58 + /// Insert a new app sync subscription. Returns `Ok(true)` if a row was inserted,
59 + /// `Ok(false)` if a subscription already existed for this (user, app) pair
60 + /// (idempotent webhook replay).
61 + #[tracing::instrument(skip_all)]
62 + pub async fn create_app_sync_subscription(
63 + pool: &PgPool,
64 + sub: &NewAppSyncSubscription<'_>,
65 + ) -> Result<bool> {
66 + let result = sqlx::query(
67 + r#"
68 + INSERT INTO app_sync_subscriptions
69 + (user_id, app_id, stripe_subscription_id, stripe_customer_id,
70 + tier, status, storage_limit_bytes)
71 + VALUES ($1, $2, $3, $4, $5, 'active', $6)
72 + ON CONFLICT (user_id, app_id) DO NOTHING
73 + "#,
74 + )
75 + .bind(sub.user_id)
76 + .bind(sub.app_id)
77 + .bind(sub.stripe_subscription_id)
78 + .bind(sub.stripe_customer_id)
79 + .bind(sub.interval)
80 + .bind(sub.storage_limit_bytes)
81 + .execute(pool)
82 + .await?;
83 + Ok(result.rows_affected() > 0)
84 + }
85 +
86 + /// Update the status of an existing app sync subscription (e.g. on
87 + /// `customer.subscription.updated` or `.deleted` webhook events).
88 + #[tracing::instrument(skip_all)]
89 + pub async fn update_app_sync_subscription_status(
90 + pool: &PgPool,
91 + stripe_subscription_id: &str,
92 + status: &str,
93 + current_period_end: Option<DateTime<Utc>>,
94 + ) -> Result<()> {
95 + sqlx::query(
96 + r#"
97 + UPDATE app_sync_subscriptions
98 + SET status = $2,
99 + current_period_end = COALESCE($3, current_period_end),
100 + canceled_at = CASE WHEN $2 = 'canceled' THEN NOW() ELSE canceled_at END
101 + WHERE stripe_subscription_id = $1
102 + "#,
103 + )
104 + .bind(stripe_subscription_id)
105 + .bind(status)
106 + .bind(current_period_end)
107 + .execute(pool)
108 + .await?;
109 + Ok(())
110 + }
111 +
112 + /// Look up an app sync subscription by its Stripe subscription ID. Used by
113 + /// webhook handlers to find the row to update.
114 + #[tracing::instrument(skip_all)]
115 + pub async fn get_subscription_by_stripe_id(
116 + pool: &PgPool,
117 + stripe_subscription_id: &str,
118 + ) -> Result<Option<(UserId, SyncAppId)>> {
119 + let row: Option<(UserId, SyncAppId)> = sqlx::query_as(
120 + r#"
121 + SELECT user_id, app_id
122 + FROM app_sync_subscriptions
123 + WHERE stripe_subscription_id = $1
124 + "#,
125 + )
126 + .bind(stripe_subscription_id)
127 + .fetch_optional(pool)
128 + .await?;
129 + Ok(row)
130 + }
131 +
132 + /// Queue a storage-cap change to apply at the next billing cycle. Stores the
133 + /// new cap in `pending_storage_limit_bytes`; the renewal webhook handler
134 + /// promotes it to `storage_limit_bytes` once Stripe confirms the period roll.
135 + #[tracing::instrument(skip_all)]
136 + pub async fn set_pending_storage_cap(
137 + pool: &PgPool,
138 + user_id: UserId,
139 + app_id: SyncAppId,
140 + pending_bytes: i64,
141 + ) -> Result<()> {
142 + sqlx::query(
143 + r#"
144 + UPDATE app_sync_subscriptions
145 + SET pending_storage_limit_bytes = $3
146 + WHERE user_id = $1 AND app_id = $2
147 + "#,
148 + )
149 + .bind(user_id)
150 + .bind(app_id)
151 + .bind(pending_bytes)
152 + .execute(pool)
153 + .await?;
154 + Ok(())
155 + }
156 +
157 + /// Promote a queued cap change to the active cap. Called from the renewal
158 + /// webhook handler when Stripe rolls the subscription to a new period.
159 + /// No-op if no pending change is queued.
160 + #[tracing::instrument(skip_all)]
161 + pub async fn apply_pending_storage_cap(
162 + pool: &PgPool,
163 + stripe_subscription_id: &str,
164 + ) -> Result<()> {
165 + sqlx::query(
166 + r#"
167 + UPDATE app_sync_subscriptions
168 + SET storage_limit_bytes = pending_storage_limit_bytes,
169 + pending_storage_limit_bytes = NULL
170 + WHERE stripe_subscription_id = $1
171 + AND pending_storage_limit_bytes IS NOT NULL
172 + "#,
173 + )
174 + .bind(stripe_subscription_id)
175 + .execute(pool)
176 + .await?;
177 + Ok(())
178 + }
@@ -8,14 +8,15 @@ use std::collections::HashMap;
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 @@ fn build_price_line_item(price_id: &str) -> CreateCheckoutSessionLineItems {
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 @@ impl StripeClient {
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 @@ pub fn is_cart_checkout(meta: Option<&CheckoutMetaMap>) -> bool {
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 @@ pub trait PaymentProvider: Send + Sync {
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 @@ pub trait PaymentProvider: Send + Sync {
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 @@ impl PaymentProvider for StripeClient {
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 @@ impl PaymentProvider for StripeClient {
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 }
@@ -0,0 +1,145 @@
1 + //! Formula-driven pricing for end-user SyncKit subscriptions.
2 + //!
3 + //! No tier table — users pick any storage cap and the server quotes a price
4 + //! computed from a fixed formula. Constants are kept in code (not the DB)
5 + //! because there is exactly one formula in production today and a schema row
6 + //! would just be indirection.
7 +
8 + use crate::error::{AppError, Result};
9 +
10 + /// Per-GB monthly cost in tenths of a cent. 8 ⇒ $0.008/GB/month
11 + /// (0.8¢ = 8 tenths-of-a-cent), chosen to roughly cover Hetzner Object
12 + /// Storage at the rack rate plus a thin egress buffer.
13 + pub const PER_GB_TENTHS_OF_CENT_PER_MONTH: i64 = 8;
14 +
15 + /// Minimum monthly or annual charge in cents. Covers Stripe's per-transaction
16 + /// floor ($0.30 + 2.9%) without forcing the price up further for tiny accounts.
17 + pub const MIN_CHARGE_CENTS: i64 = 200;
18 +
19 + /// Annual price multiplier vs. monthly. ×10 = "two months free" structurally,
20 + /// which is also where Stripe per-transaction fees disappear into the noise.
21 + pub const ANNUAL_MULTIPLIER: i64 = 10;
22 +
23 + /// Minimum and maximum storage caps the user may pick.
24 + pub const MIN_CAP_BYTES: i64 = 10 * 1024 * 1024 * 1024; // 10 GiB
25 + pub const MAX_CAP_BYTES: i64 = 10 * 1024 * 1024 * 1024 * 1024; // 10 TiB
26 +
27 + /// Billing interval for a SyncKit app subscription.
28 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
29 + pub enum SyncBillingInterval {
30 + Monthly,
31 + Annual,
32 + }
33 +
34 + impl SyncBillingInterval {
35 + pub fn parse(s: &str) -> Result<Self> {
36 + match s {
37 + "monthly" => Ok(Self::Monthly),
38 + "annual" => Ok(Self::Annual),
39 + other => Err(AppError::BadRequest(format!(
40 + "Invalid interval '{other}', expected 'monthly' or 'annual'"
41 + ))),
42 + }
43 + }
44 +
45 + pub fn as_str(self) -> &'static str {
46 + match self {
47 + Self::Monthly => "monthly",
48 + Self::Annual => "annual",
49 + }
50 + }
51 + }
52 +
53 + fn cap_bytes_to_gb_ceil(cap_bytes: i64) -> i64 {
54 + const GB: i64 = 1024 * 1024 * 1024;
55 + (cap_bytes + GB - 1) / GB
56 + }
57 +
58 + /// Compute the price (in cents) for a given storage cap and interval.
59 + ///
60 + /// `cap_bytes` is validated against `MIN_CAP_BYTES` / `MAX_CAP_BYTES`.
61 + pub fn quote_price_cents(cap_bytes: i64, interval: SyncBillingInterval) -> Result<i64> {
62 + if cap_bytes < MIN_CAP_BYTES {
63 + return Err(AppError::BadRequest(format!(
64 + "Storage cap must be at least {} GiB",
65 + MIN_CAP_BYTES / (1024 * 1024 * 1024)
66 + )));
67 + }
68 + if cap_bytes > MAX_CAP_BYTES {
69 + return Err(AppError::BadRequest(format!(
70 + "Storage cap may not exceed {} GiB",
71 + MAX_CAP_BYTES / (1024 * 1024 * 1024)
72 + )));
73 + }
74 +
75 + let gb = cap_bytes_to_gb_ceil(cap_bytes);
76 + // Storage-driven monthly cents, ceiling-divided so partial cents round up.
77 + let storage_monthly_cents = gb * PER_GB_TENTHS_OF_CENT_PER_MONTH;
78 + let storage_monthly_cents = (storage_monthly_cents + 9) / 10;
79 + let monthly_cents = storage_monthly_cents.max(MIN_CHARGE_CENTS);
80 +
81 + Ok(match interval {
82 + SyncBillingInterval::Monthly => monthly_cents,
83 + SyncBillingInterval::Annual => monthly_cents * ANNUAL_MULTIPLIER,
84 + })
85 + }
86 +
87 + #[cfg(test)]
88 + mod tests {
89 + use super::*;
90 +
91 + fn gb(n: i64) -> i64 {
92 + n * 1024 * 1024 * 1024
93 + }
94 +
95 + #[test]
96 + fn minimum_cap_hits_floor() {
97 + let price = quote_price_cents(gb(10), SyncBillingInterval::Monthly).unwrap();
98 + assert_eq!(price, MIN_CHARGE_CENTS);
99 + }
100 +
101 + #[test]
102 + fn small_cap_uses_floor_until_breakeven() {
103 + // 100 GB × $0.008 = $0.80 → below the $2 floor.
104 + let price = quote_price_cents(gb(100), SyncBillingInterval::Monthly).unwrap();
105 + assert_eq!(price, MIN_CHARGE_CENTS);
106 + }
107 +
108 + #[test]
109 + fn breakeven_around_250gb() {
110 + // 250 GB × $0.008 = $2.00 → equal to floor; storage formula starts taking over.
111 + let price = quote_price_cents(gb(250), SyncBillingInterval::Monthly).unwrap();
112 + assert_eq!(price, 200);
113 + }
114 +
115 + #[test]
116 + fn large_cap_scales() {
117 + // 1 TiB = 1024 GiB × $0.008 = $8.192 → 820 cents (ceil).
118 + let price = quote_price_cents(gb(1024), SyncBillingInterval::Monthly).unwrap();
119 + assert_eq!(price, 820);
120 + }
121 +
122 + #[test]
123 + fn annual_is_ten_times_monthly() {
124 + let monthly = quote_price_cents(gb(1024), SyncBillingInterval::Monthly).unwrap();
125 + let annual = quote_price_cents(gb(1024), SyncBillingInterval::Annual).unwrap();
126 + assert_eq!(annual, monthly * 10);
127 + }
128 +
129 + #[test]
130 + fn below_minimum_cap_rejected() {
131 + assert!(quote_price_cents(gb(5), SyncBillingInterval::Monthly).is_err());
132 + }
133 +
134 + #[test]
135 + fn above_maximum_cap_rejected() {
136 + assert!(quote_price_cents(gb(20_000), SyncBillingInterval::Monthly).is_err());
137 + }
138 +
139 + #[test]
140 + fn interval_parse_round_trip() {
141 + assert_eq!(SyncBillingInterval::parse("monthly").unwrap(), SyncBillingInterval::Monthly);
142 + assert_eq!(SyncBillingInterval::parse("annual").unwrap(), SyncBillingInterval::Annual);
143 + assert!(SyncBillingInterval::parse("weekly").is_err());
144 + }
145 + }
@@ -17,7 +17,9 @@ use stripe_billing::subscription::{
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 @@ impl StripeClient {
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
@@ -22,6 +22,34 @@ pub(super) async fn handle_invoice_payment_succeeded(
22 22
23 23 let is_renewal = invoice.is_renewal();
24 24
25 + // End-user SyncKit app subscription? Apply any pending storage-cap change
26 + // and refresh the period. Only meaningful on renewals; the first invoice's
27 + // cap was set at checkout.
28 + if db::synckit::get_subscription_by_stripe_id(&state.db, &stripe_sub_id)
29 + .await
30 + .context("fetch app sync subscription by stripe id")?
31 + .is_some()
32 + {
33 + let period_end = stripe_timestamp(invoice.period_end);
34 + db::synckit::update_app_sync_subscription_status(
35 + &state.db, &stripe_sub_id, "active", Some(period_end),
36 + )
37 + .await
38 + .context("refresh app sync subscription period")?;
39 + if is_renewal {
40 + db::synckit::apply_pending_storage_cap(&state.db, &stripe_sub_id)
41 + .await
42 + .context("apply pending storage cap")?;
43 + }
44 + if let Err(e) = db::subscriptions::log_subscription_event(
45 + &state.db, None, event_id, "invoice.payment_succeeded.synckit_app_sub",
46 + &serde_json::json!({"stripe_sub_id": stripe_sub_id, "is_renewal": is_renewal}),
47 + ).await {
48 + tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
49 + }
50 + return Ok(());
51 + }
52 +
25 53 // SyncKit v2 developer subscription? Identified by the local sync_apps row.
26 54 if let Some(app_id) = db::synckit_billing::get_app_by_stripe_subscription(&state.db, &stripe_sub_id).await.context("fetch synckit app by stripe sub id")? {
27 55 let period_start = stripe_timestamp(invoice.period_start);
@@ -4,7 +4,7 @@ use crate::{
4 4 db,
5 5 error::{AppError, Result, ResultExt},
6 6 helpers::{self, spawn_email},
7 - payments::{CheckoutMetadata, CreatorTierCheckoutMetadata, FanPlusCheckoutMetadata, SubscriptionCheckoutMetadata, TipCheckoutMetadata},
7 + payments::{CheckoutMetadata, CreatorTierCheckoutMetadata, FanPlusCheckoutMetadata, SubscriptionCheckoutMetadata, SynckitAppSubCheckoutMetadata, TipCheckoutMetadata},
8 8 AppState,
9 9 };
10 10
@@ -635,3 +635,57 @@ pub(super) async fn handle_guest_checkout_completed(
635 635
636 636 Ok(())
637 637 }
638 +
639 + /// Handle checkout.session.completed for an end-user SyncKit app subscription.
640 + /// Inserts the `app_sync_subscriptions` row; subsequent
641 + /// `customer.subscription.updated/.deleted` events keep it in sync.
642 + #[tracing::instrument(skip_all, name = "stripe::handle_synckit_app_sub_checkout")]
643 + pub(super) async fn handle_synckit_app_sub_checkout_completed(
644 + state: &AppState,
645 + session: &crate::payments::CheckoutSessionView,
646 + event_id: &str,
647 + ) -> Result<()> {
648 + let session_id = session.id.clone();
649 + tracing::info!(session_id = %session_id, "processing completed SyncKit app subscription checkout");
650 +
651 + let meta = SynckitAppSubCheckoutMetadata::from_metadata(session.metadata.as_ref())?;
652 +
653 + let stripe_subscription_id = session
654 + .subscription
655 + .clone()
656 + .ok_or_else(|| AppError::BadRequest("Missing subscription ID on session".to_string()))?;
657 + let stripe_customer_id = session
658 + .customer
659 + .clone()
660 + .ok_or_else(|| AppError::BadRequest("Missing customer ID on session".to_string()))?;
661 +
662 + let inserted = db::synckit::create_app_sync_subscription(
663 + &state.db,
664 + &db::synckit::NewAppSyncSubscription {
665 + user_id: meta.user_id,
666 + app_id: meta.app_id,
667 + stripe_subscription_id: &stripe_subscription_id,
668 + stripe_customer_id: &stripe_customer_id,
669 + interval: &meta.tier, // metadata "tier" carries the interval string ("monthly"/"annual")
670 + storage_limit_bytes: meta.storage_limit_bytes.unwrap_or(0),
671 + },
672 + )
673 + .await
674 + .with_context(|| {
675 + format!(
676 + "create app sync subscription user={} app={}",
677 + meta.user_id, meta.app_id
678 + )
679 + })?;
680 +
681 + if !inserted {
682 + tracing::info!(
683 + user_id = %meta.user_id,
684 + app_id = %meta.app_id,
685 + "SyncKit app subscription already exists, ignoring duplicate webhook"
686 + );
687 + }
688 +
689 + let _ = event_id;
690 + Ok(())
691 + }
@@ -106,6 +106,8 @@ pub(crate) async fn process_webhook_event(
106 106 checkout::handle_fan_plus_checkout_completed(state, &session, event_id).await?;
107 107 } else if payments::is_creator_tier_checkout(meta) {
108 108 checkout::handle_creator_tier_checkout_completed(state, &session, event_id).await?;
109 + } else if payments::is_synckit_app_sub_checkout(meta) {
110 + checkout::handle_synckit_app_sub_checkout_completed(state, &session, event_id).await?;
109 111 } else if payments::is_tip_checkout(meta) {
110 112 checkout::handle_tip_checkout_completed(state, &session, event_id).await?;
111 113 } else if payments::is_subscription_checkout(meta) {
@@ -37,6 +37,31 @@ pub(super) async fn handle_subscription_updated(
37 37 return Ok(());
38 38 }
39 39
40 + // Check if this is an end-user SyncKit app subscription.
41 + if db::synckit::get_subscription_by_stripe_id(&state.db, &stripe_sub_id)
42 + .await
43 + .context("fetch app sync subscription by stripe id")?
44 + .is_some()
45 + {
46 + let (_, end_ts) = sub.current_period().unwrap_or((0, 0));
47 + let period_end = if end_ts > 0 { Some(stripe_timestamp(end_ts)) } else { None };
48 + db::synckit::update_app_sync_subscription_status(
49 + &state.db,
50 + &stripe_sub_id,
51 + sub.status.as_str(),
52 + period_end,
53 + )
54 + .await
55 + .context("update app sync subscription status")?;
56 + if let Err(e) = db::subscriptions::log_subscription_event(
57 + &state.db, None, event_id, "customer.subscription.updated.synckit_app_sub",
58 + &serde_json::json!({"status": sub.status}),
59 + ).await {
60 + tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
61 + }
62 + return Ok(());
63 + }
64 +
40 65 // Check if this is a Fan+ subscription
41 66 if let Some(_fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch fan plus by stripe id")? {
42 67 let status_str = sub.status.as_str();
@@ -138,6 +163,26 @@ pub(super) async fn handle_subscription_deleted(
138 163 return Ok(());
139 164 }
140 165
166 + // Check if this is an end-user SyncKit app subscription.
167 + if db::synckit::get_subscription_by_stripe_id(&state.db, &stripe_sub_id)
168 + .await
169 + .context("fetch app sync subscription by stripe id")?
170 + .is_some()
171 + {
172 + db::synckit::update_app_sync_subscription_status(
173 + &state.db, &stripe_sub_id, "canceled", None,
174 + )
175 + .await
176 + .context("cancel app sync subscription")?;
177 + if let Err(e) = db::subscriptions::log_subscription_event(
178 + &state.db, None, event_id, "customer.subscription.deleted.synckit_app_sub",
179 + &serde_json::json!({"stripe_sub_id": stripe_sub_id}),
180 + ).await {
181 + tracing::warn!(event_id = %event_id, error = ?e, "failed to log subscription event");
182 + }
183 + return Ok(());
184 + }
185 +
141 186 // Check if this is a Fan+ subscription
142 187 if let Some(fan_sub) = db::fan_plus::get_fan_plus_by_stripe_id(&state.db, &stripe_sub_id).await.context("fetch fan plus by stripe id")? {
143 188 db::fan_plus::cancel_fan_plus(&state.db, &stripe_sub_id).await.context("cancel fan plus")?;
@@ -184,6 +184,80 @@ pub(crate) struct SyncAccountResponse {
184 184 pub username: String,
185 185 }
186 186
187 + /// Status of the authenticated user's subscription to this app's cloud sync.
188 + /// Shape matches `synckit_client::SubscriptionStatus`.
189 + #[derive(Serialize, utoipa::ToSchema)]
190 + pub(crate) struct SyncSubscriptionStatusResponse {
191 + pub active: bool,
192 + /// Billing interval ("monthly" / "annual"). Kept under the legacy `tier`
193 + /// key for client SDK backwards compatibility.
194 + pub tier: Option<String>,
195 + pub status: Option<String>,
196 + pub storage_limit_bytes: Option<i64>,
197 + /// Queued storage cap, applied at the next billing cycle. `None` when no
198 + /// change is pending.
199 + pub pending_storage_limit_bytes: Option<i64>,
200 + pub storage_used_bytes: Option<i64>,
201 + pub current_period_end: Option<String>,
202 + }
203 +
204 + /// Request body for `POST /api/v1/sync/app/pricing`. Identifies the app by
205 + /// its public API key; no JWT required so the UI can quote pricing pre-login.
206 + #[derive(Deserialize, utoipa::ToSchema)]
207 + pub(crate) struct AppPricingRequest {
208 + pub api_key: String,
209 + }
210 +
211 + /// Pricing formula constants the client uses to quote a price locally as the
212 + /// user drags a cap slider. The same formula is enforced server-side at
213 + /// checkout — clients are not trusted to compute the final price.
214 + #[derive(Serialize, utoipa::ToSchema)]
215 + pub(crate) struct AppPricingResponse {
216 + pub app_name: String,
217 + /// Floor charge in cents (monthly or annual — same floor applies to both).
218 + pub min_charge_cents: i64,
219 + /// Per-GiB monthly storage rate, in tenths of a cent.
220 + pub per_gb_tenths_of_cent_per_month: i64,
221 + /// Annual is monthly × this value.
222 + pub annual_multiplier: i64,
223 + pub min_cap_bytes: i64,
224 + pub max_cap_bytes: i64,
225 + }
226 +
227 + /// Request body for `POST /api/v1/sync/subscription/quote`.
228 + #[derive(Deserialize, utoipa::ToSchema)]
229 + pub(crate) struct SyncQuoteRequest {
230 + pub cap_bytes: i64,
231 + pub interval: String,
232 + }
233 +
234 + #[derive(Serialize, utoipa::ToSchema)]
235 + pub(crate) struct SyncQuoteResponse {
236 + pub cap_bytes: i64,
237 + pub interval: String,
238 + pub price_cents: i64,
239 + }
240 +
241 + /// Request body for `POST /api/v1/sync/subscription/checkout`.
242 + #[derive(Deserialize, utoipa::ToSchema)]
243 + pub(crate) struct SyncSubscribeRequest {
244 + pub cap_bytes: i64,
245 + /// "monthly" or "annual".
246 + pub interval: String,
247 + }
248 +
249 + #[derive(Serialize, utoipa::ToSchema)]
250 + pub(crate) struct SyncCheckoutResponse {
251 + pub checkout_url: String,
252 + }
253 +
254 + /// Request body for `POST /api/v1/sync/subscription/storage-cap` — queues a
255 + /// cap change that applies at the next billing cycle.
256 + #[derive(Deserialize, utoipa::ToSchema)]
257 + pub(crate) struct SyncCapChangeRequest {
258 + pub cap_bytes: i64,
259 + }
260 +
187 261 #[derive(Deserialize, utoipa::ToSchema)]
188 262 pub(crate) struct PutKeyRequest {
189 263 pub encrypted_key: String,
@@ -466,6 +540,8 @@ pub fn synckit_routes() -> Router<AppState> {
466 540 .route("/api/v1/sync/keys/release", post(keys::release))
467 541 .route("/api/sync/keys/list", post(keys::list))
468 542 .route("/api/v1/sync/keys/list", post(keys::list))
543 + .route("/api/sync/app/pricing", post(sync::get_app_pricing))
544 + .route("/api/v1/sync/app/pricing", post(sync::get_app_pricing))
469 545 .route_layer(GovernorLayer {
470 546 config: auth_rate_limit,
471 547 });
@@ -484,6 +560,14 @@ pub fn synckit_routes() -> Router<AppState> {
484 560 .route("/api/v1/sync/status", get(sync::sync_status))
485 561 .route("/api/sync/account", get(sync::sync_account))
486 562 .route("/api/v1/sync/account", get(sync::sync_account))
563 + .route("/api/sync/subscription", get(sync::sync_subscription_status))
564 + .route("/api/v1/sync/subscription", get(sync::sync_subscription_status))
565 + .route("/api/sync/subscription/quote", post(sync::quote_subscription_price))
566 + .route("/api/v1/sync/subscription/quote", post(sync::quote_subscription_price))
567 + .route("/api/sync/subscription/checkout", post(sync::create_subscription_checkout))
568 + .route("/api/v1/sync/subscription/checkout", post(sync::create_subscription_checkout))
569 + .route("/api/sync/subscription/storage-cap", post(sync::queue_storage_cap_change))
570 + .route("/api/v1/sync/subscription/storage-cap", post(sync::queue_storage_cap_change))
487 571 .route("/api/sync/devices", post(sync::register_device))
488 572 .route("/api/v1/sync/devices", post(sync::register_device))
489 573 .route("/api/sync/devices", get(sync::list_devices))
@@ -10,17 +10,20 @@ use crate::{
10 10 constants,
11 11 db::{self, SyncDeviceId},
12 12 error::{AppError, Result},
13 + payments::{self, SyncBillingInterval},
13 14 synckit_auth::SyncUser,
14 15 validation,
15 16 AppState,
16 17 };
17 18
18 19 use super::{
19 - BeginRotationRequest, BeginRotationResponse, CompleteRotationErrorResponse,
20 - CompleteRotationRequest, GetKeyResponse, PendingKeyInfo, PullChangeEntry, PullRequest,
21 - PullResponse, PushRequest, PushResponse, PutKeyRequest, RegisterDeviceRequest,
22 - RotationBatchRequest, RotationBatchResponse, RotationEntriesRequest, RotationEntriesResponse,
23 - RotationEntry, SyncAccountResponse, SyncDeviceResponse, SyncStatusResponse,
20 + AppPricingRequest, AppPricingResponse, BeginRotationRequest, BeginRotationResponse,
21 + CompleteRotationErrorResponse, CompleteRotationRequest, GetKeyResponse, PendingKeyInfo,
22 + PullChangeEntry, PullRequest, PullResponse, PushRequest, PushResponse, PutKeyRequest,
23 + RegisterDeviceRequest, RotationBatchRequest, RotationBatchResponse, RotationEntriesRequest,
24 + RotationEntriesResponse, RotationEntry, SyncAccountResponse, SyncCapChangeRequest,
25 + SyncCheckoutResponse, SyncDeviceResponse, SyncQuoteRequest, SyncQuoteResponse,
26 + SyncStatusResponse, SyncSubscribeRequest, SyncSubscriptionStatusResponse,
24 27 };
25 28
26 29 // ── Sync endpoints (JWT auth) ──
@@ -228,6 +231,235 @@ pub(super) async fn sync_account(
228 231 }))
229 232 }
230 233
234 + /// Return the authenticated user's subscription status for this app.
235 + /// Returns `active: false` and `None` fields when the user has no subscription
236 + /// (rather than 404) so clients can render a "subscribe" CTA uniformly.
237 + #[utoipa::path(get, path = "/api/v1/sync/subscription", tag = "SyncKit",
238 + responses((status = 200, description = "Subscription status", body = SyncSubscriptionStatusResponse)),
239 + security(("bearer" = [])),
240 + )]
241 + #[tracing::instrument(skip_all, name = "synckit::sync_subscription_status")]
242 + pub(super) async fn sync_subscription_status(
243 + State(state): State<AppState>,
244 + sync_user: SyncUser,
245 + ) -> Result<impl IntoResponse> {
246 + let sub = db::synckit::get_user_app_subscription(
247 + &state.db,
248 + sync_user.user_id,
249 + sync_user.app_id,
250 + )
251 + .await?;
252 +
253 + let response = match sub {
254 + Some(s) => SyncSubscriptionStatusResponse {
255 + active: s.status == "active",
256 + tier: Some(s.interval),
257 + status: Some(s.status),
258 + storage_limit_bytes: s.storage_limit_bytes,
259 + pending_storage_limit_bytes: s.pending_storage_limit_bytes,
260 + storage_used_bytes: None,
261 + current_period_end: s.current_period_end.map(|t| t.to_rfc3339()),
262 + },
263 + None => SyncSubscriptionStatusResponse {
264 + active: false,
265 + tier: None,
266 + status: None,
267 + storage_limit_bytes: None,
268 + pending_storage_limit_bytes: None,
269 + storage_used_bytes: None,
270 + current_period_end: None,
271 + },
272 + };
273 +
274 + Ok(Json(response))
275 + }
276 +
277 + /// Return the pricing-formula constants for an app. The client uses these to
278 + /// quote a price locally as the user adjusts the cap slider; the same formula
279 + /// is enforced server-side at checkout so the client number is only advisory.
280 + #[utoipa::path(post, path = "/api/v1/sync/app/pricing", tag = "SyncKit",
281 + request_body = AppPricingRequest,
282 + responses((status = 200, description = "Pricing formula", body = AppPricingResponse)),
283 + )]
284 + #[tracing::instrument(skip_all, name = "synckit::get_app_pricing")]
285 + pub(super) async fn get_app_pricing(
286 + State(state): State<AppState>,
287 + Json(req): Json<AppPricingRequest>,
288 + ) -> Result<impl IntoResponse> {
289 + let app = db::synckit::get_sync_app_by_api_key(&state.db, &req.api_key)
290 + .await?
291 + .ok_or(AppError::NotFound)?;
292 +
293 + Ok(Json(AppPricingResponse {
294 + app_name: app.name,
295 + min_charge_cents: payments::MIN_CHARGE_CENTS,
296 + per_gb_tenths_of_cent_per_month: payments::synckit_app_pricing::PER_GB_TENTHS_OF_CENT_PER_MONTH,
297 + annual_multiplier: payments::ANNUAL_MULTIPLIER,
298 + min_cap_bytes: payments::MIN_CAP_BYTES,
299 + max_cap_bytes: payments::MAX_CAP_BYTES,
300 + }))
301 + }
302 +
303 + /// Quote the price for a (cap, interval) pair. Authenticated so clients
304 + /// cannot scrape pricing without an account, but otherwise pure: the result
305 + /// only depends on the formula constants returned by `app/pricing`.
306 + #[utoipa::path(post, path = "/api/v1/sync/subscription/quote", tag = "SyncKit",
307 + request_body = SyncQuoteRequest,
308 + responses((status = 200, description = "Quoted price", body = SyncQuoteResponse)),
309 + security(("bearer" = [])),
310 + )]
311 + #[tracing::instrument(skip_all, name = "synckit::quote_subscription_price")]
312 + pub(super) async fn quote_subscription_price(
313 + State(_state): State<AppState>,
314 + _sync_user: SyncUser,
315 + Json(req): Json<SyncQuoteRequest>,
316 + ) -> Result<impl IntoResponse> {
317 + let interval = SyncBillingInterval::parse(&req.interval)?;
318 + let price_cents = payments::quote_price_cents(req.cap_bytes, interval)?;
319 + Ok(Json(SyncQuoteResponse {
320 + cap_bytes: req.cap_bytes,
321 + interval: interval.as_str().to_string(),
322 + price_cents,
323 + }))
324 + }
325 +
326 + /// Create a Stripe Checkout Session for subscribing this user to the app's
327 + /// cloud sync at their chosen storage cap. The `app_sync_subscriptions` row
328 + /// is written by the Stripe webhook on `checkout.session.completed`.
329 + #[utoipa::path(post, path = "/api/v1/sync/subscription/checkout", tag = "SyncKit",
330 + request_body = SyncSubscribeRequest,
331 + responses((status = 200, description = "Checkout URL", body = SyncCheckoutResponse)),
332 + security(("bearer" = [])),
333 + )]
334 + #[tracing::instrument(skip_all, name = "synckit::create_subscription_checkout")]
335 + pub(super) async fn create_subscription_checkout(
336 + State(state): State<AppState>,
337 + sync_user: SyncUser,
338 + Json(req): Json<SyncSubscribeRequest>,
339 + ) -> Result<impl IntoResponse> {
340 + if db::synckit::get_user_app_subscription(&state.db, sync_user.user_id, sync_user.app_id)
341 + .await?
342 + .is_some()
343 + {
344 + return Err(AppError::BadRequest(
345 + "Already subscribed; use the storage-cap endpoint to adjust your cap".to_string(),
346 + ));
347 + }
348 +
349 + let interval = SyncBillingInterval::parse(&req.interval)?;
350 + let amount_cents = payments::quote_price_cents(req.cap_bytes, interval)?;
351 +
352 + let app = db::synckit::get_sync_app_by_id(&state.db, sync_user.app_id)
353 + .await?
354 + .ok_or(AppError::NotFound)?;
355 + let cap_gib = req.cap_bytes / (1024 * 1024 * 1024);
356 + let product_name = format!("{} cloud sync — {} GiB", app.name, cap_gib);
357 +
358 + let stripe = state
359 + .stripe
360 + .as_ref()
361 + .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
362 +
363 + let success_url = format!("{}/sync/subscribed", state.config.host_url);
364 + let cancel_url = format!("{}/sync/canceled", state.config.host_url);
365 +
366 + let result = stripe
367 + .create_synckit_app_sub_checkout_session(
368 + &product_name,
369 + amount_cents,
370 + interval.as_str(),
371 + sync_user.user_id,
372 + sync_user.app_id,
373 + interval.as_str(),
374 + Some(req.cap_bytes),
375 + &success_url,
376 + &cancel_url,
377 + )
378 + .await?;
379 +
380 + let checkout_url = result
381 + .url
382 + .ok_or_else(|| AppError::BadRequest("No checkout URL returned".to_string()))?;
383 +
384 + Ok(Json(SyncCheckoutResponse { checkout_url }))
385 + }
386 +
387 + /// Queue a storage-cap change to take effect at the next billing cycle.
388 + /// Holding the change for the cycle boundary avoids mid-cycle proration
389 + /// surprises and keeps the user in control of when their bill changes.
390 + ///
391 + /// Updates Stripe first (re-price the subscription item with
392 + /// `proration_behavior=None`), then records the pending cap in the DB. The
393 + /// renewal webhook promotes the pending cap to active when Stripe rolls the
394 + /// period — and because the Stripe price is already updated, the new invoice
395 + /// is at the new price.
396 + #[utoipa::path(post, path = "/api/v1/sync/subscription/storage-cap", tag = "SyncKit",
397 + request_body = SyncCapChangeRequest,
398 + responses((status = 200, description = "Pending cap recorded", body = SyncSubscriptionStatusResponse)),
399 + security(("bearer" = [])),
400 + )]
401 + #[tracing::instrument(skip_all, name = "synckit::queue_storage_cap_change")]
402 + pub(super) async fn queue_storage_cap_change(
403 + State(state): State<AppState>,
404 + sync_user: SyncUser,
405 + Json(req): Json<SyncCapChangeRequest>,
406 + ) -> Result<impl IntoResponse> {
407 + let sub = db::synckit::get_user_app_subscription(&state.db, sync_user.user_id, sync_user.app_id)
408 + .await?
409 + .ok_or_else(|| AppError::BadRequest("No active subscription to adjust".to_string()))?;
410 +
411 + if req.cap_bytes < payments::MIN_CAP_BYTES || req.cap_bytes > payments::MAX_CAP_BYTES {
412 + return Err(AppError::BadRequest(format!(
413 + "Storage cap must be between {} and {} GiB",
414 + payments::MIN_CAP_BYTES / (1024 * 1024 * 1024),
415 + payments::MAX_CAP_BYTES / (1024 * 1024 * 1024)
416 + )));
417 + }
418 +
419 + let interval = payments::SyncBillingInterval::parse(&sub.interval)?;
420 + let new_price_cents = payments::quote_price_cents(req.cap_bytes, interval)?;
421 +
422 + let stripe = state
423 + .stripe
424 + .as_ref()
425 + .ok_or_else(|| AppError::BadRequest("Stripe is not configured".to_string()))?;
426 +
427 + let app = db::synckit::get_sync_app_by_id(&state.db, sync_user.app_id)
428 + .await?
429 + .ok_or(AppError::NotFound)?;
430 + let cap_gib = req.cap_bytes / (1024 * 1024 * 1024);
431 + let product_name = format!("{} cloud sync — {} GiB", app.name, cap_gib);
432 +
433 + // Stripe first: if this fails, we want the DB pending cap to stay
434 + // unchanged so the user isn't sitting on an upgrade they never paid for.
435 + stripe
436 + .update_synckit_app_sub_price(
437 + &sub.stripe_subscription_id,
438 + new_price_cents,
439 + interval,
440 + &product_name,
441 + )
442 + .await?;
443 +
444 + db::synckit::set_pending_storage_cap(
445 + &state.db,
446 + sync_user.user_id,
447 + sync_user.app_id,
448 + req.cap_bytes,
449 + )
450 + .await?;
451 +
452 + Ok(Json(SyncSubscriptionStatusResponse {
453 + active: sub.status == "active",
454 + tier: Some(sub.interval),
455 + status: Some(sub.status),
456 + storage_limit_bytes: sub.storage_limit_bytes,
457 + pending_storage_limit_bytes: Some(req.cap_bytes),
458 + storage_used_bytes: None,
459 + current_period_end: sub.current_period_end.map(|t| t.to_rfc3339()),
460 + }))
461 + }
462 +
231 463 // ── Device endpoints (JWT auth) ──
232 464
233 465 /// Register a new sync device (or update an existing one by name).
@@ -216,6 +216,14 @@ impl PaymentProvider for MockPaymentProvider {
216 216 Ok(())
217 217 }
218 218
219 + async fn update_synckit_app_sub_price(&self, _subscription_id: &str, _new_price_cents: i64, _interval: makenotwork::payments::SyncBillingInterval, _product_name: &str) -> Result<()> {
220 + Ok(())
221 + }
222 +
223 + async fn create_synckit_app_sub_checkout_session(&self, _product_name: &str, _amount_cents: i64, _interval: &str, _user_id: makenotwork::db::UserId, _app_id: makenotwork::db::SyncAppId, _tier: &str, _storage_limit_bytes: Option<i64>, _success_url: &str, _cancel_url: &str) -> Result<CheckoutResult> {
224 + Ok(self.next_session())
225 + }
226 +
219 227 async fn cancel_synckit_subscription(&self, _subscription_id: &str) -> Result<()> {
220 228 Ok(())
221 229 }
@@ -1506,7 +1506,7 @@ dependencies = [
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",
@@ -105,8 +105,9 @@ struct Endpoints {
105 105 blobs_download: String,
106 106 subscription: String,
107 107 subscription_checkout: String,
108 - subscription_change: String,
109 - app_tiers: String,
108 + subscription_quote: String,
109 + subscription_storage_cap: String,
110 + app_pricing: String,
110 111 account: String,
111 112 }
112 113
@@ -127,8 +128,9 @@ impl Endpoints {
127 128 blobs_download: format!("{base}/api/v1/sync/blobs/download"),
128 129 subscription: format!("{base}/api/v1/sync/subscription"),
129 130 subscription_checkout: format!("{base}/api/v1/sync/subscription/checkout"),
130 - subscription_change: format!("{base}/api/v1/sync/subscription/change"),
131 - app_tiers: format!("{base}/api/v1/sync/app/tiers"),
131 + subscription_quote: format!("{base}/api/v1/sync/subscription/quote"),
132 + subscription_storage_cap: format!("{base}/api/v1/sync/subscription/storage-cap"),
133 + app_pricing: format!("{base}/api/v1/sync/app/pricing"),
132 134 account: format!("{base}/api/v1/sync/account"),
133 135 }
134 136 }
@@ -1,4 +1,11 @@
1 - //! Subscription status and checkout methods.
1 + //! Subscription status, pricing-formula quoting, checkout, and storage-cap
2 + //! management for end-user SyncKit subscriptions.
3 + //!
4 + //! The server uses a formula-driven pricing model: there are no fixed tiers,
5 + //! the user picks any storage cap (within a min/max) and the server quotes a
6 + //! price. Apps typically show a slider, call [`SyncKitClient::quote_price`]
7 + //! to get the live number, then call [`SyncKitClient::create_subscription_checkout`]
8 + //! once the user clicks subscribe.
2 9
3 10 use serde::{Deserialize, Serialize};
4 11
@@ -7,52 +14,89 @@ use super::SyncKitClient;
7 14 use super::helpers::check_response;
8 15
9 16 /// Subscription status as returned by the server.
10 - #[derive(Debug, Clone, Serialize, Deserialize)]
17 + #[derive(Debug, Clone, Default, Serialize, Deserialize)]
11 18 pub struct SubscriptionStatus {
12 19 /// Whether the user has an active sync subscription for this app.
13 20 pub active: bool,
14 - /// Subscription tier (if active).
15 - pub tier: Option<String>,
21 + /// Billing interval ("monthly" or "annual"). `None` if no subscription.
22 + /// Kept under the legacy field name `tier` on the wire for SDK compat.
23 + #[serde(rename = "tier")]
24 + pub interval: Option<String>,
16 25 /// Subscription status string (e.g. "active", "past_due", "canceled").
17 26 pub status: Option<String>,
18 - /// Blob storage limit in bytes (AF only).
27 + /// Current blob storage cap, in bytes.
19 28 pub storage_limit_bytes: Option<i64>,
20 - /// Blob storage used in bytes.
29 + /// A queued cap change that applies at the next billing cycle. `None`
30 + /// when no change is pending.
31 + #[serde(default)]
32 + pub pending_storage_limit_bytes: Option<i64>,
33 + /// Blob storage currently in use, in bytes (when the server has the info).
21 34 pub storage_used_bytes: Option<i64>,
22 35 /// ISO 8601 end of current billing period.
23 36 pub current_period_end: Option<String>,
24 37 }
25 38
26 - /// Information about an available pricing tier.
39 + /// Pricing-formula constants for an app. Clients use these to quote a price
40 + /// locally as a slider moves; the server enforces the same formula at
41 + /// checkout so client-computed prices are advisory only.
27 42 #[derive(Debug, Clone, Serialize, Deserialize)]
28 - pub struct TierInfo {
29 - /// Tier identifier (e.g. "light", "standard", "large").
30 - pub id: String,
31 - /// Human-readable label.
32 - pub label: String,
33 - /// Short description (e.g. "10 GB storage").
34 - pub description: String,
35 - /// Blob storage limit in bytes, if applicable.
36 - pub storage_bytes: Option<i64>,
37 - /// Monthly price in cents.
38 - pub monthly_price_cents: i64,
39 - /// Annual price in cents.
40 - pub annual_price_cents: i64,
43 + pub struct AppPricing {
44 + pub app_name: String,
45 + /// Minimum charge in cents (applies to both monthly and annual).
46 + pub min_charge_cents: i64,
47 + /// Storage rate per GiB per month, in tenths of a cent. Convert with
48 + /// `(gib * per_gb_tenths + 9) / 10` to get cents.
49 + pub per_gb_tenths_of_cent_per_month: i64,
50 + /// Annual price is monthly × this multiplier.
51 + pub annual_multiplier: i64,
52 + pub min_cap_bytes: i64,
53 + pub max_cap_bytes: i64,
41 54 }
42 55
43 - /// Response from the app tiers endpoint.
44 - #[derive(Debug, Deserialize)]
45 - struct AppTiersResponse {
46 - #[allow(dead_code)]
47 - app_name: String,
48 - tiers: Vec<TierInfo>,
56 + impl AppPricing {
57 + /// Compute the price in cents for a given cap and interval. Mirrors the
58 + /// server-side formula so client-side previews match what the user will
59 + /// actually be charged.
60 + pub fn quote_cents(&self, cap_bytes: i64, interval: BillingInterval) -> i64 {
61 + let cap_bytes = cap_bytes.clamp(self.min_cap_bytes, self.max_cap_bytes);
62 + let gib = cap_bytes_to_gib_ceil(cap_bytes);
63 + let storage_monthly = (gib * self.per_gb_tenths_of_cent_per_month + 9) / 10;
64 + let monthly = storage_monthly.max(self.min_charge_cents);
65 + match interval {
66 + BillingInterval::Monthly => monthly,
67 + BillingInterval::Annual => monthly * self.annual_multiplier,
68 + }
69 + }
49 70 }
50 71
51 - /// Request body for creating a checkout session or changing tier.
52 - #[derive(Debug, Serialize)]
53 - struct TierRequest<'a> {
54 - tier: &'a str,
55 - interval: &'a str,
72 + fn cap_bytes_to_gib_ceil(cap_bytes: i64) -> i64 {
73 + const GIB: i64 = 1024 * 1024 * 1024;
74 + (cap_bytes + GIB - 1) / GIB
75 + }
76 +
77 + /// Billing interval for SyncKit subscriptions.
78 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
79 + pub enum BillingInterval {
80 + Monthly,
81 + Annual,
82 + }
83 +
84 + impl BillingInterval {
85 + pub fn as_str(self) -> &'static str {
86 + match self {
87 + Self::Monthly => "monthly",
88 + Self::Annual => "annual",
89 + }
90 + }
91 +
92 + /// Parse from the wire string. Falls back to `Monthly` for unknown
93 + /// values rather than erroring — defensive against forward-compat tags.
94 + pub fn from_str(s: &str) -> Self {
95 + match s {
96 + "annual" => Self::Annual,
97 + _ => Self::Monthly,
98 + }
99 + }
56 100 }
57 101
58 102 /// Response from creating a checkout session.
@@ -71,23 +115,62 @@ pub struct AccountInfo {
71 115 pub username: String,
72 116 }
73 117
74 - impl SyncKitClient {
75 - /// Fetch available pricing tiers for this app.
76 - ///
77 - /// Authenticated by API key only (no JWT needed). Can be called before
78 - /// the user has logged in, allowing the app to display pricing upfront.
79 - pub async fn get_available_tiers(&self) -> Result<Vec<TierInfo>> {
80 - let body = serde_json::json!({ "api_key": self.config.api_key });
118 + #[derive(Debug, Serialize, Deserialize)]
119 + pub struct PriceQuote {
120 + pub cap_bytes: i64,
121 + pub interval: String,
122 + pub price_cents: i64,
123 + }
124 +
125 + #[derive(Debug, Serialize)]
126 + struct AppPricingRequest<'a> {
127 + api_key: &'a str,
128 + }
81 129
130 + #[derive(Debug, Serialize)]
131 + struct QuoteRequest {
132 + cap_bytes: i64,
133 + interval: &'static str,
134 + }
135 +
136 + #[derive(Debug, Serialize)]
137 + struct CheckoutRequest {
138 + cap_bytes: i64,
139 + interval: &'static str,
140 + }
141 +
142 + #[derive(Debug, Serialize)]
143 + struct CapChangeRequest {
144 + cap_bytes: i64,
145 + }
146 +
147 + impl SyncKitClient {
148 + /// Fetch the pricing-formula constants for this app. No JWT needed; the
149 + /// app's API key is sent in the body. Safe to call before login so the
150 + /// UI can show pricing on the marketing/onboarding view.
151 + pub async fn get_app_pricing(&self) -> Result<AppPricing> {
82 152 let resp = self.http
83 - .post(&self.endpoints.app_tiers)
84 - .json(&body)
153 + .post(&self.endpoints.app_pricing)
154 + .json(&AppPricingRequest { api_key: &self.config.api_key })
85 155 .send()
86 156 .await?;
157 + let resp = check_response(resp).await?;
158 + Ok(resp.json().await?)
159 + }
87 160
161 + /// Server-side price quote for a (cap, interval). Use this to confirm
162 + /// the number you display matches what Stripe will charge; the result is
163 + /// authoritative.
164 + pub async fn quote_price(&self, cap_bytes: i64, interval: BillingInterval) -> Result<PriceQuote> {
165 + let token = self.require_token()?;
166 + let resp = self.http
167 + .post(&self.endpoints.subscription_quote)
168 + .bearer_auth(token.as_str())
169 + .json(&QuoteRequest { cap_bytes, interval: interval.as_str() })
170 + .send()
171 + .await?;
88 172 let resp = check_response(resp).await?;
89 - let tiers_resp: AppTiersResponse = resp.json().await?;
90 - Ok(tiers_resp.tiers)
173 + Ok(resp.json().await?)
91 174 }
92 175
93 176 /// Fetch the authenticated user's email and username.
@@ -125,28 +208,20 @@ impl SyncKitClient {
125 208 Ok(status)
126 209 }
127 210
128 - /// Create a Stripe Checkout session for subscribing to sync.
129 - ///
130 - /// Returns a URL that should be opened in the user's browser.
131 - /// After payment, the server creates the subscription record and
132 - /// subsequent `get_subscription_status()` calls will return `active: true`.
133 - ///
134 - /// # Arguments
135 - /// * `tier` - "standard" for GO/BB, "light"/"standard"/"large" for AF
136 - /// * `interval` - "monthly" or "annual"
211 + /// Create a Stripe Checkout session for subscribing to cloud sync at the
212 + /// chosen storage cap. Returns a URL that should be opened in the user's
213 + /// browser.
137 214 pub async fn create_subscription_checkout(
138 215 &self,
139 - tier: &str,
140 - interval: &str,
216 + cap_bytes: i64,
217 + interval: BillingInterval,
141 218 ) -> Result<CheckoutResponse> {
142 219 let token = self.require_token()?;
143 220
144 - let body = TierRequest { tier, interval };
145 -
146 221 let resp = self.http
147 222 .post(&self.endpoints.subscription_checkout)
148 223 .bearer_auth(token.as_str())
149 - .json(&body)
224 + .json(&CheckoutRequest { cap_bytes, interval: interval.as_str() })
150 225 .send()
151 226 .await?;
152 227
@@ -155,26 +230,16 @@ impl SyncKitClient {
155 230 Ok(checkout)
156 231 }
157 232
158 - /// Change the tier of an existing sync subscription.
159 - ///
160 - /// Stripe prorates the charge. Returns updated subscription status.
161 - ///
162 - /// # Arguments
163 - /// * `tier` - "light", "standard", or "large"
164 - /// * `interval` - "monthly" or "annual"
165 - pub async fn change_subscription_tier(
166 - &self,
167 - tier: &str,
168 - interval: &str,
169 - ) -> Result<SubscriptionStatus> {
233 + /// Queue a storage-cap change that takes effect at the next billing
234 + /// cycle. Returns the updated subscription status with the new cap in
235 + /// `pending_storage_limit_bytes`.
236 + pub async fn queue_storage_cap_change(&self, cap_bytes: i64) -> Result<SubscriptionStatus> {
170 237 let token = self.require_token()?;
171 238
172 - let body = TierRequest { tier, interval };
173 -
174 239 let resp = self.http
175 - .post(&self.endpoints.subscription_change)
240 + .post(&self.endpoints.subscription_storage_cap)
176 241 .bearer_auth(token.as_str())
177 - .json(&body)
242 + .json(&CapChangeRequest { cap_bytes })
178 243 .send()
179 244 .await?;
180 245