Skip to main content

max / makenotwork

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