Skip to main content

max / makenotwork

19.9 KB · 508 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 use std::collections::HashMap;
7
8 use stripe::StripeRequest;
9 use stripe_checkout::checkout_session::{
10 CreateCheckoutSession, CreateCheckoutSessionAutomaticTax, CreateCheckoutSessionLineItems,
11 CreateCheckoutSessionLineItemsPriceData, CreateCheckoutSessionLineItemsPriceDataRecurring,
12 CreateCheckoutSessionLineItemsPriceDataRecurringInterval,
13 CreateCheckoutSessionPaymentMethodCollection, CreateCheckoutSessionSubscriptionData,
14 ProductData,
15 };
16 use stripe_shared::CheckoutSessionMode;
17 use stripe_types::Currency;
18
19 use super::StripeClient;
20 use crate::constants;
21 use crate::db::{
22 Cents, CheckoutType, ItemId, ProjectId, PromoCodeId, SubscriptionTierId, SyncAppId, UserId,
23 };
24 use crate::error::{AppError, Result};
25
26 /// Parameters for creating a one-time purchase Checkout Session.
27 pub struct CheckoutParams<'a> {
28 pub connected_account_id: &'a str,
29 pub item_title: &'a str,
30 pub amount_cents: Cents,
31 pub buyer_id: UserId,
32 pub seller_id: UserId,
33 /// `None` for project-level purchases (no specific item).
34 pub item_id: Option<ItemId>,
35 pub success_url: &'a str,
36 pub cancel_url: &'a str,
37 pub promo_code_id: Option<PromoCodeId>,
38 pub enable_stripe_tax: bool,
39 }
40
41 /// A single line item in a cart checkout.
42 pub struct CartLineItem<'a> {
43 pub title: &'a str,
44 pub amount_cents: i64,
45 }
46
47 /// Parameters for creating a multi-item cart Checkout Session.
48 pub struct CartCheckoutParams<'a> {
49 pub connected_account_id: &'a str,
50 pub line_items: &'a [CartLineItem<'a>],
51 pub buyer_id: UserId,
52 pub seller_id: UserId,
53 pub success_url: &'a str,
54 pub cancel_url: &'a str,
55 pub enable_stripe_tax: bool,
56 }
57
58 /// Parameters for creating a subscription Checkout Session.
59 pub struct SubscriptionCheckoutParams<'a> {
60 pub connected_account_id: &'a str,
61 pub stripe_price_id: &'a str,
62 pub subscriber_id: UserId,
63 pub project_id: ProjectId,
64 pub tier_id: SubscriptionTierId,
65 pub success_url: &'a str,
66 pub cancel_url: &'a str,
67 pub trial_days: Option<i32>,
68 pub promo_code_id: Option<PromoCodeId>,
69 pub enable_stripe_tax: bool,
70 }
71
72 /// Parameters for creating a tip Checkout Session.
73 pub struct TipCheckoutParams<'a> {
74 pub connected_account_id: &'a str,
75 pub recipient_display_name: &'a str,
76 pub amount_cents: Cents,
77 pub tipper_id: UserId,
78 pub recipient_id: UserId,
79 pub project_id: Option<ProjectId>,
80 pub message: Option<&'a str>,
81 pub success_url: &'a str,
82 pub cancel_url: &'a str,
83 pub enable_stripe_tax: bool,
84 }
85
86 /// Parameters for creating a guest (no-account) purchase Checkout Session.
87 pub struct GuestCheckoutParams<'a> {
88 pub connected_account_id: &'a str,
89 pub item_title: &'a str,
90 pub amount_cents: Cents,
91 pub seller_id: UserId,
92 pub item_id: ItemId,
93 pub success_url: &'a str,
94 pub cancel_url: &'a str,
95 pub promo_code_id: Option<PromoCodeId>,
96 pub enable_stripe_tax: bool,
97 }
98
99 /// Parameters for a SyncKit developer app-subscription Checkout Session. The
100 /// price (`amount_cents` / `interval`) rides inline via `price_data`, so no
101 /// Stripe Product/Price needs pre-configuring. `interval` is `"monthly"` or
102 /// `"annual"`.
103 pub struct SynckitAppSubCheckoutParams<'a> {
104 pub product_name: &'a str,
105 pub amount_cents: i64,
106 pub interval: &'a str,
107 pub user_id: UserId,
108 pub app_id: SyncAppId,
109 pub storage_limit_bytes: Option<i64>,
110 pub success_url: &'a str,
111 pub cancel_url: &'a str,
112 }
113
114 /// Reject a charge below Stripe's per-transaction minimum (Stripe hard-rejects
115 /// sub-minimum amounts with an unfriendly error). Free ($0) items are allowed;
116 /// callers gate those separately. Shared by the Stripe session builders here and
117 /// by the checkout routes, which call it before reserving a promo so a rejected
118 /// sub-minimum checkout doesn't burn a use of the code.
119 pub(crate) fn check_min_charge(amount_cents: i64) -> Result<()> {
120 if amount_cents > 0 && amount_cents < constants::STRIPE_MINIMUM_CHARGE_CENTS {
121 return Err(AppError::BadRequest(format!(
122 "Minimum purchase amount is {}",
123 crate::formatting::format_revenue(constants::STRIPE_MINIMUM_CHARGE_CENTS)
124 )));
125 }
126 Ok(())
127 }
128
129 fn build_inline_line_item(title: &str, amount_cents: i64) -> CreateCheckoutSessionLineItems {
130 CreateCheckoutSessionLineItems {
131 price_data: Some(CreateCheckoutSessionLineItemsPriceData {
132 currency: Currency::USD,
133 product_data: Some(ProductData::new(title.to_string())),
134 unit_amount: Some(amount_cents),
135 ..CreateCheckoutSessionLineItemsPriceData::new(Currency::USD)
136 }),
137 quantity: Some(1),
138 ..CreateCheckoutSessionLineItems::new()
139 }
140 }
141
142 fn build_price_line_item(price_id: &str) -> CreateCheckoutSessionLineItems {
143 CreateCheckoutSessionLineItems {
144 price: Some(price_id.to_string()),
145 quantity: Some(1),
146 ..CreateCheckoutSessionLineItems::new()
147 }
148 }
149
150 /// Build an inline recurring line item, used by SyncKit app subscriptions
151 /// so we don't have to pre-provision Stripe Products and Prices for every
152 /// (app, tier, interval) combination.
153 fn build_inline_recurring_line_item(
154 product_name: &str,
155 amount_cents: i64,
156 interval: CreateCheckoutSessionLineItemsPriceDataRecurringInterval,
157 ) -> CreateCheckoutSessionLineItems {
158 CreateCheckoutSessionLineItems {
159 price_data: Some(CreateCheckoutSessionLineItemsPriceData {
160 currency: Currency::USD,
161 product_data: Some(ProductData::new(product_name.to_string())),
162 unit_amount: Some(amount_cents),
163 recurring: Some(CreateCheckoutSessionLineItemsPriceDataRecurring::new(
164 interval,
165 )),
166 ..CreateCheckoutSessionLineItemsPriceData::new(Currency::USD)
167 }),
168 quantity: Some(1),
169 ..CreateCheckoutSessionLineItems::new()
170 }
171 }
172
173 fn automatic_tax(enable: bool) -> Option<CreateCheckoutSessionAutomaticTax> {
174 if enable {
175 Some(CreateCheckoutSessionAutomaticTax::new(true))
176 } else {
177 None
178 }
179 }
180
181 impl StripeClient {
182 async fn send_on_connected_account(
183 &self,
184 builder: CreateCheckoutSession,
185 connected_account_id: &str,
186 log_label: &str,
187 ) -> Result<stripe_shared::CheckoutSession> {
188 let account_id = Self::parse_account_id(connected_account_id)?;
189 builder
190 .customize()
191 .account_id(account_id)
192 .send(&self.client)
193 .await
194 .map_err(|e| {
195 tracing::error!(error = ?e, label = %log_label, "failed to create checkout session");
196 AppError::BadRequest("Failed to create checkout session".to_string())
197 })
198 }
199
200 async fn send_on_platform(
201 &self,
202 builder: CreateCheckoutSession,
203 log_label: &str,
204 ) -> Result<stripe_shared::CheckoutSession> {
205 builder.send(&self.client).await.map_err(|e| {
206 tracing::error!(error = ?e, label = %log_label, "failed to create checkout session");
207 AppError::BadRequest("Failed to create checkout session".to_string())
208 })
209 }
210
211 /// Build a one-time payment checkout session for a guest purchase.
212 #[tracing::instrument(skip_all, name = "payments::create_guest_checkout_session")]
213 pub async fn create_guest_checkout_session(
214 &self,
215 checkout: &GuestCheckoutParams<'_>,
216 ) -> Result<stripe_shared::CheckoutSession> {
217 check_min_charge(checkout.amount_cents.as_i64())?;
218
219 let mut metadata = HashMap::new();
220 metadata.insert("checkout_type".to_string(), CheckoutType::Guest.to_string());
221 metadata.insert("seller_id".to_string(), checkout.seller_id.to_string());
222 metadata.insert("item_id".to_string(), checkout.item_id.to_string());
223 if let Some(pc_id) = checkout.promo_code_id {
224 metadata.insert("promo_code_id".to_string(), pc_id.to_string());
225 }
226
227 let mut builder = CreateCheckoutSession::new()
228 .mode(CheckoutSessionMode::Payment)
229 .success_url(checkout.success_url.to_string())
230 .cancel_url(checkout.cancel_url.to_string())
231 .line_items(vec![build_inline_line_item(
232 checkout.item_title,
233 checkout.amount_cents.as_i64(),
234 )])
235 .metadata(metadata);
236 if let Some(tax) = automatic_tax(checkout.enable_stripe_tax) {
237 builder = builder.automatic_tax(tax);
238 }
239
240 self.send_on_connected_account(builder, checkout.connected_account_id, "guest_checkout")
241 .await
242 }
243
244 /// Build a one-time payment checkout session for a purchase by a logged-in user.
245 #[tracing::instrument(skip_all, name = "payments::create_checkout_session")]
246 pub async fn create_checkout_session(
247 &self,
248 checkout: &CheckoutParams<'_>,
249 ) -> Result<stripe_shared::CheckoutSession> {
250 check_min_charge(checkout.amount_cents.as_i64())?;
251
252 let mut metadata = HashMap::new();
253 metadata.insert("buyer_id".to_string(), checkout.buyer_id.to_string());
254 metadata.insert("seller_id".to_string(), checkout.seller_id.to_string());
255 if let Some(item_id) = checkout.item_id {
256 metadata.insert("item_id".to_string(), item_id.to_string());
257 }
258 if let Some(pc_id) = checkout.promo_code_id {
259 metadata.insert("promo_code_id".to_string(), pc_id.to_string());
260 }
261
262 let mut builder = CreateCheckoutSession::new()
263 .mode(CheckoutSessionMode::Payment)
264 .success_url(checkout.success_url.to_string())
265 .cancel_url(checkout.cancel_url.to_string())
266 .line_items(vec![build_inline_line_item(
267 checkout.item_title,
268 checkout.amount_cents.as_i64(),
269 )])
270 .metadata(metadata);
271 if let Some(tax) = automatic_tax(checkout.enable_stripe_tax) {
272 builder = builder.automatic_tax(tax);
273 }
274
275 self.send_on_connected_account(builder, checkout.connected_account_id, "checkout")
276 .await
277 }
278
279 /// Build a multi-line-item Checkout Session for a cart purchase.
280 #[tracing::instrument(skip_all, name = "payments::create_cart_checkout_session")]
281 pub async fn create_cart_checkout_session(
282 &self,
283 cart: &CartCheckoutParams<'_>,
284 ) -> Result<stripe_shared::CheckoutSession> {
285 let total_cents: i64 = cart.line_items.iter().map(|li| li.amount_cents).sum();
286 check_min_charge(total_cents)?;
287
288 let line_items: Vec<CreateCheckoutSessionLineItems> = cart
289 .line_items
290 .iter()
291 .map(|li| build_inline_line_item(li.title, li.amount_cents))
292 .collect();
293
294 let mut metadata = HashMap::new();
295 metadata.insert("checkout_type".to_string(), CheckoutType::Cart.to_string());
296 metadata.insert("buyer_id".to_string(), cart.buyer_id.to_string());
297 metadata.insert("seller_id".to_string(), cart.seller_id.to_string());
298
299 let mut builder = CreateCheckoutSession::new()
300 .mode(CheckoutSessionMode::Payment)
301 .success_url(cart.success_url.to_string())
302 .cancel_url(cart.cancel_url.to_string())
303 .line_items(line_items)
304 .metadata(metadata);
305 if let Some(tax) = automatic_tax(cart.enable_stripe_tax) {
306 builder = builder.automatic_tax(tax);
307 }
308
309 self.send_on_connected_account(builder, cart.connected_account_id, "cart_checkout")
310 .await
311 }
312
313 /// Build a subscription Checkout Session on a connected account.
314 #[tracing::instrument(skip_all, name = "payments::create_subscription_checkout_session")]
315 pub async fn create_subscription_checkout_session(
316 &self,
317 sub: &SubscriptionCheckoutParams<'_>,
318 ) -> Result<stripe_shared::CheckoutSession> {
319 let mut metadata = HashMap::new();
320 metadata.insert("subscriber_id".to_string(), sub.subscriber_id.to_string());
321 metadata.insert("project_id".to_string(), sub.project_id.to_string());
322 metadata.insert("tier_id".to_string(), sub.tier_id.to_string());
323 metadata.insert(
324 "checkout_type".to_string(),
325 CheckoutType::Subscription.to_string(),
326 );
327 if let Some(pc_id) = sub.promo_code_id {
328 metadata.insert("promo_code_id".to_string(), pc_id.to_string());
329 }
330
331 let mut builder = CreateCheckoutSession::new()
332 .mode(CheckoutSessionMode::Subscription)
333 .success_url(sub.success_url.to_string())
334 .cancel_url(sub.cancel_url.to_string())
335 .line_items(vec![build_price_line_item(sub.stripe_price_id)])
336 .metadata(metadata);
337 if let Some(tax) = automatic_tax(sub.enable_stripe_tax) {
338 builder = builder.automatic_tax(tax);
339 }
340
341 if let Some(days) = sub.trial_days {
342 let trial_days: u32 = days
343 .try_into()
344 .map_err(|_| AppError::BadRequest("Invalid trial period".to_string()))?;
345 builder = builder.subscription_data(CreateCheckoutSessionSubscriptionData {
346 trial_period_days: Some(trial_days),
347 ..CreateCheckoutSessionSubscriptionData::new()
348 });
349 }
350
351 self.send_on_connected_account(builder, sub.connected_account_id, "subscription_checkout")
352 .await
353 }
354
355 /// Build a Checkout Session for a tip to a creator.
356 #[tracing::instrument(skip_all, name = "payments::create_tip_checkout_session")]
357 pub async fn create_tip_checkout_session(
358 &self,
359 tip: &TipCheckoutParams<'_>,
360 ) -> Result<stripe_shared::CheckoutSession> {
361 let product_name = format!("Tip for {}", tip.recipient_display_name);
362
363 let mut metadata = HashMap::new();
364 metadata.insert("checkout_type".to_string(), CheckoutType::Tip.to_string());
365 metadata.insert("tipper_id".to_string(), tip.tipper_id.to_string());
366 metadata.insert("recipient_id".to_string(), tip.recipient_id.to_string());
367 if let Some(project_id) = tip.project_id {
368 metadata.insert("project_id".to_string(), project_id.to_string());
369 }
370 if let Some(msg) = tip.message {
371 metadata.insert("message".to_string(), msg.chars().take(500).collect());
372 }
373
374 let mut builder = CreateCheckoutSession::new()
375 .mode(CheckoutSessionMode::Payment)
376 .success_url(tip.success_url.to_string())
377 .cancel_url(tip.cancel_url.to_string())
378 .line_items(vec![build_inline_line_item(
379 &product_name,
380 tip.amount_cents.as_i64(),
381 )])
382 .metadata(metadata);
383
384 if let Some(tax) = automatic_tax(tip.enable_stripe_tax) {
385 builder = builder.automatic_tax(tax);
386 }
387
388 self.send_on_connected_account(builder, tip.connected_account_id, "tip_checkout")
389 .await
390 }
391
392 /// Build a Checkout Session for a Fan+ subscription on MNW's own Stripe account.
393 #[tracing::instrument(skip_all, name = "payments::create_fan_plus_checkout_session")]
394 pub async fn create_fan_plus_checkout_session(
395 &self,
396 price_id: &str,
397 user_id: UserId,
398 success_url: &str,
399 cancel_url: &str,
400 ) -> Result<stripe_shared::CheckoutSession> {
401 let mut metadata = HashMap::new();
402 metadata.insert(
403 "checkout_type".to_string(),
404 CheckoutType::FanPlus.to_string(),
405 );
406 metadata.insert("user_id".to_string(), user_id.to_string());
407
408 let builder = CreateCheckoutSession::new()
409 .mode(CheckoutSessionMode::Subscription)
410 .success_url(success_url.to_string())
411 .cancel_url(cancel_url.to_string())
412 .line_items(vec![build_price_line_item(price_id)])
413 .metadata(metadata);
414
415 self.send_on_platform(builder, "fan_plus_checkout").await
416 }
417
418 /// Build a Checkout Session for a creator tier subscription on MNW's own Stripe account.
419 #[tracing::instrument(skip_all, name = "payments::create_creator_tier_checkout_session")]
420 pub async fn create_creator_tier_checkout_session(
421 &self,
422 price_id: &str,
423 user_id: UserId,
424 tier: &str,
425 success_url: &str,
426 cancel_url: &str,
427 trial_days: Option<i32>,
428 ) -> Result<stripe_shared::CheckoutSession> {
429 let mut metadata = HashMap::new();
430 metadata.insert(
431 "checkout_type".to_string(),
432 CheckoutType::CreatorTier.to_string(),
433 );
434 metadata.insert("user_id".to_string(), user_id.to_string());
435 metadata.insert("tier".to_string(), tier.to_string());
436
437 let mut builder = CreateCheckoutSession::new()
438 .mode(CheckoutSessionMode::Subscription)
439 .success_url(success_url.to_string())
440 .cancel_url(cancel_url.to_string())
441 .line_items(vec![build_price_line_item(price_id)])
442 .metadata(metadata);
443
444 // A comp code grants a free trial: don't collect a card up front
445 // (`if_required` skips card collection when no charge is due yet), and
446 // delay the first charge by `trial_days`. With no payment method on
447 // file, the subscription lapses at trial end unless the creator
448 // adds one, continuing is an explicit opt-in, never a silent charge.
449 // The price stays the one chosen by the caller (founder price during
450 // the founder window), so opting in renews at that rate.
451 if let Some(days) = trial_days {
452 let days: u32 = days
453 .try_into()
454 .map_err(|_| AppError::BadRequest("Invalid trial period".to_string()))?;
455 builder = builder
456 .payment_method_collection(CreateCheckoutSessionPaymentMethodCollection::IfRequired)
457 .subscription_data(CreateCheckoutSessionSubscriptionData {
458 trial_period_days: Some(days),
459 ..CreateCheckoutSessionSubscriptionData::new()
460 });
461 }
462
463 self.send_on_platform(builder, "creator_tier_checkout")
464 .await
465 }
466
467 /// Build a Checkout Session for an end-user subscribing to an app's cloud
468 /// sync (SyncKit). Runs on MNW's own Stripe account. Uses inline
469 /// `price_data` so no Stripe Products/Prices need to be pre-configured,
470 /// the tier name and cents come from the `sync_app_tiers` row.
471 #[tracing::instrument(skip_all, name = "payments::create_synckit_app_sub_checkout_session")]
472 pub async fn create_synckit_app_sub_checkout_session(
473 &self,
474 p: &SynckitAppSubCheckoutParams<'_>,
475 ) -> Result<stripe_shared::CheckoutSession> {
476 use CreateCheckoutSessionLineItemsPriceDataRecurringInterval as Recurring;
477 let interval = match p.interval {
478 "monthly" => Recurring::Month,
479 "annual" => Recurring::Year,
480 other => return Err(AppError::BadRequest(format!("Invalid interval '{other}'"))),
481 };
482
483 let mut metadata = HashMap::new();
484 metadata.insert(
485 "checkout_type".to_string(),
486 CheckoutType::SynckitAppSub.to_string(),
487 );
488 metadata.insert("user_id".to_string(), p.user_id.to_string());
489 metadata.insert("app_id".to_string(), p.app_id.to_string());
490 metadata.insert("interval".to_string(), p.interval.to_string());
491 if let Some(bytes) = p.storage_limit_bytes {
492 metadata.insert("storage_limit_bytes".to_string(), bytes.to_string());
493 }
494
495 let line_item = build_inline_recurring_line_item(p.product_name, p.amount_cents, interval);
496
497 let builder = CreateCheckoutSession::new()
498 .mode(CheckoutSessionMode::Subscription)
499 .success_url(p.success_url.to_string())
500 .cancel_url(p.cancel_url.to_string())
501 .line_items(vec![line_item])
502 .metadata(metadata);
503
504 self.send_on_platform(builder, "synckit_app_sub_checkout")
505 .await
506 }
507 }
508