Skip to main content

max / makenotwork

28.9 KB · 737 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 impl StripeClient {
240 async fn send_on_connected_account(
241 &self,
242 builder: CreateCheckoutSession,
243 connected_account_id: &str,
244 log_label: &str,
245 ) -> Result<stripe_shared::CheckoutSession> {
246 let account_id = Self::parse_account_id(connected_account_id)?;
247 builder
248 .customize()
249 .account_id(account_id)
250 .send(&self.client)
251 .await
252 .map_err(|e| {
253 tracing::error!(error = ?e, label = %log_label, "failed to create checkout session");
254 AppError::BadRequest("Failed to create checkout session".to_string())
255 })
256 }
257
258 async fn send_on_platform(
259 &self,
260 builder: CreateCheckoutSession,
261 log_label: &str,
262 ) -> Result<stripe_shared::CheckoutSession> {
263 builder.send(&self.client).await.map_err(|e| {
264 tracing::error!(error = ?e, label = %log_label, "failed to create checkout session");
265 AppError::BadRequest("Failed to create checkout session".to_string())
266 })
267 }
268
269 /// Build a one-time payment checkout session for a guest purchase.
270 #[tracing::instrument(skip_all, name = "payments::create_guest_checkout_session")]
271 pub async fn create_guest_checkout_session(
272 &self,
273 checkout: &GuestCheckoutParams<'_>,
274 ) -> 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
300 self.send_on_connected_account(builder, checkout.connected_account_id, "guest_checkout")
301 .await
302 }
303
304 /// Build a one-time payment checkout session for a purchase by a logged-in user.
305 #[tracing::instrument(skip_all, name = "payments::create_checkout_session")]
306 pub async fn create_checkout_session(
307 &self,
308 checkout: &CheckoutParams<'_>,
309 ) -> 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
337 self.send_on_connected_account(builder, checkout.connected_account_id, "checkout")
338 .await
339 }
340
341 /// Build a multi-line-item Checkout Session for a cart purchase.
342 #[tracing::instrument(skip_all, name = "payments::create_cart_checkout_session")]
343 pub async fn create_cart_checkout_session(
344 &self,
345 cart: &CartCheckoutParams<'_>,
346 ) -> 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
372 self.send_on_connected_account(builder, cart.connected_account_id, "cart_checkout")
373 .await
374 }
375
376 /// Build a subscription Checkout Session on a connected account.
377 #[tracing::instrument(skip_all, name = "payments::create_subscription_checkout_session")]
378 pub async fn create_subscription_checkout_session(
379 &self,
380 sub: &SubscriptionCheckoutParams<'_>,
381 ) -> 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
415 self.send_on_connected_account(builder, sub.connected_account_id, "subscription_checkout")
416 .await
417 }
418
419 /// Build a Checkout Session for a tip to a creator.
420 #[tracing::instrument(skip_all, name = "payments::create_tip_checkout_session")]
421 pub async fn create_tip_checkout_session(
422 &self,
423 tip: &TipCheckoutParams<'_>,
424 ) -> 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
454 self.send_on_connected_account(builder, tip.connected_account_id, "tip_checkout")
455 .await
456 }
457
458 /// Build a Checkout Session for a Fan+ subscription on MNW's own Stripe account.
459 #[tracing::instrument(skip_all, name = "payments::create_fan_plus_checkout_session")]
460 pub async fn create_fan_plus_checkout_session(
461 &self,
462 price_id: &str,
463 user_id: UserId,
464 success_url: &str,
465 cancel_url: &str,
466 ) -> 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
481 self.send_on_platform(builder, "fan_plus_checkout").await
482 }
483
484 /// Build a Checkout Session for a creator tier subscription on MNW's own Stripe account.
485 #[tracing::instrument(skip_all, name = "payments::create_creator_tier_checkout_session")]
486 pub async fn create_creator_tier_checkout_session(
487 &self,
488 price_id: &str,
489 user_id: UserId,
490 tier: &str,
491 success_url: &str,
492 cancel_url: &str,
493 trial_days: Option<i32>,
494 ) -> 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 });
527 }
528
529 self.send_on_platform(builder, "creator_tier_checkout")
530 .await
531 }
532
533 /// Build a Checkout Session for an end-user subscribing to an app's cloud
534 /// sync (SyncKit). Runs on MNW's own Stripe account. Uses inline
535 /// `price_data` so no Stripe Products/Prices need to be pre-configured,
536 /// the tier name and cents come from the `sync_app_tiers` row.
537 #[tracing::instrument(skip_all, name = "payments::create_synckit_app_sub_checkout_session")]
538 pub async fn create_synckit_app_sub_checkout_session(
539 &self,
540 p: &SynckitAppSubCheckoutParams<'_>,
541 ) -> Result<stripe_shared::CheckoutSession> {
542 use CreateCheckoutSessionLineItemsPriceDataRecurringInterval as Recurring;
543 let interval = match p.interval {
544 "monthly" => Recurring::Month,
545 "annual" => Recurring::Year,
546 other => return Err(AppError::BadRequest(format!("Invalid interval '{other}'"))),
547 };
548
549 let mut metadata = HashMap::new();
550 metadata.insert(
551 "checkout_type".to_string(),
552 CheckoutType::SynckitAppSub.to_string(),
553 );
554 metadata.insert("user_id".to_string(), p.user_id.to_string());
555 metadata.insert("app_id".to_string(), p.app_id.to_string());
556 metadata.insert("interval".to_string(), p.interval.to_string());
557 if let Some(bytes) = p.storage_limit_bytes {
558 metadata.insert("storage_limit_bytes".to_string(), bytes.to_string());
559 }
560
561 let line_item =
562 build_inline_recurring_line_item_usd(p.product_name, p.amount_cents, interval);
563
564 let builder = CreateCheckoutSession::new()
565 .mode(CheckoutSessionMode::Subscription)
566 .success_url(p.success_url.to_string())
567 .cancel_url(p.cancel_url.to_string())
568 .line_items(vec![line_item])
569 .metadata(metadata);
570
571 self.send_on_platform(builder, "synckit_app_sub_checkout")
572 .await
573 }
574 }
575
576 #[cfg(test)]
577 mod tests {
578 //! The shape of what we ask Stripe to charge. Everything here is pure and
579 //! sits directly on the money path: a wrong `unit_amount`, a missing
580 //! `quantity`, or a minimum-charge boundary off by one cent is a real
581 //! charge that is wrong, and none of it was covered.
582
583 use super::*;
584
585 // ── the Stripe per-transaction minimum ──
586
587 const USD: SettlementCurrency = SettlementCurrency::Usd;
588
589 #[test]
590 fn the_minimum_is_per_currency_and_gbp_is_lower() {
591 // GBP's floor is 30, not 50. Applying the USD floor to a British
592 // creator would refuse charges Stripe would have accepted.
593 assert!(check_min_charge(30, SettlementCurrency::Gbp).is_ok());
594 assert!(matches!(
595 check_min_charge(30, SettlementCurrency::Usd),
596 Err(AppError::BadRequest(_))
597 ));
598 }
599
600 #[test]
601 fn the_rejection_names_the_currency_it_refused_in() {
602 let Err(AppError::BadRequest(msg)) = check_min_charge(1, SettlementCurrency::Gbp) else {
603 panic!("1 penny should be rejected");
604 };
605 assert!(
606 msg.contains("\u{a3}0.30"),
607 "a GBP rejection must not quote a dollar minimum: {msg}"
608 );
609 }
610
611 #[test]
612 fn a_free_item_is_allowed_through() {
613 // $0 items are legitimate; callers gate them before they reach Stripe.
614 assert!(check_min_charge(0, USD).is_ok());
615 }
616
617 #[test]
618 fn the_minimum_itself_is_allowed_and_one_cent_under_is_not() {
619 let min = USD.minimum_charge_cents();
620 assert!(
621 check_min_charge(min, USD).is_ok(),
622 "the boundary is inclusive"
623 );
624 assert!(
625 matches!(check_min_charge(min - 1, USD), Err(AppError::BadRequest(_))),
626 "one cent under the minimum must be refused before Stripe refuses it"
627 );
628 assert!(check_min_charge(min + 1, USD).is_ok());
629 }
630
631 #[test]
632 fn the_rejection_names_the_minimum_in_dollars() {
633 // The message reaches a buyer, so it must not say "50".
634 let Err(AppError::BadRequest(msg)) = check_min_charge(1, USD) else {
635 panic!("1 cent should be rejected");
636 };
637 assert!(
638 msg.contains("$0.50"),
639 "buyer-facing message should format the minimum as currency: {msg}"
640 );
641 }
642
643 #[test]
644 fn a_negative_amount_is_not_rejected_here() {
645 // Documenting the current contract rather than endorsing it: the guard
646 // is `> 0 && < minimum`, so negatives pass. Every caller computes its
647 // amount from a price and a discount, and none is proven non-negative
648 // here. If a discount is ever allowed to exceed a price, this is the
649 // gate that will not catch it.
650 assert!(check_min_charge(-1, USD).is_ok());
651 }
652
653 // ── line items ──
654
655 #[test]
656 fn an_inline_line_item_charges_the_given_amount_once_in_usd() {
657 let item = build_inline_line_item("A Record", 2500, USD);
658 let price = item.price_data.expect("inline items carry price_data");
659 assert_eq!(price.unit_amount, Some(2500));
660 assert_eq!(price.currency, Currency::USD);
661 assert_eq!(
662 item.quantity,
663 Some(1),
664 "quantity must be pinned: None would let Stripe default and charge differently"
665 );
666 assert!(
667 item.price.is_none(),
668 "an inline item must not also reference a Stripe Price"
669 );
670 assert_eq!(
671 price.product_data.map(|p| p.name).as_deref(),
672 Some("A Record"),
673 "without product_data the buyer sees an unnamed line on the Stripe page"
674 );
675 }
676
677 #[test]
678 fn a_price_line_item_references_stripe_and_sets_no_amount_of_its_own() {
679 let item = build_price_line_item("price_123");
680 assert_eq!(item.price.as_deref(), Some("price_123"));
681 assert_eq!(item.quantity, Some(1));
682 assert!(
683 item.price_data.is_none(),
684 "a Price-backed item that also carries price_data would charge the inline amount"
685 );
686 }
687
688 #[test]
689 fn a_recurring_item_carries_its_interval_and_a_recurring_price() {
690 let item = build_inline_recurring_line_item_usd(
691 "SyncKit Pro",
692 900,
693 CreateCheckoutSessionLineItemsPriceDataRecurringInterval::Month,
694 );
695 let price = item.price_data.expect("recurring items carry price_data");
696 assert_eq!(price.unit_amount, Some(900));
697 assert_eq!(price.currency, Currency::USD);
698 assert!(
699 price.recurring.is_some(),
700 "without `recurring` Stripe bills this once instead of every period"
701 );
702 assert_eq!(item.quantity, Some(1));
703 assert_eq!(
704 price.product_data.map(|p| p.name).as_deref(),
705 Some("SyncKit Pro")
706 );
707 }
708
709 // ── adaptive pricing ──
710
711 #[test]
712 fn adaptive_pricing_states_the_buyers_choice_rather_than_omitting_it() {
713 // `None` is not a neutral value: it hands the decision to the Connect
714 // dashboard setting, which is the failure the always-send rule exists
715 // to prevent.
716 assert_eq!(
717 adaptive_pricing(ConversionChoice::AtCheckout).enabled,
718 Some(true)
719 );
720 assert_eq!(
721 adaptive_pricing(ConversionChoice::ByBuyersBank).enabled,
722 Some(false)
723 );
724 }
725
726 // ── automatic tax ──
727
728 #[test]
729 fn automatic_tax_is_absent_rather_than_disabled_when_off() {
730 assert!(
731 automatic_tax(false).is_none(),
732 "sending an explicit disabled block is not the same as omitting it"
733 );
734 assert!(automatic_tax(true).is_some());
735 }
736 }
737