Skip to main content

max / makenotwork

20.5 KB · 634 lines History Blame Raw
1 //! Stripe payment processing via Connect Direct Charges.
2 //!
3 //! Wraps the Stripe API for one-time purchases and recurring subscriptions
4 //! using the Direct Charges pattern: payments are created directly on the
5 //! creator's connected Stripe account with no `application_fee_amount`,
6 //! enforcing MakeNotWork's 0% platform fee promise. The only deduction
7 //! creators see is Stripe's own processing fee (~3%).
8 //!
9 //! Key responsibilities:
10 //! - Standard connected account creation and onboarding links
11 //! - One-time purchase and subscription Checkout Session creation
12 //! - Webhook signature verification (v1 and v2 thin events)
13 //! - Event extraction helpers for checkout, subscription, invoice, account,
14 //! and refund webhook events
15 //! - Subscription product and price creation on connected accounts
16
17 mod checkout;
18 mod checkout_metadata;
19 mod connect;
20 pub mod fan_ops;
21 pub mod synckit_app_pricing;
22 pub mod synckit_billing;
23 mod webhooks;
24
25 pub use checkout::*;
26 pub use checkout_metadata::*;
27 pub use synckit_app_pricing::{
28 ANNUAL_MULTIPLIER, MAX_CAP_BYTES, MIN_CAP_BYTES, MIN_CHARGE_CENTS, SyncBillingInterval,
29 quote_price_cents,
30 };
31 pub use synckit_billing::SynckitSubResult;
32 pub use webhooks::*;
33
34 use std::time::Duration;
35
36 use crate::config::StripeConfig;
37 use stripe::{Client, ClientBuilder};
38
39 /// Per-attempt HTTP timeout for outbound Stripe calls. The async client has no
40 /// timeout by default, so a hung connection would otherwise stall the caller
41 /// indefinitely, on a webhook handler that holds the response open and invites
42 /// Stripe's retry storm. 30s matches async-stripe's own blocking-client default;
43 /// the request strategy still retries a timed-out attempt where permitted.
44 const STRIPE_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
45
46 /// Stripe client wrapper for payment operations
47 #[derive(Clone)]
48 pub struct StripeClient {
49 pub(crate) client: Client,
50 pub(crate) config: StripeConfig,
51 }
52
53 impl StripeClient {
54 /// Create a new Stripe client from configuration.
55 ///
56 /// Fallible because the builder validates the client config; a build error is
57 /// an internal invariant violation (the secret key comes from validated
58 /// config), so it is classified `Internal` and surfaces at boot.
59 pub fn new(config: &StripeConfig) -> Result<Self> {
60 let client = ClientBuilder::new(&config.secret_key)
61 .timeout(STRIPE_HTTP_TIMEOUT)
62 .build()
63 .map_err(|e| {
64 AppError::Internal(anyhow::anyhow!("failed to build Stripe client: {e}"))
65 })?;
66 Ok(StripeClient {
67 client,
68 config: config.clone(),
69 })
70 }
71
72 /// Parse a connected account ID string into an `AccountId`.
73 ///
74 /// Account IDs are read from our own DB (`users.stripe_account_id`), so a
75 /// parse failure is an internal invariant violation rather than bad user
76 /// input, classify it `Internal` and keep the underlying error for ops.
77 pub(crate) fn parse_account_id(account_id: &str) -> Result<stripe_shared::AccountId> {
78 account_id.parse().map_err(|e| {
79 AppError::Internal(anyhow::anyhow!(
80 "Invalid Stripe account ID '{account_id}': {e}"
81 ))
82 })
83 }
84 }
85
86 use crate::error::{AppError, Result};
87
88 /// Simplified checkout result: what handlers need from Stripe sessions.
89 pub struct CheckoutResult {
90 pub id: String,
91 pub url: Option<String>,
92 }
93
94 /// Simplified balance: what handlers need from Stripe balance.
95 pub struct BalanceSummary {
96 pub available_cents: i64,
97 pub pending_cents: i64,
98 }
99
100 /// Payment provider abstraction for checkout, connect, and webhook operations.
101 #[async_trait::async_trait]
102 pub trait PaymentProvider: Send + Sync {
103 // Checkout
104 async fn create_checkout_session(
105 &self,
106 params: &CheckoutParams<'_>,
107 ) -> crate::error::Result<CheckoutResult>;
108 async fn create_guest_checkout_session(
109 &self,
110 params: &GuestCheckoutParams<'_>,
111 ) -> crate::error::Result<CheckoutResult>;
112 async fn create_subscription_checkout_session(
113 &self,
114 params: &SubscriptionCheckoutParams<'_>,
115 ) -> crate::error::Result<CheckoutResult>;
116 async fn create_tip_checkout_session(
117 &self,
118 params: &TipCheckoutParams<'_>,
119 ) -> crate::error::Result<CheckoutResult>;
120 async fn create_fan_plus_checkout_session(
121 &self,
122 price_id: &str,
123 user_id: crate::db::UserId,
124 success_url: &str,
125 cancel_url: &str,
126 ) -> crate::error::Result<CheckoutResult>;
127 async fn create_creator_tier_checkout_session(
128 &self,
129 price_id: &str,
130 user_id: crate::db::UserId,
131 tier: &str,
132 success_url: &str,
133 cancel_url: &str,
134 trial_days: Option<i32>,
135 ) -> crate::error::Result<CheckoutResult>;
136 async fn create_synckit_app_sub_checkout_session(
137 &self,
138 params: &SynckitAppSubCheckoutParams<'_>,
139 ) -> crate::error::Result<CheckoutResult>;
140 async fn create_cart_checkout_session(
141 &self,
142 params: &CartCheckoutParams<'_>,
143 ) -> crate::error::Result<CheckoutResult>;
144
145 // Connect
146 async fn create_connect_account(
147 &self,
148 email: &str,
149 ) -> crate::error::Result<crate::db::StripeAccountId>;
150 async fn create_account_link(
151 &self,
152 account_id: &str,
153 return_url: &str,
154 refresh_url: &str,
155 ) -> crate::error::Result<String>;
156 async fn fetch_account(&self, account_id: &str) -> crate::error::Result<AccountUpdate>;
157 async fn create_subscription_product_and_price(
158 &self,
159 connected_account_id: &str,
160 tier_name: &str,
161 tier_description: Option<&str>,
162 price_cents: i64,
163 ) -> crate::error::Result<(String, String)>;
164 async fn get_balance(&self, account_id: &str) -> crate::error::Result<BalanceSummary>;
165
166 // Subscription lifecycle
167 async fn pause_subscription(
168 &self,
169 stripe_sub_id: &str,
170 connected_account_id: &str,
171 ) -> crate::error::Result<()>;
172 async fn resume_subscription(
173 &self,
174 stripe_sub_id: &str,
175 connected_account_id: &str,
176 ) -> crate::error::Result<()>;
177 async fn cancel_subscription(
178 &self,
179 stripe_sub_id: &str,
180 connected_account_id: &str,
181 ) -> crate::error::Result<()>;
182 /// Set or clear `cancel_at_period_end` on a fan subscription (for creator pause/resume).
183 async fn set_cancel_at_period_end(
184 &self,
185 stripe_sub_id: &str,
186 connected_account_id: &str,
187 cancel: bool,
188 ) -> crate::error::Result<()>;
189 /// Cancel a platform-level subscription (creator tier, Fan+). Not on a connected account.
190 async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> crate::error::Result<()>;
191 /// Set or clear `cancel_at_period_end` on a platform subscription (Fan+, creator tier).
192 async fn set_platform_cancel_at_period_end(
193 &self,
194 stripe_sub_id: &str,
195 cancel: bool,
196 ) -> crate::error::Result<()>;
197 /// Create a Stripe-hosted billing portal session. Returns the URL to redirect to.
198 async fn create_billing_portal_session(
199 &self,
200 stripe_customer_id: &str,
201 return_url: &str,
202 ) -> crate::error::Result<String>;
203
204 // Refunds, line-scoped: refunds `amount_cents` of the shared PaymentIntent
205 // and tags the refund with the transaction id so the refund.created webhook
206 // marks/revokes exactly that line (cart orders share one PaymentIntent).
207 async fn create_refund_for_transaction(
208 &self,
209 payment_intent_id: &str,
210 connected_account_id: &str,
211 amount_cents: i64,
212 transaction_id: crate::db::TransactionId,
213 ) -> crate::error::Result<()>;
214
215 // Platform-funded credit reimbursement, a platform -> connected transfer that
216 // makes the creator whole for a Fan+ credit applied to their sale (MNW funds it).
217 // Deterministic idempotency key keeps replays/retries from double-paying.
218 // Returns the transfer id so it can be reversed if the sale is refunded.
219 async fn create_platform_credit_transfer(
220 &self,
221 connected_account_id: &str,
222 amount_cents: i64,
223 transaction_id: crate::db::TransactionId,
224 ) -> crate::error::Result<String>;
225
226 // Reverse a settled platform-credit transfer when its sale is refunded,
227 // clawing the reimbursement back from the connected account to MNW.
228 // Deterministic idempotency key keeps replays/retries from clawing back twice.
229 async fn create_platform_credit_reversal(
230 &self,
231 transfer_id: &str,
232 amount_cents: i64,
233 transaction_id: crate::db::TransactionId,
234 ) -> crate::error::Result<()>;
235
236 // Webhooks
237 fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<UntypedEvent>;
238 fn verify_webhook_v2(
239 &self,
240 payload: &str,
241 signature: &str,
242 ) -> crate::error::Result<serde_json::Value>;
243
244 // SyncKit v2 developer billing, one customer + subscription per app,
245 // separate from creator-tier and Fan+ subscriptions. See
246 // `synckit_billing.rs` for the rationale on per-app customers.
247 async fn create_synckit_customer(
248 &self,
249 developer_user_id: crate::db::UserId,
250 app_id: crate::db::SyncAppId,
251 email: &str,
252 app_name: &str,
253 ) -> crate::error::Result<String>;
254 async fn create_synckit_subscription(
255 &self,
256 customer_id: &str,
257 app_id: crate::db::SyncAppId,
258 app_name: &str,
259 price_cents: i64,
260 ) -> crate::error::Result<SynckitSubResult>;
261 async fn update_synckit_subscription_price(
262 &self,
263 subscription_id: &str,
264 new_price_cents: i64,
265 app_name: &str,
266 ) -> crate::error::Result<()>;
267 /// Re-price an end-user SyncKit app subscription. Used by the cap-change
268 /// path; takes effect at next billing cycle (no proration).
269 async fn update_synckit_app_sub_price(
270 &self,
271 subscription_id: &str,
272 new_price_cents: i64,
273 interval: SyncBillingInterval,
274 product_name: &str,
275 ) -> crate::error::Result<()>;
276 async fn cancel_synckit_subscription(&self, subscription_id: &str) -> crate::error::Result<()>;
277 async fn create_synckit_billing_portal(
278 &self,
279 customer_id: &str,
280 return_url: &str,
281 ) -> crate::error::Result<String>;
282 }
283
284 #[async_trait::async_trait]
285 impl PaymentProvider for StripeClient {
286 async fn create_checkout_session(
287 &self,
288 params: &CheckoutParams<'_>,
289 ) -> crate::error::Result<CheckoutResult> {
290 let session = StripeClient::create_checkout_session(self, params).await?;
291 Ok(CheckoutResult {
292 id: session.id.to_string(),
293 url: session.url,
294 })
295 }
296
297 async fn create_guest_checkout_session(
298 &self,
299 params: &GuestCheckoutParams<'_>,
300 ) -> crate::error::Result<CheckoutResult> {
301 let session = StripeClient::create_guest_checkout_session(self, params).await?;
302 Ok(CheckoutResult {
303 id: session.id.to_string(),
304 url: session.url,
305 })
306 }
307
308 async fn create_subscription_checkout_session(
309 &self,
310 params: &SubscriptionCheckoutParams<'_>,
311 ) -> crate::error::Result<CheckoutResult> {
312 let session = StripeClient::create_subscription_checkout_session(self, params).await?;
313 Ok(CheckoutResult {
314 id: session.id.to_string(),
315 url: session.url,
316 })
317 }
318
319 async fn create_tip_checkout_session(
320 &self,
321 params: &TipCheckoutParams<'_>,
322 ) -> crate::error::Result<CheckoutResult> {
323 let session = StripeClient::create_tip_checkout_session(self, params).await?;
324 Ok(CheckoutResult {
325 id: session.id.to_string(),
326 url: session.url,
327 })
328 }
329
330 async fn create_fan_plus_checkout_session(
331 &self,
332 price_id: &str,
333 user_id: crate::db::UserId,
334 success_url: &str,
335 cancel_url: &str,
336 ) -> crate::error::Result<CheckoutResult> {
337 let session = StripeClient::create_fan_plus_checkout_session(
338 self,
339 price_id,
340 user_id,
341 success_url,
342 cancel_url,
343 )
344 .await?;
345 Ok(CheckoutResult {
346 id: session.id.to_string(),
347 url: session.url,
348 })
349 }
350
351 async fn create_creator_tier_checkout_session(
352 &self,
353 price_id: &str,
354 user_id: crate::db::UserId,
355 tier: &str,
356 success_url: &str,
357 cancel_url: &str,
358 trial_days: Option<i32>,
359 ) -> crate::error::Result<CheckoutResult> {
360 let session = StripeClient::create_creator_tier_checkout_session(
361 self,
362 price_id,
363 user_id,
364 tier,
365 success_url,
366 cancel_url,
367 trial_days,
368 )
369 .await?;
370 Ok(CheckoutResult {
371 id: session.id.to_string(),
372 url: session.url,
373 })
374 }
375
376 async fn create_synckit_app_sub_checkout_session(
377 &self,
378 params: &SynckitAppSubCheckoutParams<'_>,
379 ) -> crate::error::Result<CheckoutResult> {
380 let session = StripeClient::create_synckit_app_sub_checkout_session(self, params).await?;
381 Ok(CheckoutResult {
382 id: session.id.to_string(),
383 url: session.url,
384 })
385 }
386
387 async fn create_cart_checkout_session(
388 &self,
389 params: &CartCheckoutParams<'_>,
390 ) -> crate::error::Result<CheckoutResult> {
391 let session = StripeClient::create_cart_checkout_session(self, params).await?;
392 Ok(CheckoutResult {
393 id: session.id.to_string(),
394 url: session.url,
395 })
396 }
397
398 async fn create_connect_account(
399 &self,
400 email: &str,
401 ) -> crate::error::Result<crate::db::StripeAccountId> {
402 StripeClient::create_connect_account(self, email).await
403 }
404
405 async fn create_account_link(
406 &self,
407 account_id: &str,
408 return_url: &str,
409 refresh_url: &str,
410 ) -> crate::error::Result<String> {
411 StripeClient::create_account_link(self, account_id, return_url, refresh_url).await
412 }
413
414 async fn fetch_account(&self, account_id: &str) -> crate::error::Result<AccountUpdate> {
415 StripeClient::fetch_account(self, account_id).await
416 }
417
418 async fn create_subscription_product_and_price(
419 &self,
420 connected_account_id: &str,
421 tier_name: &str,
422 tier_description: Option<&str>,
423 price_cents: i64,
424 ) -> crate::error::Result<(String, String)> {
425 StripeClient::create_subscription_product_and_price(
426 self,
427 connected_account_id,
428 tier_name,
429 tier_description,
430 price_cents,
431 )
432 .await
433 }
434
435 async fn get_balance(&self, account_id: &str) -> crate::error::Result<BalanceSummary> {
436 let balance = self.get_connected_account_balance(account_id).await?;
437 let available_cents: i64 = balance
438 .available
439 .iter()
440 .filter(|b| b.currency == stripe_types::Currency::USD)
441 .map(|b| b.amount)
442 .sum();
443 let pending_cents: i64 = balance
444 .pending
445 .iter()
446 .filter(|b| b.currency == stripe_types::Currency::USD)
447 .map(|b| b.amount)
448 .sum();
449 Ok(BalanceSummary {
450 available_cents,
451 pending_cents,
452 })
453 }
454
455 async fn pause_subscription(
456 &self,
457 stripe_sub_id: &str,
458 connected_account_id: &str,
459 ) -> crate::error::Result<()> {
460 StripeClient::pause_subscription(self, stripe_sub_id, connected_account_id).await
461 }
462
463 async fn resume_subscription(
464 &self,
465 stripe_sub_id: &str,
466 connected_account_id: &str,
467 ) -> crate::error::Result<()> {
468 StripeClient::resume_subscription(self, stripe_sub_id, connected_account_id).await
469 }
470
471 async fn cancel_subscription(
472 &self,
473 stripe_sub_id: &str,
474 connected_account_id: &str,
475 ) -> crate::error::Result<()> {
476 StripeClient::cancel_subscription(self, stripe_sub_id, connected_account_id).await
477 }
478
479 async fn set_cancel_at_period_end(
480 &self,
481 stripe_sub_id: &str,
482 connected_account_id: &str,
483 cancel: bool,
484 ) -> crate::error::Result<()> {
485 StripeClient::set_cancel_at_period_end(self, stripe_sub_id, connected_account_id, cancel)
486 .await
487 }
488
489 async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> crate::error::Result<()> {
490 StripeClient::cancel_platform_subscription(self, stripe_sub_id).await
491 }
492
493 async fn set_platform_cancel_at_period_end(
494 &self,
495 stripe_sub_id: &str,
496 cancel: bool,
497 ) -> crate::error::Result<()> {
498 StripeClient::set_platform_cancel_at_period_end(self, stripe_sub_id, cancel).await
499 }
500
501 async fn create_billing_portal_session(
502 &self,
503 stripe_customer_id: &str,
504 return_url: &str,
505 ) -> crate::error::Result<String> {
506 StripeClient::create_billing_portal_session(self, stripe_customer_id, return_url).await
507 }
508
509 async fn create_refund_for_transaction(
510 &self,
511 payment_intent_id: &str,
512 connected_account_id: &str,
513 amount_cents: i64,
514 transaction_id: crate::db::TransactionId,
515 ) -> crate::error::Result<()> {
516 StripeClient::create_refund_for_transaction(
517 self,
518 payment_intent_id,
519 connected_account_id,
520 amount_cents,
521 transaction_id,
522 )
523 .await
524 }
525
526 async fn create_platform_credit_transfer(
527 &self,
528 connected_account_id: &str,
529 amount_cents: i64,
530 transaction_id: crate::db::TransactionId,
531 ) -> crate::error::Result<String> {
532 StripeClient::create_platform_credit_transfer(
533 self,
534 connected_account_id,
535 amount_cents,
536 transaction_id,
537 )
538 .await
539 }
540
541 async fn create_platform_credit_reversal(
542 &self,
543 transfer_id: &str,
544 amount_cents: i64,
545 transaction_id: crate::db::TransactionId,
546 ) -> crate::error::Result<()> {
547 StripeClient::create_platform_credit_reversal(
548 self,
549 transfer_id,
550 amount_cents,
551 transaction_id,
552 )
553 .await
554 }
555
556 fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<UntypedEvent> {
557 StripeClient::verify_webhook(self, payload, signature)
558 }
559
560 fn verify_webhook_v2(
561 &self,
562 payload: &str,
563 signature: &str,
564 ) -> crate::error::Result<serde_json::Value> {
565 StripeClient::verify_webhook_v2(self, payload, signature)
566 }
567
568 async fn create_synckit_customer(
569 &self,
570 developer_user_id: crate::db::UserId,
571 app_id: crate::db::SyncAppId,
572 email: &str,
573 app_name: &str,
574 ) -> crate::error::Result<String> {
575 StripeClient::create_synckit_customer(self, developer_user_id, app_id, email, app_name)
576 .await
577 }
578
579 async fn create_synckit_subscription(
580 &self,
581 customer_id: &str,
582 app_id: crate::db::SyncAppId,
583 app_name: &str,
584 price_cents: i64,
585 ) -> crate::error::Result<SynckitSubResult> {
586 StripeClient::create_synckit_subscription(self, customer_id, app_id, app_name, price_cents)
587 .await
588 }
589
590 async fn update_synckit_subscription_price(
591 &self,
592 subscription_id: &str,
593 new_price_cents: i64,
594 app_name: &str,
595 ) -> crate::error::Result<()> {
596 StripeClient::update_synckit_subscription_price(
597 self,
598 subscription_id,
599 new_price_cents,
600 app_name,
601 )
602 .await
603 }
604
605 async fn update_synckit_app_sub_price(
606 &self,
607 subscription_id: &str,
608 new_price_cents: i64,
609 interval: SyncBillingInterval,
610 product_name: &str,
611 ) -> crate::error::Result<()> {
612 StripeClient::update_synckit_app_sub_price(
613 self,
614 subscription_id,
615 new_price_cents,
616 interval,
617 product_name,
618 )
619 .await
620 }
621
622 async fn cancel_synckit_subscription(&self, subscription_id: &str) -> crate::error::Result<()> {
623 StripeClient::cancel_synckit_subscription(self, subscription_id).await
624 }
625
626 async fn create_synckit_billing_portal(
627 &self,
628 customer_id: &str,
629 return_url: &str,
630 ) -> crate::error::Result<String> {
631 StripeClient::create_synckit_billing_portal(self, customer_id, return_url).await
632 }
633 }
634