Skip to main content

max / makenotwork

17.9 KB · 427 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 //! # Currency
15 //!
16 //! USD throughout, on purpose. This is Make Creative billing a developer for
17 //! SyncKit, not a creator selling to a fan, so there is no settlement currency
18 //! to read: the same rule that keeps the creator tiers and Fan+ in USD. The
19 //! `Currency::USD` literals below are the intent, not a missed conversion.
20
21 use std::collections::HashMap;
22
23 use stripe::{IdempotencyKey, RequestStrategy, StripeRequest};
24 use stripe_billing::subscription::{
25 CancelSubscription, CreateSubscription, CreateSubscriptionItems,
26 CreateSubscriptionItemsPriceData, CreateSubscriptionItemsPriceDataRecurring,
27 CreateSubscriptionItemsPriceDataRecurringInterval, RetrieveSubscription, UpdateSubscription,
28 UpdateSubscriptionItems, UpdateSubscriptionItemsPriceData,
29 UpdateSubscriptionItemsPriceDataRecurring, UpdateSubscriptionItemsPriceDataRecurringInterval,
30 UpdateSubscriptionProrationBehavior,
31 };
32 use stripe_core::customer::CreateCustomer;
33 use stripe_product::product::CreateProduct;
34 use stripe_types::Currency;
35
36 use super::StripeClient;
37 use crate::db::{SyncAppId, UserId};
38 use crate::error::{AppError, Result};
39
40 /// Build a deterministic Stripe idempotency key. Keying the SyncKit
41 /// customer / product / subscription creates on the app id means two racing
42 /// `activate` (or `setup`) requests, which each pass the `billing_status =
43 /// 'draft'` read before either writes, return the *same* live Stripe object
44 /// instead of orphaning a duplicate subscription that would bill with no local
45 /// row (audit Run 17 Concurrency). The DB `activate_billing` UPDATE is already
46 /// `WHERE billing_status = 'draft'`-guarded, so the loser gets a Conflict; this
47 /// keeps the Stripe side from leaking a second billable object in that window.
48 fn synckit_idempotency_key(prefix: &str, app_id: SyncAppId) -> Result<IdempotencyKey> {
49 IdempotencyKey::new(format!("{prefix}-{app_id}"))
50 .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))
51 }
52
53 /// Result of creating a SyncKit subscription. Carries enough information for
54 /// the route handler to stamp the local `sync_apps` row in one go.
55 pub struct SynckitSubResult {
56 pub subscription_id: String,
57 pub current_period_start: i64,
58 pub current_period_end: i64,
59 }
60
61 fn parse_subscription_id(id: &str) -> Result<stripe_shared::SubscriptionId> {
62 id.parse().map_err(|e| {
63 AppError::Internal(anyhow::anyhow!(
64 "Invalid Stripe subscription ID '{id}': {e}"
65 ))
66 })
67 }
68
69 impl StripeClient {
70 /// Create a Stripe Customer for a SyncKit app. The customer represents
71 /// one app, not the developer's MNW account, because each app is billed
72 /// independently. Metadata pins the customer to both developer and app
73 /// for audit-trail visibility in the Stripe dashboard.
74 #[tracing::instrument(skip_all, name = "payments::create_synckit_customer")]
75 pub async fn create_synckit_customer(
76 &self,
77 developer_user_id: UserId,
78 app_id: SyncAppId,
79 email: &str,
80 app_name: &str,
81 ) -> Result<String> {
82 let mut metadata = HashMap::new();
83 metadata.insert("mnw_user_id".to_string(), developer_user_id.to_string());
84 metadata.insert("synckit_app_name".to_string(), app_name.to_string());
85
86 let key = synckit_idempotency_key("synckit-customer", app_id)?;
87 let customer = CreateCustomer::new()
88 .email(email.to_string())
89 .name(format!("SyncKit: {app_name}"))
90 .metadata(metadata)
91 .customize()
92 .request_strategy(RequestStrategy::Idempotent(key))
93 .send(&self.client)
94 .await
95 .map_err(|e| {
96 tracing::error!(error = ?e, "failed to create SyncKit Stripe customer");
97 AppError::Internal(anyhow::anyhow!("Failed to create Stripe customer"))
98 })?;
99
100 Ok(customer.id.to_string())
101 }
102
103 /// Create a Stripe Product for a SyncKit app. Called once during billing
104 /// activation; the same product is reused on re-price.
105 async fn create_synckit_product(&self, app_id: SyncAppId, app_name: &str) -> Result<String> {
106 let key = synckit_idempotency_key("synckit-product", app_id)?;
107 let product = CreateProduct::new(format!("SyncKit: {app_name}"))
108 .customize()
109 .request_strategy(RequestStrategy::Idempotent(key))
110 .send(&self.client)
111 .await
112 .map_err(|e| {
113 tracing::error!(error = ?e, "failed to create SyncKit Stripe product");
114 AppError::Internal(anyhow::anyhow!("Failed to create Stripe product"))
115 })?;
116 Ok(product.id.to_string())
117 }
118
119 /// Create a monthly recurring subscription for a SyncKit app. The price
120 /// is created inline via `price_data` (`unit_amount = price_cents`,
121 /// `interval = month`, `currency = usd`). Metadata `synckit_app_id` lets
122 /// the webhook dispatcher distinguish these from creator-tier / Fan+
123 /// subscriptions.
124 #[tracing::instrument(skip_all, name = "payments::create_synckit_subscription")]
125 pub async fn create_synckit_subscription(
126 &self,
127 customer_id: &str,
128 app_id: SyncAppId,
129 app_name: &str,
130 price_cents: i64,
131 ) -> Result<SynckitSubResult> {
132 if price_cents <= 0 {
133 return Err(AppError::BadRequest(
134 "Subscription price must be positive".to_string(),
135 ));
136 }
137
138 // We need a Product id to use inline price_data; create one per app.
139 let product_id = self.create_synckit_product(app_id, app_name).await?;
140
141 let price_data = CreateSubscriptionItemsPriceData {
142 currency: Currency::USD,
143 product: product_id,
144 recurring: CreateSubscriptionItemsPriceDataRecurring::new(
145 CreateSubscriptionItemsPriceDataRecurringInterval::Month,
146 ),
147 tax_behavior: None,
148 unit_amount: Some(price_cents),
149 unit_amount_decimal: None,
150 };
151
152 let mut item = CreateSubscriptionItems::new();
153 item.price_data = Some(price_data);
154
155 let mut metadata = HashMap::new();
156 metadata.insert("synckit_app_id".to_string(), app_id.to_string());
157
158 let key = synckit_idempotency_key("synckit-sub", app_id)?;
159 let subscription = CreateSubscription::new()
160 .customer(customer_id.to_string())
161 .items(vec![item])
162 .metadata(metadata)
163 .customize()
164 .request_strategy(RequestStrategy::Idempotent(key))
165 .send(&self.client)
166 .await
167 .map_err(|e| {
168 tracing::error!(error = ?e, app_id = %app_id, "failed to create SyncKit subscription");
169 AppError::Internal(anyhow::anyhow!("Failed to create Stripe subscription"))
170 })?;
171
172 let first_item = subscription.items.data.first().ok_or_else(|| {
173 AppError::Internal(anyhow::anyhow!("Stripe subscription has no items"))
174 })?;
175
176 Ok(SynckitSubResult {
177 subscription_id: subscription.id.to_string(),
178 current_period_start: first_item.current_period_start,
179 current_period_end: first_item.current_period_end,
180 })
181 }
182
183 /// Re-price a SyncKit subscription. Fetches the existing subscription to
184 /// learn its item id, then attaches a new inline `price_data` with the
185 /// new amount. Prorations are turned on (`create_prorations`) so the
186 /// developer is credited / charged the difference on the next invoice.
187 #[tracing::instrument(skip_all, name = "payments::update_synckit_subscription_price")]
188 pub async fn update_synckit_subscription_price(
189 &self,
190 subscription_id: &str,
191 new_price_cents: i64,
192 app_name: &str,
193 ) -> Result<()> {
194 if new_price_cents <= 0 {
195 return Err(AppError::BadRequest(
196 "Subscription price must be positive".to_string(),
197 ));
198 }
199
200 let sub_id = parse_subscription_id(subscription_id)?;
201
202 // Need the existing subscription item id to update its price.
203 let existing = RetrieveSubscription::new(sub_id.clone())
204 .send(&self.client)
205 .await
206 .map_err(|e| {
207 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to retrieve SyncKit subscription");
208 AppError::Internal(anyhow::anyhow!("Failed to retrieve Stripe subscription"))
209 })?;
210
211 let existing_item = existing.items.data.first().ok_or_else(|| {
212 AppError::Internal(anyhow::anyhow!(
213 "Stripe subscription {subscription_id} has no items"
214 ))
215 })?;
216
217 // Reuse the existing item's product so we don't accumulate orphans.
218 let product_id = existing_item.price.product.id().to_string();
219
220 let new_price_data = UpdateSubscriptionItemsPriceData {
221 currency: Currency::USD,
222 product: product_id,
223 recurring: stripe_billing::subscription::UpdateSubscriptionItemsPriceDataRecurring::new(
224 stripe_billing::subscription::UpdateSubscriptionItemsPriceDataRecurringInterval::Month,
225 ),
226 tax_behavior: None,
227 unit_amount: Some(new_price_cents),
228 unit_amount_decimal: None,
229 };
230
231 let item = UpdateSubscriptionItems {
232 id: Some(existing_item.id.to_string()),
233 price_data: Some(new_price_data),
234 ..Default::default()
235 };
236
237 // The product name (which surfaces on the Stripe dashboard for this
238 // product) is set once at create-time. Re-naming is a separate Stripe
239 // call we currently don't need, record the param so future re-naming
240 // hooks have it without changing the trait signature.
241 let _ = app_name;
242
243 UpdateSubscription::new(sub_id)
244 .items(vec![item])
245 .proration_behavior(UpdateSubscriptionProrationBehavior::CreateProrations)
246 .send(&self.client)
247 .await
248 .map_err(|e| {
249 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to update SyncKit subscription price");
250 AppError::Internal(anyhow::anyhow!("Failed to update Stripe subscription"))
251 })?;
252
253 Ok(())
254 }
255
256 /// Re-price an end-user SyncKit app subscription (the per-user subs that
257 /// run on MNW's own Stripe account, distinct from the developer-billing
258 /// subs above). Used by the storage-cap change path: when a user queues a
259 /// new cap, we update Stripe to charge the new price *at the next billing
260 /// cycle*, `proration_behavior=None`, so the cap and the price flip
261 /// together at the period boundary, matching the DB pending-cap semantics.
262 #[tracing::instrument(skip_all, name = "payments::update_synckit_app_sub_price")]
263 pub async fn update_synckit_app_sub_price(
264 &self,
265 subscription_id: &str,
266 new_price_cents: i64,
267 interval: super::SyncBillingInterval,
268 product_name: &str,
269 ) -> Result<()> {
270 if new_price_cents <= 0 {
271 return Err(AppError::BadRequest(
272 "Subscription price must be positive".to_string(),
273 ));
274 }
275
276 let sub_id = parse_subscription_id(subscription_id)?;
277
278 let existing = RetrieveSubscription::new(sub_id.clone())
279 .send(&self.client)
280 .await
281 .map_err(|e| {
282 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to retrieve app sub");
283 AppError::Internal(anyhow::anyhow!("Failed to retrieve Stripe subscription"))
284 })?;
285
286 let existing_item = existing.items.data.first().ok_or_else(|| {
287 AppError::Internal(anyhow::anyhow!(
288 "Stripe subscription {subscription_id} has no items"
289 ))
290 })?;
291
292 let product_id = existing_item.price.product.id().to_string();
293 let _ = product_name;
294
295 let recurring_interval = match interval {
296 super::SyncBillingInterval::Monthly => {
297 UpdateSubscriptionItemsPriceDataRecurringInterval::Month
298 }
299 super::SyncBillingInterval::Annual => {
300 UpdateSubscriptionItemsPriceDataRecurringInterval::Year
301 }
302 };
303
304 let new_price_data = UpdateSubscriptionItemsPriceData {
305 currency: Currency::USD,
306 product: product_id,
307 recurring: UpdateSubscriptionItemsPriceDataRecurring::new(recurring_interval),
308 tax_behavior: None,
309 unit_amount: Some(new_price_cents),
310 unit_amount_decimal: None,
311 };
312
313 let item = UpdateSubscriptionItems {
314 id: Some(existing_item.id.to_string()),
315 price_data: Some(new_price_data),
316 ..Default::default()
317 };
318
319 UpdateSubscription::new(sub_id)
320 .items(vec![item])
321 .proration_behavior(UpdateSubscriptionProrationBehavior::None)
322 .send(&self.client)
323 .await
324 .map_err(|e| {
325 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to re-price app sub");
326 AppError::Internal(anyhow::anyhow!("Failed to update Stripe subscription"))
327 })?;
328
329 Ok(())
330 }
331
332 /// Cancel a SyncKit subscription immediately.
333 ///
334 /// We cancel immediately (rather than at_period_end=true) because the
335 /// developer is paying for cloud resources we'll stop providing the
336 /// moment the app is canceled. Holding the subscription open for a few
337 /// extra weeks would let the developer keep billing accruing against a
338 /// dead app, worse for everyone.
339 #[tracing::instrument(skip_all, name = "payments::cancel_synckit_subscription")]
340 pub async fn cancel_synckit_subscription(&self, subscription_id: &str) -> Result<()> {
341 let sub_id = parse_subscription_id(subscription_id)?;
342 CancelSubscription::new(sub_id)
343 .send(&self.client)
344 .await
345 .map_err(|e| {
346 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to cancel SyncKit subscription");
347 AppError::Internal(anyhow::anyhow!("Failed to cancel Stripe subscription"))
348 })?;
349 Ok(())
350 }
351
352 /// Open a Stripe billing portal session for the SyncKit app's customer.
353 /// Reuses the platform-level billing portal pattern.
354 #[tracing::instrument(skip_all, name = "payments::create_synckit_billing_portal")]
355 pub async fn create_synckit_billing_portal(
356 &self,
357 customer_id: &str,
358 return_url: &str,
359 ) -> Result<String> {
360 // Identical to the creator-tier / Fan+ billing portal path, kept as
361 // a separate method so the trait surface mirrors the SyncKit domain.
362 self.create_billing_portal_session(customer_id, return_url)
363 .await
364 }
365 }
366
367 #[cfg(test)]
368 mod tests {
369 //! Idempotency keys for SyncKit's Stripe writes. The key is the only thing
370 //! standing between two racing `activate` requests and a duplicate live
371 //! subscription that bills a developer with no local row to cancel it, so
372 //! its determinism is a billing invariant rather than a detail.
373
374 use super::*;
375
376 #[test]
377 fn the_same_app_and_prefix_always_produce_the_same_key() {
378 let app = SyncAppId::nil();
379 let a = synckit_idempotency_key("synckit-sub", app).expect("valid key");
380 let b = synckit_idempotency_key("synckit-sub", app).expect("valid key");
381 assert_eq!(
382 format!("{a:?}"),
383 format!("{b:?}"),
384 "two racing activates must reuse one Stripe object, not create two"
385 );
386 }
387
388 #[test]
389 fn a_different_operation_on_one_app_gets_a_different_key() {
390 let app = SyncAppId::nil();
391 let sub = synckit_idempotency_key("synckit-sub", app).expect("valid key");
392 let cust = synckit_idempotency_key("synckit-cust", app).expect("valid key");
393 assert_ne!(
394 format!("{sub:?}"),
395 format!("{cust:?}"),
396 "sharing a key across operations would make Stripe replay the wrong response"
397 );
398 }
399
400 #[test]
401 fn an_over_long_prefix_is_an_error_rather_than_a_silently_truncated_key() {
402 // Stripe caps idempotency keys at 255 characters. Truncation would make
403 // two distinct operations collide, which is worse than failing loudly.
404 let err = synckit_idempotency_key(&"x".repeat(300), SyncAppId::nil());
405 assert!(
406 matches!(err, Err(AppError::Internal(_))),
407 "an unusable key must not reach Stripe"
408 );
409 }
410
411 #[test]
412 fn subscription_id_parsing_rejects_nothing_at_all() {
413 // Not the contract this function's name and error message imply.
414 // `stripe_shared::SubscriptionId` derives `FromStr` with
415 // `type Err = Infallible`: it wraps the string and always succeeds. The
416 // `AppError::Internal("Invalid Stripe subscription ID")` branch is
417 // unreachable, so an empty or garbage id from our own row is passed
418 // straight to Stripe's cancel / re-price calls.
419 //
420 // Documented rather than asserted-as-correct: adding a real check is a
421 // money-path behaviour change. Filed as a problem against mnw-server.
422 assert!(parse_subscription_id("").is_ok());
423 assert!(parse_subscription_id("not a sub id").is_ok());
424 assert!(parse_subscription_id("acct_wrong_type").is_ok());
425 }
426 }
427