//! Subscription status, pricing-formula quoting, checkout, and storage-cap //! management for end-user SyncKit subscriptions. //! //! The server uses a formula-driven pricing model: there are no fixed tiers, //! the user picks any storage cap (within a min/max) and the server quotes a //! price. Apps typically show a slider, call [`SyncKitClient::quote_price`] //! to get the live number, then call [`SyncKitClient::create_subscription_checkout`] //! once the user clicks subscribe. use serde::{Deserialize, Serialize}; use crate::error::Result; use super::SyncKitClient; use super::helpers::check_response; /// Subscription status as returned by the server. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SubscriptionStatus { /// Whether the user has an active sync subscription for this app. pub active: bool, /// Billing interval ("monthly" or "annual"). `None` if no subscription. /// Kept under the legacy field name `tier` on the wire for SDK compat. #[serde(rename = "tier")] pub interval: Option, /// Subscription status string (e.g. "active", "past_due", "canceled"). pub status: Option, /// Current blob storage cap, in bytes. pub storage_limit_bytes: Option, /// A queued cap change that applies at the next billing cycle. `None` /// when no change is pending. #[serde(default)] pub pending_storage_limit_bytes: Option, /// Blob storage currently in use, in bytes (when the server has the info). pub storage_used_bytes: Option, /// ISO 8601 end of current billing period. pub current_period_end: Option, } /// Pricing-formula constants for an app. Clients use these to quote a price /// locally as a slider moves; the server enforces the same formula at /// checkout so client-computed prices are advisory only. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppPricing { pub app_name: String, /// Minimum charge in cents (applies to both monthly and annual). pub min_charge_cents: i64, /// Storage rate per GiB per month, in tenths of a cent. Convert with /// `(gib * per_gb_tenths + 9) / 10` to get cents. pub per_gb_tenths_of_cent_per_month: i64, /// Annual price is monthly × this multiplier. pub annual_multiplier: i64, pub min_cap_bytes: i64, pub max_cap_bytes: i64, } impl AppPricing { /// Compute the price in cents for a given cap and interval. Mirrors the /// server-side formula so client-side previews match what the user will /// actually be charged. pub fn quote_cents(&self, cap_bytes: i64, interval: BillingInterval) -> i64 { let cap_bytes = cap_bytes.clamp(self.min_cap_bytes, self.max_cap_bytes); let gib = cap_bytes_to_gib_ceil(cap_bytes); let storage_monthly = (gib * self.per_gb_tenths_of_cent_per_month + 9) / 10; let monthly = storage_monthly.max(self.min_charge_cents); match interval { BillingInterval::Monthly => monthly, BillingInterval::Annual => monthly * self.annual_multiplier, } } } fn cap_bytes_to_gib_ceil(cap_bytes: i64) -> i64 { const GIB: i64 = 1024 * 1024 * 1024; (cap_bytes + GIB - 1) / GIB } /// Billing interval for SyncKit subscriptions. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BillingInterval { Monthly, Annual, } impl BillingInterval { pub fn as_str(self) -> &'static str { match self { Self::Monthly => "monthly", Self::Annual => "annual", } } /// Parse from the wire string. Falls back to `Monthly` for unknown /// values rather than erroring — defensive against forward-compat tags. pub fn from_str(s: &str) -> Self { match s { "annual" => Self::Annual, _ => Self::Monthly, } } } /// Response from creating a checkout session. #[derive(Debug, Deserialize)] pub struct CheckoutResponse { /// URL to redirect the user to for Stripe Checkout. pub checkout_url: String, } /// Account identifiers for the authenticated MNW user. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AccountInfo { /// The authenticated user's email address. pub email: String, /// The authenticated user's username. pub username: String, } #[derive(Debug, Serialize, Deserialize)] pub struct PriceQuote { pub cap_bytes: i64, pub interval: String, pub price_cents: i64, } #[derive(Debug, Serialize)] struct AppPricingRequest<'a> { api_key: &'a str, } #[derive(Debug, Serialize)] struct QuoteRequest { cap_bytes: i64, interval: &'static str, } #[derive(Debug, Serialize)] struct CheckoutRequest { cap_bytes: i64, interval: &'static str, } #[derive(Debug, Serialize)] struct CapChangeRequest { cap_bytes: i64, } impl SyncKitClient { /// Fetch the pricing-formula constants for this app. No JWT needed; the /// app's API key is sent in the body. Safe to call before login so the /// UI can show pricing on the marketing/onboarding view. pub async fn get_app_pricing(&self) -> Result { let resp = self.http .post(&self.endpoints.app_pricing) .json(&AppPricingRequest { api_key: &self.config.api_key }) .send() .await?; let resp = check_response(resp).await?; Ok(resp.json().await?) } /// Server-side price quote for a (cap, interval). Use this to confirm /// the number you display matches what Stripe will charge; the result is /// authoritative. pub async fn quote_price(&self, cap_bytes: i64, interval: BillingInterval) -> Result { let token = self.require_token()?; let resp = self.http .post(&self.endpoints.subscription_quote) .bearer_auth(token.as_str()) .json(&QuoteRequest { cap_bytes, interval: interval.as_str() }) .send() .await?; let resp = check_response(resp).await?; Ok(resp.json().await?) } /// Fetch the authenticated user's email and username. /// /// Used by apps to display "logged in as ..." in their cloud-sync UI. pub async fn get_account_info(&self) -> Result { let token = self.require_token()?; let resp = self.http .get(&self.endpoints.account) .bearer_auth(token.as_str()) .send() .await?; let resp = check_response(resp).await?; let info: AccountInfo = resp.json().await?; Ok(info) } /// Check the subscription status for this authenticated user + app. /// /// Returns `SubscriptionStatus` with `active: true` if sync is allowed, /// or `active: false` if the user needs to subscribe. pub async fn get_subscription_status(&self) -> Result { let token = self.require_token()?; let resp = self.http .get(&self.endpoints.subscription) .bearer_auth(token.as_str()) .send() .await?; let resp = check_response(resp).await?; let status: SubscriptionStatus = resp.json().await?; Ok(status) } /// Create a Stripe Checkout session for subscribing to cloud sync at the /// chosen storage cap. Returns a URL that should be opened in the user's /// browser. pub async fn create_subscription_checkout( &self, cap_bytes: i64, interval: BillingInterval, ) -> Result { let token = self.require_token()?; let resp = self.http .post(&self.endpoints.subscription_checkout) .bearer_auth(token.as_str()) .json(&CheckoutRequest { cap_bytes, interval: interval.as_str() }) .send() .await?; let resp = check_response(resp).await?; let checkout: CheckoutResponse = resp.json().await?; Ok(checkout) } /// Queue a storage-cap change that takes effect at the next billing /// cycle. Returns the updated subscription status with the new cap in /// `pending_storage_limit_bytes`. pub async fn queue_storage_cap_change(&self, cap_bytes: i64) -> Result { let token = self.require_token()?; let resp = self.http .post(&self.endpoints.subscription_storage_cap) .bearer_auth(token.as_str()) .json(&CapChangeRequest { cap_bytes }) .send() .await?; let resp = check_response(resp).await?; let status: SubscriptionStatus = resp.json().await?; Ok(status) } }