Skip to main content

max / makenotwork

8.5 KB · 251 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 serde::{Deserialize, Serialize};
11
12 use crate::error::Result;
13 use super::SyncKitClient;
14 use super::helpers::check_response;
15
16 /// Subscription status as returned by the server.
17 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
18 pub struct SubscriptionStatus {
19 /// Whether the user has an active sync subscription for this app.
20 pub active: bool,
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>,
25 /// Subscription status string (e.g. "active", "past_due", "canceled").
26 pub status: Option<String>,
27 /// Current blob storage cap, in bytes.
28 pub storage_limit_bytes: Option<i64>,
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).
34 pub storage_used_bytes: Option<i64>,
35 /// ISO 8601 end of current billing period.
36 pub current_period_end: Option<String>,
37 }
38
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.
42 #[derive(Debug, Clone, Serialize, Deserialize)]
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,
54 }
55
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 }
70 }
71
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 }
100 }
101
102 /// Response from creating a checkout session.
103 #[derive(Debug, Deserialize)]
104 pub struct CheckoutResponse {
105 /// URL to redirect the user to for Stripe Checkout.
106 pub checkout_url: String,
107 }
108
109 /// Account identifiers for the authenticated MNW user.
110 #[derive(Debug, Clone, Serialize, Deserialize)]
111 pub struct AccountInfo {
112 /// The authenticated user's email address.
113 pub email: String,
114 /// The authenticated user's username.
115 pub username: String,
116 }
117
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 }
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> {
152 let resp = self.http
153 .post(&self.endpoints.app_pricing)
154 .json(&AppPricingRequest { api_key: &self.config.api_key })
155 .send()
156 .await?;
157 let resp = check_response(resp).await?;
158 Ok(resp.json().await?)
159 }
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?;
172 let resp = check_response(resp).await?;
173 Ok(resp.json().await?)
174 }
175
176 /// Fetch the authenticated user's email and username.
177 ///
178 /// Used by apps to display "logged in as ..." in their cloud-sync UI.
179 pub async fn get_account_info(&self) -> Result<AccountInfo> {
180 let token = self.require_token()?;
181
182 let resp = self.http
183 .get(&self.endpoints.account)
184 .bearer_auth(token.as_str())
185 .send()
186 .await?;
187
188 let resp = check_response(resp).await?;
189 let info: AccountInfo = resp.json().await?;
190 Ok(info)
191 }
192
193 /// Check the subscription status for this authenticated user + app.
194 ///
195 /// Returns `SubscriptionStatus` with `active: true` if sync is allowed,
196 /// or `active: false` if the user needs to subscribe.
197 pub async fn get_subscription_status(&self) -> Result<SubscriptionStatus> {
198 let token = self.require_token()?;
199
200 let resp = self.http
201 .get(&self.endpoints.subscription)
202 .bearer_auth(token.as_str())
203 .send()
204 .await?;
205
206 let resp = check_response(resp).await?;
207 let status: SubscriptionStatus = resp.json().await?;
208 Ok(status)
209 }
210
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.
214 pub async fn create_subscription_checkout(
215 &self,
216 cap_bytes: i64,
217 interval: BillingInterval,
218 ) -> Result<CheckoutResponse> {
219 let token = self.require_token()?;
220
221 let resp = self.http
222 .post(&self.endpoints.subscription_checkout)
223 .bearer_auth(token.as_str())
224 .json(&CheckoutRequest { cap_bytes, interval: interval.as_str() })
225 .send()
226 .await?;
227
228 let resp = check_response(resp).await?;
229 let checkout: CheckoutResponse = resp.json().await?;
230 Ok(checkout)
231 }
232
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> {
237 let token = self.require_token()?;
238
239 let resp = self.http
240 .post(&self.endpoints.subscription_storage_cap)
241 .bearer_auth(token.as_str())
242 .json(&CapChangeRequest { cap_bytes })
243 .send()
244 .await?;
245
246 let resp = check_response(resp).await?;
247 let status: SubscriptionStatus = resp.json().await?;
248 Ok(status)
249 }
250 }
251