Skip to main content

max / makenotwork

17.2 KB · 434 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 }
81
82 /// Parameters for creating a guest (no-account) purchase Checkout Session.
83 pub struct GuestCheckoutParams<'a> {
84 pub connected_account_id: &'a str,
85 pub item_title: &'a str,
86 pub amount_cents: Cents,
87 pub seller_id: UserId,
88 pub item_id: ItemId,
89 pub success_url: &'a str,
90 pub cancel_url: &'a str,
91 pub promo_code_id: Option<PromoCodeId>,
92 pub enable_stripe_tax: bool,
93 }
94
95 fn check_min_charge(amount_cents: i64) -> Result<()> {
96 if amount_cents > 0 && amount_cents < constants::STRIPE_MINIMUM_CHARGE_CENTS {
97 return Err(AppError::BadRequest(format!(
98 "Minimum purchase amount is ${:.2}",
99 constants::STRIPE_MINIMUM_CHARGE_CENTS as f64 / 100.0
100 )));
101 }
102 Ok(())
103 }
104
105 fn build_inline_line_item(title: &str, amount_cents: i64) -> CreateCheckoutSessionLineItems {
106 CreateCheckoutSessionLineItems {
107 price_data: Some(CreateCheckoutSessionLineItemsPriceData {
108 currency: Currency::USD,
109 product_data: Some(ProductData::new(title.to_string())),
110 unit_amount: Some(amount_cents),
111 ..CreateCheckoutSessionLineItemsPriceData::new(Currency::USD)
112 }),
113 quantity: Some(1),
114 ..CreateCheckoutSessionLineItems::new()
115 }
116 }
117
118 fn build_price_line_item(price_id: &str) -> CreateCheckoutSessionLineItems {
119 CreateCheckoutSessionLineItems {
120 price: Some(price_id.to_string()),
121 quantity: Some(1),
122 ..CreateCheckoutSessionLineItems::new()
123 }
124 }
125
126 /// Build an inline recurring line item — used by SyncKit app subscriptions
127 /// so we don't have to pre-provision Stripe Products and Prices for every
128 /// (app, tier, interval) combination.
129 fn build_inline_recurring_line_item(
130 product_name: &str,
131 amount_cents: i64,
132 interval: CreateCheckoutSessionLineItemsPriceDataRecurringInterval,
133 ) -> CreateCheckoutSessionLineItems {
134 CreateCheckoutSessionLineItems {
135 price_data: Some(CreateCheckoutSessionLineItemsPriceData {
136 currency: Currency::USD,
137 product_data: Some(ProductData::new(product_name.to_string())),
138 unit_amount: Some(amount_cents),
139 recurring: Some(CreateCheckoutSessionLineItemsPriceDataRecurring::new(interval)),
140 ..CreateCheckoutSessionLineItemsPriceData::new(Currency::USD)
141 }),
142 quantity: Some(1),
143 ..CreateCheckoutSessionLineItems::new()
144 }
145 }
146
147 fn automatic_tax(enable: bool) -> Option<CreateCheckoutSessionAutomaticTax> {
148 if enable {
149 Some(CreateCheckoutSessionAutomaticTax::new(true))
150 } else {
151 None
152 }
153 }
154
155 impl StripeClient {
156 async fn send_on_connected_account(
157 &self,
158 builder: CreateCheckoutSession,
159 connected_account_id: &str,
160 log_label: &str,
161 ) -> Result<stripe_shared::CheckoutSession> {
162 let account_id = Self::parse_account_id(connected_account_id)?;
163 builder
164 .customize()
165 .account_id(account_id)
166 .send(&self.client)
167 .await
168 .map_err(|e| {
169 tracing::error!(error = ?e, label = %log_label, "failed to create checkout session");
170 AppError::BadRequest("Failed to create checkout session".to_string())
171 })
172 }
173
174 async fn send_on_platform(
175 &self,
176 builder: CreateCheckoutSession,
177 log_label: &str,
178 ) -> Result<stripe_shared::CheckoutSession> {
179 builder
180 .send(&self.client)
181 .await
182 .map_err(|e| {
183 tracing::error!(error = ?e, label = %log_label, "failed to create checkout session");
184 AppError::BadRequest("Failed to create checkout session".to_string())
185 })
186 }
187
188 /// Build a one-time payment checkout session for a guest purchase.
189 #[tracing::instrument(skip_all, name = "payments::create_guest_checkout_session")]
190 pub async fn create_guest_checkout_session(
191 &self,
192 checkout: &GuestCheckoutParams<'_>,
193 ) -> Result<stripe_shared::CheckoutSession> {
194 check_min_charge(checkout.amount_cents.as_i64())?;
195
196 let mut metadata = HashMap::new();
197 metadata.insert("checkout_type".to_string(), CheckoutType::Guest.to_string());
198 metadata.insert("seller_id".to_string(), checkout.seller_id.to_string());
199 metadata.insert("item_id".to_string(), checkout.item_id.to_string());
200 if let Some(pc_id) = checkout.promo_code_id {
201 metadata.insert("promo_code_id".to_string(), pc_id.to_string());
202 }
203
204 let mut builder = CreateCheckoutSession::new()
205 .mode(CheckoutSessionMode::Payment)
206 .success_url(checkout.success_url.to_string())
207 .cancel_url(checkout.cancel_url.to_string())
208 .line_items(vec![build_inline_line_item(checkout.item_title, checkout.amount_cents.as_i64())])
209 .metadata(metadata);
210 if let Some(tax) = automatic_tax(checkout.enable_stripe_tax) {
211 builder = builder.automatic_tax(tax);
212 }
213
214 self.send_on_connected_account(builder, checkout.connected_account_id, "guest_checkout").await
215 }
216
217 /// Build a one-time payment checkout session for a purchase by a logged-in user.
218 #[tracing::instrument(skip_all, name = "payments::create_checkout_session")]
219 pub async fn create_checkout_session(
220 &self,
221 checkout: &CheckoutParams<'_>,
222 ) -> Result<stripe_shared::CheckoutSession> {
223 check_min_charge(checkout.amount_cents.as_i64())?;
224
225 let mut metadata = HashMap::new();
226 metadata.insert("buyer_id".to_string(), checkout.buyer_id.to_string());
227 metadata.insert("seller_id".to_string(), checkout.seller_id.to_string());
228 if let Some(item_id) = checkout.item_id {
229 metadata.insert("item_id".to_string(), item_id.to_string());
230 }
231 if let Some(pc_id) = checkout.promo_code_id {
232 metadata.insert("promo_code_id".to_string(), pc_id.to_string());
233 }
234
235 let mut builder = CreateCheckoutSession::new()
236 .mode(CheckoutSessionMode::Payment)
237 .success_url(checkout.success_url.to_string())
238 .cancel_url(checkout.cancel_url.to_string())
239 .line_items(vec![build_inline_line_item(checkout.item_title, checkout.amount_cents.as_i64())])
240 .metadata(metadata);
241 if let Some(tax) = automatic_tax(checkout.enable_stripe_tax) {
242 builder = builder.automatic_tax(tax);
243 }
244
245 self.send_on_connected_account(builder, checkout.connected_account_id, "checkout").await
246 }
247
248 /// Build a multi-line-item Checkout Session for a cart purchase.
249 #[tracing::instrument(skip_all, name = "payments::create_cart_checkout_session")]
250 pub async fn create_cart_checkout_session(
251 &self,
252 cart: &CartCheckoutParams<'_>,
253 ) -> Result<stripe_shared::CheckoutSession> {
254 let total_cents: i64 = cart.line_items.iter().map(|li| li.amount_cents).sum();
255 check_min_charge(total_cents)?;
256
257 let line_items: Vec<CreateCheckoutSessionLineItems> = cart
258 .line_items
259 .iter()
260 .map(|li| build_inline_line_item(li.title, li.amount_cents))
261 .collect();
262
263 let mut metadata = HashMap::new();
264 metadata.insert("checkout_type".to_string(), CheckoutType::Cart.to_string());
265 metadata.insert("buyer_id".to_string(), cart.buyer_id.to_string());
266 metadata.insert("seller_id".to_string(), cart.seller_id.to_string());
267
268 let mut builder = CreateCheckoutSession::new()
269 .mode(CheckoutSessionMode::Payment)
270 .success_url(cart.success_url.to_string())
271 .cancel_url(cart.cancel_url.to_string())
272 .line_items(line_items)
273 .metadata(metadata);
274 if let Some(tax) = automatic_tax(cart.enable_stripe_tax) {
275 builder = builder.automatic_tax(tax);
276 }
277
278 self.send_on_connected_account(builder, cart.connected_account_id, "cart_checkout").await
279 }
280
281 /// Build a subscription Checkout Session on a connected account.
282 #[tracing::instrument(skip_all, name = "payments::create_subscription_checkout_session")]
283 pub async fn create_subscription_checkout_session(
284 &self,
285 sub: &SubscriptionCheckoutParams<'_>,
286 ) -> Result<stripe_shared::CheckoutSession> {
287 let mut metadata = HashMap::new();
288 metadata.insert("subscriber_id".to_string(), sub.subscriber_id.to_string());
289 metadata.insert("project_id".to_string(), sub.project_id.to_string());
290 metadata.insert("tier_id".to_string(), sub.tier_id.to_string());
291 metadata.insert("checkout_type".to_string(), CheckoutType::Subscription.to_string());
292 if let Some(pc_id) = sub.promo_code_id {
293 metadata.insert("promo_code_id".to_string(), pc_id.to_string());
294 }
295
296 let mut builder = CreateCheckoutSession::new()
297 .mode(CheckoutSessionMode::Subscription)
298 .success_url(sub.success_url.to_string())
299 .cancel_url(sub.cancel_url.to_string())
300 .line_items(vec![build_price_line_item(sub.stripe_price_id)])
301 .metadata(metadata);
302 if let Some(tax) = automatic_tax(sub.enable_stripe_tax) {
303 builder = builder.automatic_tax(tax);
304 }
305
306 if let Some(days) = sub.trial_days {
307 let trial_days: u32 = days.try_into().map_err(|_| {
308 AppError::BadRequest("Invalid trial period".to_string())
309 })?;
310 builder = builder.subscription_data(CreateCheckoutSessionSubscriptionData {
311 trial_period_days: Some(trial_days),
312 ..CreateCheckoutSessionSubscriptionData::new()
313 });
314 }
315
316 self.send_on_connected_account(builder, sub.connected_account_id, "subscription_checkout").await
317 }
318
319 /// Build a Checkout Session for a tip to a creator.
320 #[tracing::instrument(skip_all, name = "payments::create_tip_checkout_session")]
321 pub async fn create_tip_checkout_session(
322 &self,
323 tip: &TipCheckoutParams<'_>,
324 ) -> Result<stripe_shared::CheckoutSession> {
325 let product_name = format!("Tip for {}", tip.recipient_display_name);
326
327 let mut metadata = HashMap::new();
328 metadata.insert("checkout_type".to_string(), CheckoutType::Tip.to_string());
329 metadata.insert("tipper_id".to_string(), tip.tipper_id.to_string());
330 metadata.insert("recipient_id".to_string(), tip.recipient_id.to_string());
331 if let Some(project_id) = tip.project_id {
332 metadata.insert("project_id".to_string(), project_id.to_string());
333 }
334 if let Some(msg) = tip.message {
335 metadata.insert("message".to_string(), msg.chars().take(500).collect());
336 }
337
338 let builder = CreateCheckoutSession::new()
339 .mode(CheckoutSessionMode::Payment)
340 .success_url(tip.success_url.to_string())
341 .cancel_url(tip.cancel_url.to_string())
342 .line_items(vec![build_inline_line_item(&product_name, tip.amount_cents.as_i64())])
343 .metadata(metadata);
344
345 self.send_on_connected_account(builder, tip.connected_account_id, "tip_checkout").await
346 }
347
348 /// Build a Checkout Session for a Fan+ subscription on MNW's own Stripe account.
349 #[tracing::instrument(skip_all, name = "payments::create_fan_plus_checkout_session")]
350 pub async fn create_fan_plus_checkout_session(
351 &self,
352 price_id: &str,
353 user_id: UserId,
354 success_url: &str,
355 cancel_url: &str,
356 ) -> Result<stripe_shared::CheckoutSession> {
357 let mut metadata = HashMap::new();
358 metadata.insert("checkout_type".to_string(), CheckoutType::FanPlus.to_string());
359 metadata.insert("user_id".to_string(), user_id.to_string());
360
361 let builder = CreateCheckoutSession::new()
362 .mode(CheckoutSessionMode::Subscription)
363 .success_url(success_url.to_string())
364 .cancel_url(cancel_url.to_string())
365 .line_items(vec![build_price_line_item(price_id)])
366 .metadata(metadata);
367
368 self.send_on_platform(builder, "fan_plus_checkout").await
369 }
370
371 /// Build a Checkout Session for a creator tier subscription on MNW's own Stripe account.
372 #[tracing::instrument(skip_all, name = "payments::create_creator_tier_checkout_session")]
373 pub async fn create_creator_tier_checkout_session(
374 &self,
375 price_id: &str,
376 user_id: UserId,
377 tier: &str,
378 success_url: &str,
379 cancel_url: &str,
380 ) -> Result<stripe_shared::CheckoutSession> {
381 let mut metadata = HashMap::new();
382 metadata.insert("checkout_type".to_string(), CheckoutType::CreatorTier.to_string());
383 metadata.insert("user_id".to_string(), user_id.to_string());
384 metadata.insert("tier".to_string(), tier.to_string());
385
386 let builder = CreateCheckoutSession::new()
387 .mode(CheckoutSessionMode::Subscription)
388 .success_url(success_url.to_string())
389 .cancel_url(cancel_url.to_string())
390 .line_items(vec![build_price_line_item(price_id)])
391 .metadata(metadata);
392
393 self.send_on_platform(builder, "creator_tier_checkout").await
394 }
395
396 /// Build a Checkout Session for an end-user subscribing to an app's cloud
397 /// sync (SyncKit). Runs on MNW's own Stripe account. Uses inline
398 /// `price_data` so no Stripe Products/Prices need to be pre-configured —
399 /// the tier name and cents come from the `sync_app_tiers` row.
400 #[tracing::instrument(skip_all, name = "payments::create_synckit_app_sub_checkout_session")]
401 pub async fn create_synckit_app_sub_checkout_session(
402 &self,
403 product_name: &str,
404 amount_cents: i64,
405 interval: CreateCheckoutSessionLineItemsPriceDataRecurringInterval,
406 user_id: UserId,
407 app_id: SyncAppId,
408 tier: &str,
409 storage_limit_bytes: Option<i64>,
410 success_url: &str,
411 cancel_url: &str,
412 ) -> Result<stripe_shared::CheckoutSession> {
413 let mut metadata = HashMap::new();
414 metadata.insert("checkout_type".to_string(), CheckoutType::SynckitAppSub.to_string());
415 metadata.insert("user_id".to_string(), user_id.to_string());
416 metadata.insert("app_id".to_string(), app_id.to_string());
417 metadata.insert("tier".to_string(), tier.to_string());
418 if let Some(bytes) = storage_limit_bytes {
419 metadata.insert("storage_limit_bytes".to_string(), bytes.to_string());
420 }
421
422 let line_item = build_inline_recurring_line_item(product_name, amount_cents, interval);
423
424 let builder = CreateCheckoutSession::new()
425 .mode(CheckoutSessionMode::Subscription)
426 .success_url(success_url.to_string())
427 .cancel_url(cancel_url.to_string())
428 .line_items(vec![line_item])
429 .metadata(metadata);
430
431 self.send_on_platform(builder, "synckit_app_sub_checkout").await
432 }
433 }
434