Skip to main content

max / makenotwork

Assert the requests the Stripe client actually sends Every StripeClient method was a request built inline and sent in one chain, so the only testable half was unreachable without calling Stripe and every field on the wire was a mutation survivor. Each method now splits: a pure builder that states what goes to Stripe, and the send that maps the error. The tests read the built request back through StripeRequest::build, which carries the path, the verb and the form body, and assert the fields that decide money. In connect: the Standard account type, both onboarding urls, the tier product and its monthly price in the creator's settlement currency, pause-by-voiding, cancel_at_period_end in both directions, the line-scoped refund amount and its transaction tag, the credit transfer's currency and destination, the reversal, and the three deterministic idempotency keys being distinct. In synckit_billing: the per-app customer and product, the inline monthly price, synckit_app_id as the webhook's routing key, prorations on a developer re-price against none on an end-user one, and the annual interval surviving to Stripe. In checkout: checkout_type per session kind, payment against subscription mode, the cart minimum applying to the order rather than the line, trial days, no card up front for a comp, and the storage cap the SyncKit webhook stamps from. Extraction only; no request changed. The payments survivor count was not re-measured, since mutation runs stay off fw13.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-23 23:06 UTC
Signed with PGP, not checked
Commit: fc65520c707089b12a5e617d26bb519889672f1a
Parent: 886ecbe
3 files changed, +929 insertions, -339 deletions
@@ -236,6 +236,270 @@
236 236 }
237 237 }
238 238
239 + /// The session bodies, one per checkout kind.
240 + ///
241 + /// Split from the methods that send them so what Stripe is asked to charge can
242 + /// be read back in a test; the method half is a `send` that cannot be reached
243 + /// without calling Stripe. `checkout_type` is the field the webhook dispatcher
244 + /// routes every completed session on, so it belongs to the request rather than
245 + /// to the transport.
246 + fn guest_checkout_request(checkout: &GuestCheckoutParams<'_>) -> Result<CreateCheckoutSession> {
247 + check_min_charge(checkout.amount_cents.as_i64(), checkout.currency)?;
248 +
249 + let mut metadata = HashMap::new();
250 + metadata.insert("checkout_type".to_string(), CheckoutType::Guest.to_string());
251 + metadata.insert("seller_id".to_string(), checkout.seller_id.to_string());
252 + metadata.insert("item_id".to_string(), checkout.item_id.to_string());
253 + if let Some(pc_id) = checkout.promo_code_id {
254 + metadata.insert("promo_code_id".to_string(), pc_id.to_string());
255 + }
256 +
257 + let mut builder = CreateCheckoutSession::new()
258 + .mode(CheckoutSessionMode::Payment)
259 + .success_url(checkout.success_url.to_string())
260 + .cancel_url(checkout.cancel_url.to_string())
261 + .line_items(vec![build_inline_line_item(
262 + checkout.item_title,
263 + checkout.amount_cents.as_i64(),
264 + checkout.currency,
265 + )])
266 + .adaptive_pricing(adaptive_pricing(checkout.conversion))
267 + .metadata(metadata);
268 + if let Some(tax) = automatic_tax(checkout.enable_stripe_tax) {
269 + builder = builder.automatic_tax(tax);
270 + }
271 + Ok(builder)
272 + }
273 +
274 + fn checkout_request(checkout: &CheckoutParams<'_>) -> Result<CreateCheckoutSession> {
275 + check_min_charge(checkout.amount_cents.as_i64(), checkout.currency)?;
276 +
277 + let mut metadata = HashMap::new();
278 + metadata.insert("buyer_id".to_string(), checkout.buyer_id.to_string());
279 + metadata.insert("seller_id".to_string(), checkout.seller_id.to_string());
280 + if let Some(item_id) = checkout.item_id {
281 + metadata.insert("item_id".to_string(), item_id.to_string());
282 + }
283 + if let Some(pc_id) = checkout.promo_code_id {
284 + metadata.insert("promo_code_id".to_string(), pc_id.to_string());
285 + }
286 +
287 + let mut builder = CreateCheckoutSession::new()
288 + .mode(CheckoutSessionMode::Payment)
289 + .success_url(checkout.success_url.to_string())
290 + .cancel_url(checkout.cancel_url.to_string())
291 + .line_items(vec![build_inline_line_item(
292 + checkout.item_title,
293 + checkout.amount_cents.as_i64(),
294 + checkout.currency,
295 + )])
296 + .adaptive_pricing(adaptive_pricing(checkout.conversion))
297 + .metadata(metadata);
298 + if let Some(tax) = automatic_tax(checkout.enable_stripe_tax) {
299 + builder = builder.automatic_tax(tax);
300 + }
301 + Ok(builder)
302 + }
303 +
304 + /// The cart's floor is the order total, not the line. A per-line minimum
305 + /// would refuse a basket of cheap items that together clear it.
306 + fn cart_checkout_request(cart: &CartCheckoutParams<'_>) -> Result<CreateCheckoutSession> {
307 + let total_cents: i64 = cart.line_items.iter().map(|li| li.amount_cents).sum();
308 + check_min_charge(total_cents, cart.currency)?;
309 +
310 + let line_items: Vec<CreateCheckoutSessionLineItems> = cart
311 + .line_items
312 + .iter()
313 + .map(|li| build_inline_line_item(li.title, li.amount_cents, cart.currency))
314 + .collect();
315 +
316 + let mut metadata = HashMap::new();
317 + metadata.insert("checkout_type".to_string(), CheckoutType::Cart.to_string());
318 + metadata.insert("buyer_id".to_string(), cart.buyer_id.to_string());
319 + metadata.insert("seller_id".to_string(), cart.seller_id.to_string());
320 +
321 + let mut builder = CreateCheckoutSession::new()
322 + .mode(CheckoutSessionMode::Payment)
323 + .success_url(cart.success_url.to_string())
324 + .cancel_url(cart.cancel_url.to_string())
325 + .line_items(line_items)
326 + .adaptive_pricing(adaptive_pricing(cart.conversion))
327 + .metadata(metadata);
328 + if let Some(tax) = automatic_tax(cart.enable_stripe_tax) {
329 + builder = builder.automatic_tax(tax);
330 + }
331 + Ok(builder)
332 + }
333 +
334 + fn subscription_checkout_request(
335 + sub: &SubscriptionCheckoutParams<'_>,
336 + ) -> Result<CreateCheckoutSession> {
337 + let mut metadata = HashMap::new();
338 + metadata.insert("subscriber_id".to_string(), sub.subscriber_id.to_string());
339 + metadata.insert("project_id".to_string(), sub.project_id.to_string());
340 + metadata.insert("tier_id".to_string(), sub.tier_id.to_string());
341 + metadata.insert(
342 + "checkout_type".to_string(),
343 + CheckoutType::Subscription.to_string(),
344 + );
345 + if let Some(pc_id) = sub.promo_code_id {
346 + metadata.insert("promo_code_id".to_string(), pc_id.to_string());
347 + }
348 +
349 + let mut builder = CreateCheckoutSession::new()
350 + .mode(CheckoutSessionMode::Subscription)
351 + .success_url(sub.success_url.to_string())
352 + .cancel_url(sub.cancel_url.to_string())
353 + .line_items(vec![build_price_line_item(sub.stripe_price_id)])
354 + .adaptive_pricing(adaptive_pricing(sub.conversion))
355 + .metadata(metadata);
356 + if let Some(tax) = automatic_tax(sub.enable_stripe_tax) {
357 + builder = builder.automatic_tax(tax);
358 + }
359 +
360 + if let Some(days) = sub.trial_days {
361 + let trial_days: u32 = days
362 + .try_into()
363 + .map_err(|_| AppError::BadRequest("Invalid trial period".to_string()))?;
364 + builder = builder.subscription_data(CreateCheckoutSessionSubscriptionData {
365 + trial_period_days: Some(trial_days),
366 + ..CreateCheckoutSessionSubscriptionData::new()
367 + });
368 + }
369 + Ok(builder)
370 + }
371 +
372 + fn tip_checkout_request(tip: &TipCheckoutParams<'_>) -> CreateCheckoutSession {
373 + let product_name = format!("Tip for {}", tip.recipient_display_name);
374 +
375 + let mut metadata = HashMap::new();
376 + metadata.insert("checkout_type".to_string(), CheckoutType::Tip.to_string());
377 + metadata.insert("tipper_id".to_string(), tip.tipper_id.to_string());
378 + metadata.insert("recipient_id".to_string(), tip.recipient_id.to_string());
379 + if let Some(project_id) = tip.project_id {
380 + metadata.insert("project_id".to_string(), project_id.to_string());
381 + }
382 + if let Some(msg) = tip.message {
383 + // Stripe caps a metadata value at 500 characters, and a rejected
384 + // session is a tip that never happens.
385 + metadata.insert("message".to_string(), msg.chars().take(500).collect());
386 + }
387 +
388 + let mut builder = CreateCheckoutSession::new()
389 + .mode(CheckoutSessionMode::Payment)
390 + .success_url(tip.success_url.to_string())
391 + .cancel_url(tip.cancel_url.to_string())
392 + .line_items(vec![build_inline_line_item(
393 + &product_name,
394 + tip.amount_cents.as_i64(),
395 + tip.currency,
396 + )])
397 + .adaptive_pricing(adaptive_pricing(tip.conversion))
398 + .metadata(metadata);
399 +
400 + if let Some(tax) = automatic_tax(tip.enable_stripe_tax) {
401 + builder = builder.automatic_tax(tax);
402 + }
403 + builder
404 + }
405 +
406 + fn fan_plus_checkout_request(
407 + price_id: &str,
408 + user_id: UserId,
409 + success_url: &str,
410 + cancel_url: &str,
411 + ) -> CreateCheckoutSession {
412 + let mut metadata = HashMap::new();
413 + metadata.insert(
414 + "checkout_type".to_string(),
415 + CheckoutType::FanPlus.to_string(),
416 + );
417 + metadata.insert("user_id".to_string(), user_id.to_string());
418 +
419 + CreateCheckoutSession::new()
420 + .mode(CheckoutSessionMode::Subscription)
421 + .success_url(success_url.to_string())
422 + .cancel_url(cancel_url.to_string())
423 + .line_items(vec![build_price_line_item(price_id)])
424 + .metadata(metadata)
425 + }
426 +
427 + fn creator_tier_checkout_request(
428 + price_id: &str,
429 + user_id: UserId,
430 + tier: &str,
431 + success_url: &str,
432 + cancel_url: &str,
433 + trial_days: Option<i32>,
434 + ) -> Result<CreateCheckoutSession> {
435 + let mut metadata = HashMap::new();
436 + metadata.insert(
437 + "checkout_type".to_string(),
438 + CheckoutType::CreatorTier.to_string(),
439 + );
440 + metadata.insert("user_id".to_string(), user_id.to_string());
441 + metadata.insert("tier".to_string(), tier.to_string());
442 +
443 + let mut builder = CreateCheckoutSession::new()
444 + .mode(CheckoutSessionMode::Subscription)
445 + .success_url(success_url.to_string())
446 + .cancel_url(cancel_url.to_string())
447 + .line_items(vec![build_price_line_item(price_id)])
448 + .metadata(metadata);
449 +
450 + // A comp code grants a free trial: don't collect a card up front
451 + // (`if_required` skips card collection when no charge is due yet), and
452 + // delay the first charge by `trial_days`. With no payment method on
453 + // file, the subscription lapses at trial end unless the creator
454 + // adds one, continuing is an explicit opt-in, never a silent charge.
455 + // The price stays the one chosen by the caller (founder price during
456 + // the founder window), so opting in renews at that rate.
457 + if let Some(days) = trial_days {
458 + let days: u32 = days
459 + .try_into()
460 + .map_err(|_| AppError::BadRequest("Invalid trial period".to_string()))?;
461 + builder = builder
462 + .payment_method_collection(CreateCheckoutSessionPaymentMethodCollection::IfRequired)
463 + .subscription_data(CreateCheckoutSessionSubscriptionData {
464 + trial_period_days: Some(days),
465 + ..CreateCheckoutSessionSubscriptionData::new()
466 + });
467 + }
468 + Ok(builder)
469 + }
470 +
471 + fn synckit_app_sub_checkout_request(
472 + p: &SynckitAppSubCheckoutParams<'_>,
473 + ) -> Result<CreateCheckoutSession> {
474 + use CreateCheckoutSessionLineItemsPriceDataRecurringInterval as Recurring;
475 + let interval = match p.interval {
476 + "monthly" => Recurring::Month,
477 + "annual" => Recurring::Year,
478 + other => return Err(AppError::BadRequest(format!("Invalid interval '{other}'"))),
479 + };
480 +
481 + let mut metadata = HashMap::new();
482 + metadata.insert(
483 + "checkout_type".to_string(),
484 + CheckoutType::SynckitAppSub.to_string(),
485 + );
486 + metadata.insert("user_id".to_string(), p.user_id.to_string());
487 + metadata.insert("app_id".to_string(), p.app_id.to_string());
488 + metadata.insert("interval".to_string(), p.interval.to_string());
489 + if let Some(bytes) = p.storage_limit_bytes {
490 + metadata.insert("storage_limit_bytes".to_string(), bytes.to_string());
491 + }
492 +
493 + let line_item = build_inline_recurring_line_item_usd(p.product_name, p.amount_cents, interval);
494 +
495 + Ok(CreateCheckoutSession::new()
496 + .mode(CheckoutSessionMode::Subscription)
497 + .success_url(p.success_url.to_string())
498 + .cancel_url(p.cancel_url.to_string())
499 + .line_items(vec![line_item])
500 + .metadata(metadata))
501 + }
502 +
239 503 impl StripeClient {
240 504 async fn send_on_connected_account(
241 505 &self,
@@ -272,31 +536,7 @@
272 536 &self,
273 537 checkout: &GuestCheckoutParams<'_>,
274 538 ) -> Result<stripe_shared::CheckoutSession> {
275 - check_min_charge(checkout.amount_cents.as_i64(), checkout.currency)?;
276 -
277 - let mut metadata = HashMap::new();
278 - metadata.insert("checkout_type".to_string(), CheckoutType::Guest.to_string());
279 - metadata.insert("seller_id".to_string(), checkout.seller_id.to_string());
280 - metadata.insert("item_id".to_string(), checkout.item_id.to_string());
281 - if let Some(pc_id) = checkout.promo_code_id {
282 - metadata.insert("promo_code_id".to_string(), pc_id.to_string());
283 - }
284 -
285 - let mut builder = CreateCheckoutSession::new()
286 - .mode(CheckoutSessionMode::Payment)
287 - .success_url(checkout.success_url.to_string())
288 - .cancel_url(checkout.cancel_url.to_string())
289 - .line_items(vec![build_inline_line_item(
290 - checkout.item_title,
291 - checkout.amount_cents.as_i64(),
292 - checkout.currency,
293 - )])
294 - .adaptive_pricing(adaptive_pricing(checkout.conversion))
295 - .metadata(metadata);
296 - if let Some(tax) = automatic_tax(checkout.enable_stripe_tax) {
297 - builder = builder.automatic_tax(tax);
298 - }
299 -
539 + let builder = guest_checkout_request(checkout)?;
300 540 self.send_on_connected_account(builder, checkout.connected_account_id, "guest_checkout")
301 541 .await
302 542 }
@@ -307,33 +547,7 @@
307 547 &self,
308 548 checkout: &CheckoutParams<'_>,
309 549 ) -> Result<stripe_shared::CheckoutSession> {
310 - check_min_charge(checkout.amount_cents.as_i64(), checkout.currency)?;
311 -
312 - let mut metadata = HashMap::new();
313 - metadata.insert("buyer_id".to_string(), checkout.buyer_id.to_string());
314 - metadata.insert("seller_id".to_string(), checkout.seller_id.to_string());
315 - if let Some(item_id) = checkout.item_id {
316 - metadata.insert("item_id".to_string(), item_id.to_string());
317 - }
318 - if let Some(pc_id) = checkout.promo_code_id {
319 - metadata.insert("promo_code_id".to_string(), pc_id.to_string());
320 - }
321 -
322 - let mut builder = CreateCheckoutSession::new()
323 - .mode(CheckoutSessionMode::Payment)
324 - .success_url(checkout.success_url.to_string())
325 - .cancel_url(checkout.cancel_url.to_string())
326 - .line_items(vec![build_inline_line_item(
327 - checkout.item_title,
328 - checkout.amount_cents.as_i64(),
329 - checkout.currency,
330 - )])
331 - .adaptive_pricing(adaptive_pricing(checkout.conversion))
332 - .metadata(metadata);
333 - if let Some(tax) = automatic_tax(checkout.enable_stripe_tax) {
334 - builder = builder.automatic_tax(tax);
335 - }
336 -
550 + let builder = checkout_request(checkout)?;
337 551 self.send_on_connected_account(builder, checkout.connected_account_id, "checkout")
338 552 .await
339 553 }
@@ -344,31 +558,7 @@
344 558 &self,
345 559 cart: &CartCheckoutParams<'_>,
346 560 ) -> Result<stripe_shared::CheckoutSession> {
347 - let total_cents: i64 = cart.line_items.iter().map(|li| li.amount_cents).sum();
348 - check_min_charge(total_cents, cart.currency)?;
349 -
350 - let line_items: Vec<CreateCheckoutSessionLineItems> = cart
351 - .line_items
352 - .iter()
353 - .map(|li| build_inline_line_item(li.title, li.amount_cents, cart.currency))
354 - .collect();
355 -
356 - let mut metadata = HashMap::new();
357 - metadata.insert("checkout_type".to_string(), CheckoutType::Cart.to_string());
358 - metadata.insert("buyer_id".to_string(), cart.buyer_id.to_string());
359 - metadata.insert("seller_id".to_string(), cart.seller_id.to_string());
360 -
361 - let mut builder = CreateCheckoutSession::new()
362 - .mode(CheckoutSessionMode::Payment)
363 - .success_url(cart.success_url.to_string())
364 - .cancel_url(cart.cancel_url.to_string())
365 - .line_items(line_items)
366 - .adaptive_pricing(adaptive_pricing(cart.conversion))
367 - .metadata(metadata);
368 - if let Some(tax) = automatic_tax(cart.enable_stripe_tax) {
369 - builder = builder.automatic_tax(tax);
370 - }
371 -
561 + let builder = cart_checkout_request(cart)?;
372 562 self.send_on_connected_account(builder, cart.connected_account_id, "cart_checkout")
373 563 .await
374 564 }
@@ -379,39 +569,7 @@
379 569 &self,
380 570 sub: &SubscriptionCheckoutParams<'_>,
381 571 ) -> Result<stripe_shared::CheckoutSession> {
382 - let mut metadata = HashMap::new();
383 - metadata.insert("subscriber_id".to_string(), sub.subscriber_id.to_string());
384 - metadata.insert("project_id".to_string(), sub.project_id.to_string());
385 - metadata.insert("tier_id".to_string(), sub.tier_id.to_string());
386 - metadata.insert(
387 - "checkout_type".to_string(),
388 - CheckoutType::Subscription.to_string(),
389 - );
390 - if let Some(pc_id) = sub.promo_code_id {
391 - metadata.insert("promo_code_id".to_string(), pc_id.to_string());
392 - }
393 -
394 - let mut builder = CreateCheckoutSession::new()
395 - .mode(CheckoutSessionMode::Subscription)
396 - .success_url(sub.success_url.to_string())
397 - .cancel_url(sub.cancel_url.to_string())
398 - .line_items(vec![build_price_line_item(sub.stripe_price_id)])
399 - .adaptive_pricing(adaptive_pricing(sub.conversion))
400 - .metadata(metadata);
401 - if let Some(tax) = automatic_tax(sub.enable_stripe_tax) {
402 - builder = builder.automatic_tax(tax);
403 - }
404 -
405 - if let Some(days) = sub.trial_days {
406 - let trial_days: u32 = days
407 - .try_into()
408 - .map_err(|_| AppError::BadRequest("Invalid trial period".to_string()))?;
409 - builder = builder.subscription_data(CreateCheckoutSessionSubscriptionData {
410 - trial_period_days: Some(trial_days),
411 - ..CreateCheckoutSessionSubscriptionData::new()
412 - });
413 - }
414 -
572 + let builder = subscription_checkout_request(sub)?;
415 573 self.send_on_connected_account(builder, sub.connected_account_id, "subscription_checkout")
416 574 .await
417 575 }
@@ -422,35 +580,7 @@
422 580 &self,
423 581 tip: &TipCheckoutParams<'_>,
424 582 ) -> Result<stripe_shared::CheckoutSession> {
425 - let product_name = format!("Tip for {}", tip.recipient_display_name);
426 -
427 - let mut metadata = HashMap::new();
428 - metadata.insert("checkout_type".to_string(), CheckoutType::Tip.to_string());
429 - metadata.insert("tipper_id".to_string(), tip.tipper_id.to_string());
430 - metadata.insert("recipient_id".to_string(), tip.recipient_id.to_string());
431 - if let Some(project_id) = tip.project_id {
432 - metadata.insert("project_id".to_string(), project_id.to_string());
433 - }
434 - if let Some(msg) = tip.message {
435 - metadata.insert("message".to_string(), msg.chars().take(500).collect());
436 - }
437 -
438 - let mut builder = CreateCheckoutSession::new()
439 - .mode(CheckoutSessionMode::Payment)
440 - .success_url(tip.success_url.to_string())
441 - .cancel_url(tip.cancel_url.to_string())
442 - .line_items(vec![build_inline_line_item(
443 - &product_name,
444 - tip.amount_cents.as_i64(),
445 - tip.currency,
446 - )])
447 - .adaptive_pricing(adaptive_pricing(tip.conversion))
448 - .metadata(metadata);
449 -
450 - if let Some(tax) = automatic_tax(tip.enable_stripe_tax) {
451 - builder = builder.automatic_tax(tax);
452 - }
453 -
583 + let builder = tip_checkout_request(tip);
454 584 self.send_on_connected_account(builder, tip.connected_account_id, "tip_checkout")
455 585 .await
456 586 }
@@ -464,20 +594,7 @@
464 594 success_url: &str,
465 595 cancel_url: &str,
466 596 ) -> Result<stripe_shared::CheckoutSession> {
467 - let mut metadata = HashMap::new();
468 - metadata.insert(
469 - "checkout_type".to_string(),
470 - CheckoutType::FanPlus.to_string(),
471 - );
472 - metadata.insert("user_id".to_string(), user_id.to_string());
473 -
474 - let builder = CreateCheckoutSession::new()
475 - .mode(CheckoutSessionMode::Subscription)
476 - .success_url(success_url.to_string())
477 - .cancel_url(cancel_url.to_string())
478 - .line_items(vec![build_price_line_item(price_id)])
479 - .metadata(metadata);
480 -
597 + let builder = fan_plus_checkout_request(price_id, user_id, success_url, cancel_url);
481 598 self.send_on_platform(builder, "fan_plus_checkout").await
482 599 }
483 600
@@ -492,40 +609,14 @@
492 609 cancel_url: &str,
493 610 trial_days: Option<i32>,
494 611 ) -> Result<stripe_shared::CheckoutSession> {
495 - let mut metadata = HashMap::new();
496 - metadata.insert(
497 - "checkout_type".to_string(),
498 - CheckoutType::CreatorTier.to_string(),
499 - );
500 - metadata.insert("user_id".to_string(), user_id.to_string());
501 - metadata.insert("tier".to_string(), tier.to_string());
502 -
503 - let mut builder = CreateCheckoutSession::new()
504 - .mode(CheckoutSessionMode::Subscription)
505 - .success_url(success_url.to_string())
506 - .cancel_url(cancel_url.to_string())
507 - .line_items(vec![build_price_line_item(price_id)])
508 - .metadata(metadata);
509 -
510 - // A comp code grants a free trial: don't collect a card up front
511 - // (`if_required` skips card collection when no charge is due yet), and
512 - // delay the first charge by `trial_days`. With no payment method on
513 - // file, the subscription lapses at trial end unless the creator
514 - // adds one, continuing is an explicit opt-in, never a silent charge.
515 - // The price stays the one chosen by the caller (founder price during
516 - // the founder window), so opting in renews at that rate.
517 - if let Some(days) = trial_days {
518 - let days: u32 = days
519 - .try_into()
520 - .map_err(|_| AppError::BadRequest("Invalid trial period".to_string()))?;
521 - builder = builder
522 - .payment_method_collection(CreateCheckoutSessionPaymentMethodCollection::IfRequired)
523 - .subscription_data(CreateCheckoutSessionSubscriptionData {
524 - trial_period_days: Some(days),
525 - ..CreateCheckoutSessionSubscriptionData::new()
526 - });
Lines truncated
@@ -29,13 +29,157 @@
29 29 })
30 30 }
31 31
32 + /// The `POST /accounts` body for a creator's Standard connected account.
33 + ///
34 + /// Split from the method that sends it so the request Stripe is handed can be
35 + /// read back in a test. Nothing here calls Stripe, and every builder below
36 + /// follows the same shape: the method parses ids, sends, and maps the error;
37 + /// the function states what goes on the wire.
38 + fn connect_account_request(email: &str) -> CreateAccount {
39 + CreateAccount::new()
40 + .type_(CreateAccountType::Standard)
41 + .email(email.to_string())
42 + }
43 +
44 + /// The onboarding Account Link body. `CreateAccountLink` takes the account id
45 + /// as a plain String, not an `AccountId`.
46 + fn account_link_request(
47 + account_id: &str,
48 + return_url: &str,
49 + refresh_url: &str,
50 + ) -> CreateAccountLink {
51 + CreateAccountLink::new(
52 + account_id.to_string(),
53 + CreateAccountLinkType::AccountOnboarding,
54 + )
55 + .return_url(return_url.to_string())
56 + .refresh_url(refresh_url.to_string())
57 + }
58 +
59 + /// The Product a creator tier is sold as.
60 + fn subscription_product_request(tier_name: &str, tier_description: Option<&str>) -> CreateProduct {
61 + let req = CreateProduct::new(tier_name.to_string());
62 + match tier_description {
63 + Some(desc) => req.description(desc.to_string()),
64 + None => req,
65 + }
66 + }
67 +
68 + /// The monthly recurring Price for a creator tier, in the creator's
69 + /// settlement currency.
70 + fn subscription_price_request(
71 + product_id: &str,
72 + price_cents: i64,
73 + currency: SettlementCurrency,
74 + ) -> CreatePrice {
75 + CreatePrice::new(currency.to_stripe())
76 + .product(product_id.to_string())
77 + .unit_amount(price_cents)
78 + .recurring(CreatePriceRecurring::new(
79 + CreatePriceRecurringInterval::Month,
80 + ))
81 + }
82 +
83 + /// Pause collection by voiding invoices, rather than cancelling.
84 + fn pause_collection_request(sub_id: stripe_shared::SubscriptionId) -> UpdateSubscription {
85 + UpdateSubscription::new(sub_id).pause_collection(UpdateSubscriptionPauseCollection::new(
86 + UpdateSubscriptionPauseCollectionBehavior::Void,
87 + ))
88 + }
89 +
90 + /// Set or clear `cancel_at_period_end`. `cancel` is always sent, so clearing
91 + /// the flag is a state the request states rather than one it omits.
92 + fn cancel_at_period_end_request(
93 + sub_id: stripe_shared::SubscriptionId,
94 + cancel: bool,
95 + ) -> UpdateSubscription {
96 + UpdateSubscription::new(sub_id).cancel_at_period_end(cancel)
97 + }
98 +
99 + /// The Billing Portal session body.
100 + fn billing_portal_request(
101 + stripe_customer_id: &str,
102 + return_url: &str,
103 + ) -> CreateBillingPortalSession {
104 + CreateBillingPortalSession::new()
105 + .customer(stripe_customer_id.to_string())
106 + .return_url(return_url.to_string())
107 + }
108 +
109 + /// A line-scoped refund against the order's shared PaymentIntent, tagged with
110 + /// the transaction the `refund.created` webhook has to revoke.
111 + fn refund_request(
112 + payment_intent_id: &str,
113 + amount_cents: i64,
114 + transaction_id: crate::db::TransactionId,
115 + ) -> CreateRefund {
116 + let metadata = std::collections::HashMap::from([(
117 + "mnw_transaction_id".to_string(),
118 + transaction_id.to_string(),
119 + )]);
120 + CreateRefund::new()
121 + .payment_intent(payment_intent_id.to_string())
122 + .amount(amount_cents)
123 + .metadata(metadata)
124 + }
125 +
126 + /// The platform-funded credit reimbursement, denominated in the sale's
127 + /// currency rather than MNW's.
128 + fn platform_credit_transfer_request(
129 + acct: &stripe::AccountId,
130 + amount_cents: i64,
131 + transaction_id: crate::db::TransactionId,
132 + currency: SettlementCurrency,
133 + ) -> CreateTransfer {
134 + let metadata = std::collections::HashMap::from([
135 + ("mnw_transaction_id".to_string(), transaction_id.to_string()),
136 + ("reason".to_string(), "platform_funded_credit".to_string()),
137 + ]);
138 + CreateTransfer::new(currency.to_stripe(), acct.to_string())
139 + .amount(amount_cents)
140 + .description("Fan+ credit reimbursement")
141 + .metadata(metadata)
142 + }
143 +
144 + /// Claw a settled platform credit back when its sale is refunded.
145 + fn platform_credit_reversal_request(
146 + transfer_id: &str,
147 + amount_cents: i64,
148 + transaction_id: crate::db::TransactionId,
149 + ) -> CreateIdTransferReversal {
150 + let metadata = std::collections::HashMap::from([
151 + ("mnw_transaction_id".to_string(), transaction_id.to_string()),
152 + (
153 + "reason".to_string(),
154 + "platform_funded_credit_reversal".to_string(),
155 + ),
156 + ]);
157 + CreateIdTransferReversal::new(transfer_id.to_string())
158 + .amount(amount_cents)
159 + .metadata(metadata)
160 + }
161 +
162 + /// Deterministic idempotency keys. A retry after a crash or a transient
163 + /// failure has to return the same Stripe object rather than debiting or
164 + /// paying a second time, and a transaction is refunded, reimbursed and
165 + /// reversed at most once each, so its id is the correct dedup scope.
166 + fn refund_key(transaction_id: crate::db::TransactionId) -> String {
167 + format!("refund-{transaction_id}")
168 + }
169 +
170 + fn platform_credit_key(transaction_id: crate::db::TransactionId) -> String {
171 + format!("platform-credit-{transaction_id}")
172 + }
173 +
174 + fn platform_credit_reversal_key(transaction_id: crate::db::TransactionId) -> String {
175 + format!("platform-credit-reversal-{transaction_id}")
176 + }
177 +
32 178 impl StripeClient {
33 179 /// Create a Stripe Standard connected account for a creator.
34 180 #[tracing::instrument(skip_all, name = "payments::create_connect_account")]
35 181 pub async fn create_connect_account(&self, email: &str) -> Result<StripeAccountId> {
36 - let account = CreateAccount::new()
37 - .type_(CreateAccountType::Standard)
38 - .email(email.to_string())
182 + let account = connect_account_request(email)
39 183 .send(&self.client)
40 184 .await
41 185 .map_err(|e| {
@@ -54,19 +198,13 @@
54 198 return_url: &str,
55 199 refresh_url: &str,
56 200 ) -> Result<String> {
57 - // CreateAccountLink takes the account id as a plain String, not AccountId.
58 - let link = CreateAccountLink::new(
59 - account_id.to_string(),
60 - CreateAccountLinkType::AccountOnboarding,
61 - )
62 - .return_url(return_url.to_string())
63 - .refresh_url(refresh_url.to_string())
64 - .send(&self.client)
65 - .await
66 - .map_err(|e| {
67 - tracing::error!(error = ?e, "failed to create Stripe account link");
68 - AppError::BadRequest("Failed to create Stripe onboarding link".to_string())
69 - })?;
201 + let link = account_link_request(account_id, return_url, refresh_url)
202 + .send(&self.client)
203 + .await
204 + .map_err(|e| {
205 + tracing::error!(error = ?e, "failed to create Stripe account link");
206 + AppError::BadRequest("Failed to create Stripe onboarding link".to_string())
207 + })?;
70 208 Ok(link.url)
71 209 }
72 210
@@ -101,11 +239,7 @@
101 239
102 240 let acct = Self::parse_account_id(connected_account_id)?;
103 241
104 - let mut product_req = CreateProduct::new(tier_name.to_string());
105 - if let Some(desc) = tier_description {
106 - product_req = product_req.description(desc.to_string());
107 - }
108 - let product = product_req
242 + let product = subscription_product_request(tier_name, tier_description)
109 243 .customize()
110 244 .account_id(acct.clone())
111 245 .send(&self.client)
@@ -119,12 +253,7 @@
119 253 // never re-denominated afterwards. A creator who later changes their
120 254 // Stripe currency keeps tiers priced in the old one until they re-price,
121 255 // which is the honest outcome: the number they typed meant that currency.
122 - let price = CreatePrice::new(currency.to_stripe())
123 - .product(product.id.to_string())
124 - .unit_amount(price_cents)
125 - .recurring(CreatePriceRecurring::new(
126 - CreatePriceRecurringInterval::Month,
127 - ))
256 + let price = subscription_price_request(product.id.as_ref(), price_cents, currency)
128 257 .customize()
129 258 .account_id(acct)
130 259 .send(&self.client)
@@ -165,10 +294,7 @@
165 294 let acct = Self::parse_account_id(connected_account_id)?;
166 295 let sub_id = parse_subscription_id(stripe_sub_id)?;
167 296
168 - UpdateSubscription::new(sub_id)
169 - .pause_collection(UpdateSubscriptionPauseCollection::new(
170 - UpdateSubscriptionPauseCollectionBehavior::Void,
171 - ))
297 + pause_collection_request(sub_id)
172 298 .customize()
173 299 .account_id(acct)
174 300 .send(&self.client)
@@ -252,8 +378,7 @@
252 378 cancel: bool,
253 379 ) -> Result<()> {
254 380 let sub_id = parse_subscription_id(stripe_sub_id)?;
255 - UpdateSubscription::new(sub_id)
256 - .cancel_at_period_end(cancel)
381 + cancel_at_period_end_request(sub_id, cancel)
257 382 .send(&self.client)
258 383 .await
259 384 .map_err(|e| {
@@ -273,8 +398,7 @@
273 398 ) -> Result<()> {
274 399 let acct = Self::parse_account_id(connected_account_id)?;
275 400 let sub_id = parse_subscription_id(stripe_sub_id)?;
276 - UpdateSubscription::new(sub_id)
277 - .cancel_at_period_end(cancel)
401 + cancel_at_period_end_request(sub_id, cancel)
278 402 .customize()
279 403 .account_id(acct)
280 404 .send(&self.client)
@@ -293,9 +417,7 @@
293 417 stripe_customer_id: &str,
294 418 return_url: &str,
295 419 ) -> Result<String> {
296 - let session = CreateBillingPortalSession::new()
297 - .customer(stripe_customer_id.to_string())
298 - .return_url(return_url.to_string())
420 + let session = billing_portal_request(stripe_customer_id, return_url)
299 421 .send(&self.client)
300 422 .await
301 423 .map_err(|e| {
@@ -326,16 +448,9 @@
326 448 // failure returns the same refund rather than double-debiting the
327 449 // creator's connected balance. A transaction is refunded in full exactly
328 450 // once, so keying on its id is the correct dedup scope.
329 - let key = IdempotencyKey::new(format!("refund-{transaction_id}"))
451 + let key = IdempotencyKey::new(refund_key(transaction_id))
330 452 .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?;
331 - let metadata = std::collections::HashMap::from([(
332 - "mnw_transaction_id".to_string(),
333 - transaction_id.to_string(),
334 - )]);
335 - CreateRefund::new()
336 - .payment_intent(payment_intent_id.to_string())
337 - .amount(amount_cents)
338 - .metadata(metadata)
453 + refund_request(payment_intent_id, amount_cents, transaction_id)
339 454 .customize()
340 455 .account_id(acct)
341 456 .request_strategy(RequestStrategy::Idempotent(key))
@@ -370,20 +485,13 @@
370 485 currency: SettlementCurrency,
371 486 ) -> Result<String> {
372 487 let acct = Self::parse_account_id(connected_account_id)?;
373 - let key = IdempotencyKey::new(format!("platform-credit-{transaction_id}"))
488 + let key = IdempotencyKey::new(platform_credit_key(transaction_id))
374 489 .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?;
375 - let metadata = std::collections::HashMap::from([
376 - ("mnw_transaction_id".to_string(), transaction_id.to_string()),
377 - ("reason".to_string(), "platform_funded_credit".to_string()),
378 - ]);
379 490 // Denominated in the sale's currency, not MNW's. The creator is owed the
380 491 // amount of a sale that was priced in their currency, so MNW carries any
381 492 // conversion out of its own balance rather than handing the creator a
382 493 // number that happens to match in USD.
383 - let transfer = CreateTransfer::new(currency.to_stripe(), acct.to_string())
384 - .amount(amount_cents)
385 - .description("Fan+ credit reimbursement")
386 - .metadata(metadata)
494 + let transfer = platform_credit_transfer_request(&acct, amount_cents, transaction_id, currency)
387 495 .customize()
388 496 .request_strategy(RequestStrategy::Idempotent(key))
389 497 .send(&self.client)
@@ -410,18 +518,9 @@
410 518 amount_cents: i64,
411 519 transaction_id: crate::db::TransactionId,
412 520 ) -> Result<()> {
413 - let key = IdempotencyKey::new(format!("platform-credit-reversal-{transaction_id}"))
521 + let key = IdempotencyKey::new(platform_credit_reversal_key(transaction_id))
414 522 .map_err(|e| AppError::Internal(anyhow::anyhow!("invalid idempotency key: {e}")))?;
415 - let metadata = std::collections::HashMap::from([
416 - ("mnw_transaction_id".to_string(), transaction_id.to_string()),
417 - (
418 - "reason".to_string(),
419 - "platform_funded_credit_reversal".to_string(),
420 - ),
421 - ]);
422 - CreateIdTransferReversal::new(transfer_id.to_string())
423 - .amount(amount_cents)
424 - .metadata(metadata)
523 + platform_credit_reversal_request(transfer_id, amount_cents, transaction_id)
425 524 .customize()
426 525 .request_strategy(RequestStrategy::Idempotent(key))
427 526 .send(&self.client)
@@ -437,6 +536,28 @@
437 536 #[cfg(test)]
438 537 mod tests {
439 538 use super::*;
539 + use crate::db::TransactionId;
540 +
541 + /// The form-encoded body a request would be sent with, decoded into pairs.
542 + ///
543 + /// `RequestBuilder` is what the transport is handed, so this is the last
544 + /// point before the wire that a test can read. Percent-decoding it means an
545 + /// assertion names the value Stripe parses rather than its encoding.
546 + fn form(req: &impl StripeRequest) -> std::collections::BTreeMap<String, String> {
547 + let built = req.build();
548 + let body = built.body.unwrap_or_default();
549 + url::form_urlencoded::parse(body.as_bytes())
550 + .map(|(k, v)| (k.into_owned(), v.into_owned()))
551 + .collect()
552 + }
553 +
554 + fn path_of(req: &impl StripeRequest) -> String {
555 + req.build().path
556 + }
557 +
558 + fn method_of(req: &impl StripeRequest) -> String {
559 + format!("{:?}", req.build().method)
560 + }
440 561
441 562 // NOTE: async-stripe's `*Id` types are permissive newtypes, `FromStr`
442 563 // accepts any non-pathological string without validating the `acct_`/`sub_`
@@ -456,4 +577,218 @@
456 577 let sub = parse_subscription_id("sub_1A2b3C4d5E6f7G8h").unwrap();
457 578 assert_eq!(sub.to_string(), "sub_1A2b3C4d5E6f7G8h");
458 579 }
580 +
581 + // ── what each method puts on the wire ──
582 + //
583 + // Every StripeClient method below is a request builder plus a `send`, and
584 + // the `send` half cannot be reached without calling Stripe. These pin the
585 + // half that can: the path, the verb, and the fields. A missing field here
586 + // is a real outage class rather than a coverage statistic: an account link
587 + // with no `return_url` strands the creator on Stripe's page, and a refund
588 + // with no `mnw_transaction_id` makes the webhook revoke the wrong line.
589 +
590 + #[test]
591 + fn a_connected_account_is_created_standard_and_named_by_email() {
592 + let req = connect_account_request("creator@example.com");
593 + assert_eq!(path_of(&req), "/accounts");
594 + assert_eq!(method_of(&req), "Post");
595 + let f = form(&req);
596 + assert_eq!(f.get("type").map(String::as_str), Some("standard"));
597 + assert_eq!(
598 + f.get("email").map(String::as_str),
599 + Some("creator@example.com")
600 + );
601 + }
602 +
603 + #[test]
604 + fn an_account_link_carries_both_urls_and_the_onboarding_type() {
605 + let req = account_link_request(
606 + "acct_1A2b3C4d5E6f7G",
607 + "https://makenot.work/connect/return",
608 + "https://makenot.work/connect/refresh",
609 + );
610 + assert_eq!(path_of(&req), "/account_links");
611 + let f = form(&req);
612 + assert_eq!(
613 + f.get("account").map(String::as_str),
614 + Some("acct_1A2b3C4d5E6f7G")
615 + );
616 + assert_eq!(
617 + f.get("type").map(String::as_str),
618 + Some("account_onboarding")
619 + );
620 + assert_eq!(
621 + f.get("return_url").map(String::as_str),
622 + Some("https://makenot.work/connect/return")
623 + );
624 + assert_eq!(
625 + f.get("refresh_url").map(String::as_str),
626 + Some("https://makenot.work/connect/refresh"),
627 + "without a refresh url an expired link is a dead end"
628 + );
629 + }
630 +
631 + #[test]
632 + fn a_tier_product_sends_its_description_only_when_it_has_one() {
633 + let with = subscription_product_request("Gold", Some("Everything"));
634 + assert_eq!(path_of(&with), "/products");
635 + let f = form(&with);
636 + assert_eq!(f.get("name").map(String::as_str), Some("Gold"));
637 + assert_eq!(f.get("description").map(String::as_str), Some("Everything"));
638 +
639 + let without = subscription_product_request("Gold", None);
640 + assert!(
641 + !form(&without).contains_key("description"),
642 + "an absent description is absent, not an empty string"
643 + );
644 + }
645 +
646 + #[test]
647 + fn a_tier_price_is_monthly_and_in_the_creators_currency() {
648 + let req = subscription_price_request("prod_123", 1500, SettlementCurrency::Eur);
649 + assert_eq!(path_of(&req), "/prices");
650 + let f = form(&req);
651 + assert_eq!(f.get("product").map(String::as_str), Some("prod_123"));
652 + assert_eq!(f.get("unit_amount").map(String::as_str), Some("1500"));
653 + assert_eq!(
654 + f.get("currency").map(String::as_str),
655 + Some("eur"),
656 + "the tier is minted in the settlement currency, never re-denominated"
657 + );
658 + assert_eq!(
659 + f.get("recurring[interval]").map(String::as_str),
660 + Some("month"),
661 + "without `recurring` Stripe bills this once instead of every month"
662 + );
663 + }
664 +
665 + #[test]
666 + fn pausing_voids_invoices_rather_than_cancelling() {
667 + let req = pause_collection_request("sub_1A2b3C4d5E".parse().unwrap());
668 + assert_eq!(path_of(&req), "/subscriptions/sub_1A2b3C4d5E");
669 + assert_eq!(
670 + form(&req)
671 + .get("pause_collection[behavior]")
672 + .map(String::as_str),
673 + Some("void"),
674 + "the fan is not billed while the creator is paused"
675 + );
676 + }
677 +
678 + #[test]
679 + fn cancel_at_period_end_states_the_flag_in_both_directions() {
680 + let set = cancel_at_period_end_request("sub_1A2b3C4d5E".parse().unwrap(), true);
681 + assert_eq!(path_of(&set), "/subscriptions/sub_1A2b3C4d5E");
682 + assert_eq!(
683 + form(&set).get("cancel_at_period_end").map(String::as_str),
684 + Some("true")
685 + );
686 + // Clearing it has to be sent, or an un-pause leaves the subscription
687 + // still scheduled to end.
688 + let cleared = cancel_at_period_end_request("sub_1A2b3C4d5E".parse().unwrap(), false);
689 + assert_eq!(
690 + form(&cleared)
691 + .get("cancel_at_period_end")
692 + .map(String::as_str),
693 + Some("false")
694 + );
695 + }
696 +
697 + #[test]
698 + fn a_billing_portal_session_names_the_customer_and_where_to_come_back_to() {
699 + let req = billing_portal_request("cus_123", "https://makenot.work/settings");
700 + assert_eq!(path_of(&req), "/billing_portal/sessions");
701 + let f = form(&req);
702 + assert_eq!(f.get("customer").map(String::as_str), Some("cus_123"));
703 + assert_eq!(
704 + f.get("return_url").map(String::as_str),
705 + Some("https://makenot.work/settings")
706 + );
707 + }
708 +
709 + #[test]
710 + fn a_refund_is_line_scoped_and_tagged_with_its_transaction() {
711 + let txn = TransactionId::nil();
712 + let req = refund_request("pi_123", 250, txn);
713 + assert_eq!(path_of(&req), "/refunds");
714 + let f = form(&req);
715 + assert_eq!(f.get("payment_intent").map(String::as_str), Some("pi_123"));
716 + assert_eq!(
717 + f.get("amount").map(String::as_str),
718 + Some("250"),
719 + "a cart order is one PaymentIntent, so an amount-less refund would \
720 + reverse every line of it"
721 + );
722 + assert_eq!(
723 + f.get("metadata[mnw_transaction_id]").map(String::as_str),
724 + Some(txn.to_string()).as_deref(),
725 + "the refund.created webhook revokes the transaction this names"
726 + );
727 + }
728 +
729 + #[test]
730 + fn a_platform_credit_transfer_is_denominated_in_the_sales_currency() {
731 + let txn = TransactionId::nil();
732 + let acct: stripe::AccountId = "acct_1A2b3C4d5E6f7G".parse().unwrap();
733 + let req = platform_credit_transfer_request(&acct, 500, txn, SettlementCurrency::Eur);
734 + assert_eq!(path_of(&req), "/transfers");
735 + let f = form(&req);
736 + assert_eq!(
737 + f.get("destination").map(String::as_str),
738 + Some("acct_1A2b3C4d5E6f7G")
Lines truncated
@@ -66,6 +66,105 @@
66 66 })
67 67 }
68 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 +
69 168 impl StripeClient {
70 169 /// Create a Stripe Customer for a SyncKit app. The customer represents
71 170 /// one app, not the developer's MNW account, because each app is billed
@@ -79,15 +178,8 @@
79 178 email: &str,
80 179 app_name: &str,
81 180 ) -> 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 181 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)
182 + let customer = synckit_customer_request(developer_user_id, email, app_name)
91 183 .customize()
92 184 .request_strategy(RequestStrategy::Idempotent(key))
93 185 .send(&self.client)
@@ -104,7 +196,7 @@
104 196 /// activation; the same product is reused on re-price.
105 197 async fn create_synckit_product(&self, app_id: SyncAppId, app_name: &str) -> Result<String> {
106 198 let key = synckit_idempotency_key("synckit-product", app_id)?;
107 - let product = CreateProduct::new(format!("SyncKit: {app_name}"))
199 + let product = synckit_product_request(app_name)
108 200 .customize()
109 201 .request_strategy(RequestStrategy::Idempotent(key))
110 202 .send(&self.client)
@@ -138,36 +230,21 @@
138 230 // We need a Product id to use inline price_data; create one per app.
139 231 let product_id = self.create_synckit_product(app_id, app_name).await?;
140 232
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 233 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 - })?;
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 + })?;
171 248
172 249 let first_item = subscription.items.data.first().ok_or_else(|| {
173 250 AppError::Internal(anyhow::anyhow!("Stripe subscription has no items"))
@@ -217,33 +294,21 @@
217 294 // Reuse the existing item's product so we don't accumulate orphans.
218 295 let product_id = existing_item.price.product.id().to_string();
219 296
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 297 // The product name (which surfaces on the Stripe dashboard for this
238 298 // product) is set once at create-time. Re-naming is a separate Stripe
239 299 // call we currently don't need, record the param so future re-naming
240 300 // hooks have it without changing the trait signature.
241 301 let _ = app_name;
242 302
243 - UpdateSubscription::new(sub_id)
244 - .items(vec![item])
245 - .proration_behavior(UpdateSubscriptionProrationBehavior::CreateProrations)
246 - .send(&self.client)
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)
247 312 .await
248 313 .map_err(|e| {
249 314 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to update SyncKit subscription price");
@@ -292,34 +357,15 @@
292 357 let product_id = existing_item.price.product.id().to_string();
293 358 let _ = product_name;
294 359
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)
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)
323 369 .await
324 370 .map_err(|e| {
325 371 tracing::error!(error = ?e, subscription_id = %subscription_id, "failed to re-price app sub");
@@ -373,6 +419,20 @@
373 419
374 420 use super::*;
375 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 +
376 436 #[test]
377 437 fn the_same_app_and_prefix_always_produce_the_same_key() {
378 438 let app = SyncAppId::nil();
@@ -423,4 +483,169 @@
423 483 assert!(parse_subscription_id("not a sub id").is_ok());
424 484 assert!(parse_subscription_id("acct_wrong_type").is_ok());
425 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 + }
426 651 }