//! 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 bytes::Bytes; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::SyncKitClient; use super::helpers::{Idempotency, check_response}; use crate::error::Result; /// A monetary amount in whole cents. /// /// All pricing arithmetic flows through this newtype so the saturating /// discipline lives in one place rather than being re-derived at each call /// site: the inputs are server-supplied and a hostile or buggy value must /// never panic (debug overflow) or wrap to a negative price (release). It is /// `#[serde(transparent)]`, so it is wire- and JSON-identical to the bare `i64` /// it replaces. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)] #[serde(transparent)] pub struct Cents(pub i64); impl Cents { /// The underlying whole-cent count. pub const fn get(self) -> i64 { self.0 } /// Add two amounts, saturating at [`i64::MAX`] rather than overflowing. #[must_use] pub fn saturating_add(self, other: Cents) -> Cents { Cents(self.0.saturating_add(other.0)) } /// Scale by an integer factor (e.g. a months multiplier), saturating. #[must_use] pub fn saturating_mul(self, factor: i64) -> Cents { Cents(self.0.saturating_mul(factor)) } /// The larger of two amounts (e.g. applying a price floor). #[must_use] pub fn max(self, other: Cents) -> Cents { Cents(self.0.max(other.0)) } } impl std::fmt::Display for Cents { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } /// Subscription status as returned by the server. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[non_exhaustive] pub struct SubscriptionStatus { /// Whether the user has an active sync subscription for this app. pub active: bool, /// Billing interval. `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)] #[non_exhaustive] pub struct AppPricing { /// SyncKit app this pricing applies to. pub app_name: String, /// Minimum charge (applies to both monthly and annual). pub min_charge_cents: Cents, /// 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, /// Smallest storage cap the slider allows, in bytes. pub min_cap_bytes: i64, /// Largest storage cap the slider allows, in bytes. 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. /// /// All arithmetic saturates: the inputs are server-supplied `i64` values, and /// a hostile or buggy server returning huge rates must not panic the client /// (debug overflow) or wrap to a negative price (release). The server is /// authoritative at checkout, so this is an advisory preview regardless. pub fn quote_cents(&self, cap_bytes: i64, interval: BillingInterval) -> Cents { let cap_bytes = cap_bytes.clamp(self.min_cap_bytes, self.max_cap_bytes); let gib = cap_bytes_to_gib_ceil(cap_bytes); // The storage rate is tenths-of-a-cent per GiB; convert to whole cents, // rounding up. Intermediate stays i64 (a rate, not money) until the // result becomes a `Cents` amount. let storage_monthly = Cents( gib.saturating_mul(self.per_gb_tenths_of_cent_per_month) .saturating_add(9) / 10, ); let monthly = storage_monthly.max(self.min_charge_cents); match interval { BillingInterval::Monthly => monthly, BillingInterval::Annual => monthly.saturating_mul(self.annual_multiplier), } } } fn cap_bytes_to_gib_ceil(cap_bytes: i64) -> i64 { const GIB: i64 = 1024 * 1024 * 1024; cap_bytes.saturating_add(GIB - 1) / GIB } /// Billing interval for SyncKit subscriptions. /// /// Serializes to/from the wire string (`"monthly"`/`"annual"`). Deserialization /// is lenient via [`from_wire`](BillingInterval::from_wire): an unknown tag maps /// to `Monthly` rather than erroring, so a future server-side interval cannot /// break an older client's status parse. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BillingInterval { /// Billed once per month. Monthly, /// Billed once per year. Annual, } impl Serialize for BillingInterval { fn serialize(&self, serializer: S) -> std::result::Result { serializer.serialize_str(self.as_str()) } } impl<'de> Deserialize<'de> for BillingInterval { fn deserialize>(deserializer: D) -> std::result::Result { let s = String::deserialize(deserializer)?; Ok(BillingInterval::from_wire(&s)) } } impl BillingInterval { /// The server wire string for this interval (`"monthly"` / `"annual"`). pub fn as_str(self) -> &'static str { match self { Self::Monthly => "monthly", Self::Annual => "annual", } } /// Parse from the server wire string. Falls back to `Monthly` for unknown /// values rather than erroring, defensive against a future interval tag. /// Named `from_wire` (not `from_str`) because it is infallible and lenient, /// unlike `std::str::FromStr`. pub fn from_wire(s: &str) -> Self { match s { "annual" => Self::Annual, other => { if !other.is_empty() && other != "monthly" { tracing::debug!(tag = %other, "unknown billing interval tag; defaulting to monthly"); } Self::Monthly } } } } /// Response from creating a checkout session. #[derive(Debug, Deserialize)] #[non_exhaustive] 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)] #[non_exhaustive] pub struct AccountInfo { /// The authenticated user's email address. pub email: String, /// The authenticated user's username. pub username: String, } /// A single computed price point for a cap/interval pair. #[derive(Debug, Serialize, Deserialize)] #[non_exhaustive] pub struct PriceQuote { /// Storage cap this quote is for, in bytes. pub cap_bytes: i64, /// Billing interval this quote is for. pub interval: BillingInterval, /// Quoted price, in cents. pub price_cents: Cents, } #[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. #[tracing::instrument(skip(self))] pub async fn get_app_pricing(&self) -> Result { let body = Bytes::from(serde_json::to_vec(&AppPricingRequest { api_key: &self.config.api_key, })?); self.retry_request_json(Idempotency::ReadOnly, || { let req = self .http .post(&self.endpoints.app_pricing) .header("content-type", "application/json") .body(body.clone()); async move { check_response(req.send().await?).await } }) .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. #[tracing::instrument(skip(self))] pub async fn quote_price( &self, cap_bytes: i64, interval: BillingInterval, ) -> Result { let token = self.require_token()?; let body = Bytes::from(serde_json::to_vec(&QuoteRequest { cap_bytes, interval: interval.as_str(), })?); self.retry_request_json(Idempotency::ReadOnly, || { let req = self .http .post(&self.endpoints.subscription_quote) .bearer_auth(token.as_str()) .header("content-type", "application/json") .body(body.clone()); async move { check_response(req.send().await?).await } }) .await } /// Fetch the authenticated user's email and username. /// /// Used by apps to display "logged in as ..." in their cloud-sync UI. #[tracing::instrument(skip(self))] pub async fn get_account_info(&self) -> Result { let token = self.require_token()?; self.retry_request_json(Idempotency::ReadOnly, || { let req = self .http .get(&self.endpoints.account) .bearer_auth(token.as_str()); async move { check_response(req.send().await?).await } }) .await } /// 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. #[tracing::instrument(skip(self))] pub async fn get_subscription_status(&self) -> Result { let token = self.require_token()?; self.retry_request_json(Idempotency::ReadOnly, || { let req = self .http .get(&self.endpoints.subscription) .bearer_auth(token.as_str()); async move { check_response(req.send().await?).await } }) .await } /// 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. #[tracing::instrument(skip(self))] pub async fn create_subscription_checkout( &self, cap_bytes: i64, interval: BillingInterval, ) -> Result { let token = self.require_token()?; let body = Bytes::from(serde_json::to_vec(&CheckoutRequest { cap_bytes, interval: interval.as_str(), })?); // Not idempotent: a blind auto-retry could mint a second Stripe Checkout // session. Attempt once; the user re-initiates on a transient failure. self.retry_request_json(Idempotency::Unsafe, || { let req = self .http .post(&self.endpoints.subscription_checkout) .bearer_auth(token.as_str()) .header("content-type", "application/json") .body(body.clone()); async move { check_response(req.send().await?).await } }) .await } /// 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`. #[tracing::instrument(skip(self))] pub async fn queue_storage_cap_change(&self, cap_bytes: i64) -> Result { let token = self.require_token()?; let body = Bytes::from(serde_json::to_vec(&CapChangeRequest { cap_bytes })?); self.retry_request_json( Idempotency::IdempotentWrite { on: "pending cap change (upsert)", }, || { let req = self .http .post(&self.endpoints.subscription_storage_cap) .bearer_auth(token.as_str()) .header("content-type", "application/json") .body(body.clone()); async move { check_response(req.send().await?).await } }, ) .await } } #[cfg(test)] mod tests { use super::*; fn pricing(min_charge: i64, per_gb_tenths: i64, annual_mult: i64) -> AppPricing { AppPricing { app_name: "test".into(), min_charge_cents: Cents(min_charge), per_gb_tenths_of_cent_per_month: per_gb_tenths, annual_multiplier: annual_mult, min_cap_bytes: 0, max_cap_bytes: i64::MAX, } } #[test] fn quote_cents_applies_floor_and_annual_multiplier() { // 10 GiB at 50 tenths-of-a-cent/GiB/mo = 500 tenths => 50 cents, above the // 16-cent floor; annual = 10x. let p = pricing(16, 50, 10); let ten_gib = 10 * 1024 * 1024 * 1024; assert_eq!(p.quote_cents(ten_gib, BillingInterval::Monthly), Cents(50)); assert_eq!(p.quote_cents(ten_gib, BillingInterval::Annual), Cents(500)); // Tiny cap falls back to the minimum charge. assert_eq!(p.quote_cents(1, BillingInterval::Monthly), Cents(16)); } #[test] fn quote_cents_saturates_on_hostile_server_numbers() { // A server returning an absurd per-GiB rate and multiplier must not panic // (debug) or wrap negative (release), it saturates. let p = pricing(0, i64::MAX, i64::MAX); let big = p.quote_cents(i64::MAX, BillingInterval::Annual); assert!(big.get() >= 0, "price must never wrap negative, got {big}"); assert_eq!(big, Cents(i64::MAX)); } #[test] fn billing_interval_from_wire_defaults_to_monthly() { assert_eq!( BillingInterval::from_wire("annual"), BillingInterval::Annual ); assert_eq!( BillingInterval::from_wire("monthly"), BillingInterval::Monthly ); assert_eq!( BillingInterval::from_wire("weekly"), BillingInterval::Monthly ); assert_eq!(BillingInterval::from_wire(""), BillingInterval::Monthly); } #[test] fn billing_interval_serde_roundtrips_as_wire_string() { // Serializes to the bare wire string and parses back leniently. assert_eq!( serde_json::to_string(&BillingInterval::Annual).unwrap(), "\"annual\"" ); assert_eq!( serde_json::from_str::("\"monthly\"").unwrap(), BillingInterval::Monthly ); // Unknown future tag falls back to Monthly, not a parse error. assert_eq!( serde_json::from_str::("\"weekly\"").unwrap(), BillingInterval::Monthly ); } #[test] fn cents_is_wire_transparent_to_i64() { assert_eq!(serde_json::to_string(&Cents(1234)).unwrap(), "1234"); assert_eq!(serde_json::from_str::("1234").unwrap(), Cents(1234)); } }