Skip to main content

max / makenotwork

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