Skip to main content

max / makenotwork

13.7 KB · 338 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 item = UpdateSubscriptionItems {
204 id: Some(existing_item.id.to_string()),
205 price_data: Some(new_price_data),
206 ..Default::default()
207 };
208
209 // The product name (which surfaces on the Stripe dashboard for this
210 // product) is set once at create-time. Re-naming is a separate Stripe
211 // call we currently don't need — record the param so future re-naming
212 // hooks have it without changing the trait signature.
213 let _ = app_name;
214
215 UpdateSubscription::new(sub_id)
216 .items(vec![item])
217 .proration_behavior(UpdateSubscriptionProrationBehavior::CreateProrations)
218 .send(&self.client)
219 .await
220 .map_err(|e| {
221 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to update SyncKit subscription price");
222 AppError::Internal(anyhow::anyhow!("Failed to update Stripe subscription"))
223 })?;
224
225 Ok(())
226 }
227
228 /// Re-price an end-user SyncKit app subscription (the per-user subs that
229 /// run on MNW's own Stripe account, distinct from the developer-billing
230 /// subs above). Used by the storage-cap change path: when a user queues a
231 /// new cap, we update Stripe to charge the new price *at the next billing
232 /// cycle* — `proration_behavior=None` — so the cap and the price flip
233 /// together at the period boundary, matching the DB pending-cap semantics.
234 #[tracing::instrument(skip_all, name = "payments::update_synckit_app_sub_price")]
235 pub async fn update_synckit_app_sub_price(
236 &self,
237 subscription_id: &str,
238 new_price_cents: i64,
239 interval: super::SyncBillingInterval,
240 product_name: &str,
241 ) -> Result<()> {
242 if new_price_cents <= 0 {
243 return Err(AppError::BadRequest(
244 "Subscription price must be positive".to_string(),
245 ));
246 }
247
248 let sub_id = parse_subscription_id(subscription_id)?;
249
250 let existing = RetrieveSubscription::new(sub_id.clone())
251 .send(&self.client)
252 .await
253 .map_err(|e| {
254 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to retrieve app sub");
255 AppError::Internal(anyhow::anyhow!("Failed to retrieve Stripe subscription"))
256 })?;
257
258 let existing_item = existing.items.data.first().ok_or_else(|| {
259 AppError::Internal(anyhow::anyhow!(
260 "Stripe subscription {} has no items",
261 subscription_id
262 ))
263 })?;
264
265 let product_id = existing_item.price.product.id().to_string();
266 let _ = product_name;
267
268 let recurring_interval = match interval {
269 super::SyncBillingInterval::Monthly => {
270 UpdateSubscriptionItemsPriceDataRecurringInterval::Month
271 }
272 super::SyncBillingInterval::Annual => {
273 UpdateSubscriptionItemsPriceDataRecurringInterval::Year
274 }
275 };
276
277 let new_price_data = UpdateSubscriptionItemsPriceData {
278 currency: Currency::USD,
279 product: product_id,
280 recurring: UpdateSubscriptionItemsPriceDataRecurring::new(recurring_interval),
281 tax_behavior: None,
282 unit_amount: Some(new_price_cents),
283 unit_amount_decimal: None,
284 };
285
286 let item = UpdateSubscriptionItems {
287 id: Some(existing_item.id.to_string()),
288 price_data: Some(new_price_data),
289 ..Default::default()
290 };
291
292 UpdateSubscription::new(sub_id)
293 .items(vec![item])
294 .proration_behavior(UpdateSubscriptionProrationBehavior::None)
295 .send(&self.client)
296 .await
297 .map_err(|e| {
298 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to re-price app sub");
299 AppError::Internal(anyhow::anyhow!("Failed to update Stripe subscription"))
300 })?;
301
302 Ok(())
303 }
304
305 /// Cancel a SyncKit subscription immediately.
306 ///
307 /// We cancel immediately (rather than at_period_end=true) because the
308 /// developer is paying for cloud resources we'll stop providing the
309 /// moment the app is canceled. Holding the subscription open for a few
310 /// extra weeks would let the developer keep billing accruing against a
311 /// dead app, worse for everyone.
312 #[tracing::instrument(skip_all, name = "payments::cancel_synckit_subscription")]
313 pub async fn cancel_synckit_subscription(&self, subscription_id: &str) -> Result<()> {
314 let sub_id = parse_subscription_id(subscription_id)?;
315 CancelSubscription::new(sub_id)
316 .send(&self.client)
317 .await
318 .map_err(|e| {
319 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to cancel SyncKit subscription");
320 AppError::Internal(anyhow::anyhow!("Failed to cancel Stripe subscription"))
321 })?;
322 Ok(())
323 }
324
325 /// Open a Stripe billing portal session for the SyncKit app's customer.
326 /// Reuses the platform-level billing portal pattern.
327 #[tracing::instrument(skip_all, name = "payments::create_synckit_billing_portal")]
328 pub async fn create_synckit_billing_portal(
329 &self,
330 customer_id: &str,
331 return_url: &str,
332 ) -> Result<String> {
333 // Identical to the creator-tier / Fan+ billing portal path — kept as
334 // a separate method so the trait surface mirrors the SyncKit domain.
335 self.create_billing_portal_session(customer_id, return_url).await
336 }
337 }
338