Skip to main content

max / makenotwork

37.6 KB · 1102 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 /// Sum the entries whose currency is `want`, ignoring the rest.
107 ///
108 /// Split out of `get_balance` so the filter has a test. A connected account's
109 /// Stripe balance carries one entry per currency it holds, and the sum comes
110 /// back as a bare `i64` with no currency attached to contradict it, so summing
111 /// the wrong entries reports another currency's money as this one's and looks
112 /// like a plausible number rather than an error.
113 fn sum_in_currency<'a, C>(entries: impl IntoIterator<Item = (&'a C, i64)>, want: &C) -> i64
114 where
115 C: PartialEq + 'a,
116 {
117 entries
118 .into_iter()
119 .filter(|(currency, _)| *currency == want)
120 .map(|(_, amount)| amount)
121 .sum()
122 }
123
124 /// Payment provider abstraction for checkout, connect, and webhook operations.
125 #[async_trait::async_trait]
126 pub trait PaymentProvider: Send + Sync {
127 // Checkout
128 async fn create_checkout_session(
129 &self,
130 params: &CheckoutParams<'_>,
131 ) -> crate::error::Result<CheckoutResult>;
132 async fn create_guest_checkout_session(
133 &self,
134 params: &GuestCheckoutParams<'_>,
135 ) -> crate::error::Result<CheckoutResult>;
136 async fn create_subscription_checkout_session(
137 &self,
138 params: &SubscriptionCheckoutParams<'_>,
139 ) -> crate::error::Result<CheckoutResult>;
140 async fn create_tip_checkout_session(
141 &self,
142 params: &TipCheckoutParams<'_>,
143 ) -> crate::error::Result<CheckoutResult>;
144 async fn create_fan_plus_checkout_session(
145 &self,
146 price_id: &str,
147 user_id: crate::db::UserId,
148 success_url: &str,
149 cancel_url: &str,
150 ) -> crate::error::Result<CheckoutResult>;
151 async fn create_creator_tier_checkout_session(
152 &self,
153 price_id: &str,
154 user_id: crate::db::UserId,
155 tier: &str,
156 success_url: &str,
157 cancel_url: &str,
158 trial_days: Option<i32>,
159 ) -> crate::error::Result<CheckoutResult>;
160 async fn create_synckit_app_sub_checkout_session(
161 &self,
162 params: &SynckitAppSubCheckoutParams<'_>,
163 ) -> crate::error::Result<CheckoutResult>;
164 async fn create_cart_checkout_session(
165 &self,
166 params: &CartCheckoutParams<'_>,
167 ) -> crate::error::Result<CheckoutResult>;
168
169 // Connect
170 async fn create_connect_account(
171 &self,
172 email: &str,
173 ) -> crate::error::Result<crate::db::StripeAccountId>;
174 async fn create_account_link(
175 &self,
176 account_id: &str,
177 return_url: &str,
178 refresh_url: &str,
179 ) -> crate::error::Result<String>;
180 async fn fetch_account(&self, account_id: &str) -> crate::error::Result<AccountUpdate>;
181 async fn create_subscription_product_and_price(
182 &self,
183 connected_account_id: &str,
184 tier_name: &str,
185 tier_description: Option<&str>,
186 price_cents: i64,
187 currency: crate::currency::SettlementCurrency,
188 ) -> crate::error::Result<(String, String)>;
189 /// Balance in the account's own settlement currency. A connected account can
190 /// hold several currencies at once; summing across them would be adding
191 /// pounds to euros.
192 async fn get_balance(
193 &self,
194 account_id: &str,
195 currency: crate::currency::SettlementCurrency,
196 ) -> crate::error::Result<BalanceSummary>;
197
198 // Subscription lifecycle
199 async fn pause_subscription(
200 &self,
201 stripe_sub_id: &str,
202 connected_account_id: &str,
203 ) -> crate::error::Result<()>;
204 async fn resume_subscription(
205 &self,
206 stripe_sub_id: &str,
207 connected_account_id: &str,
208 ) -> crate::error::Result<()>;
209 async fn cancel_subscription(
210 &self,
211 stripe_sub_id: &str,
212 connected_account_id: &str,
213 ) -> crate::error::Result<()>;
214 /// Set or clear `cancel_at_period_end` on a fan subscription (for creator pause/resume).
215 async fn set_cancel_at_period_end(
216 &self,
217 stripe_sub_id: &str,
218 connected_account_id: &str,
219 cancel: bool,
220 ) -> crate::error::Result<()>;
221 /// Cancel a platform-level subscription (creator tier, Fan+). Not on a connected account.
222 async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> crate::error::Result<()>;
223 /// Set or clear `cancel_at_period_end` on a platform subscription (Fan+, creator tier).
224 async fn set_platform_cancel_at_period_end(
225 &self,
226 stripe_sub_id: &str,
227 cancel: bool,
228 ) -> crate::error::Result<()>;
229 /// Create a Stripe-hosted billing portal session. Returns the URL to redirect to.
230 async fn create_billing_portal_session(
231 &self,
232 stripe_customer_id: &str,
233 return_url: &str,
234 ) -> crate::error::Result<String>;
235
236 // Refunds, line-scoped: refunds `amount_cents` of the shared PaymentIntent
237 // and tags the refund with the transaction id so the refund.created webhook
238 // marks/revokes exactly that line (cart orders share one PaymentIntent).
239 async fn create_refund_for_transaction(
240 &self,
241 payment_intent_id: &str,
242 connected_account_id: &str,
243 amount_cents: i64,
244 transaction_id: crate::db::TransactionId,
245 ) -> crate::error::Result<()>;
246
247 // Platform-funded credit reimbursement, a platform -> connected transfer that
248 // makes the creator whole for a Fan+ credit applied to their sale (MNW funds it).
249 // Deterministic idempotency key keeps replays/retries from double-paying.
250 // Returns the transfer id so it can be reversed if the sale is refunded.
251 async fn create_platform_credit_transfer(
252 &self,
253 connected_account_id: &str,
254 amount_cents: i64,
255 transaction_id: crate::db::TransactionId,
256 currency: crate::currency::SettlementCurrency,
257 ) -> crate::error::Result<String>;
258
259 // Reverse a settled platform-credit transfer when its sale is refunded,
260 // clawing the reimbursement back from the connected account to MNW.
261 // Deterministic idempotency key keeps replays/retries from clawing back twice.
262 async fn create_platform_credit_reversal(
263 &self,
264 transfer_id: &str,
265 amount_cents: i64,
266 transaction_id: crate::db::TransactionId,
267 ) -> crate::error::Result<()>;
268
269 // Webhooks
270 fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<UntypedEvent>;
271 fn verify_webhook_v2(
272 &self,
273 payload: &str,
274 signature: &str,
275 ) -> crate::error::Result<serde_json::Value>;
276
277 // SyncKit v2 developer billing, one customer + subscription per app,
278 // separate from creator-tier and Fan+ subscriptions. See
279 // `synckit_billing.rs` for the rationale on per-app customers.
280 async fn create_synckit_customer(
281 &self,
282 developer_user_id: crate::db::UserId,
283 app_id: crate::db::SyncAppId,
284 email: &str,
285 app_name: &str,
286 ) -> crate::error::Result<String>;
287 async fn create_synckit_subscription(
288 &self,
289 customer_id: &str,
290 app_id: crate::db::SyncAppId,
291 app_name: &str,
292 price_cents: i64,
293 ) -> crate::error::Result<SynckitSubResult>;
294 async fn update_synckit_subscription_price(
295 &self,
296 subscription_id: &str,
297 new_price_cents: i64,
298 app_name: &str,
299 ) -> crate::error::Result<()>;
300 /// Re-price an end-user SyncKit app subscription. Used by the cap-change
301 /// path; takes effect at next billing cycle (no proration).
302 async fn update_synckit_app_sub_price(
303 &self,
304 subscription_id: &str,
305 new_price_cents: i64,
306 interval: SyncBillingInterval,
307 product_name: &str,
308 ) -> crate::error::Result<()>;
309 async fn cancel_synckit_subscription(&self, subscription_id: &str) -> crate::error::Result<()>;
310 async fn create_synckit_billing_portal(
311 &self,
312 customer_id: &str,
313 return_url: &str,
314 ) -> crate::error::Result<String>;
315 }
316
317 #[cfg(test)]
318 pub(crate) mod test_provider {
319 //! A crate-visible [`PaymentProvider`] double for lib tests.
320 //!
321 //! The integration suite already has `MockPaymentProvider`
322 //! (`tests/harness/stripe.rs`), which is richer: it captures checkout
323 //! sessions and signs webhooks. It lives in a separate test binary, so a
324 //! `--lib` test cannot reach it, and this is deliberately the smaller
325 //! thing. It answers the subscription-lifecycle calls and panics on
326 //! everything else, which is enough to test the code that fans those out
327 //! without a database, a router or a Stripe key.
328 //!
329 //! Implement a method here when a lib test needs it. Growing this toward
330 //! the harness's copy would give the crate two mocks to keep in agreement,
331 //! which is the imitation-oracle failure wiki `testing-posture` describes.
332
333 use std::collections::HashSet;
334 use std::sync::Mutex;
335
336 use super::*;
337
338 /// Records every subscription op it is asked for, and fails the ones whose
339 /// subscription id was listed as failing.
340 #[derive(Default)]
341 pub(crate) struct ScriptedProvider {
342 failing: HashSet<String>,
343 calls: Mutex<Vec<(&'static str, String)>>,
344 }
345
346 impl ScriptedProvider {
347 /// Every call succeeds.
348 pub(crate) fn healthy() -> Self {
349 Self::default()
350 }
351
352 /// Every call succeeds except those naming one of `sub_ids`.
353 pub(crate) fn failing(sub_ids: impl IntoIterator<Item = &'static str>) -> Self {
354 Self {
355 failing: sub_ids.into_iter().map(str::to_owned).collect(),
356 calls: Mutex::new(Vec::new()),
357 }
358 }
359
360 /// `(op, subscription id)` in the order they were applied.
361 pub(crate) fn calls(&self) -> Vec<(&'static str, String)> {
362 self.calls
363 .lock()
364 .expect("no test panics while holding this")
365 .clone()
366 }
367
368 fn record(&self, op: &'static str, sub_id: &str) -> crate::error::Result<()> {
369 self.calls
370 .lock()
371 .expect("no test panics while holding this")
372 .push((op, sub_id.to_string()));
373 if self.failing.contains(sub_id) {
374 return Err(crate::error::AppError::BadRequest(format!(
375 "scripted failure for {sub_id}"
376 )));
377 }
378 Ok(())
379 }
380 }
381
382 /// The methods no lib test drives yet. A call is a bug in the test, not a
383 /// condition to handle, so it panics rather than returning an error the
384 /// code under test would quietly count as a Stripe failure.
385 macro_rules! unused {
386 ($($name:ident),+ $(,)?) => {
387 $(
388 #[allow(unused_variables)]
389 fn $name(&self) -> ! {
390 unimplemented!(
391 "ScriptedProvider::{} is not implemented; add it if a lib test needs it",
392 stringify!($name)
393 )
394 }
395 )+
396 };
397 }
398
399 impl ScriptedProvider {
400 unused!(
401 create_checkout_session,
402 create_guest_checkout_session,
403 create_subscription_checkout_session,
404 create_tip_checkout_session,
405 create_fan_plus_checkout_session,
406 create_creator_tier_checkout_session,
407 create_synckit_app_sub_checkout_session,
408 create_cart_checkout_session,
409 create_connect_account,
410 create_account_link,
411 fetch_account,
412 create_subscription_product_and_price,
413 get_balance,
414 cancel_platform_subscription,
415 set_platform_cancel_at_period_end,
416 create_billing_portal_session,
417 create_refund_for_transaction,
418 create_platform_credit_transfer,
419 create_platform_credit_reversal,
420 verify_webhook,
421 verify_webhook_v2,
422 create_synckit_customer,
423 create_synckit_subscription,
424 update_synckit_subscription_price,
425 update_synckit_app_sub_price,
426 cancel_synckit_subscription,
427 create_synckit_billing_portal,
428 );
429 }
430
431 #[async_trait::async_trait]
432 impl PaymentProvider for ScriptedProvider {
433 // ── what the fan-out drives ──
434
435 async fn pause_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
436 self.record("pause", sub)
437 }
438
439 async fn resume_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
440 self.record("resume", sub)
441 }
442
443 async fn cancel_subscription(&self, sub: &str, _account: &str) -> crate::error::Result<()> {
444 self.record("cancel", sub)
445 }
446
447 async fn set_cancel_at_period_end(
448 &self,
449 sub: &str,
450 _account: &str,
451 cancel: bool,
452 ) -> crate::error::Result<()> {
453 self.record(
454 if cancel {
455 "set_cancel_at_period_end"
456 } else {
457 "clear_cancel_at_period_end"
458 },
459 sub,
460 )
461 }
462
463 // ── everything else ──
464
465 async fn create_checkout_session(
466 &self,
467 _params: &CheckoutParams<'_>,
468 ) -> crate::error::Result<CheckoutResult> {
469 ScriptedProvider::create_checkout_session(self)
470 }
471 async fn create_guest_checkout_session(
472 &self,
473 _params: &GuestCheckoutParams<'_>,
474 ) -> crate::error::Result<CheckoutResult> {
475 ScriptedProvider::create_guest_checkout_session(self)
476 }
477 async fn create_subscription_checkout_session(
478 &self,
479 _params: &SubscriptionCheckoutParams<'_>,
480 ) -> crate::error::Result<CheckoutResult> {
481 ScriptedProvider::create_subscription_checkout_session(self)
482 }
483 async fn create_tip_checkout_session(
484 &self,
485 _params: &TipCheckoutParams<'_>,
486 ) -> crate::error::Result<CheckoutResult> {
487 ScriptedProvider::create_tip_checkout_session(self)
488 }
489 async fn create_fan_plus_checkout_session(
490 &self,
491 _price_id: &str,
492 _user_id: crate::db::UserId,
493 _success_url: &str,
494 _cancel_url: &str,
495 ) -> crate::error::Result<CheckoutResult> {
496 ScriptedProvider::create_fan_plus_checkout_session(self)
497 }
498 async fn create_creator_tier_checkout_session(
499 &self,
500 _price_id: &str,
501 _user_id: crate::db::UserId,
502 _tier: &str,
503 _success_url: &str,
504 _cancel_url: &str,
505 _trial_days: Option<i32>,
506 ) -> crate::error::Result<CheckoutResult> {
507 ScriptedProvider::create_creator_tier_checkout_session(self)
508 }
509 async fn create_synckit_app_sub_checkout_session(
510 &self,
511 _params: &SynckitAppSubCheckoutParams<'_>,
512 ) -> crate::error::Result<CheckoutResult> {
513 ScriptedProvider::create_synckit_app_sub_checkout_session(self)
514 }
515 async fn create_cart_checkout_session(
516 &self,
517 _params: &CartCheckoutParams<'_>,
518 ) -> crate::error::Result<CheckoutResult> {
519 ScriptedProvider::create_cart_checkout_session(self)
520 }
521 async fn create_connect_account(
522 &self,
523 _email: &str,
524 ) -> crate::error::Result<crate::db::StripeAccountId> {
525 ScriptedProvider::create_connect_account(self)
526 }
527 async fn create_account_link(
528 &self,
529 _account_id: &str,
530 _return_url: &str,
531 _refresh_url: &str,
532 ) -> crate::error::Result<String> {
533 ScriptedProvider::create_account_link(self)
534 }
535 async fn fetch_account(&self, _account_id: &str) -> crate::error::Result<AccountUpdate> {
536 ScriptedProvider::fetch_account(self)
537 }
538 async fn create_subscription_product_and_price(
539 &self,
540 _connected_account_id: &str,
541 _tier_name: &str,
542 _tier_description: Option<&str>,
543 _price_cents: i64,
544 _currency: crate::currency::SettlementCurrency,
545 ) -> crate::error::Result<(String, String)> {
546 ScriptedProvider::create_subscription_product_and_price(self)
547 }
548 async fn get_balance(
549 &self,
550 _account_id: &str,
551 _currency: crate::currency::SettlementCurrency,
552 ) -> crate::error::Result<BalanceSummary> {
553 ScriptedProvider::get_balance(self)
554 }
555 async fn cancel_platform_subscription(&self, _sub: &str) -> crate::error::Result<()> {
556 ScriptedProvider::cancel_platform_subscription(self)
557 }
558 async fn set_platform_cancel_at_period_end(
559 &self,
560 _sub: &str,
561 _cancel: bool,
562 ) -> crate::error::Result<()> {
563 ScriptedProvider::set_platform_cancel_at_period_end(self)
564 }
565 async fn create_billing_portal_session(
566 &self,
567 _customer_id: &str,
568 _return_url: &str,
569 ) -> crate::error::Result<String> {
570 ScriptedProvider::create_billing_portal_session(self)
571 }
572 async fn create_refund_for_transaction(
573 &self,
574 _payment_intent_id: &str,
575 _connected_account_id: &str,
576 _amount_cents: i64,
577 _transaction_id: crate::db::TransactionId,
578 ) -> crate::error::Result<()> {
579 ScriptedProvider::create_refund_for_transaction(self)
580 }
581 async fn create_platform_credit_transfer(
582 &self,
583 _connected_account_id: &str,
584 _amount_cents: i64,
585 _transaction_id: crate::db::TransactionId,
586 _currency: crate::currency::SettlementCurrency,
587 ) -> crate::error::Result<String> {
588 ScriptedProvider::create_platform_credit_transfer(self)
589 }
590 async fn create_platform_credit_reversal(
591 &self,
592 _transfer_id: &str,
593 _amount_cents: i64,
594 _transaction_id: crate::db::TransactionId,
595 ) -> crate::error::Result<()> {
596 ScriptedProvider::create_platform_credit_reversal(self)
597 }
598 fn verify_webhook(
599 &self,
600 _payload: &str,
601 _signature: &str,
602 ) -> crate::error::Result<UntypedEvent> {
603 ScriptedProvider::verify_webhook(self)
604 }
605 fn verify_webhook_v2(
606 &self,
607 _payload: &str,
608 _signature: &str,
609 ) -> crate::error::Result<serde_json::Value> {
610 ScriptedProvider::verify_webhook_v2(self)
611 }
612 async fn create_synckit_customer(
613 &self,
614 _developer_user_id: crate::db::UserId,
615 _app_id: crate::db::SyncAppId,
616 _email: &str,
617 _app_name: &str,
618 ) -> crate::error::Result<String> {
619 ScriptedProvider::create_synckit_customer(self)
620 }
621 async fn create_synckit_subscription(
622 &self,
623 _customer_id: &str,
624 _app_id: crate::db::SyncAppId,
625 _app_name: &str,
626 _price_cents: i64,
627 ) -> crate::error::Result<SynckitSubResult> {
628 ScriptedProvider::create_synckit_subscription(self)
629 }
630 async fn update_synckit_subscription_price(
631 &self,
632 _subscription_id: &str,
633 _new_price_cents: i64,
634 _app_name: &str,
635 ) -> crate::error::Result<()> {
636 ScriptedProvider::update_synckit_subscription_price(self)
637 }
638 async fn update_synckit_app_sub_price(
639 &self,
640 _subscription_id: &str,
641 _new_price_cents: i64,
642 _interval: SyncBillingInterval,
643 _product_name: &str,
644 ) -> crate::error::Result<()> {
645 ScriptedProvider::update_synckit_app_sub_price(self)
646 }
647 async fn cancel_synckit_subscription(&self, _sub: &str) -> crate::error::Result<()> {
648 ScriptedProvider::cancel_synckit_subscription(self)
649 }
650 async fn create_synckit_billing_portal(
651 &self,
652 _customer_id: &str,
653 _return_url: &str,
654 ) -> crate::error::Result<String> {
655 ScriptedProvider::create_synckit_billing_portal(self)
656 }
657 }
658 }
659
660 #[async_trait::async_trait]
661 impl PaymentProvider for StripeClient {
662 async fn create_checkout_session(
663 &self,
664 params: &CheckoutParams<'_>,
665 ) -> crate::error::Result<CheckoutResult> {
666 let session = StripeClient::create_checkout_session(self, params).await?;
667 Ok(CheckoutResult {
668 id: session.id.to_string(),
669 url: session.url,
670 })
671 }
672
673 async fn create_guest_checkout_session(
674 &self,
675 params: &GuestCheckoutParams<'_>,
676 ) -> crate::error::Result<CheckoutResult> {
677 let session = StripeClient::create_guest_checkout_session(self, params).await?;
678 Ok(CheckoutResult {
679 id: session.id.to_string(),
680 url: session.url,
681 })
682 }
683
684 async fn create_subscription_checkout_session(
685 &self,
686 params: &SubscriptionCheckoutParams<'_>,
687 ) -> crate::error::Result<CheckoutResult> {
688 let session = StripeClient::create_subscription_checkout_session(self, params).await?;
689 Ok(CheckoutResult {
690 id: session.id.to_string(),
691 url: session.url,
692 })
693 }
694
695 async fn create_tip_checkout_session(
696 &self,
697 params: &TipCheckoutParams<'_>,
698 ) -> crate::error::Result<CheckoutResult> {
699 let session = StripeClient::create_tip_checkout_session(self, params).await?;
700 Ok(CheckoutResult {
701 id: session.id.to_string(),
702 url: session.url,
703 })
704 }
705
706 async fn create_fan_plus_checkout_session(
707 &self,
708 price_id: &str,
709 user_id: crate::db::UserId,
710 success_url: &str,
711 cancel_url: &str,
712 ) -> crate::error::Result<CheckoutResult> {
713 let session = StripeClient::create_fan_plus_checkout_session(
714 self,
715 price_id,
716 user_id,
717 success_url,
718 cancel_url,
719 )
720 .await?;
721 Ok(CheckoutResult {
722 id: session.id.to_string(),
723 url: session.url,
724 })
725 }
726
727 async fn create_creator_tier_checkout_session(
728 &self,
729 price_id: &str,
730 user_id: crate::db::UserId,
731 tier: &str,
732 success_url: &str,
733 cancel_url: &str,
734 trial_days: Option<i32>,
735 ) -> crate::error::Result<CheckoutResult> {
736 let session = StripeClient::create_creator_tier_checkout_session(
737 self,
738 price_id,
739 user_id,
740 tier,
741 success_url,
742 cancel_url,
743 trial_days,
744 )
745 .await?;
746 Ok(CheckoutResult {
747 id: session.id.to_string(),
748 url: session.url,
749 })
750 }
751
752 async fn create_synckit_app_sub_checkout_session(
753 &self,
754 params: &SynckitAppSubCheckoutParams<'_>,
755 ) -> crate::error::Result<CheckoutResult> {
756 let session = StripeClient::create_synckit_app_sub_checkout_session(self, params).await?;
757 Ok(CheckoutResult {
758 id: session.id.to_string(),
759 url: session.url,
760 })
761 }
762
763 async fn create_cart_checkout_session(
764 &self,
765 params: &CartCheckoutParams<'_>,
766 ) -> crate::error::Result<CheckoutResult> {
767 let session = StripeClient::create_cart_checkout_session(self, params).await?;
768 Ok(CheckoutResult {
769 id: session.id.to_string(),
770 url: session.url,
771 })
772 }
773
774 async fn create_connect_account(
775 &self,
776 email: &str,
777 ) -> crate::error::Result<crate::db::StripeAccountId> {
778 StripeClient::create_connect_account(self, email).await
779 }
780
781 async fn create_account_link(
782 &self,
783 account_id: &str,
784 return_url: &str,
785 refresh_url: &str,
786 ) -> crate::error::Result<String> {
787 StripeClient::create_account_link(self, account_id, return_url, refresh_url).await
788 }
789
790 async fn fetch_account(&self, account_id: &str) -> crate::error::Result<AccountUpdate> {
791 StripeClient::fetch_account(self, account_id).await
792 }
793
794 async fn create_subscription_product_and_price(
795 &self,
796 connected_account_id: &str,
797 tier_name: &str,
798 tier_description: Option<&str>,
799 price_cents: i64,
800 currency: crate::currency::SettlementCurrency,
801 ) -> crate::error::Result<(String, String)> {
802 StripeClient::create_subscription_product_and_price(
803 self,
804 connected_account_id,
805 tier_name,
806 tier_description,
807 price_cents,
808 currency,
809 )
810 .await
811 }
812
813 async fn get_balance(
814 &self,
815 account_id: &str,
816 currency: crate::currency::SettlementCurrency,
817 ) -> crate::error::Result<BalanceSummary> {
818 let balance = self.get_connected_account_balance(account_id).await?;
819 let want = currency.to_stripe();
820 let available_cents = sum_in_currency(
821 balance.available.iter().map(|b| (&b.currency, b.amount)),
822 &want,
823 );
824 let pending_cents = sum_in_currency(
825 balance.pending.iter().map(|b| (&b.currency, b.amount)),
826 &want,
827 );
828 Ok(BalanceSummary {
829 available_cents,
830 pending_cents,
831 })
832 }
833
834 async fn pause_subscription(
835 &self,
836 stripe_sub_id: &str,
837 connected_account_id: &str,
838 ) -> crate::error::Result<()> {
839 StripeClient::pause_subscription(self, stripe_sub_id, connected_account_id).await
840 }
841
842 async fn resume_subscription(
843 &self,
844 stripe_sub_id: &str,
845 connected_account_id: &str,
846 ) -> crate::error::Result<()> {
847 StripeClient::resume_subscription(self, stripe_sub_id, connected_account_id).await
848 }
849
850 async fn cancel_subscription(
851 &self,
852 stripe_sub_id: &str,
853 connected_account_id: &str,
854 ) -> crate::error::Result<()> {
855 StripeClient::cancel_subscription(self, stripe_sub_id, connected_account_id).await
856 }
857
858 async fn set_cancel_at_period_end(
859 &self,
860 stripe_sub_id: &str,
861 connected_account_id: &str,
862 cancel: bool,
863 ) -> crate::error::Result<()> {
864 StripeClient::set_cancel_at_period_end(self, stripe_sub_id, connected_account_id, cancel)
865 .await
866 }
867
868 async fn cancel_platform_subscription(&self, stripe_sub_id: &str) -> crate::error::Result<()> {
869 StripeClient::cancel_platform_subscription(self, stripe_sub_id).await
870 }
871
872 async fn set_platform_cancel_at_period_end(
873 &self,
874 stripe_sub_id: &str,
875 cancel: bool,
876 ) -> crate::error::Result<()> {
877 StripeClient::set_platform_cancel_at_period_end(self, stripe_sub_id, cancel).await
878 }
879
880 async fn create_billing_portal_session(
881 &self,
882 stripe_customer_id: &str,
883 return_url: &str,
884 ) -> crate::error::Result<String> {
885 StripeClient::create_billing_portal_session(self, stripe_customer_id, return_url).await
886 }
887
888 async fn create_refund_for_transaction(
889 &self,
890 payment_intent_id: &str,
891 connected_account_id: &str,
892 amount_cents: i64,
893 transaction_id: crate::db::TransactionId,
894 ) -> crate::error::Result<()> {
895 StripeClient::create_refund_for_transaction(
896 self,
897 payment_intent_id,
898 connected_account_id,
899 amount_cents,
900 transaction_id,
901 )
902 .await
903 }
904
905 async fn create_platform_credit_transfer(
906 &self,
907 connected_account_id: &str,
908 amount_cents: i64,
909 transaction_id: crate::db::TransactionId,
910 currency: crate::currency::SettlementCurrency,
911 ) -> crate::error::Result<String> {
912 StripeClient::create_platform_credit_transfer(
913 self,
914 connected_account_id,
915 amount_cents,
916 transaction_id,
917 currency,
918 )
919 .await
920 }
921
922 async fn create_platform_credit_reversal(
923 &self,
924 transfer_id: &str,
925 amount_cents: i64,
926 transaction_id: crate::db::TransactionId,
927 ) -> crate::error::Result<()> {
928 StripeClient::create_platform_credit_reversal(
929 self,
930 transfer_id,
931 amount_cents,
932 transaction_id,
933 )
934 .await
935 }
936
937 fn verify_webhook(&self, payload: &str, signature: &str) -> crate::error::Result<UntypedEvent> {
938 StripeClient::verify_webhook(self, payload, signature)
939 }
940
941 fn verify_webhook_v2(
942 &self,
943 payload: &str,
944 signature: &str,
945 ) -> crate::error::Result<serde_json::Value> {
946 StripeClient::verify_webhook_v2(self, payload, signature)
947 }
948
949 async fn create_synckit_customer(
950 &self,
951 developer_user_id: crate::db::UserId,
952 app_id: crate::db::SyncAppId,
953 email: &str,
954 app_name: &str,
955 ) -> crate::error::Result<String> {
956 StripeClient::create_synckit_customer(self, developer_user_id, app_id, email, app_name)
957 .await
958 }
959
960 async fn create_synckit_subscription(
961 &self,
962 customer_id: &str,
963 app_id: crate::db::SyncAppId,
964 app_name: &str,
965 price_cents: i64,
966 ) -> crate::error::Result<SynckitSubResult> {
967 StripeClient::create_synckit_subscription(self, customer_id, app_id, app_name, price_cents)
968 .await
969 }
970
971 async fn update_synckit_subscription_price(
972 &self,
973 subscription_id: &str,
974 new_price_cents: i64,
975 app_name: &str,
976 ) -> crate::error::Result<()> {
977 StripeClient::update_synckit_subscription_price(
978 self,
979 subscription_id,
980 new_price_cents,
981 app_name,
982 )
983 .await
984 }
985
986 async fn update_synckit_app_sub_price(
987 &self,
988 subscription_id: &str,
989 new_price_cents: i64,
990 interval: SyncBillingInterval,
991 product_name: &str,
992 ) -> crate::error::Result<()> {
993 StripeClient::update_synckit_app_sub_price(
994 self,
995 subscription_id,
996 new_price_cents,
997 interval,
998 product_name,
999 )
1000 .await
1001 }
1002
1003 async fn cancel_synckit_subscription(&self, subscription_id: &str) -> crate::error::Result<()> {
1004 StripeClient::cancel_synckit_subscription(self, subscription_id).await
1005 }
1006
1007 async fn create_synckit_billing_portal(
1008 &self,
1009 customer_id: &str,
1010 return_url: &str,
1011 ) -> crate::error::Result<String> {
1012 StripeClient::create_synckit_billing_portal(self, customer_id, return_url).await
1013 }
1014 }
1015
1016 #[cfg(test)]
1017 mod tests {
1018 //! Stripe id parsing at the boundary between our database and Stripe's API.
1019 //! These ids come out of our own rows, so a parse failure means our data is
1020 //! wrong, and the classification matters: `Internal` pages us, `BadRequest`
1021 //! would blame the creator for our own corrupted column.
1022
1023 use super::*;
1024
1025 #[test]
1026 fn account_id_parsing_rejects_nothing_at_all() {
1027 // Same dead guard as `parse_subscription_id`. `stripe_shared::AccountId`
1028 // derives `FromStr` with `type Err = Infallible`, so every value parses
1029 // and the `Invalid Stripe account ID` branch cannot be reached. The doc
1030 // comment above reasons carefully about classifying the failure as
1031 // `Internal` rather than `BadRequest`; there is no failure to classify.
1032 //
1033 // The consequence is not academic: an empty `users.stripe_account_id`
1034 // becomes an empty connected-account header on a live charge instead of
1035 // an error we can see.
1036 assert!(StripeClient::parse_account_id("acct_1A2b3C4d5E6f7G8h").is_ok());
1037 for anything in ["", "cus_123", "not an id", "acct_"] {
1038 assert!(
1039 StripeClient::parse_account_id(anything).is_ok(),
1040 "{anything:?} parses today; if this now fails, the guard became real \
1041 and the test should assert the new contract"
1042 );
1043 }
1044 }
1045
1046 // ── sum_in_currency, the filter behind `get_balance` ──
1047
1048 use crate::currency::SettlementCurrency;
1049
1050 /// The entries a connected account holding three currencies would carry.
1051 fn mixed() -> Vec<(stripe_types::Currency, i64)> {
1052 vec![
1053 (SettlementCurrency::Usd.to_stripe(), 1_000),
1054 (SettlementCurrency::Gbp.to_stripe(), 2_500),
1055 (SettlementCurrency::Usd.to_stripe(), 250),
1056 (SettlementCurrency::Eur.to_stripe(), 9_999),
1057 ]
1058 }
1059
1060 #[test]
1061 fn sums_every_entry_in_the_wanted_currency() {
1062 let entries = mixed();
1063 let total = sum_in_currency(
1064 entries.iter().map(|(c, a)| (c, *a)),
1065 &SettlementCurrency::Usd.to_stripe(),
1066 );
1067 assert_eq!(total, 1_250, "both USD entries, and only those");
1068 }
1069
1070 #[test]
1071 fn ignores_every_entry_in_another_currency() {
1072 let entries = mixed();
1073 for currency in SettlementCurrency::ALL {
1074 let total =
1075 sum_in_currency(entries.iter().map(|(c, a)| (c, *a)), &currency.to_stripe());
1076 let expected = match currency {
1077 SettlementCurrency::Usd => 1_250,
1078 SettlementCurrency::Gbp => 2_500,
1079 SettlementCurrency::Eur => 9_999,
1080 _ => 0,
1081 };
1082 assert_eq!(
1083 total, expected,
1084 "{currency} must see its own money and nobody else's"
1085 );
1086 }
1087 }
1088
1089 #[test]
1090 fn a_currency_the_account_does_not_hold_is_zero_rather_than_everything() {
1091 let entries = mixed();
1092 let total = sum_in_currency(
1093 entries.iter().map(|(c, a)| (c, *a)),
1094 &SettlementCurrency::Nzd.to_stripe(),
1095 );
1096 assert_eq!(
1097 total, 0,
1098 "an inverted filter would report 13,749 NZD cents the account never held"
1099 );
1100 }
1101 }
1102