Skip to main content

max / synckit

15.8 KB · 443 lines History Blame Raw
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.
9
10 use bytes::Bytes;
11 use serde::{Deserialize, Deserializer, Serialize, Serializer};
12
13 use super::SyncKitClient;
14 use super::helpers::{Idempotency, check_response};
15 use crate::error::Result;
16
17 /// A monetary amount in whole cents.
18 ///
19 /// All pricing arithmetic flows through this newtype so the saturating
20 /// discipline lives in one place rather than being re-derived at each call
21 /// site: the inputs are server-supplied and a hostile or buggy value must
22 /// never panic (debug overflow) or wrap to a negative price (release). It is
23 /// `#[serde(transparent)]`, so it is wire- and JSON-identical to the bare `i64`
24 /// it replaces.
25 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
26 #[serde(transparent)]
27 pub struct Cents(pub i64);
28
29 impl Cents {
30 /// The underlying whole-cent count.
31 pub const fn get(self) -> i64 {
32 self.0
33 }
34
35 /// Add two amounts, saturating at [`i64::MAX`] rather than overflowing.
36 #[must_use]
37 pub fn saturating_add(self, other: Cents) -> Cents {
38 Cents(self.0.saturating_add(other.0))
39 }
40
41 /// Scale by an integer factor (e.g. a months multiplier), saturating.
42 #[must_use]
43 pub fn saturating_mul(self, factor: i64) -> Cents {
44 Cents(self.0.saturating_mul(factor))
45 }
46
47 /// The larger of two amounts (e.g. applying a price floor).
48 #[must_use]
49 pub fn max(self, other: Cents) -> Cents {
50 Cents(self.0.max(other.0))
51 }
52 }
53
54 impl std::fmt::Display for Cents {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 write!(f, "{}", self.0)
57 }
58 }
59
60 /// Subscription status as returned by the server.
61 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
62 #[non_exhaustive]
63 pub struct SubscriptionStatus {
64 /// Whether the user has an active sync subscription for this app.
65 pub active: bool,
66 /// Billing interval. `None` if no subscription.
67 /// Kept under the legacy field name `tier` on the wire for SDK compat.
68 #[serde(rename = "tier")]
69 pub interval: Option<BillingInterval>,
70 /// Subscription status string (e.g. "active", "past_due", "canceled").
71 pub status: Option<String>,
72 /// Current blob storage cap, in bytes.
73 pub storage_limit_bytes: Option<i64>,
74 /// A queued cap change that applies at the next billing cycle. `None`
75 /// when no change is pending.
76 #[serde(default)]
77 pub pending_storage_limit_bytes: Option<i64>,
78 /// Blob storage currently in use, in bytes (when the server has the info).
79 pub storage_used_bytes: Option<i64>,
80 /// ISO 8601 end of current billing period.
81 pub current_period_end: Option<String>,
82 }
83
84 /// Pricing-formula constants for an app. Clients use these to quote a price
85 /// locally as a slider moves; the server enforces the same formula at
86 /// checkout so client-computed prices are advisory only.
87 #[derive(Debug, Clone, Serialize, Deserialize)]
88 #[non_exhaustive]
89 pub struct AppPricing {
90 pub app_name: String,
91 /// Minimum charge (applies to both monthly and annual).
92 pub min_charge_cents: Cents,
93 /// Storage rate per GiB per month, in tenths of a cent. Convert with
94 /// `(gib * per_gb_tenths + 9) / 10` to get cents.
95 pub per_gb_tenths_of_cent_per_month: i64,
96 /// Annual price is monthly × this multiplier.
97 pub annual_multiplier: i64,
98 pub min_cap_bytes: i64,
99 pub max_cap_bytes: i64,
100 }
101
102 impl AppPricing {
103 /// Compute the price in cents for a given cap and interval. Mirrors the
104 /// server-side formula so client-side previews match what the user will
105 /// actually be charged.
106 ///
107 /// All arithmetic saturates: the inputs are server-supplied `i64` values, and
108 /// a hostile or buggy server returning huge rates must not panic the client
109 /// (debug overflow) or wrap to a negative price (release). The server is
110 /// authoritative at checkout, so this is an advisory preview regardless.
111 pub fn quote_cents(&self, cap_bytes: i64, interval: BillingInterval) -> Cents {
112 let cap_bytes = cap_bytes.clamp(self.min_cap_bytes, self.max_cap_bytes);
113 let gib = cap_bytes_to_gib_ceil(cap_bytes);
114 // The storage rate is tenths-of-a-cent per GiB; convert to whole cents,
115 // rounding up. Intermediate stays i64 (a rate, not money) until the
116 // result becomes a `Cents` amount.
117 let storage_monthly = Cents(
118 gib.saturating_mul(self.per_gb_tenths_of_cent_per_month)
119 .saturating_add(9)
120 / 10,
121 );
122 let monthly = storage_monthly.max(self.min_charge_cents);
123 match interval {
124 BillingInterval::Monthly => monthly,
125 BillingInterval::Annual => monthly.saturating_mul(self.annual_multiplier),
126 }
127 }
128 }
129
130 fn cap_bytes_to_gib_ceil(cap_bytes: i64) -> i64 {
131 const GIB: i64 = 1024 * 1024 * 1024;
132 cap_bytes.saturating_add(GIB - 1) / GIB
133 }
134
135 /// Billing interval for SyncKit subscriptions.
136 ///
137 /// Serializes to/from the wire string (`"monthly"`/`"annual"`). Deserialization
138 /// is lenient via [`from_wire`](BillingInterval::from_wire): an unknown tag maps
139 /// to `Monthly` rather than erroring, so a future server-side interval cannot
140 /// break an older client's status parse.
141 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
142 pub enum BillingInterval {
143 Monthly,
144 Annual,
145 }
146
147 impl Serialize for BillingInterval {
148 fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
149 serializer.serialize_str(self.as_str())
150 }
151 }
152
153 impl<'de> Deserialize<'de> for BillingInterval {
154 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
155 let s = String::deserialize(deserializer)?;
156 Ok(BillingInterval::from_wire(&s))
157 }
158 }
159
160 impl BillingInterval {
161 pub fn as_str(self) -> &'static str {
162 match self {
163 Self::Monthly => "monthly",
164 Self::Annual => "annual",
165 }
166 }
167
168 /// Parse from the server wire string. Falls back to `Monthly` for unknown
169 /// values rather than erroring, defensive against a future interval tag.
170 /// Named `from_wire` (not `from_str`) because it is infallible and lenient,
171 /// unlike `std::str::FromStr`.
172 pub fn from_wire(s: &str) -> Self {
173 match s {
174 "annual" => Self::Annual,
175 other => {
176 if !other.is_empty() && other != "monthly" {
177 tracing::debug!(tag = %other, "unknown billing interval tag; defaulting to monthly");
178 }
179 Self::Monthly
180 }
181 }
182 }
183 }
184
185 /// Response from creating a checkout session.
186 #[derive(Debug, Deserialize)]
187 #[non_exhaustive]
188 pub struct CheckoutResponse {
189 /// URL to redirect the user to for Stripe Checkout.
190 pub checkout_url: String,
191 }
192
193 /// Account identifiers for the authenticated MNW user.
194 #[derive(Debug, Clone, Serialize, Deserialize)]
195 #[non_exhaustive]
196 pub struct AccountInfo {
197 /// The authenticated user's email address.
198 pub email: String,
199 /// The authenticated user's username.
200 pub username: String,
201 }
202
203 #[derive(Debug, Serialize, Deserialize)]
204 #[non_exhaustive]
205 pub struct PriceQuote {
206 pub cap_bytes: i64,
207 pub interval: BillingInterval,
208 pub price_cents: Cents,
209 }
210
211 #[derive(Debug, Serialize)]
212 struct AppPricingRequest<'a> {
213 api_key: &'a str,
214 }
215
216 #[derive(Debug, Serialize)]
217 struct QuoteRequest {
218 cap_bytes: i64,
219 interval: &'static str,
220 }
221
222 #[derive(Debug, Serialize)]
223 struct CheckoutRequest {
224 cap_bytes: i64,
225 interval: &'static str,
226 }
227
228 #[derive(Debug, Serialize)]
229 struct CapChangeRequest {
230 cap_bytes: i64,
231 }
232
233 impl SyncKitClient {
234 /// Fetch the pricing-formula constants for this app. No JWT needed; the
235 /// app's API key is sent in the body. Safe to call before login so the
236 /// UI can show pricing on the marketing/onboarding view.
237 #[tracing::instrument(skip(self))]
238 pub async fn get_app_pricing(&self) -> Result<AppPricing> {
239 let body = Bytes::from(serde_json::to_vec(&AppPricingRequest {
240 api_key: &self.config.api_key,
241 })?);
242 self.retry_request_json(Idempotency::ReadOnly, || {
243 let req = self
244 .http
245 .post(&self.endpoints.app_pricing)
246 .header("content-type", "application/json")
247 .body(body.clone());
248 async move { check_response(req.send().await?).await }
249 })
250 .await
251 }
252
253 /// Server-side price quote for a (cap, interval). Use this to confirm
254 /// the number you display matches what Stripe will charge; the result is
255 /// authoritative.
256 #[tracing::instrument(skip(self))]
257 pub async fn quote_price(
258 &self,
259 cap_bytes: i64,
260 interval: BillingInterval,
261 ) -> Result<PriceQuote> {
262 let token = self.require_token()?;
263 let body = Bytes::from(serde_json::to_vec(&QuoteRequest {
264 cap_bytes,
265 interval: interval.as_str(),
266 })?);
267 self.retry_request_json(Idempotency::ReadOnly, || {
268 let req = self
269 .http
270 .post(&self.endpoints.subscription_quote)
271 .bearer_auth(token.as_str())
272 .header("content-type", "application/json")
273 .body(body.clone());
274 async move { check_response(req.send().await?).await }
275 })
276 .await
277 }
278
279 /// Fetch the authenticated user's email and username.
280 ///
281 /// Used by apps to display "logged in as ..." in their cloud-sync UI.
282 #[tracing::instrument(skip(self))]
283 pub async fn get_account_info(&self) -> Result<AccountInfo> {
284 let token = self.require_token()?;
285 self.retry_request_json(Idempotency::ReadOnly, || {
286 let req = self
287 .http
288 .get(&self.endpoints.account)
289 .bearer_auth(token.as_str());
290 async move { check_response(req.send().await?).await }
291 })
292 .await
293 }
294
295 /// Check the subscription status for this authenticated user + app.
296 ///
297 /// Returns `SubscriptionStatus` with `active: true` if sync is allowed,
298 /// or `active: false` if the user needs to subscribe.
299 #[tracing::instrument(skip(self))]
300 pub async fn get_subscription_status(&self) -> Result<SubscriptionStatus> {
301 let token = self.require_token()?;
302 self.retry_request_json(Idempotency::ReadOnly, || {
303 let req = self
304 .http
305 .get(&self.endpoints.subscription)
306 .bearer_auth(token.as_str());
307 async move { check_response(req.send().await?).await }
308 })
309 .await
310 }
311
312 /// Create a Stripe Checkout session for subscribing to cloud sync at the
313 /// chosen storage cap. Returns a URL that should be opened in the user's
314 /// browser.
315 #[tracing::instrument(skip(self))]
316 pub async fn create_subscription_checkout(
317 &self,
318 cap_bytes: i64,
319 interval: BillingInterval,
320 ) -> Result<CheckoutResponse> {
321 let token = self.require_token()?;
322 let body = Bytes::from(serde_json::to_vec(&CheckoutRequest {
323 cap_bytes,
324 interval: interval.as_str(),
325 })?);
326 // Not idempotent: a blind auto-retry could mint a second Stripe Checkout
327 // session. Attempt once; the user re-initiates on a transient failure.
328 self.retry_request_json(Idempotency::Unsafe, || {
329 let req = self
330 .http
331 .post(&self.endpoints.subscription_checkout)
332 .bearer_auth(token.as_str())
333 .header("content-type", "application/json")
334 .body(body.clone());
335 async move { check_response(req.send().await?).await }
336 })
337 .await
338 }
339
340 /// Queue a storage-cap change that takes effect at the next billing
341 /// cycle. Returns the updated subscription status with the new cap in
342 /// `pending_storage_limit_bytes`.
343 #[tracing::instrument(skip(self))]
344 pub async fn queue_storage_cap_change(&self, cap_bytes: i64) -> Result<SubscriptionStatus> {
345 let token = self.require_token()?;
346 let body = Bytes::from(serde_json::to_vec(&CapChangeRequest { cap_bytes })?);
347 self.retry_request_json(
348 Idempotency::IdempotentWrite {
349 on: "pending cap change (upsert)",
350 },
351 || {
352 let req = self
353 .http
354 .post(&self.endpoints.subscription_storage_cap)
355 .bearer_auth(token.as_str())
356 .header("content-type", "application/json")
357 .body(body.clone());
358 async move { check_response(req.send().await?).await }
359 },
360 )
361 .await
362 }
363 }
364
365 #[cfg(test)]
366 mod tests {
367 use super::*;
368
369 fn pricing(min_charge: i64, per_gb_tenths: i64, annual_mult: i64) -> AppPricing {
370 AppPricing {
371 app_name: "test".into(),
372 min_charge_cents: Cents(min_charge),
373 per_gb_tenths_of_cent_per_month: per_gb_tenths,
374 annual_multiplier: annual_mult,
375 min_cap_bytes: 0,
376 max_cap_bytes: i64::MAX,
377 }
378 }
379
380 #[test]
381 fn quote_cents_applies_floor_and_annual_multiplier() {
382 // 10 GiB at 50 tenths-of-a-cent/GiB/mo = 500 tenths => 50 cents, above the
383 // 16-cent floor; annual = 10x.
384 let p = pricing(16, 50, 10);
385 let ten_gib = 10 * 1024 * 1024 * 1024;
386 assert_eq!(p.quote_cents(ten_gib, BillingInterval::Monthly), Cents(50));
387 assert_eq!(p.quote_cents(ten_gib, BillingInterval::Annual), Cents(500));
388 // Tiny cap falls back to the minimum charge.
389 assert_eq!(p.quote_cents(1, BillingInterval::Monthly), Cents(16));
390 }
391
392 #[test]
393 fn quote_cents_saturates_on_hostile_server_numbers() {
394 // A server returning an absurd per-GiB rate and multiplier must not panic
395 // (debug) or wrap negative (release), it saturates.
396 let p = pricing(0, i64::MAX, i64::MAX);
397 let big = p.quote_cents(i64::MAX, BillingInterval::Annual);
398 assert!(big.get() >= 0, "price must never wrap negative, got {big}");
399 assert_eq!(big, Cents(i64::MAX));
400 }
401
402 #[test]
403 fn billing_interval_from_wire_defaults_to_monthly() {
404 assert_eq!(
405 BillingInterval::from_wire("annual"),
406 BillingInterval::Annual
407 );
408 assert_eq!(
409 BillingInterval::from_wire("monthly"),
410 BillingInterval::Monthly
411 );
412 assert_eq!(
413 BillingInterval::from_wire("weekly"),
414 BillingInterval::Monthly
415 );
416 assert_eq!(BillingInterval::from_wire(""), BillingInterval::Monthly);
417 }
418
419 #[test]
420 fn billing_interval_serde_roundtrips_as_wire_string() {
421 // Serializes to the bare wire string and parses back leniently.
422 assert_eq!(
423 serde_json::to_string(&BillingInterval::Annual).unwrap(),
424 "\"annual\""
425 );
426 assert_eq!(
427 serde_json::from_str::<BillingInterval>("\"monthly\"").unwrap(),
428 BillingInterval::Monthly
429 );
430 // Unknown future tag falls back to Monthly, not a parse error.
431 assert_eq!(
432 serde_json::from_str::<BillingInterval>("\"weekly\"").unwrap(),
433 BillingInterval::Monthly
434 );
435 }
436
437 #[test]
438 fn cents_is_wire_transparent_to_i64() {
439 assert_eq!(serde_json::to_string(&Cents(1234)).unwrap(), "1234");
440 assert_eq!(serde_json::from_str::<Cents>("1234").unwrap(), Cents(1234));
441 }
442 }
443