Skip to main content

max / makenotwork

25.7 KB · 652 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. 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 /// The Customer body for one SyncKit app. The customer represents the app,
70 /// not the developer's MNW account, because each app is billed independently.
71 ///
72 /// Split from the method that sends it so the request Stripe is handed can be
73 /// read back in a test; every builder below follows the same shape.
74 fn synckit_customer_request(
75 developer_user_id: UserId,
76 email: &str,
77 app_name: &str,
78 ) -> CreateCustomer {
79 let mut metadata = HashMap::new();
80 metadata.insert("mnw_user_id".to_string(), developer_user_id.to_string());
81 metadata.insert("synckit_app_name".to_string(), app_name.to_string());
82 CreateCustomer::new()
83 .email(email.to_string())
84 .name(format!("SyncKit: {app_name}"))
85 .metadata(metadata)
86 }
87
88 /// The Product body. Created once per app; inline `price_data` needs a
89 /// product id even though the price itself is never a stored Price object.
90 fn synckit_product_request(app_name: &str) -> CreateProduct {
91 CreateProduct::new(format!("SyncKit: {app_name}"))
92 }
93
94 /// The monthly developer subscription, priced inline. `synckit_app_id` in the
95 /// metadata is what the webhook dispatcher routes on, so a subscription
96 /// without it arrives as an unrecognised creator-tier event.
97 fn synckit_subscription_request(
98 customer_id: &str,
99 app_id: SyncAppId,
100 product_id: &str,
101 price_cents: i64,
102 ) -> CreateSubscription {
103 let price_data = CreateSubscriptionItemsPriceData {
104 currency: Currency::USD,
105 product: product_id.to_string(),
106 recurring: CreateSubscriptionItemsPriceDataRecurring::new(
107 CreateSubscriptionItemsPriceDataRecurringInterval::Month,
108 ),
109 tax_behavior: None,
110 unit_amount: Some(price_cents),
111 unit_amount_decimal: None,
112 };
113 let mut item = CreateSubscriptionItems::new();
114 item.price_data = Some(price_data);
115
116 let mut metadata = HashMap::new();
117 metadata.insert("synckit_app_id".to_string(), app_id.to_string());
118
119 CreateSubscription::new()
120 .customer(customer_id.to_string())
121 .items(vec![item])
122 .metadata(metadata)
123 }
124
125 /// A re-price of an existing subscription item, reusing its product so
126 /// orphans do not accumulate. `proration` decides whether the developer is
127 /// charged the difference now or at the period boundary.
128 fn reprice_request(
129 sub_id: stripe_shared::SubscriptionId,
130 item_id: &str,
131 product_id: &str,
132 new_price_cents: i64,
133 interval: UpdateSubscriptionItemsPriceDataRecurringInterval,
134 proration: UpdateSubscriptionProrationBehavior,
135 ) -> UpdateSubscription {
136 let new_price_data = UpdateSubscriptionItemsPriceData {
137 currency: Currency::USD,
138 product: product_id.to_string(),
139 recurring: UpdateSubscriptionItemsPriceDataRecurring::new(interval),
140 tax_behavior: None,
141 unit_amount: Some(new_price_cents),
142 unit_amount_decimal: None,
143 };
144 let item = UpdateSubscriptionItems {
145 id: Some(item_id.to_string()),
146 price_data: Some(new_price_data),
147 ..Default::default()
148 };
149 UpdateSubscription::new(sub_id)
150 .items(vec![item])
151 .proration_behavior(proration)
152 }
153
154 /// The recurring interval an end-user app subscription bills on.
155 fn app_sub_interval(
156 interval: super::SyncBillingInterval,
157 ) -> UpdateSubscriptionItemsPriceDataRecurringInterval {
158 match interval {
159 super::SyncBillingInterval::Monthly => {
160 UpdateSubscriptionItemsPriceDataRecurringInterval::Month
161 }
162 super::SyncBillingInterval::Annual => {
163 UpdateSubscriptionItemsPriceDataRecurringInterval::Year
164 }
165 }
166 }
167
168 impl StripeClient {
169 /// Create a Stripe Customer for a SyncKit app. The customer represents
170 /// one app, not the developer's MNW account, because each app is billed
171 /// independently. Metadata pins the customer to both developer and app
172 /// for audit-trail visibility in the Stripe dashboard.
173 #[tracing::instrument(skip_all, name = "payments::create_synckit_customer")]
174 pub async fn create_synckit_customer(
175 &self,
176 developer_user_id: UserId,
177 app_id: SyncAppId,
178 email: &str,
179 app_name: &str,
180 ) -> Result<String> {
181 let key = synckit_idempotency_key("synckit-customer", app_id)?;
182 let customer = synckit_customer_request(developer_user_id, email, app_name)
183 .customize()
184 .request_strategy(RequestStrategy::Idempotent(key))
185 .send(&self.client)
186 .await
187 .map_err(|e| {
188 tracing::error!(error = ?e, "failed to create SyncKit Stripe customer");
189 AppError::Internal(anyhow::anyhow!("Failed to create Stripe customer"))
190 })?;
191
192 Ok(customer.id.to_string())
193 }
194
195 /// Create a Stripe Product for a SyncKit app. Called once during billing
196 /// activation; the same product is reused on re-price.
197 async fn create_synckit_product(&self, app_id: SyncAppId, app_name: &str) -> Result<String> {
198 let key = synckit_idempotency_key("synckit-product", app_id)?;
199 let product = synckit_product_request(app_name)
200 .customize()
201 .request_strategy(RequestStrategy::Idempotent(key))
202 .send(&self.client)
203 .await
204 .map_err(|e| {
205 tracing::error!(error = ?e, "failed to create SyncKit Stripe product");
206 AppError::Internal(anyhow::anyhow!("Failed to create Stripe product"))
207 })?;
208 Ok(product.id.to_string())
209 }
210
211 /// Create a monthly recurring subscription for a SyncKit app. The price
212 /// is created inline via `price_data` (`unit_amount = price_cents`,
213 /// `interval = month`, `currency = usd`). Metadata `synckit_app_id` lets
214 /// the webhook dispatcher distinguish these from creator-tier / Fan+
215 /// subscriptions.
216 #[tracing::instrument(skip_all, name = "payments::create_synckit_subscription")]
217 pub async fn create_synckit_subscription(
218 &self,
219 customer_id: &str,
220 app_id: SyncAppId,
221 app_name: &str,
222 price_cents: i64,
223 ) -> Result<SynckitSubResult> {
224 if price_cents <= 0 {
225 return Err(AppError::BadRequest(
226 "Subscription price must be positive".to_string(),
227 ));
228 }
229
230 // We need a Product id to use inline price_data; create one per app.
231 let product_id = self.create_synckit_product(app_id, app_name).await?;
232
233 let key = synckit_idempotency_key("synckit-sub", app_id)?;
234 let subscription = synckit_subscription_request(
235 customer_id,
236 app_id,
237 &product_id,
238 price_cents,
239 )
240 .customize()
241 .request_strategy(RequestStrategy::Idempotent(key))
242 .send(&self.client)
243 .await
244 .map_err(|e| {
245 tracing::error!(error = ?e, app_id = %app_id, "failed to create SyncKit subscription");
246 AppError::Internal(anyhow::anyhow!("Failed to create Stripe subscription"))
247 })?;
248
249 let first_item = subscription.items.data.first().ok_or_else(|| {
250 AppError::Internal(anyhow::anyhow!("Stripe subscription has no items"))
251 })?;
252
253 Ok(SynckitSubResult {
254 subscription_id: subscription.id.to_string(),
255 current_period_start: first_item.current_period_start,
256 current_period_end: first_item.current_period_end,
257 })
258 }
259
260 /// Re-price a SyncKit subscription. Fetches the existing subscription to
261 /// learn its item id, then attaches a new inline `price_data` with the
262 /// new amount. Prorations are turned on (`create_prorations`) so the
263 /// developer is credited / charged the difference on the next invoice.
264 #[tracing::instrument(skip_all, name = "payments::update_synckit_subscription_price")]
265 pub async fn update_synckit_subscription_price(
266 &self,
267 subscription_id: &str,
268 new_price_cents: i64,
269 app_name: &str,
270 ) -> Result<()> {
271 if new_price_cents <= 0 {
272 return Err(AppError::BadRequest(
273 "Subscription price must be positive".to_string(),
274 ));
275 }
276
277 let sub_id = parse_subscription_id(subscription_id)?;
278
279 // Need the existing subscription item id to update its price.
280 let existing = RetrieveSubscription::new(sub_id.clone())
281 .send(&self.client)
282 .await
283 .map_err(|e| {
284 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to retrieve SyncKit subscription");
285 AppError::Internal(anyhow::anyhow!("Failed to retrieve Stripe subscription"))
286 })?;
287
288 let existing_item = existing.items.data.first().ok_or_else(|| {
289 AppError::Internal(anyhow::anyhow!(
290 "Stripe subscription {subscription_id} has no items"
291 ))
292 })?;
293
294 // Reuse the existing item's product so we don't accumulate orphans.
295 let product_id = existing_item.price.product.id().to_string();
296
297 // The product name (which surfaces on the Stripe dashboard for this
298 // product) is set once at create-time. Re-naming is a separate Stripe
299 // call we currently don't need, record the param so future re-naming
300 // hooks have it without changing the trait signature.
301 let _ = app_name;
302
303 reprice_request(
304 sub_id,
305 existing_item.id.as_ref(),
306 &product_id,
307 new_price_cents,
308 UpdateSubscriptionItemsPriceDataRecurringInterval::Month,
309 UpdateSubscriptionProrationBehavior::CreateProrations,
310 )
311 .send(&self.client)
312 .await
313 .map_err(|e| {
314 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to update SyncKit subscription price");
315 AppError::Internal(anyhow::anyhow!("Failed to update Stripe subscription"))
316 })?;
317
318 Ok(())
319 }
320
321 /// Re-price an end-user SyncKit app subscription (the per-user subs that
322 /// run on MNW's own Stripe account, distinct from the developer-billing
323 /// subs above). Used by the storage-cap change path: when a user queues a
324 /// new cap, we update Stripe to charge the new price *at the next billing
325 /// cycle*, `proration_behavior=None`, so the cap and the price flip
326 /// together at the period boundary, matching the DB pending-cap semantics.
327 #[tracing::instrument(skip_all, name = "payments::update_synckit_app_sub_price")]
328 pub async fn update_synckit_app_sub_price(
329 &self,
330 subscription_id: &str,
331 new_price_cents: i64,
332 interval: super::SyncBillingInterval,
333 product_name: &str,
334 ) -> Result<()> {
335 if new_price_cents <= 0 {
336 return Err(AppError::BadRequest(
337 "Subscription price must be positive".to_string(),
338 ));
339 }
340
341 let sub_id = parse_subscription_id(subscription_id)?;
342
343 let existing = RetrieveSubscription::new(sub_id.clone())
344 .send(&self.client)
345 .await
346 .map_err(|e| {
347 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to retrieve app sub");
348 AppError::Internal(anyhow::anyhow!("Failed to retrieve Stripe subscription"))
349 })?;
350
351 let existing_item = existing.items.data.first().ok_or_else(|| {
352 AppError::Internal(anyhow::anyhow!(
353 "Stripe subscription {subscription_id} has no items"
354 ))
355 })?;
356
357 let product_id = existing_item.price.product.id().to_string();
358 let _ = product_name;
359
360 reprice_request(
361 sub_id,
362 existing_item.id.as_ref(),
363 &product_id,
364 new_price_cents,
365 app_sub_interval(interval),
366 UpdateSubscriptionProrationBehavior::None,
367 )
368 .send(&self.client)
369 .await
370 .map_err(|e| {
371 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to re-price app sub");
372 AppError::Internal(anyhow::anyhow!("Failed to update Stripe subscription"))
373 })?;
374
375 Ok(())
376 }
377
378 /// Cancel a SyncKit subscription immediately.
379 ///
380 /// We cancel immediately (rather than at_period_end=true) because the
381 /// developer is paying for cloud resources we'll stop providing the
382 /// moment the app is canceled. Holding the subscription open for a few
383 /// extra weeks would let the developer keep billing accruing against a
384 /// dead app, worse for everyone.
385 #[tracing::instrument(skip_all, name = "payments::cancel_synckit_subscription")]
386 pub async fn cancel_synckit_subscription(&self, subscription_id: &str) -> Result<()> {
387 let sub_id = parse_subscription_id(subscription_id)?;
388 CancelSubscription::new(sub_id)
389 .send(&self.client)
390 .await
391 .map_err(|e| {
392 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to cancel SyncKit subscription");
393 AppError::Internal(anyhow::anyhow!("Failed to cancel Stripe subscription"))
394 })?;
395 Ok(())
396 }
397
398 /// Open a Stripe billing portal session for the SyncKit app's customer.
399 /// Reuses the platform-level billing portal pattern.
400 #[tracing::instrument(skip_all, name = "payments::create_synckit_billing_portal")]
401 pub async fn create_synckit_billing_portal(
402 &self,
403 customer_id: &str,
404 return_url: &str,
405 ) -> Result<String> {
406 // Identical to the creator-tier / Fan+ billing portal path, kept as
407 // a separate method so the trait surface mirrors the SyncKit domain.
408 self.create_billing_portal_session(customer_id, return_url)
409 .await
410 }
411 }
412
413 #[cfg(test)]
414 mod tests {
415 //! Idempotency keys for SyncKit's Stripe writes. The key is the only thing
416 //! standing between two racing `activate` requests and a duplicate live
417 //! subscription that bills a developer with no local row to cancel it, so
418 //! its determinism is a billing invariant rather than a detail.
419
420 use super::*;
421
422 /// The form-encoded body a request would be sent with, decoded into pairs.
423 /// `RequestBuilder` is the last point before the wire a test can read.
424 fn form(req: &impl StripeRequest) -> std::collections::BTreeMap<String, String> {
425 let built = req.build();
426 let body = built.body.unwrap_or_default();
427 url::form_urlencoded::parse(body.as_bytes())
428 .map(|(k, v)| (k.into_owned(), v.into_owned()))
429 .collect()
430 }
431
432 fn path_of(req: &impl StripeRequest) -> String {
433 req.build().path
434 }
435
436 #[test]
437 fn the_same_app_and_prefix_always_produce_the_same_key() {
438 let app = SyncAppId::nil();
439 let a = synckit_idempotency_key("synckit-sub", app).expect("valid key");
440 let b = synckit_idempotency_key("synckit-sub", app).expect("valid key");
441 assert_eq!(
442 format!("{a:?}"),
443 format!("{b:?}"),
444 "two racing activates must reuse one Stripe object, not create two"
445 );
446 }
447
448 #[test]
449 fn a_different_operation_on_one_app_gets_a_different_key() {
450 let app = SyncAppId::nil();
451 let sub = synckit_idempotency_key("synckit-sub", app).expect("valid key");
452 let cust = synckit_idempotency_key("synckit-cust", app).expect("valid key");
453 assert_ne!(
454 format!("{sub:?}"),
455 format!("{cust:?}"),
456 "sharing a key across operations would make Stripe replay the wrong response"
457 );
458 }
459
460 #[test]
461 fn an_over_long_prefix_is_an_error_rather_than_a_silently_truncated_key() {
462 // Stripe caps idempotency keys at 255 characters. Truncation would make
463 // two distinct operations collide, which is worse than failing loudly.
464 let err = synckit_idempotency_key(&"x".repeat(300), SyncAppId::nil());
465 assert!(
466 matches!(err, Err(AppError::Internal(_))),
467 "an unusable key must not reach Stripe"
468 );
469 }
470
471 #[test]
472 fn subscription_id_parsing_rejects_nothing_at_all() {
473 // Not the contract this function's name and error message imply.
474 // `stripe_shared::SubscriptionId` derives `FromStr` with
475 // `type Err = Infallible`: it wraps the string and always succeeds. The
476 // `AppError::Internal("Invalid Stripe subscription ID")` branch is
477 // unreachable, so an empty or garbage id from our own row is passed
478 // straight to Stripe's cancel / re-price calls.
479 //
480 // Documented rather than asserted-as-correct: adding a real check is a
481 // money-path behaviour change. Filed as a problem against mnw-server.
482 assert!(parse_subscription_id("").is_ok());
483 assert!(parse_subscription_id("not a sub id").is_ok());
484 assert!(parse_subscription_id("acct_wrong_type").is_ok());
485 }
486
487 // ── what each method puts on the wire ──
488 //
489 // The `send` half of these methods cannot be reached without calling
490 // Stripe; the request half can, and it is where the billing decisions are.
491 // The currency is USD throughout on purpose (see the module header): this
492 // is Make Creative billing a developer, not a creator selling to a fan.
493
494 #[test]
495 fn a_synckit_customer_is_the_app_rather_than_the_developer() {
496 let req = synckit_customer_request(UserId::nil(), "dev@example.com", "Notes");
497 assert_eq!(path_of(&req), "/customers");
498 let f = form(&req);
499 assert_eq!(f.get("email").map(String::as_str), Some("dev@example.com"));
500 assert_eq!(
501 f.get("name").map(String::as_str),
502 Some("SyncKit: Notes"),
503 "one customer per app, so the dashboard has to name the app"
504 );
505 assert_eq!(
506 f.get("metadata[synckit_app_name]").map(String::as_str),
507 Some("Notes")
508 );
509 assert_eq!(
510 f.get("metadata[mnw_user_id]").map(String::as_str),
511 Some(UserId::nil().to_string()).as_deref()
512 );
513 }
514
515 #[test]
516 fn a_synckit_product_is_named_for_its_app() {
517 let req = synckit_product_request("Notes");
518 assert_eq!(path_of(&req), "/products");
519 assert_eq!(
520 form(&req).get("name").map(String::as_str),
521 Some("SyncKit: Notes")
522 );
523 }
524
525 #[test]
526 fn a_developer_subscription_prices_inline_and_routes_by_app_id() {
527 let app = SyncAppId::nil();
528 let req = synckit_subscription_request("cus_123", app, "prod_123", 2500);
529 assert_eq!(path_of(&req), "/subscriptions");
530 let f = form(&req);
531 assert_eq!(f.get("customer").map(String::as_str), Some("cus_123"));
532 assert_eq!(
533 f.get("items[0][price_data][product]").map(String::as_str),
534 Some("prod_123")
535 );
536 assert_eq!(
537 f.get("items[0][price_data][unit_amount]")
538 .map(String::as_str),
539 Some("2500")
540 );
541 assert_eq!(
542 f.get("items[0][price_data][currency]").map(String::as_str),
543 Some("usd")
544 );
545 assert_eq!(
546 f.get("items[0][price_data][recurring][interval]")
547 .map(String::as_str),
548 Some("month"),
549 "without `recurring` Stripe bills the developer once, not monthly"
550 );
551 assert_eq!(
552 f.get("metadata[synckit_app_id]").map(String::as_str),
553 Some(app.to_string()).as_deref(),
554 "the webhook dispatcher routes on this; without it the event reads \
555 as a creator-tier one"
556 );
557 }
558
559 #[test]
560 fn a_developer_reprice_prorates_and_reuses_the_existing_item() {
561 let req = reprice_request(
562 "sub_1A2b3C".parse().unwrap(),
563 "si_123",
564 "prod_123",
565 4000,
566 UpdateSubscriptionItemsPriceDataRecurringInterval::Month,
567 UpdateSubscriptionProrationBehavior::CreateProrations,
568 );
569 assert_eq!(path_of(&req), "/subscriptions/sub_1A2b3C");
570 let f = form(&req);
571 assert_eq!(
572 f.get("items[0][id]").map(String::as_str),
573 Some("si_123"),
574 "re-pricing the existing item rather than adding one is what keeps \
575 the developer on a single charge"
576 );
577 assert_eq!(
578 f.get("items[0][price_data][product]").map(String::as_str),
579 Some("prod_123")
580 );
581 assert_eq!(
582 f.get("items[0][price_data][unit_amount]")
583 .map(String::as_str),
584 Some("4000")
585 );
586 assert_eq!(
587 f.get("proration_behavior").map(String::as_str),
588 Some("create_prorations"),
589 "the developer is credited or charged the difference on the next \
590 invoice"
591 );
592 }
593
594 #[test]
595 fn an_end_user_reprice_waits_for_the_period_boundary() {
596 // The DB queues the cap change to the next cycle, so the price has to
597 // flip at the same moment. Prorating here would charge for storage the
598 // user does not have yet.
599 let req = reprice_request(
600 "sub_1A2b3C".parse().unwrap(),
601 "si_123",
602 "prod_123",
603 900,
604 app_sub_interval(super::super::SyncBillingInterval::Monthly),
605 UpdateSubscriptionProrationBehavior::None,
606 );
607 assert_eq!(
608 form(&req).get("proration_behavior").map(String::as_str),
609 Some("none")
610 );
611 }
612
613 #[test]
614 fn the_billing_interval_reaches_stripe_as_the_one_the_user_bought() {
615 for (interval, want) in [
616 (super::super::SyncBillingInterval::Monthly, "month"),
617 (super::super::SyncBillingInterval::Annual, "year"),
618 ] {
619 let req = reprice_request(
620 "sub_1A2b3C".parse().unwrap(),
621 "si_123",
622 "prod_123",
623 900,
624 app_sub_interval(interval),
625 UpdateSubscriptionProrationBehavior::None,
626 );
627 assert_eq!(
628 form(&req)
629 .get("items[0][price_data][recurring][interval]")
630 .map(String::as_str),
631 Some(want),
632 "an annual subscriber re-priced monthly is billed twelve times \
633 over"
634 );
635 }
636 }
637
638 #[test]
639 fn a_synckit_cancel_is_immediate_rather_than_at_period_end() {
640 // The developer is paying for resources that stop the moment the app
641 // is canceled, so the request is a DELETE of the subscription and not
642 // an update carrying cancel_at_period_end.
643 let req = CancelSubscription::new(
644 "sub_1A2b3C"
645 .parse::<stripe_shared::SubscriptionId>()
646 .unwrap(),
647 );
648 assert_eq!(path_of(&req), "/subscriptions/sub_1A2b3C");
649 assert_eq!(format!("{:?}", req.build().method), "Delete");
650 }
651 }
652