Skip to main content

max / makenotwork

28.1 KB · 713 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 let currency = currency.to_stripe();
177 CreateCheckoutSessionLineItems {
178 price_data: Some(CreateCheckoutSessionLineItemsPriceData {
179 currency: currency.clone(),
180 product_data: Some(ProductData::new(title.to_string())),
181 unit_amount: Some(amount_cents),
182 ..CreateCheckoutSessionLineItemsPriceData::new(currency)
183 }),
184 quantity: Some(1),
185 ..CreateCheckoutSessionLineItems::new()
186 }
187 }
188
189 fn build_price_line_item(price_id: &str) -> CreateCheckoutSessionLineItems {
190 CreateCheckoutSessionLineItems {
191 price: Some(price_id.to_string()),
192 quantity: Some(1),
193 ..CreateCheckoutSessionLineItems::new()
194 }
195 }
196
197 /// Build an inline recurring line item in USD.
198 ///
199 /// SyncKit developer billing only: Make Creative bills developers in USD
200 /// regardless of where they are, same as the creator tiers. Nothing a creator
201 /// sells goes through here, so there is no settlement currency to read.
202 fn build_inline_recurring_line_item_usd(
203 product_name: &str,
204 amount_cents: i64,
205 interval: CreateCheckoutSessionLineItemsPriceDataRecurringInterval,
206 ) -> CreateCheckoutSessionLineItems {
207 CreateCheckoutSessionLineItems {
208 price_data: Some(CreateCheckoutSessionLineItemsPriceData {
209 currency: Currency::USD,
210 product_data: Some(ProductData::new(product_name.to_string())),
211 unit_amount: Some(amount_cents),
212 recurring: Some(CreateCheckoutSessionLineItemsPriceDataRecurring::new(
213 interval,
214 )),
215 ..CreateCheckoutSessionLineItemsPriceData::new(Currency::USD)
216 }),
217 quantity: Some(1),
218 ..CreateCheckoutSessionLineItems::new()
219 }
220 }
221
222 /// The Adaptive Pricing flag for a buyer's conversion choice.
223 ///
224 /// Always sent, never omitted. Omitting it hands the decision to the Connect
225 /// dashboard setting, which would override the buyer's choice without any
226 /// evidence in the code that a choice was ever made.
227 fn adaptive_pricing(conversion: ConversionChoice) -> CreateCheckoutSessionAdaptivePricing {
228 CreateCheckoutSessionAdaptivePricing {
229 enabled: Some(conversion.adaptive_pricing_enabled()),
230 }
231 }
232
233 fn automatic_tax(enable: bool) -> Option<CreateCheckoutSessionAutomaticTax> {
234 if enable {
235 Some(CreateCheckoutSessionAutomaticTax::new(true))
236 } else {
237 None
238 }
239 }
240
241 impl StripeClient {
242 async fn send_on_connected_account(
243 &self,
244 builder: CreateCheckoutSession,
245 connected_account_id: &str,
246 log_label: &str,
247 ) -> Result<stripe_shared::CheckoutSession> {
248 let account_id = Self::parse_account_id(connected_account_id)?;
249 builder
250 .customize()
251 .account_id(account_id)
252 .send(&self.client)
253 .await
254 .map_err(|e| {
255 tracing::error!(error = ?e, label = %log_label, "failed to create checkout session");
256 AppError::BadRequest("Failed to create checkout session".to_string())
257 })
258 }
259
260 async fn send_on_platform(
261 &self,
262 builder: CreateCheckoutSession,
263 log_label: &str,
264 ) -> Result<stripe_shared::CheckoutSession> {
265 builder.send(&self.client).await.map_err(|e| {
266 tracing::error!(error = ?e, label = %log_label, "failed to create checkout session");
267 AppError::BadRequest("Failed to create checkout session".to_string())
268 })
269 }
270
271 /// Build a one-time payment checkout session for a guest purchase.
272 #[tracing::instrument(skip_all, name = "payments::create_guest_checkout_session")]
273 pub async fn create_guest_checkout_session(
274 &self,
275 checkout: &GuestCheckoutParams<'_>,
276 ) -> Result<stripe_shared::CheckoutSession> {
277 check_min_charge(checkout.amount_cents.as_i64(), checkout.currency)?;
278
279 let mut metadata = HashMap::new();
280 metadata.insert("checkout_type".to_string(), CheckoutType::Guest.to_string());
281 metadata.insert("seller_id".to_string(), checkout.seller_id.to_string());
282 metadata.insert("item_id".to_string(), checkout.item_id.to_string());
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
302 self.send_on_connected_account(builder, checkout.connected_account_id, "guest_checkout")
303 .await
304 }
305
306 /// Build a one-time payment checkout session for a purchase by a logged-in user.
307 #[tracing::instrument(skip_all, name = "payments::create_checkout_session")]
308 pub async fn create_checkout_session(
309 &self,
310 checkout: &CheckoutParams<'_>,
311 ) -> Result<stripe_shared::CheckoutSession> {
312 check_min_charge(checkout.amount_cents.as_i64(), checkout.currency)?;
313
314 let mut metadata = HashMap::new();
315 metadata.insert("buyer_id".to_string(), checkout.buyer_id.to_string());
316 metadata.insert("seller_id".to_string(), checkout.seller_id.to_string());
317 if let Some(item_id) = checkout.item_id {
318 metadata.insert("item_id".to_string(), item_id.to_string());
319 }
320 if let Some(pc_id) = checkout.promo_code_id {
321 metadata.insert("promo_code_id".to_string(), pc_id.to_string());
322 }
323
324 let mut builder = CreateCheckoutSession::new()
325 .mode(CheckoutSessionMode::Payment)
326 .success_url(checkout.success_url.to_string())
327 .cancel_url(checkout.cancel_url.to_string())
328 .line_items(vec![build_inline_line_item(
329 checkout.item_title,
330 checkout.amount_cents.as_i64(),
331 checkout.currency,
332 )])
333 .adaptive_pricing(adaptive_pricing(checkout.conversion))
334 .metadata(metadata);
335 if let Some(tax) = automatic_tax(checkout.enable_stripe_tax) {
336 builder = builder.automatic_tax(tax);
337 }
338
339 self.send_on_connected_account(builder, checkout.connected_account_id, "checkout")
340 .await
341 }
342
343 /// Build a multi-line-item Checkout Session for a cart purchase.
344 #[tracing::instrument(skip_all, name = "payments::create_cart_checkout_session")]
345 pub async fn create_cart_checkout_session(
346 &self,
347 cart: &CartCheckoutParams<'_>,
348 ) -> Result<stripe_shared::CheckoutSession> {
349 let total_cents: i64 = cart.line_items.iter().map(|li| li.amount_cents).sum();
350 check_min_charge(total_cents, cart.currency)?;
351
352 let line_items: Vec<CreateCheckoutSessionLineItems> = cart
353 .line_items
354 .iter()
355 .map(|li| build_inline_line_item(li.title, li.amount_cents, cart.currency))
356 .collect();
357
358 let mut metadata = HashMap::new();
359 metadata.insert("checkout_type".to_string(), CheckoutType::Cart.to_string());
360 metadata.insert("buyer_id".to_string(), cart.buyer_id.to_string());
361 metadata.insert("seller_id".to_string(), cart.seller_id.to_string());
362
363 let mut builder = CreateCheckoutSession::new()
364 .mode(CheckoutSessionMode::Payment)
365 .success_url(cart.success_url.to_string())
366 .cancel_url(cart.cancel_url.to_string())
367 .line_items(line_items)
368 .adaptive_pricing(adaptive_pricing(cart.conversion))
369 .metadata(metadata);
370 if let Some(tax) = automatic_tax(cart.enable_stripe_tax) {
371 builder = builder.automatic_tax(tax);
372 }
373
374 self.send_on_connected_account(builder, cart.connected_account_id, "cart_checkout")
375 .await
376 }
377
378 /// Build a subscription Checkout Session on a connected account.
379 #[tracing::instrument(skip_all, name = "payments::create_subscription_checkout_session")]
380 pub async fn create_subscription_checkout_session(
381 &self,
382 sub: &SubscriptionCheckoutParams<'_>,
383 ) -> Result<stripe_shared::CheckoutSession> {
384 let mut metadata = HashMap::new();
385 metadata.insert("subscriber_id".to_string(), sub.subscriber_id.to_string());
386 metadata.insert("project_id".to_string(), sub.project_id.to_string());
387 metadata.insert("tier_id".to_string(), sub.tier_id.to_string());
388 metadata.insert(
389 "checkout_type".to_string(),
390 CheckoutType::Subscription.to_string(),
391 );
392 if let Some(pc_id) = sub.promo_code_id {
393 metadata.insert("promo_code_id".to_string(), pc_id.to_string());
394 }
395
396 let mut builder = CreateCheckoutSession::new()
397 .mode(CheckoutSessionMode::Subscription)
398 .success_url(sub.success_url.to_string())
399 .cancel_url(sub.cancel_url.to_string())
400 .line_items(vec![build_price_line_item(sub.stripe_price_id)])
401 .adaptive_pricing(adaptive_pricing(sub.conversion))
402 .metadata(metadata);
403 if let Some(tax) = automatic_tax(sub.enable_stripe_tax) {
404 builder = builder.automatic_tax(tax);
405 }
406
407 if let Some(days) = sub.trial_days {
408 let trial_days: u32 = days
409 .try_into()
410 .map_err(|_| AppError::BadRequest("Invalid trial period".to_string()))?;
411 builder = builder.subscription_data(CreateCheckoutSessionSubscriptionData {
412 trial_period_days: Some(trial_days),
413 ..CreateCheckoutSessionSubscriptionData::new()
414 });
415 }
416
417 self.send_on_connected_account(builder, sub.connected_account_id, "subscription_checkout")
418 .await
419 }
420
421 /// Build a Checkout Session for a tip to a creator.
422 #[tracing::instrument(skip_all, name = "payments::create_tip_checkout_session")]
423 pub async fn create_tip_checkout_session(
424 &self,
425 tip: &TipCheckoutParams<'_>,
426 ) -> Result<stripe_shared::CheckoutSession> {
427 let product_name = format!("Tip for {}", tip.recipient_display_name);
428
429 let mut metadata = HashMap::new();
430 metadata.insert("checkout_type".to_string(), CheckoutType::Tip.to_string());
431 metadata.insert("tipper_id".to_string(), tip.tipper_id.to_string());
432 metadata.insert("recipient_id".to_string(), tip.recipient_id.to_string());
433 if let Some(project_id) = tip.project_id {
434 metadata.insert("project_id".to_string(), project_id.to_string());
435 }
436 if let Some(msg) = tip.message {
437 metadata.insert("message".to_string(), msg.chars().take(500).collect());
438 }
439
440 let mut builder = CreateCheckoutSession::new()
441 .mode(CheckoutSessionMode::Payment)
442 .success_url(tip.success_url.to_string())
443 .cancel_url(tip.cancel_url.to_string())
444 .line_items(vec![build_inline_line_item(
445 &product_name,
446 tip.amount_cents.as_i64(),
447 tip.currency,
448 )])
449 .adaptive_pricing(adaptive_pricing(tip.conversion))
450 .metadata(metadata);
451
452 if let Some(tax) = automatic_tax(tip.enable_stripe_tax) {
453 builder = builder.automatic_tax(tax);
454 }
455
456 self.send_on_connected_account(builder, tip.connected_account_id, "tip_checkout")
457 .await
458 }
459
460 /// Build a Checkout Session for a Fan+ subscription on MNW's own Stripe account.
461 #[tracing::instrument(skip_all, name = "payments::create_fan_plus_checkout_session")]
462 pub async fn create_fan_plus_checkout_session(
463 &self,
464 price_id: &str,
465 user_id: UserId,
466 success_url: &str,
467 cancel_url: &str,
468 ) -> Result<stripe_shared::CheckoutSession> {
469 let mut metadata = HashMap::new();
470 metadata.insert(
471 "checkout_type".to_string(),
472 CheckoutType::FanPlus.to_string(),
473 );
474 metadata.insert("user_id".to_string(), user_id.to_string());
475
476 let builder = CreateCheckoutSession::new()
477 .mode(CheckoutSessionMode::Subscription)
478 .success_url(success_url.to_string())
479 .cancel_url(cancel_url.to_string())
480 .line_items(vec![build_price_line_item(price_id)])
481 .metadata(metadata);
482
483 self.send_on_platform(builder, "fan_plus_checkout").await
484 }
485
486 /// Build a Checkout Session for a creator tier subscription on MNW's own Stripe account.
487 #[tracing::instrument(skip_all, name = "payments::create_creator_tier_checkout_session")]
488 pub async fn create_creator_tier_checkout_session(
489 &self,
490 price_id: &str,
491 user_id: UserId,
492 tier: &str,
493 success_url: &str,
494 cancel_url: &str,
495 trial_days: Option<i32>,
496 ) -> Result<stripe_shared::CheckoutSession> {
497 let mut metadata = HashMap::new();
498 metadata.insert(
499 "checkout_type".to_string(),
500 CheckoutType::CreatorTier.to_string(),
501 );
502 metadata.insert("user_id".to_string(), user_id.to_string());
503 metadata.insert("tier".to_string(), tier.to_string());
504
505 let mut builder = CreateCheckoutSession::new()
506 .mode(CheckoutSessionMode::Subscription)
507 .success_url(success_url.to_string())
508 .cancel_url(cancel_url.to_string())
509 .line_items(vec![build_price_line_item(price_id)])
510 .metadata(metadata);
511
512 // A comp code grants a free trial: don't collect a card up front
513 // (`if_required` skips card collection when no charge is due yet), and
514 // delay the first charge by `trial_days`. With no payment method on
515 // file, the subscription lapses at trial end unless the creator
516 // adds one, continuing is an explicit opt-in, never a silent charge.
517 // The price stays the one chosen by the caller (founder price during
518 // the founder window), so opting in renews at that rate.
519 if let Some(days) = trial_days {
520 let days: u32 = days
521 .try_into()
522 .map_err(|_| AppError::BadRequest("Invalid trial period".to_string()))?;
523 builder = builder
524 .payment_method_collection(CreateCheckoutSessionPaymentMethodCollection::IfRequired)
525 .subscription_data(CreateCheckoutSessionSubscriptionData {
526 trial_period_days: Some(days),
527 ..CreateCheckoutSessionSubscriptionData::new()
528 });
529 }
530
531 self.send_on_platform(builder, "creator_tier_checkout")
532 .await
533 }
534
535 /// Build a Checkout Session for an end-user subscribing to an app's cloud
536 /// sync (SyncKit). Runs on MNW's own Stripe account. Uses inline
537 /// `price_data` so no Stripe Products/Prices need to be pre-configured,
538 /// the tier name and cents come from the `sync_app_tiers` row.
539 #[tracing::instrument(skip_all, name = "payments::create_synckit_app_sub_checkout_session")]
540 pub async fn create_synckit_app_sub_checkout_session(
541 &self,
542 p: &SynckitAppSubCheckoutParams<'_>,
543 ) -> Result<stripe_shared::CheckoutSession> {
544 use CreateCheckoutSessionLineItemsPriceDataRecurringInterval as Recurring;
545 let interval = match p.interval {
546 "monthly" => Recurring::Month,
547 "annual" => Recurring::Year,
548 other => return Err(AppError::BadRequest(format!("Invalid interval '{other}'"))),
549 };
550
551 let mut metadata = HashMap::new();
552 metadata.insert(
553 "checkout_type".to_string(),
554 CheckoutType::SynckitAppSub.to_string(),
555 );
556 metadata.insert("user_id".to_string(), p.user_id.to_string());
557 metadata.insert("app_id".to_string(), p.app_id.to_string());
558 metadata.insert("interval".to_string(), p.interval.to_string());
559 if let Some(bytes) = p.storage_limit_bytes {
560 metadata.insert("storage_limit_bytes".to_string(), bytes.to_string());
561 }
562
563 let line_item =
564 build_inline_recurring_line_item_usd(p.product_name, p.amount_cents, interval);
565
566 let builder = CreateCheckoutSession::new()
567 .mode(CheckoutSessionMode::Subscription)
568 .success_url(p.success_url.to_string())
569 .cancel_url(p.cancel_url.to_string())
570 .line_items(vec![line_item])
571 .metadata(metadata);
572
573 self.send_on_platform(builder, "synckit_app_sub_checkout")
574 .await
575 }
576 }
577
578 #[cfg(test)]
579 mod tests {
580 //! The shape of what we ask Stripe to charge. Everything here is pure and
581 //! sits directly on the money path: a wrong `unit_amount`, a missing
582 //! `quantity`, or a minimum-charge boundary off by one cent is a real
583 //! charge that is wrong, and none of it was covered.
584
585 use super::*;
586
587 // ── the Stripe per-transaction minimum ──
588
589 const USD: SettlementCurrency = SettlementCurrency::Usd;
590
591 #[test]
592 fn the_minimum_is_per_currency_and_gbp_is_lower() {
593 // GBP's floor is 30, not 50. Applying the USD floor to a British
594 // creator would refuse charges Stripe would have accepted.
595 assert!(check_min_charge(30, SettlementCurrency::Gbp).is_ok());
596 assert!(matches!(
597 check_min_charge(30, SettlementCurrency::Usd),
598 Err(AppError::BadRequest(_))
599 ));
600 }
601
602 #[test]
603 fn the_rejection_names_the_currency_it_refused_in() {
604 let Err(AppError::BadRequest(msg)) = check_min_charge(1, SettlementCurrency::Gbp) else {
605 panic!("1 penny should be rejected");
606 };
607 assert!(
608 msg.contains("\u{a3}0.30"),
609 "a GBP rejection must not quote a dollar minimum: {msg}"
610 );
611 }
612
613 #[test]
614 fn a_free_item_is_allowed_through() {
615 // $0 items are legitimate; callers gate them before they reach Stripe.
616 assert!(check_min_charge(0, USD).is_ok());
617 }
618
619 #[test]
620 fn the_minimum_itself_is_allowed_and_one_cent_under_is_not() {
621 let min = USD.minimum_charge_cents();
622 assert!(
623 check_min_charge(min, USD).is_ok(),
624 "the boundary is inclusive"
625 );
626 assert!(
627 matches!(check_min_charge(min - 1, USD), Err(AppError::BadRequest(_))),
628 "one cent under the minimum must be refused before Stripe refuses it"
629 );
630 assert!(check_min_charge(min + 1, USD).is_ok());
631 }
632
633 #[test]
634 fn the_rejection_names_the_minimum_in_dollars() {
635 // The message reaches a buyer, so it must not say "50".
636 let Err(AppError::BadRequest(msg)) = check_min_charge(1, USD) else {
637 panic!("1 cent should be rejected");
638 };
639 assert!(
640 msg.contains("$0.50"),
641 "buyer-facing message should format the minimum as currency: {msg}"
642 );
643 }
644
645 #[test]
646 fn a_negative_amount_is_not_rejected_here() {
647 // Documenting the current contract rather than endorsing it: the guard
648 // is `> 0 && < minimum`, so negatives pass. Every caller computes its
649 // amount from a price and a discount, and none is proven non-negative
650 // here. If a discount is ever allowed to exceed a price, this is the
651 // gate that will not catch it.
652 assert!(check_min_charge(-1, USD).is_ok());
653 }
654
655 // ── line items ──
656
657 #[test]
658 fn an_inline_line_item_charges_the_given_amount_once_in_usd() {
659 let item = build_inline_line_item("A Record", 2500, USD);
660 let price = item.price_data.expect("inline items carry price_data");
661 assert_eq!(price.unit_amount, Some(2500));
662 assert_eq!(price.currency, Currency::USD);
663 assert_eq!(
664 item.quantity,
665 Some(1),
666 "quantity must be pinned: None would let Stripe default and charge differently"
667 );
668 assert!(
669 item.price.is_none(),
670 "an inline item must not also reference a Stripe Price"
671 );
672 }
673
674 #[test]
675 fn a_price_line_item_references_stripe_and_sets_no_amount_of_its_own() {
676 let item = build_price_line_item("price_123");
677 assert_eq!(item.price.as_deref(), Some("price_123"));
678 assert_eq!(item.quantity, Some(1));
679 assert!(
680 item.price_data.is_none(),
681 "a Price-backed item that also carries price_data would charge the inline amount"
682 );
683 }
684
685 #[test]
686 fn a_recurring_item_carries_its_interval_and_a_recurring_price() {
687 let item = build_inline_recurring_line_item_usd(
688 "SyncKit Pro",
689 900,
690 CreateCheckoutSessionLineItemsPriceDataRecurringInterval::Month,
691 );
692 let price = item.price_data.expect("recurring items carry price_data");
693 assert_eq!(price.unit_amount, Some(900));
694 assert_eq!(price.currency, Currency::USD);
695 assert!(
696 price.recurring.is_some(),
697 "without `recurring` Stripe bills this once instead of every period"
698 );
699 assert_eq!(item.quantity, Some(1));
700 }
701
702 // ── automatic tax ──
703
704 #[test]
705 fn automatic_tax_is_absent_rather_than_disabled_when_off() {
706 assert!(
707 automatic_tax(false).is_none(),
708 "sending an explicit disabled block is not the same as omitting it"
709 );
710 assert!(automatic_tax(true).is_some());
711 }
712 }
713