Skip to main content

max / makenotwork

22.8 KB · 689 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 // `build()` constructs the rustls connector, which reads the
61 // process-wide provider and panics if none is installed. Doing it here
62 // rather than relying on a caller means no construction order can get
63 // this wrong; the call is idempotent.
64 crate::crypto::install_default_crypto_provider();
65
66 let client = ClientBuilder::new(&config.secret_key)
67 .timeout(STRIPE_HTTP_TIMEOUT)
68 .build()
69 .map_err(|e| {
70 AppError::Internal(anyhow::anyhow!("failed to build Stripe client: {e}"))
71 })?;
72 Ok(StripeClient {
73 client,
74 config: config.clone(),
75 })
76 }
77
78 /// Parse a connected account ID string into an `AccountId`.
79 ///
80 /// Account IDs are read from our own DB (`users.stripe_account_id`), so a
81 /// parse failure is an internal invariant violation rather than bad user
82 /// input, classify it `Internal` and keep the underlying error for ops.
83 pub(crate) fn parse_account_id(account_id: &str) -> Result<stripe_shared::AccountId> {
84 account_id.parse().map_err(|e| {
85 AppError::Internal(anyhow::anyhow!(
86 "Invalid Stripe account ID '{account_id}': {e}"
87 ))
88 })
89 }
90 }
91
92 use crate::error::{AppError, Result};
93
94 /// Simplified checkout result: what handlers need from Stripe sessions.
95 pub struct CheckoutResult {
96 pub id: String,
97 pub url: Option<String>,
98 }
99
100 /// Simplified balance: what handlers need from Stripe balance.
101 pub struct BalanceSummary {
102 pub available_cents: i64,
103 pub pending_cents: i64,
104 }
105
106 /// Payment provider abstraction for checkout, connect, and webhook operations.
107 #[async_trait::async_trait]
108 pub trait PaymentProvider: Send + Sync {
109 // Checkout
110 async fn create_checkout_session(
111 &self,
112 params: &CheckoutParams<'_>,
113 ) -> crate::error::Result<CheckoutResult>;
114 async fn create_guest_checkout_session(
115 &self,
116 params: &GuestCheckoutParams<'_>,
117 ) -> crate::error::Result<CheckoutResult>;
118 async fn create_subscription_checkout_session(
119 &self,
120 params: &SubscriptionCheckoutParams<'_>,
121 ) -> crate::error::Result<CheckoutResult>;
122 async fn create_tip_checkout_session(
123 &self,
124 params: &TipCheckoutParams<'_>,
125 ) -> crate::error::Result<CheckoutResult>;
126 async fn create_fan_plus_checkout_session(
127 &self,
128 price_id: &str,
129 user_id: crate::db::UserId,
130 success_url: &str,
131 cancel_url: &str,
132 ) -> crate::error::Result<CheckoutResult>;
133 async fn create_creator_tier_checkout_session(
134 &self,
135 price_id: &str,
136 user_id: crate::db::UserId,
137 tier: &str,
138 success_url: &str,
139 cancel_url: &str,
140 trial_days: Option<i32>,
141 ) -> crate::error::Result<CheckoutResult>;
142 async fn create_synckit_app_sub_checkout_session(
143 &self,
144 params: &SynckitAppSubCheckoutParams<'_>,
145 ) -> crate::error::Result<CheckoutResult>;
146 async fn create_cart_checkout_session(
147 &self,
148 params: &CartCheckoutParams<'_>,
149 ) -> crate::error::Result<CheckoutResult>;
150
151 // Connect
152 async fn create_connect_account(
153 &self,
154 email: &str,
155 ) -> crate::error::Result<crate::db::StripeAccountId>;
156 async fn create_account_link(
157 &self,
158 account_id: &str,
159 return_url: &str,
160 refresh_url: &str,
161 ) -> crate::error::Result<String>;
162 async fn fetch_account(&self, account_id: &str) -> crate::error::Result<AccountUpdate>;
163 async fn create_subscription_product_and_price(
164 &self,
165 connected_account_id: &str,
166 tier_name: &str,
167 tier_description: Option<&str>,
168 price_cents: i64,
169 currency: crate::currency::SettlementCurrency,
170 ) -> crate::error::Result<(String, String)>;
171 /// Balance in the account's own settlement currency. A connected account can
172 /// hold several currencies at once; summing across them would be adding
173 /// pounds to euros.
174 async fn get_balance(
175 &self,
176 account_id: &str,
177 currency: crate::currency::SettlementCurrency,
178 ) -> crate::error::Result<BalanceSummary>;
179
180 // Subscription lifecycle
181 async fn pause_subscription(
182 &self,
183 stripe_sub_id: &str,
184 connected_account_id: &str,
185 ) -> crate::error::Result<()>;
186 async fn resume_subscription(
187 &self,
188 stripe_sub_id: &str,
189 connected_account_id: &str,
190 ) -> crate::error::Result<()>;
191 async fn cancel_subscription(
192 &self,
193 stripe_sub_id: &str,
194 connected_account_id: &str,
195 ) -> crate::error::Result<()>;
196 /// Set or clear `cancel_at_period_end` on a fan subscription (for creator pause/resume).
197 async fn set_cancel_at_period_end(
198 &self,
199 stripe_sub_id: &str,
200 connected_account_id: &str,
201 cancel: bool,
202 ) -> crate::error::Result<()>;
203 /// Cancel a platform-level subscription (creator tier, Fan+). Not on a connected account.
204 async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> crate::error::Result<()>;
205 /// Set or clear `cancel_at_period_end` on a platform subscription (Fan+, creator tier).
206 async fn set_platform_cancel_at_period_end(
207 &self,
208 stripe_sub_id: &str,
209 cancel: bool,
210 ) -> crate::error::Result<()>;
211 /// Create a Stripe-hosted billing portal session. Returns the URL to redirect to.
212 async fn create_billing_portal_session(
213 &self,
214 stripe_customer_id: &str,
215 return_url: &str,
216 ) -> crate::error::Result<String>;
217
218 // Refunds, line-scoped: refunds `amount_cents` of the shared PaymentIntent
219 // and tags the refund with the transaction id so the refund.created webhook
220 // marks/revokes exactly that line (cart orders share one PaymentIntent).
221 async fn create_refund_for_transaction(
222 &self,
223 payment_intent_id: &str,
224 connected_account_id: &str,
225 amount_cents: i64,
226 transaction_id: crate::db::TransactionId,
227 ) -> crate::error::Result<()>;
228
229 // Platform-funded credit reimbursement, a platform -> connected transfer that
230 // makes the creator whole for a Fan+ credit applied to their sale (MNW funds it).
231 // Deterministic idempotency key keeps replays/retries from double-paying.
232 // Returns the transfer id so it can be reversed if the sale is refunded.
233 async fn create_platform_credit_transfer(
234 &self,
235 connected_account_id: &str,
236 amount_cents: i64,
237 transaction_id: crate::db::TransactionId,
238 currency: crate::currency::SettlementCurrency,
239 ) -> crate::error::Result<String>;
240
241 // Reverse a settled platform-credit transfer when its sale is refunded,
242 // clawing the reimbursement back from the connected account to MNW.
243 // Deterministic idempotency key keeps replays/retries from clawing back twice.
244 async fn create_platform_credit_reversal(
245 &self,
246 transfer_id: &str,
247 amount_cents: i64,
248 transaction_id: crate::db::TransactionId,
249 ) -> crate::error::Result<()>;
250
251 // Webhooks
252 fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<UntypedEvent>;
253 fn verify_webhook_v2(
254 &self,
255 payload: &str,
256 signature: &str,
257 ) -> crate::error::Result<serde_json::Value>;
258
259 // SyncKit v2 developer billing, one customer + subscription per app,
260 // separate from creator-tier and Fan+ subscriptions. See
261 // `synckit_billing.rs` for the rationale on per-app customers.
262 async fn create_synckit_customer(
263 &self,
264 developer_user_id: crate::db::UserId,
265 app_id: crate::db::SyncAppId,
266 email: &str,
267 app_name: &str,
268 ) -> crate::error::Result<String>;
269 async fn create_synckit_subscription(
270 &self,
271 customer_id: &str,
272 app_id: crate::db::SyncAppId,
273 app_name: &str,
274 price_cents: i64,
275 ) -> crate::error::Result<SynckitSubResult>;
276 async fn update_synckit_subscription_price(
277 &self,
278 subscription_id: &str,
279 new_price_cents: i64,
280 app_name: &str,
281 ) -> crate::error::Result<()>;
282 /// Re-price an end-user SyncKit app subscription. Used by the cap-change
283 /// path; takes effect at next billing cycle (no proration).
284 async fn update_synckit_app_sub_price(
285 &self,
286 subscription_id: &str,
287 new_price_cents: i64,
288 interval: SyncBillingInterval,
289 product_name: &str,
290 ) -> crate::error::Result<()>;
291 async fn cancel_synckit_subscription(&self, subscription_id: &str) -> crate::error::Result<()>;
292 async fn create_synckit_billing_portal(
293 &self,
294 customer_id: &str,
295 return_url: &str,
296 ) -> crate::error::Result<String>;
297 }
298
299 #[async_trait::async_trait]
300 impl PaymentProvider for StripeClient {
301 async fn create_checkout_session(
302 &self,
303 params: &CheckoutParams<'_>,
304 ) -> crate::error::Result<CheckoutResult> {
305 let session = StripeClient::create_checkout_session(self, params).await?;
306 Ok(CheckoutResult {
307 id: session.id.to_string(),
308 url: session.url,
309 })
310 }
311
312 async fn create_guest_checkout_session(
313 &self,
314 params: &GuestCheckoutParams<'_>,
315 ) -> crate::error::Result<CheckoutResult> {
316 let session = StripeClient::create_guest_checkout_session(self, params).await?;
317 Ok(CheckoutResult {
318 id: session.id.to_string(),
319 url: session.url,
320 })
321 }
322
323 async fn create_subscription_checkout_session(
324 &self,
325 params: &SubscriptionCheckoutParams<'_>,
326 ) -> crate::error::Result<CheckoutResult> {
327 let session = StripeClient::create_subscription_checkout_session(self, params).await?;
328 Ok(CheckoutResult {
329 id: session.id.to_string(),
330 url: session.url,
331 })
332 }
333
334 async fn create_tip_checkout_session(
335 &self,
336 params: &TipCheckoutParams<'_>,
337 ) -> crate::error::Result<CheckoutResult> {
338 let session = StripeClient::create_tip_checkout_session(self, params).await?;
339 Ok(CheckoutResult {
340 id: session.id.to_string(),
341 url: session.url,
342 })
343 }
344
345 async fn create_fan_plus_checkout_session(
346 &self,
347 price_id: &str,
348 user_id: crate::db::UserId,
349 success_url: &str,
350 cancel_url: &str,
351 ) -> crate::error::Result<CheckoutResult> {
352 let session = StripeClient::create_fan_plus_checkout_session(
353 self,
354 price_id,
355 user_id,
356 success_url,
357 cancel_url,
358 )
359 .await?;
360 Ok(CheckoutResult {
361 id: session.id.to_string(),
362 url: session.url,
363 })
364 }
365
366 async fn create_creator_tier_checkout_session(
367 &self,
368 price_id: &str,
369 user_id: crate::db::UserId,
370 tier: &str,
371 success_url: &str,
372 cancel_url: &str,
373 trial_days: Option<i32>,
374 ) -> crate::error::Result<CheckoutResult> {
375 let session = StripeClient::create_creator_tier_checkout_session(
376 self,
377 price_id,
378 user_id,
379 tier,
380 success_url,
381 cancel_url,
382 trial_days,
383 )
384 .await?;
385 Ok(CheckoutResult {
386 id: session.id.to_string(),
387 url: session.url,
388 })
389 }
390
391 async fn create_synckit_app_sub_checkout_session(
392 &self,
393 params: &SynckitAppSubCheckoutParams<'_>,
394 ) -> crate::error::Result<CheckoutResult> {
395 let session = StripeClient::create_synckit_app_sub_checkout_session(self, params).await?;
396 Ok(CheckoutResult {
397 id: session.id.to_string(),
398 url: session.url,
399 })
400 }
401
402 async fn create_cart_checkout_session(
403 &self,
404 params: &CartCheckoutParams<'_>,
405 ) -> crate::error::Result<CheckoutResult> {
406 let session = StripeClient::create_cart_checkout_session(self, params).await?;
407 Ok(CheckoutResult {
408 id: session.id.to_string(),
409 url: session.url,
410 })
411 }
412
413 async fn create_connect_account(
414 &self,
415 email: &str,
416 ) -> crate::error::Result<crate::db::StripeAccountId> {
417 StripeClient::create_connect_account(self, email).await
418 }
419
420 async fn create_account_link(
421 &self,
422 account_id: &str,
423 return_url: &str,
424 refresh_url: &str,
425 ) -> crate::error::Result<String> {
426 StripeClient::create_account_link(self, account_id, return_url, refresh_url).await
427 }
428
429 async fn fetch_account(&self, account_id: &str) -> crate::error::Result<AccountUpdate> {
430 StripeClient::fetch_account(self, account_id).await
431 }
432
433 async fn create_subscription_product_and_price(
434 &self,
435 connected_account_id: &str,
436 tier_name: &str,
437 tier_description: Option<&str>,
438 price_cents: i64,
439 currency: crate::currency::SettlementCurrency,
440 ) -> crate::error::Result<(String, String)> {
441 StripeClient::create_subscription_product_and_price(
442 self,
443 connected_account_id,
444 tier_name,
445 tier_description,
446 price_cents,
447 currency,
448 )
449 .await
450 }
451
452 async fn get_balance(
453 &self,
454 account_id: &str,
455 currency: crate::currency::SettlementCurrency,
456 ) -> crate::error::Result<BalanceSummary> {
457 let balance = self.get_connected_account_balance(account_id).await?;
458 let want = currency.to_stripe();
459 let available_cents: i64 = balance
460 .available
461 .iter()
462 .filter(|b| b.currency == want)
463 .map(|b| b.amount)
464 .sum();
465 let pending_cents: i64 = balance
466 .pending
467 .iter()
468 .filter(|b| b.currency == want)
469 .map(|b| b.amount)
470 .sum();
471 Ok(BalanceSummary {
472 available_cents,
473 pending_cents,
474 })
475 }
476
477 async fn pause_subscription(
478 &self,
479 stripe_sub_id: &str,
480 connected_account_id: &str,
481 ) -> crate::error::Result<()> {
482 StripeClient::pause_subscription(self, stripe_sub_id, connected_account_id).await
483 }
484
485 async fn resume_subscription(
486 &self,
487 stripe_sub_id: &str,
488 connected_account_id: &str,
489 ) -> crate::error::Result<()> {
490 StripeClient::resume_subscription(self, stripe_sub_id, connected_account_id).await
491 }
492
493 async fn cancel_subscription(
494 &self,
495 stripe_sub_id: &str,
496 connected_account_id: &str,
497 ) -> crate::error::Result<()> {
498 StripeClient::cancel_subscription(self, stripe_sub_id, connected_account_id).await
499 }
500
501 async fn set_cancel_at_period_end(
502 &self,
503 stripe_sub_id: &str,
504 connected_account_id: &str,
505 cancel: bool,
506 ) -> crate::error::Result<()> {
507 StripeClient::set_cancel_at_period_end(self, stripe_sub_id, connected_account_id, cancel)
508 .await
509 }
510
511 async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> crate::error::Result<()> {
512 StripeClient::cancel_platform_subscription(self, stripe_sub_id).await
513 }
514
515 async fn set_platform_cancel_at_period_end(
516 &self,
517 stripe_sub_id: &str,
518 cancel: bool,
519 ) -> crate::error::Result<()> {
520 StripeClient::set_platform_cancel_at_period_end(self, stripe_sub_id, cancel).await
521 }
522
523 async fn create_billing_portal_session(
524 &self,
525 stripe_customer_id: &str,
526 return_url: &str,
527 ) -> crate::error::Result<String> {
528 StripeClient::create_billing_portal_session(self, stripe_customer_id, return_url).await
529 }
530
531 async fn create_refund_for_transaction(
532 &self,
533 payment_intent_id: &str,
534 connected_account_id: &str,
535 amount_cents: i64,
536 transaction_id: crate::db::TransactionId,
537 ) -> crate::error::Result<()> {
538 StripeClient::create_refund_for_transaction(
539 self,
540 payment_intent_id,
541 connected_account_id,
542 amount_cents,
543 transaction_id,
544 )
545 .await
546 }
547
548 async fn create_platform_credit_transfer(
549 &self,
550 connected_account_id: &str,
551 amount_cents: i64,
552 transaction_id: crate::db::TransactionId,
553 currency: crate::currency::SettlementCurrency,
554 ) -> crate::error::Result<String> {
555 StripeClient::create_platform_credit_transfer(
556 self,
557 connected_account_id,
558 amount_cents,
559 transaction_id,
560 currency,
561 )
562 .await
563 }
564
565 async fn create_platform_credit_reversal(
566 &self,
567 transfer_id: &str,
568 amount_cents: i64,
569 transaction_id: crate::db::TransactionId,
570 ) -> crate::error::Result<()> {
571 StripeClient::create_platform_credit_reversal(
572 self,
573 transfer_id,
574 amount_cents,
575 transaction_id,
576 )
577 .await
578 }
579
580 fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<UntypedEvent> {
581 StripeClient::verify_webhook(self, payload, signature)
582 }
583
584 fn verify_webhook_v2(
585 &self,
586 payload: &str,
587 signature: &str,
588 ) -> crate::error::Result<serde_json::Value> {
589 StripeClient::verify_webhook_v2(self, payload, signature)
590 }
591
592 async fn create_synckit_customer(
593 &self,
594 developer_user_id: crate::db::UserId,
595 app_id: crate::db::SyncAppId,
596 email: &str,
597 app_name: &str,
598 ) -> crate::error::Result<String> {
599 StripeClient::create_synckit_customer(self, developer_user_id, app_id, email, app_name)
600 .await
601 }
602
603 async fn create_synckit_subscription(
604 &self,
605 customer_id: &str,
606 app_id: crate::db::SyncAppId,
607 app_name: &str,
608 price_cents: i64,
609 ) -> crate::error::Result<SynckitSubResult> {
610 StripeClient::create_synckit_subscription(self, customer_id, app_id, app_name, price_cents)
611 .await
612 }
613
614 async fn update_synckit_subscription_price(
615 &self,
616 subscription_id: &str,
617 new_price_cents: i64,
618 app_name: &str,
619 ) -> crate::error::Result<()> {
620 StripeClient::update_synckit_subscription_price(
621 self,
622 subscription_id,
623 new_price_cents,
624 app_name,
625 )
626 .await
627 }
628
629 async fn update_synckit_app_sub_price(
630 &self,
631 subscription_id: &str,
632 new_price_cents: i64,
633 interval: SyncBillingInterval,
634 product_name: &str,
635 ) -> crate::error::Result<()> {
636 StripeClient::update_synckit_app_sub_price(
637 self,
638 subscription_id,
639 new_price_cents,
640 interval,
641 product_name,
642 )
643 .await
644 }
645
646 async fn cancel_synckit_subscription(&self, subscription_id: &str) -> crate::error::Result<()> {
647 StripeClient::cancel_synckit_subscription(self, subscription_id).await
648 }
649
650 async fn create_synckit_billing_portal(
651 &self,
652 customer_id: &str,
653 return_url: &str,
654 ) -> crate::error::Result<String> {
655 StripeClient::create_synckit_billing_portal(self, customer_id, return_url).await
656 }
657 }
658
659 #[cfg(test)]
660 mod tests {
661 //! Stripe id parsing at the boundary between our database and Stripe's API.
662 //! These ids come out of our own rows, so a parse failure means our data is
663 //! wrong, and the classification matters: `Internal` pages us, `BadRequest`
664 //! would blame the creator for our own corrupted column.
665
666 use super::*;
667
668 #[test]
669 fn account_id_parsing_rejects_nothing_at_all() {
670 // Same dead guard as `parse_subscription_id`. `stripe_shared::AccountId`
671 // derives `FromStr` with `type Err = Infallible`, so every value parses
672 // and the `Invalid Stripe account ID` branch cannot be reached. The doc
673 // comment above reasons carefully about classifying the failure as
674 // `Internal` rather than `BadRequest`; there is no failure to classify.
675 //
676 // The consequence is not academic: an empty `users.stripe_account_id`
677 // becomes an empty connected-account header on a live charge instead of
678 // an error we can see.
679 assert!(StripeClient::parse_account_id("acct_1A2b3C4d5E6f7G8h").is_ok());
680 for anything in ["", "cus_123", "not an id", "acct_"] {
681 assert!(
682 StripeClient::parse_account_id(anything).is_ok(),
683 "{anything:?} parses today; if this now fails, the guard became real \
684 and the test should assert the new contract"
685 );
686 }
687 }
688 }
689