Skip to main content

max / makenotwork

44.2 KB · 1191 lines History Blame Raw
1 //! Checkout session creation.
2 //!
3 //! Direct Charges pattern: payment goes directly to the connected account.
4 //! No `application_fee_amount` is set: the 0% platform fee promise.
5 //!
6 //! # Currency
7 //!
8 //! A session is always created in the **seller's** settlement currency, never
9 //! the buyer's. On a direct charge Stripe converts the presentment currency to
10 //! the connected account's default currency, so denominating in the seller's
11 //! currency is what makes the creator receive the amount they set.
12 //!
13 //! The buyer's conversion choice does not change that. It sets
14 //! `adaptive_pricing.enabled`: with it on, Stripe presents and converts in the
15 //! buyer's local currency; with it off, the buyer's card issuer converts. Both
16 //! sessions are denominated identically. **Always set the flag explicitly** —
17 //! left unset, Stripe falls back to the Connect dashboard setting, and the
18 //! buyer's choice silently stops meaning anything.
19 //!
20 //! MNW's own billing (Fan+, creator tiers, SyncKit) is USD regardless of
21 //! creator, and those builders take a pre-made Stripe Price, so no currency
22 //! literal appears in them.
23
24 use std::collections::HashMap;
25
26 use stripe::StripeRequest;
27 use stripe_checkout::checkout_session::{
28 CreateCheckoutSession, CreateCheckoutSessionAdaptivePricing, CreateCheckoutSessionAutomaticTax,
29 CreateCheckoutSessionLineItems, CreateCheckoutSessionLineItemsPriceData,
30 CreateCheckoutSessionLineItemsPriceDataRecurring,
31 CreateCheckoutSessionLineItemsPriceDataRecurringInterval,
32 CreateCheckoutSessionPaymentMethodCollection, CreateCheckoutSessionSubscriptionData,
33 ProductData,
34 };
35 use stripe_shared::CheckoutSessionMode;
36 use stripe_types::Currency;
37
38 use super::StripeClient;
39 use crate::currency::{ConversionChoice, SettlementCurrency};
40 use crate::db::{
41 Cents, CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, SyncAppId, UserId,
42 };
43 use crate::error::{AppError, Result};
44
45 /// Parameters for creating a one-time purchase Checkout Session.
46 pub struct CheckoutParams<'a> {
47 pub connected_account_id: &'a str,
48 pub item_title: &'a str,
49 pub amount_cents: Cents,
50 pub buyer_id: UserId,
51 pub seller_id: UserId,
52 /// `None` for project-level purchases (no specific item).
53 pub item_id: Option<ItemId>,
54 pub success_url: &'a str,
55 pub cancel_url: &'a str,
56 pub promo_code_id: Option<PromoCodeId>,
57 pub enable_stripe_tax: bool,
58 /// The seller's settlement currency. The session is denominated in it.
59 pub currency: SettlementCurrency,
60 /// How the buyer chose to handle conversion, if their currency differs.
61 pub conversion: ConversionChoice,
62 }
63
64 /// A single line item in a cart checkout.
65 pub struct CartLineItem<'a> {
66 pub title: &'a str,
67 pub amount_cents: i64,
68 }
69
70 /// Parameters for creating a multi-item cart Checkout Session.
71 pub struct CartCheckoutParams<'a> {
72 pub connected_account_id: &'a str,
73 pub line_items: &'a [CartLineItem<'a>],
74 pub buyer_id: UserId,
75 pub seller_id: UserId,
76 pub success_url: &'a str,
77 pub cancel_url: &'a str,
78 pub enable_stripe_tax: bool,
79 /// The seller's settlement currency. The session is denominated in it.
80 pub currency: SettlementCurrency,
81 /// How the buyer chose to handle conversion, if their currency differs.
82 pub conversion: ConversionChoice,
83 }
84
85 /// Parameters for creating a subscription Checkout Session.
86 pub struct SubscriptionCheckoutParams<'a> {
87 pub connected_account_id: &'a str,
88 pub stripe_price_id: &'a str,
89 pub subscriber_id: UserId,
90 pub project_id: ProjectId,
91 pub tier_id: SubscriptionTierId,
92 pub success_url: &'a str,
93 pub cancel_url: &'a str,
94 pub trial_days: Option<i32>,
95 pub promo_code_id: Option<PromoCodeId>,
96 pub enable_stripe_tax: bool,
97 /// The creator's settlement currency. The Stripe Price named by
98 /// `stripe_price_id` was minted in it; this is carried for the conversion
99 /// flag and for the minimum-charge check, not to re-denominate the price.
100 pub currency: SettlementCurrency,
101 /// How the buyer chose to handle conversion, if their currency differs.
102 pub conversion: ConversionChoice,
103 }
104
105 /// Parameters for creating a tip Checkout Session.
106 pub struct TipCheckoutParams<'a> {
107 pub connected_account_id: &'a str,
108 pub recipient_display_name: &'a str,
109 pub amount_cents: Cents,
110 pub tipper_id: UserId,
111 pub recipient_id: UserId,
112 pub project_id: Option<ProjectId>,
113 pub message: Option<&'a str>,
114 pub success_url: &'a str,
115 pub cancel_url: &'a str,
116 pub enable_stripe_tax: bool,
117 /// The recipient's settlement currency. The session is denominated in it.
118 pub currency: SettlementCurrency,
119 /// How the tipper chose to handle conversion, if their currency differs.
120 pub conversion: ConversionChoice,
121 }
122
123 /// Parameters for creating a guest (no-account) purchase Checkout Session.
124 pub struct GuestCheckoutParams<'a> {
125 pub connected_account_id: &'a str,
126 pub item_title: &'a str,
127 pub amount_cents: Cents,
128 pub seller_id: UserId,
129 pub item_id: ItemId,
130 pub success_url: &'a str,
131 pub cancel_url: &'a str,
132 pub promo_code_id: Option<PromoCodeId>,
133 pub enable_stripe_tax: bool,
134 /// The seller's settlement currency. The session is denominated in it.
135 pub currency: SettlementCurrency,
136 /// How the buyer chose to handle conversion, if their currency differs.
137 pub conversion: ConversionChoice,
138 }
139
140 /// Parameters for a SyncKit developer app-subscription Checkout Session. The
141 /// price (`amount_cents` / `interval`) rides inline via `price_data`, so no
142 /// Stripe Product/Price needs pre-configuring. `interval` is `"monthly"` or
143 /// `"annual"`.
144 pub struct SynckitAppSubCheckoutParams<'a> {
145 pub product_name: &'a str,
146 pub amount_cents: i64,
147 pub interval: &'a str,
148 pub user_id: UserId,
149 pub app_id: SyncAppId,
150 pub storage_limit_bytes: Option<i64>,
151 pub success_url: &'a str,
152 pub cancel_url: &'a str,
153 }
154
155 /// Reject a charge below Stripe's per-transaction minimum (Stripe hard-rejects
156 /// sub-minimum amounts with an unfriendly error). Free ($0) items are allowed;
157 /// callers gate those separately. Shared by the Stripe session builders here and
158 /// by the checkout routes, which call it before reserving a promo so a rejected
159 /// sub-minimum checkout doesn't burn a use of the code.
160 pub(crate) fn check_min_charge(amount_cents: i64, currency: SettlementCurrency) -> Result<()> {
161 let minimum = currency.minimum_charge_cents();
162 if amount_cents > 0 && amount_cents < minimum {
163 return Err(AppError::BadRequest(format!(
164 "Minimum purchase amount is {}",
165 crate::formatting::format_revenue(minimum, currency)
166 )));
167 }
168 Ok(())
169 }
170
171 fn build_inline_line_item(
172 title: &str,
173 amount_cents: i64,
174 currency: SettlementCurrency,
175 ) -> CreateCheckoutSessionLineItems {
176 CreateCheckoutSessionLineItems {
177 price_data: Some(CreateCheckoutSessionLineItemsPriceData {
178 product_data: Some(ProductData::new(title.to_string())),
179 unit_amount: Some(amount_cents),
180 // `new` takes the currency; restating it here only cost a clone.
181 ..CreateCheckoutSessionLineItemsPriceData::new(currency.to_stripe())
182 }),
183 quantity: Some(1),
184 ..CreateCheckoutSessionLineItems::new()
185 }
186 }
187
188 fn build_price_line_item(price_id: &str) -> CreateCheckoutSessionLineItems {
189 CreateCheckoutSessionLineItems {
190 price: Some(price_id.to_string()),
191 quantity: Some(1),
192 ..CreateCheckoutSessionLineItems::new()
193 }
194 }
195
196 /// Build an inline recurring line item in USD.
197 ///
198 /// SyncKit developer billing only: Make Creative bills developers in USD
199 /// regardless of where they are, same as the creator tiers. Nothing a creator
200 /// sells goes through here, so there is no settlement currency to read.
201 fn build_inline_recurring_line_item_usd(
202 product_name: &str,
203 amount_cents: i64,
204 interval: CreateCheckoutSessionLineItemsPriceDataRecurringInterval,
205 ) -> CreateCheckoutSessionLineItems {
206 CreateCheckoutSessionLineItems {
207 price_data: Some(CreateCheckoutSessionLineItemsPriceData {
208 product_data: Some(ProductData::new(product_name.to_string())),
209 unit_amount: Some(amount_cents),
210 recurring: Some(CreateCheckoutSessionLineItemsPriceDataRecurring::new(
211 interval,
212 )),
213 ..CreateCheckoutSessionLineItemsPriceData::new(Currency::USD)
214 }),
215 quantity: Some(1),
216 ..CreateCheckoutSessionLineItems::new()
217 }
218 }
219
220 /// The Adaptive Pricing flag for a buyer's conversion choice.
221 ///
222 /// Always sent, never omitted. Omitting it hands the decision to the Connect
223 /// dashboard setting, which would override the buyer's choice without any
224 /// evidence in the code that a choice was ever made.
225 fn adaptive_pricing(conversion: ConversionChoice) -> CreateCheckoutSessionAdaptivePricing {
226 CreateCheckoutSessionAdaptivePricing {
227 enabled: Some(conversion.adaptive_pricing_enabled()),
228 }
229 }
230
231 fn automatic_tax(enable: bool) -> Option<CreateCheckoutSessionAutomaticTax> {
232 if enable {
233 Some(CreateCheckoutSessionAutomaticTax::new(true))
234 } else {
235 None
236 }
237 }
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
503 impl StripeClient {
504 async fn send_on_connected_account(
505 &self,
506 builder: CreateCheckoutSession,
507 connected_account_id: &str,
508 log_label: &str,
509 ) -> Result<stripe_shared::CheckoutSession> {
510 let account_id = Self::parse_account_id(connected_account_id)?;
511 builder
512 .customize()
513 .account_id(account_id)
514 .send(&self.client)
515 .await
516 .map_err(|e| {
517 tracing::error!(error = ?e, label = %log_label, "failed to create checkout session");
518 AppError::BadRequest("Failed to create checkout session".to_string())
519 })
520 }
521
522 async fn send_on_platform(
523 &self,
524 builder: CreateCheckoutSession,
525 log_label: &str,
526 ) -> Result<stripe_shared::CheckoutSession> {
527 builder.send(&self.client).await.map_err(|e| {
528 tracing::error!(error = ?e, label = %log_label, "failed to create checkout session");
529 AppError::BadRequest("Failed to create checkout session".to_string())
530 })
531 }
532
533 /// Build a one-time payment checkout session for a guest purchase.
534 #[tracing::instrument(skip_all, name = "payments::create_guest_checkout_session")]
535 pub async fn create_guest_checkout_session(
536 &self,
537 checkout: &GuestCheckoutParams<'_>,
538 ) -> Result<stripe_shared::CheckoutSession> {
539 let builder = guest_checkout_request(checkout)?;
540 self.send_on_connected_account(builder, checkout.connected_account_id, "guest_checkout")
541 .await
542 }
543
544 /// Build a one-time payment checkout session for a purchase by a logged-in user.
545 #[tracing::instrument(skip_all, name = "payments::create_checkout_session")]
546 pub async fn create_checkout_session(
547 &self,
548 checkout: &CheckoutParams<'_>,
549 ) -> Result<stripe_shared::CheckoutSession> {
550 let builder = checkout_request(checkout)?;
551 self.send_on_connected_account(builder, checkout.connected_account_id, "checkout")
552 .await
553 }
554
555 /// Build a multi-line-item Checkout Session for a cart purchase.
556 #[tracing::instrument(skip_all, name = "payments::create_cart_checkout_session")]
557 pub async fn create_cart_checkout_session(
558 &self,
559 cart: &CartCheckoutParams<'_>,
560 ) -> Result<stripe_shared::CheckoutSession> {
561 let builder = cart_checkout_request(cart)?;
562 self.send_on_connected_account(builder, cart.connected_account_id, "cart_checkout")
563 .await
564 }
565
566 /// Build a subscription Checkout Session on a connected account.
567 #[tracing::instrument(skip_all, name = "payments::create_subscription_checkout_session")]
568 pub async fn create_subscription_checkout_session(
569 &self,
570 sub: &SubscriptionCheckoutParams<'_>,
571 ) -> Result<stripe_shared::CheckoutSession> {
572 let builder = subscription_checkout_request(sub)?;
573 self.send_on_connected_account(builder, sub.connected_account_id, "subscription_checkout")
574 .await
575 }
576
577 /// Build a Checkout Session for a tip to a creator.
578 #[tracing::instrument(skip_all, name = "payments::create_tip_checkout_session")]
579 pub async fn create_tip_checkout_session(
580 &self,
581 tip: &TipCheckoutParams<'_>,
582 ) -> Result<stripe_shared::CheckoutSession> {
583 let builder = tip_checkout_request(tip);
584 self.send_on_connected_account(builder, tip.connected_account_id, "tip_checkout")
585 .await
586 }
587
588 /// Build a Checkout Session for a Fan+ subscription on MNW's own Stripe account.
589 #[tracing::instrument(skip_all, name = "payments::create_fan_plus_checkout_session")]
590 pub async fn create_fan_plus_checkout_session(
591 &self,
592 price_id: &str,
593 user_id: UserId,
594 success_url: &str,
595 cancel_url: &str,
596 ) -> Result<stripe_shared::CheckoutSession> {
597 let builder = fan_plus_checkout_request(price_id, user_id, success_url, cancel_url);
598 self.send_on_platform(builder, "fan_plus_checkout").await
599 }
600
601 /// Build a Checkout Session for a creator tier subscription on MNW's own Stripe account.
602 #[tracing::instrument(skip_all, name = "payments::create_creator_tier_checkout_session")]
603 pub async fn create_creator_tier_checkout_session(
604 &self,
605 price_id: &str,
606 user_id: UserId,
607 tier: &str,
608 success_url: &str,
609 cancel_url: &str,
610 trial_days: Option<i32>,
611 ) -> Result<stripe_shared::CheckoutSession> {
612 let builder = creator_tier_checkout_request(
613 price_id,
614 user_id,
615 tier,
616 success_url,
617 cancel_url,
618 trial_days,
619 )?;
620 self.send_on_platform(builder, "creator_tier_checkout")
621 .await
622 }
623
624 /// Build a Checkout Session for an end-user subscribing to an app's cloud
625 /// sync (SyncKit). Runs on MNW's own Stripe account. Uses inline
626 /// `price_data` so no Stripe Products/Prices need to be pre-configured,
627 /// the tier name and cents come from the `sync_app_tiers` row.
628 #[tracing::instrument(skip_all, name = "payments::create_synckit_app_sub_checkout_session")]
629 pub async fn create_synckit_app_sub_checkout_session(
630 &self,
631 p: &SynckitAppSubCheckoutParams<'_>,
632 ) -> Result<stripe_shared::CheckoutSession> {
633 let builder = synckit_app_sub_checkout_request(p)?;
634 self.send_on_platform(builder, "synckit_app_sub_checkout")
635 .await
636 }
637 }
638
639 #[cfg(test)]
640 mod tests {
641 //! The shape of what we ask Stripe to charge. Everything here is pure and
642 //! sits directly on the money path: a wrong `unit_amount`, a missing
643 //! `quantity`, or a minimum-charge boundary off by one cent is a real
644 //! charge that is wrong, and none of it was covered.
645
646 use super::*;
647 use crate::db::{ItemId, ProjectId, SubscriptionTierId, SyncAppId, UserId};
648
649 /// The form-encoded body a request would be sent with, decoded into pairs.
650 /// `RequestBuilder` is what the transport is handed, so it is the last
651 /// point before the wire a test can read.
652 fn form(req: &impl StripeRequest) -> std::collections::BTreeMap<String, String> {
653 let built = req.build();
654 let body = built.body.unwrap_or_default();
655 url::form_urlencoded::parse(body.as_bytes())
656 .map(|(k, v)| (k.into_owned(), v.into_owned()))
657 .collect()
658 }
659
660 fn field(req: &impl StripeRequest, key: &str) -> Option<String> {
661 form(req).get(key).cloned()
662 }
663
664 // ── the Stripe per-transaction minimum ──
665
666 const USD: SettlementCurrency = SettlementCurrency::Usd;
667
668 #[test]
669 fn the_minimum_is_per_currency_and_gbp_is_lower() {
670 // GBP's floor is 30, not 50. Applying the USD floor to a British
671 // creator would refuse charges Stripe would have accepted.
672 assert!(check_min_charge(30, SettlementCurrency::Gbp).is_ok());
673 assert!(matches!(
674 check_min_charge(30, SettlementCurrency::Usd),
675 Err(AppError::BadRequest(_))
676 ));
677 }
678
679 #[test]
680 fn the_rejection_names_the_currency_it_refused_in() {
681 let Err(AppError::BadRequest(msg)) = check_min_charge(1, SettlementCurrency::Gbp) else {
682 panic!("1 penny should be rejected");
683 };
684 assert!(
685 msg.contains("\u{a3}0.30"),
686 "a GBP rejection must not quote a dollar minimum: {msg}"
687 );
688 }
689
690 #[test]
691 fn a_free_item_is_allowed_through() {
692 // $0 items are legitimate; callers gate them before they reach Stripe.
693 assert!(check_min_charge(0, USD).is_ok());
694 }
695
696 #[test]
697 fn the_minimum_itself_is_allowed_and_one_cent_under_is_not() {
698 let min = USD.minimum_charge_cents();
699 assert!(
700 check_min_charge(min, USD).is_ok(),
701 "the boundary is inclusive"
702 );
703 assert!(
704 matches!(check_min_charge(min - 1, USD), Err(AppError::BadRequest(_))),
705 "one cent under the minimum must be refused before Stripe refuses it"
706 );
707 assert!(check_min_charge(min + 1, USD).is_ok());
708 }
709
710 #[test]
711 fn the_rejection_names_the_minimum_in_dollars() {
712 // The message reaches a buyer, so it must not say "50".
713 let Err(AppError::BadRequest(msg)) = check_min_charge(1, USD) else {
714 panic!("1 cent should be rejected");
715 };
716 assert!(
717 msg.contains("$0.50"),
718 "buyer-facing message should format the minimum as currency: {msg}"
719 );
720 }
721
722 #[test]
723 fn a_negative_amount_is_not_rejected_here() {
724 // Documenting the current contract rather than endorsing it: the guard
725 // is `> 0 && < minimum`, so negatives pass. Every caller computes its
726 // amount from a price and a discount, and none is proven non-negative
727 // here. If a discount is ever allowed to exceed a price, this is the
728 // gate that will not catch it.
729 assert!(check_min_charge(-1, USD).is_ok());
730 }
731
732 // ── line items ──
733
734 #[test]
735 fn an_inline_line_item_charges_the_given_amount_once_in_usd() {
736 let item = build_inline_line_item("A Record", 2500, USD);
737 let price = item.price_data.expect("inline items carry price_data");
738 assert_eq!(price.unit_amount, Some(2500));
739 assert_eq!(price.currency, Currency::USD);
740 assert_eq!(
741 item.quantity,
742 Some(1),
743 "quantity must be pinned: None would let Stripe default and charge differently"
744 );
745 assert!(
746 item.price.is_none(),
747 "an inline item must not also reference a Stripe Price"
748 );
749 assert_eq!(
750 price.product_data.map(|p| p.name).as_deref(),
751 Some("A Record"),
752 "without product_data the buyer sees an unnamed line on the Stripe page"
753 );
754 }
755
756 #[test]
757 fn a_price_line_item_references_stripe_and_sets_no_amount_of_its_own() {
758 let item = build_price_line_item("price_123");
759 assert_eq!(item.price.as_deref(), Some("price_123"));
760 assert_eq!(item.quantity, Some(1));
761 assert!(
762 item.price_data.is_none(),
763 "a Price-backed item that also carries price_data would charge the inline amount"
764 );
765 }
766
767 #[test]
768 fn a_recurring_item_carries_its_interval_and_a_recurring_price() {
769 let item = build_inline_recurring_line_item_usd(
770 "SyncKit Pro",
771 900,
772 CreateCheckoutSessionLineItemsPriceDataRecurringInterval::Month,
773 );
774 let price = item.price_data.expect("recurring items carry price_data");
775 assert_eq!(price.unit_amount, Some(900));
776 assert_eq!(price.currency, Currency::USD);
777 assert!(
778 price.recurring.is_some(),
779 "without `recurring` Stripe bills this once instead of every period"
780 );
781 assert_eq!(item.quantity, Some(1));
782 assert_eq!(
783 price.product_data.map(|p| p.name).as_deref(),
784 Some("SyncKit Pro")
785 );
786 }
787
788 // ── adaptive pricing ──
789
790 #[test]
791 fn adaptive_pricing_states_the_buyers_choice_rather_than_omitting_it() {
792 // `None` is not a neutral value: it hands the decision to the Connect
793 // dashboard setting, which is the failure the always-send rule exists
794 // to prevent.
795 assert_eq!(
796 adaptive_pricing(ConversionChoice::AtCheckout).enabled,
797 Some(true)
798 );
799 assert_eq!(
800 adaptive_pricing(ConversionChoice::ByBuyersBank).enabled,
801 Some(false)
802 );
803 }
804
805 // ── automatic tax ──
806
807 #[test]
808 fn automatic_tax_is_absent_rather_than_disabled_when_off() {
809 assert!(
810 automatic_tax(false).is_none(),
811 "sending an explicit disabled block is not the same as omitting it"
812 );
813 assert!(automatic_tax(true).is_some());
814 }
815
816 // ── the session each checkout kind asks Stripe for ──
817 //
818 // `checkout_type` is what the `checkout.session.completed` dispatcher
819 // routes on (see `checkout_metadata`), so a session that carries the wrong
820 // one is money taken and nothing granted. The ids beside it are what the
821 // handler then credits, and `mode` decides whether the buyer is charged
822 // once or every month.
823
824 fn guest_params<'a>() -> GuestCheckoutParams<'a> {
825 GuestCheckoutParams {
826 connected_account_id: "acct_1A2b3C",
827 item_title: "A Record",
828 amount_cents: Cents::new(2500),
829 seller_id: UserId::nil(),
830 item_id: ItemId::nil(),
831 success_url: "https://makenot.work/ok",
832 cancel_url: "https://makenot.work/no",
833 promo_code_id: None,
834 enable_stripe_tax: false,
835 currency: SettlementCurrency::Usd,
836 conversion: ConversionChoice::AtCheckout,
837 }
838 }
839
840 #[test]
841 fn a_guest_session_is_a_one_off_payment_naming_the_item_it_sells() {
842 let req = guest_checkout_request(&guest_params()).expect("above the minimum");
843 assert_eq!(req.build().path, "/checkout/sessions");
844 assert_eq!(field(&req, "mode").as_deref(), Some("payment"));
845 assert_eq!(
846 field(&req, "metadata[checkout_type]").as_deref(),
847 Some("guest"),
848 "the completed-session dispatcher routes on this"
849 );
850 assert_eq!(
851 field(&req, "metadata[item_id]").as_deref(),
852 Some(ItemId::nil().to_string()).as_deref(),
853 "without the item id the purchase grants nothing"
854 );
855 assert_eq!(
856 field(&req, "metadata[seller_id]").as_deref(),
857 Some(UserId::nil().to_string()).as_deref()
858 );
859 assert_eq!(
860 field(&req, "success_url").as_deref(),
861 Some("https://makenot.work/ok")
862 );
863 assert_eq!(
864 field(&req, "cancel_url").as_deref(),
865 Some("https://makenot.work/no")
866 );
867 assert_eq!(
868 field(&req, "line_items[0][price_data][unit_amount]").as_deref(),
869 Some("2500")
870 );
871 assert!(
872 !form(&req).contains_key("metadata[promo_code_id]"),
873 "no promo code means no key, not an empty one"
874 );
875 }
876
877 #[test]
878 fn a_sub_minimum_guest_session_is_refused_before_it_is_built() {
879 let params = GuestCheckoutParams {
880 amount_cents: Cents::new(1),
881 ..guest_params()
882 };
883 assert!(matches!(
884 guest_checkout_request(&params),
885 Err(AppError::BadRequest(_))
886 ));
887 }
888
889 #[test]
890 fn a_promo_code_rides_along_so_the_webhook_can_burn_it() {
891 let code = crate::db::PromoCodeId::nil();
892 let params = GuestCheckoutParams {
893 promo_code_id: Some(code),
894 ..guest_params()
895 };
896 let req = guest_checkout_request(&params).unwrap();
897 assert_eq!(
898 field(&req, "metadata[promo_code_id]").as_deref(),
899 Some(code.to_string()).as_deref()
900 );
901 }
902
903 #[test]
904 fn a_logged_in_session_names_the_buyer_the_guest_one_cannot() {
905 let params = CheckoutParams {
906 connected_account_id: "acct_1A2b3C",
907 item_title: "A Record",
908 amount_cents: Cents::new(2500),
909 buyer_id: UserId::nil(),
910 seller_id: UserId::nil(),
911 item_id: Some(ItemId::nil()),
912 success_url: "https://makenot.work/ok",
913 cancel_url: "https://makenot.work/no",
914 promo_code_id: None,
915 enable_stripe_tax: false,
916 currency: SettlementCurrency::Usd,
917 conversion: ConversionChoice::AtCheckout,
918 };
919 let req = checkout_request(&params).unwrap();
920 assert_eq!(field(&req, "mode").as_deref(), Some("payment"));
921 assert_eq!(
922 field(&req, "metadata[buyer_id]").as_deref(),
923 Some(UserId::nil().to_string()).as_deref()
924 );
925 }
926
927 #[test]
928 fn a_cart_session_bills_every_line_and_clears_the_minimum_on_the_total() {
929 let lines = [
930 CartLineItem {
931 title: "A Record",
932 amount_cents: 30,
933 },
934 CartLineItem {
935 title: "A Zine",
936 amount_cents: 30,
937 },
938 ];
939 let cart = CartCheckoutParams {
940 connected_account_id: "acct_1A2b3C",
941 line_items: &lines,
942 buyer_id: UserId::nil(),
943 seller_id: UserId::nil(),
944 success_url: "https://makenot.work/ok",
945 cancel_url: "https://makenot.work/no",
946 enable_stripe_tax: false,
947 currency: SettlementCurrency::Usd,
948 conversion: ConversionChoice::AtCheckout,
949 };
950 // Neither line clears the 50c floor on its own; the order does.
951 let req = cart_checkout_request(&cart).expect("the order total is what Stripe charges");
952 assert_eq!(
953 field(&req, "metadata[checkout_type]").as_deref(),
954 Some("cart")
955 );
956 assert_eq!(
957 field(&req, "line_items[0][price_data][unit_amount]").as_deref(),
958 Some("30")
959 );
960 assert_eq!(
961 field(&req, "line_items[1][price_data][unit_amount]").as_deref(),
962 Some("30"),
963 "a dropped line is an item the buyer paid nothing for and receives"
964 );
965 }
966
967 fn sub_params<'a>() -> SubscriptionCheckoutParams<'a> {
968 SubscriptionCheckoutParams {
969 connected_account_id: "acct_1A2b3C",
970 stripe_price_id: "price_123",
971 subscriber_id: UserId::nil(),
972 project_id: ProjectId::nil(),
973 tier_id: SubscriptionTierId::nil(),
974 success_url: "https://makenot.work/ok",
975 cancel_url: "https://makenot.work/no",
976 trial_days: None,
977 promo_code_id: None,
978 enable_stripe_tax: false,
979 currency: SettlementCurrency::Usd,
980 conversion: ConversionChoice::AtCheckout,
981 }
982 }
983
984 #[test]
985 fn a_creator_subscription_is_recurring_and_on_the_creators_own_price() {
986 let req = subscription_checkout_request(&sub_params()).unwrap();
987 assert_eq!(
988 field(&req, "mode").as_deref(),
989 Some("subscription"),
990 "payment mode would charge the fan once and grant them a tier forever"
991 );
992 assert_eq!(
993 field(&req, "line_items[0][price]").as_deref(),
994 Some("price_123")
995 );
996 assert_eq!(
997 field(&req, "metadata[tier_id]").as_deref(),
998 Some(SubscriptionTierId::nil().to_string()).as_deref()
999 );
1000 assert!(
1001 !form(&req).contains_key("subscription_data[trial_period_days]"),
1002 "no trial means no trial, not a zero-day one"
1003 );
1004 }
1005
1006 #[test]
1007 fn a_trial_on_a_creator_subscription_delays_the_first_charge() {
1008 let params = SubscriptionCheckoutParams {
1009 trial_days: Some(14),
1010 ..sub_params()
1011 };
1012 let req = subscription_checkout_request(&params).unwrap();
1013 assert_eq!(
1014 field(&req, "subscription_data[trial_period_days]").as_deref(),
1015 Some("14")
1016 );
1017 }
1018
1019 #[test]
1020 fn a_negative_trial_is_refused_rather_than_wrapping_around() {
1021 // `try_into` to u32 is the guard: -1 as a wrapped u32 would be a
1022 // four-billion-day free trial.
1023 let params = SubscriptionCheckoutParams {
1024 trial_days: Some(-1),
1025 ..sub_params()
1026 };
1027 assert!(matches!(
1028 subscription_checkout_request(&params),
1029 Err(AppError::BadRequest(_))
1030 ));
1031 }
1032
1033 #[test]
1034 fn a_tip_names_its_recipient_and_truncates_the_message_stripe_would_reject() {
1035 let long = "x".repeat(600);
1036 let tip = TipCheckoutParams {
1037 connected_account_id: "acct_1A2b3C",
1038 recipient_display_name: "Ada",
1039 amount_cents: Cents::new(500),
1040 tipper_id: UserId::nil(),
1041 recipient_id: UserId::nil(),
1042 project_id: None,
1043 message: Some(&long),
1044 success_url: "https://makenot.work/ok",
1045 cancel_url: "https://makenot.work/no",
1046 enable_stripe_tax: false,
1047 currency: SettlementCurrency::Usd,
1048 conversion: ConversionChoice::AtCheckout,
1049 };
1050 let req = tip_checkout_request(&tip);
1051 assert_eq!(
1052 field(&req, "metadata[checkout_type]").as_deref(),
1053 Some("tip")
1054 );
1055 assert_eq!(
1056 field(&req, "line_items[0][price_data][product_data][name]").as_deref(),
1057 Some("Tip for Ada"),
1058 "the buyer has to see who they are tipping on Stripe's page"
1059 );
1060 assert_eq!(
1061 field(&req, "metadata[message]").map(|m| m.chars().count()),
1062 Some(500),
1063 "Stripe caps a metadata value at 500, and a rejected session is a \
1064 tip that never happens"
1065 );
1066 assert!(
1067 !form(&req).contains_key("metadata[project_id]"),
1068 "a tip with no project names none"
1069 );
1070 }
1071
1072 #[test]
1073 fn fan_plus_bills_on_the_platform_price_and_says_who_it_is_for() {
1074 let req = fan_plus_checkout_request(
1075 "price_fanplus",
1076 UserId::nil(),
1077 "https://makenot.work/ok",
1078 "https://makenot.work/no",
1079 );
1080 assert_eq!(field(&req, "mode").as_deref(), Some("subscription"));
1081 assert_eq!(
1082 field(&req, "metadata[checkout_type]").as_deref(),
1083 Some("fan_plus")
1084 );
1085 assert_eq!(
1086 field(&req, "line_items[0][price]").as_deref(),
1087 Some("price_fanplus")
1088 );
1089 assert_eq!(
1090 field(&req, "metadata[user_id]").as_deref(),
1091 Some(UserId::nil().to_string()).as_deref()
1092 );
1093 }
1094
1095 #[test]
1096 fn a_comped_creator_tier_asks_for_no_card_and_a_paid_one_does() {
1097 let comped = creator_tier_checkout_request(
1098 "price_tier",
1099 UserId::nil(),
1100 "everything",
1101 "https://makenot.work/ok",
1102 "https://makenot.work/no",
1103 Some(30),
1104 )
1105 .unwrap();
1106 assert_eq!(
1107 field(&comped, "payment_method_collection").as_deref(),
1108 Some("if_required"),
1109 "a comp must not put a card on file, or the trial end is a charge \
1110 nobody opted into"
1111 );
1112 assert_eq!(
1113 field(&comped, "subscription_data[trial_period_days]").as_deref(),
1114 Some("30")
1115 );
1116 assert_eq!(
1117 field(&comped, "metadata[tier]").as_deref(),
1118 Some("everything")
1119 );
1120
1121 let paid = creator_tier_checkout_request(
1122 "price_tier",
1123 UserId::nil(),
1124 "everything",
1125 "https://makenot.work/ok",
1126 "https://makenot.work/no",
1127 None,
1128 )
1129 .unwrap();
1130 assert!(
1131 !form(&paid).contains_key("payment_method_collection"),
1132 "an ordinary signup collects a card the usual way"
1133 );
1134 assert!(!form(&paid).contains_key("subscription_data[trial_period_days]"));
1135 }
1136
1137 #[test]
1138 fn a_synckit_app_subscription_carries_the_cap_it_was_bought_for() {
1139 let p = SynckitAppSubCheckoutParams {
1140 product_name: "Notes Sync",
1141 amount_cents: 900,
1142 interval: "annual",
1143 user_id: UserId::nil(),
1144 app_id: SyncAppId::nil(),
1145 storage_limit_bytes: Some(50_000_000_000),
1146 success_url: "https://makenot.work/ok",
1147 cancel_url: "https://makenot.work/no",
1148 };
1149 let req = synckit_app_sub_checkout_request(&p).unwrap();
1150 assert_eq!(field(&req, "mode").as_deref(), Some("subscription"));
1151 assert_eq!(
1152 field(&req, "metadata[checkout_type]").as_deref(),
1153 Some("synckit_app_sub")
1154 );
1155 assert_eq!(
1156 field(&req, "line_items[0][price_data][recurring][interval]").as_deref(),
1157 Some("year"),
1158 "an annual purchase billed monthly charges twelve times over"
1159 );
1160 assert_eq!(field(&req, "metadata[interval]").as_deref(), Some("annual"));
1161 assert_eq!(
1162 field(&req, "metadata[storage_limit_bytes]").as_deref(),
1163 Some("50000000000"),
1164 "the webhook stamps the cap from here; without it the user pays \
1165 for storage the row never grants"
1166 );
1167 assert_eq!(
1168 field(&req, "metadata[app_id]").as_deref(),
1169 Some(SyncAppId::nil().to_string()).as_deref()
1170 );
1171 }
1172
1173 #[test]
1174 fn an_unknown_billing_interval_is_refused_rather_than_defaulted() {
1175 let p = SynckitAppSubCheckoutParams {
1176 product_name: "Notes Sync",
1177 amount_cents: 900,
1178 interval: "weekly",
1179 user_id: UserId::nil(),
1180 app_id: SyncAppId::nil(),
1181 storage_limit_bytes: None,
1182 success_url: "https://makenot.work/ok",
1183 cancel_url: "https://makenot.work/no",
1184 };
1185 assert!(matches!(
1186 synckit_app_sub_checkout_request(&p),
1187 Err(AppError::BadRequest(_))
1188 ));
1189 }
1190 }
1191