Skip to main content

max / makenotwork

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