Skip to main content

max / makenotwork

15.1 KB · 359 lines History Blame Raw
1 //! Stripe wiring for SyncKit v2 developer billing.
2 //!
3 //! One Stripe Customer is created per sync app (not per developer's MNW
4 //! account), because each app is billed independently. The subscription's
5 //! metadata carries `synckit_app_id` so the webhook dispatcher can route
6 //! events to the SyncKit billing path.
7 //!
8 //! Prices are created inline on the subscription via `price_data` rather than
9 //! by pre-creating Stripe Price objects; this keeps the dashboard tidy and
10 //! lets us re-price freely on every knob change. We do create a Stripe
11 //! `Product` per app once (the SDK requires a product id even for inline
12 //! price_data); the product is reused for subsequent re-prices.
13
14 use std::collections::HashMap;
15
16 use stripe::{IdempotencyKey, RequestStrategy, StripeRequest};
17 use stripe_billing::subscription::{
18 CancelSubscription, CreateSubscription, CreateSubscriptionItems,
19 CreateSubscriptionItemsPriceData, CreateSubscriptionItemsPriceDataRecurring,
20 CreateSubscriptionItemsPriceDataRecurringInterval, RetrieveSubscription, UpdateSubscription,
21 UpdateSubscriptionItems, UpdateSubscriptionItemsPriceData,
22 UpdateSubscriptionItemsPriceDataRecurring, UpdateSubscriptionItemsPriceDataRecurringInterval,
23 UpdateSubscriptionProrationBehavior,
24 };
25 use stripe_core::customer::CreateCustomer;
26 use stripe_product::product::CreateProduct;
27 use stripe_types::Currency;
28
29 use super::StripeClient;
30 use crate::db::{SyncAppId, UserId};
31 use crate::error::{AppError, Result};
32
33 /// Build a deterministic Stripe idempotency key. Keying the SyncKit
34 /// customer / product / subscription creates on the app id means two racing
35 /// `activate` (or `setup`) requests, which each pass the `billing_status =
36 /// 'draft'` read before either writes, return the *same* live Stripe object
37 /// instead of orphaning a duplicate subscription that would bill with no local
38 /// row (audit Run 17 Concurrency). The DB `activate_billing` UPDATE is already
39 /// `WHERE billing_status = 'draft'`-guarded, so the loser gets a Conflict; this
40 /// keeps the Stripe side from leaking a second billable object in that window.
41 fn synckit_idempotency_key(prefix: &str, app_id: SyncAppId) -> Result<IdempotencyKey> {
42 IdempotencyKey::new(format!("{prefix}-{app_id}"))
43 .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))
44 }
45
46 /// Result of creating a SyncKit subscription. Carries enough information for
47 /// the route handler to stamp the local `sync_apps` row in one go.
48 pub struct SynckitSubResult {
49 pub subscription_id: String,
50 pub current_period_start: i64,
51 pub current_period_end: i64,
52 }
53
54 fn parse_subscription_id(id: &str) -> Result<stripe_shared::SubscriptionId> {
55 id.parse().map_err(|e| {
56 AppError::Internal(anyhow::anyhow!(
57 "Invalid Stripe subscription ID '{id}': {e}"
58 ))
59 })
60 }
61
62 impl StripeClient {
63 /// Create a Stripe Customer for a SyncKit app. The customer represents
64 /// one app, not the developer's MNW account, because each app is billed
65 /// independently. Metadata pins the customer to both developer and app
66 /// for audit-trail visibility in the Stripe dashboard.
67 #[tracing::instrument(skip_all, name = "payments::create_synckit_customer")]
68 pub async fn create_synckit_customer(
69 &self,
70 developer_user_id: UserId,
71 app_id: SyncAppId,
72 email: &str,
73 app_name: &str,
74 ) -> Result<String> {
75 let mut metadata = HashMap::new();
76 metadata.insert("mnw_user_id".to_string(), developer_user_id.to_string());
77 metadata.insert("synckit_app_name".to_string(), app_name.to_string());
78
79 let key = synckit_idempotency_key("synckit-customer", app_id)?;
80 let customer = CreateCustomer::new()
81 .email(email.to_string())
82 .name(format!("SyncKit: {app_name}"))
83 .metadata(metadata)
84 .customize()
85 .request_strategy(RequestStrategy::Idempotent(key))
86 .send(&self.client)
87 .await
88 .map_err(|e| {
89 tracing::error!(error = ?e, "failed to create SyncKit Stripe customer");
90 AppError::Internal(anyhow::anyhow!("Failed to create Stripe customer"))
91 })?;
92
93 Ok(customer.id.to_string())
94 }
95
96 /// Create a Stripe Product for a SyncKit app. Called once during billing
97 /// activation; the same product is reused on re-price.
98 async fn create_synckit_product(&self, app_id: SyncAppId, app_name: &str) -> Result<String> {
99 let key = synckit_idempotency_key("synckit-product", app_id)?;
100 let product = CreateProduct::new(format!("SyncKit: {app_name}"))
101 .customize()
102 .request_strategy(RequestStrategy::Idempotent(key))
103 .send(&self.client)
104 .await
105 .map_err(|e| {
106 tracing::error!(error = ?e, "failed to create SyncKit Stripe product");
107 AppError::Internal(anyhow::anyhow!("Failed to create Stripe product"))
108 })?;
109 Ok(product.id.to_string())
110 }
111
112 /// Create a monthly recurring subscription for a SyncKit app. The price
113 /// is created inline via `price_data` (`unit_amount = price_cents`,
114 /// `interval = month`, `currency = usd`). Metadata `synckit_app_id` lets
115 /// the webhook dispatcher distinguish these from creator-tier / Fan+
116 /// subscriptions.
117 #[tracing::instrument(skip_all, name = "payments::create_synckit_subscription")]
118 pub async fn create_synckit_subscription(
119 &self,
120 customer_id: &str,
121 app_id: SyncAppId,
122 app_name: &str,
123 price_cents: i64,
124 ) -> Result<SynckitSubResult> {
125 if price_cents <= 0 {
126 return Err(AppError::BadRequest(
127 "Subscription price must be positive".to_string(),
128 ));
129 }
130
131 // We need a Product id to use inline price_data; create one per app.
132 let product_id = self.create_synckit_product(app_id, app_name).await?;
133
134 let price_data = CreateSubscriptionItemsPriceData {
135 currency: Currency::USD,
136 product: product_id,
137 recurring: CreateSubscriptionItemsPriceDataRecurring::new(
138 CreateSubscriptionItemsPriceDataRecurringInterval::Month,
139 ),
140 tax_behavior: None,
141 unit_amount: Some(price_cents),
142 unit_amount_decimal: None,
143 };
144
145 let mut item = CreateSubscriptionItems::new();
146 item.price_data = Some(price_data);
147
148 let mut metadata = HashMap::new();
149 metadata.insert("synckit_app_id".to_string(), app_id.to_string());
150
151 let key = synckit_idempotency_key("synckit-sub", app_id)?;
152 let subscription = CreateSubscription::new()
153 .customer(customer_id.to_string())
154 .items(vec![item])
155 .metadata(metadata)
156 .customize()
157 .request_strategy(RequestStrategy::Idempotent(key))
158 .send(&self.client)
159 .await
160 .map_err(|e| {
161 tracing::error!(error = ?e, app_id = %app_id, "failed to create SyncKit subscription");
162 AppError::Internal(anyhow::anyhow!("Failed to create Stripe subscription"))
163 })?;
164
165 let first_item = subscription.items.data.first().ok_or_else(|| {
166 AppError::Internal(anyhow::anyhow!("Stripe subscription has no items"))
167 })?;
168
169 Ok(SynckitSubResult {
170 subscription_id: subscription.id.to_string(),
171 current_period_start: first_item.current_period_start,
172 current_period_end: first_item.current_period_end,
173 })
174 }
175
176 /// Re-price a SyncKit subscription. Fetches the existing subscription to
177 /// learn its item id, then attaches a new inline `price_data` with the
178 /// new amount. Prorations are turned on (`create_prorations`) so the
179 /// developer is credited / charged the difference on the next invoice.
180 #[tracing::instrument(skip_all, name = "payments::update_synckit_subscription_price")]
181 pub async fn update_synckit_subscription_price(
182 &self,
183 subscription_id: &str,
184 new_price_cents: i64,
185 app_name: &str,
186 ) -> Result<()> {
187 if new_price_cents <= 0 {
188 return Err(AppError::BadRequest(
189 "Subscription price must be positive".to_string(),
190 ));
191 }
192
193 let sub_id = parse_subscription_id(subscription_id)?;
194
195 // Need the existing subscription item id to update its price.
196 let existing = RetrieveSubscription::new(sub_id.clone())
197 .send(&self.client)
198 .await
199 .map_err(|e| {
200 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to retrieve SyncKit subscription");
201 AppError::Internal(anyhow::anyhow!("Failed to retrieve Stripe subscription"))
202 })?;
203
204 let existing_item = existing.items.data.first().ok_or_else(|| {
205 AppError::Internal(anyhow::anyhow!(
206 "Stripe subscription {subscription_id} has no items"
207 ))
208 })?;
209
210 // Reuse the existing item's product so we don't accumulate orphans.
211 let product_id = existing_item.price.product.id().to_string();
212
213 let new_price_data = UpdateSubscriptionItemsPriceData {
214 currency: Currency::USD,
215 product: product_id,
216 recurring: stripe_billing::subscription::UpdateSubscriptionItemsPriceDataRecurring::new(
217 stripe_billing::subscription::UpdateSubscriptionItemsPriceDataRecurringInterval::Month,
218 ),
219 tax_behavior: None,
220 unit_amount: Some(new_price_cents),
221 unit_amount_decimal: None,
222 };
223
224 let item = UpdateSubscriptionItems {
225 id: Some(existing_item.id.to_string()),
226 price_data: Some(new_price_data),
227 ..Default::default()
228 };
229
230 // The product name (which surfaces on the Stripe dashboard for this
231 // product) is set once at create-time. Re-naming is a separate Stripe
232 // call we currently don't need, record the param so future re-naming
233 // hooks have it without changing the trait signature.
234 let _ = app_name;
235
236 UpdateSubscription::new(sub_id)
237 .items(vec![item])
238 .proration_behavior(UpdateSubscriptionProrationBehavior::CreateProrations)
239 .send(&self.client)
240 .await
241 .map_err(|e| {
242 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to update SyncKit subscription price");
243 AppError::Internal(anyhow::anyhow!("Failed to update Stripe subscription"))
244 })?;
245
246 Ok(())
247 }
248
249 /// Re-price an end-user SyncKit app subscription (the per-user subs that
250 /// run on MNW's own Stripe account, distinct from the developer-billing
251 /// subs above). Used by the storage-cap change path: when a user queues a
252 /// new cap, we update Stripe to charge the new price *at the next billing
253 /// cycle*, `proration_behavior=None`, so the cap and the price flip
254 /// together at the period boundary, matching the DB pending-cap semantics.
255 #[tracing::instrument(skip_all, name = "payments::update_synckit_app_sub_price")]
256 pub async fn update_synckit_app_sub_price(
257 &self,
258 subscription_id: &str,
259 new_price_cents: i64,
260 interval: super::SyncBillingInterval,
261 product_name: &str,
262 ) -> Result<()> {
263 if new_price_cents <= 0 {
264 return Err(AppError::BadRequest(
265 "Subscription price must be positive".to_string(),
266 ));
267 }
268
269 let sub_id = parse_subscription_id(subscription_id)?;
270
271 let existing = RetrieveSubscription::new(sub_id.clone())
272 .send(&self.client)
273 .await
274 .map_err(|e| {
275 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to retrieve app sub");
276 AppError::Internal(anyhow::anyhow!("Failed to retrieve Stripe subscription"))
277 })?;
278
279 let existing_item = existing.items.data.first().ok_or_else(|| {
280 AppError::Internal(anyhow::anyhow!(
281 "Stripe subscription {subscription_id} has no items"
282 ))
283 })?;
284
285 let product_id = existing_item.price.product.id().to_string();
286 let _ = product_name;
287
288 let recurring_interval = match interval {
289 super::SyncBillingInterval::Monthly => {
290 UpdateSubscriptionItemsPriceDataRecurringInterval::Month
291 }
292 super::SyncBillingInterval::Annual => {
293 UpdateSubscriptionItemsPriceDataRecurringInterval::Year
294 }
295 };
296
297 let new_price_data = UpdateSubscriptionItemsPriceData {
298 currency: Currency::USD,
299 product: product_id,
300 recurring: UpdateSubscriptionItemsPriceDataRecurring::new(recurring_interval),
301 tax_behavior: None,
302 unit_amount: Some(new_price_cents),
303 unit_amount_decimal: None,
304 };
305
306 let item = UpdateSubscriptionItems {
307 id: Some(existing_item.id.to_string()),
308 price_data: Some(new_price_data),
309 ..Default::default()
310 };
311
312 UpdateSubscription::new(sub_id)
313 .items(vec![item])
314 .proration_behavior(UpdateSubscriptionProrationBehavior::None)
315 .send(&self.client)
316 .await
317 .map_err(|e| {
318 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to re-price app sub");
319 AppError::Internal(anyhow::anyhow!("Failed to update Stripe subscription"))
320 })?;
321
322 Ok(())
323 }
324
325 /// Cancel a SyncKit subscription immediately.
326 ///
327 /// We cancel immediately (rather than at_period_end=true) because the
328 /// developer is paying for cloud resources we'll stop providing the
329 /// moment the app is canceled. Holding the subscription open for a few
330 /// extra weeks would let the developer keep billing accruing against a
331 /// dead app, worse for everyone.
332 #[tracing::instrument(skip_all, name = "payments::cancel_synckit_subscription")]
333 pub async fn cancel_synckit_subscription(&self, subscription_id: &str) -> Result<()> {
334 let sub_id = parse_subscription_id(subscription_id)?;
335 CancelSubscription::new(sub_id)
336 .send(&self.client)
337 .await
338 .map_err(|e| {
339 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to cancel SyncKit subscription");
340 AppError::Internal(anyhow::anyhow!("Failed to cancel Stripe subscription"))
341 })?;
342 Ok(())
343 }
344
345 /// Open a Stripe billing portal session for the SyncKit app's customer.
346 /// Reuses the platform-level billing portal pattern.
347 #[tracing::instrument(skip_all, name = "payments::create_synckit_billing_portal")]
348 pub async fn create_synckit_billing_portal(
349 &self,
350 customer_id: &str,
351 return_url: &str,
352 ) -> Result<String> {
353 // Identical to the creator-tier / Fan+ billing portal path, kept as
354 // a separate method so the trait surface mirrors the SyncKit domain.
355 self.create_billing_portal_session(customer_id, return_url)
356 .await
357 }
358 }
359